From 98c27af05e05dd1a95b5b03917d65b72cfd7130c Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 21:56:44 -0700 Subject: [PATCH 1/9] feat: add generic external command resolver --- src/external-command.ts | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/external-command.ts diff --git a/src/external-command.ts b/src/external-command.ts new file mode 100644 index 0000000..56978ac --- /dev/null +++ b/src/external-command.ts @@ -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; +} From 24768242f73e6a9882fd05588faf08bf65d4cafb Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 21:56:52 -0700 Subject: [PATCH 2/9] feat: route non-core commands to local extension host --- src/launcher.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/launcher.ts diff --git a/src/launcher.ts b/src/launcher.ts new file mode 100644 index 0000000..a8af429 --- /dev/null +++ b/src/launcher.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { resolveExternalCommandHost, runExternalCommand, shouldTryExternalCommand } from "./external-command.js"; + +async function runCoreCli(): Promise { + await import("./cli.js"); +} + +async function main(): Promise { + 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; +}); From 1b79fe78f37ace27b181fbf55073a3ed635d2690 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 21:57:04 -0700 Subject: [PATCH 3/9] test: cover external command handoff --- test/external-command.test.ts | 62 +++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 test/external-command.test.ts diff --git a/test/external-command.test.ts b/test/external-command.test.ts new file mode 100644 index 0000000..5278421 --- /dev/null +++ b/test/external-command.test.ts @@ -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); + }); +}); From bbcf3e6db5403dfcb97ce51d1f12f8bb570c3fda Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 21:57:20 -0700 Subject: [PATCH 4/9] build: package the generic command launcher --- package.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 52c6e0b..99427c6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ } }, "bin": { - "agent-memory": "./dist/cli.js" + "agent-memory": "./dist/launcher.js" }, "type": "module", "engines": { @@ -56,6 +56,10 @@ "dist/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" ], @@ -68,14 +72,14 @@ "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: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": { From c0285db6898b35f872a4a81e8c805c992ab6ab0a Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 21:57:40 -0700 Subject: [PATCH 5/9] ci: enforce an explicit public source allowlist --- scripts/check-public-boundary.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/check-public-boundary.mjs b/scripts/check-public-boundary.mjs index 84f818e..6dbc0f0 100644 --- a/scripts/check-public-boundary.mjs +++ b/scripts/check-public-boundary.mjs @@ -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); From b9cfb6ea5c2d9c766b8a85cbd9e203d4eef87e5e Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 22:00:54 -0700 Subject: [PATCH 6/9] fix: preserve portable cli package layout --- src/launcher.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/launcher.ts b/src/launcher.ts index a8af429..e136a8b 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -1,9 +1,16 @@ #!/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 { - await import("./cli.js"); + // 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. The standalone Bun build runs directly from launcher.ts. + const runningAsPortableBin = path.basename(fileURLToPath(import.meta.url)) === "cli.js"; + if (runningAsPortableBin) await import("./core-cli.js"); + else await import("./cli.js"); } async function main(): Promise { From 251f8aed140358e76011b9e85dcd7233cb83c184 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 22:01:02 -0700 Subject: [PATCH 7/9] build: assemble portable launcher without changing bin path --- scripts/assemble-portable-cli.cjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 scripts/assemble-portable-cli.cjs diff --git a/scripts/assemble-portable-cli.cjs b/scripts/assemble-portable-cli.cjs new file mode 100644 index 0000000..edc04db --- /dev/null +++ b/scripts/assemble-portable-cli.cjs @@ -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); From 64e99b34a607f6a9c8bab8be198491e0f4cd6bb5 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 22:01:18 -0700 Subject: [PATCH 8/9] fix: retain portable npm bin contract --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 99427c6..c80587a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ } }, "bin": { - "agent-memory": "./dist/launcher.js" + "agent-memory": "./dist/cli.js" }, "type": "module", "engines": { @@ -54,6 +54,8 @@ "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", @@ -71,7 +73,7 @@ "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: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", From ea388a503bcf45783f32a768b3083ae6b6295a2d Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Thu, 10 Sep 2026 22:03:16 -0700 Subject: [PATCH 9/9] fix: keep portable-only import out of TypeScript resolution --- src/launcher.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/launcher.ts b/src/launcher.ts index e136a8b..2f106d2 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -7,10 +7,16 @@ import { resolveExternalCommandHost, runExternalCommand, shouldTryExternalComman async function runCoreCli(): Promise { // 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. The standalone Bun build runs directly from launcher.ts. + // 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) await import("./core-cli.js"); - else await import("./cli.js"); + if (runningAsPortableBin) { + const portableCoreCli = `./core-${"cli"}.js`; + await import(portableCoreCli); + return; + } + await import("./cli.js"); } async function main(): Promise {