@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 CHANGED
@@ -17,30 +17,34 @@ npm install -g openspec
17
17
 
18
18
  (data-loom invokes your installed `openspec`; if it's missing, it exits with this guidance instead of showing a blank dashboard.)
19
19
 
20
- ## Get it (recommended): install from npm
20
+ ## Get started (one command)
21
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):
22
+ Requires [Node.js](https://nodejs.org) ≥ 20. From any project (a directory with an `openspec/` workspace), a single command takes a fresh machine to a fully working, always-on DataLoom:
25
23
 
26
24
  ```
27
- npx @lyric_dev/data-loom "C:\path\to\your\project"
25
+ npx @lyric_dev/data-loom up "C:\path\to\your\project"
28
26
  ```
29
27
 
30
- Or install it globally and run the `data-loom` command:
28
+ `data-loom up` checks the `openspec` prerequisite up front (and stops with the install command if it's missing, before changing anything), registers login autostart, starts the background daemon, and registers DataLoom with Claude Code — then prints a summary: dashboard URL and daemon state, autostart state, Claude Code registration, and a `/loom:weave` pointer. It's safe to re-run on an already-configured host it reports each part as already in place, so it doubles as a "check my setup" command. Pass `--no-start` (register for next login only) or `--no-connect` (skip the Claude Code registration). Then open <http://127.0.0.1:4317>.
29
+
30
+ For a durable setup, install globally so autostart points at a stable location instead of an ephemeral npx cache (`up` reminds you of this when run via npx):
31
31
 
32
32
  ```
33
33
  npm install -g @lyric_dev/data-loom
34
- data-loom "C:\path\to\your\project"
34
+ data-loom up "C:\path\to\your\project"
35
35
  ```
36
36
 
37
- With no argument it uses the current directory. Then open <http://127.0.0.1:4317>.
37
+ **Just want a quick look?** Run it in the foreground with no setup — it stops when you close the terminal (with no argument it uses the current directory):
38
+
39
+ ```
40
+ npx @lyric_dev/data-loom "C:\path\to\your\project"
41
+ ```
38
42
 
39
43
  > Published to npm automatically by CI on version tags (`vX.Y.Z`) via GitHub Actions.
40
44
 
41
- ## Run it always-on (background + autostart)
45
+ ## Run it always-on (manual controls)
42
46
 
43
- The command above runs in the foreground and stops when you close the terminal. Since the daemon also hosts the MCP endpoint, you usually want it always running. Manage a detached background daemon with:
47
+ `data-loom up` (above) sets all of this up in one shot; the commands here are the individual controls it composes, for when you want to manage the pieces directly. Since the daemon also hosts the MCP endpoint, you usually want it always running. Manage a detached background daemon with:
44
48
 
45
49
  ```
46
50
  data-loom start [C:\path\to\project] # launch detached; returns immediately
@@ -51,7 +55,7 @@ data-loom restart [path] # stop then start
51
55
 
52
56
  There is at most one instance: `start` while one is already running is a no-op that just reports it. Background output goes to a log file (path shown by `status`) since there's no attached console — on Windows, `%LOCALAPPDATA%\data-loom\daemon.log` (macOS `~/Library/Application Support/data-loom/`, Linux `${XDG_STATE_HOME:-~/.local/state}/data-loom/`).
53
57
 
54
- To have it launch automatically when you log in:
58
+ To have it launch automatically when you log in — and restart itself if it ever crashes:
55
59
 
56
60
  ```
57
61
  data-loom autostart enable # register a per-user login item AND start it now
@@ -59,9 +63,19 @@ data-loom autostart status # is autostart registered?
59
63
  data-loom autostart disable # remove the login item (does not stop a running daemon)
60
64
  ```
61
65
 
62
- `enable` also starts the daemon immediately; pass `--no-start` to only register for next login. The login item is per-user and needs no admin rights a Startup-folder shortcut on Windows, a LaunchAgent on macOS, an XDG autostart entry on Linux.
66
+ `enable` also starts the daemon immediately **and registers DataLoom with Claude Code** (the same as `data-loom connect claude-code`), so the always-on path both hosts the MCP endpoint and points Claude Code at it in one command. Pass `--no-start` to only register for next login, or `--no-connect` to skip the Claude Code registration. The Claude Code step is best-effort if the `claude` CLI isn't found, `enable` warns and still sets up the login item and daemon.
67
+
68
+ The login item is per-user and needs no admin rights, and it **supervises** the daemon so a crash restarts it automatically (a deliberate `data-loom stop` stays stopped) — a per-user Scheduled Task on Windows, a LaunchAgent with `KeepAlive` on macOS, a systemd user unit with `Restart=on-failure` on Linux. `data-loom status` reports which mechanism is registered and whether it's supervised. If the supervising mechanism can't be created on your host (e.g. Task Scheduler unavailable, no systemd user session), `enable` falls back to a plain login-item shortcut and tells you supervision isn't available — autostart still works, it just won't self-heal from a crash.
69
+
70
+ **Upgrading from an earlier version?** Re-running `data-loom autostart enable` migrates an existing plain login item to the supervised form automatically — no separate migration step. Or use the single command below, which upgrades and re-registers in one step:
71
+
72
+ ```
73
+ data-loom update # upgrade the global install, restart a running daemon, refresh autostart
74
+ ```
63
75
 
64
- Everything is reversible: `data-loom stop`, `data-loom autostart disable`, and `data-loom disconnect claude-desktop` (below) undo each side effect, and each is idempotent.
76
+ `update` upgrades the globally-installed package, restarts the daemon if one is running, and rewrites the autostart registration (including migrating to the supervised form) if autostart is enabled reporting each step. It only works for a global npm install; if you run DataLoom via `npx`, it tells you so instead of guessing.
77
+
78
+ Everything is reversible: `data-loom stop`, `data-loom autostart disable`, `data-loom disconnect claude-code`, and `data-loom disconnect claude-desktop` (below) undo each side effect, and each is idempotent.
65
79
 
66
80
  ## Run from source (development)
67
81
 
@@ -85,14 +99,30 @@ npm start # serves the current directory's project
85
99
 
86
100
  The running daemon also **hosts an MCP server** over HTTP, 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. One registration serves **every** project; the target project is resolved per call (an explicit `project` argument, falling back to whatever the dashboard has selected).
87
101
 
88
- 1. Register it once, globally — no per-project setup:
102
+ 1. Register it once, globally — no per-project setup. The easy way:
103
+
104
+ ```
105
+ data-loom connect claude-code
106
+ ```
107
+
108
+ This registers DataLoom's loopback endpoint with Claude Code at user scope via Claude Code's own CLI (it runs `claude mcp add` for you; DataLoom never edits `~/.claude.json` itself) **and** provisions the `/loom:weave` command (see step 3) — one command, one reload, both the tools and `/loom:weave` are available. Remove both any time with `data-loom disconnect claude-code`. If the `claude` CLI isn't on your PATH, the command prints the manual line to run instead:
89
109
 
90
110
  ```
91
111
  claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
92
112
  ```
93
113
 
114
+ Either way, if you enable always-on autostart (below), `data-loom autostart enable` already runs this registration for you — so a fresh install can be one command.
115
+
94
116
  The MCP server lives in the daemon, so **DataLoom must be running** for the tools to be reachable (start it with `data-loom start` or `npx @lyric_dev/data-loom "C:\path\to\your\project"`). It binds to loopback only.
95
117
 
118
+ **Prefer not to think about starting DataLoom at all?** Register the on-demand form instead:
119
+
120
+ ```
121
+ data-loom connect claude-code --on-demand
122
+ ```
123
+
124
+ This registers a stdio server that runs `data-loom mcp-shim` instead of pointing at the HTTP endpoint directly. Claude Code spawns that shim per session; it starts the daemon itself (through the same detached path as `data-loom start`) if it isn't already running, waits for it to come up, then transparently forwards MCP traffic to it — the shim adds no tools of its own, it just gets the real daemon running. Pick this when you don't run `autostart enable` and don't want to remember to `data-loom start` first; keep the default HTTP form when you're already running the dashboard always-on, since it's one less process per session. Only one form is ever registered — switching re-runs the same command with `--on-demand` (or without it, to switch back), and it reports the switch. `data-loom disconnect claude-code` removes whichever form is present.
125
+
96
126
  **Claude Desktop** reaches the same daemon — register it with:
97
127
 
98
128
  ```
@@ -106,11 +136,11 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
106
136
  - `list_open_proposals(project?)` — the open changes with their proposal text, current phase/readiness, and dependency-review state (read-only; proposal text only, no secrets).
107
137
  - `set_dependency(from, to, project?)` — writes a `## Depends On` entry into a proposal.
108
138
  - `mark_independent(change, project?)` — records that a proposal genuinely depends on nothing, by writing an empty `## Depends On` block.
109
- - `install_weave_skill` — installs the `/loom:weave` shortcut command (one-time setup, see below).
139
+ - `install_weave_skill` — fallback installer for the `/loom:weave` command (see below); unnecessary if you registered with `connect claude-code`, which already provisions it.
110
140
 
111
141
  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.
112
142
 
113
- 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, passing that project explicitly. (It needs the daemon running; if the tools aren't reachable it tells you to start DataLoom.)
143
+ 3. **One command for it all: `/loom:weave`.** The whole review workflow — list, propose, confirm, apply — is served by the daemon itself as an MCP prompt named `weave`, so it always matches the running version. `data-loom connect claude-code` provisions a thin `/loom:weave` alias for it automatically (no separate install step); from then on, `/loom:weave` in any project fetches and runs the workflow, passing that project explicitly. If you registered another way, ask Claude to *"install the weave skill"* (it calls `install_weave_skill`) to add the alias yourself, then reload. (The alias needs the daemon running and registered; if unreachable it tells you to run `data-loom status` / `data-loom start` / `data-loom connect claude-code`.) Other MCP clients that support the prompts capability — e.g. Claude Desktop's prompt picker — can invoke the `weave` prompt directly, with no alias needed.
114
144
 
115
145
  > **Upgrading from an earlier version?** The MCP server used to be a per-project stdio registration (`claude mcp add data-loom -- npx … mcp "<path>"`). That mode is gone. Remove any old per-project `data-loom` registrations and add the single user-scope HTTP one above.
116
146
 
package/dist/autostart.js CHANGED
@@ -1,22 +1,85 @@
1
1
  // Per-user login autostart: register the background daemon to launch when the
2
- // user logs in, using the native, no-elevation mechanism for each OS —
3
- // Windows a shortcut in the per-user Startup folder
4
- // macOS a LaunchAgent plist with RunAtLoad
5
- // Linux an XDG autostart .desktop entry
6
- // Every registration launches `<node> <script> start`, so a login-triggered
7
- // launch shares the exact same detached/single-instance/logging path as a
8
- // manual `data-loom start`.
2
+ // user logs in, using the native, no-elevation, per-user SUPERVISING
3
+ // mechanism for each OS
4
+ // Windows a Scheduled Task (logon trigger, restart-on-failure)
5
+ // macOS a LaunchAgent plist with RunAtLoad + KeepAlive (crash-only)
6
+ // Linux a systemd user unit with Restart=on-failure
7
+ // Each of these executes the daemon directly (via the stable launcher below)
8
+ // in the FOREGROUND, so the supervisor tracks the daemon's own exit code and
9
+ // can restart it on crash — a launcher that detaches and returns (like `data-
10
+ // loom start`) would leave the supervisor watching nothing. When a platform's
11
+ // supervising mechanism can't be created or isn't available, registration
12
+ // falls back to the legacy, unsupervised form (Startup-folder shortcut /
13
+ // XDG autostart), which still launches the daemon via the normal detached
14
+ // `start` path. `enable()` migrates a legacy registration to the supervised
15
+ // form when possible; `disable()` removes both generations.
9
16
  import { spawn } from "node:child_process";
10
- import { writeFile, rm, access, mkdir } from "node:fs/promises";
17
+ import { writeFile, rm, access, mkdir, chmod, readFile } from "node:fs/promises";
11
18
  import { homedir } from "node:os";
12
19
  import { dirname, join } from "node:path";
20
+ import { launcherFile, logFile, ensureStateDir } from "./paths.js";
13
21
  const NODE = process.execPath;
14
22
  const SCRIPT = process.argv[1];
15
23
  const LABEL = "dev.lyric.data-loom";
24
+ const WIN_TASK_NAME = "DataLoom";
25
+ const SYSTEMD_UNIT_NAME = "data-loom.service";
16
26
  function exists(p) {
17
27
  return access(p).then(() => true, () => false);
18
28
  }
19
- // ---- Windows: Startup-folder shortcut -------------------------------------
29
+ /** Run a command, ignoring its outcome — used for best-effort teardown/reload steps. */
30
+ function runBestEffort(cmd, args) {
31
+ return new Promise((resolve) => {
32
+ const p = spawn(cmd, args, { windowsHide: true, stdio: "ignore" });
33
+ p.on("error", () => resolve());
34
+ p.on("exit", () => resolve());
35
+ });
36
+ }
37
+ /** Run a command and resolve true iff it exits 0 — used for existence/availability probes. */
38
+ function probe(cmd, args, timeout) {
39
+ return new Promise((resolve) => {
40
+ const p = spawn(cmd, args, { windowsHide: true, stdio: "ignore", timeout });
41
+ p.on("error", () => resolve(false));
42
+ p.on("exit", (code) => resolve(code === 0));
43
+ });
44
+ }
45
+ /**
46
+ * Write the stable launcher that OS supervisors invoke. It resolves
47
+ * `data-loom` from PATH at launch time, falling back to the node/script paths
48
+ * recorded when this was written, so a Node version-manager switch or an npm
49
+ * package relocation doesn't break the registration. It runs the daemon in
50
+ * the FOREGROUND with `DATA_LOOM_DETACHED=1` (no browser, daemon writes its
51
+ * own PID, output appended to the background log) and never backgrounds
52
+ * itself — the supervisor must see the daemon's own exit code.
53
+ */
54
+ export async function writeLauncher() {
55
+ await ensureStateDir();
56
+ const path = launcherFile();
57
+ const log = logFile();
58
+ if (process.platform === "win32") {
59
+ const script = "@echo off\r\n" +
60
+ "set DATA_LOOM_DETACHED=1\r\n" +
61
+ "where data-loom >nul 2>nul\r\n" +
62
+ "if %ERRORLEVEL%==0 (\r\n" +
63
+ ` data-loom >> "${log}" 2>&1\r\n` +
64
+ ") else (\r\n" +
65
+ ` "${NODE}" "${SCRIPT}" >> "${log}" 2>&1\r\n` +
66
+ ")\r\n";
67
+ await writeFile(path, script, "utf8");
68
+ }
69
+ else {
70
+ const script = "#!/bin/sh\n" +
71
+ "export DATA_LOOM_DETACHED=1\n" +
72
+ "if command -v data-loom >/dev/null 2>&1; then\n" +
73
+ ` exec data-loom >> "${log}" 2>&1\n` +
74
+ "else\n" +
75
+ ` exec "${NODE}" "${SCRIPT}" >> "${log}" 2>&1\n` +
76
+ "fi\n";
77
+ await writeFile(path, script, "utf8");
78
+ await chmod(path, 0o755);
79
+ }
80
+ return path;
81
+ }
82
+ // ---- Windows: Scheduled Task (supervised) / Startup-folder shortcut (legacy) ----
20
83
  function winStartupLnk() {
21
84
  const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
22
85
  return join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "data-loom.lnk");
@@ -28,29 +91,67 @@ function runPowerShell(script) {
28
91
  ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`powershell exited ${code}`))));
29
92
  });
30
93
  }
31
- async function winEnable() {
94
+ async function winScheduledTaskExists() {
95
+ return probe("schtasks", ["/Query", "/TN", WIN_TASK_NAME]);
96
+ }
97
+ async function winRegisterScheduledTask() {
98
+ const launcher = await writeLauncher();
99
+ const q = (s) => `'${s.replace(/'/g, "''")}'`;
100
+ const script = [
101
+ `$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c "' + ${q(launcher)} + '"')`,
102
+ "$trigger = New-ScheduledTaskTrigger -AtLogOn",
103
+ "$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries",
104
+ `Register-ScheduledTask -TaskName ${q(WIN_TASK_NAME)} -Action $action -Trigger $trigger -Settings $settings -RunLevel Limited -Force | Out-Null`,
105
+ ].join("; ");
106
+ await runPowerShell(script);
107
+ }
108
+ /** Legacy, unsupervised registration — a plain Startup-folder shortcut running the normal detached `start`. */
109
+ async function winEnableLegacyShortcut() {
32
110
  const lnk = winStartupLnk();
33
111
  await mkdir(dirname(lnk), { recursive: true });
34
- // Create the .lnk via WScript.Shell; WindowStyle 7 = minimized so the brief
35
- // launcher console doesn't grab focus (the daemon itself is detached).
36
112
  const q = (s) => `'${s.replace(/'/g, "''")}'`;
37
113
  await runPowerShell([
38
114
  "$s = (New-Object -ComObject WScript.Shell).CreateShortcut(" + q(lnk) + ")",
39
115
  "$s.TargetPath = " + q(NODE),
40
- '$s.Arguments = ' + q(`"${SCRIPT}" start`),
116
+ "$s.Arguments = " + q(`"${SCRIPT}" start`),
41
117
  "$s.WindowStyle = 7",
42
118
  "$s.Description = 'DataLoom background daemon'",
43
119
  "$s.Save()",
44
120
  ].join("; "));
45
121
  }
122
+ async function winEnable() {
123
+ try {
124
+ await winRegisterScheduledTask();
125
+ // Migrate away from any legacy shortcut now that the supervised form exists.
126
+ await rm(winStartupLnk(), { force: true }).catch(() => { });
127
+ // The Scheduled Task fires on the next logon, not now — the caller still
128
+ // needs to start the daemon for this session.
129
+ return { mechanism: "scheduled-task", supervised: true, startedNow: false };
130
+ }
131
+ catch {
132
+ // Scheduled Task creation failed (e.g. Task Scheduler service unavailable) —
133
+ // fall back to the legacy shortcut so autostart still works, just unsupervised.
134
+ await winEnableLegacyShortcut();
135
+ return { mechanism: "startup-shortcut", supervised: false, startedNow: false };
136
+ }
137
+ }
46
138
  async function winDisable() {
139
+ await runBestEffort("schtasks", ["/Delete", "/TN", WIN_TASK_NAME, "/F"]);
47
140
  await rm(winStartupLnk(), { force: true }).catch(() => { });
48
141
  }
49
- // ---- macOS: LaunchAgent ----------------------------------------------------
142
+ async function winGetRegistrationInfo() {
143
+ if (await winScheduledTaskExists())
144
+ return { enabled: true, mechanism: "scheduled-task", supervised: true };
145
+ if (await exists(winStartupLnk()))
146
+ return { enabled: true, mechanism: "startup-shortcut", supervised: false };
147
+ return { enabled: false, mechanism: "none", supervised: false };
148
+ }
149
+ // ---- macOS: LaunchAgent (KeepAlive = supervised) ---------------------------
50
150
  function macPlistPath() {
51
151
  return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
52
152
  }
53
153
  async function macEnable() {
154
+ const launcher = await writeLauncher();
54
155
  const path = macPlistPath();
55
156
  await mkdir(dirname(path), { recursive: true });
56
157
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
@@ -60,38 +161,87 @@ async function macEnable() {
60
161
  <key>Label</key><string>${LABEL}</string>
61
162
  <key>ProgramArguments</key>
62
163
  <array>
63
- <string>${NODE}</string>
64
- <string>${SCRIPT}</string>
65
- <string>start</string>
164
+ <string>/bin/sh</string>
165
+ <string>${launcher}</string>
66
166
  </array>
67
167
  <key>RunAtLoad</key><true/>
168
+ <key>KeepAlive</key>
169
+ <dict>
170
+ <key>SuccessfulExit</key><false/>
171
+ </dict>
68
172
  </dict>
69
173
  </plist>
70
174
  `;
71
175
  await writeFile(path, plist, "utf8");
72
- await new Promise((resolve) => {
73
- const p = spawn("launchctl", ["load", "-w", path], { stdio: "ignore" });
74
- p.on("error", () => resolve()); // best-effort; the plist is written regardless
75
- p.on("exit", () => resolve());
76
- });
176
+ // Unload first (best-effort) so a rewritten KeepAlive setting actually takes
177
+ // effect if the agent was already loaded from a prior (pre-supervision) version.
178
+ await runBestEffort("launchctl", ["unload", "-w", path]);
179
+ await runBestEffort("launchctl", ["load", "-w", path]);
180
+ // `load -w` with RunAtLoad launches the daemon now, so it's already up this
181
+ // session — the caller must not spawn a second instance that races it.
182
+ return { mechanism: "launch-agent", supervised: true, startedNow: true };
77
183
  }
78
184
  async function macDisable() {
79
185
  const path = macPlistPath();
80
186
  if (await exists(path)) {
81
- await new Promise((resolve) => {
82
- const p = spawn("launchctl", ["unload", "-w", path], { stdio: "ignore" });
83
- p.on("error", () => resolve());
84
- p.on("exit", () => resolve());
85
- });
187
+ await runBestEffort("launchctl", ["unload", "-w", path]);
86
188
  }
87
189
  await rm(path, { force: true }).catch(() => { });
88
190
  }
89
- // ---- Linux: XDG autostart --------------------------------------------------
191
+ async function macGetRegistrationInfo() {
192
+ const path = macPlistPath();
193
+ if (!(await exists(path)))
194
+ return { enabled: false, mechanism: "none", supervised: false };
195
+ const content = await readFile(path, "utf8").catch(() => "");
196
+ return { enabled: true, mechanism: "launch-agent", supervised: content.includes("KeepAlive") };
197
+ }
198
+ // ---- Linux: systemd user unit (supervised) / XDG autostart (legacy) -------
90
199
  function linuxDesktopPath() {
91
200
  const cfg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
92
201
  return join(cfg, "autostart", "data-loom.desktop");
93
202
  }
94
- async function linuxEnable() {
203
+ function systemdUnitDir() {
204
+ return join(homedir(), ".config", "systemd", "user");
205
+ }
206
+ function systemdUnitPath() {
207
+ return join(systemdUnitDir(), SYSTEMD_UNIT_NAME);
208
+ }
209
+ async function systemdUnitFileExists() {
210
+ return exists(systemdUnitPath());
211
+ }
212
+ /** Probe whether `systemctl --user` is usable (systemd present, user session/bus reachable). */
213
+ async function hasSystemdUserAvailable() {
214
+ return probe("systemctl", ["--user", "list-units", "--no-legend"], 3000);
215
+ }
216
+ /** True iff the supervised systemd unit is currently active — used by `stop` to avoid a raw SIGTERM racing systemd's own bookkeeping. */
217
+ export async function isSystemdUnitActive() {
218
+ if (process.platform !== "linux")
219
+ return false;
220
+ return probe("systemctl", ["--user", "is-active", "--quiet", SYSTEMD_UNIT_NAME]);
221
+ }
222
+ /** Stop the daemon through systemd rather than a raw signal, so the unit's state reflects an intentional stop. */
223
+ export async function stopSystemdUnit() {
224
+ await runBestEffort("systemctl", ["--user", "stop", SYSTEMD_UNIT_NAME]);
225
+ }
226
+ async function linuxSystemdEnable() {
227
+ const launcher = await writeLauncher();
228
+ await mkdir(systemdUnitDir(), { recursive: true });
229
+ const unit = "[Unit]\n" +
230
+ "Description=DataLoom background daemon\n" +
231
+ "\n" +
232
+ "[Service]\n" +
233
+ "Type=simple\n" +
234
+ `ExecStart=${launcher}\n` +
235
+ "Restart=on-failure\n" +
236
+ "\n" +
237
+ "[Install]\n" +
238
+ "WantedBy=default.target\n";
239
+ await writeFile(systemdUnitPath(), unit, "utf8");
240
+ await runBestEffort("systemctl", ["--user", "daemon-reload"]);
241
+ await runBestEffort("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT_NAME]);
242
+ }
243
+ /** Legacy, unsupervised registration — an XDG autostart entry running the normal detached `start`. */
244
+ async function linuxEnableXdg() {
95
245
  await mkdir(dirname(linuxDesktopPath()), { recursive: true });
96
246
  const entry = `[Desktop Entry]
97
247
  Type=Application
@@ -102,14 +252,47 @@ X-GNOME-Autostart-enabled=true
102
252
  `;
103
253
  await writeFile(linuxDesktopPath(), entry, "utf8");
104
254
  }
255
+ async function linuxEnable() {
256
+ if (await hasSystemdUserAvailable()) {
257
+ await linuxSystemdEnable();
258
+ // Migrate away from any legacy XDG entry now that the supervised form exists.
259
+ await rm(linuxDesktopPath(), { force: true }).catch(() => { });
260
+ // `enable --now` starts the unit immediately, so the daemon is already up
261
+ // this session — the caller must not spawn a second instance that races it.
262
+ return { mechanism: "systemd-unit", supervised: true, startedNow: true };
263
+ }
264
+ // No systemd user session available — fall back to XDG autostart, unsupervised.
265
+ // XDG entries only fire on the next login, so the caller still starts the
266
+ // daemon for this session.
267
+ await linuxEnableXdg();
268
+ return { mechanism: "xdg-autostart", supervised: false, startedNow: false };
269
+ }
105
270
  async function linuxDisable() {
271
+ if (await systemdUnitFileExists()) {
272
+ await runBestEffort("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT_NAME]);
273
+ await rm(systemdUnitPath(), { force: true }).catch(() => { });
274
+ await runBestEffort("systemctl", ["--user", "daemon-reload"]);
275
+ }
106
276
  await rm(linuxDesktopPath(), { force: true }).catch(() => { });
107
277
  }
278
+ async function linuxGetRegistrationInfo() {
279
+ if (await systemdUnitFileExists())
280
+ return { enabled: true, mechanism: "systemd-unit", supervised: true };
281
+ if (await exists(linuxDesktopPath()))
282
+ return { enabled: true, mechanism: "xdg-autostart", supervised: false };
283
+ return { enabled: false, mechanism: "none", supervised: false };
284
+ }
108
285
  // ---- Dispatch --------------------------------------------------------------
109
286
  function unsupported() {
110
287
  throw new Error(`[data-loom] autostart is not supported on platform "${process.platform}"`);
111
288
  }
112
- /** Register the daemon to launch at login (idempotent — overwrites any prior). */
289
+ /**
290
+ * Register the daemon to launch at login (idempotent — overwrites any prior).
291
+ * Prefers the OS's supervising mechanism so a crashed daemon restarts
292
+ * automatically; falls back to the legacy, unsupervised form when the
293
+ * supervising mechanism can't be created. Migrates away from a legacy
294
+ * registration when the supervised form succeeds.
295
+ */
113
296
  export async function enable() {
114
297
  if (process.platform === "win32")
115
298
  return winEnable();
@@ -119,7 +302,7 @@ export async function enable() {
119
302
  return linuxEnable();
120
303
  unsupported();
121
304
  }
122
- /** Remove the login registration (idempotent succeeds when not enabled). */
305
+ /** Remove the login registration — both the supervised and any legacy form (idempotent). */
123
306
  export async function disable() {
124
307
  if (process.platform === "win32")
125
308
  return winDisable();
@@ -129,13 +312,17 @@ export async function disable() {
129
312
  return linuxDisable();
130
313
  unsupported();
131
314
  }
132
- /** True iff a login registration currently exists for this user. */
315
+ /** True iff a login registration currently exists for this user, of either generation. */
133
316
  export async function isEnabled() {
317
+ return (await getRegistrationInfo()).enabled;
318
+ }
319
+ /** Full registration state: whether one exists, which mechanism, and whether it's supervised. */
320
+ export async function getRegistrationInfo() {
134
321
  if (process.platform === "win32")
135
- return exists(winStartupLnk());
322
+ return winGetRegistrationInfo();
136
323
  if (process.platform === "darwin")
137
- return exists(macPlistPath());
324
+ return macGetRegistrationInfo();
138
325
  if (process.platform === "linux")
139
- return exists(linuxDesktopPath());
140
- return false;
326
+ return linuxGetRegistrationInfo();
327
+ return { enabled: false, mechanism: "none", supervised: false };
141
328
  }
@@ -0,0 +1,144 @@
1
+ // Claude Code integration: register DataLoom's loopback MCP endpoint with
2
+ // Claude Code at user (global) scope, so the same running daemon that serves
3
+ // the dashboard also serves a Claude Code session.
4
+ //
5
+ // Unlike the Claude Desktop integration (which edits a small, DataLoom-owned
6
+ // config file directly), this registers through Claude Code's OWN CLI
7
+ // (`claude mcp add`/`remove`). DataLoom never reads or writes `~/.claude.json`
8
+ // itself — that file holds OAuth tokens and per-project history and is written
9
+ // live by any running session, so its one owner (the `claude` CLI) serializes
10
+ // the write. When the `claude` CLI is missing, connect degrades to printing the
11
+ // manual command rather than failing hard.
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { mcpUrl } from "./paths.js";
15
+ import { provisionWeaveAlias, removeWeaveAliasIfOurs } from "./weaveAlias.js";
16
+ import { VERSION } from "./version.js";
17
+ const execFileP = promisify(execFile);
18
+ const KEY = "data-loom";
19
+ // The on-demand form registers this PATH command (never node+script — same
20
+ // stable-launcher reasoning as autostart's supervised launcher), which starts
21
+ // the daemon if needed and proxies to it for the session (see mcpShim.ts).
22
+ const SHIM_COMMAND = "data-loom";
23
+ const SHIM_ARGS = ["mcp-shim"];
24
+ /** On Windows the CLI is a `.cmd`/`.ps1` shim, so it must be invoked through a shell. */
25
+ function runClaude(args) {
26
+ return execFileP("claude", args, { shell: process.platform === "win32" });
27
+ }
28
+ /** The registration command a user can run by hand when the `claude` CLI is absent. */
29
+ export function manualCommand(onDemand = false) {
30
+ return onDemand
31
+ ? `claude mcp add --scope user ${KEY} -- ${SHIM_COMMAND} ${SHIM_ARGS.join(" ")}`
32
+ : `claude mcp add --transport http --scope user ${KEY} ${mcpUrl()}`;
33
+ }
34
+ /** True iff the `claude` CLI is invocable (resolves its version). */
35
+ async function claudeAvailable() {
36
+ try {
37
+ await runClaude(["--version"]);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * The form of the current `data-loom` registration, or undefined when there is
46
+ * none (or it can't be determined). Parsed from `claude mcp get`'s human-
47
+ * readable output, so this is best-effort — used only to report a form switch
48
+ * to the user, never to decide correctness (removeRegistration + add below
49
+ * guarantees a single entry regardless).
50
+ */
51
+ async function currentForm() {
52
+ try {
53
+ const { stdout } = await runClaude(["mcp", "get", KEY]);
54
+ const m = /^\s*Type:\s*(\S+)/im.exec(stdout);
55
+ if (!m)
56
+ return undefined;
57
+ return m[1].toLowerCase() === "stdio" ? "stdio" : "http";
58
+ }
59
+ catch {
60
+ return undefined; // not registered, or `claude mcp get` itself failed
61
+ }
62
+ }
63
+ /** Remove any existing user-scope registration; tolerates "not registered". */
64
+ async function removeRegistration() {
65
+ try {
66
+ await runClaude(["mcp", "remove", "--scope", "user", KEY]);
67
+ }
68
+ catch {
69
+ /* not registered (or nothing to remove) — idempotent no-op */
70
+ }
71
+ }
72
+ /**
73
+ * Register DataLoom with Claude Code at user scope: by default the daemon's
74
+ * loopback HTTP MCP endpoint, or — with `onDemand` — the stdio shim that
75
+ * starts the daemon itself when needed. Idempotent upsert: remove any prior
76
+ * entry (of either form), then add, so exactly one current entry remains;
77
+ * when a registration of the other form existed, `switchedFrom` reports it.
78
+ * Returns `registered: false` (with printed guidance) when the `claude` CLI is
79
+ * unavailable, so callers can degrade gracefully instead of failing.
80
+ *
81
+ * Also provisions the `/loom:weave` alias, best-effort: a failure to write it
82
+ * warns but does not fail the connect, since the MCP registration is the
83
+ * primary outcome.
84
+ */
85
+ export async function connect(opts = {}) {
86
+ const onDemand = opts.onDemand ?? false;
87
+ if (!(await claudeAvailable())) {
88
+ console.log("[data-loom] the `claude` CLI was not found — cannot register automatically.\n" +
89
+ `[data-loom] register it by hand once with:\n ${manualCommand(onDemand)}`);
90
+ return { registered: false };
91
+ }
92
+ const existingForm = await currentForm();
93
+ await removeRegistration();
94
+ if (onDemand) {
95
+ await runClaude(["mcp", "add", "--scope", "user", KEY, "--", SHIM_COMMAND, ...SHIM_ARGS]);
96
+ }
97
+ else {
98
+ await runClaude(["mcp", "add", "--transport", "http", "--scope", "user", KEY, mcpUrl()]);
99
+ }
100
+ try {
101
+ await provisionWeaveAlias(VERSION);
102
+ }
103
+ catch (err) {
104
+ console.warn(`[data-loom] could not write the /loom:weave command (continuing): ${err instanceof Error ? err.message : err}`);
105
+ }
106
+ const requestedForm = onDemand ? "stdio" : "http";
107
+ return {
108
+ registered: true,
109
+ switchedFrom: existingForm && existingForm !== requestedForm ? existingForm : undefined,
110
+ };
111
+ }
112
+ /**
113
+ * Remove DataLoom's user-scope registration from Claude Code, whichever form
114
+ * (HTTP or on-demand stdio) is present — `claude mcp remove` deletes the entry
115
+ * by name only, so this is form-agnostic already. Idempotent — reports whether
116
+ * anything was actually registered. Throws only if the `claude` CLI itself is
117
+ * unavailable (nothing could have been registered without it).
118
+ *
119
+ * Also removes the `/loom:weave` alias, but only when its version stamp
120
+ * identifies it as ours — a file we don't recognize is left untouched. This is
121
+ * best-effort like the alias write in connect().
122
+ */
123
+ export async function disconnect() {
124
+ if (!(await claudeAvailable())) {
125
+ throw new Error("the `claude` CLI was not found — nothing could have been registered");
126
+ }
127
+ // `claude mcp get <name>` exits non-zero when the entry is absent; use it to
128
+ // report removed-vs-nothing without parsing `list` output.
129
+ let existed = true;
130
+ try {
131
+ await runClaude(["mcp", "get", KEY]);
132
+ }
133
+ catch {
134
+ existed = false;
135
+ }
136
+ await removeRegistration();
137
+ try {
138
+ await removeWeaveAliasIfOurs();
139
+ }
140
+ catch (err) {
141
+ console.warn(`[data-loom] could not remove the /loom:weave command (continuing): ${err instanceof Error ? err.message : err}`);
142
+ }
143
+ return { removed: existed };
144
+ }