@jefuriiij/synthra 0.13.0 → 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,36 @@ 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
+
26
+ ## [0.13.1] — 2026-06-24
27
+
28
+ ### Fixed
29
+
30
+ - **Minified/bundle files are no longer indexed.** Committed vendored plugin JS
31
+ (`*.min.js`, `*.bundle.js`, `*.min.css`, …) has no readable symbols, so indexing
32
+ it only polluted retrieval and caused **useless Moat blocks** on markup-heavy
33
+ projects — a Grep for CSS classes like `nav|menu|toggle` would spuriously match a
34
+ symbol *inside* the minified library and get blocked, only for `graph_continue` to
35
+ then find nothing. The scanner now skips these files (cleaner retrieval, smaller
36
+ graph, no behavior change for real source).
37
+
38
+ ---
39
+
10
40
  ## [0.13.0] — 2026-06-24
11
41
 
12
42
  ### Added
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.0",
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) {
@@ -2890,7 +2909,17 @@ var DEFAULT_IGNORE = [
2890
2909
  ".mypy_cache/",
2891
2910
  ".ruff_cache/",
2892
2911
  // .NET
2893
- "obj/"
2912
+ "obj/",
2913
+ // Generated / minified bundles — no readable symbols, so indexing them only
2914
+ // pollutes retrieval (a markup query like `nav|menu|toggle` spuriously matches
2915
+ // a symbol inside vendored plugin JS → a useless Moat block) and bloats the
2916
+ // graph. Committed bootstrap/swiper-style plugin JS is the common offender.
2917
+ "*.min.js",
2918
+ "*.min.cjs",
2919
+ "*.min.mjs",
2920
+ "*.min.css",
2921
+ "*.bundle.js",
2922
+ "*-min.js"
2894
2923
  ];
2895
2924
  var BINARY_EXTS = /* @__PURE__ */ new Set([
2896
2925
  ".png",
@@ -3021,6 +3050,9 @@ var POLICY_VERSION = 8;
3021
3050
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
3022
3051
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
3023
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
+ }
3024
3056
  function policyBlock() {
3025
3057
  return [
3026
3058
  POLICY_BEGIN,
@@ -4728,11 +4760,11 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
4728
4760
  callerIds.push(e.from);
4729
4761
  }
4730
4762
  }
4731
- const resolve6 = (ids) => ids.map((id) => symById.get(id)).filter((n) => !!n);
4732
- const callees = resolve6(calleeIds).sort(
4763
+ const resolve7 = (ids) => ids.map((id) => symById.get(id)).filter((n) => !!n);
4764
+ const callees = resolve7(calleeIds).sort(
4733
4765
  (a, b) => a.file === b.file ? a.start_line - b.start_line : a.file < b.file ? -1 : 1
4734
4766
  );
4735
- const callers = resolve6(callerIds);
4767
+ const callers = resolve7(callerIds);
4736
4768
  if (callees.length === 0 && callers.length === 0) return "";
4737
4769
  const lines = [];
4738
4770
  let used = 0;
@@ -4759,10 +4791,10 @@ function buildDepsFooter(symbol, graph, maxChars = loadConfig().readDepsMaxChars
4759
4791
  let cUsed = used + sep3 + head.length;
4760
4792
  for (const c of callers.slice(0, DEPS_MAX_CALLERS)) {
4761
4793
  const part = `${c.name} \u2192 ${c.file}`;
4762
- const join13 = shown.length > 0 ? 3 : 0;
4763
- if (cUsed + join13 + part.length > maxChars) break;
4794
+ const join14 = shown.length > 0 ? 3 : 0;
4795
+ if (cUsed + join14 + part.length > maxChars) break;
4764
4796
  shown.push(part);
4765
- cUsed += join13 + part.length;
4797
+ cUsed += join14 + part.length;
4766
4798
  }
4767
4799
  if (lines.length > 0) lines.push("");
4768
4800
  if (shown.length > 0) {
@@ -5818,8 +5850,8 @@ async function startServer(paths, options = {}) {
5818
5850
  await fileWatcher.stop().catch(() => void 0);
5819
5851
  await gitWatcher.stop().catch(() => void 0);
5820
5852
  await ctx.learn?.flush().catch(() => void 0);
5821
- await new Promise((resolve6, reject) => {
5822
- nodeServer.close((err2) => err2 ? reject(err2) : resolve6());
5853
+ await new Promise((resolve7, reject) => {
5854
+ nodeServer.close((err2) => err2 ? reject(err2) : resolve7());
5823
5855
  });
5824
5856
  }
5825
5857
  };
@@ -6062,6 +6094,10 @@ async function doctorCommand(rawPath) {
6062
6094
  log.info("");
6063
6095
  }
6064
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
+
6065
6101
  // src/cli/self-update.ts
6066
6102
  import { mkdir as mkdir15, readFile as readFile19, writeFile as writeFile12 } from "fs/promises";
6067
6103
  import { homedir as homedir4 } from "os";
@@ -6137,15 +6173,15 @@ async function writeLastSeen(version) {
6137
6173
  }
6138
6174
  }
6139
6175
  function npmGlobalRoot() {
6140
- return new Promise((resolve6) => {
6176
+ return new Promise((resolve7) => {
6141
6177
  const chunks = [];
6142
6178
  const proc = spawn2("npm", ["root", "-g"], { stdio: ["ignore", "pipe", "ignore"] });
6143
6179
  proc.stdout?.on("data", (c) => chunks.push(c));
6144
- proc.on("error", () => resolve6(null));
6180
+ proc.on("error", () => resolve7(null));
6145
6181
  proc.on("exit", (code) => {
6146
- if (code !== 0) return resolve6(null);
6182
+ if (code !== 0) return resolve7(null);
6147
6183
  const out = Buffer.concat(chunks).toString("utf8").trim();
6148
- resolve6(out || null);
6184
+ resolve7(out || null);
6149
6185
  });
6150
6186
  });
6151
6187
  }
@@ -6208,12 +6244,12 @@ async function promptYesNo(question) {
6208
6244
  }
6209
6245
  }
6210
6246
  function runNpmUpdate() {
6211
- return new Promise((resolve6) => {
6247
+ return new Promise((resolve7) => {
6212
6248
  const proc = spawn2("npm", ["install", "-g", PKG_NAME + "@latest"], {
6213
6249
  stdio: "inherit"
6214
6250
  });
6215
- proc.on("error", () => resolve6(false));
6216
- proc.on("exit", (code) => resolve6(code === 0));
6251
+ proc.on("error", () => resolve7(false));
6252
+ proc.on("exit", (code) => resolve7(code === 0));
6217
6253
  });
6218
6254
  }
6219
6255
  async function promptForUpdateOrLog() {
@@ -6247,41 +6283,11 @@ async function promptForUpdateOrLog() {
6247
6283
  }
6248
6284
  }
6249
6285
 
6250
- // src/cli/serve-command.ts
6251
- import { resolve as resolve4 } from "path";
6252
- import { stat as stat5 } from "fs/promises";
6253
- async function serveCommand(rawPath) {
6254
- const projectRoot = resolve4(rawPath);
6255
- const paths = resolvePaths(projectRoot);
6256
- try {
6257
- await stat5(paths.infoGraph);
6258
- } catch {
6259
- log.error(`no graph found at ${paths.infoGraph}`);
6260
- log.error("run `syn scan` in this project first.");
6261
- process.exit(2);
6262
- }
6263
- const handle = await startServer(paths);
6264
- log.info(`MCP server listening on ${handle.url}`);
6265
- log.info(`port written to ${paths.mcpPort}`);
6266
- log.info("press Ctrl+C to stop.");
6267
- const shutdown = async (signal) => {
6268
- log.info(`received ${signal} \u2014 shutting down\u2026`);
6269
- try {
6270
- await handle.stop();
6271
- } catch (err2) {
6272
- log.error("shutdown error:", err2.message);
6273
- }
6274
- process.exit(0);
6275
- };
6276
- process.on("SIGINT", shutdown);
6277
- process.on("SIGTERM", shutdown);
6278
- }
6279
-
6280
6286
  // src/cli/start-claude.ts
6281
6287
  import spawn3 from "cross-spawn";
6282
6288
  var MCP_NAME = "synthra";
6283
6289
  function runClaude(bin, args, cwd, stdio = "pipe") {
6284
- return new Promise((resolve6) => {
6290
+ return new Promise((resolve7) => {
6285
6291
  const proc = spawn3(bin, args, {
6286
6292
  cwd,
6287
6293
  stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"]
@@ -6290,8 +6296,8 @@ function runClaude(bin, args, cwd, stdio = "pipe") {
6290
6296
  let stderr = "";
6291
6297
  proc.stdout?.on("data", (c) => stdout += String(c));
6292
6298
  proc.stderr?.on("data", (c) => stderr += String(c));
6293
- proc.on("error", () => resolve6({ code: -1, stdout, stderr: stderr || "claude not on PATH" }));
6294
- 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 }));
6295
6301
  });
6296
6302
  }
6297
6303
  async function registerMcp(bin, mcpPort, cwd) {
@@ -6325,6 +6331,233 @@ async function spawnClaude(bin, opts) {
6325
6331
  return result.code;
6326
6332
  }
6327
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
+
6328
6561
  // src/cli/index.ts
6329
6562
  var VERSION2 = package_default.version;
6330
6563
  function printReadyBanner(info) {
@@ -6353,11 +6586,11 @@ function printReadyBanner(info) {
6353
6586
  log.info("");
6354
6587
  }
6355
6588
  function waitForSignal() {
6356
- return new Promise((resolve6) => {
6589
+ return new Promise((resolve7) => {
6357
6590
  const handler = (sig) => {
6358
6591
  process.off("SIGINT", handler);
6359
6592
  process.off("SIGTERM", handler);
6360
- resolve6(sig);
6593
+ resolve7(sig);
6361
6594
  };
6362
6595
  process.on("SIGINT", handler);
6363
6596
  process.on("SIGTERM", handler);
@@ -6365,7 +6598,7 @@ function waitForSignal() {
6365
6598
  }
6366
6599
  async function defaultFlow(rawPath, opts) {
6367
6600
  const launchCli = opts["launch-cli"] === true;
6368
- const projectRoot = resolve5(rawPath);
6601
+ const projectRoot = resolve6(rawPath);
6369
6602
  const paths = resolvePaths(projectRoot);
6370
6603
  const cfg = loadConfig();
6371
6604
  await runStartupChangelogCheck();
@@ -6433,6 +6666,12 @@ function buildProgram() {
6433
6666
  prog.command("doctor [path]", "Diagnose this project's Synthra setup + environment.").action(async (path) => {
6434
6667
  await doctorCommand(path ?? ".");
6435
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
+ });
6436
6675
  return prog;
6437
6676
  }
6438
6677
  async function main(argv) {