@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 +16 -0
- package/README.md +4 -0
- package/dist/cli/index.js +303 -74
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +1 -1
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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((
|
|
154
|
+
return new Promise((resolve7) => {
|
|
155
155
|
const s = createServer();
|
|
156
|
-
s.once("error", () =>
|
|
157
|
-
s.once("listening", () => s.close(() =>
|
|
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,
|
|
440
|
-
const eff =
|
|
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((
|
|
898
|
-
nodeServer.close((err2) => err2 ? reject(err2) :
|
|
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
|
|
4742
|
-
const callees =
|
|
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 =
|
|
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
|
|
4773
|
-
if (cUsed +
|
|
4794
|
+
const join14 = shown.length > 0 ? 3 : 0;
|
|
4795
|
+
if (cUsed + join14 + part.length > maxChars) break;
|
|
4774
4796
|
shown.push(part);
|
|
4775
|
-
cUsed +=
|
|
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((
|
|
5832
|
-
nodeServer.close((err2) => err2 ? reject(err2) :
|
|
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((
|
|
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", () =>
|
|
6180
|
+
proc.on("error", () => resolve7(null));
|
|
6155
6181
|
proc.on("exit", (code) => {
|
|
6156
|
-
if (code !== 0) return
|
|
6182
|
+
if (code !== 0) return resolve7(null);
|
|
6157
6183
|
const out = Buffer.concat(chunks).toString("utf8").trim();
|
|
6158
|
-
|
|
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((
|
|
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", () =>
|
|
6226
|
-
proc.on("exit", (code) =>
|
|
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((
|
|
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", () =>
|
|
6304
|
-
proc.on("exit", (code) =>
|
|
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((
|
|
6589
|
+
return new Promise((resolve7) => {
|
|
6367
6590
|
const handler = (sig) => {
|
|
6368
6591
|
process.off("SIGINT", handler);
|
|
6369
6592
|
process.off("SIGTERM", handler);
|
|
6370
|
-
|
|
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 =
|
|
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) {
|