@lifeaitools/clauth 2.1.1 → 2.10.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.
Files changed (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2613 -11219
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +98 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
package/cli/index.js CHANGED
@@ -153,7 +153,6 @@ import { runServe, MCP_TOOLS } from './commands/serve.js';
153
153
  import { deregisterPlugin, listPlugins, registerPlugin, syncPluginsFromRepos, SYNC_REPO_NAMES, SYNC_SKIP_STATES } from './supervisor-registry.js';
154
154
  import { runOps } from './commands/ops.js';
155
155
  import { runOpsInstall } from './commands/ops-install.js';
156
- import { runCodevelop } from './commands/codevelop.js';
157
156
  import { runNpm, runPublish } from './commands/npm.js';
158
157
  import { runLogin } from './commands/login.js';
159
158
 
@@ -216,44 +215,6 @@ program
216
215
  await runUninstall(opts);
217
216
  });
218
217
 
219
- program
220
- .command("codevelop")
221
- .description("Install and launch Claude/Codex co-development terminal sessions")
222
- .argument("[action]", "install-terminal | start | join | say | read | watch | who | ask | reply | inbox | listen | request-partner | launch-peer | check-partner | sync | help", "help")
223
- .option("--repo <path>", "Repo root", "C:\\Dev\\regen-root")
224
- .option("--port <port>", "Isolated clauth port", "53137")
225
- .option("--base-url <url>", "Override clauth base URL")
226
- .option("--name <name>", "Session name")
227
- .option("--session <idOrManifestPath>", "Co-develop session id or manifest path")
228
- .option("--task <task>", "Initial partner request task")
229
- .option("--context <path>", "Context, plan, or architecture file the partner should read first")
230
- .option("--channel <name>", "Ad hoc channel name for join/say/read/watch")
231
- .option("--message <text>", "Ad hoc message for say")
232
- .option("--to <peer>", "Target peer for ask/reply, or optional direct ad hoc recipient for say")
233
- .option("--from <peer>", "Sender peer for ask/reply")
234
- .option("--turn <turn_id>", "Turn ID for reply")
235
- .option("--role <role>", "Turn role, e.g. reviewer or builder")
236
- .option("--skill <skill>", "Requested skill name, e.g. rdc:review")
237
- .option("--verdict <verdict>", "Reply verdict: pass, fail, or blocked")
238
- .option("--summary <summary>", "Reply summary")
239
- .option("--evidence <items>", "Reply evidence; separate multiple items with semicolons")
240
- .option("--files-changed <items>", "Reply changed files; separate multiple items with semicolons")
241
- .option("--commits <items>", "Reply commits; separate multiple items with semicolons")
242
- .option("--blockers <items>", "Reply blockers; separate multiple items with semicolons")
243
- .option("--next <items>", "Reply next actions; separate multiple items with semicolons")
244
- .option("--wait", "For ask: wait for a matching reply")
245
- .option("--once", "For listen: exit after the first message event")
246
- .option("--json", "For read: print raw JSON")
247
- .option("--timeout-ms <ms>", "Wait timeout in milliseconds", "300000")
248
- .option("--interval-ms <ms>", "Wait polling interval in milliseconds", "2000")
249
- .option("--start-isolated-clauth", "Start isolated clauth if the selected port is not running")
250
- .option("--peer <peer>", "Peer name for launch-peer/check-partner/sync, or ad hoc name such as codex-1")
251
- .option("--dry-run", "Print and write session manifest without opening Windows Terminal")
252
- .option("--no-open", "Create session/config but do not open Windows Terminal")
253
- .option("--print-only", "For launch-peer: resolve command without starting the CLI")
254
- .action(async (action, opts) => {
255
- await runCodevelop({ ...opts, action });
256
- });
257
218
 
258
219
  program
259
220
  .command("npm")
@@ -1106,56 +1067,6 @@ pluginCmd
1106
1067
  console.log(` ✓ ${chalk.white(receipt.target?.plugin_id || '?')} ${state} — surfaces: ${(receipt.resulting_state?.surfaces || []).join(', ') || 'none'}${target ? ` — ${chalk.gray(target)}` : ''}`);
1107
1068
  });
1108
1069
 
1109
- // ──────────────────────────────────────────────
1110
- // clauth chitchat --session <id>
1111
- // ──────────────────────────────────────────────
1112
- program
1113
- .command("chitchat")
1114
- .description("Join a chitchat collab session — auto-starts /rdc:collab via claude -p")
1115
- .requiredOption("--session <id>", "Session ID from claude.ai")
1116
- .action(async (opts) => {
1117
- const id = opts.session;
1118
- // Verify session exists in the daemon
1119
- try {
1120
- const r = await fetch(`http://127.0.0.1:52437/chitchat/${id}`, { signal: AbortSignal.timeout(3000) });
1121
- if (!r.ok) {
1122
- console.error(`\n ✗ Session ${id} not found (daemon returned ${r.status})\n`);
1123
- process.exit(1);
1124
- }
1125
- } catch (e) {
1126
- console.error(`\n ✗ Daemon not reachable at http://127.0.0.1:52437 — is clauth running?\n`);
1127
- process.exit(1);
1128
- }
1129
- console.log(chalk.cyan(`\n [collab] session ${id} — starting /rdc:collab...\n`));
1130
-
1131
- // Find claude binary (same candidates as daemon)
1132
- const { execSync: es, spawn } = await import("child_process");
1133
- let claudeBin = null;
1134
- for (const c of [
1135
- process.env.CLAUDE_BIN,
1136
- path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
1137
- path.join(process.env.APPDATA || '', 'npm', 'claude'),
1138
- 'claude',
1139
- ].filter(Boolean)) {
1140
- try { es(`"${c}" --version`, { stdio: 'ignore', timeout: 3000 }); claudeBin = c; break; } catch {}
1141
- }
1142
- if (!claudeBin) {
1143
- console.error(' ✗ claude CLI not found — is @anthropic-ai/claude-code installed globally?');
1144
- process.exit(1);
1145
- }
1146
-
1147
- // Auto-invoke /rdc:collab skill with streaming output to this terminal
1148
- const proc = spawn(claudeBin, [
1149
- '-p', `/rdc:collab --session ${id}`,
1150
- '--dangerously-skip-permissions',
1151
- ], {
1152
- stdio: 'inherit',
1153
- cwd: 'C:/Dev/regen-root',
1154
- shell: true,
1155
- });
1156
- proc.on('error', e => { console.error(` ✗ spawn error: ${e.message}`); process.exit(1); });
1157
- proc.on('exit', code => process.exit(code ?? 0));
1158
- });
1159
1070
 
1160
1071
  // ──────────────────────────────────────────────
1161
1072
  // clauth --help override banner
@@ -1175,7 +1086,7 @@ program.addHelpText("beforeAll", chalk.cyan(`
1175
1086
  // ──────────────────────────────────────────────
1176
1087
  program
1177
1088
  .command("serve [action]")
1178
- .description("Manage localhost HTTP vault daemon and supervisor (start|stop|restart|ping|supervisor|install|uninstall)")
1089
+ .description("Manage localhost HTTP vault daemon and supervisor (start|stop|restart|ping|test|supervisor|install|uninstall)")
1179
1090
  .option("--port <n>", "Port (default: 52437)")
1180
1091
  .option("-p, --pw <password>", "clauth password (optional — omit to start locked, unlock in browser)")
1181
1092
  .option("--services <list>", "Comma-separated service whitelist (default: all)")
@@ -1193,6 +1104,8 @@ Actions:
1193
1104
  restart Stop + start
1194
1105
  ping Check if the daemon is running
1195
1106
  foreground Run in foreground (Ctrl+C to stop) — default if no action given
1107
+ test Run an isolated, passwordless test lane on the fixed port 52440 —
1108
+ shorthand for: CLAUTH_SUPERVISOR_TEST_NO_TOKEN=1 serve foreground --isolated --port 52440
1196
1109
  mcp Run as MCP stdio server for Claude Code (JSON-RPC over stdin/stdout)
1197
1110
  supervisor Run localhost-only supervisor control plane on port 52439
1198
1111
  install Store password securely + register auto-start service (cross-platform)
@@ -1215,7 +1128,8 @@ Examples:
1215
1128
  clauth serve mcp -p mypass Start MCP server pre-unlocked
1216
1129
  clauth serve supervisor Start supervisor API on http://127.0.0.1:52439
1217
1130
  clauth serve foreground --port 53137 --isolated
1218
- Start isolated passwordless server for route tests
1131
+ Start isolated passwordless server for route tests (ad-hoc port)
1132
+ clauth serve test Start the fixed test lane on http://127.0.0.1:52440
1219
1133
  clauth serve install Set up auto-start on login (DPAPI/Keychain/libsecret)
1220
1134
  clauth serve install --tunnel host Auto-start with Cloudflare Tunnel
1221
1135
  clauth serve uninstall Remove auto-start
@@ -0,0 +1,467 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ import {
9
+ runGitAsync,
10
+ runGitPushAuthed,
11
+ gitInProgressState,
12
+ repoStatus,
13
+ useBranch,
14
+ commit,
15
+ diff,
16
+ FS_GIT_PROTECTED_BRANCHES,
17
+ } from "./fs-git.js";
18
+
19
+ // Real git repos in temp dirs — this is plumbing logic that talks to the real
20
+ // git binary, and a mock of git would just be re-asserting the mock. A bare
21
+ // repo as "origin" gives a real, local push target with no network and no
22
+ // token verification, so the success path is exercised for real, not stubbed.
23
+ function git(cwd, args) {
24
+ return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
25
+ }
26
+
27
+ function initRepo(dir) {
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ git(dir, ["init", "-q", "-b", "develop"]);
30
+ git(dir, ["config", "user.email", "test@clauth.local"]);
31
+ git(dir, ["config", "user.name", "clauth-test"]);
32
+ return dir;
33
+ }
34
+
35
+ function withRepo(fn) {
36
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-fsgit-test-"));
37
+ // realpathSync: on this box, os.tmpdir() resolves through the 8.3 short
38
+ // alias (DAVELA~1) while `git rev-parse --show-toplevel` always answers with
39
+ // the canonical long path (DaveLadouceur). commit()'s repo-root guard
40
+ // compares its caller-supplied `repoRoot` against that git-resolved value
41
+ // byte-for-byte, so a short-alias path fails the guard on a repo that is, in
42
+ // every real sense, the repo root. Canonicalizing here is the test fixture's
43
+ // job — passing a short-alias path is not the scenario "Invalid repo path"
44
+ // or "Base moved" are meant to cover.
45
+ const repo = fs.realpathSync.native(initRepo(path.join(root, "repo")));
46
+ return (async () => {
47
+ try { return await fn(repo, root); } finally { fs.rmSync(root, { recursive: true, force: true }); }
48
+ })();
49
+ }
50
+
51
+ function withRepoAndOrigin(fn) {
52
+ return withRepo(async (repo, root) => {
53
+ // A local bare repo stands in for "origin" — a real push target with
54
+ // no network and no token check, so success paths are exercised for real.
55
+ const bare = path.join(root, "origin.git");
56
+ git(root, ["init", "-q", "--bare", bare]);
57
+ fs.writeFileSync(path.join(repo, "README.md"), "# repo\n");
58
+ git(repo, ["add", "-A"]);
59
+ git(repo, ["commit", "-q", "-m", "initial"]);
60
+ git(repo, ["remote", "add", "origin", bare]);
61
+ git(repo, ["push", "-q", "-u", "origin", "develop"]);
62
+ return fn(repo, bare);
63
+ });
64
+ }
65
+
66
+ // ── runGitAsync ──────────────────────────────────────────────────────────────
67
+
68
+ test("runGitAsync resolves with trimmed stdout on success", async () =>
69
+ withRepo(async (repo) => {
70
+ // A fresh init with zero commits leaves HEAD pointing at an unborn branch;
71
+ // `rev-parse --abbrev-ref HEAD` treats that as an unresolvable revision on
72
+ // this git version ("ambiguous argument HEAD"). One commit is the minimum
73
+ // needed for HEAD to resolve at all — every other test in this file already
74
+ // commits first for the same reason.
75
+ git(repo, ["commit", "--allow-empty", "-q", "-m", "first"]);
76
+ const out = await runGitAsync(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
77
+ assert.equal(out, "develop");
78
+ }));
79
+
80
+ test("runGitAsync rejects with stderr detail on a non-zero exit", async () =>
81
+ withRepo(async (repo) => {
82
+ await assert.rejects(
83
+ () => runGitAsync(repo, ["rev-parse", "--verify", "refs/heads/does-not-exist"]),
84
+ /failed/,
85
+ );
86
+ }));
87
+
88
+ test("runGitAsync scrubs a secret substring from the error text", async () =>
89
+ withRepo(async (repo) => {
90
+ await assert.rejects(
91
+ () => runGitAsync(repo, ["show", "secretvalue123:nope"], { scrub: "secretvalue123", label: "git show" }),
92
+ (err) => {
93
+ assert.ok(!err.message.includes("secretvalue123"), `secret leaked into error: ${err.message}`);
94
+ assert.ok(err.message.includes("***"), "scrubbed text must be replaced with a marker, not silently dropped");
95
+ return true;
96
+ },
97
+ );
98
+ }));
99
+
100
+ test("runGitAsync uses opts.label instead of the raw args in the error", async () =>
101
+ withRepo(async (repo) => {
102
+ await assert.rejects(
103
+ () => runGitAsync(repo, ["rev-parse", "--verify", "nope"], { label: "custom label here" }),
104
+ /^Error: custom label here failed/,
105
+ );
106
+ }));
107
+
108
+ // ── runGitPushAuthed ─────────────────────────────────────────────────────────
109
+
110
+ test("runGitPushAuthed pushes successfully to a real local remote and never persists the token", async () =>
111
+ withRepoAndOrigin(async (repo, bare) => {
112
+ fs.writeFileSync(path.join(repo, "new-file.txt"), "hello\n");
113
+ git(repo, ["add", "-A"]);
114
+ git(repo, ["commit", "-q", "-m", "second commit"]);
115
+ await runGitPushAuthed(repo, "fake-token-value", "origin", "HEAD:develop");
116
+ const remoteLog = git(bare, ["log", "-1", "--pretty=%s", "refs/heads/develop"]);
117
+ assert.equal(remoteLog, "second commit");
118
+ // The token must never land in git config or the remote URL.
119
+ const config = fs.readFileSync(path.join(repo, ".git", "config"), "utf8");
120
+ assert.ok(!config.includes("fake-token-value"), "token leaked into persisted git config");
121
+ }));
122
+
123
+ test("runGitPushAuthed scrubs the base64 auth header from a failure message", async () =>
124
+ withRepo(async (repo) => {
125
+ // No remote named "origin" configured — push fails before network, but the
126
+ // scrub must still apply to whatever error text git produces.
127
+ await assert.rejects(
128
+ () => runGitPushAuthed(repo, "another-fake-token", "origin", "HEAD:develop"),
129
+ (err) => {
130
+ const basic = Buffer.from("x-access-token:another-fake-token").toString("base64");
131
+ assert.ok(!err.message.includes(basic), "base64 credential leaked into the error message");
132
+ return true;
133
+ },
134
+ );
135
+ }));
136
+
137
+ // ── gitInProgressState ───────────────────────────────────────────────────────
138
+
139
+ test("gitInProgressState reports false/false on a clean repo", async () =>
140
+ withRepo(async (repo) => {
141
+ const state = await gitInProgressState(repo);
142
+ assert.deepEqual(state, { merge: false, rebase: false });
143
+ }));
144
+
145
+ test("gitInProgressState returns false/false (not a throw) outside any git repo", async () => {
146
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-fsgit-notrepo-"));
147
+ try {
148
+ const state = await gitInProgressState(dir);
149
+ assert.deepEqual(state, { merge: false, rebase: false });
150
+ } finally {
151
+ fs.rmSync(dir, { recursive: true, force: true });
152
+ }
153
+ });
154
+
155
+ // ── repoStatus ───────────────────────────────────────────────────────────────
156
+
157
+ test("repoStatus reports a clean checked-out repo accurately", async () =>
158
+ withRepo(async (repo) => {
159
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
160
+ git(repo, ["add", "-A"]);
161
+ git(repo, ["commit", "-q", "-m", "first"]);
162
+ const { result } = await repoStatus(repo);
163
+ assert.equal(result.branch, "develop");
164
+ assert.equal(result.clean, true);
165
+ assert.equal(result.dirty_count, 0);
166
+ assert.equal(result.subject, "first");
167
+ assert.equal(result.merge_in_progress, false);
168
+ assert.equal(result.rebase_in_progress, false);
169
+ assert.equal(path.normalize(result.repo_root).toLowerCase(), path.normalize(repo).toLowerCase());
170
+ }));
171
+
172
+ test("repoStatus reports dirty and staged paths separately", async () =>
173
+ withRepo(async (repo) => {
174
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
175
+ git(repo, ["add", "-A"]);
176
+ git(repo, ["commit", "-q", "-m", "first"]);
177
+ fs.writeFileSync(path.join(repo, "a.txt"), "changed\n");
178
+ fs.writeFileSync(path.join(repo, "b.txt"), "new\n");
179
+ git(repo, ["add", "b.txt"]);
180
+ const { result } = await repoStatus(repo);
181
+ assert.equal(result.clean, false);
182
+ assert.equal(result.dirty_count, 2, "both the modified and the new file count as dirty");
183
+ assert.ok(result.staged_paths.includes("b.txt"));
184
+ assert.ok(!result.staged_paths.includes("a.txt"), "unstaged modification must not appear in staged_paths");
185
+ }));
186
+
187
+ test("repoStatus reports ahead/behind null when there is no upstream", async () =>
188
+ withRepo(async (repo) => {
189
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
190
+ git(repo, ["add", "-A"]);
191
+ git(repo, ["commit", "-q", "-m", "first"]);
192
+ const { result } = await repoStatus(repo);
193
+ assert.equal(result.upstream, null);
194
+ assert.equal(result.ahead, null);
195
+ assert.equal(result.behind, null);
196
+ }));
197
+
198
+ // ── useBranch ────────────────────────────────────────────────────────────────
199
+
200
+ test("useBranch creates a new branch from HEAD", async () =>
201
+ withRepo(async (repo) => {
202
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
203
+ git(repo, ["add", "-A"]);
204
+ git(repo, ["commit", "-q", "-m", "first"]);
205
+ const { result } = await useBranch(repo, "feature/x", true);
206
+ assert.equal(result.status, "ok");
207
+ assert.equal(result.created, true);
208
+ assert.equal(git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]), "feature/x");
209
+ }));
210
+
211
+ test("useBranch refuses create=true on an already-existing branch", async () =>
212
+ withRepo(async (repo) => {
213
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
214
+ git(repo, ["add", "-A"]);
215
+ git(repo, ["commit", "-q", "-m", "first"]);
216
+ await useBranch(repo, "feature/x", true);
217
+ git(repo, ["switch", "develop"]);
218
+ const { error } = await useBranch(repo, "feature/x", true);
219
+ assert.match(error, /already exists/);
220
+ }));
221
+
222
+ test("useBranch refuses create=false on a nonexistent branch", async () =>
223
+ withRepo(async (repo) => {
224
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
225
+ git(repo, ["add", "-A"]);
226
+ git(repo, ["commit", "-q", "-m", "first"]);
227
+ const { error } = await useBranch(repo, "does-not-exist", false);
228
+ assert.match(error, /does not exist/);
229
+ }));
230
+
231
+ test("useBranch requires a non-empty branch name", async () =>
232
+ withRepo(async (repo) => {
233
+ const { error } = await useBranch(repo, " ", true);
234
+ assert.equal(error, "branch is required");
235
+ }));
236
+
237
+ test("useBranch refuses to switch during an in-progress merge", async () =>
238
+ withRepo(async (repo) => {
239
+ fs.writeFileSync(path.join(repo, "a.txt"), "base\n");
240
+ git(repo, ["add", "-A"]);
241
+ git(repo, ["commit", "-q", "-m", "base"]);
242
+ git(repo, ["switch", "-c", "feature/y"]);
243
+ fs.writeFileSync(path.join(repo, "a.txt"), "feature change\n");
244
+ git(repo, ["add", "-A"]);
245
+ git(repo, ["commit", "-q", "-m", "feature change"]);
246
+ git(repo, ["switch", "develop"]);
247
+ fs.writeFileSync(path.join(repo, "a.txt"), "develop change\n");
248
+ git(repo, ["add", "-A"]);
249
+ git(repo, ["commit", "-q", "-m", "develop change"]);
250
+ try { git(repo, ["merge", "feature/y"]); } catch { /* conflict expected */ }
251
+ const { error } = await useBranch(repo, "feature/y", false);
252
+ assert.match(error, /merge or rebase is in progress/);
253
+ }));
254
+
255
+ // ── commit ───────────────────────────────────────────────────────────────────
256
+
257
+ test("commit requires a message", async () =>
258
+ withRepo(async (repo) => {
259
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
260
+ git(repo, ["add", "-A"]);
261
+ git(repo, ["commit", "-q", "-m", "first"]);
262
+ const { error } = await commit(repo, { message: "" });
263
+ assert.equal(error, "message is required");
264
+ }));
265
+
266
+ test("commit refuses on detached HEAD", async () =>
267
+ withRepo(async (repo) => {
268
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
269
+ git(repo, ["add", "-A"]);
270
+ git(repo, ["commit", "-q", "-m", "first"]);
271
+ const sha = git(repo, ["rev-parse", "HEAD"]);
272
+ git(repo, ["checkout", "-q", sha]);
273
+ const { error } = await commit(repo, { message: "x", push: false });
274
+ assert.match(error, /detached HEAD/);
275
+ }));
276
+
277
+ test("commit reports nothing_to_commit when the working tree is clean", async () =>
278
+ withRepo(async (repo) => {
279
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
280
+ git(repo, ["add", "-A"]);
281
+ git(repo, ["commit", "-q", "-m", "first"]);
282
+ const { result } = await commit(repo, { message: "no-op", push: false });
283
+ assert.equal(result.status, "nothing_to_commit");
284
+ }));
285
+
286
+ test("commit dry-run reports what WOULD happen without mutating the tree", async () =>
287
+ withRepo(async (repo) => {
288
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
289
+ git(repo, ["add", "-A"]);
290
+ git(repo, ["commit", "-q", "-m", "first"]);
291
+ fs.writeFileSync(path.join(repo, "b.txt"), "new\n");
292
+ const { result } = await commit(repo, { message: "would commit b", push: true, dryRun: true });
293
+ assert.equal(result.status, "dry_run");
294
+ assert.deepEqual(result.would_commit, ["b.txt"]);
295
+ assert.equal(result.protected_branch, false);
296
+ // Nothing must actually be staged or committed by a dry run.
297
+ const status = git(repo, ["status", "--porcelain"]);
298
+ assert.match(status, /\?\? b\.txt/, "the file must still be untracked — dry run must not stage it");
299
+ }));
300
+
301
+ test("commit dry-run flags a protected branch and reports would_push=false", async () =>
302
+ withRepo(async (repo) => {
303
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
304
+ git(repo, ["add", "-A"]);
305
+ git(repo, ["commit", "-q", "-m", "first"]);
306
+ git(repo, ["branch", "-m", "main"]);
307
+ fs.writeFileSync(path.join(repo, "b.txt"), "new\n");
308
+ const { result } = await commit(repo, { message: "x", push: true, dryRun: true });
309
+ assert.equal(result.protected_branch, true);
310
+ assert.equal(result.would_push, false);
311
+ }));
312
+
313
+ test("commit saves locally and refuses to push a protected branch", async () =>
314
+ withRepo(async (repo) => {
315
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
316
+ git(repo, ["add", "-A"]);
317
+ git(repo, ["commit", "-q", "-m", "first"]);
318
+ git(repo, ["branch", "-m", "main"]);
319
+ fs.writeFileSync(path.join(repo, "b.txt"), "new\n");
320
+ const { result } = await commit(repo, { message: "protected", push: true, token: "irrelevant" });
321
+ assert.equal(result.status, "committed_local_not_pushed");
322
+ assert.equal(result.push_blocked, "protected_branch");
323
+ assert.equal(git(repo, ["log", "-1", "--pretty=%s"]), "protected", "the commit itself must still land locally");
324
+ }));
325
+
326
+ test("commit saves locally when push is requested but no token is available", async () =>
327
+ withRepo(async (repo) => {
328
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
329
+ git(repo, ["add", "-A"]);
330
+ git(repo, ["commit", "-q", "-m", "first"]);
331
+ fs.writeFileSync(path.join(repo, "b.txt"), "new\n");
332
+ const { result } = await commit(repo, { message: "no token", push: true, tokenError: "vault locked" });
333
+ assert.equal(result.status, "committed_local_not_pushed");
334
+ assert.equal(result.push_blocked, "no_token");
335
+ assert.match(result.message, /vault locked/);
336
+ }));
337
+
338
+ test("commit with push:false never attempts a push even with a token present", async () =>
339
+ withRepo(async (repo) => {
340
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
341
+ git(repo, ["add", "-A"]);
342
+ git(repo, ["commit", "-q", "-m", "first"]);
343
+ fs.writeFileSync(path.join(repo, "b.txt"), "new\n");
344
+ const { result } = await commit(repo, { message: "local only", push: false, token: "unused-token" });
345
+ assert.equal(result.status, "committed_local");
346
+ assert.equal(result.pushed, false);
347
+ }));
348
+
349
+ test("commit stages only the given paths, not the whole tree", async () =>
350
+ withRepo(async (repo) => {
351
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
352
+ git(repo, ["add", "-A"]);
353
+ git(repo, ["commit", "-q", "-m", "first"]);
354
+ fs.writeFileSync(path.join(repo, "b.txt"), "new-b\n");
355
+ fs.writeFileSync(path.join(repo, "c.txt"), "new-c\n");
356
+ const { result } = await commit(repo, { message: "only b", push: false, paths: ["b.txt"] });
357
+ assert.deepEqual(result.files, ["b.txt"]);
358
+ const status = git(repo, ["status", "--porcelain"]);
359
+ assert.match(status, /\?\? c\.txt/, "c.txt must remain untracked — it was not in the requested scope");
360
+ }));
361
+
362
+ test("commit rejects a path escaping the repo root", async () =>
363
+ withRepo(async (repo) => {
364
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
365
+ git(repo, ["add", "-A"]);
366
+ git(repo, ["commit", "-q", "-m", "first"]);
367
+ const { error } = await commit(repo, { message: "x", push: false, paths: ["../../etc/passwd"] });
368
+ assert.match(error, /Invalid repo path/);
369
+ }));
370
+
371
+ test("commit refuses when the optimistic-concurrency expectedHead has moved", async () =>
372
+ withRepo(async (repo) => {
373
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
374
+ git(repo, ["add", "-A"]);
375
+ git(repo, ["commit", "-q", "-m", "first"]);
376
+ const staleSha = git(repo, ["rev-parse", "HEAD"]);
377
+ fs.writeFileSync(path.join(repo, "a.txt"), "moved\n");
378
+ git(repo, ["add", "-A"]);
379
+ git(repo, ["commit", "-q", "-m", "moved on"]);
380
+ fs.writeFileSync(path.join(repo, "b.txt"), "new\n");
381
+ const { error } = await commit(repo, { message: "x", push: false, expectedHead: staleSha });
382
+ assert.match(error, /Base moved/);
383
+ }));
384
+
385
+ test("commit end-to-end: commits AND pushes to a real local remote", async () =>
386
+ withRepoAndOrigin(async (repo, bare) => {
387
+ fs.writeFileSync(path.join(repo, "new.txt"), "content\n");
388
+ const { result } = await commit(repo, { message: "ship it", push: true, token: "fake-token" });
389
+ assert.equal(result.status, "committed_and_pushed");
390
+ assert.equal(result.pushed, true);
391
+ assert.equal(git(bare, ["log", "-1", "--pretty=%s", "refs/heads/develop"]), "ship it");
392
+ }));
393
+
394
+ test("FS_GIT_PROTECTED_BRANCHES covers the expected set and nothing else", () => {
395
+ assert.deepEqual([...FS_GIT_PROTECTED_BRANCHES].sort(), ["main", "master", "prod", "production"]);
396
+ });
397
+
398
+ // ── diff ─────────────────────────────────────────────────────────────────────
399
+
400
+ test("diff reports working-tree changes against HEAD by default", async () =>
401
+ withRepo(async (repo) => {
402
+ fs.writeFileSync(path.join(repo, "a.txt"), "line1\n");
403
+ git(repo, ["add", "-A"]);
404
+ git(repo, ["commit", "-q", "-m", "first"]);
405
+ fs.writeFileSync(path.join(repo, "a.txt"), "line1\nline2\n");
406
+ const { result } = await diff(repo, {});
407
+ assert.equal(result.compared_to, "HEAD");
408
+ assert.equal(result.staged, false);
409
+ assert.match(result.patch, /\+line2/);
410
+ }));
411
+
412
+ test("diff --staged compares the index, not the working tree", async () =>
413
+ withRepo(async (repo) => {
414
+ fs.writeFileSync(path.join(repo, "a.txt"), "line1\n");
415
+ git(repo, ["add", "-A"]);
416
+ git(repo, ["commit", "-q", "-m", "first"]);
417
+ fs.writeFileSync(path.join(repo, "a.txt"), "line1\nstaged-line\n");
418
+ git(repo, ["add", "-A"]);
419
+ fs.appendFileSync(path.join(repo, "a.txt"), "unstaged-line\n");
420
+ const { result } = await diff(repo, { staged: true });
421
+ assert.match(result.patch, /\+staged-line/);
422
+ assert.ok(!result.patch.includes("unstaged-line"), "staged diff must not include unstaged changes");
423
+ }));
424
+
425
+ test("diff reports (no differences) on a clean tree instead of an empty string", async () =>
426
+ withRepo(async (repo) => {
427
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
428
+ git(repo, ["add", "-A"]);
429
+ git(repo, ["commit", "-q", "-m", "first"]);
430
+ const { result } = await diff(repo, {});
431
+ assert.equal(result.patch, "(no differences)");
432
+ assert.equal(result.stat, "(no differences)");
433
+ }));
434
+
435
+ test("diff rejects an unknown ref with a clean error instead of a raw git failure", async () =>
436
+ withRepo(async (repo) => {
437
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
438
+ git(repo, ["add", "-A"]);
439
+ git(repo, ["commit", "-q", "-m", "first"]);
440
+ const { error } = await diff(repo, { ref: "origin/does-not-exist" });
441
+ assert.match(error, /Unknown ref/);
442
+ }));
443
+
444
+ test("diff rejects a path escaping the repo root", async () =>
445
+ withRepo(async (repo) => {
446
+ fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
447
+ git(repo, ["add", "-A"]);
448
+ git(repo, ["commit", "-q", "-m", "first"]);
449
+ const { error } = await diff(repo, { paths: ["../outside.txt"] });
450
+ assert.match(error, /Invalid repo path/);
451
+ }));
452
+
453
+ test("diff truncates a very large patch and flags truncated:true", async () =>
454
+ withRepo(async (repo) => {
455
+ // Plain `git diff` (working tree vs HEAD) never shows an UNTRACKED file —
456
+ // that is real git behavior, and it is what production diff() inherits by
457
+ // default. To exercise the >60000-char cap through that same default path,
458
+ // the file must already be tracked so the size shows up as a
459
+ // (small tracked line) -> (huge line) MODIFICATION, not a new file.
460
+ fs.writeFileSync(path.join(repo, "big.txt"), "small\n");
461
+ git(repo, ["add", "-A"]);
462
+ git(repo, ["commit", "-q", "-m", "first"]);
463
+ fs.writeFileSync(path.join(repo, "big.txt"), "x".repeat(61000) + "\n");
464
+ const { result } = await diff(repo, {});
465
+ assert.equal(result.truncated, true);
466
+ assert.ok(result.patch.endsWith("(diff truncated at 60KB)"));
467
+ }));
@@ -0,0 +1,30 @@
1
+ // cli/mcp/providers/call-agent-provider.js
2
+ // The call_agent MCP tool, as its own provider file registering against the
3
+ // shared cli/mcp/tool-registry.js — completing the same file-level split as
4
+ // terminal-provider.js, ops-provider.js, and clauth-core-provider.js.
5
+ //
6
+ // Deliberately different from those three: runCallAgent itself (Supabase
7
+ // job persistence, the AgentCron scheduler, getAgentPool()) is genuinely
8
+ // core, cross-cutting daemon infrastructure — it's also the shared
9
+ // entrypoint for POST /call-agent, a REST route, not just this MCP tool.
10
+ // Moving that logic wholesale would need a much deeper dependency audit
11
+ // than the other three groups needed, so it stays in serve.js.
12
+ //
13
+ // This file therefore does NOT import runCallAgent from serve.js — that
14
+ // would make serve.js (which imports this file for its registerTool side
15
+ // effect) and this file mutually dependent, a real circular import and
16
+ // exactly the coupling Dependency Inversion is supposed to prevent.
17
+ // Instead it exports a registration function that takes runCallAgent as an
18
+ // explicit parameter; serve.js calls it once, right after runCallAgent's
19
+ // own definition, passing itself in. This file's only real dependency is
20
+ // the shared tool-registry.js interface — runCallAgent is injected, not
21
+ // imported.
22
+ import { mcpResult, mcpError, registerTool } from "../tool-registry.js";
23
+
24
+ export function registerCallAgentTool(runCallAgent) {
25
+ registerTool("call_agent", async (args, vault) => {
26
+ const result = await runCallAgent(args || {});
27
+ if (!result.ok) return mcpError(`${result.error}${result.message ? `: ${result.message}` : ""}`);
28
+ return mcpResult(JSON.stringify(result));
29
+ });
30
+ }