@staix/agent-mem 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.ts CHANGED
@@ -1,21 +1,28 @@
1
+ import { cliAttribution } from "./memory/cli-attribution.ts";
2
+ import { configuredPolicy } from "./memory/config.ts";
1
3
  import { SETUP_CONFIG_BYTES, updateTomlArray } from "./setup-toml.ts";
2
4
  import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, sameImage, type ConfigImage } from "./setup-owned.ts";
3
5
  import { spawnSync } from "node:child_process";
4
6
  import { createHash, randomUUID } from "node:crypto";
5
7
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
6
8
  import { homedir, tmpdir } from "node:os";
7
- import { createInterface } from "node:readline/promises";
8
9
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { isDeepStrictEqual } from "node:util";
11
12
  import { HOOK_EVENTS } from "./hook-contract.ts";
12
13
  import { memoryHome } from "./memory/home.ts";
14
+ import { label, PLAIN, singleLine, type Style } from "./memory/style.ts";
13
15
  import { canonicalPath, memoryGitEnv, memoryGitSync, processLiveness, processSignature, realPath } from "./memory/platform.ts";
14
16
 
15
17
  export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
16
18
  /** Workspace-scoped Antigravity setup is always selected explicitly. */
17
19
  export const SETUP_ALL_TARGETS = SETUP_TARGETS.filter(target => target !== "agy");
18
20
  export type SetupTarget = typeof SETUP_TARGETS[number];
21
+ // Only recorded layout verification versions belong here; null means no version was recorded.
22
+ const SETUP_VERIFIED_VERSIONS: Readonly<Record<SetupTarget, string | null>> = {
23
+ claude: null, codex: "0.165.0", kimi: null, pi: null, qwen: "0.26.1", jcode: "0.99.1", agy: null,
24
+ };
25
+ interface ClientVersion { output: string | null; verified: string | null; matches: boolean | null }
19
26
  type Env = Readonly<Record<string, string | undefined>>;
20
27
  type Image = ConfigImage;
21
28
  interface PlannedHook { event: string; command: string; timeout: number }
@@ -24,7 +31,7 @@ interface DirectoryIdentity { dev: number; ino: number }
24
31
  interface Receipt { createdDirectory?: DirectoryIdentity; version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
25
32
  export interface SetupContext {
26
33
  env?: Env; bun?: string; entry?: string; stdinTTY?: boolean; cwd?: string;
27
- /** Trusted confirmation seam; ordinary CLI use always asks the person through stdin. */
34
+ /** @deprecated Retained for source compatibility; explicit apply/undo modes do not prompt. */
28
35
  confirm?: (plan: SetupPlan) => Promise<boolean>;
29
36
  /** Host-only race fixture, called after temporary-file fsync and before the final comparison/rename. */
30
37
  beforePublish?: (file: string) => void;
@@ -33,7 +40,7 @@ export interface SetupContext {
33
40
  }
34
41
  export interface SetupPlan {
35
42
  targets: SetupTarget[]; mode: "preview" | "apply" | "undo";
36
- actions: { target: SetupTarget; files: string[]; receipt: string; cwd?: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
43
+ actions: { target: SetupTarget; version?: ClientVersion; files: string[]; receipt: string; cwd?: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
37
44
  notes: string[];
38
45
  }
39
46
  const MAX = SETUP_CONFIG_BYTES, JOURNAL_MAX = 12 * MAX;
@@ -152,7 +159,8 @@ function context(options: SetupContext, workspace?: WorkspaceBinding) {
152
159
  }
153
160
  function command(target: SetupTarget, bun: string, entry: string): string[] | null {
154
161
  if (target === "kimi" || target === "jcode" || target === "agy") return null;
155
- return [target, "mcp", "add", ...(target === "claude" || target === "qwen" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
162
+ if (target === "qwen") return [target, "mcp", "add", "--scope", "user", "--transport", "stdio", "amem", bun, "--", "--smol", entry, "mcp"];
163
+ return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
156
164
  }
157
165
  function assertJcodeHome(env: Env): void {
158
166
  if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
@@ -161,7 +169,7 @@ function assertJcodeRuntime(c: ReturnType<typeof context>): void {
161
169
  assertJcodeHome(c.env);
162
170
  if ([c.bun, c.entry].some(value => value.includes("${"))) throw Error("Jcode interpolates environment placeholders in MCP command paths; setup cannot register this runtime exactly");
163
171
  }
164
- function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(target === "agy" ? [c.workspace!.root] : [])]); }
172
+ function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
165
173
  function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
166
174
  function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
167
175
  const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
@@ -184,7 +192,7 @@ function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): Plann
184
192
  if (target === "jcode" || target === "agy") return [];
185
193
  const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
186
194
  return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
187
- : [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}`, timeout: target === "pi" ? 2.5 : 3 }];
195
+ : [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}${target === "pi" ? "" : " --json"}`, timeout: target === "pi" ? 2.5 : 3 }];
188
196
  }
189
197
  const lifecycle = (target: SetupTarget) => target === "jcode" || target === "agy" ? "MCP only; native capture unavailable" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
190
198
  export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
@@ -217,8 +225,43 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
217
225
  const hasAmem = target === "jcode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
218
226
  const unchanged = !!prior && prior.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => { try { const live = read(change.path); restoreChange(prior, change, index, live); return managedAfter(target, index, change.before, change.after, live); } catch { return false; } });
219
227
  const add = command(target, c.bun, c.entry), remove = [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
220
- return { target, available: available(c, target, options), files: c.paths[target], receipt: journal(c, target), ...(target === "agy" ? { cwd: c.workspace!.root } : {}), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !available(c, target, options) ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
221
- }), notes: [...pluginNotes, ...(/\/[^/]*\d+\.\d+[^/]*\/(?:bin\/)?bun$/.test(c.bun) ? ["this bun path is version-specific; rerun setup --apply after changing Bun"] : []), "Preview only unless --apply or --undo is confirmed in a person's terminal.", "Native registration commands run in a private staging HOME; only the listed configuration files are published.", "Original file bytes restore exactly when untouched; otherwise undo preserves CLI-owned changes and restores only managed entries, refusing edits to those entries.", "No AGENTS.md, CLAUDE.md or rule files are edited.", `A private ${join(c.state, "setup.lock")} serializes mutations; temporary ${join(realPath(tmpdir()), "amem-setup-*")} staging homes are removed.`, "Codex requires its native /hooks review before newly registered hooks run; setup does not grant hook trust. Runtime registrations use the PATH-resolved absolute Bun path without resolving its stable symlink; setup all reports and skips missing clients."] };
228
+ const installed = available(c, target, options), version = installed && target !== "agy" && mode !== "undo" ? probeClientVersion(c, target, options) : undefined;
229
+ if (version?.matches === false) pluginNotes.push(`WARNING: ${target} version ${version.output}; layout verified on ${version.verified}. Apply remains guarded by native configuration validation.`);
230
+ return { target, available: installed, ...(version ? { version } : {}), files: c.paths[target], receipt: journal(c, target), ...(target === "agy" ? { cwd: c.workspace!.root } : {}), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !installed ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
231
+ }), notes: [...pluginNotes, ...(/\/[^/]*\d+\.\d+[^/]*\/(?:bin\/)?bun$/.test(c.bun) ? ["this bun path is version-specific; rerun setup --apply after changing Bun"] : []), "Preview only unless --apply or --undo is passed; explicit modes apply without a prompt.", "Native registration commands run in a private staging HOME; only the listed configuration files are published.", "Original file bytes restore exactly when untouched; otherwise undo preserves CLI-owned changes and restores only managed entries, refusing edits to those entries.", "No AGENTS.md, CLAUDE.md or rule files are edited.", `A private ${join(c.state, "setup.lock")} serializes mutations; temporary ${join(realPath(tmpdir()), "amem-setup-*")} staging homes are removed.`, "Codex requires its native /hooks review before newly registered hooks run; setup does not grant hook trust. Runtime registrations use the PATH-resolved absolute Bun path without resolving its stable symlink; setup all reports and skips missing clients."] };
232
+ }
233
+ /** Native probes and registration share one private HOME, cwd and configuration layout. */
234
+ function stagingEnvironment(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext, staging: string) {
235
+ const env: Record<string, string | undefined> = { ...c.env, HOME: staging, AMEM_HOME: join(staging, "memory"), AGENTHUB_HOME: join(staging, "hub"), CODEX_HOME: join(staging, ".codex"), KIMI_CODE_HOME: join(staging, ".kimi-code"), PI_CODING_AGENT_DIR: join(staging, ".pi", "agent"), QWEN_HOME: join(staging, ".qwen"), JCODE_HOME: join(staging, ".jcode") };
236
+ Object.assign(env, { XDG_CONFIG_HOME: join(staging, ".config"), XDG_DATA_HOME: join(staging, ".local", "share"), XDG_CACHE_HOME: join(staging, ".cache"), XDG_STATE_HOME: join(staging, ".local", "state"), TMPDIR: join(staging, "tmp"), TMP: join(staging, "tmp"), TEMP: join(staging, "tmp") });
237
+ env.QWEN_RUNTIME_DIR = join(staging, "qwen-runtime");
238
+ env.QWEN_CODE_SYSTEM_SETTINGS_PATH = join(staging, "qwen-system-settings.json");
239
+ env.QWEN_CODE_SYSTEM_DEFAULTS_PATH = join(staging, "qwen-system-defaults.json");
240
+ mkdirSync(env.TMPDIR!, { mode: 0o700 });
241
+ if (c.env.CLAUDE_CONFIG_DIR?.trim()) env.CLAUDE_CONFIG_DIR = join(staging, ".claude"); else delete env.CLAUDE_CONFIG_DIR;
242
+ // The staging workspace is a trusted private binding, not a Git repository or the real caller cwd.
243
+ const staged = context({ ...options, env, cwd: staging }, target === "agy" ? { root: staging, ignored: true, assertCurrent() {} } : undefined);
244
+ // Codex refuses an explicit CODEX_HOME that does not exist, even for mcp add.
245
+ mkdirSync(dirname(staged.paths[target][0]!), { recursive: true, mode: 0o700 });
246
+ return { env, staged };
247
+ }
248
+ /** Advisory only: never queried by apply/undo or persisted in the ownership receipt. */
249
+ function probeClientVersion(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): ClientVersion {
250
+ const verified = SETUP_VERIFIED_VERSIONS[target];
251
+ let output: string | null = null;
252
+ // Like the help probe, trusted native fixtures do not invoke installed executables.
253
+ if (!options.native) {
254
+ let staging: string | undefined;
255
+ try {
256
+ staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
257
+ const { env } = stagingEnvironment(c, target, options, staging);
258
+ const result = spawnSync(target, ["--version"], { cwd: staging, env: { ...env }, encoding: "utf8", timeout: 1000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] });
259
+ if (result.status === 0 && !result.error) output = Array.from(singleLine(result.stdout).trim()).slice(0, 256).join("") || null;
260
+ } catch { /* An absent, failed or timed-out native version never prevents setup. */ }
261
+ finally { if (staging) try { rmSync(staging, { recursive: true, force: true }); } catch { output = null; } }
262
+ }
263
+ const observed = output?.match(/(?:^|\s)v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?=\s|$)/)?.[1];
264
+ return { output, verified, matches: observed === undefined || verified === null ? null : observed === verified };
222
265
  }
223
266
  function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
224
267
  // In older free-form Pi CLIs, an unknown subcommand can be treated as a model prompt.
@@ -227,7 +270,12 @@ function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
227
270
  }
228
271
  function native(argv: readonly string[], env: Env, cwd: string): void {
229
272
  const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
230
- if (result.status !== 0) throw Error(`${argv[0]} MCP registration failed in staging; no native output disclosed`);
273
+ if (result.status !== 0) {
274
+ const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
275
+ const argumentsRejected = argv[0] === "qwen" && `${result.stderr ?? ""}\n${result.stdout ?? ""}`.includes("Not enough non-option arguments:");
276
+ const reason = timedOut ? "timed out" : argumentsRejected ? "rejected command arguments" : "failed";
277
+ throw Error(`${argv[0]} MCP registration ${reason} in staging; no native output disclosed`);
278
+ }
231
279
  }
232
280
  function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
233
281
  const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
@@ -261,17 +309,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
261
309
  const changes: Change[] = c.paths[target].map((path, i) => ({ key: `${target}:${i}`, path, before: restored[i]!, after: restored[i]!, start: expected[i]! }));
262
310
  const staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
263
311
  try {
264
- const env: Record<string, string | undefined> = { ...c.env, HOME: staging, AMEM_HOME: join(staging, "memory"), AGENTHUB_HOME: join(staging, "hub"), CODEX_HOME: join(staging, ".codex"), KIMI_CODE_HOME: join(staging, ".kimi-code"), PI_CODING_AGENT_DIR: join(staging, ".pi", "agent"), QWEN_HOME: join(staging, ".qwen"), JCODE_HOME: join(staging, ".jcode") };
265
- Object.assign(env, { XDG_CONFIG_HOME: join(staging, ".config"), XDG_DATA_HOME: join(staging, ".local", "share"), XDG_CACHE_HOME: join(staging, ".cache"), XDG_STATE_HOME: join(staging, ".local", "state"), TMPDIR: join(staging, "tmp"), TMP: join(staging, "tmp"), TEMP: join(staging, "tmp") });
266
- env.QWEN_RUNTIME_DIR = join(staging, "qwen-runtime");
267
- env.QWEN_CODE_SYSTEM_SETTINGS_PATH = join(staging, "qwen-system-settings.json");
268
- env.QWEN_CODE_SYSTEM_DEFAULTS_PATH = join(staging, "qwen-system-defaults.json");
269
- mkdirSync(env.TMPDIR!, { mode: 0o700 });
270
- if (c.env.CLAUDE_CONFIG_DIR?.trim()) env.CLAUDE_CONFIG_DIR = join(staging, ".claude"); else delete env.CLAUDE_CONFIG_DIR;
271
- // The staging workspace is a trusted private binding, not a Git repository or the real caller cwd.
272
- const staged = context({ ...options, env, cwd: staging }, target === "agy" ? { root: staging, ignored: true, assertCurrent() {} } : undefined);
273
- // Codex refuses an explicit CODEX_HOME that does not exist, even for mcp add.
274
- mkdirSync(dirname(staged.paths[target][0]!), { recursive: true, mode: 0o700 });
312
+ const { env, staged } = stagingEnvironment(c, target, options, staging);
275
313
  for (let i = 0; i < changes.length; i++) if (expected[i]!.bytes !== null) write(staged.paths[target][i]!, expected[i]!);
276
314
  const argv = command(target, c.bun, c.entry);
277
315
  if (argv && prior?.intent === intent(c, target)) {
@@ -284,16 +322,33 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
284
322
  if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
285
323
  (options.native ?? native)(argv, env, staging);
286
324
  changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
287
- if (changes[0]!.after.bytes === null) throw Error("native registration produced no configuration");
325
+ if (changes[0]!.after.bytes === null) throw Error(`${target} native registration produced no configuration`);
288
326
  const installedText = Buffer.from(changes[0]!.after.bytes!, "base64").toString("utf8");
289
- const installed = target === "codex" ? toml(installedText) : json(changes[0]!.after);
290
- const installedServer = record(record(installed[target === "codex" ? "mcp_servers" : "mcpServers"]).amem);
291
- if (installedServer.command !== c.bun || JSON.stringify(installedServer.args) !== JSON.stringify(["--smol", c.entry, "mcp"]) || installedServer.enabled === false) throw Error("native registration did not produce the planned amem server");
327
+ let installed: Record<string, unknown>, installedServer: Record<string, unknown>;
328
+ try {
329
+ installed = target === "codex" ? toml(installedText) : json(changes[0]!.after);
330
+ installedServer = record(record(installed[target === "codex" ? "mcp_servers" : "mcpServers"]).amem);
331
+ } catch { throw Error(`${target} native registration produced invalid configuration; no configuration published`); }
332
+ if (installedServer.command !== c.bun || JSON.stringify(installedServer.args) !== JSON.stringify(["--smol", c.entry, "mcp"]) || installedServer.enabled === false) throw Error(`${target} native registration did not produce the planned amem server`);
292
333
  const serverKey = target === "codex" ? "mcp_servers" : "mcpServers";
293
334
  const withoutAmem = (value: Record<string, unknown>) => { const copy = { ...value }, servers = { ...record(value[serverKey] ?? {}) }; delete servers.amem; copy[serverKey] = servers; return copy; };
294
- if (!isDeepStrictEqual(withoutAmem(document)[serverKey], withoutAmem(installed)[serverKey])) throw Error("native registration changed unrelated MCP servers; no configuration published");
335
+ const originalUnrelated = withoutAmem(document), installedUnrelated = withoutAmem(installed);
295
336
  if (target === "codex") {
296
- if (!isDeepStrictEqual(withoutAmem(document), withoutAmem(installed))) throw Error("native registration changed unrelated settings; no configuration published");
337
+ // Restore the omitted default only in this detached comparison, never in native output.
338
+ const originalServers = record(originalUnrelated[serverKey]), installedServers = record(installedUnrelated[serverKey]);
339
+ for (const [name, value] of Object.entries(originalServers)) {
340
+ if (!Object.hasOwn(installedServers, name)) continue;
341
+ const before = record(value), after = installedServers[name];
342
+ if (typeof before.command === "string" && Object.hasOwn(before, "args") && Array.isArray(before.args) && before.args.length === 0
343
+ && after && typeof after === "object" && !Array.isArray(after) && !Object.hasOwn(after, "args")) {
344
+ installedServers[name] = { ...after, args: [] };
345
+ }
346
+ }
347
+ }
348
+ if (!isDeepStrictEqual(originalUnrelated[serverKey], installedUnrelated[serverKey])) throw Error(`${target} native registration changed unrelated MCP servers; no configuration published`);
349
+ if (target === "codex") {
350
+ if (!isDeepStrictEqual(originalUnrelated, installedUnrelated)) throw Error("codex native registration changed unrelated settings; no configuration published");
351
+ changes[0]!.after = putMcpRecord(target, changes[0]!.before, installedServer);
297
352
  } else {
298
353
  // Keep all unrelated JSON settings from the original; use only the CLI-generated server record.
299
354
  // Qwen's restored image also retires its previous managed hook on a runtime upgrade.
@@ -311,7 +366,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
311
366
  servers.amem = { command: c.bun, args: ["--smol", c.entry, "mcp"] }; document.mcpServers = servers;
312
367
  changes[0]!.after = objectImage(document, changes[0]!.before);
313
368
  }
314
- const capture = (kind: string) => `${quote(c.bun)} --smol ${quote(c.entry)} ingest ${kind}`;
369
+ const capture = (kind: string) => `${quote(c.bun)} --smol ${quote(c.entry)} ingest ${kind} --json`;
315
370
  if (target === "claude") changes[1]!.after = hookDocument(changes[1]!.before, HOOK_EVENTS, event => `${quote(c.bun)} --smol ${quote(c.entry)} hook ${event}`);
316
371
  if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3);
317
372
  // Qwen's user MCP and hooks share settings.json: publish and journal one combined image.
@@ -367,7 +422,7 @@ function contextForPlan(plan: SetupPlan, options: SetupContext) {
367
422
  function publishGuard(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext) {
368
423
  return target === "agy" ? (file: string) => { options.beforePublish?.(file); c.workspace!.assertCurrent(); } : options.beforePublish;
369
424
  }
370
- /** Caller enforces person markers/TTY and confirmation; CLI-owned unrelated fields remain live. */
425
+ /** CLI-owned unrelated fields remain live. */
371
426
  function applyUnlocked(plan: SetupPlan, options: SetupContext = {}): SetupApplyResult {
372
427
  const c = contextForPlan(plan, options), skipped: { target: SetupTarget; reason: string }[] = [];
373
428
  const targets = plan.targets.filter(target => { if (available(c, target, options)) return true; if (plan.targets.length === 1) throw Error(`${target} CLI is not installed`); skipped.push({ target, reason: "CLI not installed" }); return false; });
@@ -425,16 +480,51 @@ export function undoSetup(plan: SetupPlan, options: SetupContext = {}): { restor
425
480
  return withSetupLock(options, () => undoUnlocked(plan, options));
426
481
  }
427
482
 
428
- export async function runSetup(target: string, mode: SetupPlan["mode"], options: SetupContext = {}): Promise<string> {
429
- const plan = planSetup(target, mode, options);
430
- if (mode === "preview") return JSON.stringify(plan, null, 2);
431
- if (!(options.stdinTTY ?? process.stdin.isTTY)) throw Error("setup --apply/--undo require a terminal on stdin");
432
- const confirm = options.confirm ?? (async (shown: SetupPlan) => {
433
- process.stdout.write(JSON.stringify(shown, null, 2) + "\n");
434
- const terminal = createInterface({ input: process.stdin, output: process.stdout });
435
- try { return (await terminal.question(`Type yes to ${mode} the listed configuration changes: `)).trim() === "yes"; }
436
- finally { terminal.close(); }
437
- });
438
- if (!await confirm(plan)) return JSON.stringify({ cancelled: true });
439
- return JSON.stringify(mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options), null, 2);
483
+ /** Person-facing setup plan (#125): each target once, with its effective files, MCP and hook changes, then skipped clients and notes. `--json` keeps the raw plan. */
484
+ export function setupPlanText(plan: SetupPlan, s: Style = PLAIN): string {
485
+ const verb = plan.mode === "undo" ? "undo" : plan.mode === "apply" ? "apply" : "preview";
486
+ // Undo restores every target with a receipt whether or not its CLI is still installed (undoUnlocked), so undo never collapses an action.
487
+ const full = (action: SetupPlan["actions"][number]) => plan.mode === "undo" || action.available;
488
+ const skipped = plan.actions.filter(action => !full(action)), changing = plan.actions.filter(action => full(action) && action.mcpChange !== "unchanged");
489
+ const lines = [`${s.bold(`amem setup ${verb}:`)} ${plan.targets.length} target${plan.targets.length === 1 ? "" : "s"}, ${changing.length} with ${plan.mode === "undo" ? "restores" : "MCP changes"}${skipped.length ? `, ${skipped.length} skipped` : ""}`, ""];
490
+ for (const action of plan.actions) {
491
+ if (!full(action)) { lines.push(`${label(s, "skipped")} ${s.bold(action.target)}: CLI not installed`); continue; }
492
+ const state = action.mcpChange === "unchanged" ? "ok" : "attention";
493
+ const what = action.mcpChange === "unchanged" ? plan.mode === "undo" ? "no setup receipt; nothing to restore" : "already set up; no change" : action.mcpChange === "restore" ? `restore original MCP/hook files${action.available ? "" : " (CLI not installed)"}` : `MCP ${action.mcpChange}`;
494
+ lines.push(`${label(s, state)} ${s.bold(action.target)}: ${what} ${s.dim(`(${action.state})`)}`);
495
+ if (action.version) {
496
+ const reported = action.version.output === null ? "unknown" : action.version.verified && action.version.matches === null ? `unrecognised (${action.version.output})` : action.version.output;
497
+ lines.push(` version ${reported}; ${action.version.verified ? `verified ${action.version.verified}` : "unverified (no verified version recorded)"}`);
498
+ }
499
+ for (const file of action.files) lines.push(` file ${file}`);
500
+ if (action.cwd) lines.push(` cwd ${action.cwd}`);
501
+ lines.push(` receipt ${action.receipt}`);
502
+ if (action.available && action.registration) lines.push(` command ${action.registration.map(quote).join(" ")}`);
503
+ for (const hook of action.hooks) lines.push(` hook ${hook.event}: ${hook.command} (timeout ${hook.timeout})`);
504
+ if (action.available && !action.hooks.length && plan.mode !== "undo") lines.push(` hook ${s.dim("none")}`);
505
+ lines.push(` ${s.dim(action.lifecycle)}`);
506
+ }
507
+ const warnings = plan.notes.filter(note => /^WARNING|detected|version-specific/.test(note)), notes = plan.notes.filter(note => !warnings.includes(note));
508
+ if (skipped.length) lines.push("", `Skipped clients: ${skipped.map(action => action.target).join(", ")} (not installed)`);
509
+ if (warnings.length) lines.push("", "Warnings:", ...warnings.map(note => ` - ${s.attention(note)}`));
510
+ lines.push("", "Notes:", ...notes.map(note => ` - ${note}`));
511
+ if (plan.mode === "preview") lines.push("", `Next: amem setup ${plan.targets.length === 1 ? plan.targets[0] : "all"} --apply writes without a prompt; --json prints the full plan`);
512
+ return lines.join("\n");
513
+ }
514
+
515
+ function setupResultText(result: SetupApplyResult | { restored: SetupTarget[] }, s: Style): string {
516
+ if ("restored" in result) return `${label(s, "ok")} restored: ${result.restored.join(", ") || "none"}`;
517
+ return [`${label(s, "ok")} applied: ${result.applied.join(", ") || "none"}`, `${label(s, "ok")} unchanged: ${result.unchanged.join(", ") || "none"}`,
518
+ ...(result.skipped ?? []).map(item => `${label(s, "skipped")} ${item.target}: ${item.reason}`)].join("\n");
519
+ }
520
+
521
+ /** Compatibility formatter for confirmation-style callers: the raw plan under `--json`, the readable plan otherwise. */
522
+ export function setupConfirmationText(plan: SetupPlan, json: boolean, s: Style = PLAIN): string { return json ? JSON.stringify(plan, null, 2) : setupPlanText(plan, s); }
523
+
524
+ export async function runSetup(target: string, mode: SetupPlan["mode"], options: SetupContext & { json?: boolean; style?: Style } = {}): Promise<string> {
525
+ const plan = planSetup(target, mode, options), s = options.json ? PLAIN : options.style ?? PLAIN;
526
+ if (cliAttribution()?.actor === "agent" && !configuredPolicy(memoryHome(options.env ?? process.env), options.cwd ?? process.cwd()).screenStandalone(JSON.parse(JSON.stringify(plan)))) throw Error("Memory operation unavailable or refused; no partial result is shown.");
527
+ if (mode === "preview") return options.json ? JSON.stringify(plan, null, 2) : setupPlanText(plan, s);
528
+ const result = mode === "apply" ? applySetup(plan, options) : undoSetup(plan, options);
529
+ return options.json ? JSON.stringify(result, null, 2) : setupResultText(result, s);
440
530
  }