@primitive.ai/prim 0.1.0-alpha.13 → 0.1.0-alpha.15
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/SKILL.md +68 -11
- package/dist/{chunk-3APLWTLB.js → chunk-SHLF6OL2.js} +30 -1
- package/dist/hooks/pre-commit.js +54 -11
- package/dist/index.js +308 -76
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -81,11 +81,56 @@ npx --yes @primitive.ai/prim project create -n "<name>" -d "<desc>"
|
|
|
81
81
|
npx --yes @primitive.ai/prim project create -n "<name>" --spec <contextId> # value is a context ID
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
### Link a spec to a branch (and an optional PR)
|
|
85
|
+
|
|
86
|
+
A branch-linked spec only auto-syncs from commits on its branch — so per-branch work doesn't mutate specs that aren't relevant. The link is the contract the pre-commit hook checks before every `sync-diff`.
|
|
87
|
+
|
|
88
|
+
Two ways to bind a spec to a branch:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
npx --yes @primitive.ai/prim spec create -s project -n "<name>" --file <path> --branch <branch> --pr <n> # explicit at creation; --pr is optional
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or implicitly: the pre-commit hook **auto-links an unlinked spec to the current branch** the first time it sees a sync on that branch — no flag needed. The `[synced]` line on that first sync prints ` (auto-linking to <branch>)`; subsequent syncs print ` (linked to <branch> #<pr> <state>)` once the link sticks.
|
|
95
|
+
|
|
96
|
+
Inspect a spec's bindings via `npx --yes @primitive.ai/prim context get <id>`. The `linkedBranches[]` field lists every `(branch, prNumber, prState, prReviewDecision)` the spec is bound to. The editor UI surfaces the same data as a status pill.
|
|
97
|
+
|
|
98
|
+
- **`--branch` requires a GitHub origin.** With `--branch`, the CLI reads `repoFullName` from `git remote get-url origin`. If origin isn't GitHub, the link is silently dropped with a warning on stderr — the spec is still created, just unlinked — fix it later from the editor UI.
|
|
99
|
+
- **There is no `prim spec link` subcommand in v1.** To re-link a spec to a different branch, edit it from the spec editor. The CLI only ever auto-links on first sync or accepts `--branch` at creation.
|
|
100
|
+
|
|
101
|
+
### Trigger PR Intent Review or dispatch drift-fix against a linked PR
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
npx --yes @primitive.ai/prim spec review <id> --pr <n> # head SHA defaults to `git rev-parse HEAD`
|
|
105
|
+
npx --yes @primitive.ai/prim spec review <id> --pr <n> --sha <s> # explicit SHA
|
|
106
|
+
npx --yes @primitive.ai/prim spec drift <id> --pr <n> # dispatch the Claude Code drift-fix workflow against the PR
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The review bot runs server-side, posts a PR comment with findings, and the outcome surfaces on the **next** pre-commit sync's `[synced]` line as ` (reviewed: <n> finding(s) → <prCommentUrl>)` or ` (review failed)` — don't poll the API yourself.
|
|
110
|
+
|
|
111
|
+
`spec drift` requires the `primitive-drift-fix.yml` workflow file checked into the repo and the GitHub App's `actions:write` scope granted on the org. The CLI errors out otherwise with a one-liner naming the likely causes.
|
|
112
|
+
|
|
113
|
+
Neither `spec review` nor `spec drift` accepts `--json` in v1 — they emit a single human-readable line on stdout. Capture it as text or branch on `$?`.
|
|
114
|
+
|
|
115
|
+
### Inspect a task's auto-completion state
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
npx --yes @primitive.ai/prim spec status <taskId>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Reports the task's `status`, whether `auto-complete suppressed: yes/no`, and the timestamp + PR # of the most-recent auto-completion activity (with the bot's explanation). Use this after a merge to verify the auto-complete bot acted — or to see *why* it didn't (suppressed via the dashboard, last activity from a different PR, etc.).
|
|
122
|
+
|
|
123
|
+
`spec status` operates on a **task ID**, not a context/spec ID. Discover task IDs from `prim project create` output or from the editor URL. No `--json` support in v1; output is a fixed `key: value` block on stdout.
|
|
124
|
+
|
|
84
125
|
### Install the pre-commit hook
|
|
85
126
|
```
|
|
86
|
-
npx --yes @primitive.ai/prim hooks install
|
|
127
|
+
npx --yes @primitive.ai/prim hooks install # auto-detects Husky and prompts
|
|
128
|
+
npx --yes @primitive.ai/prim hooks install --yes # confirm Husky (non-interactive)
|
|
129
|
+
npx --yes @primitive.ai/prim hooks install --target=git-hooks # force .git/hooks (skip Husky detection)
|
|
87
130
|
npx --yes @primitive.ai/prim hooks uninstall
|
|
88
131
|
```
|
|
132
|
+
Under `CI=1` (or with `--non-interactive`), `hooks install` fails fast in a Husky repo unless `--yes` or `--target` is set. The error message names both escapes.
|
|
133
|
+
|
|
89
134
|
**Note:** `hooks uninstall` only removes `.git/hooks/pre-commit`. If the hook was installed into `.husky/pre-commit`, you must remove the prim block from that file manually.
|
|
90
135
|
|
|
91
136
|
## How the pre-commit hook behaves
|
|
@@ -102,21 +147,32 @@ What that means:
|
|
|
102
147
|
- **The hook is not `npx --yes @primitive.ai/prim spec sync`.** `npx --yes @primitive.ai/prim spec sync` re-applies the *existing* spec to the project. The hook calls `sync-diff` -- an LLM updates the spec from the code change, then applies the new spec to the project. The casual "just commit and the hook will sync" is ambiguous; when explaining to the user, specify which operation you mean.
|
|
103
148
|
- **The hook never blocks the commit.** Failures (auth, network, backend) print `[error]` to stderr but exit 0, so a successful `git commit` doesn't prove the spec changed. Check the hook's `[synced]` / `[error]` / `[skip]` output, or verify with `npx --yes @primitive.ai/prim spec get <id>`.
|
|
104
149
|
- **Diffs over 256 KiB are truncated.** The hook logs `(truncated: X KiB -> Y KiB analyzed)`. The LLM only sees the first 256 KiB of the diff.
|
|
150
|
+
- **The hook is branch-aware.** It sends `repoFullName`, `branch`, `sha`, and `prNumber` (the last detected from `gh pr view` when `gh` is on `PATH`, silently null otherwise). The server filters mappings to specs linked to the current branch *or* unlinked (auto-link candidates); specs bound to other branches are silently excluded from the affected list — they don't surface as `[skip]` lines, they just don't appear. If you push an explicit `sync-diff` for an other-branch spec via the API, the hook logs `[skip] <id> — <name> — not linked to <branch>` and continues.
|
|
151
|
+
- **Link state and review results piggyback on the synced line.** `[synced]` lines carry ` (linked to <branch> #<pr> <state>)` or ` (auto-linking to <branch>)`, and once a PR Intent Review completes they grow ` (reviewed: <n> finding(s) → <prCommentUrl>)` or ` (review failed)`. PR `<state>` (`open` / `closed` / `merged`) tracks GitHub webhook deliveries — give it a few seconds to settle after a state change.
|
|
105
152
|
- **To suppress the hook for one commit** (e.g., when intentionally desyncing code from spec, or when committing unrelated changes), use `git commit --no-verify`.
|
|
106
153
|
|
|
107
154
|
## Output formats
|
|
108
155
|
|
|
109
|
-
|
|
156
|
+
Every data-returning command accepts `--json`. With `--json` set, stdout is a single JSON document — pipe to `jq` instead of parsing text:
|
|
157
|
+
|
|
158
|
+
- `id=$(npx --yes @primitive.ai/prim context create -s global -n foo --text "x" --json | jq -r ._id)` — capture an ID
|
|
159
|
+
- `npx --yes @primitive.ai/prim spec list --json | jq -r '.[]._id'` — list every spec ID
|
|
160
|
+
- `npx --yes @primitive.ai/prim auth status --json | jq -r .authenticated` — boolean; the exit code remains the authoritative signal
|
|
161
|
+
|
|
162
|
+
Without `--json`, mutating commands (`context create/update/delete/link/unlink`, `spec create/update/sync/map/unmap/auto-map`, `project create`) emit the bare resource `_id` to **stdout** (one line, no prefix) and human-readable diagnostics to **stderr**. So this also works as a one-liner without `jq`:
|
|
163
|
+
|
|
164
|
+
- `id=$(npx --yes @primitive.ai/prim context create -s global -n foo --text "x")`
|
|
165
|
+
|
|
166
|
+
| Command | Without `--json` | With `--json` |
|
|
110
167
|
|---|---|---|
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `npx --yes @primitive.ai/prim spec get <id> --text-only` | Raw spec markdown, nothing else | n/a |
|
|
168
|
+
| Mutators above | stdout: bare `_id`; stderr: `Created/Updated/...` prefix (plus secondary lines: `Root project:`, `Linked spec:`, pattern lists) | stdout: `{ "_id": "<id>", … }` with extras where applicable (`spec sync` adds `specRootTaskId`; `context link/unlink` add `project`; `project create --spec` adds `spec`; `spec map/unmap` add `filePatterns`) |
|
|
169
|
+
| `context list`, `spec list` (non-empty) | stdout: rows (first token = `_id`); stderr: `N context(s)` / `N spec(s)` summary | stdout: JSON array |
|
|
170
|
+
| `context list`, `spec list` (empty) | stdout: (empty); stderr: `No contexts found.` / `No spec documents found.` | stdout: `[]` |
|
|
171
|
+
| `spec list --project-id <pid>` | stdout: key:value block (or stdout empty + stderr `No spec document found for this project.` if none) | stdout: single object or `null` |
|
|
172
|
+
| `context get <id>` | stdout: pretty-printed JSON (always JSON; `--json` accepted for symmetry) | stdout: pretty-printed JSON |
|
|
173
|
+
| `spec get <id>` | stdout: human-readable key:value block (`ID:` line first) | stdout: JSON object |
|
|
174
|
+
| `spec get <id> --text-only` | stdout: raw spec markdown, nothing else | stdout: JSON object (`--json` wins over `--text-only`) |
|
|
175
|
+
| `auth status` | stdout: human readout; **exit code is the authoritative signal** (0 = authed) | stdout: JSON; exit code unchanged |
|
|
120
176
|
|
|
121
177
|
## Pitfalls
|
|
122
178
|
|
|
@@ -126,6 +182,7 @@ What that means:
|
|
|
126
182
|
- **`npx --yes @primitive.ai/prim spec sync` rejects non-spec contexts** with "Context is not a spec document. Use `prim context` instead." Use `npx --yes @primitive.ai/prim spec list` to find spec IDs.
|
|
127
183
|
- **`npx --yes @primitive.ai/prim context delete` is permanent.** Confirm with the user before deleting.
|
|
128
184
|
- **Scope is set at creation.** To change it, delete and recreate the context.
|
|
185
|
+
- **The hook silently excludes specs bound to other branches.** If you don't see a spec you expected to sync, check its `linkedBranches[]` via `npx --yes @primitive.ai/prim context get <id>` — it may be bound to a different branch. To re-bind, use the spec editor (no CLI subcommand in v1).
|
|
129
186
|
|
|
130
187
|
## After each task
|
|
131
188
|
|
|
@@ -153,6 +153,34 @@ function getClient() {
|
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// src/utils/git.ts
|
|
157
|
+
import { execSync } from "child_process";
|
|
158
|
+
function safeExec(cmd) {
|
|
159
|
+
try {
|
|
160
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function parseRepoFullName(remoteUrl) {
|
|
166
|
+
const match = remoteUrl.match(/(?:github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
167
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
168
|
+
}
|
|
169
|
+
function getGitContext() {
|
|
170
|
+
const branchRaw = safeExec("git rev-parse --abbrev-ref HEAD");
|
|
171
|
+
const branch = branchRaw && branchRaw !== "HEAD" ? branchRaw : null;
|
|
172
|
+
const sha = safeExec("git rev-parse HEAD");
|
|
173
|
+
const remoteUrl = safeExec("git remote get-url origin");
|
|
174
|
+
const repoFullName = remoteUrl ? parseRepoFullName(remoteUrl) : null;
|
|
175
|
+
let prNumber = null;
|
|
176
|
+
if (safeExec("command -v gh")) {
|
|
177
|
+
const raw = safeExec("gh pr view --json number -q .number");
|
|
178
|
+
const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
179
|
+
if (Number.isFinite(n)) prNumber = n;
|
|
180
|
+
}
|
|
181
|
+
return { branch, sha, repoFullName, prNumber };
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
export {
|
|
157
185
|
TOKEN_FILE_PATH,
|
|
158
186
|
REFRESH_TOKEN_PATH,
|
|
@@ -161,5 +189,6 @@ export {
|
|
|
161
189
|
getTokenExpiresAt,
|
|
162
190
|
getAuthToken,
|
|
163
191
|
getSiteUrl,
|
|
164
|
-
getClient
|
|
192
|
+
getClient,
|
|
193
|
+
getGitContext
|
|
165
194
|
};
|
package/dist/hooks/pre-commit.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
getClient
|
|
4
|
-
|
|
3
|
+
getClient,
|
|
4
|
+
getGitContext
|
|
5
|
+
} from "../chunk-SHLF6OL2.js";
|
|
5
6
|
|
|
6
7
|
// src/hooks/pre-commit.ts
|
|
7
8
|
import { execSync } from "child_process";
|
|
@@ -47,7 +48,8 @@ var HOOK_TIMEOUT_MS = 1e4;
|
|
|
47
48
|
var defaultDeps = {
|
|
48
49
|
getClient,
|
|
49
50
|
getStagedFiles,
|
|
50
|
-
getStagedDiff
|
|
51
|
+
getStagedDiff,
|
|
52
|
+
getGitContext
|
|
51
53
|
};
|
|
52
54
|
async function syncAffectedSpecs(deps = defaultDeps) {
|
|
53
55
|
const stagedFiles = deps.getStagedFiles();
|
|
@@ -55,9 +57,18 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
55
57
|
return [];
|
|
56
58
|
}
|
|
57
59
|
const client = deps.getClient();
|
|
60
|
+
const gitCtx = deps.getGitContext();
|
|
61
|
+
let mappingsUrl = "/api/cli/specs/mappings";
|
|
62
|
+
if (gitCtx.repoFullName && gitCtx.branch) {
|
|
63
|
+
const params = new URLSearchParams({
|
|
64
|
+
repoFullName: gitCtx.repoFullName,
|
|
65
|
+
branch: gitCtx.branch
|
|
66
|
+
});
|
|
67
|
+
mappingsUrl = `${mappingsUrl}?${params.toString()}`;
|
|
68
|
+
}
|
|
58
69
|
let mappings = [];
|
|
59
70
|
try {
|
|
60
|
-
mappings = await client.get(
|
|
71
|
+
mappings = await client.get(mappingsUrl, {
|
|
61
72
|
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
|
|
62
73
|
});
|
|
63
74
|
} catch {
|
|
@@ -66,6 +77,7 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
66
77
|
if (mappings.length === 0) {
|
|
67
78
|
return [];
|
|
68
79
|
}
|
|
80
|
+
const specsById = new Map(mappings.map((s) => [s._id, s]));
|
|
69
81
|
const affectedContexts = findAffectedContexts(stagedFiles, mappings);
|
|
70
82
|
if (affectedContexts.size === 0) {
|
|
71
83
|
return [];
|
|
@@ -90,20 +102,51 @@ async function syncAffectedSpecs(deps = defaultDeps) {
|
|
|
90
102
|
console.log(` [skip] ${contextId} \u2014 no diff content`);
|
|
91
103
|
continue;
|
|
92
104
|
}
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
);
|
|
105
|
+
const body = {
|
|
106
|
+
diffContent,
|
|
107
|
+
affectedFiles: affected.matchedFiles
|
|
108
|
+
};
|
|
109
|
+
if (gitCtx.branch) body.branch = gitCtx.branch;
|
|
110
|
+
if (gitCtx.sha) body.sha = gitCtx.sha;
|
|
111
|
+
if (gitCtx.repoFullName) body.repoFullName = gitCtx.repoFullName;
|
|
112
|
+
if (gitCtx.prNumber !== null) body.prNumber = gitCtx.prNumber;
|
|
113
|
+
const response = await client.post(`/api/cli/contexts/${contextId}/sync-diff`, body, {
|
|
114
|
+
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
|
|
115
|
+
});
|
|
98
116
|
const name = ctx.name ?? "(unnamed)";
|
|
117
|
+
if (!response.analyzing && response.reason === "not_linked") {
|
|
118
|
+
console.log(
|
|
119
|
+
` [skip] ${contextId} \u2014 ${name} \u2014 not linked to ${gitCtx.branch ?? "(no branch)"}`
|
|
120
|
+
);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const spec = specsById.get(contextId);
|
|
124
|
+
const link = spec?.linkedBranches?.find((l) => l.branch === gitCtx.branch);
|
|
125
|
+
let linkSuffix = "";
|
|
126
|
+
if (link) {
|
|
127
|
+
const prBits = link.prNumber ? ` #${String(link.prNumber)}${link.prState ? ` ${link.prState}` : ""}` : "";
|
|
128
|
+
linkSuffix = ` (linked to ${link.branch}${prBits})`;
|
|
129
|
+
} else if (gitCtx.branch && spec?.linkedBranches?.length === 0) {
|
|
130
|
+
linkSuffix = ` (auto-linking to ${gitCtx.branch})`;
|
|
131
|
+
}
|
|
132
|
+
const review = link?.latestReviewSummary;
|
|
133
|
+
let reviewSuffix = "";
|
|
134
|
+
if (review?.status === "completed") {
|
|
135
|
+
const n = review.findingsCount ?? 0;
|
|
136
|
+
const urlSuffix = review.prCommentUrl ? ` \u2192 ${review.prCommentUrl.replace(/^https?:\/\//, "")}` : "";
|
|
137
|
+
reviewSuffix = ` (reviewed: ${String(n)} finding${n === 1 ? "" : "s"}${urlSuffix})`;
|
|
138
|
+
} else if (review?.status === "failed") {
|
|
139
|
+
reviewSuffix = " (review failed)";
|
|
140
|
+
}
|
|
141
|
+
linkSuffix += reviewSuffix;
|
|
99
142
|
if (response.truncated && response.sizeChars && response.limitChars) {
|
|
100
143
|
const sizeKiB = Math.round(response.sizeChars / 1024);
|
|
101
144
|
const limitKiB = Math.round(response.limitChars / 1024);
|
|
102
145
|
console.log(
|
|
103
|
-
` [synced] ${contextId} \u2014 ${name} (truncated: ${String(sizeKiB)} KiB \u2192 ${String(limitKiB)} KiB analyzed)`
|
|
146
|
+
` [synced] ${contextId} \u2014 ${name} (truncated: ${String(sizeKiB)} KiB \u2192 ${String(limitKiB)} KiB analyzed)${linkSuffix}`
|
|
104
147
|
);
|
|
105
148
|
} else {
|
|
106
|
-
console.log(` [synced] ${contextId} \u2014 ${name}`);
|
|
149
|
+
console.log(` [synced] ${contextId} \u2014 ${name}${linkSuffix}`);
|
|
107
150
|
}
|
|
108
151
|
synced.push(contextId);
|
|
109
152
|
} catch (error) {
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
TOKEN_FILE_PATH,
|
|
6
6
|
getAuthToken,
|
|
7
7
|
getClient,
|
|
8
|
+
getGitContext,
|
|
8
9
|
getSiteUrl,
|
|
9
10
|
getTokenExpiresAt,
|
|
10
11
|
saveTokenExpiry
|
|
11
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-SHLF6OL2.js";
|
|
12
13
|
|
|
13
14
|
// src/index.ts
|
|
14
15
|
import { readFileSync as readFileSync6 } from "fs";
|
|
@@ -24,6 +25,13 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
|
24
25
|
import { createServer } from "http";
|
|
25
26
|
import { platform } from "os";
|
|
26
27
|
import { dirname } from "path";
|
|
28
|
+
|
|
29
|
+
// src/output.ts
|
|
30
|
+
function printJson(data) {
|
|
31
|
+
console.log(JSON.stringify(data, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/commands/auth.ts
|
|
27
35
|
var FILE_MODE = 384;
|
|
28
36
|
var LOCALHOST = "127.0.0.1";
|
|
29
37
|
var CALLBACK_PORT = 19876;
|
|
@@ -174,8 +182,22 @@ ${authUrl.toString()}
|
|
|
174
182
|
console.log("No saved tokens found.");
|
|
175
183
|
}
|
|
176
184
|
});
|
|
177
|
-
auth.command("status").description("Check authentication status and token expiry").action(() => {
|
|
185
|
+
auth.command("status").description("Check authentication status and token expiry").option("--json", "Output as JSON").action((opts) => {
|
|
178
186
|
const token = getAuthToken();
|
|
187
|
+
if (opts.json) {
|
|
188
|
+
const expiresAt2 = getTokenExpiresAt();
|
|
189
|
+
const expiresInMs = expiresAt2 ? expiresAt2 - Date.now() : null;
|
|
190
|
+
const refreshPresent = existsSync(REFRESH_TOKEN_PATH);
|
|
191
|
+
printJson({
|
|
192
|
+
authenticated: !!token,
|
|
193
|
+
tokenFile: token ? TOKEN_FILE_PATH : null,
|
|
194
|
+
accessTokenExpiresInMs: expiresInMs,
|
|
195
|
+
accessTokenExpired: expiresInMs !== null && expiresInMs <= 0,
|
|
196
|
+
refreshTokenPresent: refreshPresent,
|
|
197
|
+
warnings: !token || refreshPresent ? [] : ["no refresh token"]
|
|
198
|
+
});
|
|
199
|
+
process.exit(token ? 0 : 1);
|
|
200
|
+
}
|
|
179
201
|
if (!token) {
|
|
180
202
|
console.log("Not authenticated. Run `prim auth login` to authenticate.");
|
|
181
203
|
process.exit(1);
|
|
@@ -238,7 +260,7 @@ async function exchangeCode(siteUrl, code, codeVerifier, redirectUri) {
|
|
|
238
260
|
import { readFileSync as readFileSync2 } from "fs";
|
|
239
261
|
function registerContextCommands(program2) {
|
|
240
262
|
const context = program2.command("context").description("Manage contexts");
|
|
241
|
-
context.command("list").description("List contexts").option("-s, --scope <scope>", "Filter by scope: project, global, external").option("-t, --project-id <projectId>", "List contexts linked to a specific project").action(async (opts) => {
|
|
263
|
+
context.command("list").description("List contexts").option("-s, --scope <scope>", "Filter by scope: project, global, external").option("-t, --project-id <projectId>", "List contexts linked to a specific project").option("--json", "Output as JSON").action(async (opts) => {
|
|
242
264
|
const client = getClient();
|
|
243
265
|
const params = new URLSearchParams();
|
|
244
266
|
if (opts.projectId) {
|
|
@@ -248,14 +270,18 @@ function registerContextCommands(program2) {
|
|
|
248
270
|
params.set("scope", opts.scope === "project" ? "task" : opts.scope);
|
|
249
271
|
}
|
|
250
272
|
const contexts = await client.get(`/api/cli/contexts?${params.toString()}`);
|
|
273
|
+
if (opts.json) {
|
|
274
|
+
printJson(contexts);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
251
277
|
printContextList(contexts);
|
|
252
278
|
});
|
|
253
|
-
context.command("get <contextId>").description("Get a context by ID").action(async (contextId) => {
|
|
279
|
+
context.command("get <contextId>").description("Get a context by ID").option("--json", "Output as JSON (default behavior; accepted for symmetry)").action(async (contextId) => {
|
|
254
280
|
const client = getClient();
|
|
255
281
|
const ctx = await client.get(`/api/cli/contexts/${contextId}`);
|
|
256
|
-
|
|
282
|
+
printJson(ctx);
|
|
257
283
|
});
|
|
258
|
-
context.command("create").description("Create a new context").requiredOption("-s, --scope <scope>", "Scope: project, global, external").requiredOption("-n, --name <name>", "Context name").option("-t, --text <text>", "Context text content").option("-f, --file <path>", "Read text content from file").option("--project-id <projectId>", "Link to project(s), comma-separated").option("--spec", "Mark as a spec document").action(
|
|
284
|
+
context.command("create").description("Create a new context").requiredOption("-s, --scope <scope>", "Scope: project, global, external").requiredOption("-n, --name <name>", "Context name").option("-t, --text <text>", "Context text content").option("-f, --file <path>", "Read text content from file").option("--project-id <projectId>", "Link to project(s), comma-separated").option("--spec", "Mark as a spec document").option("--json", "Output as JSON").action(
|
|
259
285
|
async (opts) => {
|
|
260
286
|
const client = getClient();
|
|
261
287
|
let text = opts.text;
|
|
@@ -270,44 +296,71 @@ function registerContextCommands(program2) {
|
|
|
270
296
|
taskIds,
|
|
271
297
|
isSpecDocument: opts.spec ?? false
|
|
272
298
|
});
|
|
273
|
-
|
|
299
|
+
if (opts.json) {
|
|
300
|
+
printJson({ _id: result._id });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
console.error(`Created context: ${result._id}`);
|
|
304
|
+
console.log(result._id);
|
|
274
305
|
}
|
|
275
306
|
);
|
|
276
|
-
context.command("update <contextId>").description("Update a context").option("-n, --name <name>", "New name").option("-t, --text <text>", "New text content").option("-f, --file <path>", "Read text content from file").
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
307
|
+
context.command("update <contextId>").description("Update a context").option("-n, --name <name>", "New name").option("-t, --text <text>", "New text content").option("-f, --file <path>", "Read text content from file").option("--json", "Output as JSON").action(
|
|
308
|
+
async (contextId, opts) => {
|
|
309
|
+
const client = getClient();
|
|
310
|
+
let text = opts.text;
|
|
311
|
+
if (opts.file) {
|
|
312
|
+
text = readFileSync2(opts.file, "utf-8");
|
|
313
|
+
}
|
|
314
|
+
await client.patch(`/api/cli/contexts/${contextId}`, {
|
|
315
|
+
name: opts.name,
|
|
316
|
+
text
|
|
317
|
+
});
|
|
318
|
+
if (opts.json) {
|
|
319
|
+
printJson({ _id: contextId });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
console.error(`Updated context: ${contextId}`);
|
|
323
|
+
console.log(contextId);
|
|
281
324
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
text
|
|
285
|
-
});
|
|
286
|
-
console.log(`Updated context: ${contextId}`);
|
|
287
|
-
});
|
|
288
|
-
context.command("delete <contextId>").description("Delete a context").action(async (contextId) => {
|
|
325
|
+
);
|
|
326
|
+
context.command("delete <contextId>").description("Delete a context").option("--json", "Output as JSON").action(async (contextId, opts) => {
|
|
289
327
|
const client = getClient();
|
|
290
328
|
await client.delete(`/api/cli/contexts/${contextId}`);
|
|
291
|
-
|
|
329
|
+
if (opts.json) {
|
|
330
|
+
printJson({ _id: contextId });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
console.error(`Deleted context: ${contextId}`);
|
|
334
|
+
console.log(contextId);
|
|
292
335
|
});
|
|
293
|
-
context.command("link <contextId>").description("Link a context to a project").requiredOption("--project <projectId>", "Project ID to link to").action(async (contextId, opts) => {
|
|
336
|
+
context.command("link <contextId>").description("Link a context to a project").requiredOption("--project <projectId>", "Project ID to link to").option("--json", "Output as JSON").action(async (contextId, opts) => {
|
|
294
337
|
const client = getClient();
|
|
295
338
|
await client.post(`/api/cli/contexts/${contextId}/link`, {
|
|
296
339
|
taskId: opts.project
|
|
297
340
|
});
|
|
298
|
-
|
|
341
|
+
if (opts.json) {
|
|
342
|
+
printJson({ _id: contextId, project: opts.project });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
console.error(`Linked context ${contextId} to project ${opts.project}`);
|
|
346
|
+
console.log(contextId);
|
|
299
347
|
});
|
|
300
|
-
context.command("unlink <contextId>").description("Unlink a context from a project").requiredOption("--project <projectId>", "Project ID to unlink from").action(async (contextId, opts) => {
|
|
348
|
+
context.command("unlink <contextId>").description("Unlink a context from a project").requiredOption("--project <projectId>", "Project ID to unlink from").option("--json", "Output as JSON").action(async (contextId, opts) => {
|
|
301
349
|
const client = getClient();
|
|
302
350
|
await client.post(`/api/cli/contexts/${contextId}/unlink`, {
|
|
303
351
|
taskId: opts.project
|
|
304
352
|
});
|
|
305
|
-
|
|
353
|
+
if (opts.json) {
|
|
354
|
+
printJson({ _id: contextId, project: opts.project });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
console.error(`Unlinked context ${contextId} from project ${opts.project}`);
|
|
358
|
+
console.log(contextId);
|
|
306
359
|
});
|
|
307
360
|
}
|
|
308
361
|
function printContextList(contexts) {
|
|
309
362
|
if (contexts.length === 0) {
|
|
310
|
-
console.
|
|
363
|
+
console.error("No contexts found.");
|
|
311
364
|
return;
|
|
312
365
|
}
|
|
313
366
|
for (const ctx of contexts) {
|
|
@@ -316,7 +369,7 @@ function printContextList(contexts) {
|
|
|
316
369
|
const name = ctx.name ?? ctx.title ?? "(unnamed)";
|
|
317
370
|
console.log(`${ctx._id} ${scope.padEnd(8)} ${name}${spec}`);
|
|
318
371
|
}
|
|
319
|
-
console.
|
|
372
|
+
console.error(`
|
|
320
373
|
${contexts.length} context(s)`);
|
|
321
374
|
}
|
|
322
375
|
|
|
@@ -324,6 +377,7 @@ ${contexts.length} context(s)`);
|
|
|
324
377
|
import { execSync } from "child_process";
|
|
325
378
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
326
379
|
import { resolve } from "path";
|
|
380
|
+
import { Option } from "commander";
|
|
327
381
|
var HOOK_SCRIPT = `#!/bin/sh
|
|
328
382
|
# prim pre-commit hook \u2014 auto-syncs affected specs on commit
|
|
329
383
|
# Installed by: prim hooks install
|
|
@@ -431,17 +485,37 @@ function installToDotGit(gitRoot) {
|
|
|
431
485
|
}
|
|
432
486
|
function registerHooksCommands(program2) {
|
|
433
487
|
const hooks = program2.command("hooks").description("Manage git hooks");
|
|
434
|
-
hooks.command("install").description("Install the prim pre-commit hook").
|
|
488
|
+
hooks.command("install").description("Install the prim pre-commit hook (auto-detects Husky; use --target to override)").addOption(
|
|
489
|
+
new Option("--target <where>", "install destination; bypasses Husky detection").choices([
|
|
490
|
+
"husky",
|
|
491
|
+
"git-hooks"
|
|
492
|
+
])
|
|
493
|
+
).action(async (opts, command) => {
|
|
494
|
+
const globals = command.optsWithGlobals();
|
|
495
|
+
const nonInteractive = Boolean(
|
|
496
|
+
globals.nonInteractive || process.env.CI || process.env.PRIM_NON_INTERACTIVE
|
|
497
|
+
);
|
|
435
498
|
const gitRoot = getGitRoot();
|
|
499
|
+
if (opts.target === "husky") return installToHusky(gitRoot);
|
|
500
|
+
if (opts.target === "git-hooks") return installToDotGit(gitRoot);
|
|
436
501
|
if (detectHusky(gitRoot)) {
|
|
437
|
-
|
|
502
|
+
if (globals.yes) return installToHusky(gitRoot);
|
|
503
|
+
if (nonInteractive) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
"--non-interactive set, refusing to prompt for Husky-hook installation. Pass --yes to confirm or --target=git-hooks to choose."
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (!process.stdin.isTTY) {
|
|
509
|
+
console.error(
|
|
510
|
+
"Note: Husky detected but stdin is not a TTY \u2014 falling back to .git/hooks. Pass --yes for Husky or --non-interactive to fail fast."
|
|
511
|
+
);
|
|
512
|
+
} else if (await askConfirmation(
|
|
438
513
|
"Husky detected. Install prim hook into .husky/pre-commit instead of .git/hooks/pre-commit?"
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
514
|
+
)) {
|
|
515
|
+
return installToHusky(gitRoot);
|
|
516
|
+
} else {
|
|
517
|
+
console.log("Falling back to .git/hooks/pre-commit install.");
|
|
443
518
|
}
|
|
444
|
-
console.log("Falling back to .git/hooks/pre-commit install.");
|
|
445
519
|
}
|
|
446
520
|
installToDotGit(gitRoot);
|
|
447
521
|
});
|
|
@@ -460,17 +534,22 @@ function registerHooksCommands(program2) {
|
|
|
460
534
|
// src/commands/project.ts
|
|
461
535
|
function registerProjectCommands(program2) {
|
|
462
536
|
const project = program2.command("project").description("Manage projects");
|
|
463
|
-
project.command("create").description("Create a new project").requiredOption("-n, --name <name>", "Project name").option("-d, --description <description>", "Project description").option("--spec <contextId>", "Link an existing spec as this project's spec").action(async (opts) => {
|
|
537
|
+
project.command("create").description("Create a new project").requiredOption("-n, --name <name>", "Project name").option("-d, --description <description>", "Project description").option("--spec <contextId>", "Link an existing spec as this project's spec").option("--json", "Output as JSON").action(async (opts) => {
|
|
464
538
|
const client = getClient();
|
|
465
539
|
const result = await client.post("/api/cli/tasks", {
|
|
466
540
|
name: opts.name,
|
|
467
541
|
description: opts.description,
|
|
468
542
|
specContextId: opts.spec
|
|
469
543
|
});
|
|
470
|
-
|
|
544
|
+
if (opts.json) {
|
|
545
|
+
printJson(opts.spec ? { _id: result._id, spec: opts.spec } : { _id: result._id });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
console.error(`Created project: ${result._id}`);
|
|
471
549
|
if (opts.spec) {
|
|
472
|
-
console.
|
|
550
|
+
console.error(`Linked spec: ${opts.spec}`);
|
|
473
551
|
}
|
|
552
|
+
console.log(result._id);
|
|
474
553
|
});
|
|
475
554
|
}
|
|
476
555
|
|
|
@@ -592,12 +671,21 @@ function runUninstall(cwd, opts) {
|
|
|
592
671
|
function runStatus(cwd, opts) {
|
|
593
672
|
const target = resolveTarget(cwd, opts.target);
|
|
594
673
|
if (target === null) return 1;
|
|
595
|
-
|
|
674
|
+
const fileExists = existsSync3(target);
|
|
675
|
+
let installed = false;
|
|
676
|
+
if (fileExists) {
|
|
677
|
+
const content = readFileSync4(target, "utf-8");
|
|
678
|
+
installed = content.includes(SKILL_BEGIN) && content.includes(SKILL_END);
|
|
679
|
+
}
|
|
680
|
+
if (opts.json) {
|
|
681
|
+
printJson({ installed, target });
|
|
682
|
+
return installed ? 0 : 1;
|
|
683
|
+
}
|
|
684
|
+
if (!fileExists) {
|
|
596
685
|
console.log(`No rules file at ${target}`);
|
|
597
686
|
return 1;
|
|
598
687
|
}
|
|
599
|
-
|
|
600
|
-
if (content.includes(SKILL_BEGIN) && content.includes(SKILL_END)) {
|
|
688
|
+
if (installed) {
|
|
601
689
|
console.log(`PRIM SKILL v1 installed at ${target}`);
|
|
602
690
|
return 0;
|
|
603
691
|
}
|
|
@@ -617,7 +705,7 @@ function registerSkillCommands(program2) {
|
|
|
617
705
|
skill.command("uninstall").description("Remove the prim skill block from your project rules file").option("--target <path>", "Path to the rules file (overrides auto-detection)").action((opts) => {
|
|
618
706
|
process.exit(runUninstall(process.cwd(), opts));
|
|
619
707
|
});
|
|
620
|
-
skill.command("status").description("Report whether the prim skill block is installed").option("--target <path>", "Path to the rules file (overrides auto-detection)").action((opts) => {
|
|
708
|
+
skill.command("status").description("Report whether the prim skill block is installed").option("--target <path>", "Path to the rules file (overrides auto-detection)").option("--json", "Output as JSON").action((opts) => {
|
|
621
709
|
process.exit(runStatus(process.cwd(), opts));
|
|
622
710
|
});
|
|
623
711
|
}
|
|
@@ -626,20 +714,28 @@ function registerSkillCommands(program2) {
|
|
|
626
714
|
import { readFileSync as readFileSync5 } from "fs";
|
|
627
715
|
function registerSpecCommands(program2) {
|
|
628
716
|
const spec = program2.command("spec").description("Manage spec documents");
|
|
629
|
-
spec.command("list").description("List spec documents").option("-t, --project-id <projectId>", "List spec for a specific root project").action(async (opts) => {
|
|
717
|
+
spec.command("list").description("List spec documents").option("-t, --project-id <projectId>", "List spec for a specific root project").option("--json", "Output as JSON").action(async (opts) => {
|
|
630
718
|
const client = getClient();
|
|
631
719
|
if (opts.projectId) {
|
|
632
720
|
const specs = await client.get(`/api/cli/specs?rootTaskId=${opts.projectId}`);
|
|
721
|
+
if (opts.json) {
|
|
722
|
+
printJson(specs[0] ?? null);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
633
725
|
if (specs.length === 0) {
|
|
634
|
-
console.
|
|
726
|
+
console.error("No spec document found for this project.");
|
|
635
727
|
return;
|
|
636
728
|
}
|
|
637
729
|
printSpec(specs[0]);
|
|
638
730
|
return;
|
|
639
731
|
}
|
|
640
732
|
const contexts = await client.get("/api/cli/specs");
|
|
733
|
+
if (opts.json) {
|
|
734
|
+
printJson(contexts);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
641
737
|
if (contexts.length === 0) {
|
|
642
|
-
console.
|
|
738
|
+
console.error("No spec documents found.");
|
|
643
739
|
return;
|
|
644
740
|
}
|
|
645
741
|
for (const ctx of contexts) {
|
|
@@ -648,39 +744,91 @@ function registerSpecCommands(program2) {
|
|
|
648
744
|
const name = ctx.name ?? "(unnamed)";
|
|
649
745
|
console.log(`${ctx._id} ${scope.padEnd(8)} ${String(review).padEnd(10)} ${name}`);
|
|
650
746
|
}
|
|
651
|
-
console.
|
|
747
|
+
console.error(`
|
|
652
748
|
${contexts.length} spec(s)`);
|
|
653
749
|
});
|
|
654
|
-
spec.command("get <contextId>").description("Get a spec document by ID").option("--text-only", "Print only the text content (no metadata)").action(async (contextId, opts) => {
|
|
750
|
+
spec.command("get <contextId>").description("Get a spec document by ID").option("--text-only", "Print only the text content (no metadata)").option("--json", "Output as JSON (overrides --text-only)").action(async (contextId, opts) => {
|
|
655
751
|
const client = getClient();
|
|
656
752
|
const ctx = await client.get(`/api/cli/contexts/${contextId}`);
|
|
753
|
+
if (opts.json) {
|
|
754
|
+
printJson(ctx);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
657
757
|
if (opts.textOnly) {
|
|
658
758
|
console.log(ctx.text ?? "");
|
|
659
759
|
return;
|
|
660
760
|
}
|
|
661
761
|
printSpec(ctx);
|
|
662
762
|
});
|
|
663
|
-
spec.command("
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
763
|
+
spec.command("create").description("Create a new spec document").requiredOption("-s, --scope <scope>", "Scope: project, global, external").requiredOption("-n, --name <name>", "Spec name").option("-t, --text <text>", "Spec text content").option("-f, --file <path>", "Read text content from file").option("--project-id <projectId>", "Link to project(s), comma-separated").option("--branch <branch>", "Link spec to this branch on the current repo").option("--pr <prNumber>", "Optional PR number to attach to the link").option("--json", "Output as JSON").action(
|
|
764
|
+
async (opts) => {
|
|
765
|
+
const client = getClient();
|
|
766
|
+
let text = opts.text;
|
|
767
|
+
if (opts.file) {
|
|
768
|
+
text = readFileSync5(opts.file, "utf-8");
|
|
769
|
+
}
|
|
770
|
+
const taskIds = opts.projectId ? opts.projectId.split(",").map((id) => id.trim()) : void 0;
|
|
771
|
+
let linkedBranch;
|
|
772
|
+
if (opts.branch) {
|
|
773
|
+
const { repoFullName } = getGitContext();
|
|
774
|
+
if (!repoFullName) {
|
|
775
|
+
console.warn(
|
|
776
|
+
"[prim] --branch supplied but origin remote is not GitHub; skipping link."
|
|
777
|
+
);
|
|
778
|
+
} else {
|
|
779
|
+
linkedBranch = { repoFullName, branch: opts.branch };
|
|
780
|
+
if (opts.pr) {
|
|
781
|
+
const n = Number.parseInt(opts.pr, 10);
|
|
782
|
+
if (Number.isFinite(n)) linkedBranch.prNumber = n;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const result = await client.post("/api/cli/contexts", {
|
|
787
|
+
scope: opts.scope === "project" ? "task" : opts.scope,
|
|
788
|
+
name: opts.name,
|
|
789
|
+
text,
|
|
790
|
+
taskIds,
|
|
791
|
+
isSpecDocument: true,
|
|
792
|
+
linkedBranch
|
|
793
|
+
});
|
|
794
|
+
if (opts.json) {
|
|
795
|
+
printJson({ _id: result._id });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
console.error(
|
|
799
|
+
`Created spec: ${result._id}${linkedBranch ? ` (linked to ${linkedBranch.branch})` : ""}`
|
|
800
|
+
);
|
|
801
|
+
console.log(result._id);
|
|
672
802
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
803
|
+
);
|
|
804
|
+
spec.command("update <contextId>").description("Update a spec document's text content").option("-t, --text <text>", "New text content").option("-f, --file <path>", "Read text content from file").option("-n, --name <name>", "New name").option("--json", "Output as JSON").action(
|
|
805
|
+
async (contextId, opts) => {
|
|
806
|
+
const client = getClient();
|
|
807
|
+
let text = opts.text;
|
|
808
|
+
if (opts.file) {
|
|
809
|
+
text = readFileSync5(opts.file, "utf-8");
|
|
810
|
+
}
|
|
811
|
+
if (!(text || opts.name)) {
|
|
812
|
+
console.error("Provide --text, --file, or --name to update.");
|
|
813
|
+
process.exit(1);
|
|
814
|
+
}
|
|
815
|
+
await client.patch(`/api/cli/contexts/${contextId}`, {
|
|
816
|
+
name: opts.name,
|
|
817
|
+
text,
|
|
818
|
+
skipTiptapLifecycle: !!text
|
|
819
|
+
});
|
|
820
|
+
if (text) {
|
|
821
|
+
await client.post(`/api/cli/contexts/${contextId}/inject`);
|
|
822
|
+
}
|
|
823
|
+
if (opts.json) {
|
|
824
|
+
printJson({ _id: contextId });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
console.error(`Updated spec: ${contextId}`);
|
|
828
|
+
console.log(contextId);
|
|
680
829
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
spec.command("sync <contextId>").description("Trigger spec \u2194 project DAG synchronization").action(async (contextId) => {
|
|
830
|
+
);
|
|
831
|
+
spec.command("sync <contextId>").description("Trigger spec \u2194 project DAG synchronization").option("--json", "Output as JSON").action(async (contextId, opts) => {
|
|
684
832
|
const client = getClient();
|
|
685
833
|
const ctx = await client.get(`/api/cli/contexts/${contextId}`);
|
|
686
834
|
if (!ctx.isSpecDocument) {
|
|
@@ -688,42 +836,123 @@ ${contexts.length} spec(s)`);
|
|
|
688
836
|
process.exit(1);
|
|
689
837
|
}
|
|
690
838
|
await client.post(`/api/cli/contexts/${contextId}/sync`);
|
|
691
|
-
|
|
839
|
+
if (opts.json) {
|
|
840
|
+
printJson(
|
|
841
|
+
ctx.specRootTaskId ? { _id: contextId, specRootTaskId: ctx.specRootTaskId } : { _id: contextId }
|
|
842
|
+
);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
console.error(`Triggered sync for spec: ${contextId}`);
|
|
692
846
|
if (ctx.specRootTaskId) {
|
|
693
|
-
console.
|
|
847
|
+
console.error(`Root project: ${ctx.specRootTaskId}`);
|
|
848
|
+
}
|
|
849
|
+
console.log(contextId);
|
|
850
|
+
});
|
|
851
|
+
spec.command("review <contextId>").description("Manually trigger the PR Intent Review bot for a spec").requiredOption("--pr <prNumber>", "PR number to review against").option("--sha <headSha>", "Commit SHA the review runs against (defaults to current HEAD)").action(async (contextId, opts) => {
|
|
852
|
+
const prNumber = Number.parseInt(opts.pr, 10);
|
|
853
|
+
if (!Number.isFinite(prNumber)) {
|
|
854
|
+
console.error("--pr must be an integer.");
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
const headSha = opts.sha ?? getGitContext().sha;
|
|
858
|
+
if (!headSha) {
|
|
859
|
+
console.error("Could not determine head SHA \u2014 pass --sha or run inside a git checkout.");
|
|
860
|
+
process.exit(1);
|
|
861
|
+
}
|
|
862
|
+
const client = getClient();
|
|
863
|
+
await client.post(`/api/cli/contexts/${contextId}/review`, {
|
|
864
|
+
prNumber,
|
|
865
|
+
headSha
|
|
866
|
+
});
|
|
867
|
+
console.log(
|
|
868
|
+
`Scheduled review: ${contextId} against PR #${String(prNumber)} @ ${headSha.slice(0, 7)}`
|
|
869
|
+
);
|
|
870
|
+
});
|
|
871
|
+
spec.command("drift <contextId>").description("Dispatch the Claude Code drift-fix workflow against a PR").requiredOption("--pr <prNumber>", "PR number to dispatch the drift-fix workflow against").action(async (contextId, opts) => {
|
|
872
|
+
const prNumber = Number.parseInt(opts.pr, 10);
|
|
873
|
+
if (!Number.isFinite(prNumber)) {
|
|
874
|
+
console.error("--pr must be an integer.");
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
const client = getClient();
|
|
878
|
+
const result = await client.post(`/api/cli/contexts/${contextId}/drift`, {
|
|
879
|
+
prNumber
|
|
880
|
+
});
|
|
881
|
+
if (result.dispatched) {
|
|
882
|
+
const ref = result.runUrl ? `: ${result.runUrl}` : "";
|
|
883
|
+
console.log(`Dispatched drift-fix workflow${ref}`);
|
|
884
|
+
} else {
|
|
885
|
+
console.error(
|
|
886
|
+
"Drift-fix dispatch failed. Likely causes: actions:write App scope not granted, primitive-drift-fix.yml workflow file missing, or no findings on the latest review."
|
|
887
|
+
);
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
spec.command("status <taskId>").description(
|
|
892
|
+
"Show task status, auto-complete suppression flag, and the most-recent bot auto-completion"
|
|
893
|
+
).action(async (taskId) => {
|
|
894
|
+
const client = getClient();
|
|
895
|
+
const result = await client.get(`/api/cli/tasks/${taskId}/status`);
|
|
896
|
+
console.log(`status: ${result.status}`);
|
|
897
|
+
console.log(`auto-complete suppressed: ${result.autoCompleteSuppressed ? "yes" : "no"}`);
|
|
898
|
+
const last = result.lastAutoCompleteActivity;
|
|
899
|
+
if (last) {
|
|
900
|
+
const when = last.createdAt ? new Date(last.createdAt).toISOString() : "\u2014";
|
|
901
|
+
const pr = last.prNumber ? `#${String(last.prNumber)}` : "\u2014";
|
|
902
|
+
console.log(`last auto-complete: ${when} (PR ${pr})`);
|
|
903
|
+
if (last.explanation) {
|
|
904
|
+
console.log(` ${last.explanation}`);
|
|
905
|
+
}
|
|
906
|
+
} else {
|
|
907
|
+
console.log("last auto-complete: \u2014");
|
|
694
908
|
}
|
|
695
909
|
});
|
|
696
910
|
spec.command("map <contextId>").description("Map file patterns to a spec (used by pre-commit hook to detect affected specs)").requiredOption(
|
|
697
911
|
"-p, --pattern <patterns...>",
|
|
698
912
|
'Glob pattern(s) to associate, e.g. "src/auth/**"'
|
|
699
|
-
).action(async (contextId, opts) => {
|
|
913
|
+
).option("--json", "Output as JSON").action(async (contextId, opts) => {
|
|
700
914
|
const client = getClient();
|
|
701
915
|
const result = await client.post(`/api/cli/contexts/${contextId}/map`, {
|
|
702
916
|
patterns: opts.pattern
|
|
703
917
|
});
|
|
704
|
-
|
|
918
|
+
if (opts.json) {
|
|
919
|
+
printJson({ _id: contextId, filePatterns: result.filePatterns });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
console.error(`Mapped patterns to spec ${contextId}:`);
|
|
705
923
|
for (const p of result.filePatterns) {
|
|
706
|
-
console.
|
|
924
|
+
console.error(` ${p}`);
|
|
707
925
|
}
|
|
926
|
+
console.log(contextId);
|
|
708
927
|
});
|
|
709
|
-
spec.command("unmap <contextId>").description("Remove file pattern mappings from a spec (omit --pattern to clear all)").option("-p, --pattern <patterns...>", "Specific pattern(s) to remove (omit to clear all)").action(async (contextId, opts) => {
|
|
928
|
+
spec.command("unmap <contextId>").description("Remove file pattern mappings from a spec (omit --pattern to clear all)").option("-p, --pattern <patterns...>", "Specific pattern(s) to remove (omit to clear all)").option("--json", "Output as JSON").action(async (contextId, opts) => {
|
|
710
929
|
const client = getClient();
|
|
711
930
|
const result = await client.post(`/api/cli/contexts/${contextId}/unmap`, {
|
|
712
931
|
patterns: opts.pattern
|
|
713
932
|
});
|
|
933
|
+
if (opts.json) {
|
|
934
|
+
printJson({ _id: contextId, filePatterns: result.filePatterns });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
714
937
|
if (result.filePatterns.length === 0) {
|
|
715
|
-
console.
|
|
938
|
+
console.error(`Cleared all file patterns from spec ${contextId}`);
|
|
716
939
|
} else {
|
|
717
|
-
console.
|
|
940
|
+
console.error(`Updated patterns for spec ${contextId}:`);
|
|
718
941
|
for (const p of result.filePatterns) {
|
|
719
|
-
console.
|
|
942
|
+
console.error(` ${p}`);
|
|
720
943
|
}
|
|
721
944
|
}
|
|
945
|
+
console.log(contextId);
|
|
722
946
|
});
|
|
723
|
-
spec.command("auto-map <contextId>").description("Trigger auto-mapping of file patterns for a spec").action(async (contextId) => {
|
|
947
|
+
spec.command("auto-map <contextId>").description("Trigger auto-mapping of file patterns for a spec").option("--json", "Output as JSON").action(async (contextId, opts) => {
|
|
724
948
|
const client = getClient();
|
|
725
949
|
await client.post(`/api/cli/contexts/${contextId}/auto-map`);
|
|
726
|
-
|
|
950
|
+
if (opts.json) {
|
|
951
|
+
printJson({ _id: contextId });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
console.error(`Auto-mapping triggered for spec: ${contextId}`);
|
|
955
|
+
console.log(contextId);
|
|
727
956
|
});
|
|
728
957
|
}
|
|
729
958
|
function printSpec(ctx) {
|
|
@@ -752,7 +981,10 @@ var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
|
752
981
|
var pkg = JSON.parse(readFileSync6(resolve3(__dirname2, "../package.json"), "utf-8"));
|
|
753
982
|
updateNotifier({ pkg }).notify();
|
|
754
983
|
var program = new Command();
|
|
755
|
-
program.name("prim").description("CLI for managing Primitive specs and contexts").version(pkg.version)
|
|
984
|
+
program.name("prim").description("CLI for managing Primitive specs and contexts").version(pkg.version).option("-y, --yes", "auto-confirm prompts").option(
|
|
985
|
+
"--non-interactive",
|
|
986
|
+
"fail fast instead of prompting (also: CI=1, PRIM_NON_INTERACTIVE=1)"
|
|
987
|
+
);
|
|
756
988
|
registerAuthCommands(program);
|
|
757
989
|
registerContextCommands(program);
|
|
758
990
|
registerSpecCommands(program);
|