@lyric_dev/data-loom 0.6.0 → 0.8.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/README.md +37 -15
- package/dist/autostart.js +223 -36
- package/dist/claudeCode.js +78 -15
- package/dist/index.js +156 -11
- package/dist/lifecycle.js +116 -2
- package/dist/mcpServer.js +31 -40
- package/dist/mcpShim.js +144 -0
- package/dist/paths.js +6 -0
- package/dist/server.js +10 -1
- package/dist/version.js +18 -0
- package/dist/weaveAlias.js +66 -0
- package/package.json +1 -1
- package/public/app.js +73 -1
- package/public/style.css +102 -0
package/dist/index.js
CHANGED
|
@@ -12,11 +12,14 @@ import { discover } from "./mcp/discovery.js";
|
|
|
12
12
|
import { checkServer } from "./mcp/availability.js";
|
|
13
13
|
import { discoverProjects, isViewableProject } from "./projects.js";
|
|
14
14
|
import { resolvePublicDir } from "./assets.js";
|
|
15
|
-
import { HOST, PORT } from "./paths.js";
|
|
15
|
+
import { HOST, PORT, baseUrl } from "./paths.js";
|
|
16
16
|
import * as lifecycle from "./lifecycle.js";
|
|
17
17
|
import * as autostart from "./autostart.js";
|
|
18
18
|
import * as claudeDesktop from "./claudeDesktop.js";
|
|
19
19
|
import * as claudeCode from "./claudeCode.js";
|
|
20
|
+
import { runShim } from "./mcpShim.js";
|
|
21
|
+
import { refreshWeaveAliasIfOutdated } from "./weaveAlias.js";
|
|
22
|
+
import { VERSION } from "./version.js";
|
|
20
23
|
import { initTray } from "./tray.js";
|
|
21
24
|
const host = HOST;
|
|
22
25
|
const port = PORT;
|
|
@@ -35,6 +38,14 @@ async function main() {
|
|
|
35
38
|
"data_loom does not bundle it — install it separately, e.g. `npm i -g openspec`, then retry.");
|
|
36
39
|
process.exit(1);
|
|
37
40
|
}
|
|
41
|
+
// Heal an existing `/loom:weave` alias left by an older version (or a
|
|
42
|
+
// pre-stamp install) — never creates the file when the user hasn't opted in.
|
|
43
|
+
try {
|
|
44
|
+
await refreshWeaveAliasIfOutdated(VERSION);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.warn(`[data-loom] could not refresh the /loom:weave command (continuing): ${err instanceof Error ? err.message : err}`);
|
|
48
|
+
}
|
|
38
49
|
// Resolve a viewable project: the launch dir, else the first discovered one,
|
|
39
50
|
// else none. Never exit just because the launch dir isn't a project.
|
|
40
51
|
let active = isViewableProject(initialProject) ? initialProject : null;
|
|
@@ -48,6 +59,9 @@ async function main() {
|
|
|
48
59
|
else {
|
|
49
60
|
console.log("[data-loom] no openspec project found at launch — open the dashboard and pick one");
|
|
50
61
|
}
|
|
62
|
+
// Forward reference so the loopback /api/shutdown route can invoke the same
|
|
63
|
+
// graceful shutdown() defined below (it's declared after startServer).
|
|
64
|
+
let shutdownRef = () => process.exit(0);
|
|
51
65
|
try {
|
|
52
66
|
server = await startServer({
|
|
53
67
|
publicDir: resolvePublicDir(),
|
|
@@ -59,6 +73,7 @@ async function main() {
|
|
|
59
73
|
getProjects,
|
|
60
74
|
selectProject,
|
|
61
75
|
getCurrentProject: () => session?.project ?? null,
|
|
76
|
+
onShutdown: () => shutdownRef(),
|
|
62
77
|
});
|
|
63
78
|
}
|
|
64
79
|
catch (err) {
|
|
@@ -77,15 +92,28 @@ async function main() {
|
|
|
77
92
|
if (session)
|
|
78
93
|
console.log(`[data-loom] project: ${session.project}`);
|
|
79
94
|
openBrowser(url);
|
|
95
|
+
// When launched supervised (an OS login supervisor execs this directly, in
|
|
96
|
+
// the foreground, with DATA_LOOM_DETACHED=1), there is no parent `start`
|
|
97
|
+
// spawn to record our PID — write it ourselves so `status`/`stop` work the
|
|
98
|
+
// same as when `start` launched us detached.
|
|
99
|
+
if (process.env.DATA_LOOM_DETACHED) {
|
|
100
|
+
await lifecycle.writeSelfPid().catch(() => { });
|
|
101
|
+
}
|
|
80
102
|
// Tray icon: an ambient "DataLoom is running" indicator (essential in the
|
|
81
103
|
// detached mode, which has no console). Guarded no-op where unavailable.
|
|
82
104
|
let tray = { dispose: () => { } };
|
|
105
|
+
// Every deliberate shutdown path below (SIGINT, SIGTERM, tray Stop) funnels
|
|
106
|
+
// through here and exits 0. OS supervisors (Scheduled Task restart-on-
|
|
107
|
+
// failure, launchd KeepAlive, systemd Restart=on-failure) treat a clean
|
|
108
|
+
// exit 0 as "stopped on purpose" and do NOT restart it — only a crash or
|
|
109
|
+
// non-zero exit does. Do not change a deliberate stop to any other exit code.
|
|
83
110
|
const shutdown = () => {
|
|
84
111
|
tray.dispose();
|
|
85
112
|
session?.stopWatch();
|
|
86
113
|
server?.close();
|
|
87
114
|
process.exit(0);
|
|
88
115
|
};
|
|
116
|
+
shutdownRef = shutdown; // route the loopback /api/shutdown POST here too
|
|
89
117
|
process.on("SIGINT", shutdown);
|
|
90
118
|
process.on("SIGTERM", shutdown);
|
|
91
119
|
tray = initTray({
|
|
@@ -197,16 +225,37 @@ function copyToClipboard(text) {
|
|
|
197
225
|
// A leading reserved verb selects a lifecycle/autostart/integration command;
|
|
198
226
|
// anything else falls through to the foreground daemon (with argv[2] as the
|
|
199
227
|
// optional project path), preserving the original invocation unchanged.
|
|
200
|
-
const VERBS = new Set([
|
|
228
|
+
const VERBS = new Set([
|
|
229
|
+
"up",
|
|
230
|
+
"start",
|
|
231
|
+
"stop",
|
|
232
|
+
"restart",
|
|
233
|
+
"status",
|
|
234
|
+
"autostart",
|
|
235
|
+
"connect",
|
|
236
|
+
"disconnect",
|
|
237
|
+
"update",
|
|
238
|
+
"mcp-shim",
|
|
239
|
+
]);
|
|
201
240
|
async function runAutostart(rest) {
|
|
202
241
|
const sub = rest[0];
|
|
203
242
|
if (sub === "enable") {
|
|
204
|
-
await autostart.enable();
|
|
205
|
-
console.log(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
243
|
+
const info = await autostart.enable();
|
|
244
|
+
console.log(`[data-loom] autostart enabled (${info.mechanism})` +
|
|
245
|
+
(info.supervised
|
|
246
|
+
? " — the daemon restarts automatically if it crashes"
|
|
247
|
+
: " — supervision is not available on this host; the daemon will not auto-restart on crash"));
|
|
248
|
+
// Enabling also brings the daemon up now, so it's running this session too.
|
|
249
|
+
// A supervised start-on-register mechanism (systemd `enable --now`, launchd
|
|
250
|
+
// `RunAtLoad`) already launched it — starting again would spawn a second
|
|
251
|
+
// detached instance that races the supervised one for the port, so only
|
|
252
|
+
// start it ourselves when registration didn't. Opt out with --no-start.
|
|
253
|
+
if (info.startedNow) {
|
|
254
|
+
console.log(`[data-loom] the daemon is running now — ${baseUrl()}`);
|
|
255
|
+
}
|
|
256
|
+
else if (!rest.includes("--no-start")) {
|
|
209
257
|
await lifecycle.start();
|
|
258
|
+
}
|
|
210
259
|
// ...and points Claude Code at it, so the always-on path both hosts and
|
|
211
260
|
// registers the MCP endpoint. Best-effort: a missing `claude` CLI must not
|
|
212
261
|
// fail the enable (the login item + daemon already succeeded). Opt out with
|
|
@@ -237,10 +286,18 @@ async function runAutostart(rest) {
|
|
|
237
286
|
async function runConnect(rest) {
|
|
238
287
|
const target = rest[0];
|
|
239
288
|
if (target === "claude-code") {
|
|
240
|
-
const
|
|
289
|
+
const onDemand = rest.includes("--on-demand");
|
|
290
|
+
const { registered, switchedFrom } = await claudeCode.connect({ onDemand });
|
|
241
291
|
if (registered) {
|
|
242
|
-
|
|
243
|
-
|
|
292
|
+
if (switchedFrom) {
|
|
293
|
+
console.log(`[data-loom] switched Claude Code registration from ${switchedFrom === "stdio" ? "on-demand stdio" : "native HTTP"} to ${onDemand ? "on-demand stdio" : "native HTTP"}`);
|
|
294
|
+
}
|
|
295
|
+
console.log(onDemand
|
|
296
|
+
? "[data-loom] registered DataLoom in Claude Code (user scope, on-demand stdio shim)"
|
|
297
|
+
: "[data-loom] registered DataLoom in Claude Code (user scope, native HTTP)");
|
|
298
|
+
console.log(onDemand
|
|
299
|
+
? "[data-loom] start a new Claude Code session (or /mcp reconnect) to pick it up; the shim starts DataLoom automatically when needed."
|
|
300
|
+
: "[data-loom] start a new Claude Code session (or /mcp reconnect) to pick it up; DataLoom must be running to serve the tools.");
|
|
244
301
|
}
|
|
245
302
|
return;
|
|
246
303
|
}
|
|
@@ -251,7 +308,7 @@ async function runConnect(rest) {
|
|
|
251
308
|
console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
|
|
252
309
|
return;
|
|
253
310
|
}
|
|
254
|
-
throw new Error("usage: data-loom connect <claude-code|claude-desktop [--bridge]>");
|
|
311
|
+
throw new Error("usage: data-loom connect <claude-code [--on-demand]|claude-desktop [--bridge]>");
|
|
255
312
|
}
|
|
256
313
|
async function runDisconnect(rest) {
|
|
257
314
|
const target = rest[0];
|
|
@@ -271,9 +328,93 @@ async function runDisconnect(rest) {
|
|
|
271
328
|
}
|
|
272
329
|
throw new Error("usage: data-loom disconnect <claude-code|claude-desktop>");
|
|
273
330
|
}
|
|
331
|
+
/** True iff DataLoom is running from an npx / `npm exec` cache rather than a global install. */
|
|
332
|
+
function isNpxInvocation() {
|
|
333
|
+
return /[\\/]_npx[\\/]/.test(process.argv[1] ?? "");
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* `data-loom up [project]` — the one-command setup. Verifies the openspec
|
|
337
|
+
* prerequisite up front (fail-fast, so a missing prerequisite leaves the
|
|
338
|
+
* system untouched), then runs the same sequence as `autostart enable`
|
|
339
|
+
* (register autostart, start the daemon this session, register Claude Code)
|
|
340
|
+
* and prints a state summary. Idempotent: a re-run on an already-configured
|
|
341
|
+
* host reports each aspect as already in place instead of duplicating it.
|
|
342
|
+
* Honors the `--no-start` and `--no-connect` opt-outs.
|
|
343
|
+
*/
|
|
344
|
+
async function runUp(rest) {
|
|
345
|
+
const project = rest.find((a) => !a.startsWith("--"));
|
|
346
|
+
const noStart = rest.includes("--no-start");
|
|
347
|
+
const noConnect = rest.includes("--no-connect");
|
|
348
|
+
// Prerequisite FIRST, before any setup step — a missing openspec must leave
|
|
349
|
+
// the system unchanged (no registration, no start, no connect).
|
|
350
|
+
try {
|
|
351
|
+
const version = await new OpenSpecClient(resolve(project ?? process.cwd())).checkAvailable();
|
|
352
|
+
console.log(`[data-loom] openspec CLI ${version}`);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
console.error("[data-loom] ERROR: the `openspec` CLI was not found — nothing was changed.\n" +
|
|
356
|
+
"data_loom does not bundle it; install it separately, then re-run `data-loom up`:\n" +
|
|
357
|
+
" npm install -g openspec");
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
// Snapshot prior state so the summary can report what was already in place.
|
|
361
|
+
const wasRunning = await lifecycle.isRunning();
|
|
362
|
+
const priorEnabled = (await autostart.getRegistrationInfo()).enabled;
|
|
363
|
+
// 1. Register autostart (idempotent — overwrites any prior registration).
|
|
364
|
+
const info = await autostart.enable();
|
|
365
|
+
console.log(`[data-loom] autostart ${priorEnabled ? "already enabled — refreshed" : "enabled"} (${info.mechanism})` +
|
|
366
|
+
(info.supervised ? "" : " — no crash supervision on this host"));
|
|
367
|
+
// 2. Bring the daemon up this session — unless the supervisor already did
|
|
368
|
+
// (systemd/launchd), it's already running, or the user opted out.
|
|
369
|
+
let daemonState;
|
|
370
|
+
if (info.startedNow) {
|
|
371
|
+
daemonState = "running (launched by the supervisor)";
|
|
372
|
+
}
|
|
373
|
+
else if (wasRunning) {
|
|
374
|
+
daemonState = "already running";
|
|
375
|
+
}
|
|
376
|
+
else if (noStart) {
|
|
377
|
+
daemonState = "not started (--no-start)";
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
await lifecycle.start(project);
|
|
381
|
+
daemonState = "started";
|
|
382
|
+
}
|
|
383
|
+
// 3. Point Claude Code at the daemon (best-effort; --no-connect skips it).
|
|
384
|
+
let connectState;
|
|
385
|
+
if (noConnect) {
|
|
386
|
+
connectState = "skipped (--no-connect)";
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
try {
|
|
390
|
+
const { registered } = await claudeCode.connect();
|
|
391
|
+
connectState = registered
|
|
392
|
+
? "registered (user scope, native HTTP)"
|
|
393
|
+
: "not registered — `claude` CLI not found (run the printed command by hand)";
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
connectState = `not registered — ${err instanceof Error ? err.message : err}`;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Summary — doubles as a setup health check when `up` is re-run.
|
|
400
|
+
const running = await lifecycle.isRunning();
|
|
401
|
+
console.log("");
|
|
402
|
+
console.log("[data-loom] setup summary");
|
|
403
|
+
console.log(` dashboard: ${baseUrl()} — ${running ? "running" : "not running"} (${daemonState})`);
|
|
404
|
+
console.log(` autostart: ${info.mechanism}${info.supervised ? " (supervised)" : " (unsupervised)"}`);
|
|
405
|
+
console.log(` claude code: ${connectState}`);
|
|
406
|
+
console.log(" weave: run /loom:weave in a Claude Code session to order proposal dependencies");
|
|
407
|
+
if (isNpxInvocation()) {
|
|
408
|
+
console.log("");
|
|
409
|
+
console.log("[data-loom] you launched via npx — for a durable always-on setup, install globally instead:");
|
|
410
|
+
console.log(" npm install -g @lyric_dev/data-loom");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
274
413
|
async function runCli(argv) {
|
|
275
414
|
const [verb, ...rest] = argv;
|
|
276
415
|
switch (verb) {
|
|
416
|
+
case "up":
|
|
417
|
+
return runUp(rest);
|
|
277
418
|
case "start":
|
|
278
419
|
return lifecycle.start(rest[0]);
|
|
279
420
|
case "stop":
|
|
@@ -282,12 +423,16 @@ async function runCli(argv) {
|
|
|
282
423
|
return lifecycle.restart(rest[0]);
|
|
283
424
|
case "status":
|
|
284
425
|
return lifecycle.status();
|
|
426
|
+
case "update":
|
|
427
|
+
return lifecycle.update();
|
|
285
428
|
case "autostart":
|
|
286
429
|
return runAutostart(rest);
|
|
287
430
|
case "connect":
|
|
288
431
|
return runConnect(rest);
|
|
289
432
|
case "disconnect":
|
|
290
433
|
return runDisconnect(rest);
|
|
434
|
+
case "mcp-shim":
|
|
435
|
+
return runShim();
|
|
291
436
|
default:
|
|
292
437
|
throw new Error(`unknown command: ${verb}`);
|
|
293
438
|
}
|
package/dist/lifecycle.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { openSync } from "node:fs";
|
|
7
7
|
import { readFile, writeFile, rm } from "node:fs/promises";
|
|
8
|
-
import { get } from "node:http";
|
|
8
|
+
import { get, request } from "node:http";
|
|
9
9
|
import { HOST, PORT, baseUrl, logFile, pidFile, ensureStateDir } from "./paths.js";
|
|
10
|
+
import * as autostart from "./autostart.js";
|
|
10
11
|
/** Probe the loopback endpoint; true iff a daemon answers within the timeout. */
|
|
11
12
|
export function isRunning() {
|
|
12
13
|
return new Promise((resolve) => {
|
|
@@ -30,7 +31,39 @@ async function readPid() {
|
|
|
30
31
|
return undefined;
|
|
31
32
|
}
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Write this process's own PID file. Used when running supervised (an OS
|
|
36
|
+
* supervisor execs the daemon directly, so there is no parent `start` spawn
|
|
37
|
+
* to record the child's PID) — keeps `status`/`stop` working the same
|
|
38
|
+
* whether the daemon was launched via `start` or by a login supervisor.
|
|
39
|
+
*/
|
|
40
|
+
export async function writeSelfPid() {
|
|
41
|
+
await ensureStateDir();
|
|
42
|
+
await writeFile(pidFile(), String(process.pid), "utf8");
|
|
43
|
+
}
|
|
33
44
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
45
|
+
/**
|
|
46
|
+
* Ask the running daemon to shut itself down through its own graceful exit-0
|
|
47
|
+
* path, via the loopback POST /api/shutdown. This is how a clean stop reaches
|
|
48
|
+
* exit 0 on Windows, where `process.kill(pid, "SIGTERM")` force-terminates
|
|
49
|
+
* (exit 1) without running the shutdown handler — which a supervising
|
|
50
|
+
* Scheduled Task would misread as a crash and restart. Resolves true iff the
|
|
51
|
+
* daemon accepted the request; false lets the caller fall back to a signal.
|
|
52
|
+
*/
|
|
53
|
+
function requestGracefulShutdown() {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const req = request({ host: HOST, port: PORT, path: "/api/shutdown", method: "POST", timeout: 2000 }, (res) => {
|
|
56
|
+
res.resume(); // drain
|
|
57
|
+
resolve(res.statusCode === 200);
|
|
58
|
+
});
|
|
59
|
+
req.on("error", () => resolve(false));
|
|
60
|
+
req.on("timeout", () => {
|
|
61
|
+
req.destroy();
|
|
62
|
+
resolve(false);
|
|
63
|
+
});
|
|
64
|
+
req.end();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
34
67
|
/**
|
|
35
68
|
* Start the daemon detached from this terminal. If one already answers on the
|
|
36
69
|
* port, this is a reported no-op (single-instance guard). The child re-runs this
|
|
@@ -65,7 +98,21 @@ export async function stop() {
|
|
|
65
98
|
console.log("[data-loom] not running — nothing to stop");
|
|
66
99
|
return;
|
|
67
100
|
}
|
|
68
|
-
|
|
101
|
+
// On Linux, prefer stopping through systemd when the supervised unit is
|
|
102
|
+
// active — keeps the unit's own state consistent with an intentional stop
|
|
103
|
+
// rather than an out-of-band signal it has to interpret after the fact.
|
|
104
|
+
if (await autostart.isSystemdUnitActive()) {
|
|
105
|
+
await autostart.stopSystemdUnit();
|
|
106
|
+
}
|
|
107
|
+
else if (running && (await requestGracefulShutdown())) {
|
|
108
|
+
// The daemon exits 0 through its own shutdown path — the only reliable way
|
|
109
|
+
// to a clean exit on Windows (see requestGracefulShutdown). Nothing more to
|
|
110
|
+
// signal; fall through to the port-free wait below.
|
|
111
|
+
}
|
|
112
|
+
else if (pid !== undefined) {
|
|
113
|
+
// Fallback: no graceful endpoint answered (older daemon, or it's hung).
|
|
114
|
+
// On POSIX this reaches the SIGTERM handler (exit 0); on Windows it force-
|
|
115
|
+
// terminates (exit 1), which is still correct for an already-wedged daemon.
|
|
69
116
|
try {
|
|
70
117
|
process.kill(pid, "SIGTERM");
|
|
71
118
|
}
|
|
@@ -98,4 +145,71 @@ export async function status() {
|
|
|
98
145
|
else {
|
|
99
146
|
console.log("[data-loom] not running");
|
|
100
147
|
}
|
|
148
|
+
const reg = await autostart.getRegistrationInfo();
|
|
149
|
+
if (!reg.enabled) {
|
|
150
|
+
console.log("[data-loom] autostart: not registered");
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
console.log(`[data-loom] autostart: ${reg.mechanism}${reg.supervised ? " (restarts automatically on crash)" : " (no restart supervision)"}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function isGloballyInstalled() {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
const p = spawn("npm", ["ls", "-g", "@lyric_dev/data-loom", "--depth=0"], {
|
|
159
|
+
shell: process.platform === "win32",
|
|
160
|
+
stdio: "ignore",
|
|
161
|
+
});
|
|
162
|
+
p.on("error", () => resolve(false));
|
|
163
|
+
p.on("exit", (code) => resolve(code === 0));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function npmInstallLatest() {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const p = spawn("npm", ["install", "-g", "@lyric_dev/data-loom@latest"], {
|
|
169
|
+
shell: process.platform === "win32",
|
|
170
|
+
stdio: "inherit",
|
|
171
|
+
});
|
|
172
|
+
p.on("error", reject);
|
|
173
|
+
p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`))));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Upgrade the globally-installed package, restart a running daemon, and
|
|
178
|
+
* refresh the autostart registration (launcher + task/plist/unit) when one
|
|
179
|
+
* exists — one command to land on the new version with a healthy always-on
|
|
180
|
+
* setup. Aborts before any restart/registration step if the upgrade fails,
|
|
181
|
+
* and never guesses at an upgrade mechanism for a non-global (e.g. npx) run.
|
|
182
|
+
*/
|
|
183
|
+
export async function update() {
|
|
184
|
+
if (!(await isGloballyInstalled())) {
|
|
185
|
+
console.log("[data-loom] not installed globally, so it can't self-update.");
|
|
186
|
+
console.log("[data-loom] run: npm install -g @lyric_dev/data-loom@latest");
|
|
187
|
+
console.log("[data-loom] (or re-run npx @lyric_dev/data-loom@latest to pick up the new version)");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log("[data-loom] upgrading @lyric_dev/data-loom...");
|
|
191
|
+
try {
|
|
192
|
+
await npmInstallLatest();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error(`[data-loom] upgrade failed: ${err instanceof Error ? err.message : err}`);
|
|
196
|
+
console.error("[data-loom] no restart or re-registration performed.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
console.log("[data-loom] upgrade complete.");
|
|
200
|
+
if (await isRunning()) {
|
|
201
|
+
console.log("[data-loom] restarting the daemon on the new version...");
|
|
202
|
+
await restart();
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
console.log("[data-loom] no daemon running — nothing to restart.");
|
|
206
|
+
}
|
|
207
|
+
if (await autostart.isEnabled()) {
|
|
208
|
+
console.log("[data-loom] refreshing the autostart registration...");
|
|
209
|
+
const info = await autostart.enable();
|
|
210
|
+
console.log(`[data-loom] autostart re-registered (${info.mechanism}${info.supervised ? ", supervised" : ", no restart supervision"})`);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
console.log("[data-loom] autostart not enabled — nothing to re-register.");
|
|
214
|
+
}
|
|
101
215
|
}
|
package/dist/mcpServer.js
CHANGED
|
@@ -7,16 +7,15 @@
|
|
|
7
7
|
// server serves every project: the target project is resolved per call from an
|
|
8
8
|
// explicit `project` argument, falling back to the daemon's current dashboard
|
|
9
9
|
// selection. The server holds no single project frozen for its lifetime.
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join, resolve } from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
15
12
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
17
14
|
import { OpenSpecClient } from "./openspecClient.js";
|
|
18
15
|
import { deriveModel } from "./derive.js";
|
|
19
16
|
import { discoverProjects, isViewableProject } from "./projects.js";
|
|
17
|
+
import { provisionWeaveAlias } from "./weaveAlias.js";
|
|
18
|
+
import { VERSION } from "./version.js";
|
|
20
19
|
/**
|
|
21
20
|
* A validation error whose message is safe to return to the client because it
|
|
22
21
|
* only references caller-supplied input (a change name, a path the caller named).
|
|
@@ -25,19 +24,6 @@ import { discoverProjects, isViewableProject } from "./projects.js";
|
|
|
25
24
|
*/
|
|
26
25
|
class ToolError extends Error {
|
|
27
26
|
}
|
|
28
|
-
// The advertised server version — read from the package manifest shipped next
|
|
29
|
-
// to the compiled code, so it can never drift from the released version again
|
|
30
|
-
// (it was hardcoded and stuck at 0.4.1 through the 0.5.0 release).
|
|
31
|
-
const VERSION = (() => {
|
|
32
|
-
try {
|
|
33
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
34
|
-
const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
35
|
-
return manifest.version ?? "0.0.0";
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return "0.0.0";
|
|
39
|
-
}
|
|
40
|
-
})();
|
|
41
27
|
// Advertised to the client on connect. The "confirm before writing" gate lives
|
|
42
28
|
// here, in the agent's behavior — the server cannot verify a human approved.
|
|
43
29
|
const INSTRUCTIONS = `This server exposes a project's open OpenSpec proposals and lets you record the dependency order between them. It holds no model and cannot infer anything on its own — the judgment is yours and the decision is the user's.
|
|
@@ -51,19 +37,15 @@ When you connect, call list_open_proposals (passing the project you are working
|
|
|
51
37
|
|
|
52
38
|
Never write a dependency the user has not confirmed. Proposals already marked "declared" need no action.
|
|
53
39
|
|
|
54
|
-
Tip:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
tags: [loom, dependencies, mcp, review]
|
|
62
|
-
---
|
|
63
|
-
|
|
64
|
-
Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
|
|
40
|
+
Tip: register this server with \`data-loom connect claude-code\` — it provisions both the MCP registration and the \`/loom:weave\` command in one step, so one reload picks up both. If you registered another way, call install_weave_skill once to add \`/loom:weave\` yourself.`;
|
|
41
|
+
const WEAVE_PROMPT_NAME = "weave";
|
|
42
|
+
const WEAVE_PROMPT_DESCRIPTION = "Review the open proposals and weave their dependency order via the data-loom MCP server";
|
|
43
|
+
// The full review workflow, served as an MCP prompt (the single source of
|
|
44
|
+
// truth — see design.md) and fetched by the thin `/loom:weave` alias installed
|
|
45
|
+
// on disk. Only orchestrates this server's own tools.
|
|
46
|
+
const WEAVE_PROMPT = `Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
|
|
65
47
|
|
|
66
|
-
**Prerequisite:** the DataLoom dashboard daemon must be running and the data-loom MCP server registered in this session — its tools are \`list_open_proposals\`, \`set_dependency\`, \`mark_independent\`, and \`list_projects\`. If those tools are not available, the daemon is almost certainly not running: tell the user to
|
|
48
|
+
**Prerequisite:** the DataLoom dashboard daemon must be running and the data-loom MCP server registered in this session — its tools are \`list_open_proposals\`, \`set_dependency\`, \`mark_independent\`, and \`list_projects\`. If those tools are not available, the daemon is almost certainly not running or not registered: tell the user to check \`data-loom status\`, start it with \`data-loom start\` if needed, and register it with \`data-loom connect claude-code\` if needed, then stop.
|
|
67
49
|
|
|
68
50
|
Steps:
|
|
69
51
|
|
|
@@ -90,7 +72,19 @@ const PROJECT_ARG = {
|
|
|
90
72
|
* the dashboard selection is the single fallback source of project truth.
|
|
91
73
|
*/
|
|
92
74
|
export function createMcpServer(deps) {
|
|
93
|
-
const server = new Server({ name: "data-loom", version: VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
75
|
+
const server = new Server({ name: "data-loom", version: VERSION }, { capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS });
|
|
76
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
77
|
+
prompts: [{ name: WEAVE_PROMPT_NAME, description: WEAVE_PROMPT_DESCRIPTION }],
|
|
78
|
+
}));
|
|
79
|
+
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
80
|
+
if (req.params.name !== WEAVE_PROMPT_NAME) {
|
|
81
|
+
throw new ToolError(`unknown prompt: ${req.params.name}`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
description: WEAVE_PROMPT_DESCRIPTION,
|
|
85
|
+
messages: [{ role: "user", content: { type: "text", text: WEAVE_PROMPT } }],
|
|
86
|
+
};
|
|
87
|
+
});
|
|
94
88
|
// Resolve the target project for a call: explicit arg, then dashboard
|
|
95
89
|
// selection, then an instructive error. Validated as a real workspace before
|
|
96
90
|
// any read or write.
|
|
@@ -150,7 +144,7 @@ export function createMcpServer(deps) {
|
|
|
150
144
|
},
|
|
151
145
|
{
|
|
152
146
|
name: "install_weave_skill",
|
|
153
|
-
description: "Install the `/loom:weave` slash command into the user's global Claude commands directory (~/.claude/commands/loom/weave.md), so this dependency review can be run as a single command from any project. Writes a static
|
|
147
|
+
description: "Install the `/loom:weave` slash command into the user's global Claude commands directory (~/.claude/commands/loom/weave.md) — a thin, version-stamped alias that fetches and follows this server's `weave` prompt, so this dependency review can be run as a single command from any project. `data-loom connect claude-code` already provisions this automatically; use this tool as a fallback when the command registered another way. Writes a static alias file only (no project data or secrets) and overwrites any existing copy. Run once, then the user reloads Claude Code.",
|
|
154
148
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
155
149
|
},
|
|
156
150
|
],
|
|
@@ -261,15 +255,12 @@ async function markIndependent(client, changesDir, project, change) {
|
|
|
261
255
|
return { project, change, written, dependencyReview: "declared" };
|
|
262
256
|
}
|
|
263
257
|
/**
|
|
264
|
-
* Provision the `/loom:weave`
|
|
265
|
-
* dir, so the review runs as one command from any project.
|
|
266
|
-
*
|
|
258
|
+
* Provision the `/loom:weave` alias into the user's GLOBAL Claude commands
|
|
259
|
+
* dir, so the review runs as one command from any project. The alias delegates
|
|
260
|
+
* to this server's `weave` prompt; see weaveAlias.ts.
|
|
267
261
|
*/
|
|
268
262
|
async function installWeaveSkill() {
|
|
269
|
-
const
|
|
270
|
-
const path = join(dir, "weave.md");
|
|
271
|
-
await mkdir(dir, { recursive: true });
|
|
272
|
-
await writeFile(path, WEAVE_COMMAND);
|
|
263
|
+
const path = await provisionWeaveAlias(VERSION);
|
|
273
264
|
return {
|
|
274
265
|
installed: true,
|
|
275
266
|
command: "/loom:weave",
|