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:
+491
-34
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user