worktrust 0.0.0-stage → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.1",
4
+ "description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "worktrust": "worktrust.mjs"
8
+ },
9
+ "files": [
10
+ "worktrust.mjs",
11
+ "setup-mcp.mjs",
12
+ "log-session.mjs",
13
+ "count-behaviour.mjs",
14
+ "README.md",
15
+ "SECURITY.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "license": "UNLICENSED",
21
+ "homepage": "https://worktrust.io",
22
+ "keywords": [
23
+ "worktrust",
24
+ "mcp",
25
+ "claude-code",
26
+ "codex",
27
+ "cursor",
28
+ "ai",
29
+ "provenance"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/fhomey/worktrust-cli.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/fhomey/worktrust-cli/issues"
41
+ }
42
+ }
package/setup-mcp.mjs ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WorkTrust — couple every MCP client on THIS machine in one go.
4
+ *
5
+ * node setup-mcp.mjs --url https://app.worktrust.io/api/mcp --token wt_... (shows what it would write)
6
+ * node setup-mcp.mjs --url ... --token ... --write (writes it)
7
+ *
8
+ * node setup-mcp.mjs --remove --write (takes it out again)
9
+ *
10
+ * One machine, one token, many clients: Claude Code, Cursor, Codex, Gemini CLI, VS Code's
11
+ * Copilot agent mode, Windsurf. Each keeps its own config file in its own shape; this finds
12
+ * the ones that exist here and adds the same WorkTrust server to each, so a coupling made once
13
+ * counts everywhere — and every client names itself when it first speaks, which is how Sources
14
+ * shows each product as coupled. Without --write nothing is touched. The token is shown
15
+ * masked; it lives only in the files it is written to. A config that already names the server
16
+ * is left as it is.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { spawnSync } from "node:child_process";
20
+ import { homedir, platform } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+
23
+ const args = process.argv.slice(2);
24
+ const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
25
+ const WRITE = args.includes("--write");
26
+ const REMOVE = args.includes("--remove");
27
+ const NAME = value("name") ?? "worktrust";
28
+ /**
29
+ * THE BRIDGE (2026-10-03). With `--bridge <path to worktrust.mjs>` no client is given a key: each
30
+ * gets a local command (`node worktrust.mjs mcp`) that reads the key from ~/.worktrust/key.json
31
+ * (mode 600) and speaks to the door for it. No config file holds the key.
32
+ */
33
+ const BRIDGE = value("bridge");
34
+ const NODE = process.env.WORKTRUST_NODE || process.execPath;
35
+
36
+ /** The coupling: given, or the one Claude Code already holds (the counter reads it the same way). */
37
+ function fromClaude() {
38
+ try {
39
+ const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
40
+ const found = [];
41
+ const walk = (node) => { if (!node || typeof node !== "object") return; if (typeof node.url === "string" && node.url.includes("/api/mcp")) { const auth = node.headers?.Authorization ?? node.headers?.authorization ?? ""; if (auth.startsWith("Bearer ")) found.push({ url: node.url, token: auth.slice(7) }); } for (const child of Object.values(node)) walk(child); };
42
+ walk(config); return found[0] ?? null;
43
+ } catch { return null; }
44
+ }
45
+ const discovered = fromClaude();
46
+ const URL_ = value("url") ?? process.env.WORKTRUST_MCP_URL ?? discovered?.url;
47
+ const TOKEN = value("token") ?? process.env.WORKTRUST_MCP_TOKEN ?? discovered?.token;
48
+ if (!URL_ || (!TOKEN && !BRIDGE)) { console.error("no coupling: pass --url and --token (from Sources → Claude (MCP) → Add coupling), or couple Claude Code first"); process.exit(1); }
49
+ const masked = BRIDGE ? "kept in ~/.worktrust/key.json, not in these files" : `${TOKEN.slice(0, 6)}…${TOKEN.slice(-4)}`;
50
+ const stdio = { command: NODE, args: [BRIDGE ?? "", "mcp"] };
51
+ const home = homedir();
52
+ /** Whether Claude Code's own command is on this machine: asked of the shell, never guessed from a file. */
53
+ const claudeOnPath = () => spawnSync(platform() === "win32" ? "where" : "which", ["claude"], { encoding: "utf8", shell: platform() === "win32" }).status === 0;
54
+ const vscodeUser = platform() === "darwin" ? join(home, "Library", "Application Support", "Code", "User") : platform() === "win32" ? join(process.env.APPDATA ?? home, "Code", "User") : join(home, ".config", "Code", "User");
55
+
56
+ /** Each client: where it lives, how it says "an HTTP MCP server with a header". */
57
+ const CLIENTS = [
58
+ { key: "cursor", label: "Cursor", present: () => existsSync(join(home, ".cursor")), file: join(home, ".cursor", "mcp.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
59
+ { key: "codex", label: "Codex CLI", present: () => existsSync(join(home, ".codex")), file: join(home, ".codex", "config.toml"), shape: "toml", block: BRIDGE ? `\n[mcp_servers.${NAME}]\ncommand = ${JSON.stringify(NODE)}\nargs = [${JSON.stringify(BRIDGE)}, "mcp"]\n` : `\n[mcp_servers.${NAME}]\nurl = "${URL_}"\nhttp_headers = { Authorization = "Bearer ${TOKEN}" }\n` },
60
+ { key: "gemini", label: "Gemini CLI", present: () => existsSync(join(home, ".gemini")), file: join(home, ".gemini", "settings.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { httpUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
61
+ { key: "copilot", label: "VS Code (Copilot agent mode)", present: () => existsSync(vscodeUser), file: join(vscodeUser, "mcp.json"), shape: "json", root: "servers", entry: BRIDGE ? { type: "stdio", ...stdio } : { type: "http", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
62
+ { key: "windsurf", label: "Windsurf", present: () => existsSync(join(home, ".codeium", "windsurf")), file: join(home, ".codeium", "windsurf", "mcp_config.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { serverUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
63
+ { key: "claude", label: "Claude Code", present: () => existsSync(join(home, ".claude.json")), file: join(home, ".claude.json"), shape: "claude" },
64
+ ];
65
+
66
+ console.log(`WorkTrust MCP ${REMOVE ? "removal" : "setup"} · door ${new URL(URL_).host} · token ${masked} · ${WRITE ? "WRITING" : "dry run — add --write to write"}`);
67
+ let touched = 0;
68
+ let standing = 0;
69
+ for (const client of CLIENTS) {
70
+ if (!client.present()) { console.log(` – ${client.label}: not on this machine`); continue; }
71
+ if (client.shape === "claude") {
72
+ // Claude Code owns this file and has its own verb for it, so this never edits the file: it RUNS
73
+ // the verb when the `claude` command is on this machine (2026-09-28; before, the line was only
74
+ // printed, and a person who had just run one command was handed a second to type), and prints
75
+ // it when the command is not. User scope, so the coupling holds in every folder on this machine.
76
+ const claudeArgs = REMOVE ? ["mcp", "remove", "-s", "user", NAME] : BRIDGE ? ["mcp", "add", "-s", "user", NAME, "--", NODE, BRIDGE, "mcp"] : ["mcp", "add", "--transport", "http", "-s", "user", NAME, URL_, "-H", `Authorization: Bearer ${TOKEN}`];
77
+ const shown = REMOVE ? `claude mcp remove -s user ${NAME}` : BRIDGE ? `claude mcp add -s user ${NAME} -- "${NODE}" "${BRIDGE}" mcp` : `claude mcp add --transport http -s user ${NAME} "${URL_}" -H "Authorization: Bearer <token>"`;
78
+ if (!REMOVE && discovered) { console.log(` ✓ ${client.label}: already coupled`); standing += 1; continue; }
79
+ if (WRITE && claudeOnPath()) {
80
+ const run = spawnSync("claude", claudeArgs, { encoding: "utf8", shell: platform() === "win32" });
81
+ if (run.status === 0) { console.log(` + ${client.label}: ${REMOVE ? "removed" : "coupled"} through \`claude mcp ${REMOVE ? "remove" : "add"}\` (user scope)`); touched += 1; continue; }
82
+ console.log(` ! ${client.label}: \`claude mcp\` refused (${(run.stderr || run.stdout || "").trim().split("\n")[0] || "no output"}); run it yourself: ${shown}`);
83
+ continue;
84
+ }
85
+ console.log(` ! ${client.label}: ${WRITE ? "the `claude` command is not on this machine's PATH; " : ""}${REMOVE ? "run" : "couple with"} \`${shown}\``);
86
+ continue;
87
+ }
88
+ const text = existsSync(client.file) ? readFileSync(client.file, "utf8") : "";
89
+ const already = text.includes(URL_) || (BRIDGE !== undefined && text.includes(JSON.stringify(BRIDGE).slice(1, -1))) || (REMOVE && /worktrust\.mjs/.test(text));
90
+ // THE WAY OUT, ON THE MACHINE. Uncoupling in the app ends the TOKEN; the config that names the
91
+ // door stays behind on every client here, so the next person to read this file believes the
92
+ // machine is coupled. `--remove` takes the entry out; the account side is a separate act and
93
+ // this says so rather than pretending one does both.
94
+ if (REMOVE) {
95
+ if (!already) { console.log(` – ${client.label}: does not name the door`); continue; }
96
+ if (client.shape === "json") {
97
+ let config = {};
98
+ try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { config = {}; }
99
+ const root = config[client.root] ?? {};
100
+ for (const [key, entry] of Object.entries(root)) {
101
+ const named = JSON.stringify(entry ?? {}).includes(URL_) || /worktrust\.mjs/.test(JSON.stringify(entry ?? {}));
102
+ if (key === NAME || named) delete root[key];
103
+ }
104
+ config[client.root] = root;
105
+ console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
106
+ if (WRITE) writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n");
107
+ } else if (client.shape === "toml") {
108
+ const text = readFileSync(client.file, "utf8");
109
+ // The block and nothing after it: from its own header to the next one, or the end.
110
+ const stripped = text.replace(new RegExp(`\\n?\\[mcp_servers\\.${NAME}\\][^\\[]*`, "g"), "\n");
111
+ console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
112
+ if (WRITE) writeFileSync(client.file, stripped);
113
+ }
114
+ touched += 1;
115
+ continue;
116
+ }
117
+ if (already) { console.log(` ✓ ${client.label}: already names the door (${client.file})`); standing += 1; continue; }
118
+ if (client.shape === "json") {
119
+ let config = {};
120
+ try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { config = {}; }
121
+ config[client.root] = { ...(config[client.root] ?? {}), [NAME]: client.entry };
122
+ console.log(` + ${client.label}: ${WRITE ? "wrote" : "would write"} ${client.file} → ${client.root}.${NAME}`);
123
+ if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n"); touched += 1; }
124
+ } else if (client.shape === "toml") {
125
+ console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → [mcp_servers.${NAME}]`);
126
+ if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, (existsSync(client.file) ? readFileSync(client.file, "utf8") : "") + client.block); touched += 1; }
127
+ }
128
+ }
129
+ // A COUNT OF WHAT CHANGED IS NOT A COUNT OF WHAT WORKS. "0 clients coupled" after five ticks
130
+ // reads as a failed run, and somebody re-runs it, or worse, re-couples by hand and mints a second
131
+ // token for one machine. Say both numbers: what was added, and what already stood.
132
+ console.log(
133
+ REMOVE
134
+ ? WRITE
135
+ ? `\n${touched} client${touched === 1 ? "" : "s"} no longer name the door on this machine. THE COUPLING ITSELF STILL EXISTS: end it in the app (Sources → the computer's row → end this coupling), or the token keeps working wherever else it is installed.`
136
+ : "\nNothing written. Run again with --remove --write to take it out."
137
+ : WRITE
138
+ ? `\n${touched} added, ${standing + (discovered ? 1 : 0)} already coupled.${touched > 0 ? " Restart each client that changed; it names itself the first time it speaks, and Sources shows it as coupled." : " Nothing needed changing."}`
139
+ : "\nNothing written. Run again with --write to couple them.",
140
+ );