@lyric_dev/data-loom 0.5.0 → 0.7.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 +46 -16
- package/dist/autostart.js +223 -36
- package/dist/claudeCode.js +144 -0
- package/dist/index.js +257 -33
- package/dist/lifecycle.js +116 -2
- package/dist/mcpServer.js +30 -24
- package/dist/mcpShim.js +144 -0
- package/dist/paths.js +6 -0
- package/dist/server.js +63 -3
- package/dist/tray.js +142 -0
- package/dist/version.js +18 -0
- package/dist/weaveAlias.js +66 -0
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -12,10 +12,15 @@ 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
|
+
import * as claudeCode from "./claudeCode.js";
|
|
20
|
+
import { runShim } from "./mcpShim.js";
|
|
21
|
+
import { refreshWeaveAliasIfOutdated } from "./weaveAlias.js";
|
|
22
|
+
import { VERSION } from "./version.js";
|
|
23
|
+
import { initTray } from "./tray.js";
|
|
19
24
|
const host = HOST;
|
|
20
25
|
const port = PORT;
|
|
21
26
|
// Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
|
|
@@ -33,6 +38,14 @@ async function main() {
|
|
|
33
38
|
"data_loom does not bundle it — install it separately, e.g. `npm i -g openspec`, then retry.");
|
|
34
39
|
process.exit(1);
|
|
35
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
|
+
}
|
|
36
49
|
// Resolve a viewable project: the launch dir, else the first discovered one,
|
|
37
50
|
// else none. Never exit just because the launch dir isn't a project.
|
|
38
51
|
let active = isViewableProject(initialProject) ? initialProject : null;
|
|
@@ -46,28 +59,70 @@ async function main() {
|
|
|
46
59
|
else {
|
|
47
60
|
console.log("[data-loom] no openspec project found at launch — open the dashboard and pick one");
|
|
48
61
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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);
|
|
65
|
+
try {
|
|
66
|
+
server = await startServer({
|
|
67
|
+
publicDir: resolvePublicDir(),
|
|
68
|
+
host,
|
|
69
|
+
port,
|
|
70
|
+
getRoadmap: () => session?.model ?? null,
|
|
71
|
+
getMcp,
|
|
72
|
+
checkMcp,
|
|
73
|
+
getProjects,
|
|
74
|
+
selectProject,
|
|
75
|
+
getCurrentProject: () => session?.project ?? null,
|
|
76
|
+
onShutdown: () => shutdownRef(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (err.code === "EADDRINUSE") {
|
|
81
|
+
// A DataLoom daemon is already on this port (e.g. the background one).
|
|
82
|
+
// That's fine — point at the running instance instead of crashing.
|
|
83
|
+
console.log(`[data-loom] already running at http://${host}:${port} — using that instance.`);
|
|
84
|
+
openBrowser(`http://${host}:${port}`);
|
|
85
|
+
session?.stopWatch(); // release the watcher started for this aborted launch
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
const url = `http://${host}:${server.port}`;
|
|
91
|
+
console.log(`[data-loom] dashboard ready at ${url}`);
|
|
61
92
|
if (session)
|
|
62
93
|
console.log(`[data-loom] project: ${session.project}`);
|
|
63
|
-
openBrowser(
|
|
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
|
+
}
|
|
102
|
+
// Tray icon: an ambient "DataLoom is running" indicator (essential in the
|
|
103
|
+
// detached mode, which has no console). Guarded no-op where unavailable.
|
|
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.
|
|
64
110
|
const shutdown = () => {
|
|
111
|
+
tray.dispose();
|
|
65
112
|
session?.stopWatch();
|
|
66
113
|
server?.close();
|
|
67
114
|
process.exit(0);
|
|
68
115
|
};
|
|
116
|
+
shutdownRef = shutdown; // route the loopback /api/shutdown POST here too
|
|
69
117
|
process.on("SIGINT", shutdown);
|
|
70
118
|
process.on("SIGTERM", shutdown);
|
|
119
|
+
tray = initTray({
|
|
120
|
+
url,
|
|
121
|
+
onOpen: () => launchBrowser(url),
|
|
122
|
+
onCopy: () => copyToClipboard(url),
|
|
123
|
+
onStop: shutdown,
|
|
124
|
+
log: (msg) => console.error(msg),
|
|
125
|
+
});
|
|
71
126
|
}
|
|
72
127
|
async function buildSession(project) {
|
|
73
128
|
const client = new OpenSpecClient(project);
|
|
@@ -132,6 +187,10 @@ function openBrowser(url) {
|
|
|
132
187
|
// terminal, so never pop a browser for them (nor when explicitly suppressed).
|
|
133
188
|
if (process.env.DATA_LOOM_NO_OPEN || process.env.DATA_LOOM_DETACHED)
|
|
134
189
|
return;
|
|
190
|
+
launchBrowser(url);
|
|
191
|
+
}
|
|
192
|
+
/** Open a URL in the default browser unconditionally (used by the tray). */
|
|
193
|
+
function launchBrowser(url) {
|
|
135
194
|
try {
|
|
136
195
|
if (process.platform === "win32") {
|
|
137
196
|
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
|
|
@@ -147,20 +206,68 @@ function openBrowser(url) {
|
|
|
147
206
|
/* non-fatal — the URL is already logged */
|
|
148
207
|
}
|
|
149
208
|
}
|
|
209
|
+
/** Best-effort copy of text to the system clipboard; failure is silent. */
|
|
210
|
+
function copyToClipboard(text) {
|
|
211
|
+
try {
|
|
212
|
+
const proc = process.platform === "win32"
|
|
213
|
+
? spawn("clip")
|
|
214
|
+
: process.platform === "darwin"
|
|
215
|
+
? spawn("pbcopy")
|
|
216
|
+
: spawn("xclip", ["-selection", "clipboard"]);
|
|
217
|
+
proc.on("error", () => { });
|
|
218
|
+
proc.stdin?.end(text);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* no clipboard tool available — non-fatal */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
150
224
|
// ---- CLI verb dispatch -----------------------------------------------------
|
|
151
225
|
// A leading reserved verb selects a lifecycle/autostart/integration command;
|
|
152
226
|
// anything else falls through to the foreground daemon (with argv[2] as the
|
|
153
227
|
// optional project path), preserving the original invocation unchanged.
|
|
154
|
-
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
|
+
]);
|
|
155
240
|
async function runAutostart(rest) {
|
|
156
241
|
const sub = rest[0];
|
|
157
242
|
if (sub === "enable") {
|
|
158
|
-
await autostart.enable();
|
|
159
|
-
console.log(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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")) {
|
|
163
257
|
await lifecycle.start();
|
|
258
|
+
}
|
|
259
|
+
// ...and points Claude Code at it, so the always-on path both hosts and
|
|
260
|
+
// registers the MCP endpoint. Best-effort: a missing `claude` CLI must not
|
|
261
|
+
// fail the enable (the login item + daemon already succeeded). Opt out with
|
|
262
|
+
// --no-connect.
|
|
263
|
+
if (!rest.includes("--no-connect")) {
|
|
264
|
+
try {
|
|
265
|
+
await claudeCode.connect();
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
console.warn(`[data-loom] could not register with Claude Code (continuing): ${err instanceof Error ? err.message : err}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
164
271
|
return;
|
|
165
272
|
}
|
|
166
273
|
if (sub === "disable") {
|
|
@@ -174,27 +281,140 @@ async function runAutostart(rest) {
|
|
|
174
281
|
: "[data-loom] autostart is not enabled");
|
|
175
282
|
return;
|
|
176
283
|
}
|
|
177
|
-
throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start]");
|
|
284
|
+
throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start] [--no-connect]");
|
|
178
285
|
}
|
|
179
286
|
async function runConnect(rest) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
287
|
+
const target = rest[0];
|
|
288
|
+
if (target === "claude-code") {
|
|
289
|
+
const onDemand = rest.includes("--on-demand");
|
|
290
|
+
const { registered, switchedFrom } = await claudeCode.connect({ onDemand });
|
|
291
|
+
if (registered) {
|
|
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.");
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (target === "claude-desktop") {
|
|
305
|
+
const bridge = rest.includes("--bridge");
|
|
306
|
+
const path = await claudeDesktop.connect({ bridge });
|
|
307
|
+
console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
|
|
308
|
+
console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
throw new Error("usage: data-loom connect <claude-code [--on-demand]|claude-desktop [--bridge]>");
|
|
186
312
|
}
|
|
187
313
|
async function runDisconnect(rest) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
314
|
+
const target = rest[0];
|
|
315
|
+
if (target === "claude-code") {
|
|
316
|
+
const { removed } = await claudeCode.disconnect();
|
|
317
|
+
console.log(removed
|
|
318
|
+
? "[data-loom] removed DataLoom from Claude Code"
|
|
319
|
+
: "[data-loom] DataLoom was not registered in Claude Code — nothing to remove");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (target === "claude-desktop") {
|
|
323
|
+
const { path, removed } = await claudeDesktop.disconnect();
|
|
324
|
+
console.log(removed
|
|
325
|
+
? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
|
|
326
|
+
: "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
throw new Error("usage: data-loom disconnect <claude-code|claude-desktop>");
|
|
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
|
+
}
|
|
194
412
|
}
|
|
195
413
|
async function runCli(argv) {
|
|
196
414
|
const [verb, ...rest] = argv;
|
|
197
415
|
switch (verb) {
|
|
416
|
+
case "up":
|
|
417
|
+
return runUp(rest);
|
|
198
418
|
case "start":
|
|
199
419
|
return lifecycle.start(rest[0]);
|
|
200
420
|
case "stop":
|
|
@@ -203,12 +423,16 @@ async function runCli(argv) {
|
|
|
203
423
|
return lifecycle.restart(rest[0]);
|
|
204
424
|
case "status":
|
|
205
425
|
return lifecycle.status();
|
|
426
|
+
case "update":
|
|
427
|
+
return lifecycle.update();
|
|
206
428
|
case "autostart":
|
|
207
429
|
return runAutostart(rest);
|
|
208
430
|
case "connect":
|
|
209
431
|
return runConnect(rest);
|
|
210
432
|
case "disconnect":
|
|
211
433
|
return runDisconnect(rest);
|
|
434
|
+
case "mcp-shim":
|
|
435
|
+
return runShim();
|
|
212
436
|
default:
|
|
213
437
|
throw new Error(`unknown command: ${verb}`);
|
|
214
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,14 +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 { homedir } from "node:os";
|
|
10
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
12
11
|
import { join, resolve } from "node:path";
|
|
13
12
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
14
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
15
14
|
import { OpenSpecClient } from "./openspecClient.js";
|
|
16
15
|
import { deriveModel } from "./derive.js";
|
|
17
16
|
import { discoverProjects, isViewableProject } from "./projects.js";
|
|
17
|
+
import { provisionWeaveAlias } from "./weaveAlias.js";
|
|
18
|
+
import { VERSION } from "./version.js";
|
|
18
19
|
/**
|
|
19
20
|
* A validation error whose message is safe to return to the client because it
|
|
20
21
|
* only references caller-supplied input (a change name, a path the caller named).
|
|
@@ -36,19 +37,15 @@ When you connect, call list_open_proposals (passing the project you are working
|
|
|
36
37
|
|
|
37
38
|
Never write a dependency the user has not confirmed. Proposals already marked "declared" need no action.
|
|
38
39
|
|
|
39
|
-
Tip:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
tags: [loom, dependencies, mcp, review]
|
|
47
|
-
---
|
|
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.
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
**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 **start DataLoom** (\`npx @lyric_dev/data-loom "<project path>"\`) and, if they have not already, register it once with \`claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp\`, then stop.
|
|
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.
|
|
52
49
|
|
|
53
50
|
Steps:
|
|
54
51
|
|
|
@@ -75,7 +72,19 @@ const PROJECT_ARG = {
|
|
|
75
72
|
* the dashboard selection is the single fallback source of project truth.
|
|
76
73
|
*/
|
|
77
74
|
export function createMcpServer(deps) {
|
|
78
|
-
const server = new Server({ name: "data-loom", version:
|
|
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
|
+
});
|
|
79
88
|
// Resolve the target project for a call: explicit arg, then dashboard
|
|
80
89
|
// selection, then an instructive error. Validated as a real workspace before
|
|
81
90
|
// any read or write.
|
|
@@ -135,7 +144,7 @@ export function createMcpServer(deps) {
|
|
|
135
144
|
},
|
|
136
145
|
{
|
|
137
146
|
name: "install_weave_skill",
|
|
138
|
-
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.",
|
|
139
148
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
140
149
|
},
|
|
141
150
|
],
|
|
@@ -246,15 +255,12 @@ async function markIndependent(client, changesDir, project, change) {
|
|
|
246
255
|
return { project, change, written, dependencyReview: "declared" };
|
|
247
256
|
}
|
|
248
257
|
/**
|
|
249
|
-
* Provision the `/loom:weave`
|
|
250
|
-
* dir, so the review runs as one command from any project.
|
|
251
|
-
*
|
|
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.
|
|
252
261
|
*/
|
|
253
262
|
async function installWeaveSkill() {
|
|
254
|
-
const
|
|
255
|
-
const path = join(dir, "weave.md");
|
|
256
|
-
await mkdir(dir, { recursive: true });
|
|
257
|
-
await writeFile(path, WEAVE_COMMAND);
|
|
263
|
+
const path = await provisionWeaveAlias(VERSION);
|
|
258
264
|
return {
|
|
259
265
|
installed: true,
|
|
260
266
|
command: "/loom:weave",
|