@retasc/cli 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Retasc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # retasc
2
+
3
+ **The issue tracker AI agents pull work from.**
4
+
5
+ Over [MCP](https://modelcontextprotocol.io), a heterogeneous fleet of agents atomically
6
+ claims unblocked, prioritized tasks and runs in parallel — server-enforced, no collisions.
7
+ Issues live in a persistent, multi-tenant store; agents reach them through one MCP endpoint.
8
+ The `retasc` CLI signs you in, creates projects, mints agent API keys, and wires the MCP
9
+ server into your agent in a single command.
10
+
11
+ → [retasc.com](https://retasc.com)
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm i -g @retasc/cli
17
+ ```
18
+
19
+ This installs the `retasc` command. Requires Node.js ≥ 18.
20
+
21
+ ## Quickstart
22
+
23
+ ```sh
24
+ # 1. Sign in with GitHub (device flow)
25
+ retasc login
26
+
27
+ # 2. Create an org + project, mint an agent key, and wire it into your agent — one shot
28
+ retasc init --org "Acme" --project "Acme" --prefix ACME
29
+
30
+ # 3. Your agent (e.g. Claude Code) can now pull work over MCP
31
+ ```
32
+
33
+ `retasc init` creates the org and project, mints an agent API key, and registers the Retasc
34
+ MCP server with your agent (writing `.mcp.json` or registering with Claude Code). From then on
35
+ your agent calls `next_issue` / `next_batch` to atomically claim the top unblocked, prioritized
36
+ work — and several agents can run at once without ever claiming the same issue.
37
+
38
+ ## What the agent gets over MCP
39
+
40
+ - **Atomic claim + lease/TTL + fencing** — concurrency control on work items, so no two agents
41
+ take the same issue. A claim is a lease; `heartbeat` keeps it alive, `checkpoint` records
42
+ progress, and a reclaimer frees stalled leases so the next agent resumes from the checkpoint.
43
+ - **Dependency-graph-driven dispatch** — `next_issue`, `next_batch`, and effective priority. A
44
+ blocker inherits the urgency of everything it gates, so agents just take what the queue hands
45
+ them.
46
+ - **A persistent, multi-tenant, identity-bearing store** of human-legible work.
47
+
48
+ ## Common commands
49
+
50
+ | Command | What it does |
51
+ |---------|--------------|
52
+ | `retasc login` / `retasc logout` | Sign in / out (GitHub device flow) |
53
+ | `retasc whoami` | Show the signed-in user and their orgs |
54
+ | `retasc init …` | Create org + project, mint a key, wire the MCP — one shot |
55
+ | `retasc org create` / `retasc project create` | Create orgs / projects |
56
+ | `retasc key mint \| list \| rotate \| revoke` | Manage agent API keys |
57
+ | `retasc mcp install` | Register the Retasc MCP server with your agent |
58
+ | `retasc gate install` | Install a commit↔issue traceability gate in a repo |
59
+ | `retasc config` | Show the resolved CLI config (paths + endpoints) |
60
+
61
+ Run `retasc --help` or `retasc <command> --help` for the full set.
62
+
63
+ ## Links
64
+
65
+ - Website — [retasc.com](https://retasc.com)
66
+ - Issues — [github.com/Retasc/retasc/issues](https://github.com/Retasc/retasc/issues)
67
+
68
+ ## License
69
+
70
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,33 @@
1
+ import { ConvexHttpClient } from "convex/browser";
2
+ import { makeFunctionReference } from "convex/server";
3
+ import { loadConfig } from "./config.js";
4
+ // Typed-ish references to the public management functions in convex/manage.ts.
5
+ // The CLI is a standalone package, so we reference functions by name rather than
6
+ // importing the parent's generated api.
7
+ const fns = {
8
+ me: makeFunctionReference("manage:me"),
9
+ createOrg: makeFunctionReference("manage:createOrg"),
10
+ createProject: makeFunctionReference("manage:createProject"),
11
+ renameProjectPrefix: makeFunctionReference("manage:renameProjectPrefix"),
12
+ listKeys: makeFunctionReference("manage:listKeys"),
13
+ mintKey: makeFunctionReference("manage:mintKey"),
14
+ rotateKey: makeFunctionReference("manage:rotateKey"),
15
+ revokeKey: makeFunctionReference("manage:revokeKey"),
16
+ };
17
+ function client() {
18
+ const cfg = loadConfig();
19
+ const c = new ConvexHttpClient(cfg.deploymentUrl);
20
+ if (cfg.token)
21
+ c.setAuth(cfg.token);
22
+ return c;
23
+ }
24
+ export const api = {
25
+ me: () => client().query(fns.me, {}),
26
+ createOrg: (args) => client().mutation(fns.createOrg, args),
27
+ createProject: (args) => client().mutation(fns.createProject, args),
28
+ renameProjectPrefix: (args) => client().mutation(fns.renameProjectPrefix, args),
29
+ listKeys: (args) => client().query(fns.listKeys, args),
30
+ mintKey: (args) => client().action(fns.mintKey, args),
31
+ rotateKey: (args) => client().action(fns.rotateKey, args),
32
+ revokeKey: (args) => client().mutation(fns.revokeKey, args),
33
+ };
package/dist/auth.js ADDED
@@ -0,0 +1,78 @@
1
+ import { ConvexHttpClient } from "convex/browser";
2
+ import { makeFunctionReference } from "convex/server";
3
+ import { loadConfig, patchConfig } from "./config.js";
4
+ // The GitHub OAuth App's PUBLIC client id (safe to ship — device flow needs no
5
+ // secret). Baked in so `retasc login` works out-of-the-box; override via env.
6
+ const GITHUB_CLIENT_ID = process.env.RETASC_GITHUB_CLIENT_ID ?? "Ov23linqRy875IU8OYTW";
7
+ const DEVICE_CODE_URL = "https://github.com/login/device/code";
8
+ const TOKEN_URL = "https://github.com/login/oauth/access_token";
9
+ const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
10
+ // Convex Auth's own public sign-in action. We call it with the "github-device"
11
+ // credentials provider; it verifies the GitHub token server-side and returns a
12
+ // session ({ tokens: { token, refreshToken } }).
13
+ const signIn = makeFunctionReference("auth:signIn");
14
+ async function postForm(url, body) {
15
+ const res = await fetch(url, {
16
+ method: "POST",
17
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
18
+ body: new URLSearchParams(body).toString(),
19
+ });
20
+ return res.json();
21
+ }
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ /**
24
+ * Run the full GitHub device-flow login and persist the resulting Convex Auth
25
+ * session to ~/.retasc/config.json. Prints the user code + verification URL.
26
+ */
27
+ export async function deviceLogin() {
28
+ if (!GITHUB_CLIENT_ID) {
29
+ throw new Error("GitHub client id not configured. Set RETASC_GITHUB_CLIENT_ID (the OAuth App's public Client ID).");
30
+ }
31
+ const start = await postForm(DEVICE_CODE_URL, {
32
+ client_id: GITHUB_CLIENT_ID,
33
+ scope: "read:user user:email",
34
+ });
35
+ if (!start.device_code) {
36
+ throw new Error(`GitHub device-flow start failed: ${JSON.stringify(start)}`);
37
+ }
38
+ console.log(`\n Open: ${start.verification_uri}`);
39
+ console.log(` Enter code: ${start.user_code}\n`);
40
+ console.log(" Waiting for authorization…");
41
+ // Poll GitHub for the access token.
42
+ let intervalMs = (start.interval || 5) * 1000;
43
+ const deadline = Date.now() + start.expires_in * 1000;
44
+ let githubToken = "";
45
+ while (Date.now() < deadline) {
46
+ await sleep(intervalMs);
47
+ const r = await postForm(TOKEN_URL, {
48
+ client_id: GITHUB_CLIENT_ID,
49
+ device_code: start.device_code,
50
+ grant_type: GRANT,
51
+ });
52
+ if (r.access_token) {
53
+ githubToken = r.access_token;
54
+ break;
55
+ }
56
+ if (r.error === "authorization_pending")
57
+ continue;
58
+ if (r.error === "slow_down") {
59
+ intervalMs += 5000;
60
+ continue;
61
+ }
62
+ throw new Error(`GitHub authorization failed: ${r.error_description ?? r.error}`);
63
+ }
64
+ if (!githubToken)
65
+ throw new Error("Timed out waiting for GitHub authorization.");
66
+ // Exchange the GitHub token for a Retasc (Convex Auth) session.
67
+ const cfg = loadConfig();
68
+ const convex = new ConvexHttpClient(cfg.deploymentUrl);
69
+ const res = await convex.action(signIn, {
70
+ provider: "github-device",
71
+ params: { githubToken },
72
+ });
73
+ const tokens = res?.tokens;
74
+ if (!tokens?.token) {
75
+ throw new Error("Sign-in did not return a session token.");
76
+ }
77
+ patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
78
+ }
@@ -0,0 +1,174 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, dirname, resolve, sep } from "node:path";
4
+ import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, } from "../lib/claim.js";
5
+ function git(args, cwd) {
6
+ return spawnSync("git", args, { encoding: "utf8", cwd });
7
+ }
8
+ function gitOut(args, cwd) {
9
+ const r = git(args, cwd);
10
+ if (r.status !== 0)
11
+ return null;
12
+ return (r.stdout ?? "").trim();
13
+ }
14
+ /** stderr so --print-path / --json keep stdout machine-clean. */
15
+ function note(msg) {
16
+ process.stderr.write(msg + "\n");
17
+ }
18
+ /**
19
+ * `retasc claim` / `retasc next`: atomically claim an issue over the workspace's
20
+ * own MCP key, then create + drop you into a correctly-named git worktree
21
+ * (`../<repo>-rtsc-NN` on branch `rtsc-NN/<slug>`). Makes isolation the path of
22
+ * least resistance (RTSC-38) so two sessions can't silently share a checkout.
23
+ */
24
+ export async function claimAction(opts) {
25
+ const conn = resolveMcpConn({ mcpJson: readMcpJson() });
26
+ if (!conn.key) {
27
+ note("✗ No Retasc MCP key found. Run `retasc init` / `retasc mcp install`, or set RETASC_MCP_KEY.");
28
+ note(" (Looked at $RETASC_MCP_KEY and ./.mcp.json.)");
29
+ process.exit(1);
30
+ }
31
+ const makeWorktree = opts.worktree !== false;
32
+ // Must be in a git repo to place the worktree. Resolve the *main* checkout so
33
+ // worktrees are always siblings of it, even when claiming from another worktree.
34
+ let parentDir = opts.dir;
35
+ let repoName = "repo";
36
+ let inLinkedWorktree = false;
37
+ if (makeWorktree) {
38
+ const commonDir = gitOut(["rev-parse", "--git-common-dir"]);
39
+ const gitDir = gitOut(["rev-parse", "--git-dir"]);
40
+ if (!commonDir || !gitDir) {
41
+ note("✗ Not inside a git repository — can't create a worktree.");
42
+ note(" Re-run from the repo, or pass --no-worktree to just claim.");
43
+ process.exit(1);
44
+ }
45
+ // --git-common-dir always points at the MAIN checkout's .git; --git-dir
46
+ // diverges (.git/worktrees/<name>) only inside a linked worktree.
47
+ inLinkedWorktree = resolve(gitDir) !== resolve(commonDir);
48
+ const mainCheckout = dirname(resolve(commonDir)); // <main>/.git → <main>
49
+ repoName = basename(mainCheckout);
50
+ parentDir = parentDir ?? dirname(mainCheckout);
51
+ }
52
+ // --- claim over MCP ------------------------------------------------------
53
+ let claim;
54
+ try {
55
+ const result = opts.id
56
+ ? await mcpCall(conn, "claim_issue", { identifier: opts.id })
57
+ : await mcpCall(conn, "next_issue", opts.mine ? { mine: true } : {});
58
+ claim = parseClaimResult(result);
59
+ }
60
+ catch (e) {
61
+ const msg = String(e?.message ?? e);
62
+ note(`✗ ${msg}`);
63
+ // Re-claiming an issue you already hold is the "resume my own work" case —
64
+ // point at the worktree instead of leaving the user stuck on the error.
65
+ if (opts.id && /ALREADY_CLAIMED/.test(msg) && makeWorktree) {
66
+ note(` ${opts.id} is held. If it's your session, resume in ${parentDir}/${repoName}-${opts.id.toLowerCase()}`);
67
+ note(` or \`retasc release ${opts.id}\` first.`);
68
+ }
69
+ process.exit(1);
70
+ }
71
+ if (!claim.issueId) {
72
+ // Empty next_issue — never a silent stall.
73
+ note("· Nothing to claim right now.");
74
+ if (claim.ready !== undefined) {
75
+ note(` ready: ${claim.ready ?? 0} blocked: ${claim.blocked ?? 0} claimed: ${claim.claimed ?? 0}`);
76
+ }
77
+ process.exit(0);
78
+ }
79
+ // The id flows into a git ref and a filesystem path — reject a malformed one.
80
+ if (!isValidIssueId(claim.issueId)) {
81
+ note(`✗ Server returned an unexpected issue id: ${JSON.stringify(claim.issueId)}`);
82
+ process.exit(1);
83
+ }
84
+ // claim_issue returns just the id — fetch the title so the slug is meaningful.
85
+ let title = claim.title;
86
+ if (!title) {
87
+ try {
88
+ const full = (await mcpCall(conn, "get_issue", { identifier: claim.issueId }));
89
+ title = full?.title;
90
+ }
91
+ catch {
92
+ note(`⚠ Couldn't fetch the title for ${claim.issueId} — branch slug falls back to "issue".`);
93
+ }
94
+ }
95
+ const issueId = claim.issueId;
96
+ if (!makeWorktree) {
97
+ note(`✓ Claimed ${issueId}${title ? ` — ${title}` : ""}.`);
98
+ if (claim.claimToken)
99
+ note(` claim token: ${claim.claimToken}`);
100
+ if (opts.json) {
101
+ console.log(JSON.stringify({ issueId, title, branch: null, path: null, claimToken: claim.claimToken }, null, 2));
102
+ }
103
+ return;
104
+ }
105
+ const plan = planWorktree({ issueId, title: title ?? "", repoName, parentDir: parentDir });
106
+ // Defense in depth: the derived worktree must stay under the parent dir.
107
+ if (plan.path !== resolve(parentDir) && !plan.path.startsWith(resolve(parentDir) + sep)) {
108
+ note(`✗ Refusing to create a worktree outside ${parentDir}: ${plan.path}`);
109
+ process.exit(1);
110
+ }
111
+ if (inLinkedWorktree) {
112
+ note("⚠ You're inside a linked worktree. Worktrees are still placed beside the main checkout,");
113
+ note(" but claiming from the main checkout keeps things tidy.");
114
+ }
115
+ // Reuse an existing worktree for this issue rather than failing (resumable).
116
+ if (existsSync(plan.path)) {
117
+ note(`✓ Claimed ${issueId} — worktree already exists, reusing it.`);
118
+ finish(plan.path, plan.branch, issueId, title, claim.claimToken, opts);
119
+ return;
120
+ }
121
+ // Best-effort refresh of the base so the worktree starts from current origin.
122
+ const base = opts.base ?? "origin/main";
123
+ if (opts.fetch !== false) {
124
+ const remoteBranch = base.startsWith("origin/") ? base.slice("origin/".length) : null;
125
+ const f = remoteBranch ? git(["fetch", "origin", remoteBranch]) : git(["fetch", "origin"]);
126
+ if (f.status !== 0)
127
+ note(`⚠ git fetch failed (${(f.stderr || "").trim().split("\n")[0]}); using local ${base}.`);
128
+ }
129
+ const add = git(["worktree", "add", plan.path, "-b", plan.branch, base]);
130
+ if (add.status !== 0) {
131
+ const err = (add.stderr || add.stdout || "").trim().split("\n")[0];
132
+ note(`✗ git worktree add failed: ${err}`);
133
+ // The claim succeeded but the worktree didn't — make the held lease actionable.
134
+ note(` ${issueId} is claimed (yours)${claim.claimToken ? `; claim token: ${claim.claimToken}` : ""}.`);
135
+ if (/already (exists|checked out|used by worktree)/i.test(err)) {
136
+ note(` The branch already exists — attach without -b: git worktree add ${plan.path} ${plan.branch}`);
137
+ }
138
+ else {
139
+ note(` Resolve the error and retry, or \`retasc release ${issueId}\` to return it to the pool.`);
140
+ }
141
+ process.exit(1);
142
+ }
143
+ note(`✓ Claimed ${issueId}${title ? ` — ${title}` : ""}`);
144
+ note(` worktree: ${plan.path}`);
145
+ note(` branch: ${plan.branch}`);
146
+ finish(plan.path, plan.branch, issueId, title, claim.claimToken, opts);
147
+ }
148
+ /** Emit machine output / drop into the worktree per the chosen flags. */
149
+ function finish(path, branch, issueId, title, claimToken, opts) {
150
+ if (opts.json) {
151
+ console.log(JSON.stringify({ issueId, title, branch, path, claimToken }, null, 2));
152
+ return;
153
+ }
154
+ if (opts.printPath) {
155
+ // Only the path on stdout, for `cd "$(retasc claim --print-path)"`.
156
+ console.log(path);
157
+ return;
158
+ }
159
+ if (opts.shell) {
160
+ const shell = process.env.SHELL || "/bin/sh";
161
+ note(`↳ entering ${path} (exit the shell to return)`);
162
+ const r = spawnSync(shell, { stdio: "inherit", cwd: path });
163
+ if (r.error) {
164
+ note(`✗ couldn't start ${shell}: ${r.error.message}`);
165
+ note(` cd ${path}`);
166
+ process.exit(1);
167
+ }
168
+ if (typeof r.status === "number" && r.status !== 0)
169
+ process.exit(r.status);
170
+ return;
171
+ }
172
+ note("");
173
+ note(` cd ${path}`);
174
+ }
@@ -0,0 +1,135 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, writeFileSync, existsSync, chmodSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ // Mirror the server's project-prefix rule (convex/manage.ts PREFIX_RE): 2–10
5
+ // chars, A–Z/0–9, letter-first. The prefix is interpolated into a generated
6
+ // bash hook and a YAML grep, so validating here keeps a stray value from
7
+ // writing a broken (or hostile) gate into the customer's repo.
8
+ const PREFIX_RE = /^[A-Z][A-Z0-9]{1,9}$/;
9
+ /** commit-msg hook body requiring `<PREFIX>-NN` or `[no-issue]`. */
10
+ export function hookTemplate(prefix) {
11
+ return `#!/bin/bash
12
+ # Require a ${prefix} issue reference (${prefix}-NN) or [no-issue] in commit messages.
13
+ # Installed by \`retasc gate install\`. Enable with: git config core.hooksPath .githooks
14
+ # Note: git does not run hooks on a fresh clone — each developer opts in once.
15
+ # The authoritative gate is the check-commit-message GitHub Action (CI).
16
+
17
+ MSG=$(cat "$1")
18
+
19
+ if echo "$MSG" | grep -qiE '(${prefix}-[0-9]+|\\[no-issue\\])'; then
20
+ exit 0
21
+ fi
22
+
23
+ echo ""
24
+ echo "ERROR: Commit message must reference a ${prefix} issue (${prefix}-NN) or include [no-issue]."
25
+ echo ""
26
+ echo "Examples:"
27
+ echo ' feat: add wave dispatch'
28
+ echo ''
29
+ echo ' ${prefix}-12'
30
+ echo ''
31
+ echo ' docs: fix typo in README [no-issue]'
32
+ echo ""
33
+ exit 1
34
+ `;
35
+ }
36
+ /** GitHub Action enforcing the reference on PR title/body. */
37
+ export function actionTemplate(prefix) {
38
+ return `name: Check commit message
39
+ # Installed by \`retasc gate install\`. The authoritative commit↔issue gate:
40
+ # runs in CI on every PR, no per-developer setup, can't be bypassed.
41
+ on:
42
+ pull_request:
43
+ types: [opened, synchronize, edited]
44
+
45
+ jobs:
46
+ check:
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - name: Check for ${prefix} issue reference
50
+ env:
51
+ PR_TITLE: \${{ github.event.pull_request.title }}
52
+ PR_BODY: \${{ github.event.pull_request.body }}
53
+ run: |
54
+ if printf '%s %s' "$PR_TITLE" "$PR_BODY" | grep -qiE '(${prefix}-[0-9]+|\\[no-issue\\])'; then
55
+ echo "✓ ${prefix} issue reference found"
56
+ exit 0
57
+ fi
58
+
59
+ echo "ERROR: PR must reference a ${prefix} issue (${prefix}-NN) in title or description, or include [no-issue]."
60
+ exit 1
61
+ `;
62
+ }
63
+ /** Locate the git repo root, or null if cwd isn't inside a working tree. */
64
+ function repoRoot() {
65
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
66
+ if (r.error || r.status !== 0)
67
+ return null;
68
+ const root = r.stdout.trim();
69
+ return root || null;
70
+ }
71
+ /** Point core.hooksPath at .githooks (relative to the repo root). */
72
+ function setHooksPath(root) {
73
+ const r = spawnSync("git", ["config", "core.hooksPath", ".githooks"], {
74
+ cwd: root,
75
+ encoding: "utf8",
76
+ });
77
+ return !r.error && r.status === 0;
78
+ }
79
+ function writeFile(path, body, mode) {
80
+ mkdirSync(dirname(path), { recursive: true });
81
+ writeFileSync(path, body, "utf8");
82
+ if (mode !== undefined) {
83
+ try {
84
+ chmodSync(path, mode);
85
+ }
86
+ catch {
87
+ /* best effort (e.g. Windows) */
88
+ }
89
+ }
90
+ }
91
+ /**
92
+ * Install the commit↔issue gate into the current repo, parameterized by `prefix`.
93
+ * Writes the requested layers (hook and/or Action) and enables the hook path.
94
+ */
95
+ export function installGate(opts) {
96
+ if (!PREFIX_RE.test(opts.prefix)) {
97
+ throw new Error(`invalid prefix '${opts.prefix}' — must be 2–10 chars, A–Z/0–9, letter-first (e.g. RTSC)`);
98
+ }
99
+ const root = repoRoot();
100
+ if (!root) {
101
+ throw new Error("not a git repository — run `git init` first, then re-run from the repo root");
102
+ }
103
+ const { hook, action } = opts.layers;
104
+ if (!hook && !action) {
105
+ throw new Error("nothing to install — drop --no-hook/--no-action or pick at least one layer");
106
+ }
107
+ if (hook) {
108
+ const hookPath = join(root, ".githooks", "commit-msg");
109
+ const existed = existsSync(hookPath);
110
+ writeFile(hookPath, hookTemplate(opts.prefix), 0o755);
111
+ const enabled = setHooksPath(root);
112
+ console.log(`✓ ${existed ? "Updated" : "Wrote"} commit-msg hook (.githooks/commit-msg).`);
113
+ if (enabled) {
114
+ console.log(" Enabled: git config core.hooksPath .githooks");
115
+ }
116
+ else {
117
+ console.log(" ⚠ Could not set core.hooksPath — run: git config core.hooksPath .githooks");
118
+ }
119
+ }
120
+ if (action) {
121
+ const actionPath = join(root, ".github", "workflows", "check-commit-message.yml");
122
+ const existed = existsSync(actionPath);
123
+ writeFile(actionPath, actionTemplate(opts.prefix));
124
+ console.log(`✓ ${existed ? "Updated" : "Wrote"} GitHub Action (.github/workflows/check-commit-message.yml).`);
125
+ }
126
+ console.log("");
127
+ console.log(`Gate is keyed to prefix "${opts.prefix}" — commits/PRs need ${opts.prefix}-NN or [no-issue].`);
128
+ if (action) {
129
+ console.log("The Action is the authoritative gate (runs in CI, no per-developer setup).");
130
+ }
131
+ if (hook) {
132
+ console.log("The hook is local fast-feedback only: opt-in per clone (git won't auto-install it) and bypassable with --no-verify.");
133
+ }
134
+ console.log("\nCommit the written files so the gate ships with the repo.");
135
+ }
@@ -0,0 +1,122 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export const SERVER_NAME = "retasc";
5
+ /** The MCP server entry we wire into an agent's config. */
6
+ export function mcpServerEntry(url, key) {
7
+ return {
8
+ type: "http",
9
+ url,
10
+ headers: { Authorization: `Bearer ${key}` },
11
+ };
12
+ }
13
+ /** A copy-pasteable .mcp.json block (always shown as a fallback). */
14
+ export function mcpConfigBlock(url, key) {
15
+ return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpServerEntry(url, key) } }, null, 2);
16
+ }
17
+ /**
18
+ * The stdio server entry that runs the liveness watchdog proxy (RTSC-44). The
19
+ * harness spawns `retasc mcp-proxy`, which forwards to the remote MCP and keeps
20
+ * claimed issues' leases alive automatically. url+key go via env (not a header,
21
+ * since this is a spawned command). Harness-agnostic: any stdio MCP client works.
22
+ */
23
+ export function mcpProxyEntry(url, key) {
24
+ return {
25
+ command: "retasc",
26
+ args: ["mcp-proxy"],
27
+ env: { RETASC_MCP_URL: url, RETASC_MCP_KEY: key },
28
+ };
29
+ }
30
+ /** Copy-pasteable watchdog block for Codex / OpenCode / any stdio MCP client. */
31
+ export function mcpProxyConfigBlock(url, key) {
32
+ return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpProxyEntry(url, key) } }, null, 2);
33
+ }
34
+ /** Try `claude mcp add`. Returns true on success, false if claude is absent or errored. */
35
+ function tryClaudeCli(url, key, scope) {
36
+ const args = [
37
+ "mcp", "add",
38
+ "--transport", "http",
39
+ "--scope", scope,
40
+ SERVER_NAME, url,
41
+ "--header", `Authorization: Bearer ${key}`,
42
+ ];
43
+ return runClaudeAdd(args);
44
+ }
45
+ /** Try `claude mcp add` for the stdio watchdog proxy (RTSC-44). */
46
+ function tryClaudeCliWatchdog(url, key, scope) {
47
+ const args = [
48
+ "mcp", "add",
49
+ "--transport", "stdio",
50
+ "--scope", scope,
51
+ "--env", `RETASC_MCP_URL=${url}`,
52
+ "--env", `RETASC_MCP_KEY=${key}`,
53
+ SERVER_NAME, "--", "retasc", "mcp-proxy",
54
+ ];
55
+ return runClaudeAdd(args);
56
+ }
57
+ function runClaudeAdd(args) {
58
+ const r = spawnSync("claude", args, { encoding: "utf8" });
59
+ if (r.error)
60
+ return false; // ENOENT — claude not installed
61
+ if (r.status !== 0) {
62
+ // Surface why it failed but don't throw — we fall back to the file.
63
+ const msg = (r.stderr || r.stdout || "").trim();
64
+ if (msg)
65
+ console.error(` (claude mcp add: ${msg.split("\n")[0]})`);
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+ /** Merge a given server entry into ./.mcp.json (project scope on disk). */
71
+ function writeProjectMcpJson(entry) {
72
+ const path = join(process.cwd(), ".mcp.json");
73
+ let doc = {};
74
+ if (existsSync(path)) {
75
+ try {
76
+ doc = JSON.parse(readFileSync(path, "utf8"));
77
+ }
78
+ catch {
79
+ doc = {};
80
+ }
81
+ }
82
+ doc.mcpServers = doc.mcpServers ?? {};
83
+ doc.mcpServers[SERVER_NAME] = entry;
84
+ writeFileSync(path, JSON.stringify(doc, null, 2) + "\n", "utf8");
85
+ return path;
86
+ }
87
+ /**
88
+ * Wire the Retasc MCP server into the user's agent. Prefers the `claude` CLI;
89
+ * falls back to writing ./.mcp.json. Always prints the manual block so the user
90
+ * can paste it into any other MCP client.
91
+ */
92
+ export function installMcp(opts) {
93
+ const scope = opts.scope ?? "user";
94
+ // Watchdog mode (RTSC-44): wire the stdio proxy so claims stay alive automatically.
95
+ if (opts.watchdog) {
96
+ const viaClaude = tryClaudeCliWatchdog(opts.url, opts.key, scope);
97
+ if (viaClaude) {
98
+ console.log(`✓ Registered Retasc with the liveness watchdog (stdio proxy, scope: ${scope}).`);
99
+ }
100
+ else {
101
+ const path = writeProjectMcpJson(mcpProxyEntry(opts.url, opts.key));
102
+ console.log(`✓ Wrote watchdog MCP config to ${path}`);
103
+ console.log(" (Claude Code CLI not detected or unavailable — used .mcp.json instead.)");
104
+ }
105
+ console.log("\nWatchdog MCP config (Codex / OpenCode / any stdio MCP client):\n");
106
+ console.log(mcpProxyConfigBlock(opts.url, opts.key));
107
+ console.log("\nThe watchdog keeps your claims alive automatically — no per-claim heartbeats. If it ever isn't running you just fall back to the normal lease timeout (safe).");
108
+ return;
109
+ }
110
+ const viaClaude = tryClaudeCli(opts.url, opts.key, scope);
111
+ if (viaClaude) {
112
+ console.log(`✓ Registered MCP server "${SERVER_NAME}" with Claude Code (scope: ${scope}).`);
113
+ }
114
+ else {
115
+ const path = writeProjectMcpJson(mcpServerEntry(opts.url, opts.key));
116
+ console.log(`✓ Wrote MCP config to ${path}`);
117
+ console.log(" (Claude Code CLI not detected or unavailable — used .mcp.json instead.)");
118
+ }
119
+ console.log("\nMCP config block (for any other agent/client):\n");
120
+ console.log(mcpConfigBlock(opts.url, opts.key));
121
+ console.log(`\nYour agent can now reach Retasc at ${opts.url}.`);
122
+ }