@primitive.ai/prim 0.1.0-alpha.45 → 0.1.0-alpha.46

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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: prim
3
- description: Use the prim CLI for Primitive's decision graph — passive decision capture, the conflict gate, reconcile, rationale confirmations, and team presence. TRIGGER when the user mentions Primitive, prim, decisions / the decision graph / a conflict gate / reconcile; when an edit is denied or warned by a prior decision; when the repo's package.json depends on @primitive.ai/prim; when configuring Primitive session or git hooks. SKIP when "decision" is unrelated to Primitive, or for unrelated CLIs.
3
+ description: Use the prim CLI for Primitive's decision graph — passive capture of coding decisions, deliberate recording of higher-order forks in the road, the conflict gate, reconcile, rationale confirmations, and team presence. TRIGGER when the user mentions Primitive, prim, decisions / the decision graph / a conflict gate / reconcile; when a durable decision emerges during coding, planning, review, or connected-context work; when an edit is denied or warned by a prior decision; when the repo's package.json depends on @primitive.ai/prim; when onboarding to or configuring Primitive session or git hooks. SKIP when "decision" is unrelated to Primitive, or for unrelated CLIs.
4
4
  ---
5
5
 
6
6
  # Working with the prim CLI
@@ -11,7 +11,7 @@ description: Use the prim CLI for Primitive's decision graph — passive decisio
11
11
 
12
12
  As your team codes, prim passively captures the **decisions** you make -- which library, which pattern, which config value -- into a queryable graph, and links them: a decision can depend on earlier decisions (auto-linked from shared files, or related by hand — see *Relate decisions*) and reference the files it touched. **Conflict Gates** can check a later change against that graph and surface any load-bearing decision it conflicts with; with **Enforcement**, prim blocks the edit until you reconcile the decision and retry. Conflict Gates are **not currently enabled** — capture runs regardless. To enable them for your team, contact support@getprimitive.ai.
13
13
 
14
- You never invoke capture. It runs automatically through the session hooks installed by `npx --yes @primitive.ai/prim claude install` (Claude Code), `npx --yes @primitive.ai/prim codex install` (Codex), or `npx --yes @primitive.ai/prim hermes install` (Hermes). Your job is to **read** the graph before load-bearing edits and **answer** the occasional rationale confirmation. (Responding to Conflict Gates applies only once Enforcement is enabled — see below.)
14
+ Low-level capture runs automatically through the session hooks installed by `npx --yes @primitive.ai/prim claude install` (Claude Code), `npx --yes @primitive.ai/prim codex install` (Codex), or `npx --yes @primitive.ai/prim hermes install` (Hermes). Deliberately record higher-order forks in the road through `prim decisions create` as described below. Your job is also to **read** the graph before load-bearing edits and **answer** the occasional rationale confirmation. (Responding to Conflict Gates applies only once Enforcement is enabled — see below.)
15
15
 
16
16
  ## Auth
17
17
 
@@ -49,6 +49,8 @@ When enabled, the gate fail-opens on its *own* infrastructure errors (no daemon,
49
49
  - `npx --yes @primitive.ai/prim decisions recent` -- the team's recent decisions, each row badged by author and agent (`Your Claude Code` / `Your Codex` / `Your Hermes`); `--limit <n>` and `--since <dur>` narrow it. `--author "<name>"` filters to one teammate (feed name, `"First Last"`, last name, username, email, or email local-part) -- the way to answer "what has X decided?"; an unknown or ambiguous name comes back as `unavailable` with the reason, and `authorHasDecisions` in the JSON distinguishes "no feed-visible decisions" (false) from "has decisions, none in this window" (true). The page defaults to the 10 most recent, so it can hide older ones: on an author query the JSON's `windowTotal` is how many that teammate has in the window. When `windowTotal` exceeds the rows returned, don't present the page as complete -- tell the user you're showing the most recent N of `windowTotal` and offer to pull the rest, which is a re-run with `--limit <windowTotal>` (capped at 100; `windowTotalCapped` means the count is a floor rendered `N+`, and a window past 100 can't be fetched whole).
50
50
  - `npx --yes @primitive.ai/prim decisions show <idOrShortId>` and `npx --yes @primitive.ai/prim decisions cascade <idOrShortId>` -- full detail, and the downstream blast radius a change would disturb.
51
51
 
52
+ Before presenting decision reads, use the current conversation, task, and available memory to understand what matters to the requester. Do not dump the API's chronological rows unchanged. Group related decisions around goals or workstreams, lead with decisions that affect the requester's current work, and surface conflicts, supersessions, or consequential tradeoffs before background activity. Explain why an item is relevant when the connection is supported; do not invent relevance or rationale. Preserve the response's `unavailable`, truncation, and count semantics when reshaping it.
53
+
52
54
  ## Reconcile and the verdict footer
53
55
 
54
56
  Reconcile and the verdict footer are part of Conflict Gates **Enforcement**, which is **not currently enabled** (contact support@getprimitive.ai to turn it on); the `reconcile` command stays available regardless. When Enforcement is on:
@@ -71,15 +73,96 @@ npx --yes @primitive.ai/prim decisions confirm <idOrShortId>
71
73
 
72
74
  Confirmations are author-targeted and rare by design; answering keeps the graph's rationale trustworthy. Don't manufacture rationale — if you don't know why a decision was made, say so.
73
75
 
74
- ## Author a decision deliberately
76
+ ## Preserve durable user decisions
77
+
78
+ Capture is automatic for low-level choices made while coding. Use the deliberate CLI path for higher-order decisions that emerge in conversation: goals, priorities, principles, invariants, constraints, defaults, commitments, durable tradeoffs, and exceptions.
79
+
80
+ A decision worth deliberately recording is a genuine **fork in the road**: the user or agent encountered multiple plausible paths, selected one, and that selection should inform future work. Record the chosen behavior, direction, constraint, or tradeoff—not routine implementation needed to finish the task or follow an existing convention. A teammate working elsewhere should benefit from knowing it.
81
+
82
+ ### Ground the rationale in available context
83
+
84
+ Before deliberately recording a decision, use the full context already available to you to identify **why this path was chosen**. Start with the current conversation. When the decision or task points to relevant connected sources, inspect them too—especially Slack, Granola or other meeting notes, Linear, Zoom transcripts, email, and repository docs or skills. Prefer the specific thread, meeting, ticket, or document the user referenced over a broad search.
85
+
86
+ Record only rationale supported by those sources. Do not mistake the implementation method, the task request, or a restatement of the decision for its rationale. If the rationale remains unclear or the relevant source is unavailable, omit `--rationale` rather than inventing one.
87
+
88
+ For proactively identified decisions, let confidence in the rationale determine the interaction:
89
+
90
+ - **Clear and well-supported** — record the decision and rationale silently at the natural task boundary.
91
+ - **Plausible but uncertain** — at the task boundary, state the proposed rationale and ask for lightweight confirmation: “I understand the reason for choosing X to be Y. Is that right?” Record after confirmation or correction.
92
+ - **No supported rationale** — at the task boundary, ask one focused question: “What made you choose X over the other path?” Record after the answer.
93
+
94
+ These questions share the interruption budget below; never ask separate questions for the decision and its rationale. If both are uncertain, combine them into one concise prompt. An explicit request to “add this decision to Primitive” still records immediately with the information supplied—do not delay it to demand rationale.
95
+
96
+ ### Direct requests: record immediately
97
+
98
+ When the user asks to record a decision—for example, “add this decision to Primitive”—author it directly. Do not ask for another confirmation.
99
+
100
+ ### Clear decisions: record without interrupting
101
+
102
+ When the user clearly makes a durable fork-in-the-road decision without explicitly asking to record it, record it at the next natural task boundary without asking a redundant confirmation question. Preserve the user's meaning and stated rationale; do not strengthen, broaden, or embellish it. Prefer the governing position over the implementation activity that revealed it.
103
+
104
+ Examples that qualify:
105
+
106
+ - “We're prioritizing retention this quarter.”
107
+ - “Customer data must remain in the EU.”
108
+ - “Authentication state remains server-authoritative.”
109
+ - “Do not introduce another frontend framework.”
110
+
111
+ Treat changes to shared agent instructions and standards—such as `AGENTS.md`, `CLAUDE.md`, repository skills, architecture rules, and design-system guidance—as a high-signal opportunity to preserve a decision. Inspect what rule the change establishes or revises. If it represents a genuine fork in the road, record the durable policy itself, not “updated the docs.” If the edit only documents a decision already present in Primitive, do not create a duplicate.
112
+
113
+ Apply the same attention during non-code deliberation: planning or “grill me” workflows, spec refinement, behavior design, PR planning or review, and conversations imported from Granola or other connected sources. These workflows can settle important forks before any code changes. Accumulate the confirmed decisions and record them at a natural phase or task boundary; do not interrupt or call Primitive after every answer.
75
114
 
76
- Capture is automatic for the decisions you *make while coding*. When the user instead asks you to **record a decision explicitly** — one that didn't fall out of an edit (a design call, a convention, a choice settled in discussion) — author it directly:
115
+ ### Onboarding: propose decisions from shared repository memory
116
+
117
+ When onboarding or `prim welcome` reports `"org": "seed"`, complete this procedure before falling back to the open goals question. Do not treat memory already loaded into the conversation as a substitute for the repository scan. This is a separate decision-proposal pass: do not present repository rules as the user's stated goals or insert them into `$FOUND_GOALS`.
118
+
119
+ 1. From the repository root, discover every Git-tracked shared memory or agent-instruction file with:
120
+
121
+ ```
122
+ git ls-files -- \
123
+ 'MEMORY.md' ':(glob)**/MEMORY.md' \
124
+ 'AGENTS.md' ':(glob)**/AGENTS.md' \
125
+ 'CLAUDE.md' ':(glob)**/CLAUDE.md'
126
+ ```
127
+
128
+ 2. Read every path returned before selecting candidates. Never automatically read an untracked or gitignored memory file; use one only when the user explicitly asks to promote it to the team's Primitive graph.
129
+ 3. Extract at most three explicit, durable positions that appear to remain in force. A candidate must state a genuine fork in the road—such as a goal, priority, principle, invariant, constraint, default, commitment, tradeoff, or exception. Do not infer decisions from code, repository structure, or unstated implications. Do not bulk-import the documents.
130
+ 4. Run `npx --yes @primitive.ai/prim decisions recent --limit 20` and omit candidates equivalent to existing decisions. If the result is unavailable or too incomplete to rule out a duplicate confidently, do not propose the uncertain candidate; explain the limitation.
131
+ 5. Present the remaining candidates as proposed decisions. For each one, include the proposed intent, the source path, and only rationale or alternatives explicitly supported by that source. Ask the user to approve or revise the proposals before creating them; do not silently attribute a historical repository decision to the onboarding user.
132
+
133
+ If the scan returns no matching files or no eligible candidates, say so briefly and continue with the normal open onboarding question. Never invent a proposal to fill the gap.
134
+
135
+ Outside onboarding, before creating a proactively identified decision, read recent decisions to avoid duplicating an existing position:
136
+
137
+ ```
138
+ npx --yes @primitive.ai/prim decisions recent --limit 20
139
+ ```
140
+
141
+ If an equivalent decision is already present, do not create or suggest it again. After this duplicate check—or after the user confirms an onboarding proposal—author the decision directly:
77
142
 
78
143
  ```
79
144
  npx --yes @primitive.ai/prim decisions create --intent "Adopt prosemirror-collab over Yjs" --area data --rationale "Server-authoritative ordering" --alternatives "Yjs,Automerge"
80
145
  ```
81
146
 
82
- Only `--intent` is required. Optional: `--kind` (change|exploration|task_execution|unclear, default change), `--rationale`, `--area`, `--decided`, `--alternatives` (comma-separated), `--confidence` (high|medium|low, default high), `--reversibility` (high|low, default high), and `--files` (comma-separated repo-relative paths the decision governs — these are the files Conflict Gates would check on later edits, same path form as `decisions check`; Conflict Gates are not currently enabled). STDOUT is the created identity `{ decisionId, shortId, createdAt }`; STDERR prints `[prim] created dec_<short>.` — pass that `dec_<short>` straight into `decisions show` / `cascade` / `confirm`. Author on the user's behalf only when they ask for a decision to be recorded; don't narrate your own routine edits into the graph (the hooks already do that).
147
+ Only `--intent` is required. Optional: `--kind` (change|exploration|task_execution|unclear, default change), `--rationale`, `--area`, `--decided`, `--alternatives` (comma-separated), `--confidence` (high|medium|low, default high), `--reversibility` (high|low, default high), and `--files` (comma-separated repo-relative paths the decision governs — these are the files Conflict Gates would check on later edits, same path form as `decisions check`; Conflict Gates are not currently enabled). Omit `--files` for broad directions that should not immediately participate in file-based Conflict Gates. STDOUT is the created identity `{ decisionId, shortId, createdAt }`; STDERR prints `[prim] created dec_<short>.` — pass that `dec_<short>` straight into `decisions show` / `cascade` / `confirm`.
148
+
149
+ ### Inferred decisions: finish first, then optionally ask once
150
+
151
+ When several statements or implementation choices strongly imply a higher-order fork in the road but the user did not state the decision explicitly, do not record it automatically. Complete the requested work first. At the natural stopping point, optionally ask one concise confirmation question that states the synthesized decision:
152
+
153
+ > A broader direction seemed to emerge: prioritize activation and retention before acquisition work. Should I record that as a team decision?
154
+
155
+ Record it only after an affirmative response. Consolidate related evidence into one governing decision rather than listing or recording each underlying implementation choice.
156
+
157
+ ### Interruption budget
158
+
159
+ - Never interrupt active implementation solely to improve Primitive's records.
160
+ - Ask at most one Primitive-related confirmation question per task, only after completing the requested work.
161
+ - Ask nothing when the inference is weak, local, temporary, already recorded, or unlikely to guide another teammate.
162
+ - Do not ask when the user requested a narrow execution task or appears to want a quick result without discussion.
163
+ - Drop a rejected suggestion. Do not raise it again unless the user materially changes the decision.
164
+ - If ambiguity must already be resolved to perform the task, fold the durable-policy distinction into that necessary question instead of adding a separate Primitive question.
165
+ - Do not narrate or record routine edits, syntax, naming, testing, debugging tactics, or branch/commit mechanics; passive hooks already cover coding activity.
83
166
 
84
167
  ## Relate decisions (link / unlink)
85
168
 
@@ -142,9 +225,11 @@ Examples:
142
225
  - **An "unavailable" / "not verified" decision check is not an all-clear.** Treat constraints as UNKNOWN and proceed deliberately; never read the silence as approval — the same holds for Conflict Gates once enabled.
143
226
  - **When Enforcement is enabled, a `deny` means a real prior decision conflicts.** Reconcile only when you genuinely intend to override it; otherwise pick an approach that respects it.
144
227
  - **Reconcile bypasses are single-use and short-lived.** One bypass clears your *next* edit to the governed file; it is not a standing override.
145
- - **Capture of your coding activity is automatic, never manual.** If decisions aren't showing up, check that the session hooks are installed (`claude status` / `codex status` / `hermes status`) and the daemon is running don't try to inject moves by hand. (Deliberately *authoring* a decision the user asks you to record is a separate, supported path `decisions create`, above.)
228
+ - **Capture of coding activity is automatic; never inject moves by hand.** If decisions aren't showing up, check that the session hooks are installed (`claude status` / `codex status` / `hermes status`) and the daemon is running. Deliberately authoring a higher-order fork—either at the user's request or under the conservative proactive policy above—is a separate, supported path through `decisions create`.
146
229
  - **Don't fabricate rationale on a confirmation.** If you don't know why a decision was made, say so rather than guessing.
147
230
 
148
231
  ## After each task
149
232
 
233
+ If the user clearly made a durable fork-in-the-road decision, check recent decisions and record it now if it is not already present. If a strong higher-order decision was only inferred, decide whether it clears the interruption budget; if so, ask the single concise confirmation question after reporting the completed work. Otherwise say nothing about Primitive.
234
+
150
235
  If Conflict Gates are enabled and one denied or warned you, report which decision(s) it named and whether you reconciled. If you read the graph before a load-bearing change, note what you found so the user can verify in the dashboard.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getSiteUrl
3
- } from "./chunk-PI6LDYNJ.js";
3
+ } from "./chunk-VQ2ZV2D5.js";
4
4
 
5
5
  // src/journal.ts
6
6
  import {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getClient
3
- } from "./chunk-PI6LDYNJ.js";
3
+ } from "./chunk-VQ2ZV2D5.js";
4
4
 
5
5
  // src/decisions/feedback.ts
6
6
  var FEEDBACK_PROTOCOL_VERSION = 1;
@@ -0,0 +1,502 @@
1
+ // src/client.ts
2
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
3
+ import {
4
+ chmodSync,
5
+ existsSync as existsSync2,
6
+ mkdirSync as mkdirSync2,
7
+ readFileSync as readFileSync2,
8
+ renameSync,
9
+ rmSync as rmSync2,
10
+ writeFileSync as writeFileSync2
11
+ } from "fs";
12
+ import { homedir } from "os";
13
+ import { dirname as dirname2, join as join2, resolve } from "path";
14
+
15
+ // src/lib/file-lock.ts
16
+ import { randomBytes } from "crypto";
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
18
+ import { dirname, join } from "path";
19
+ var DEFAULT_TIMEOUT_MS = 3e4;
20
+ var DEFAULT_POLL_MS = 50;
21
+ var DEFAULT_INIT_GRACE_MS = 2e3;
22
+ var DIRECTORY_MODE = 448;
23
+ var FILE_MODE = 384;
24
+ function ownerPath(lockDir) {
25
+ return join(lockDir, "owner.json");
26
+ }
27
+ function readOwner(lockDir) {
28
+ try {
29
+ const owner = JSON.parse(readFileSync(ownerPath(lockDir), "utf8"));
30
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.nonce !== "string" || typeof owner.createdAt !== "number") {
31
+ return null;
32
+ }
33
+ return owner;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ function sameOwner(a, b) {
39
+ return a?.pid === b.pid && a.nonce === b.nonce && a.createdAt === b.createdAt;
40
+ }
41
+ function tryTakeLock(lockDir, now) {
42
+ try {
43
+ mkdirSync(lockDir, { mode: DIRECTORY_MODE });
44
+ } catch (error) {
45
+ if (error.code === "EEXIST") return null;
46
+ throw error;
47
+ }
48
+ const owner = {
49
+ pid: process.pid,
50
+ nonce: randomBytes(16).toString("hex"),
51
+ createdAt: now
52
+ };
53
+ try {
54
+ writeFileSync(ownerPath(lockDir), `${JSON.stringify(owner)}
55
+ `, {
56
+ encoding: "utf8",
57
+ mode: FILE_MODE,
58
+ flag: "wx"
59
+ });
60
+ return owner;
61
+ } catch (error) {
62
+ rmSync(lockDir, { recursive: true, force: true });
63
+ throw error;
64
+ }
65
+ }
66
+ function processIsAlive(pid) {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return true;
70
+ } catch (error) {
71
+ return error.code === "EPERM";
72
+ }
73
+ }
74
+ function recoverStaleLock(lockDir, now, alive, initGraceMs) {
75
+ const owner = readOwner(lockDir);
76
+ if (owner) {
77
+ if (alive(owner.pid)) return false;
78
+ if (!sameOwner(readOwner(lockDir), owner)) return false;
79
+ rmSync(lockDir, { recursive: true, force: true });
80
+ return true;
81
+ }
82
+ try {
83
+ const before = statSync(lockDir).mtimeMs;
84
+ if (now - before < initGraceMs) return false;
85
+ if (readOwner(lockDir)) return false;
86
+ if (statSync(lockDir).mtimeMs !== before) return false;
87
+ rmSync(lockDir, { recursive: true, force: true });
88
+ return true;
89
+ } catch {
90
+ return true;
91
+ }
92
+ }
93
+ function abortError(signal) {
94
+ if (signal.reason instanceof Error) return signal.reason;
95
+ const error = new Error("The operation was aborted");
96
+ error.name = "AbortError";
97
+ return error;
98
+ }
99
+ function throwIfAborted(signal) {
100
+ if (signal?.aborted) throw abortError(signal);
101
+ }
102
+ async function defaultSleep(ms, signal) {
103
+ if (!signal) {
104
+ await new Promise((resolve2) => setTimeout(resolve2, ms));
105
+ return;
106
+ }
107
+ throwIfAborted(signal);
108
+ await new Promise((resolve2, reject) => {
109
+ const timer = setTimeout(() => {
110
+ signal.removeEventListener("abort", onAbort);
111
+ resolve2();
112
+ }, ms);
113
+ const onAbort = () => {
114
+ clearTimeout(timer);
115
+ reject(abortError(signal));
116
+ };
117
+ signal.addEventListener("abort", onAbort, { once: true });
118
+ });
119
+ }
120
+ async function withFileLock(lockDir, operation, options = {}) {
121
+ mkdirSync(dirname(lockDir), { recursive: true, mode: DIRECTORY_MODE });
122
+ const nowMs = options.nowMs ?? Date.now;
123
+ const alive = options.processAlive ?? processIsAlive;
124
+ const deadline = nowMs() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
125
+ let owner = null;
126
+ while (!owner) {
127
+ throwIfAborted(options.signal);
128
+ const now = nowMs();
129
+ owner = tryTakeLock(lockDir, now);
130
+ if (owner) break;
131
+ recoverStaleLock(lockDir, now, alive, options.initGraceMs ?? DEFAULT_INIT_GRACE_MS);
132
+ if (now >= deadline) {
133
+ throw new Error(`timed out waiting for file lock ${lockDir}`);
134
+ }
135
+ if (options.sleep) {
136
+ await options.sleep(options.pollMs ?? DEFAULT_POLL_MS);
137
+ throwIfAborted(options.signal);
138
+ } else {
139
+ await defaultSleep(options.pollMs ?? DEFAULT_POLL_MS, options.signal);
140
+ }
141
+ }
142
+ try {
143
+ throwIfAborted(options.signal);
144
+ return await operation();
145
+ } finally {
146
+ if (sameOwner(readOwner(lockDir), owner)) {
147
+ rmSync(lockDir, { recursive: true, force: true });
148
+ }
149
+ }
150
+ }
151
+
152
+ // src/client.ts
153
+ var CONFIG_DIR_MODE = 448;
154
+ var CREDENTIAL_FILE_MODE = 384;
155
+ var REFRESH_THRESHOLD_MS = 6e4;
156
+ var DEFAULT_API_URL = "https://api.getprimitive.ai";
157
+ function loadEnvFile() {
158
+ const envVars = {};
159
+ for (const file of [".env.local", ".env"]) {
160
+ const filePath = resolve(process.cwd(), file);
161
+ if (!existsSync2(filePath)) continue;
162
+ const content = readFileSync2(filePath, "utf-8");
163
+ for (const line of content.split("\n")) {
164
+ const trimmed = line.trim();
165
+ if (!trimmed || trimmed.startsWith("#")) continue;
166
+ const eqIdx = trimmed.indexOf("=");
167
+ if (eqIdx === -1) continue;
168
+ envVars[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim();
169
+ }
170
+ }
171
+ return envVars;
172
+ }
173
+ var TOKEN_FILE_PATH = join2(homedir(), ".config", "prim", "token");
174
+ var REFRESH_TOKEN_PATH = join2(dirname2(TOKEN_FILE_PATH), "refresh_token");
175
+ var TOKEN_EXPIRES_PATH = join2(dirname2(TOKEN_FILE_PATH), "token_expires_at");
176
+ var TERMINAL_REFRESH_PATH = join2(dirname2(TOKEN_FILE_PATH), "refresh_terminal");
177
+ var CREDENTIAL_LOCK_PATH = join2(dirname2(TOKEN_FILE_PATH), "credentials.lock");
178
+ function readTrimmed(path) {
179
+ try {
180
+ const value = readFileSync2(path, "utf8").trim();
181
+ return value || void 0;
182
+ } catch {
183
+ return void 0;
184
+ }
185
+ }
186
+ function resolveAuthCredential() {
187
+ if (process.env.PRIM_TOKEN) {
188
+ return { token: process.env.PRIM_TOKEN, source: "environment" };
189
+ }
190
+ const stored = readTrimmed(TOKEN_FILE_PATH);
191
+ if (stored) {
192
+ return { token: stored, source: "token_file" };
193
+ }
194
+ const envToken = loadEnvFile().PRIM_TOKEN;
195
+ return envToken ? { token: envToken, source: "env_file" } : void 0;
196
+ }
197
+ function getSiteUrl() {
198
+ if (process.env.PRIM_API_URL) return process.env.PRIM_API_URL;
199
+ return loadEnvFile().PRIM_API_URL ?? DEFAULT_API_URL;
200
+ }
201
+ function getJwtExpiry(token) {
202
+ const parts = token.split(".");
203
+ if (parts.length !== 3) return void 0;
204
+ try {
205
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
206
+ return payload.exp ? payload.exp * 1e3 : void 0;
207
+ } catch {
208
+ return void 0;
209
+ }
210
+ }
211
+ function expiresAtFor(token, expiresIn) {
212
+ return expiresIn === void 0 ? getJwtExpiry(token) : Date.now() + expiresIn * 1e3;
213
+ }
214
+ function ensureConfigDirectory() {
215
+ const directory = dirname2(TOKEN_FILE_PATH);
216
+ mkdirSync2(directory, { recursive: true, mode: CONFIG_DIR_MODE });
217
+ chmodSync(directory, CONFIG_DIR_MODE);
218
+ }
219
+ function atomicWrite(path, content) {
220
+ ensureConfigDirectory();
221
+ const temp = join2(
222
+ dirname2(path),
223
+ `.${path.slice(dirname2(path).length + 1)}.tmp-${process.pid}-${randomBytes2(8).toString("hex")}`
224
+ );
225
+ try {
226
+ writeFileSync2(temp, content, {
227
+ encoding: "utf8",
228
+ mode: CREDENTIAL_FILE_MODE,
229
+ flag: "wx"
230
+ });
231
+ renameSync(temp, path);
232
+ } catch (error) {
233
+ rmSync2(temp, { force: true });
234
+ throw error;
235
+ }
236
+ }
237
+ function removeCredentialFile(path) {
238
+ if (!existsSync2(path)) return false;
239
+ rmSync2(path, { force: true });
240
+ return true;
241
+ }
242
+ function getTokenExpiresAt() {
243
+ const stored = readTrimmed(TOKEN_EXPIRES_PATH);
244
+ if (stored === void 0) return void 0;
245
+ const value = Number(stored);
246
+ return Number.isNaN(value) ? void 0 : value;
247
+ }
248
+ function isTokenExpiringSoon(credential) {
249
+ if (credential.source !== "token_file") return false;
250
+ const expiresAt = getTokenExpiresAt();
251
+ return expiresAt !== void 0 && Date.now() >= expiresAt - REFRESH_THRESHOLD_MS;
252
+ }
253
+ function refreshFingerprint(refreshToken2) {
254
+ return createHash("sha256").update(refreshToken2).digest("hex");
255
+ }
256
+ function terminalFingerprint() {
257
+ return readTrimmed(TERMINAL_REFRESH_PATH);
258
+ }
259
+ function writeTerminalFingerprint(refreshToken2) {
260
+ atomicWrite(TERMINAL_REFRESH_PATH, `${refreshFingerprint(refreshToken2)}
261
+ `);
262
+ }
263
+ function clearTerminalFingerprint() {
264
+ removeCredentialFile(TERMINAL_REFRESH_PATH);
265
+ }
266
+ function isSessionEnded() {
267
+ if (resolveAuthCredential()?.source !== "token_file") return false;
268
+ const refreshToken2 = readTrimmed(REFRESH_TOKEN_PATH);
269
+ const ended = terminalFingerprint();
270
+ return Boolean(refreshToken2 && ended && refreshFingerprint(refreshToken2) === ended);
271
+ }
272
+ function withCredentialLock(operation, options = {}) {
273
+ return withFileLock(CREDENTIAL_LOCK_PATH, operation, options);
274
+ }
275
+ function commitCredentialsUnlocked(credentials) {
276
+ const accessToken = credentials.accessToken.trim();
277
+ const refreshToken2 = credentials.refreshToken.trim();
278
+ if (!accessToken || !refreshToken2) {
279
+ throw new Error("OAuth credentials require both access and refresh tokens");
280
+ }
281
+ atomicWrite(REFRESH_TOKEN_PATH, `${refreshToken2}
282
+ `);
283
+ const expiresAt = expiresAtFor(accessToken, credentials.expiresIn);
284
+ if (expiresAt === void 0) removeCredentialFile(TOKEN_EXPIRES_PATH);
285
+ else atomicWrite(TOKEN_EXPIRES_PATH, `${expiresAt}
286
+ `);
287
+ atomicWrite(TOKEN_FILE_PATH, `${accessToken}
288
+ `);
289
+ clearTerminalFingerprint();
290
+ _cachedCredential = { token: accessToken, source: "token_file" };
291
+ }
292
+ async function commitCredentials(credentials, options = {}) {
293
+ await withCredentialLock(() => commitCredentialsUnlocked(credentials), options);
294
+ }
295
+ async function setStoredToken(token, options = {}) {
296
+ const value = token.trim();
297
+ if (!value) throw new Error("Token cannot be empty");
298
+ await withCredentialLock(() => {
299
+ removeCredentialFile(REFRESH_TOKEN_PATH);
300
+ removeCredentialFile(TOKEN_EXPIRES_PATH);
301
+ clearTerminalFingerprint();
302
+ atomicWrite(TOKEN_FILE_PATH, `${value}
303
+ `);
304
+ _cachedCredential = { token: value, source: "token_file" };
305
+ }, options);
306
+ }
307
+ async function clearStoredCredentials(options = {}) {
308
+ const { beforeClear, ...lockOptions } = options;
309
+ return withCredentialLock(async () => {
310
+ const refreshToken2 = readTrimmed(REFRESH_TOKEN_PATH);
311
+ let callbackError;
312
+ try {
313
+ await beforeClear?.(refreshToken2);
314
+ } catch (error) {
315
+ callbackError = error;
316
+ }
317
+ let removed = false;
318
+ for (const path of [
319
+ TOKEN_FILE_PATH,
320
+ REFRESH_TOKEN_PATH,
321
+ TOKEN_EXPIRES_PATH,
322
+ TERMINAL_REFRESH_PATH
323
+ ]) {
324
+ removed = removeCredentialFile(path) || removed;
325
+ }
326
+ _cachedCredential = void 0;
327
+ if (callbackError) throw callbackError;
328
+ return removed;
329
+ }, lockOptions);
330
+ }
331
+ function isTerminalRefreshResponse(response, detail) {
332
+ if (detail.includes("invalid_grant") || detail.includes("Session has already ended")) return true;
333
+ try {
334
+ const error = JSON.parse(detail).error;
335
+ return error === "invalid_grant" || response.status === 401 && error === "Invalid or expired refresh token";
336
+ } catch {
337
+ return response.status === 401 && detail.includes("Invalid or expired refresh token");
338
+ }
339
+ }
340
+ function refreshDiagnostic(response, detail, quiet) {
341
+ if (quiet) return;
342
+ process.stderr.write(
343
+ `[prim] token refresh rejected by broker: ${response.status} ${response.statusText}${detail ? ` \u2014 ${detail}` : ""}
344
+ `
345
+ );
346
+ }
347
+ async function performTokenRefresh(options = {}) {
348
+ const selected = resolveAuthCredential();
349
+ if (selected?.source !== "token_file") return void 0;
350
+ const startingGeneration = readTrimmed(REFRESH_TOKEN_PATH);
351
+ if (!startingGeneration || isSessionEnded()) return void 0;
352
+ return withCredentialLock(
353
+ async () => {
354
+ const currentCredential = resolveAuthCredential();
355
+ if (currentCredential?.source !== "token_file") return void 0;
356
+ const currentGeneration = readTrimmed(REFRESH_TOKEN_PATH);
357
+ if (!currentGeneration) return void 0;
358
+ if (currentGeneration !== startingGeneration) {
359
+ return currentCredential.token;
360
+ }
361
+ if (isSessionEnded()) return void 0;
362
+ const response = await fetch(`${getSiteUrl()}/mcp/broker/refresh`, {
363
+ method: "POST",
364
+ headers: { "Content-Type": "application/json" },
365
+ body: JSON.stringify({ refresh_token: currentGeneration }),
366
+ signal: options.signal
367
+ });
368
+ if (!response.ok) {
369
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
370
+ if (isTerminalRefreshResponse(response, detail) && readTrimmed(REFRESH_TOKEN_PATH) === currentGeneration) {
371
+ writeTerminalFingerprint(currentGeneration);
372
+ }
373
+ refreshDiagnostic(response, detail, options.quiet);
374
+ const winner = resolveAuthCredential();
375
+ return readTrimmed(REFRESH_TOKEN_PATH) !== currentGeneration && winner?.source === "token_file" ? winner.token : void 0;
376
+ }
377
+ let data;
378
+ try {
379
+ data = await response.json();
380
+ } catch {
381
+ if (readTrimmed(REFRESH_TOKEN_PATH) === currentGeneration) {
382
+ writeTerminalFingerprint(currentGeneration);
383
+ }
384
+ return void 0;
385
+ }
386
+ const record = typeof data === "object" && data !== null && !Array.isArray(data) ? data : void 0;
387
+ const accessToken = typeof record?.access_token === "string" ? record.access_token.trim() : "";
388
+ const replacementRefreshToken = typeof record?.refresh_token === "string" ? record.refresh_token.trim() : "";
389
+ if (!(accessToken && replacementRefreshToken) || replacementRefreshToken === currentGeneration) {
390
+ if (readTrimmed(REFRESH_TOKEN_PATH) === currentGeneration) {
391
+ writeTerminalFingerprint(currentGeneration);
392
+ }
393
+ return void 0;
394
+ }
395
+ commitCredentialsUnlocked({
396
+ accessToken,
397
+ refreshToken: replacementRefreshToken,
398
+ expiresIn: typeof record?.expires_in === "number" && Number.isFinite(record.expires_in) ? record.expires_in : void 0
399
+ });
400
+ return accessToken;
401
+ },
402
+ { signal: options.signal }
403
+ );
404
+ }
405
+ var _refreshInFlight;
406
+ function refreshToken(options = {}) {
407
+ if (_refreshInFlight) return _refreshInFlight;
408
+ const attempt = performTokenRefresh(options).then((token) => {
409
+ if (token) _cachedCredential = { token, source: "token_file" };
410
+ return token;
411
+ }).finally(() => {
412
+ if (_refreshInFlight === attempt) _refreshInFlight = void 0;
413
+ });
414
+ _refreshInFlight = attempt;
415
+ return attempt;
416
+ }
417
+ var HttpError = class extends Error {
418
+ status;
419
+ constructor(status, message) {
420
+ super(message);
421
+ this.name = "HttpError";
422
+ this.status = status;
423
+ }
424
+ };
425
+ var _cachedCredential;
426
+ function selectedCredential() {
427
+ const resolved = resolveAuthCredential();
428
+ if (!_cachedCredential || resolved?.token !== _cachedCredential.token || resolved?.source !== _cachedCredential.source) {
429
+ _cachedCredential = resolved;
430
+ }
431
+ return _cachedCredential;
432
+ }
433
+ async function request(method, path, body, options) {
434
+ const url = `${getSiteUrl()}${path}`;
435
+ let credential = selectedCredential();
436
+ let refreshAttempted = false;
437
+ if (credential && isTokenExpiringSoon(credential)) {
438
+ refreshAttempted = true;
439
+ const token = await refreshToken({
440
+ signal: options?.signal,
441
+ quiet: options?.quietRefresh
442
+ });
443
+ if (token) credential = { token, source: "token_file" };
444
+ }
445
+ const doFetch = (token) => {
446
+ const headers = { "Content-Type": "application/json" };
447
+ if (token) headers.Authorization = `Bearer ${token}`;
448
+ return fetch(url, {
449
+ method,
450
+ headers,
451
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
452
+ signal: options?.signal
453
+ });
454
+ };
455
+ const tokenUsed = credential?.token;
456
+ let response = await doFetch(tokenUsed);
457
+ if (response.status === 401) {
458
+ const latest = resolveAuthCredential();
459
+ let retryToken = latest?.token !== tokenUsed ? latest?.token : void 0;
460
+ if (!retryToken && latest?.source === "token_file" && !refreshAttempted) {
461
+ refreshAttempted = true;
462
+ retryToken = await refreshToken({ signal: options?.signal, quiet: options?.quietRefresh });
463
+ }
464
+ if (retryToken) {
465
+ _cachedCredential = {
466
+ token: retryToken,
467
+ source: latest?.source === "token_file" ? "token_file" : latest?.source ?? "token_file"
468
+ };
469
+ response = await doFetch(retryToken);
470
+ }
471
+ }
472
+ if (!response.ok) {
473
+ if (response.status === 401) {
474
+ throw new HttpError(401, "Authentication expired. Run `prim auth login` to re-authenticate.");
475
+ }
476
+ const errorBody = await response.json().catch(() => null);
477
+ throw new HttpError(response.status, errorBody?.error ?? `HTTP ${response.status}`);
478
+ }
479
+ return response.json();
480
+ }
481
+ function getClient() {
482
+ return {
483
+ get: (path, options) => request("GET", path, void 0, options),
484
+ post: (path, body, options) => request("POST", path, body, options)
485
+ };
486
+ }
487
+
488
+ export {
489
+ withFileLock,
490
+ TOKEN_FILE_PATH,
491
+ REFRESH_TOKEN_PATH,
492
+ resolveAuthCredential,
493
+ getSiteUrl,
494
+ getTokenExpiresAt,
495
+ isSessionEnded,
496
+ commitCredentials,
497
+ setStoredToken,
498
+ clearStoredCredentials,
499
+ refreshToken,
500
+ HttpError,
501
+ getClient
502
+ };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  getClient,
3
3
  getSiteUrl
4
- } from "./chunk-PI6LDYNJ.js";
4
+ } from "./chunk-VQ2ZV2D5.js";
5
5
  import {
6
6
  daemonRequest
7
7
  } from "./chunk-UTKQTZHL.js";