@jefuriiij/synthra 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.14.0] — 2026-07-02
11
+
12
+ ### Added
13
+
14
+ - **`syn remove [path]` — cleanly uninstall Synthra from a project.** Ran `syn .`
15
+ in the wrong folder, or just want Synthra out of a repo? `syn remove` reverses
16
+ the bootstrap: deletes `.synthra-graph/` and `.synthra/`, strips the policy
17
+ block from `CLAUDE.md`, Synthra's entries from `.gitignore`, its hooks from
18
+ `.claude/` — **your own content in those files survives**; a file is deleted
19
+ only when nothing else remains. Also deregisters the MCP entry (with a direct
20
+ `.mcp.json` fallback when the `claude` CLI isn't available) and removes the
21
+ project from the dashboard registry. Shows a summary and asks `[y/N]` first;
22
+ pass `--yes` to skip (required when not running in a terminal).
23
+
24
+ ---
25
+
10
26
  ## [0.13.1] — 2026-06-24
11
27
 
12
28
  ### Fixed
package/README.md CHANGED
@@ -108,6 +108,10 @@ syn . --resume <id> # Resume a Claude session (requires --launch-cli).
108
108
  syn scan [path] # Scan only — walk + parse + write graph.
109
109
  syn serve [path] # Start the MCP server only.
110
110
  syn dashboard [path] # Run only the token dashboard (standalone process).
111
+ syn doctor [path] # Diagnose this project's Synthra setup + environment.
112
+ syn remove [path] # Uninstall Synthra from a project (accidental `syn .`?
113
+ # This reverses it). Asks [y/N]; --yes to skip. Your own
114
+ # gitignore lines / CLAUDE.md content / hooks survive.
111
115
  ```
112
116
 
113
117
  ---
package/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.13.1",
21
+ version: "0.14.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -109,7 +109,7 @@ var init_package = __esm({
109
109
  // src/cli/index.ts
110
110
  init_package();
111
111
  import sade from "sade";
112
- import { resolve as resolve5 } from "path";
112
+ import { resolve as resolve6 } from "path";
113
113
 
114
114
  // src/dashboard/server.ts
115
115
  init_package();
@@ -151,10 +151,10 @@ async function findFreePort(start = PORT_RANGE_START, end = PORT_RANGE_END) {
151
151
  throw new Error(`Synthra: no free port in ${start}-${end}`);
152
152
  }
153
153
  function isFree(port) {
154
- return new Promise((resolve6) => {
154
+ return new Promise((resolve7) => {
155
155
  const s = createServer();
156
- s.once("error", () => resolve6(false));
157
- s.once("listening", () => s.close(() => resolve6(true)));
156
+ s.once("error", () => resolve7(false));
157
+ s.once("listening", () => s.close(() => resolve7(true)));
158
158
  s.listen(port, "127.0.0.1");
159
159
  });
160
160
  }
@@ -436,8 +436,8 @@ function foldEvent(store, ev) {
436
436
  function effectiveScores(store, nowMs) {
437
437
  const hl = halfLifeMs();
438
438
  const out = /* @__PURE__ */ new Map();
439
- for (const [path, stat6] of Object.entries(store.files)) {
440
- const eff = stat6.decayed * decayFactor(stat6.lastTs, nowMs, hl);
439
+ for (const [path, stat7] of Object.entries(store.files)) {
440
+ const eff = stat7.decayed * decayFactor(stat7.lastTs, nowMs, hl);
441
441
  if (eff > 0.01) out.set(path, eff);
442
442
  }
443
443
  return out;
@@ -607,6 +607,23 @@ async function recordProject(projectRoot) {
607
607
  } catch {
608
608
  }
609
609
  }
610
+ async function forgetProject(projectRoot, registryPath = REGISTRY_PATH) {
611
+ try {
612
+ const raw = await readFile3(registryPath, "utf8");
613
+ const parsed = JSON.parse(raw);
614
+ const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
615
+ const filtered = projects.filter((p) => p.path !== projectRoot);
616
+ if (filtered.length === projects.length) return false;
617
+ const next = {
618
+ schema_version: parsed.schema_version ?? SCHEMA_VERSION,
619
+ projects: filtered
620
+ };
621
+ await writeFile2(registryPath, JSON.stringify(next, null, 2) + "\n", "utf8");
622
+ return true;
623
+ } catch {
624
+ return false;
625
+ }
626
+ }
610
627
  async function listProjects() {
611
628
  const registry = await readRegistry();
612
629
  return registry.projects.slice().sort((a, b) => a.last_seen > b.last_seen ? -1 : a.last_seen < b.last_seen ? 1 : 0);
@@ -894,8 +911,8 @@ async function startDashboard(paths, preferredPort = 8901) {
894
911
  port,
895
912
  url: `http://127.0.0.1:${port}`,
896
913
  async stop() {
897
- await new Promise((resolve6, reject) => {
898
- nodeServer.close((err2) => err2 ? reject(err2) : resolve6());
914
+ await new Promise((resolve7, reject) => {
915
+ nodeServer.close((err2) => err2 ? reject(err2) : resolve7());
899
916
  });
900
917
  }
901
918
  };
@@ -905,6 +922,22 @@ async function startDashboard(paths, preferredPort = 8901) {
905
922
  import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
906
923
  import { dirname as dirname4, join as join4 } from "path";
907
924
 
925
+ // src/hooks/hooks-config.ts
926
+ var SYNTHRA_HOOK_MARKER = "synthra-hook=true";
927
+ function stripOurHooks(config) {
928
+ if (!config.hooks) return config;
929
+ const next = {};
930
+ for (const [event, entries] of Object.entries(config.hooks)) {
931
+ const filtered = entries.map((entry) => ({
932
+ ...entry,
933
+ hooks: (entry.hooks ?? []).filter((h) => h.meta !== SYNTHRA_HOOK_MARKER)
934
+ })).filter((entry) => (entry.hooks?.length ?? 0) > 0);
935
+ if (filtered.length) next[event] = filtered;
936
+ }
937
+ config.hooks = next;
938
+ return config;
939
+ }
940
+
908
941
  // src/hooks/scripts/pre-compact.ps1
909
942
  var pre_compact_default = '# PreCompact hook \u2014 Windows PowerShell.\n# Re-injects the primer after Claude auto-compacts. Same logic as prime.ps1.\n\n$ErrorActionPreference = "SilentlyContinue"\n\n$portFile = Join-Path $PWD ".synthra-graph\\mcp_port"\nif (-not (Test-Path $portFile)) { exit 0 }\n$port = (Get-Content -Path $portFile -Raw).Trim()\nif (-not $port) { exit 0 }\n\ntry {\n $resp = Invoke-RestMethod -Uri "http://127.0.0.1:$port/prime" -Method GET -TimeoutSec 3\n if ($resp.primer) { Write-Output $resp.primer }\n} catch {\n # silent\n}\nexit 0\n';
910
943
 
@@ -1195,7 +1228,6 @@ var SCRIPTS = [
1195
1228
  { event: "PreCompact", baseName: "synthra-pre-compact", ps1: pre_compact_default, sh: pre_compact_default2 },
1196
1229
  { event: "Stop", baseName: "synthra-stop", ps1: stop_default, sh: stop_default2 }
1197
1230
  ];
1198
- var SYNTHRA_HOOK_MARKER = "synthra-hook=true";
1199
1231
  function commandFor(scriptPath) {
1200
1232
  if (process.platform === "win32") {
1201
1233
  return `powershell.exe -ExecutionPolicy Bypass -NoProfile -File "${scriptPath}"`;
@@ -1216,19 +1248,6 @@ async function readSettings(path) {
1216
1248
  return {};
1217
1249
  }
1218
1250
  }
1219
- function stripOurHooks(config) {
1220
- if (!config.hooks) return config;
1221
- const next = {};
1222
- for (const [event, entries] of Object.entries(config.hooks)) {
1223
- const filtered = entries.map((entry) => ({
1224
- ...entry,
1225
- hooks: (entry.hooks ?? []).filter((h) => h.meta !== SYNTHRA_HOOK_MARKER)
1226
- })).filter((entry) => (entry.hooks?.length ?? 0) > 0);
1227
- if (filtered.length) next[event] = filtered;
1228
- }
1229
- config.hooks = next;
1230
- return config;
1231
- }
1232
1251
  function mergeOurHooks(config, paths) {
1233
1252
  const hooks = config.hooks = config.hooks ?? {};
1234
1253
  for (const s of SCRIPTS) {
@@ -3031,6 +3050,9 @@ var POLICY_VERSION = 8;
3031
3050
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3032
3051
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3033
3052
  var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
3053
+ function stripPolicyBlock(content) {
3054
+ return content.replace(ANY_BLOCK_RE, "");
3055
+ }
3034
3056
  function policyBlock() {
3035
3057
  return [
3036
3058
  POLICY_BEGIN,
@@ -4738,11 +4760,11 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
4738
4760
  callerIds.push(e.from);
4739
4761
  }
4740
4762
  }
4741
- const resolve6 = (ids) => ids.map((id) => symById.get(id)).filter((n) => !!n);
4742
- const callees = resolve6(calleeIds).sort(
4763
+ const resolve7 = (ids) => ids.map((id) => symById.get(id)).filter((n) => !!n);
4764
+ const callees = resolve7(calleeIds).sort(
4743
4765
  (a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1
4744
4766
  );
4745
- const callers = resolve6(callerIds);
4767
+ const callers = resolve7(callerIds);
4746
4768
  if (callees.length === 0 && callers.length === 0) return "";
4747
4769
  const lines = [];
4748
4770
  let used = 0;
@@ -4769,10 +4791,10 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
4769
4791
  let cUsed = used + sep3 + head.length;
4770
4792
  for (const c of callers.slice(0, DEPS_MAX_CALLERS)) {
4771
4793
  const part = `${c.name} \u2192 ${c.file}`;
4772
- const join13 = shown.length > 0 ? 3 : 0;
4773
- if (cUsed + join13 + part.length > maxChars) break;
4794
+ const join14 = shown.length > 0 ? 3 : 0;
4795
+ if (cUsed + join14 + part.length > maxChars) break;
4774
4796
  shown.push(part);
4775
- cUsed += join13 + part.length;
4797
+ cUsed += join14 + part.length;
4776
4798
  }
4777
4799
  if (lines.length > 0) lines.push("");
4778
4800
  if (shown.length > 0) {
@@ -5828,8 +5850,8 @@ async function startServer(paths, options = {}) {
5828
5850
  await fileWatcher.stop().catch(() => void 0);
5829
5851
  await gitWatcher.stop().catch(() => void 0);
5830
5852
  await ctx.learn?.flush().catch(() => void 0);
5831
- await new Promise((resolve6, reject) => {
5832
- nodeServer.close((err2) => err2 ? reject(err2) : resolve6());
5853
+ await new Promise((resolve7, reject) => {
5854
+ nodeServer.close((err2) => err2 ? reject(err2) : resolve7());
5833
5855
  });
5834
5856
  }
5835
5857
  };
@@ -6072,6 +6094,10 @@ async function doctorCommand(rawPath) {
6072
6094
  log.info("");
6073
6095
  }
6074
6096
 
6097
+ // src/cli/remove-command.ts
6098
+ import { readFile as readFile20, readdir as readdir4, rm, rmdir, stat as stat5, unlink, writeFile as writeFile13 } from "fs/promises";
6099
+ import { basename as basename6, join as join13, resolve as resolve4 } from "path";
6100
+
6075
6101
  // src/cli/self-update.ts
6076
6102
  import { mkdir as mkdir15, readFile as readFile19, writeFile as writeFile12 } from "fs/promises";
6077
6103
  import { homedir as homedir4 } from "os";
@@ -6147,15 +6173,15 @@ async function writeLastSeen(version) {
6147
6173
  }
6148
6174
  }
6149
6175
  function npmGlobalRoot() {
6150
- return new Promise((resolve6) => {
6176
+ return new Promise((resolve7) => {
6151
6177
  const chunks = [];
6152
6178
  const proc = spawn2("npm", ["root", "-g"], { stdio: ["ignore", "pipe", "ignore"] });
6153
6179
  proc.stdout?.on("data", (c) => chunks.push(c));
6154
- proc.on("error", () => resolve6(null));
6180
+ proc.on("error", () => resolve7(null));
6155
6181
  proc.on("exit", (code) => {
6156
- if (code !== 0) return resolve6(null);
6182
+ if (code !== 0) return resolve7(null);
6157
6183
  const out = Buffer.concat(chunks).toString("utf8").trim();
6158
- resolve6(out || null);
6184
+ resolve7(out || null);
6159
6185
  });
6160
6186
  });
6161
6187
  }
@@ -6218,12 +6244,12 @@ async function promptYesNo(question) {
6218
6244
  }
6219
6245
  }
6220
6246
  function runNpmUpdate() {
6221
- return new Promise((resolve6) => {
6247
+ return new Promise((resolve7) => {
6222
6248
  const proc = spawn2("npm", ["install", "-g", PKG_NAME + "@latest"], {
6223
6249
  stdio: "inherit"
6224
6250
  });
6225
- proc.on("error", () => resolve6(false));
6226
- proc.on("exit", (code) => resolve6(code === 0));
6251
+ proc.on("error", () => resolve7(false));
6252
+ proc.on("exit", (code) => resolve7(code === 0));
6227
6253
  });
6228
6254
  }
6229
6255
  async function promptForUpdateOrLog() {
@@ -6257,41 +6283,11 @@ async function promptForUpdateOrLog() {
6257
6283
  }
6258
6284
  }
6259
6285
 
6260
- // src/cli/serve-command.ts
6261
- import { resolve as resolve4 } from "path";
6262
- import { stat as stat5 } from "fs/promises";
6263
- async function serveCommand(rawPath) {
6264
- const projectRoot = resolve4(rawPath);
6265
- const paths = resolvePaths(projectRoot);
6266
- try {
6267
- await stat5(paths.infoGraph);
6268
- } catch {
6269
- log.error(`no graph found at ${paths.infoGraph}`);
6270
- log.error("run `syn scan` in this project first.");
6271
- process.exit(2);
6272
- }
6273
- const handle = await startServer(paths);
6274
- log.info(`MCP server listening on ${handle.url}`);
6275
- log.info(`port written to ${paths.mcpPort}`);
6276
- log.info("press Ctrl+C to stop.");
6277
- const shutdown = async (signal) => {
6278
- log.info(`received ${signal} \u2014 shutting down\u2026`);
6279
- try {
6280
- await handle.stop();
6281
- } catch (err2) {
6282
- log.error("shutdown error:", err2.message);
6283
- }
6284
- process.exit(0);
6285
- };
6286
- process.on("SIGINT", shutdown);
6287
- process.on("SIGTERM", shutdown);
6288
- }
6289
-
6290
6286
  // src/cli/start-claude.ts
6291
6287
  import spawn3 from "cross-spawn";
6292
6288
  var MCP_NAME = "synthra";
6293
6289
  function runClaude(bin, args, cwd, stdio = "pipe") {
6294
- return new Promise((resolve6) => {
6290
+ return new Promise((resolve7) => {
6295
6291
  const proc = spawn3(bin, args, {
6296
6292
  cwd,
6297
6293
  stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"]
@@ -6300,8 +6296,8 @@ function runClaude(bin, args, cwd, stdio = "pipe") {
6300
6296
  let stderr = "";
6301
6297
  proc.stdout?.on("data", (c) => stdout += String(c));
6302
6298
  proc.stderr?.on("data", (c) => stderr += String(c));
6303
- proc.on("error", () => resolve6({ code: -1, stdout, stderr: stderr || "claude not on PATH" }));
6304
- proc.on("exit", (code) => resolve6({ code: code ?? 0, stdout, stderr }));
6299
+ proc.on("error", () => resolve7({ code: -1, stdout, stderr: stderr || "claude not on PATH" }));
6300
+ proc.on("exit", (code) => resolve7({ code: code ?? 0, stdout, stderr }));
6305
6301
  });
6306
6302
  }
6307
6303
  async function registerMcp(bin, mcpPort, cwd) {
@@ -6335,6 +6331,233 @@ async function spawnClaude(bin, opts) {
6335
6331
  return result.code;
6336
6332
  }
6337
6333
 
6334
+ // src/cli/remove-command.ts
6335
+ var HOOK_BASENAMES = [
6336
+ "synthra-prime",
6337
+ "synthra-pre-tool-use",
6338
+ "synthra-pre-compact",
6339
+ "synthra-stop"
6340
+ ];
6341
+ async function exists3(path) {
6342
+ try {
6343
+ await stat5(path);
6344
+ return true;
6345
+ } catch {
6346
+ return false;
6347
+ }
6348
+ }
6349
+ async function readIfExists(path) {
6350
+ try {
6351
+ return await readFile20(path, "utf8");
6352
+ } catch {
6353
+ return null;
6354
+ }
6355
+ }
6356
+ function stripGitignoreEntries(content) {
6357
+ const entries = new Set(GITIGNORE_ENTRIES.map((e) => e.entry));
6358
+ const lines = content.split(/\r?\n/).filter((line) => {
6359
+ const t = line.trim();
6360
+ if (entries.has(t)) return false;
6361
+ if (t.startsWith("# added by synthra")) return false;
6362
+ return true;
6363
+ });
6364
+ return lines.join("\n");
6365
+ }
6366
+ async function removeSynthra(projectRootRaw) {
6367
+ const projectRoot = resolve4(projectRootRaw);
6368
+ const paths = resolvePaths(projectRoot);
6369
+ const result = { removed: [], kept: [], skipped: [] };
6370
+ for (const [label, dir] of [
6371
+ [".synthra-graph/", paths.graphDir],
6372
+ [".synthra/ (context store)", paths.contextDir]
6373
+ ]) {
6374
+ if (await exists3(dir)) {
6375
+ await rm(dir, { recursive: true, force: true });
6376
+ result.removed.push(label);
6377
+ } else {
6378
+ result.skipped.push(label);
6379
+ }
6380
+ }
6381
+ const gitignore = await readIfExists(paths.gitignore);
6382
+ if (gitignore === null) {
6383
+ result.skipped.push(".gitignore");
6384
+ } else {
6385
+ const stripped = stripGitignoreEntries(gitignore);
6386
+ if (stripped === gitignore) {
6387
+ result.skipped.push(".gitignore (no synthra entries)");
6388
+ } else if (stripped.trim().length === 0) {
6389
+ await unlink(paths.gitignore);
6390
+ result.removed.push(".gitignore (was synthra-only)");
6391
+ } else {
6392
+ await writeFile13(paths.gitignore, stripped.trimEnd() + "\n", "utf8");
6393
+ result.kept.push(".gitignore (synthra entries stripped)");
6394
+ }
6395
+ }
6396
+ const claudeMd = await readIfExists(paths.claudeMd);
6397
+ if (claudeMd === null) {
6398
+ result.skipped.push("CLAUDE.md");
6399
+ } else {
6400
+ const remainder = stripPolicyBlock(claudeMd);
6401
+ if (remainder === claudeMd) {
6402
+ result.skipped.push("CLAUDE.md (no synthra policy block)");
6403
+ } else {
6404
+ const pristine = onboardingSkeleton(basename6(projectRoot)).trim();
6405
+ if (remainder.trim().length === 0 || remainder.trim() === pristine) {
6406
+ await unlink(paths.claudeMd);
6407
+ result.removed.push("CLAUDE.md (was synthra-generated)");
6408
+ } else {
6409
+ await writeFile13(paths.claudeMd, remainder.trimEnd() + "\n", "utf8");
6410
+ result.kept.push("CLAUDE.md (policy block stripped, your content kept)");
6411
+ }
6412
+ }
6413
+ }
6414
+ let hooksRemoved = 0;
6415
+ for (const base of HOOK_BASENAMES) {
6416
+ for (const ext of [".ps1", ".sh"]) {
6417
+ const p = join13(paths.claudeHooksDir, `${base}${ext}`);
6418
+ if (await exists3(p)) {
6419
+ await unlink(p);
6420
+ hooksRemoved += 1;
6421
+ }
6422
+ }
6423
+ }
6424
+ if (hooksRemoved > 0) {
6425
+ result.removed.push(`.claude/hooks/synthra-* (${hooksRemoved} script(s))`);
6426
+ try {
6427
+ if ((await readdir4(paths.claudeHooksDir)).length === 0) await rmdir(paths.claudeHooksDir);
6428
+ } catch {
6429
+ }
6430
+ } else {
6431
+ result.skipped.push(".claude/hooks/synthra-*");
6432
+ }
6433
+ const settingsRaw = await readIfExists(paths.claudeSettings);
6434
+ if (settingsRaw === null) {
6435
+ result.skipped.push(".claude/settings.local.json");
6436
+ } else {
6437
+ try {
6438
+ const config = stripOurHooks(JSON.parse(settingsRaw));
6439
+ if (config.hooks && Object.keys(config.hooks).length === 0) delete config.hooks;
6440
+ if (Object.keys(config).length === 0) {
6441
+ await unlink(paths.claudeSettings);
6442
+ result.removed.push(".claude/settings.local.json (was synthra-only)");
6443
+ } else {
6444
+ await writeFile13(paths.claudeSettings, JSON.stringify(config, null, 2) + "\n", "utf8");
6445
+ result.kept.push(".claude/settings.local.json (synthra hooks stripped)");
6446
+ }
6447
+ } catch {
6448
+ result.skipped.push(".claude/settings.local.json (unparseable \u2014 left untouched)");
6449
+ }
6450
+ }
6451
+ const mcpPath = join13(projectRoot, ".mcp.json");
6452
+ const mcpRaw = await readIfExists(mcpPath);
6453
+ if (mcpRaw === null) {
6454
+ result.skipped.push(".mcp.json");
6455
+ } else {
6456
+ try {
6457
+ const mcp = JSON.parse(mcpRaw);
6458
+ const servers = mcp.mcpServers ?? {};
6459
+ const hadSynthra = "synthra" in servers;
6460
+ if (hadSynthra) delete servers.synthra;
6461
+ const serversEmpty = Object.keys(servers).length === 0;
6462
+ const onlyServersKey = Object.keys(mcp).length === 1 && "mcpServers" in mcp;
6463
+ if (serversEmpty && onlyServersKey) {
6464
+ await unlink(mcpPath);
6465
+ result.removed.push(".mcp.json (was synthra-only)");
6466
+ } else if (hadSynthra) {
6467
+ await writeFile13(mcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf8");
6468
+ result.kept.push(".mcp.json (synthra entry removed)");
6469
+ } else {
6470
+ result.skipped.push(".mcp.json (no synthra entry)");
6471
+ }
6472
+ } catch {
6473
+ result.skipped.push(".mcp.json (unparseable \u2014 left untouched)");
6474
+ }
6475
+ }
6476
+ return result;
6477
+ }
6478
+ async function inventory(projectRoot, paths) {
6479
+ const found = [];
6480
+ if (await exists3(paths.graphDir)) found.push(".synthra-graph/ (graph + logs, machine-local)");
6481
+ if (await exists3(paths.contextDir)) {
6482
+ found.push(".synthra/ (context store \u2014 git-tracked project memory)");
6483
+ }
6484
+ const gitignore = await readIfExists(paths.gitignore);
6485
+ if (gitignore?.includes("added by synthra")) found.push(".gitignore (synthra entries)");
6486
+ const claudeMd = await readIfExists(paths.claudeMd);
6487
+ if (claudeMd?.includes("synthra-policy")) found.push("CLAUDE.md (synthra policy block)");
6488
+ const settings = await readIfExists(paths.claudeSettings);
6489
+ if (settings?.includes("synthra-hook=true")) {
6490
+ found.push(".claude/settings.local.json + hooks/synthra-* (hooks)");
6491
+ }
6492
+ const mcp = await readIfExists(join13(projectRoot, ".mcp.json"));
6493
+ if (mcp?.includes('"synthra"')) found.push(".mcp.json (MCP registration)");
6494
+ return found;
6495
+ }
6496
+ async function removeCommand(rawPath, opts = {}) {
6497
+ const projectRoot = resolve4(rawPath);
6498
+ const paths = resolvePaths(projectRoot);
6499
+ const found = await inventory(projectRoot, paths);
6500
+ if (found.length === 0) {
6501
+ await forgetProject(projectRoot);
6502
+ log.info(`nothing to remove \u2014 Synthra doesn't appear to be installed in ${projectRoot}`);
6503
+ return;
6504
+ }
6505
+ log.info(`removing Synthra from ${projectRoot}`);
6506
+ log.info("this will delete / strip:");
6507
+ for (const f of found) log.info(` \u2022 ${f}`);
6508
+ if (!opts.yes) {
6509
+ if (!process.stdin.isTTY) {
6510
+ log.info("not a TTY \u2014 re-run with --yes to confirm removal.");
6511
+ return;
6512
+ }
6513
+ const ok2 = await promptYesNo("[syn] Remove Synthra from this project? [y/N]: ");
6514
+ if (!ok2) {
6515
+ log.info("aborted \u2014 nothing was changed.");
6516
+ return;
6517
+ }
6518
+ }
6519
+ try {
6520
+ await unregisterMcp(loadConfig().claudeBin, projectRoot);
6521
+ } catch {
6522
+ }
6523
+ const result = await removeSynthra(projectRoot);
6524
+ const forgot = await forgetProject(projectRoot);
6525
+ for (const r of result.removed) log.info(` removed ${r}`);
6526
+ for (const k of result.kept) log.info(` kept ${k}`);
6527
+ if (forgot) log.info(" removed dashboard registry entry");
6528
+ log.info("done \u2014 Synthra is no longer installed in this project.");
6529
+ }
6530
+
6531
+ // src/cli/serve-command.ts
6532
+ import { resolve as resolve5 } from "path";
6533
+ import { stat as stat6 } from "fs/promises";
6534
+ async function serveCommand(rawPath) {
6535
+ const projectRoot = resolve5(rawPath);
6536
+ const paths = resolvePaths(projectRoot);
6537
+ try {
6538
+ await stat6(paths.infoGraph);
6539
+ } catch {
6540
+ log.error(`no graph found at ${paths.infoGraph}`);
6541
+ log.error("run `syn scan` in this project first.");
6542
+ process.exit(2);
6543
+ }
6544
+ const handle = await startServer(paths);
6545
+ log.info(`MCP server listening on ${handle.url}`);
6546
+ log.info(`port written to ${paths.mcpPort}`);
6547
+ log.info("press Ctrl+C to stop.");
6548
+ const shutdown = async (signal) => {
6549
+ log.info(`received ${signal} \u2014 shutting down\u2026`);
6550
+ try {
6551
+ await handle.stop();
6552
+ } catch (err2) {
6553
+ log.error("shutdown error:", err2.message);
6554
+ }
6555
+ process.exit(0);
6556
+ };
6557
+ process.on("SIGINT", shutdown);
6558
+ process.on("SIGTERM", shutdown);
6559
+ }
6560
+
6338
6561
  // src/cli/index.ts
6339
6562
  var VERSION2 = package_default.version;
6340
6563
  function printReadyBanner(info) {
@@ -6363,11 +6586,11 @@ function printReadyBanner(info) {
6363
6586
  log.info("");
6364
6587
  }
6365
6588
  function waitForSignal() {
6366
- return new Promise((resolve6) => {
6589
+ return new Promise((resolve7) => {
6367
6590
  const handler = (sig) => {
6368
6591
  process.off("SIGINT", handler);
6369
6592
  process.off("SIGTERM", handler);
6370
- resolve6(sig);
6593
+ resolve7(sig);
6371
6594
  };
6372
6595
  process.on("SIGINT", handler);
6373
6596
  process.on("SIGTERM", handler);
@@ -6375,7 +6598,7 @@ function waitForSignal() {
6375
6598
  }
6376
6599
  async function defaultFlow(rawPath, opts) {
6377
6600
  const launchCli = opts["launch-cli"] === true;
6378
- const projectRoot = resolve5(rawPath);
6601
+ const projectRoot = resolve6(rawPath);
6379
6602
  const paths = resolvePaths(projectRoot);
6380
6603
  const cfg = loadConfig();
6381
6604
  await runStartupChangelogCheck();
@@ -6443,6 +6666,12 @@ function buildProgram() {
6443
6666
  prog.command("doctor [path]", "Diagnose this project's Synthra setup + environment.").action(async (path) => {
6444
6667
  await doctorCommand(path ?? ".");
6445
6668
  });
6669
+ prog.command(
6670
+ "remove [path]",
6671
+ "Remove Synthra from a project \u2014 deletes its state, strips hooks/policy/gitignore entries, deregisters MCP."
6672
+ ).option("--yes", "Skip the confirmation prompt", false).action(async (path, opts) => {
6673
+ await removeCommand(path ?? ".", { yes: opts.yes });
6674
+ });
6446
6675
  return prog;
6447
6676
  }
6448
6677
  async function main(argv) {