@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 +30 -0
- package/README.md +4 -0
- package/dist/cli/index.js +314 -75
- 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 +11 -1
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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) {
|
|
@@ -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
|
|
4732
|
-
const callees =
|
|
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 =
|
|
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
|
|
4763
|
-
if (cUsed +
|
|
4794
|
+
const join14 = shown.length > 0 ? 3 : 0;
|
|
4795
|
+
if (cUsed + join14 + part.length > maxChars) break;
|
|
4764
4796
|
shown.push(part);
|
|
4765
|
-
cUsed +=
|
|
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((
|
|
5822
|
-
nodeServer.close((err2) => err2 ? reject(err2) :
|
|
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((
|
|
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", () =>
|
|
6180
|
+
proc.on("error", () => resolve7(null));
|
|
6145
6181
|
proc.on("exit", (code) => {
|
|
6146
|
-
if (code !== 0) return
|
|
6182
|
+
if (code !== 0) return resolve7(null);
|
|
6147
6183
|
const out = Buffer.concat(chunks).toString("utf8").trim();
|
|
6148
|
-
|
|
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((
|
|
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", () =>
|
|
6216
|
-
proc.on("exit", (code) =>
|
|
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((
|
|
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", () =>
|
|
6294
|
-
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 }));
|
|
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((
|
|
6589
|
+
return new Promise((resolve7) => {
|
|
6357
6590
|
const handler = (sig) => {
|
|
6358
6591
|
process.off("SIGINT", handler);
|
|
6359
6592
|
process.off("SIGTERM", handler);
|
|
6360
|
-
|
|
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 =
|
|
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) {
|