@lyric_dev/data-loom 0.6.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 +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/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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
45
|
+
## Run it always-on (manual controls)
|
|
42
46
|
|
|
43
|
-
|
|
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,7 +63,17 @@ 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 **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.
|
|
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
|
+
```
|
|
75
|
+
|
|
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.
|
|
63
77
|
|
|
64
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
|
|
|
@@ -91,7 +105,7 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
|
|
|
91
105
|
data-loom connect claude-code
|
|
92
106
|
```
|
|
93
107
|
|
|
94
|
-
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)
|
|
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:
|
|
95
109
|
|
|
96
110
|
```
|
|
97
111
|
claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
|
|
@@ -101,6 +115,14 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
|
|
|
101
115
|
|
|
102
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.
|
|
103
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
|
+
|
|
104
126
|
**Claude Desktop** reaches the same daemon — register it with:
|
|
105
127
|
|
|
106
128
|
```
|
|
@@ -114,11 +136,11 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
|
|
|
114
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).
|
|
115
137
|
- `set_dependency(from, to, project?)` — writes a `## Depends On` entry into a proposal.
|
|
116
138
|
- `mark_independent(change, project?)` — records that a proposal genuinely depends on nothing, by writing an empty `## Depends On` block.
|
|
117
|
-
- `install_weave_skill` —
|
|
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.
|
|
118
140
|
|
|
119
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.
|
|
120
142
|
|
|
121
|
-
3. **One command for it all: `/loom:weave`.**
|
|
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.
|
|
122
144
|
|
|
123
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.
|
|
124
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
<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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
322
|
+
return winGetRegistrationInfo();
|
|
136
323
|
if (process.platform === "darwin")
|
|
137
|
-
return
|
|
324
|
+
return macGetRegistrationInfo();
|
|
138
325
|
if (process.platform === "linux")
|
|
139
|
-
return
|
|
140
|
-
return false;
|
|
326
|
+
return linuxGetRegistrationInfo();
|
|
327
|
+
return { enabled: false, mechanism: "none", supervised: false };
|
|
141
328
|
}
|
package/dist/claudeCode.js
CHANGED
|
@@ -12,15 +12,24 @@
|
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
13
|
import { promisify } from "node:util";
|
|
14
14
|
import { mcpUrl } from "./paths.js";
|
|
15
|
+
import { provisionWeaveAlias, removeWeaveAliasIfOurs } from "./weaveAlias.js";
|
|
16
|
+
import { VERSION } from "./version.js";
|
|
15
17
|
const execFileP = promisify(execFile);
|
|
16
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"];
|
|
17
24
|
/** On Windows the CLI is a `.cmd`/`.ps1` shim, so it must be invoked through a shell. */
|
|
18
25
|
function runClaude(args) {
|
|
19
26
|
return execFileP("claude", args, { shell: process.platform === "win32" });
|
|
20
27
|
}
|
|
21
28
|
/** The registration command a user can run by hand when the `claude` CLI is absent. */
|
|
22
|
-
export function manualCommand() {
|
|
23
|
-
return
|
|
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()}`;
|
|
24
33
|
}
|
|
25
34
|
/** True iff the `claude` CLI is invocable (resolves its version). */
|
|
26
35
|
async function claudeAvailable() {
|
|
@@ -32,6 +41,25 @@ async function claudeAvailable() {
|
|
|
32
41
|
return false;
|
|
33
42
|
}
|
|
34
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
|
+
}
|
|
35
63
|
/** Remove any existing user-scope registration; tolerates "not registered". */
|
|
36
64
|
async function removeRegistration() {
|
|
37
65
|
try {
|
|
@@ -42,26 +70,55 @@ async function removeRegistration() {
|
|
|
42
70
|
}
|
|
43
71
|
}
|
|
44
72
|
/**
|
|
45
|
-
* Register DataLoom with Claude Code at user scope
|
|
46
|
-
* loopback HTTP MCP endpoint
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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.
|
|
50
84
|
*/
|
|
51
|
-
export async function connect() {
|
|
85
|
+
export async function connect(opts = {}) {
|
|
86
|
+
const onDemand = opts.onDemand ?? false;
|
|
52
87
|
if (!(await claudeAvailable())) {
|
|
53
88
|
console.log("[data-loom] the `claude` CLI was not found — cannot register automatically.\n" +
|
|
54
|
-
`[data-loom] register it by hand once with:\n ${manualCommand()}`);
|
|
55
|
-
return false;
|
|
89
|
+
`[data-loom] register it by hand once with:\n ${manualCommand(onDemand)}`);
|
|
90
|
+
return { registered: false };
|
|
56
91
|
}
|
|
92
|
+
const existingForm = await currentForm();
|
|
57
93
|
await removeRegistration();
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
};
|
|
60
111
|
}
|
|
61
112
|
/**
|
|
62
|
-
* Remove DataLoom's user-scope registration from Claude Code
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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().
|
|
65
122
|
*/
|
|
66
123
|
export async function disconnect() {
|
|
67
124
|
if (!(await claudeAvailable())) {
|
|
@@ -77,5 +134,11 @@ export async function disconnect() {
|
|
|
77
134
|
existed = false;
|
|
78
135
|
}
|
|
79
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
|
+
}
|
|
80
143
|
return { removed: existed };
|
|
81
144
|
}
|