@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 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 # auto-detects Husky and prompts
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
- | Command | Output | Where the ID is |
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
- | `npx --yes @primitive.ai/prim context create` | `Created context: <id>` | Match `^Created context: (\S+)` |
112
- | `npx --yes @primitive.ai/prim project create` | `Created project: <id>` | Match `^Created project: (\S+)` |
113
- | `npx --yes @primitive.ai/prim spec update` | `Updated spec: <id>` | Match `^Updated spec: (\S+)` |
114
- | `npx --yes @primitive.ai/prim spec sync` | `Triggered sync for spec: <id>` | Match `^Triggered sync for spec: (\S+)` |
115
- | `npx --yes @primitive.ai/prim context list`, `npx --yes @primitive.ai/prim spec list` | Table with trailing count line | First token of each row (skip the final `N spec(s)` / `N context(s)` summary line) |
116
- | `npx --yes @primitive.ai/prim spec list --project-id <pid>` | Single-spec block (key:value) | `ID:` line |
117
- | `npx --yes @primitive.ai/prim context get <id>` | Pretty-printed JSON | `._id` field |
118
- | `npx --yes @primitive.ai/prim spec get <id>` | Human-readable key:value block | `ID:` line |
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
  };
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- getClient
4
- } from "../chunk-3APLWTLB.js";
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("/api/cli/specs/mappings", {
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 response = await client.post(
94
- `/api/cli/contexts/${contextId}/sync-diff`,
95
- { diffContent, affectedFiles: affected.matchedFiles },
96
- { signal: AbortSignal.timeout(HOOK_TIMEOUT_MS) }
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-3APLWTLB.js";
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
- console.log(JSON.stringify(ctx, null, 2));
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
- console.log(`Created context: ${result._id}`);
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").action(async (contextId, opts) => {
277
- const client = getClient();
278
- let text = opts.text;
279
- if (opts.file) {
280
- text = readFileSync2(opts.file, "utf-8");
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
- await client.patch(`/api/cli/contexts/${contextId}`, {
283
- name: opts.name,
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
- console.log(`Deleted context: ${contextId}`);
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
- console.log(`Linked context ${contextId} to project ${opts.project}`);
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
- console.log(`Unlinked context ${contextId} from project ${opts.project}`);
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.log("No contexts found.");
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.log(`
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").action(async () => {
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
- const confirmed = await askConfirmation(
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
- if (confirmed) {
441
- installToHusky(gitRoot);
442
- return;
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
- console.log(`Created project: ${result._id}`);
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.log(`Linked spec: ${opts.spec}`);
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
- if (!existsSync3(target)) {
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
- const content = readFileSync4(target, "utf-8");
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.log("No spec document found for this project.");
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.log("No spec documents found.");
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.log(`
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("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").action(async (contextId, opts) => {
664
- const client = getClient();
665
- let text = opts.text;
666
- if (opts.file) {
667
- text = readFileSync5(opts.file, "utf-8");
668
- }
669
- if (!(text || opts.name)) {
670
- console.error("Provide --text, --file, or --name to update.");
671
- process.exit(1);
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
- await client.patch(`/api/cli/contexts/${contextId}`, {
674
- name: opts.name,
675
- text,
676
- skipTiptapLifecycle: !!text
677
- });
678
- if (text) {
679
- await client.post(`/api/cli/contexts/${contextId}/inject`);
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
- console.log(`Updated spec: ${contextId}`);
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
- console.log(`Triggered sync for spec: ${contextId}`);
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.log(`Root project: ${ctx.specRootTaskId}`);
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
- console.log(`Mapped patterns to spec ${contextId}:`);
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.log(` ${p}`);
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.log(`Cleared all file patterns from spec ${contextId}`);
938
+ console.error(`Cleared all file patterns from spec ${contextId}`);
716
939
  } else {
717
- console.log(`Updated patterns for spec ${contextId}:`);
940
+ console.error(`Updated patterns for spec ${contextId}:`);
718
941
  for (const p of result.filePatterns) {
719
- console.log(` ${p}`);
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
- console.log(`Auto-mapping triggered for spec: ${contextId}`);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitive.ai/prim",
3
- "version": "0.1.0-alpha.13",
3
+ "version": "0.1.0-alpha.15",
4
4
  "description": "CLI for managing Primitive specs, contexts, and git hooks",
5
5
  "type": "module",
6
6
  "license": "MIT",