feat(cli): interactive doctor v2 + agentmemory remove (#406)

* feat(cli): interactive doctor v2 with inline fixes

Replace passive doctor with an interactive [F]ix/[S]kip/[?]more/[Q]uit
prompt per diagnostic. Each check now ships a one-line problem, cause,
fix-preview, and inline apply() — and we re-check only the affected
diagnostic after a fix, not the whole suite.

New flags:
  doctor --all        apply every fix without prompting (CI)
  doctor --dry-run    show what each fix would do, run nothing

Catalog (src/cli/doctor-diagnostics.ts):
- env-missing                     → runInit()
- no-llm-provider-key             → open .env in $EDITOR
- engine-version-mismatch         → runIiiInstaller + restart
- viewer-unreachable              → stop + restart engine
- stale-pidfile                   → clear pid + state, restart
- env-placeholder-keys            → open .env in $EDITOR
- iii-on-path-not-local-bin       → manual (suggest installer)

The catalog is exported as a pure data structure so tests can assert
each entry has check/fix/message without booting clack.

Passive server checks (graph populated, Claude Code hooks, flags) still
run first — they need a live engine and have no one-shot inline fix.

* feat(cli): add agentmemory remove for clean uninstall

New command tears down everything agentmemory installs:
- ~/.agentmemory/iii.pid + engine-state.json (after stopping engine)
- ~/.agentmemory/.env (asks separately — holds API keys)
- ~/.agentmemory/preferences.json
- ~/.agentmemory/backups/ (connect-PR manifest + backups)
- ~/.local/bin/iii (only when iii --version matches our pin)
- Any agent symlinks from the connect-manifest
- ~/.agentmemory/data/ (asks separately, default keep)

Surface:
  agentmemory remove              # interactive, double-confirms
  agentmemory remove --force      # skip generic confirms
  agentmemory remove --keep-data  # remove binaries + symlinks only

Plan building lives in src/cli/remove-plan.ts as a pure fs-inspecting
function so tests sandbox a fake $HOME under tmpdir() and assert on
plan shape without touching the real home directory.

Always-ask items (.env, memory data, mismatched-version local-bin iii)
still get per-item confirmation even with --force.
This commit is contained in:
Rohit Ghumare
2026-05-15 16:20:24 +01:00
committed by GitHub
parent b36a6d7c73
commit 29e8865286
5 changed files with 1514 additions and 34 deletions
+491 -34
View File
@@ -12,6 +12,7 @@ import {
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
@@ -21,6 +22,22 @@ import { fileURLToPath } from "node:url";
import { homedir, platform } from "node:os";
import * as p from "@clack/prompts";
import { generateId } from "./state/schema.js";
import {
buildDiagnostics,
dryRunPlan,
parseEnvFile,
type Diagnostic,
type DiagnosticFixResult,
type DoctorContext,
type DoctorEffects,
} from "./cli/doctor-diagnostics.js";
import {
buildRemovePlan,
formatPlan,
localBinIii,
type ConnectManifest,
type RemoveOptions,
} from "./cli/remove-plan.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
@@ -88,7 +105,11 @@ Commands:
No arg = interactive picker. --all wires every detected agent.
--dry-run shows what would change. --force re-installs.
status Show connection status, memory count, flags, and health
doctor Run diagnostic checks (server, flags, graph, providers)
doctor Interactive diagnostic + fixer. [F]ix · [S]kip · [?]more · [Q]uit
--all: apply every fix without prompting (CI)
--dry-run: show what each fix would do, don't execute
remove Cleanly uninstall agentmemory (pidfile, state, .env, binaries).
--force: skip confirmations · --keep-data: keep memory data
demo Seed sample sessions and show recall in action
upgrade Upgrade local deps + iii runtime (best effort)
stop [--force] Stop the running iii-engine started by this CLI.
@@ -922,23 +943,173 @@ function checkClaudeCodeHooks(): CCHooksCheck {
return { state: "not-loaded" };
}
async function runDoctor() {
p.intro("agentmemory doctor");
// ---------------------------------------------------------------------------
// Doctor v2 — interactive fixer.
//
// The legacy passive check-list (server reachable, flags, knowledge-graph,
// Claude Code hooks) still runs first as an informational summary because
// those checks need a live engine and don't have a one-shot inline fix.
// Then we drive the new diagnostic catalog (see src/cli/doctor-diagnostics.ts)
// which prompts Fix/Skip/More/Quit per failing check, applies the fix
// inline, and re-checks only the affected diagnostic.
function buildDoctorContext(): DoctorContext {
return {
baseUrl: getBaseUrl(),
viewerUrl: getViewerUrl(),
envPath: join(homedir(), ".agentmemory", ".env"),
pidfilePath: enginePidfilePath(),
enginePath: engineStatePath(),
pinnedVersion: IIPINNED_VERSION,
};
}
function buildDoctorEffects(): DoctorEffects {
return {
envFileExists: () => existsSync(join(homedir(), ".agentmemory", ".env")),
readEnvFile: () => {
try {
return parseEnvFile(
readFileSync(join(homedir(), ".agentmemory", ".env"), "utf-8"),
);
} catch {
return {};
}
},
pidfileExists: () => existsSync(enginePidfilePath()),
pidfilePidIsAlive: () => {
const pid = readEnginePidfile();
if (pid === null) return null;
return pidAlive(pid);
},
findIiiBinary: () => whichBinary("iii"),
localBinIiiPath: () => join(homedir(), ".local", "bin", IS_WINDOWS ? "iii.exe" : "iii"),
iiiBinaryVersion: (binPath: string) => iiiBinVersion(binPath),
viewerReachable: async (timeoutMs = 2000) => {
try {
const res = await fetch(getViewerUrl(), {
signal: AbortSignal.timeout(timeoutMs),
});
return res.ok;
} catch {
return false;
}
},
runInit: async () => {
try {
await runInit();
return { ok: true, message: "Wrote ~/.agentmemory/.env" };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
},
openEditor: async (path: string) => {
const editor = process.env["EDITOR"] || process.env["VISUAL"] || "nano";
p.log.info(`Opening ${path} in ${editor}`);
try {
// Inherit stdio so the user actually sees the editor.
const result = spawnSync(editor, [path], { stdio: "inherit" });
if (result.error) {
return {
ok: false,
message: `Failed to launch ${editor}: ${result.error.message}`,
};
}
if ((result.status ?? 0) !== 0) {
return {
ok: false,
message: `${editor} exited with code ${result.status}`,
};
}
return { ok: true, message: `Saved ${path}` };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
},
runIiiInstaller: async () => {
const r = await runIiiInstaller();
return {
ok: r.ok,
message: r.ok
? `Installed iii v${IIPINNED_VERSION} to ${r.binPath}`
: "iii installer failed (see warnings above)",
};
},
runStop: async () => {
try {
// runStop calls process.exit on its own — guard against that here
// by short-circuiting when there's nothing to stop.
const port = getRestPort();
const portPids = findEnginePidsByPort(port);
const pidfilePid = readEnginePidfile();
if (portPids.length === 0 && pidfilePid === null) {
clearEnginePidfile();
clearEngineState();
return { ok: true, message: "Nothing to stop." };
}
const candidates = new Set<number>();
if (pidfilePid) candidates.add(pidfilePid);
for (const pid of portPids) candidates.add(pid);
let allStopped = true;
for (const pid of candidates) {
const ok = await signalAndWait(pid, "SIGTERM", 3000);
if (!ok) allStopped = false;
}
clearEnginePidfile();
clearEngineState();
return {
ok: allStopped,
message: allStopped ? "Engine stopped." : "Some engine pids survived.",
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
},
runStart: async () => {
try {
const started = await startEngine();
if (!started) return { ok: false, message: "startEngine() returned false" };
const ready = await waitForEngine(15000);
return {
ok: ready,
message: ready ? "Engine ready" : "Engine did not become ready within 15s",
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
},
clearEnginePidAndState: () => {
clearEnginePidfile();
clearEngineState();
},
};
}
async function passiveServerChecks(): Promise<DoctorCheck[]> {
const base = getBaseUrl();
const viewerUrl = getViewerUrl();
const checks: DoctorCheck[] = [];
const serverUp = await isEngineRunning();
checks.push({
name: "Server reachable",
ok: serverUp,
hint: serverUp ? undefined : `Start with: npx @agentmemory/agentmemory (tried ${base})`,
hint: serverUp
? undefined
: `Start with: npx @agentmemory/agentmemory (tried ${base})`,
});
if (!serverUp) {
p.note(formatChecks(checks), "server unreachable");
process.exit(1);
}
if (!serverUp) return checks;
const [health, flags, graph] = await Promise.all([
apiFetch<any>(base, "health", 3000),
@@ -946,40 +1117,46 @@ async function runDoctor() {
apiFetch<any>(base, "graph/stats", 3000),
]);
const viewerUp = await fetch(viewerUrl, { signal: AbortSignal.timeout(2000) })
.then((r) => r.ok)
.catch(() => false);
const hasLlm = flags?.provider === "llm";
const hasEmbed = flags?.embeddingProvider === "embeddings";
const graphNodeCount = Number(graph?.totalNodes ?? graph?.nodes ?? graph?.nodeCount ?? 0);
const graphNodeCount = Number(
graph?.totalNodes ?? graph?.nodes ?? graph?.nodeCount ?? 0,
);
const graphHas = graphNodeCount > 0;
checks.push(
{
name: "Health status",
ok: health?.status === "healthy",
hint: health?.status === "healthy" ? undefined : `Status: ${health?.status || "unknown"}`,
},
{
name: "Viewer reachable",
ok: viewerUp,
hint: viewerUp ? undefined : `${viewerUrl} not responding`,
hint:
health?.status === "healthy"
? undefined
: `Status: ${health?.status || "unknown"}`,
},
{
name: "LLM provider",
ok: hasLlm,
hint: hasLlm ? undefined : "export ANTHROPIC_API_KEY=sk-ant-... (or GEMINI/OPENROUTER/MINIMAX) then restart",
hint: hasLlm ? undefined : "set ANTHROPIC_API_KEY (or GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env",
},
{
name: "Embedding provider",
ok: hasEmbed,
hint: hasEmbed ? undefined : "Running BM25-only. Add OPENAI_API_KEY / VOYAGE_API_KEY / COHERE_API_KEY / OLLAMA_HOST for semantic recall",
hint: hasEmbed
? undefined
: "Running BM25-only. Add OPENAI_API_KEY / VOYAGE_API_KEY / COHERE_API_KEY / OLLAMA_HOST",
},
);
for (const f of (flags?.flags || []) as { label: string; enabled: boolean; enableHow: string }[]) {
checks.push({ name: f.label, ok: f.enabled, hint: f.enabled ? undefined : f.enableHow });
for (const f of (flags?.flags || []) as {
label: string;
enabled: boolean;
enableHow: string;
}[]) {
checks.push({
name: f.label,
ok: f.enabled,
hint: f.enabled ? undefined : f.enableHow,
});
}
const cc = checkClaudeCodeHooks();
@@ -993,12 +1170,14 @@ async function runDoctor() {
case "not-loaded":
return {
ok: false,
hint: "Plugin enabled but hooks not loaded by Claude Code. Try: /plugin uninstall agentmemory@agentmemory && /plugin install agentmemory@agentmemory, then restart the session. CC must be >= 2.1.x for plugin-hook auto-load.",
hint:
"Plugin enabled but hooks not loaded by Claude Code. Try: /plugin uninstall agentmemory@agentmemory && /plugin install agentmemory@agentmemory, then restart the session.",
};
case "no-debug-log":
return {
ok: false,
hint: "Cannot verify — no Claude Code debug log found. Run once with `claude --debug -p \"x\"`, then re-run doctor.",
hint:
'Cannot verify — no Claude Code debug log found. Run once with `claude --debug -p "x"`, then re-run doctor.',
};
case "no-cc-dir":
return undefined;
@@ -1009,19 +1188,154 @@ async function runDoctor() {
checks.push({
name: "Knowledge graph populated",
ok: graphHas,
hint: graphHas ? undefined : "Graph is empty. Run a session with GRAPH_EXTRACTION_ENABLED=true, or POST /agentmemory/graph/extract",
hint: graphHas
? undefined
: "Graph is empty. Run a session with GRAPH_EXTRACTION_ENABLED=true.",
});
const passed = checks.filter((c) => c.ok).length;
const total = checks.length;
p.note(formatChecks(checks), `${passed}/${total} checks passing`);
return checks;
}
if (passed === total) {
p.outro("✓ All checks passed. agentmemory is healthy.");
type DoctorAction = "fix" | "skip" | "more" | "quit";
async function askFixAction(d: Diagnostic): Promise<DoctorAction> {
const choice = await p.select<DoctorAction>({
message: `[${d.id}] ${d.message}`,
options: [
{ value: "fix", label: "F Fix", hint: d.fixPreview },
{ value: "skip", label: "S Skip" },
{ value: "more", label: "? More info" },
{ value: "quit", label: "Q Quit doctor" },
],
initialValue: "fix",
});
if (p.isCancel(choice)) return "quit";
return choice;
}
async function applyFixWithReport(
d: Diagnostic,
ctx: DoctorContext,
dryRun: boolean,
): Promise<DiagnosticFixResult> {
if (dryRun) {
p.log.info(`[dry-run] would: ${d.fixPreview}`);
return { ok: true, message: "(dry-run)" };
}
const result = await d.fix(ctx);
if (result.ok) {
p.log.success(result.message ?? `${d.id} fixed.`);
} else {
p.outro(`${total - passed} issue(s) — follow hints above to fix.`);
p.log.error(result.message ?? `${d.id} fix failed.`);
}
return result;
}
async function runDoctor() {
p.intro("agentmemory doctor");
const applyAll = args.includes("--all");
const dryRun = args.includes("--dry-run");
if (applyAll && dryRun) {
p.log.error("Cannot combine --all and --dry-run.");
process.exit(2);
}
// Passive server checks (informational).
const passive = await passiveServerChecks();
const passivePassed = passive.filter((c) => c.ok).length;
p.note(formatChecks(passive), `server: ${passivePassed}/${passive.length} passing`);
// Doctor v2 interactive catalog.
const ctx = buildDoctorContext();
const effects = buildDoctorEffects();
const diagnostics = buildDiagnostics(effects);
if (dryRun) {
const results: Array<{ diagnostic: Diagnostic; status: { ok: boolean; detail?: string } }> = [];
for (const d of diagnostics) results.push({ diagnostic: d, status: await d.check(ctx) });
const lines = dryRunPlan(ctx, results);
p.note(lines.join("\n"), "dry-run plan");
p.outro("Dry-run complete. Re-run without --dry-run to apply.");
return;
}
let failed = 0;
let fixed = 0;
let skipped = 0;
let quit = false;
for (const d of diagnostics) {
if (quit) {
skipped++;
continue;
}
const status = await d.check(ctx);
if (status.ok) {
p.log.success(`${d.id}${status.detail ? ` (${status.detail})` : ""}`);
continue;
}
failed++;
p.log.warn(`${d.id}${status.detail ?? ""}`.trim());
p.log.info(`why: ${d.fixPreview}`);
if (d.manualOnly) {
p.log.info(`(manual fix only — see "${d.id}" docs)`);
}
if (applyAll) {
const r = await applyFixWithReport(d, ctx, false);
if (r.ok) fixed++;
// Re-check only this diagnostic.
const after = await d.check(ctx);
if (!after.ok) p.log.warn(`${d.id} still failing after fix.`);
continue;
}
// Interactive prompt loop — allow [?] More info without leaving the check.
while (true) {
const action = await askFixAction(d);
if (action === "fix") {
const r = await applyFixWithReport(d, ctx, false);
if (r.ok) {
const after = await d.check(ctx);
if (after.ok) {
fixed++;
} else {
p.log.warn(`${d.id} still failing after fix: ${after.detail ?? ""}`);
}
}
break;
}
if (action === "skip") {
skipped++;
break;
}
if (action === "more") {
p.note(d.moreInfo, `[${d.id}] more info`);
continue;
}
if (action === "quit") {
quit = true;
break;
}
}
}
const summary = `${diagnostics.length} checks · ${failed} failing · ${fixed} fixed · ${skipped} skipped`;
if (quit) {
p.outro(`Quit early. ${summary}`);
process.exit(1);
}
if (failed === 0) {
p.outro("All diagnostics passing. agentmemory is healthy.");
return;
}
if (failed - fixed === 0) {
p.outro(`All fixes applied. ${summary}`);
return;
}
p.outro(summary);
process.exit(1);
}
type DemoObservation = {
@@ -1810,6 +2124,148 @@ async function runImportJsonl(): Promise<void> {
}
}
// ---------------------------------------------------------------------------
// `agentmemory remove` — clean uninstall.
//
// Planning logic lives in src/cli/remove-plan.ts so it's testable without
// touching $HOME. This function loads the manifest, builds the plan,
// double-confirms, then executes step by step.
function loadConnectManifest(home: string): ConnectManifest | null {
const path = join(home, ".agentmemory", "backups", "connect-manifest.json");
try {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw) as Partial<ConnectManifest>;
if (Array.isArray(parsed?.installed)) {
return { installed: parsed.installed };
}
return null;
} catch {
return null;
}
}
function probeLocalBinIiiVersion(home: string): string | null {
const path = localBinIii(home);
if (!existsSync(path)) return null;
return iiiBinVersion(path);
}
function safeDelete(path: string): { ok: boolean; message: string } {
try {
if (!existsSync(path)) return { ok: true, message: `not present (${path})` };
const st = statSync(path);
if (st.isDirectory()) {
rmSync(path, { recursive: true, force: true });
} else {
unlinkSync(path);
}
return { ok: true, message: `deleted ${path}` };
} catch (err) {
return {
ok: false,
message: `failed ${path}: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
async function runRemove(): Promise<void> {
p.intro("agentmemory remove");
const force = args.includes("--force");
const keepData = args.includes("--keep-data");
const home = homedir();
const connectManifest = loadConnectManifest(home);
const localBinIiiVersion = probeLocalBinIiiVersion(home);
const options: RemoveOptions = { force, keepData };
const plan = buildRemovePlan(
{
home,
pinnedVersion: IIPINNED_VERSION,
localBinIiiVersion,
connectManifest,
},
options,
);
const applicable = plan.filter((it) => it.applicable);
if (applicable.length === 0) {
p.outro("Nothing to remove. agentmemory is already gone.");
return;
}
p.note(formatPlan(plan), "destruction plan");
if (!force) {
const proceed = await p.confirm({
message: "Proceed with these deletions?",
initialValue: false,
});
if (p.isCancel(proceed) || proceed !== true) {
p.cancel("Cancelled. Nothing was deleted.");
return;
}
const sure = await p.confirm({
message: "This is irreversible. Continue?",
initialValue: false,
});
if (p.isCancel(sure) || sure !== true) {
p.cancel("Cancelled. Nothing was deleted.");
return;
}
}
for (const item of plan) {
if (!item.applicable) continue;
// alwaysAsk items get a per-item confirmation even with --force.
if (item.alwaysAsk) {
const ok = await p.confirm({
message: `${item.description} — really delete${item.path ? ` ${item.path}` : ""}?`,
initialValue: false,
});
if (p.isCancel(ok) || ok !== true) {
p.log.info(`skipped: ${item.id}`);
continue;
}
}
if (item.id === "stop-engine") {
try {
const port = getRestPort();
const portPids = findEnginePidsByPort(port);
const pidfilePid = readEnginePidfile();
const cands = new Set<number>();
if (pidfilePid) cands.add(pidfilePid);
for (const pid of portPids) cands.add(pid);
for (const pid of cands) await signalAndWait(pid, "SIGTERM", 3000);
clearEnginePidfile();
clearEngineState();
p.log.success(
cands.size > 0
? `stopped engine (${cands.size} pid${cands.size === 1 ? "" : "s"})`
: "no engine running",
);
} catch (err) {
p.log.warn(
`engine stop best-effort: ${err instanceof Error ? err.message : String(err)}`,
);
}
continue;
}
if (!item.path) continue;
const r = safeDelete(item.path);
if (r.ok) p.log.success(r.message);
else p.log.error(r.message);
}
p.outro(
"Done. agentmemory cleanly removed. The npm package itself: npm uninstall -g @agentmemory/agentmemory",
);
}
const commands: Record<string, () => Promise<void>> = {
init: runInit,
connect: runConnectCmd,
@@ -1818,6 +2274,7 @@ const commands: Record<string, () => Promise<void>> = {
demo: runDemo,
upgrade: runUpgrade,
stop: runStop,
remove: runRemove,
mcp: runMcp,
"import-jsonl": runImportJsonl,
};
+378
View File
@@ -0,0 +1,378 @@
// Doctor v2 diagnostic catalog.
//
// Each entry is a self-describing diagnostic: a check function that returns
// `{ ok, detail? }`, a human-readable message, an inline fix preview, and
// an `apply` function that runs the fix. The list is exported as a pure
// data structure so unit tests can assert on shape without bringing
// @clack/prompts into the test harness.
//
// The runtime (src/cli.ts -> runDoctor) iterates the list, prompts the user
// per check, and only re-runs the SAME diagnostic after a fix — never the
// whole suite. Each fix returns `{ ok, message? }` so we can show a one-line
// outcome before moving on.
//
// Doctor v2 surface:
// agentmemory doctor # interactive: Fix/Skip/More/Quit per failed check
// agentmemory doctor --all # apply every available fix without prompting (CI)
// agentmemory doctor --dry-run # show what each fix WOULD do; execute nothing
export type DiagnosticStatus = {
ok: boolean;
/** Short status detail (one line). Shown alongside the check name. */
detail?: string;
};
export type DiagnosticFixResult = {
ok: boolean;
message?: string;
};
export type DoctorContext = {
/** Base URL for the running engine, e.g. http://localhost:3111 */
baseUrl: string;
/** Viewer URL, e.g. http://localhost:3113 */
viewerUrl: string;
/** Path to ~/.agentmemory/.env */
envPath: string;
/** Path to ~/.agentmemory/iii.pid */
pidfilePath: string;
/** Path to ~/.agentmemory/engine-state.json */
enginePath: string;
/** Pinned engine version (e.g. "0.11.2"). */
pinnedVersion: string;
};
export type Diagnostic = {
/** Stable id. Used in --json and tests. */
id: string;
/** One-line problem statement shown to the user. */
message: string;
/** One-line description of WHAT the fix will do. Shown before the prompt. */
fixPreview: string;
/** Longer explanation shown when the user picks [?] More info. */
moreInfo: string;
/** Run the check; return ok=true if everything's fine, ok=false otherwise. */
check: (ctx: DoctorContext) => Promise<DiagnosticStatus>;
/** Apply the fix. Returns ok=true on success. */
fix: (ctx: DoctorContext) => Promise<DiagnosticFixResult>;
/** True when there's nothing to auto-fix (we only suggest). */
manualOnly?: boolean;
};
// Diagnostic ids are stable for testing and machine-readable doctor output.
export const DIAGNOSTIC_IDS = [
"env-missing",
"no-llm-provider-key",
"engine-version-mismatch",
"viewer-unreachable",
"stale-pidfile",
"env-placeholder-keys",
"iii-on-path-not-local-bin",
] as const;
export type DiagnosticId = (typeof DIAGNOSTIC_IDS)[number];
// Pure helpers (no I/O) — exported for direct unit testing.
// ---------------------------------------------------------------------------
/** Common placeholder values shipped in .env.example. */
const PLACEHOLDER_VALUES = new Set([
"",
"your-key-here",
"sk-ant-...",
"sk-...",
"changeme",
"todo",
"xxx",
]);
const PROVIDER_KEY_NAMES = [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OPENROUTER_API_KEY",
"MINIMAX_API_KEY",
] as const;
export function parseEnvFile(content: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
// Strip surrounding quotes.
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
/** Returns the list of provider keys that look real (non-placeholder). */
export function realProviderKeys(env: Record<string, string>): string[] {
return PROVIDER_KEY_NAMES.filter((k) => {
const v = (env[k] ?? "").trim();
if (!v) return false;
if (PLACEHOLDER_VALUES.has(v.toLowerCase())) return false;
// Reject values that are just dots/placeholders like "xxxx-xxxx".
if (/^x+$/i.test(v.replace(/[-_]/g, ""))) return false;
return true;
});
}
/** Returns the list of provider key NAMES that exist but are placeholders. */
export function placeholderProviderKeys(env: Record<string, string>): string[] {
return PROVIDER_KEY_NAMES.filter((k) => {
const v = (env[k] ?? "").trim();
if (!v) return false;
if (PLACEHOLDER_VALUES.has(v.toLowerCase())) return true;
if (/^x+$/i.test(v.replace(/[-_]/g, ""))) return true;
return false;
});
}
/**
* Build the canonical diagnostic catalog.
*
* The factory takes the side-effect helpers as injected functions so tests
* can swap them with stubs. Production callers pass real implementations
* from src/cli.ts.
*/
export type DoctorEffects = {
/** Does ~/.agentmemory/.env exist? */
envFileExists: () => boolean;
/** Read ~/.agentmemory/.env and return parsed key=value pairs. */
readEnvFile: () => Record<string, string>;
/** Is the iii engine PID in the pidfile still alive? */
pidfilePidIsAlive: () => boolean | null;
/** Does the pidfile exist on disk? */
pidfileExists: () => boolean;
/** Resolve the iii binary on PATH; return null if not found. */
findIiiBinary: () => string | null;
/** Path to ~/.local/bin/iii (the location we install to). */
localBinIiiPath: () => string;
/** Run `iii --version`; null if it fails. */
iiiBinaryVersion: (binPath: string) => string | null;
/** Probe the viewer URL; true if it returns OK within timeoutMs. */
viewerReachable: (timeoutMs?: number) => Promise<boolean>;
/** Run init logic (copies .env.example). */
runInit: () => Promise<DiagnosticFixResult>;
/** Open a file in $EDITOR (or fallback). Resolves when editor exits. */
openEditor: (path: string) => Promise<DiagnosticFixResult>;
/** Run the iii installer. */
runIiiInstaller: () => Promise<DiagnosticFixResult>;
/** Stop the running engine cleanly. */
runStop: () => Promise<DiagnosticFixResult>;
/** Start the engine (waits for /livez). */
runStart: () => Promise<DiagnosticFixResult>;
/** Clear pidfile + engine-state. */
clearEnginePidAndState: () => void;
};
export function buildDiagnostics(effects: DoctorEffects): Diagnostic[] {
return [
{
id: "env-missing",
message: "~/.agentmemory/.env is missing.",
fixPreview: "Copy .env.example into ~/.agentmemory/.env (your keys file).",
moreInfo:
"agentmemory reads provider API keys (Anthropic, OpenAI, Gemini, …) from ~/.agentmemory/.env. " +
"Without this file the daemon falls back to BM25-only search and no LLM-backed enrichment runs.",
check: async () => ({
ok: effects.envFileExists(),
detail: effects.envFileExists() ? undefined : "no env file",
}),
fix: () => effects.runInit(),
},
{
id: "no-llm-provider-key",
message: "No LLM provider API key found in ~/.agentmemory/.env.",
fixPreview: "Open ~/.agentmemory/.env in $EDITOR and paste your key, then re-check.",
moreInfo:
"Set at least one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, " +
"OPENROUTER_API_KEY, MINIMAX_API_KEY. The daemon picks the first that resolves " +
"to a real (non-placeholder) value at startup.",
check: async () => {
if (!effects.envFileExists()) {
return { ok: false, detail: "env file missing (run env-missing fix first)" };
}
const env = effects.readEnvFile();
const real = realProviderKeys(env);
return {
ok: real.length > 0,
detail: real.length > 0 ? `found: ${real.join(", ")}` : "no provider key set",
};
},
fix: (ctx) => effects.openEditor(ctx.envPath),
},
{
id: "engine-version-mismatch",
message: "iii binary on PATH doesn't match the version agentmemory pins to.",
fixPreview:
"Re-run the iii installer for the pinned version and restart the engine.",
moreInfo:
"agentmemory pins the iii engine to a specific release because newer engines " +
"use a different worker model. Running a mismatched binary surfaces as EPIPE " +
"reconnect loops and empty search results.",
check: async (ctx) => {
const bin = effects.findIiiBinary();
if (!bin) return { ok: false, detail: "iii not on PATH" };
const v = effects.iiiBinaryVersion(bin);
if (!v) return { ok: false, detail: "iii on PATH but --version failed" };
return {
ok: v === ctx.pinnedVersion,
detail: `${v} (pinned ${ctx.pinnedVersion})`,
};
},
fix: async () => {
const r = await effects.runIiiInstaller();
if (!r.ok) return r;
// Best-effort restart: stop then start.
await effects.runStop();
return effects.runStart();
},
},
{
id: "viewer-unreachable",
message: "Viewer port not reachable.",
fixPreview: "Stop the engine, restart it, and retry the viewer probe.",
moreInfo:
"The viewer is served on REST port + 2 (default 3113). If it never came up " +
"the most common cause is port collision; a sibling PR ships auto-bump for " +
"this case. If that lands first this check just verifies; otherwise restart " +
"the engine to retry binding.",
check: async () => ({
ok: await effects.viewerReachable(),
detail: undefined,
}),
fix: async () => {
const stopped = await effects.runStop();
if (!stopped.ok) return stopped;
return effects.runStart();
},
},
{
id: "stale-pidfile",
message: "Stale pidfile: pid recorded but the process is gone.",
fixPreview: "Clear ~/.agentmemory/iii.pid + engine-state.json, then restart.",
moreInfo:
"When the engine crashes hard (kill -9, OOM, host reboot) the pidfile sticks " +
"around. agentmemory refuses to start a second engine on top of a stale pid, " +
"so this state must be cleared explicitly.",
check: async () => {
if (!effects.pidfileExists()) return { ok: true, detail: "no pidfile" };
const alive = effects.pidfilePidIsAlive();
if (alive === null) return { ok: true, detail: "pidfile unreadable" };
return {
ok: alive,
detail: alive ? "pid is alive" : "pid is gone",
};
},
fix: async () => {
effects.clearEnginePidAndState();
return effects.runStart();
},
},
{
id: "env-placeholder-keys",
message: "~/.agentmemory/.env contains placeholder/empty API keys.",
fixPreview: "Open ~/.agentmemory/.env in $EDITOR to paste real values.",
moreInfo:
"Lines like ANTHROPIC_API_KEY=sk-ant-... or =your-key-here are treated as " +
"absent. The daemon will fall back to BM25-only search. Replace placeholders " +
"with real keys or comment the line out.",
check: async () => {
if (!effects.envFileExists()) {
return { ok: true, detail: "env file missing (handled by env-missing)" };
}
const env = effects.readEnvFile();
const placeholders = placeholderProviderKeys(env);
return {
ok: placeholders.length === 0,
detail:
placeholders.length === 0
? undefined
: `placeholder: ${placeholders.join(", ")}`,
};
},
fix: (ctx) => effects.openEditor(ctx.envPath),
},
{
id: "iii-on-path-not-local-bin",
message:
"iii is on PATH but not in ~/.local/bin/iii (where we install).",
fixPreview:
"Suggest re-installing the pinned version via the installer — won't touch your PATH.",
moreInfo:
"agentmemory's installer writes to ~/.local/bin/iii. When a user-managed iii " +
"lives somewhere else (homebrew, cargo, $XDG_BIN) we don't auto-overwrite it. " +
"If you want our pinned build, run the installer; otherwise this is informational.",
manualOnly: true,
check: async () => {
const bin = effects.findIiiBinary();
if (!bin) return { ok: true, detail: "iii not on PATH (handled elsewhere)" };
const localBin = effects.localBinIiiPath();
return {
ok: bin === localBin,
detail: bin === localBin ? undefined : `iii at: ${bin}`,
};
},
fix: async () =>
effects.runIiiInstaller().then((r) => ({
ok: r.ok,
message:
r.message ??
"Installer wrote to ~/.local/bin/iii. Your PATH wasn't modified — adjust it yourself if needed.",
})),
},
];
}
export type DoctorRunMode = "interactive" | "all" | "dry-run";
/**
* Run all diagnostics and return their initial status (no fixes applied).
* Useful for tests and for `--dry-run` mode.
*/
export async function runAllChecks(
ctx: DoctorContext,
diagnostics: Diagnostic[],
): Promise<Array<{ diagnostic: Diagnostic; status: DiagnosticStatus }>> {
const results: Array<{ diagnostic: Diagnostic; status: DiagnosticStatus }> = [];
for (const d of diagnostics) {
const status = await d.check(ctx);
results.push({ diagnostic: d, status });
}
return results;
}
/**
* Dry-run output: each failing check's fix preview, prefixed by the diagnostic
* message. Pure function so we can snapshot-test the format.
*/
export function dryRunPlan(
ctx: DoctorContext,
results: Array<{ diagnostic: Diagnostic; status: DiagnosticStatus }>,
): string[] {
const lines: string[] = [];
let n = 0;
for (const { diagnostic, status } of results) {
if (status.ok) continue;
n++;
lines.push(`${n}. [${diagnostic.id}] ${diagnostic.message}`);
lines.push(` would fix: ${diagnostic.fixPreview}`);
if (status.detail) lines.push(` detail: ${status.detail}`);
}
if (lines.length === 0) {
lines.push(`All checks passing for ${ctx.baseUrl} — no fixes to run.`);
}
return lines;
}
+248
View File
@@ -0,0 +1,248 @@
// `agentmemory remove` — destruction plan.
//
// Generating the plan is a pure function of the on-disk state (which files
// exist, whether ~/.local/bin/iii matches the version we installed, the
// connect-manifest contents). All side effects live in src/cli.ts; this
// module owns only the planning logic so it's unit-testable without
// touching $HOME.
//
// CLI surface:
// agentmemory remove # interactive, double-confirms
// agentmemory remove --force # skip confirmations
// agentmemory remove --keep-data # remove binaries+symlinks, keep memory data
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
export type RemovePlanItem = {
/** Stable id, used in tests and CLI output. */
id: string;
/** Human-readable description of the action. */
description: string;
/** Absolute path being acted on (or null for non-fs actions). */
path: string | null;
/** Whether this item is `ask-again` even with --force (e.g. memory data). */
alwaysAsk: boolean;
/** Whether the file actually exists / action is meaningful. Plan-time hint. */
applicable: boolean;
/** Bytes (for files) or -1 (unknown / dir). Pure metadata. */
sizeBytes: number;
};
export type RemoveOptions = {
/** Skip confirmations (still asks separately about always-ask items). */
force: boolean;
/** Keep ~/.agentmemory/* user data; only remove binaries/symlinks. */
keepData: boolean;
};
export type RemoveContext = {
/** $HOME (so tests can sandbox). */
home: string;
/** Pinned engine version we expect ~/.local/bin/iii to match. */
pinnedVersion: string;
/**
* `iii --version` result for ~/.local/bin/iii, or null if it's missing /
* unreadable / not executable. Passed in so the plan module stays pure.
*/
localBinIiiVersion: string | null;
/** Loaded connect manifest, or null if missing. */
connectManifest: ConnectManifest | null;
};
/**
* The `agentmemory connect` PR writes this manifest at
* ~/.agentmemory/backups/connect-manifest.json. We tolerate it being absent
* (older versions, fresh installs) by treating it as `{ installed: [] }`.
*/
export type ConnectManifest = {
installed: Array<{
/** Target path the connect command wrote (symlink or file). */
target: string;
/** Agent label, e.g. "claude-code", "cursor". */
agent?: string;
/** Whether this was a symlink (true) or copy (false). */
symlink?: boolean;
}>;
};
export function pidfilePath(home: string): string {
return join(home, ".agentmemory", "iii.pid");
}
export function enginePath(home: string): string {
return join(home, ".agentmemory", "engine-state.json");
}
export function envPath(home: string): string {
return join(home, ".agentmemory", ".env");
}
export function preferencesPath(home: string): string {
return join(home, ".agentmemory", "preferences.json");
}
export function backupsDir(home: string): string {
return join(home, ".agentmemory", "backups");
}
export function dataDir(home: string): string {
return join(home, ".agentmemory", "data");
}
export function localBinIii(home: string): string {
return join(home, ".local", "bin", "iii");
}
function safeSize(path: string): number {
try {
return statSync(path).size;
} catch {
return -1;
}
}
function pathExists(path: string): boolean {
try {
return existsSync(path);
} catch {
return false;
}
}
/**
* Build the destruction plan for `agentmemory remove`.
*
* Plan items are returned regardless of whether `applicable` is true — the
* caller can decide whether to skip-and-log or hide entirely. This keeps
* the structure stable for tests.
*/
export function buildRemovePlan(
ctx: RemoveContext,
options: RemoveOptions,
): RemovePlanItem[] {
const { home, pinnedVersion, localBinIiiVersion, connectManifest } = ctx;
const plan: RemovePlanItem[] = [];
plan.push({
id: "stop-engine",
description: "Stop running iii-engine (if any) cleanly",
path: null,
alwaysAsk: false,
applicable: pathExists(pidfilePath(home)) || pathExists(enginePath(home)),
sizeBytes: -1,
});
plan.push({
id: "pidfile",
description: "Delete pidfile",
path: pidfilePath(home),
alwaysAsk: false,
applicable: pathExists(pidfilePath(home)),
sizeBytes: safeSize(pidfilePath(home)),
});
plan.push({
id: "engine-state",
description: "Delete engine-state.json",
path: enginePath(home),
alwaysAsk: false,
applicable: pathExists(enginePath(home)),
sizeBytes: safeSize(enginePath(home)),
});
// .env holds the user's API keys. Always ask before deleting, even on
// --force. --keep-data keeps it as part of "user data".
plan.push({
id: "env",
description: "Delete .env (your API keys) — will ask separately",
path: envPath(home),
alwaysAsk: true,
applicable: !options.keepData && pathExists(envPath(home)),
sizeBytes: safeSize(envPath(home)),
});
plan.push({
id: "preferences",
description: "Delete preferences.json",
path: preferencesPath(home),
alwaysAsk: false,
applicable: !options.keepData && pathExists(preferencesPath(home)),
sizeBytes: safeSize(preferencesPath(home)),
});
plan.push({
id: "backups",
description: "Delete backups/ directory (connect manifest + backups)",
path: backupsDir(home),
alwaysAsk: false,
applicable: !options.keepData && pathExists(backupsDir(home)),
sizeBytes: -1,
});
// Iterate over connect-installed agent symlinks. We always honor these
// (even with --keep-data, since they're outside ~/.agentmemory/).
if (connectManifest?.installed?.length) {
for (const entry of connectManifest.installed) {
plan.push({
id: `connect:${entry.target}`,
description: `Remove agent connection (${entry.agent ?? "unknown"})`,
path: entry.target,
alwaysAsk: false,
applicable: pathExists(entry.target),
sizeBytes: safeSize(entry.target),
});
}
}
// ~/.local/bin/iii — only remove if it matches the version we installed.
// Heuristic: spawn `iii --version`; if it returns pinnedVersion, safe to
// remove. Otherwise mark `alwaysAsk` so the operator confirms explicitly.
const localIii = localBinIii(home);
if (pathExists(localIii)) {
const matches = localBinIiiVersion === pinnedVersion;
plan.push({
id: "local-bin-iii",
description: matches
? `Delete ~/.local/bin/iii (matches pinned v${pinnedVersion})`
: `Delete ~/.local/bin/iii (version ${localBinIiiVersion ?? "unknown"} != pinned v${pinnedVersion}) — will ask`,
path: localIii,
alwaysAsk: !matches,
applicable: true,
sizeBytes: safeSize(localIii),
});
}
// Memory data dir — ALWAYS asks separately, even with --force. Default
// behavior is keep.
plan.push({
id: "data-dir",
description:
"Delete memory data directory (~/.agentmemory/data/) — will ask separately",
path: dataDir(home),
alwaysAsk: true,
applicable: !options.keepData && pathExists(dataDir(home)),
sizeBytes: -1,
});
return plan;
}
/** Format a plan for the user — one line per item. */
export function formatPlan(plan: RemovePlanItem[]): string {
return plan
.filter((p) => p.applicable)
.map((p, i) => {
const tag = p.alwaysAsk ? " [asks]" : "";
const sz =
p.sizeBytes > 0 ? ` (${humanBytes(p.sizeBytes)})` : "";
return ` ${i + 1}. ${p.description}${tag}${sz}${p.path ? `\n ${p.path}` : ""}`;
})
.join("\n");
}
function humanBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
+239
View File
@@ -0,0 +1,239 @@
// Unit tests for the doctor v2 diagnostic catalog.
//
// We exercise the data structure (every entry has check/fix/message),
// the pure parseEnvFile / realProviderKeys helpers, and the dry-run plan
// formatting. The full interactive prompt loop lives in src/cli.ts and is
// driven by clack — exercising it would require a TTY and is out of scope.
import { describe, it, expect } from "vitest";
import {
buildDiagnostics,
DIAGNOSTIC_IDS,
dryRunPlan,
parseEnvFile,
placeholderProviderKeys,
realProviderKeys,
type DoctorContext,
type DoctorEffects,
} from "../src/cli/doctor-diagnostics.js";
function stubCtx(overrides: Partial<DoctorContext> = {}): DoctorContext {
return {
baseUrl: "http://localhost:3111",
viewerUrl: "http://localhost:3113",
envPath: "/tmp/test/.agentmemory/.env",
pidfilePath: "/tmp/test/.agentmemory/iii.pid",
enginePath: "/tmp/test/.agentmemory/engine-state.json",
pinnedVersion: "0.11.2",
...overrides,
};
}
function stubEffects(overrides: Partial<DoctorEffects> = {}): DoctorEffects {
return {
envFileExists: () => true,
readEnvFile: () => ({ ANTHROPIC_API_KEY: "sk-ant-real-key-value" }),
pidfileExists: () => false,
pidfilePidIsAlive: () => null,
findIiiBinary: () => "/Users/test/.local/bin/iii",
localBinIiiPath: () => "/Users/test/.local/bin/iii",
iiiBinaryVersion: () => "0.11.2",
viewerReachable: async () => true,
runInit: async () => ({ ok: true, message: "wrote .env" }),
openEditor: async () => ({ ok: true, message: "saved" }),
runIiiInstaller: async () => ({ ok: true, message: "installed" }),
runStop: async () => ({ ok: true, message: "stopped" }),
runStart: async () => ({ ok: true, message: "started" }),
clearEnginePidAndState: () => {},
...overrides,
};
}
describe("doctor v2 diagnostic catalog", () => {
it("exports a stable list of diagnostic ids", () => {
expect(DIAGNOSTIC_IDS).toContain("env-missing");
expect(DIAGNOSTIC_IDS).toContain("no-llm-provider-key");
expect(DIAGNOSTIC_IDS).toContain("engine-version-mismatch");
expect(DIAGNOSTIC_IDS).toContain("viewer-unreachable");
expect(DIAGNOSTIC_IDS).toContain("stale-pidfile");
expect(DIAGNOSTIC_IDS).toContain("env-placeholder-keys");
expect(DIAGNOSTIC_IDS).toContain("iii-on-path-not-local-bin");
});
it("every diagnostic has check, fix, message, and fixPreview", () => {
const diagnostics = buildDiagnostics(stubEffects());
expect(diagnostics.length).toBe(DIAGNOSTIC_IDS.length);
for (const d of diagnostics) {
expect(d.id).toMatch(/^[a-z][a-z0-9-]+$/);
expect(d.message.length).toBeGreaterThan(0);
expect(d.fixPreview.length).toBeGreaterThan(0);
expect(d.moreInfo.length).toBeGreaterThan(0);
expect(typeof d.check).toBe("function");
expect(typeof d.fix).toBe("function");
}
});
it("diagnostic ids are unique", () => {
const diagnostics = buildDiagnostics(stubEffects());
const ids = diagnostics.map((d) => d.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("env-missing fails when env file is absent", async () => {
const diagnostics = buildDiagnostics(stubEffects({ envFileExists: () => false }));
const envCheck = diagnostics.find((d) => d.id === "env-missing")!;
const status = await envCheck.check(stubCtx());
expect(status.ok).toBe(false);
});
it("env-missing passes when env file exists", async () => {
const diagnostics = buildDiagnostics(stubEffects({ envFileExists: () => true }));
const envCheck = diagnostics.find((d) => d.id === "env-missing")!;
const status = await envCheck.check(stubCtx());
expect(status.ok).toBe(true);
});
it("no-llm-provider-key fails when env has only placeholders", async () => {
const diagnostics = buildDiagnostics(
stubEffects({
envFileExists: () => true,
readEnvFile: () => ({ ANTHROPIC_API_KEY: "your-key-here" }),
}),
);
const check = diagnostics.find((d) => d.id === "no-llm-provider-key")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
});
it("no-llm-provider-key passes when one real key is set", async () => {
const diagnostics = buildDiagnostics(stubEffects());
const check = diagnostics.find((d) => d.id === "no-llm-provider-key")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(true);
});
it("engine-version-mismatch fails when iii reports the wrong version", async () => {
const diagnostics = buildDiagnostics(
stubEffects({ iiiBinaryVersion: () => "0.99.99" }),
);
const check = diagnostics.find((d) => d.id === "engine-version-mismatch")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(status.detail).toContain("0.99.99");
expect(status.detail).toContain("0.11.2");
});
it("engine-version-mismatch passes when iii matches pinned version", async () => {
const diagnostics = buildDiagnostics(stubEffects());
const check = diagnostics.find((d) => d.id === "engine-version-mismatch")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(true);
});
it("viewer-unreachable fails when viewer probe returns false", async () => {
const diagnostics = buildDiagnostics(
stubEffects({ viewerReachable: async () => false }),
);
const check = diagnostics.find((d) => d.id === "viewer-unreachable")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
});
it("stale-pidfile passes when no pidfile exists", async () => {
const diagnostics = buildDiagnostics(stubEffects({ pidfileExists: () => false }));
const check = diagnostics.find((d) => d.id === "stale-pidfile")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(true);
});
it("stale-pidfile fails when pidfile points at a dead pid", async () => {
const diagnostics = buildDiagnostics(
stubEffects({ pidfileExists: () => true, pidfilePidIsAlive: () => false }),
);
const check = diagnostics.find((d) => d.id === "stale-pidfile")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(status.detail).toBe("pid is gone");
});
it("env-placeholder-keys detects sk-ant-... placeholder", async () => {
const diagnostics = buildDiagnostics(
stubEffects({
envFileExists: () => true,
readEnvFile: () => ({ ANTHROPIC_API_KEY: "sk-ant-..." }),
}),
);
const check = diagnostics.find((d) => d.id === "env-placeholder-keys")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(status.detail).toContain("ANTHROPIC_API_KEY");
});
it("iii-on-path-not-local-bin warns when iii lives in another location", async () => {
const diagnostics = buildDiagnostics(
stubEffects({
findIiiBinary: () => "/opt/homebrew/bin/iii",
localBinIiiPath: () => "/Users/test/.local/bin/iii",
}),
);
const check = diagnostics.find((d) => d.id === "iii-on-path-not-local-bin")!;
const status = await check.check(stubCtx());
expect(status.ok).toBe(false);
expect(check.manualOnly).toBe(true);
});
it("dryRunPlan lists each failing diagnostic with the fix preview", () => {
const diagnostics = buildDiagnostics(stubEffects());
const results = diagnostics.map((d) => ({
diagnostic: d,
status: { ok: false, detail: "stub fail" },
}));
const lines = dryRunPlan(stubCtx(), results);
expect(lines.some((l) => l.includes("env-missing"))).toBe(true);
expect(lines.some((l) => l.includes("would fix:"))).toBe(true);
});
it("dryRunPlan reports all-passing state", () => {
const diagnostics = buildDiagnostics(stubEffects());
const results = diagnostics.map((d) => ({
diagnostic: d,
status: { ok: true },
}));
const lines = dryRunPlan(stubCtx(), results);
expect(lines.length).toBe(1);
expect(lines[0]).toContain("All checks passing");
});
});
describe("parseEnvFile", () => {
it("strips comments and blank lines", () => {
const env = parseEnvFile("# a comment\n\nFOO=bar\nBAZ=qux\n");
expect(env).toEqual({ FOO: "bar", BAZ: "qux" });
});
it("strips surrounding quotes", () => {
const env = parseEnvFile(`A="hello"\nB='world'\nC=plain\n`);
expect(env).toEqual({ A: "hello", B: "world", C: "plain" });
});
});
describe("realProviderKeys / placeholderProviderKeys", () => {
it("returns real keys only", () => {
const env = {
ANTHROPIC_API_KEY: "sk-ant-real-value",
OPENAI_API_KEY: "sk-...",
GEMINI_API_KEY: "",
OPENROUTER_API_KEY: "your-key-here",
};
expect(realProviderKeys(env)).toEqual(["ANTHROPIC_API_KEY"]);
expect(placeholderProviderKeys(env)).toContain("OPENAI_API_KEY");
expect(placeholderProviderKeys(env)).toContain("OPENROUTER_API_KEY");
expect(placeholderProviderKeys(env)).not.toContain("GEMINI_API_KEY");
});
it("treats xxx-style placeholders as fake", () => {
expect(placeholderProviderKeys({ ANTHROPIC_API_KEY: "xxxx-xxxx" })).toEqual([
"ANTHROPIC_API_KEY",
]);
});
});
+158
View File
@@ -0,0 +1,158 @@
// Unit tests for the `agentmemory remove` destruction plan.
//
// The plan module is pure-fs (just inspects what's present) so we sandbox
// a fake $HOME under tmpdir() and assert which plan items come back. The
// actual file deletion is wrapped in src/cli.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
buildRemovePlan,
formatPlan,
type ConnectManifest,
type RemoveContext,
} from "../src/cli/remove-plan.js";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
let sandbox: string;
function ctx(overrides: Partial<RemoveContext> = {}): RemoveContext {
return {
home: sandbox,
pinnedVersion: "0.11.2",
localBinIiiVersion: null,
connectManifest: null,
...overrides,
};
}
function touch(relPath: string, content = ""): void {
const full = join(sandbox, relPath);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, content);
}
function mkdir(relPath: string): void {
mkdirSync(join(sandbox, relPath), { recursive: true });
}
beforeEach(() => {
sandbox = mkdtempSync(join(tmpdir(), "agentmemory-remove-"));
});
afterEach(() => {
rmSync(sandbox, { recursive: true, force: true });
});
describe("buildRemovePlan", () => {
it("returns no applicable items on a clean system", () => {
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const applicable = plan.filter((p) => p.applicable);
expect(applicable.length).toBe(0);
});
it("includes pidfile + engine-state when both exist", () => {
touch(".agentmemory/iii.pid", "12345\n");
touch(".agentmemory/engine-state.json", "{}");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const ids = plan.filter((p) => p.applicable).map((p) => p.id);
expect(ids).toContain("stop-engine");
expect(ids).toContain("pidfile");
expect(ids).toContain("engine-state");
});
it("marks .env as alwaysAsk", () => {
touch(".agentmemory/.env", "ANTHROPIC_API_KEY=sk-ant-real\n");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const envItem = plan.find((p) => p.id === "env")!;
expect(envItem.applicable).toBe(true);
expect(envItem.alwaysAsk).toBe(true);
});
it("--keep-data hides .env, preferences, backups, and data-dir", () => {
touch(".agentmemory/.env", "x");
touch(".agentmemory/preferences.json", "{}");
mkdir(".agentmemory/backups");
mkdir(".agentmemory/data");
const plan = buildRemovePlan(ctx(), { force: false, keepData: true });
const applicable = plan.filter((p) => p.applicable).map((p) => p.id);
expect(applicable).not.toContain("env");
expect(applicable).not.toContain("preferences");
expect(applicable).not.toContain("backups");
expect(applicable).not.toContain("data-dir");
});
it("data-dir is alwaysAsk even on --force", () => {
mkdir(".agentmemory/data");
const plan = buildRemovePlan(ctx(), { force: true, keepData: false });
const item = plan.find((p) => p.id === "data-dir")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(true);
});
it("expands connect-manifest entries into individual plan items", () => {
const manifest: ConnectManifest = {
installed: [
{ target: join(sandbox, "fake-claude-symlink"), agent: "claude-code", symlink: true },
{ target: join(sandbox, "fake-cursor-link"), agent: "cursor" },
],
};
touch("fake-claude-symlink");
touch("fake-cursor-link");
const plan = buildRemovePlan(ctx({ connectManifest: manifest }), {
force: false,
keepData: false,
});
const connectItems = plan.filter((p) => p.id.startsWith("connect:"));
expect(connectItems.length).toBe(2);
expect(connectItems.every((p) => p.applicable)).toBe(true);
});
it("local-bin/iii is alwaysAsk when version does not match", () => {
touch(".local/bin/iii", "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "9.9.9" }),
{ force: false, keepData: false },
);
const item = plan.find((p) => p.id === "local-bin-iii")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(true);
});
it("local-bin/iii is auto-fixable when version matches pinned", () => {
touch(".local/bin/iii", "fakebin");
const plan = buildRemovePlan(
ctx({ localBinIiiVersion: "0.11.2" }),
{ force: false, keepData: false },
);
const item = plan.find((p) => p.id === "local-bin-iii")!;
expect(item.applicable).toBe(true);
expect(item.alwaysAsk).toBe(false);
expect(item.description).toContain("matches pinned");
});
it("local-bin/iii absent: no plan entry created", () => {
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
expect(plan.find((p) => p.id === "local-bin-iii")).toBeUndefined();
});
});
describe("formatPlan", () => {
it("renders applicable items with numbers", () => {
touch(".agentmemory/iii.pid", "1");
touch(".agentmemory/engine-state.json", "{}");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const out = formatPlan(plan);
expect(out).toMatch(/^\s+1\./m);
expect(out).toContain("pidfile");
expect(out).toContain("engine-state.json");
});
it("marks alwaysAsk items with [asks]", () => {
touch(".agentmemory/.env", "x");
const plan = buildRemovePlan(ctx(), { force: false, keepData: false });
const out = formatPlan(plan);
expect(out).toContain("[asks]");
});
});