@lyric_dev/data-loom 0.2.4

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cyril Grossenbacher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # DataLoom
2
+
3
+ A fully-local dashboard for spec-driven development. Two views of the same workflow:
4
+
5
+ - **WHAT do I develop?** — an interactive, phased [OpenSpec](https://github.com/Fission-AI/OpenSpec) roadmap. Changes are laid out by derived dependency phase and coloured by status, with archived work in a done-band and dependency cycles / dangling deps surfaced as conflicts.
6
+ - **HOW / with what?** — a topology of your local MCP setup: a hub-and-spoke graph centred on Claude Code, with passive, never-launch liveness for each server.
7
+
8
+ It runs as a small local daemon (a Node HTTP + WebSocket server) and a browser view on `localhost`. The daemon is the single source of truth; the page is a thin, live-updating view.
9
+
10
+ ## Prerequisite
11
+
12
+ DataLoom reads your workspace through the **OpenSpec CLI**, which is **not bundled**. Install it separately:
13
+
14
+ ```
15
+ npm install -g openspec
16
+ ```
17
+
18
+ (data-loom invokes your installed `openspec`; if it's missing, it exits with this guidance instead of showing a blank dashboard.)
19
+
20
+ ## Get it (recommended): install from npm
21
+
22
+ Requires [Node.js](https://nodejs.org) ≥ 20.
23
+
24
+ Run it directly with `npx` (no install), pointing at a project (any directory containing an `openspec/` workspace):
25
+
26
+ ```
27
+ npx @lyric_dev/data-loom "C:\path\to\your\project"
28
+ ```
29
+
30
+ Or install it globally and run the `data-loom` command:
31
+
32
+ ```
33
+ npm install -g @lyric_dev/data-loom
34
+ data-loom "C:\path\to\your\project"
35
+ ```
36
+
37
+ With no argument it uses the current directory. Then open <http://127.0.0.1:4317>.
38
+
39
+ > Published to npm automatically by CI on version tags (`vX.Y.Z`) via GitHub Actions.
40
+
41
+ ## Run from source (development)
42
+
43
+ Requires Node.js ≥ 20.
44
+
45
+ ```
46
+ npm install
47
+ npm start # serves the current directory's project
48
+ # or: npm start -- "C:\path\to\project"
49
+ ```
50
+
51
+ (The npm package is `@lyric_dev/data-loom` — unscoped `data-loom` is blocked by npm as too similar to an existing package; the product is branded **DataLoom** and installs a `data-loom` command.)
52
+
53
+ ## Using the dashboard
54
+
55
+ - **Project selector** (top-right): switch which project is displayed. The list is your Claude Code known projects that contain an `openspec/` workspace, plus the active one. Switching re-scopes the roadmap, the file-watching, and the MCP project-scope live.
56
+ - **Roadmap tab**: changes by phase × status. Click a node to inspect its phase, status, and the capabilities it adds/modifies. A conflicts banner appears if the dependency graph has a cycle or a dangling dependency.
57
+ - **MCP Topology tab**: your servers around the Claude Code hub. Click **check** on a server to passively probe it — DataLoom never starts a server or its backing app; it only reports what's reachable so you know what to start yourself.
58
+
59
+ ## Plan dependencies with your Claude (MCP server)
60
+
61
+ DataLoom can also run as an **MCP server**, so your own Claude session determines and applies the order of interdependent proposals — DataLoom holds no API key and the reasoning runs under your authenticated Claude.
62
+
63
+ 1. Register it in Claude Code (stdio server):
64
+
65
+ ```
66
+ claude mcp add data-loom -- npx @lyric_dev/data-loom mcp "C:\path\to\your\project"
67
+ ```
68
+
69
+ (From source: `node dist/index.js mcp "C:\path\to\project"`.)
70
+
71
+ 2. In that project, ask Claude something like *"review DataLoom's open proposals and set the dependencies."* On connect, the server asks Claude to surface any proposal that hasn't been reviewed for dependencies yet, propose the edges, and **confirm with you before writing**. It exposes three tools:
72
+ - `list_open_proposals` — the open changes with their proposal text, current phase/readiness, and dependency-review state (read-only; proposal text only, no secrets).
73
+ - `set_dependency(from, to)` — writes a `## Depends On` entry into a proposal.
74
+ - `mark_independent(change)` — records that a proposal genuinely depends on nothing, by writing an empty `## Depends On` block.
75
+ - `install_weave_skill` — installs the `/loom:weave` shortcut command (one-time setup, see below).
76
+
77
+ Each write is an explicit, reviewable `## Depends On` edit; the roadmap then recomputes deterministically. Proposals that still need a dependency decision are flagged in the roadmap with a **"needs review"** badge, and DataLoom appears as a server in its own MCP Topology tab once registered.
78
+
79
+ 3. **One command for it all: `/loom:weave`.** Ask Claude to *"install the weave skill"* (it calls `install_weave_skill`). That writes a `/loom:weave` slash command into your global Claude config (`~/.claude/commands/loom/weave.md`); reload Claude Code, and from then on `/loom:weave` runs the whole review — list, propose, confirm, apply — in any project where DataLoom is registered.
80
+
81
+ ## Design principles
82
+
83
+ - **Derived, not stored** — phase/order is a pure function of the OpenSpec files, recomputed on every change. Nothing about ordering is persisted.
84
+ - **Mechanical dependencies** — an edge is "change B modifies a capability that change A introduces"; no NLP, no guessing.
85
+ - **Mirror, never launcher** — MCP liveness is observed passively (connect to listening URLs; scan the process table for stdio servers). The dashboard never spawns anything.
86
+ - **openspec stays external** — data-loom ships the app, but not openspec; it invokes your installed `openspec` CLI.
87
+
88
+ ## Built with itself
89
+
90
+ DataLoom was specified and built with OpenSpec; its own `openspec/` workspace is in this repo, and early in development the dashboard rendered its own build plan.
package/dist/assets.js ADDED
@@ -0,0 +1,24 @@
1
+ // Static asset access: read the SPA's files from the public/ directory on disk.
2
+ // public/ is shipped alongside the compiled code in the published npm package.
3
+ import { readFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ /** Location of the public/ directory, resolved relative to the compiled module. */
7
+ export function resolvePublicDir() {
8
+ try {
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ return join(here, "..", "public");
11
+ }
12
+ catch {
13
+ return join(process.cwd(), "public");
14
+ }
15
+ }
16
+ /** Load a static asset by its relative key (e.g. "index.html") from publicDir. */
17
+ export async function loadAsset(rel, publicDir) {
18
+ try {
19
+ return await readFile(join(publicDir, rel));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
package/dist/derive.js ADDED
@@ -0,0 +1,209 @@
1
+ // The heart of the roadmap: turn OpenSpec data into a phase x status model.
2
+ // Derivation is a pure function of the current workspace — nothing is stored.
3
+ export async function deriveModel(client) {
4
+ const [list, archived, baseline] = await Promise.all([
5
+ client.listChanges(),
6
+ client.listArchived(),
7
+ client.listBaselineCapabilities(),
8
+ ]);
9
+ const baselineSet = new Set(baseline);
10
+ const names = list.map((c) => c.name);
11
+ // Per-change capability ownership declarations (New / Modified).
12
+ const caps = new Map();
13
+ await Promise.all(names.map(async (n) => caps.set(n, await client.readProposalCaps(n))));
14
+ // Explicit `## Depends On` declarations per change.
15
+ const explicitDeps = new Map();
16
+ await Promise.all(names.map(async (n) => explicitDeps.set(n, await client.readProposalDependsOn(n))));
17
+ // Dependency-review state: whether a `## Depends On` section exists at all
18
+ // (even empty). Pure metadata — never feeds the edges or phases below.
19
+ const reviewed = new Map();
20
+ await Promise.all(names.map(async (n) => reviewed.set(n, await client.hasDependsOnSection(n))));
21
+ const nameSet = new Set(names);
22
+ const archivedBare = new Set(archived.map((a) => a.replace(/^\d{4}-\d{2}-\d{2}-/, "")));
23
+ // owner[capability] = the change that introduces it (lists it under New).
24
+ const owner = new Map();
25
+ for (const n of names) {
26
+ for (const cap of caps.get(n).newCaps) {
27
+ if (!owner.has(cap))
28
+ owner.set(cap, n);
29
+ }
30
+ }
31
+ // Build nodes + dependency edges.
32
+ const depMap = new Map();
33
+ const nodes = [];
34
+ for (const c of list) {
35
+ const { newCaps, modifiedCaps } = caps.get(c.name);
36
+ const dependsOn = new Set();
37
+ const unsatisfied = [];
38
+ for (const cap of modifiedCaps) {
39
+ if (baselineSet.has(cap))
40
+ continue; // satisfied by the settled baseline
41
+ const o = owner.get(cap);
42
+ if (o && o !== c.name)
43
+ dependsOn.add(o);
44
+ else if (!o)
45
+ unsatisfied.push(cap); // dangling / out-of-order (surfaced in Phase 2)
46
+ }
47
+ // Explicit cross-proposal dependencies, merged with the capability edges.
48
+ for (const dep of explicitDeps.get(c.name) ?? []) {
49
+ if (dep === c.name)
50
+ continue;
51
+ if (nameSet.has(dep))
52
+ dependsOn.add(dep); // active change -> edge
53
+ else if (archivedBare.has(dep))
54
+ continue; // archived/done -> satisfied
55
+ else
56
+ unsatisfied.push(dep); // unknown name -> conflict
57
+ }
58
+ depMap.set(c.name, [...dependsOn]);
59
+ nodes.push({
60
+ name: c.name,
61
+ phase: 0,
62
+ status: deriveStatus(c, false),
63
+ readiness: "done",
64
+ newCapabilities: newCaps,
65
+ modifiedCapabilities: modifiedCaps,
66
+ dependsOn: [...dependsOn],
67
+ unsatisfiedDependencies: unsatisfied,
68
+ dependencyReview: reviewed.get(c.name) ? "declared" : "pending",
69
+ archived: false,
70
+ completedTasks: c.completedTasks,
71
+ totalTasks: c.totalTasks,
72
+ });
73
+ }
74
+ // Archived changes -> done band (not part of the active DAG).
75
+ for (const a of archived) {
76
+ if (names.includes(a))
77
+ continue;
78
+ nodes.push({
79
+ name: a,
80
+ phase: 0,
81
+ status: "done",
82
+ readiness: "done",
83
+ newCapabilities: [],
84
+ modifiedCapabilities: [],
85
+ dependsOn: [],
86
+ unsatisfiedDependencies: [],
87
+ dependencyReview: "declared",
88
+ archived: true,
89
+ completedTasks: 0,
90
+ totalTasks: 0,
91
+ });
92
+ }
93
+ // Phase = longest-path depth in the DAG, with a cycle guard so a malformed
94
+ // graph still yields a layout instead of crashing.
95
+ const memo = new Map();
96
+ const inStack = new Set();
97
+ const depth = (n) => {
98
+ const cached = memo.get(n);
99
+ if (cached !== undefined)
100
+ return cached;
101
+ if (inStack.has(n))
102
+ return 1; // back-edge: break the cycle defensively
103
+ inStack.add(n);
104
+ let d = 1;
105
+ for (const dep of depMap.get(n) ?? []) {
106
+ if (depMap.has(dep))
107
+ d = Math.max(d, depth(dep) + 1);
108
+ }
109
+ inStack.delete(n);
110
+ memo.set(n, d);
111
+ return d;
112
+ };
113
+ for (const node of nodes) {
114
+ if (!node.archived)
115
+ node.phase = depth(node.name);
116
+ }
117
+ // Group into ordered phases.
118
+ const byPhase = new Map();
119
+ for (const node of nodes) {
120
+ if (node.archived)
121
+ continue;
122
+ const list = byPhase.get(node.phase) ?? [];
123
+ list.push(node.name);
124
+ byPhase.set(node.phase, list);
125
+ }
126
+ const phases = [...byPhase.entries()]
127
+ .sort((a, b) => a[0] - b[0])
128
+ .map(([phase, changeNames]) => ({ phase, changeNames }));
129
+ // Readiness: ready when every active dependency is done; blocked otherwise.
130
+ const statusByName = new Map(nodes.map((n) => [n.name, n.status]));
131
+ for (const node of nodes) {
132
+ if (node.archived || node.status === "done") {
133
+ node.readiness = "done";
134
+ }
135
+ else {
136
+ node.readiness = node.dependsOn.some((d) => statusByName.get(d) !== "done") ? "blocked" : "ready";
137
+ }
138
+ }
139
+ // Surface conflicts (detection never aborts the rest of the derivation).
140
+ const conflicts = [];
141
+ try {
142
+ for (const cyc of findCycles(depMap)) {
143
+ conflicts.push({
144
+ type: "cycle",
145
+ changes: cyc,
146
+ description: `Dependency cycle: ${[...cyc, cyc[0]].join(" → ")}`,
147
+ });
148
+ }
149
+ for (const node of nodes) {
150
+ for (const cap of node.unsatisfiedDependencies) {
151
+ conflicts.push({
152
+ type: "dangling",
153
+ changes: [node.name],
154
+ capability: cap,
155
+ description: `${node.name} modifies "${cap}", which no change adds and no baseline provides`,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ catch {
161
+ /* leave conflicts empty rather than failing the roadmap */
162
+ }
163
+ return {
164
+ generatedAt: new Date().toISOString(),
165
+ changes: nodes,
166
+ phases,
167
+ baselineCapabilities: baseline,
168
+ conflicts,
169
+ };
170
+ }
171
+ /** Find dependency cycles via DFS back-edge detection over the dependency map. */
172
+ function findCycles(depMap) {
173
+ const color = new Map(); // 0 = unvisited, 1 = on stack, 2 = done
174
+ const stack = [];
175
+ const cycles = [];
176
+ const dfs = (n) => {
177
+ color.set(n, 1);
178
+ stack.push(n);
179
+ for (const dep of depMap.get(n) ?? []) {
180
+ if (!depMap.has(dep))
181
+ continue;
182
+ const c = color.get(dep) ?? 0;
183
+ if (c === 1) {
184
+ const idx = stack.indexOf(dep);
185
+ if (idx >= 0)
186
+ cycles.push(stack.slice(idx));
187
+ }
188
+ else if (c === 0) {
189
+ dfs(dep);
190
+ }
191
+ }
192
+ stack.pop();
193
+ color.set(n, 2);
194
+ };
195
+ for (const n of depMap.keys()) {
196
+ if ((color.get(n) ?? 0) === 0)
197
+ dfs(n);
198
+ }
199
+ return cycles;
200
+ }
201
+ function deriveStatus(c, archived) {
202
+ if (archived)
203
+ return "done";
204
+ if (c.totalTasks > 0 && c.completedTasks >= c.totalTasks)
205
+ return "done";
206
+ if (c.completedTasks > 0)
207
+ return "in-progress";
208
+ return "draft";
209
+ }
package/dist/index.js ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // data_loom daemon entry point: verify the openspec CLI, derive the roadmap,
3
+ // discover the MCP setup, serve the SPA, and keep it live — for a selectable
4
+ // project that can be switched at runtime.
5
+ import { resolve } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { OpenSpecClient } from "./openspecClient.js";
8
+ import { deriveModel } from "./derive.js";
9
+ import { watchOpenspec } from "./watcher.js";
10
+ import { startServer } from "./server.js";
11
+ import { discover } from "./mcp/discovery.js";
12
+ import { checkServer } from "./mcp/availability.js";
13
+ import { discoverProjects, isViewableProject } from "./projects.js";
14
+ import { resolvePublicDir } from "./assets.js";
15
+ import { runMcpServer } from "./mcpServer.js";
16
+ const host = "127.0.0.1";
17
+ const port = Number(process.env.PORT ?? 4317);
18
+ // Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
19
+ const initialProject = resolve(process.argv[2] ?? process.env.DATA_LOOM_ROOT ?? process.cwd());
20
+ let session;
21
+ let server;
22
+ async function main() {
23
+ // openspec is an external prerequisite — fail loudly with install guidance.
24
+ try {
25
+ const version = await new OpenSpecClient(initialProject).checkAvailable();
26
+ console.log(`[data-loom] openspec CLI ${version}`);
27
+ }
28
+ catch {
29
+ console.error("[data-loom] ERROR: the `openspec` CLI was not found.\n" +
30
+ "data_loom does not bundle it — install it separately, e.g. `npm i -g openspec`, then retry.");
31
+ process.exit(1);
32
+ }
33
+ // Resolve a viewable project: the launch dir, else the first discovered one,
34
+ // else none. Never exit just because the launch dir isn't a project.
35
+ let active = isViewableProject(initialProject) ? initialProject : null;
36
+ if (!active) {
37
+ const candidates = (await discoverProjects(initialProject)).candidates;
38
+ active = candidates[0]?.path ?? null;
39
+ }
40
+ if (active) {
41
+ session = await buildSession(active);
42
+ }
43
+ else {
44
+ console.log("[data-loom] no openspec project found at launch — open the dashboard and pick one");
45
+ }
46
+ server = await startServer({
47
+ publicDir: resolvePublicDir(),
48
+ host,
49
+ port,
50
+ getRoadmap: () => session?.model ?? null,
51
+ getMcp,
52
+ checkMcp,
53
+ getProjects,
54
+ selectProject,
55
+ });
56
+ console.log(`[data-loom] dashboard ready at http://${host}:${server.port}`);
57
+ if (session)
58
+ console.log(`[data-loom] project: ${session.project}`);
59
+ openBrowser(`http://${host}:${server.port}`);
60
+ const shutdown = () => {
61
+ session?.stopWatch();
62
+ server?.close();
63
+ process.exit(0);
64
+ };
65
+ process.on("SIGINT", shutdown);
66
+ process.on("SIGTERM", shutdown);
67
+ }
68
+ async function buildSession(project) {
69
+ const client = new OpenSpecClient(project);
70
+ const model = await deriveModel(client);
71
+ let mcpServers = [];
72
+ let probes = new Map();
73
+ try {
74
+ const disc = await discover(project);
75
+ mcpServers = disc.servers;
76
+ probes = disc.probes;
77
+ }
78
+ catch (err) {
79
+ console.error("[data-loom] MCP discovery failed:", err);
80
+ }
81
+ const stopWatch = watchOpenspec(project, () => recompute());
82
+ console.log(`[data-loom] loaded ${model.changes.filter((c) => !c.archived).length} change(s), ${mcpServers.length} MCP server(s)`);
83
+ return { project, client, model, mcpServers, probes, stopWatch };
84
+ }
85
+ async function recompute() {
86
+ if (!session)
87
+ return;
88
+ try {
89
+ session.model = await deriveModel(session.client);
90
+ server?.broadcast({ type: "model", model: session.model });
91
+ console.log(`[data-loom] roadmap recomputed — ${session.model.phases.length} phase(s)`);
92
+ }
93
+ catch (err) {
94
+ console.error("[data-loom] derivation failed:", err);
95
+ }
96
+ }
97
+ async function selectProject(path) {
98
+ const target = resolve(path);
99
+ if (!isViewableProject(target)) {
100
+ throw new Error(`No openspec/ workspace at ${target}`);
101
+ }
102
+ session?.stopWatch();
103
+ session = await buildSession(target);
104
+ const projects = await getProjects();
105
+ server?.broadcast({ type: "project", project: projects });
106
+ server?.broadcast({ type: "model", model: session.model });
107
+ server?.broadcast({ type: "mcp", mcp: getMcp() });
108
+ console.log(`[data-loom] switched project -> ${target}`);
109
+ return projects;
110
+ }
111
+ function getMcp() {
112
+ return { hub: "Claude Code", servers: session?.mcpServers ?? [] };
113
+ }
114
+ async function checkMcp(name) {
115
+ const target = session?.probes.get(name);
116
+ const sv = session?.mcpServers.find((s) => s.name === name);
117
+ if (!target || !sv)
118
+ return null;
119
+ sv.liveness = await checkServer(target);
120
+ sv.lastChecked = new Date().toISOString();
121
+ return sv;
122
+ }
123
+ function getProjects() {
124
+ return discoverProjects(session?.project ?? "");
125
+ }
126
+ function openBrowser(url) {
127
+ if (process.env.DATA_LOOM_NO_OPEN)
128
+ return;
129
+ try {
130
+ if (process.platform === "win32") {
131
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
132
+ }
133
+ else if (process.platform === "darwin") {
134
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
135
+ }
136
+ else {
137
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
138
+ }
139
+ }
140
+ catch {
141
+ /* non-fatal — the URL is already logged */
142
+ }
143
+ }
144
+ if (process.argv[2] === "mcp") {
145
+ // MCP stdio server mode: `data-loom mcp [project]`
146
+ const project = resolve(process.argv[3] ?? process.env.DATA_LOOM_ROOT ?? process.cwd());
147
+ runMcpServer(project).catch((err) => {
148
+ console.error(err);
149
+ process.exit(1);
150
+ });
151
+ }
152
+ else {
153
+ main().catch((err) => {
154
+ console.error(err);
155
+ process.exit(1);
156
+ });
157
+ }
@@ -0,0 +1,59 @@
1
+ // Passive availability checks. NEVER spawns an MCP server or backing app —
2
+ // it only observes: connect to URL endpoints that already listen, and read the
3
+ // OS process table for stdio servers.
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ const execFileP = promisify(execFile);
7
+ export async function checkServer(target) {
8
+ if (target.transport === "stdio") {
9
+ return (await isProcessRunning(target)) ? "already-running" : "on-demand";
10
+ }
11
+ if (target.url) {
12
+ return checkUrl(target.url);
13
+ }
14
+ return "unknown";
15
+ }
16
+ /** Connect to an already-listening endpoint. Connecting is not launching. */
17
+ async function checkUrl(url) {
18
+ const ctrl = new AbortController();
19
+ const timer = setTimeout(() => ctrl.abort(), 2500);
20
+ try {
21
+ const res = await fetch(url, { method: "GET", signal: ctrl.signal, redirect: "manual" });
22
+ if (res.status === 401 || res.status === 403)
23
+ return "needs-auth";
24
+ return "available";
25
+ }
26
+ catch {
27
+ return "unreachable";
28
+ }
29
+ finally {
30
+ clearTimeout(timer);
31
+ }
32
+ }
33
+ /** Best-effort: is a process whose command line matches this stdio server running? */
34
+ async function isProcessRunning(target) {
35
+ const needle = distinctive(target);
36
+ if (!needle)
37
+ return false;
38
+ try {
39
+ const lines = await processCommandLines();
40
+ const n = needle.toLowerCase();
41
+ return lines.some((l) => l.toLowerCase().includes(n));
42
+ }
43
+ catch {
44
+ return false; // can't read the table -> resting state is on-demand
45
+ }
46
+ }
47
+ /** The most identifying token: the longest non-flag argument, else the command. */
48
+ function distinctive(t) {
49
+ const args = (t.args ?? []).filter((a) => !a.startsWith("-")).sort((a, b) => b.length - a.length);
50
+ return args[0] ?? t.command;
51
+ }
52
+ async function processCommandLines() {
53
+ if (process.platform === "win32") {
54
+ const { stdout } = await execFileP("powershell", ["-NoProfile", "-Command", "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine"], { maxBuffer: 32 * 1024 * 1024, windowsHide: true });
55
+ return stdout.split(/\r?\n/);
56
+ }
57
+ const { stdout } = await execFileP("ps", ["-axww", "-o", "args="], { maxBuffer: 32 * 1024 * 1024 });
58
+ return stdout.split(/\r?\n/);
59
+ }
@@ -0,0 +1,122 @@
1
+ // Merge MCP server definitions from Claude Code config sources into a single,
2
+ // de-duplicated, secret-free list. The hub is always Claude Code.
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, basename } from "node:path";
6
+ export async function discover(repoRoot) {
7
+ const servers = new Map();
8
+ const probes = new Map();
9
+ const add = (name, cfg, scope, source) => {
10
+ const existing = servers.get(name);
11
+ if (existing) {
12
+ // Collapse duplicates; a project-scoped entry is the most specific.
13
+ if (existing.scope === "project")
14
+ return;
15
+ if (scope === "global")
16
+ return; // both global — keep the first
17
+ // existing global + new project -> overwrite below
18
+ }
19
+ const { server, probe } = sanitize(name, cfg, scope, source);
20
+ servers.set(name, server);
21
+ probes.set(name, probe);
22
+ };
23
+ const home = homedir();
24
+ const claudeJson = await readJsonSafe(join(home, ".claude.json"));
25
+ const userMcp = await readJsonSafe(join(home, ".claude", ".mcp.json"));
26
+ // Global: ~/.claude.json mcpServers
27
+ for (const [n, c] of entriesOf(claudeJson?.mcpServers)) {
28
+ if (isServerCfg(c))
29
+ add(n, c, "global", "~/.claude.json");
30
+ }
31
+ // Global/user: ~/.claude/.mcp.json (bare map or { mcpServers })
32
+ const userServers = userMcp?.mcpServers ?? userMcp;
33
+ for (const [n, c] of entriesOf(userServers)) {
34
+ if (isServerCfg(c))
35
+ add(n, c, "global", "~/.claude/.mcp.json");
36
+ }
37
+ // Project: ~/.claude.json projects[<repoRoot>].mcpServers (match path variants)
38
+ const target = normalizePath(repoRoot);
39
+ for (const [p, pv] of entriesOf(claudeJson?.projects)) {
40
+ if (normalizePath(p) !== target)
41
+ continue;
42
+ const ms = pv?.mcpServers;
43
+ for (const [n, c] of entriesOf(ms)) {
44
+ if (isServerCfg(c))
45
+ add(n, c, "project", `project: ${p}`);
46
+ }
47
+ }
48
+ return { servers: [...servers.values()], probes };
49
+ }
50
+ function sanitize(name, cfg, scope, source) {
51
+ const transport = transportOf(cfg);
52
+ const probe = { name, transport, command: cfg.command, args: cfg.args, url: cfg.url };
53
+ const server = { name, transport, scope, source, liveness: "unknown" };
54
+ if (transport === "stdio") {
55
+ server.command = cfg.command ? basename(cfg.command) : undefined;
56
+ server.detail = redactArgs(cfg.args);
57
+ }
58
+ else {
59
+ server.url = safeUrl(cfg.url);
60
+ server.detail = server.url;
61
+ }
62
+ return { server, probe };
63
+ }
64
+ function transportOf(cfg) {
65
+ if (cfg.type === "http" || cfg.type === "streamable-http")
66
+ return "http";
67
+ if (cfg.type === "sse")
68
+ return "sse";
69
+ if (cfg.type === "stdio")
70
+ return "stdio";
71
+ if (cfg.command)
72
+ return "stdio";
73
+ if (cfg.url)
74
+ return "http";
75
+ return "unknown";
76
+ }
77
+ function isServerCfg(c) {
78
+ return !!c && typeof c === "object" && ("command" in c || "url" in c || "type" in c);
79
+ }
80
+ // --- redaction (secrets must never reach the client) ---
81
+ function redactArgs(args) {
82
+ if (!args || !args.length)
83
+ return "";
84
+ const sensitiveFlag = /(token|secret|key|password|passwd|auth|bearer|cred)/i;
85
+ const tokenish = /^(ghp_|gho_|sk-|xox|eyJ)|^[A-Fa-f0-9]{24,}$/;
86
+ const out = [];
87
+ for (let i = 0; i < args.length; i++) {
88
+ const a = args[i];
89
+ if (sensitiveFlag.test(args[i - 1] ?? "") || tokenish.test(a))
90
+ out.push("***");
91
+ else if (/^https?:\/\//i.test(a))
92
+ out.push(safeUrl(a));
93
+ else
94
+ out.push(a);
95
+ }
96
+ return out.join(" ").slice(0, 100);
97
+ }
98
+ function safeUrl(url) {
99
+ if (!url)
100
+ return "";
101
+ try {
102
+ const u = new URL(url);
103
+ return `${u.protocol}//${u.host}`;
104
+ }
105
+ catch {
106
+ return "";
107
+ }
108
+ }
109
+ function normalizePath(p) {
110
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
111
+ }
112
+ function entriesOf(v) {
113
+ return v && typeof v === "object" ? Object.entries(v) : [];
114
+ }
115
+ async function readJsonSafe(path) {
116
+ try {
117
+ return JSON.parse(await readFile(path, "utf8"));
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }