Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,14 @@
"scripts/postinstall.cjs",
"dist/cli.d.ts",
"dist/cli.js",
"dist/core-cli.d.ts",
"dist/core-cli.js",
"dist/core.d.ts",
"dist/core.js",
"dist/external-command.d.ts",
"dist/external-command.js",
"dist/launcher.d.ts",
"dist/launcher.js",
"README.md",
"LICENSE"
],
Expand All @@ -67,15 +73,15 @@
"check:public-boundary": "node scripts/check-public-boundary.mjs",
"build": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.eval.json --noEmit",
"build:eval": "tsc -p tsconfig.eval.json --noEmit",
"build:lib": "tsc -p tsconfig.build.json",
"build:cli": "bun build src/cli.ts --compile --outfile dist/agent-memory --define __VERSION__=\"'$(node -p \"require('./package.json').version\")'\"",
"build:lib": "tsc -p tsconfig.build.json && node scripts/assemble-portable-cli.cjs",
"build:cli": "bun build src/launcher.ts --compile --outfile dist/agent-memory --define __VERSION__=\"'$(node -p \"require('./package.json').version\")'\"",
"eval:feedback": "bun eval/run.ts",
"prepare": "npm run build:lib",
"prepack": "npm run check:public-boundary && npm run build:lib",
"lint": "biome check .",
"test": "bun test test/unit.test.ts",
"test:unit": "bun test test/unit.test.ts",
"test:cli": "bun test test/cli.test.ts --timeout 15000",
"test:cli": "bun test test/cli.test.ts test/external-command.test.ts --timeout 15000",
"test:eval": "bun test test/eval.test.ts"
},
"devDependencies": {
Expand Down
18 changes: 18 additions & 0 deletions scripts/assemble-portable-cli.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const fs = require("node:fs");
const path = require("node:path");

const root = path.resolve(__dirname, "..");
const dist = path.join(root, "dist");
const cli = path.join(dist, "cli.js");
const cliTypes = path.join(dist, "cli.d.ts");
const coreCli = path.join(dist, "core-cli.js");
const coreCliTypes = path.join(dist, "core-cli.d.ts");
const launcher = path.join(dist, "launcher.js");

for (const target of [cli, launcher]) {
if (!fs.existsSync(target)) throw new Error(`Missing build output: ${path.relative(root, target)}`);
}

fs.copyFileSync(cli, coreCli);
if (fs.existsSync(cliTypes)) fs.copyFileSync(cliTypes, coreCliTypes);
fs.copyFileSync(launcher, cli);
12 changes: 12 additions & 0 deletions scripts/check-public-boundary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,21 @@ const rules = [
"SmF5IFplbmc=",
].map(decode);

// Keep the MIT implementation surface intentionally small. Adding another source
// module requires an explicit boundary review instead of merely choosing a neutral
// filename that happens not to match the lexical rules below.
const allowedSourceFiles = new Set([
"src/core.ts",
"src/cli.ts",
"src/external-command.ts",
"src/launcher.ts",
]);

const tracked = execFileSync("git", ["ls-files", "-z"], { encoding: "utf8" }).split("\0").filter(Boolean);
const violations = [];
for (const file of tracked) {
if (file.startsWith("src/") && file.endsWith(".ts") && !allowedSourceFiles.has(file)) violations.push(file);

let data;
try {
data = fs.readFileSync(file);
Expand Down
58 changes: 58 additions & 0 deletions src/external-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

const CORE_COMMANDS = new Set([
"help",
"version",
"install-skills",
"uninstall-skills",
"context",
"write",
"read",
"scratchpad",
"search",
"distil",
"distill",
"sync",
"init",
"status",
]);

const COMMAND_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;

export function shouldTryExternalCommand(command: string | undefined): boolean {
return Boolean(command && COMMAND_NAME.test(command) && !CORE_COMMANDS.has(command));
}

export function resolveExternalCommandHost(options?: {
homeDir?: string;
platform?: NodeJS.Platform;
isFile?: (target: string) => boolean;
}): string | null {
const homeDir = options?.homeDir ?? os.homedir();
const platform = options?.platform ?? process.platform;
const isFile =
options?.isFile ??
((target: string) => {
try {
return fs.statSync(target).isFile();
} catch {
return false;
}
});
const filename = platform === "win32" ? "agent-memory-extension.exe" : "agent-memory-extension";
const target = path.join(homeDir, ".agent-memory", "bin", filename);
return isFile(target) ? target : null;
}

export function runExternalCommand(host: string, argv: string[]): number {
const result = spawnSync(host, argv, {
stdio: "inherit",
env: process.env,
windowsHide: true,
});
if (result.error) throw result.error;
return result.status ?? 1;
}
41 changes: 41 additions & 0 deletions src/launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env node

import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveExternalCommandHost, runExternalCommand, shouldTryExternalCommand } from "./external-command.js";

async function runCoreCli(): Promise<void> {
// The npm package keeps dist/cli.js as the public bin for compatibility. During
// packaging the launcher is copied to that path and the original CLI moves to
// dist/core-cli.js. Keep the portable-only import dynamic so TypeScript does not
// require that generated file at source-check time. The standalone Bun build
// still sees the static ./cli.js import and bundles Core normally.
const runningAsPortableBin = path.basename(fileURLToPath(import.meta.url)) === "cli.js";
if (runningAsPortableBin) {
const portableCoreCli = `./core-${"cli"}.js`;
await import(portableCoreCli);
return;
}
await import("./cli.js");
}

async function main(): Promise<void> {
const command = process.argv[2];
if (!shouldTryExternalCommand(command)) {
await runCoreCli();
return;
}

const host = resolveExternalCommandHost();
if (!host) {
await runCoreCli();
return;
}

process.exitCode = runExternalCommand(host, process.argv.slice(2));
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
62 changes: 62 additions & 0 deletions test/external-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import * as path from "node:path";

import { resolveExternalCommandHost, shouldTryExternalCommand } from "../src/external-command.js";

describe("external command handoff", () => {
test("keeps core commands in the public CLI", () => {
for (const command of [
"help",
"version",
"install-skills",
"uninstall-skills",
"context",
"write",
"read",
"scratchpad",
"search",
"distil",
"distill",
"sync",
"init",
"status",
]) {
expect(shouldTryExternalCommand(command)).toBe(false);
}
expect(shouldTryExternalCommand(undefined)).toBe(false);
expect(shouldTryExternalCommand("--help")).toBe(false);
});

test("accepts only safe non-core command names", () => {
expect(shouldTryExternalCommand("recall")).toBe(true);
expect(shouldTryExternalCommand("session-recall")).toBe(true);
expect(shouldTryExternalCommand("../recall")).toBe(false);
expect(shouldTryExternalCommand("Recall")).toBe(false);
expect(shouldTryExternalCommand("recall/foo")).toBe(false);
});

test("resolves one fixed user-local host path", () => {
const homeDir = path.join(path.sep, "tmp", "home");
const expected = path.join(homeDir, ".agent-memory", "bin", "agent-memory-extension");
expect(
resolveExternalCommandHost({
homeDir,
platform: "linux",
isFile: (target) => target === expected,
}),
).toBe(expected);
expect(resolveExternalCommandHost({ homeDir, platform: "linux", isFile: () => false })).toBeNull();
});

test("uses an executable suffix on Windows", () => {
const homeDir = "C:\\Users\\test";
const expected = path.join(homeDir, ".agent-memory", "bin", "agent-memory-extension.exe");
expect(
resolveExternalCommandHost({
homeDir,
platform: "win32",
isFile: (target) => target === expected,
}),
).toBe(expected);
});
});