@primitive.ai/prim 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Primitive
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @primitive.ai/prim
2
+
3
+ The official CLI for [Primitive](https://getprimitive.ai). Manage specs, contexts, tasks, and git hooks from the command line.
4
+
5
+ > [!WARNING]
6
+ > This project is in **alpha**. Commands and APIs may change between releases.
7
+
8
+ ## Installation
9
+
10
+ Requires Node.js 20+.
11
+
12
+ ```bash
13
+ npm install -g @primitive.ai/prim
14
+ ```
15
+
16
+ Or run directly without installing:
17
+
18
+ ```bash
19
+ npx @primitive.ai/prim
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # Authenticate via browser (WorkOS OAuth)
26
+ prim auth login
27
+
28
+ # List your specs
29
+ prim spec list
30
+
31
+ # Install the pre-commit hook
32
+ prim hooks install
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ ### Auth
38
+
39
+ ```bash
40
+ prim auth login # Authenticate via browser
41
+ prim auth set-token <token> # Save a bearer token (e.g. for CI)
42
+ prim auth clear # Remove saved tokens
43
+ prim auth status # Check authentication status
44
+ ```
45
+
46
+ ### Specs
47
+
48
+ Specs are documents that drive implementation. They can be synced to a task DAG and mapped to file patterns for automatic pre-commit hook integration.
49
+
50
+ ```bash
51
+ prim spec list # List all specs
52
+ prim spec list --task-id <id> # Find spec for a root task
53
+ prim spec get <id> # Show spec details
54
+ prim spec get <id> --text-only # Print raw spec text
55
+ prim spec update <id> --file spec.md # Update spec from file
56
+ prim spec update <id> --name "New" # Rename a spec
57
+ prim spec sync <id> # Trigger spec-to-task sync
58
+ prim spec map <id> -p "src/auth/**" # Map file patterns to a spec
59
+ prim spec unmap <id> # Clear all file patterns
60
+ prim spec unmap <id> -p "src/auth/**" # Remove specific pattern
61
+ prim spec auto-map <id> # Auto-detect file patterns
62
+ ```
63
+
64
+ ### Contexts
65
+
66
+ ```bash
67
+ prim context list # List all contexts
68
+ prim context list --scope task # Filter by scope
69
+ prim context list --task-id <id> # List contexts for a task
70
+ prim context get <id> # Get context details
71
+ prim context create -s task -n "Name" # Create a context
72
+ prim context create -s task -n "Name" --file path/to/file
73
+ prim context update <id> --name "New" # Update a context
74
+ prim context delete <id> # Delete a context
75
+ prim context link <id> --task <tid> # Link context to task
76
+ prim context unlink <id> --task <tid> # Unlink context from task
77
+ ```
78
+
79
+ ### Tasks
80
+
81
+ ```bash
82
+ prim task create -n "Task name" # Create a task
83
+ prim task create -n "Task name" -d "Description" # Create with description
84
+ prim task create -n "Task name" --spec <contextId> # Create and link a spec
85
+ ```
86
+
87
+ ### Hooks
88
+
89
+ ```bash
90
+ prim hooks install # Install pre-commit hook
91
+ prim hooks uninstall # Remove pre-commit hook
92
+ ```
93
+
94
+ The pre-commit hook automatically syncs specs when you commit changes to files matching a spec's file patterns (configured via `prim spec map`).
95
+
96
+ Supports [Husky](https://typicode.github.io/husky/) — `prim hooks install` detects Husky and offers to install into `.husky/pre-commit`.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ pnpm install
102
+ pnpm dev # Build in watch mode
103
+ pnpm build # Production build
104
+ pnpm test # Run tests
105
+ pnpm typecheck # Type-check
106
+ pnpm lint # Lint
107
+ ```
108
+
109
+ ## License
110
+
111
+ [MIT](LICENSE)
@@ -0,0 +1,165 @@
1
+ // src/client.ts
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join, resolve } from "path";
5
+ function loadEnvFile() {
6
+ const envVars = {};
7
+ const candidates = [".env.local", ".env"];
8
+ for (const file of candidates) {
9
+ const filePath = resolve(process.cwd(), file);
10
+ if (existsSync(filePath)) {
11
+ const content = readFileSync(filePath, "utf-8");
12
+ for (const line of content.split("\n")) {
13
+ const trimmed = line.trim();
14
+ if (!trimmed || trimmed.startsWith("#")) continue;
15
+ const eqIdx = trimmed.indexOf("=");
16
+ if (eqIdx === -1) continue;
17
+ const key = trimmed.slice(0, eqIdx).trim();
18
+ const value = trimmed.slice(eqIdx + 1).trim();
19
+ envVars[key] = value;
20
+ }
21
+ }
22
+ }
23
+ return envVars;
24
+ }
25
+ var TOKEN_FILE_PATH = join(homedir(), ".config", "prim", "token");
26
+ var REFRESH_TOKEN_PATH = TOKEN_FILE_PATH.replace("/token", "/refresh_token");
27
+ var TOKEN_EXPIRES_PATH = join(homedir(), ".config", "prim", "token_expires_at");
28
+ var REFRESH_THRESHOLD_MS = 6e4;
29
+ function isTokenExpiringSoon() {
30
+ if (!existsSync(TOKEN_EXPIRES_PATH)) return false;
31
+ const expiresAt = Number(readFileSync(TOKEN_EXPIRES_PATH, "utf-8").trim());
32
+ return !Number.isNaN(expiresAt) && Date.now() >= expiresAt - REFRESH_THRESHOLD_MS;
33
+ }
34
+ function getJwtExpiry(token) {
35
+ const parts = token.split(".");
36
+ if (parts.length !== 3) return void 0;
37
+ try {
38
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
39
+ return payload.exp ? payload.exp * 1e3 : void 0;
40
+ } catch {
41
+ return void 0;
42
+ }
43
+ }
44
+ function saveTokenExpiry(token, expiresIn) {
45
+ const expiresAt = expiresIn ? Date.now() + expiresIn * 1e3 : getJwtExpiry(token);
46
+ if (expiresAt) {
47
+ writeFileSync(TOKEN_EXPIRES_PATH, String(expiresAt), { mode: 384 });
48
+ }
49
+ }
50
+ function getTokenExpiresAt() {
51
+ if (!existsSync(TOKEN_EXPIRES_PATH)) return void 0;
52
+ const val = Number(readFileSync(TOKEN_EXPIRES_PATH, "utf-8").trim());
53
+ return Number.isNaN(val) ? void 0 : val;
54
+ }
55
+ function getAuthToken() {
56
+ if (process.env.PRIM_TOKEN) {
57
+ return process.env.PRIM_TOKEN;
58
+ }
59
+ if (existsSync(TOKEN_FILE_PATH)) {
60
+ const token = readFileSync(TOKEN_FILE_PATH, "utf-8").trim();
61
+ if (token) {
62
+ return token;
63
+ }
64
+ }
65
+ const envVars = loadEnvFile();
66
+ if (envVars.PRIM_TOKEN) {
67
+ return envVars.PRIM_TOKEN;
68
+ }
69
+ return void 0;
70
+ }
71
+ var API_URL = "https://api.getprimitive.ai";
72
+ function getSiteUrl() {
73
+ return API_URL;
74
+ }
75
+ async function refreshToken() {
76
+ if (!existsSync(REFRESH_TOKEN_PATH)) {
77
+ return void 0;
78
+ }
79
+ const refreshTokenValue = readFileSync(REFRESH_TOKEN_PATH, "utf-8").trim();
80
+ if (!refreshTokenValue) {
81
+ return void 0;
82
+ }
83
+ const siteUrl = getSiteUrl();
84
+ const response = await fetch(`${siteUrl}/mcp/broker/refresh`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({ refresh_token: refreshTokenValue })
88
+ });
89
+ if (!response.ok) {
90
+ return void 0;
91
+ }
92
+ const data = await response.json();
93
+ if (!data.access_token) {
94
+ return void 0;
95
+ }
96
+ writeFileSync(TOKEN_FILE_PATH, data.access_token, { mode: 384 });
97
+ if (data.refresh_token) {
98
+ writeFileSync(REFRESH_TOKEN_PATH, data.refresh_token, { mode: 384 });
99
+ }
100
+ saveTokenExpiry(data.access_token, data.expires_in);
101
+ return data.access_token;
102
+ }
103
+ var _cachedToken;
104
+ async function request(method, path, body, options) {
105
+ const siteUrl = getSiteUrl();
106
+ const url = `${siteUrl}${path}`;
107
+ if (!_cachedToken) {
108
+ _cachedToken = getAuthToken();
109
+ }
110
+ if (_cachedToken && isTokenExpiringSoon()) {
111
+ const newToken = await refreshToken();
112
+ if (newToken) {
113
+ _cachedToken = newToken;
114
+ }
115
+ }
116
+ const doFetch = async (token) => {
117
+ const headers = {
118
+ "Content-Type": "application/json"
119
+ };
120
+ if (token) {
121
+ headers.Authorization = `Bearer ${token}`;
122
+ }
123
+ return fetch(url, {
124
+ method,
125
+ headers,
126
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
127
+ signal: options?.signal
128
+ });
129
+ };
130
+ let res = await doFetch(_cachedToken);
131
+ if (res.status === 401) {
132
+ const newToken = await refreshToken();
133
+ if (newToken) {
134
+ _cachedToken = newToken;
135
+ res = await doFetch(newToken);
136
+ }
137
+ }
138
+ if (!res.ok) {
139
+ if (res.status === 401) {
140
+ throw new Error("Authentication expired. Run `prim auth login` to re-authenticate.");
141
+ }
142
+ const errorBody = await res.json().catch(() => null);
143
+ throw new Error(errorBody?.error ?? `HTTP ${res.status}`);
144
+ }
145
+ return res.json();
146
+ }
147
+ function getClient() {
148
+ return {
149
+ get: (path, options) => request("GET", path, void 0, options),
150
+ post: (path, body, options) => request("POST", path, body, options),
151
+ patch: (path, body, options) => request("PATCH", path, body, options),
152
+ delete: (path, options) => request("DELETE", path, void 0, options)
153
+ };
154
+ }
155
+
156
+ export {
157
+ TOKEN_FILE_PATH,
158
+ REFRESH_TOKEN_PATH,
159
+ TOKEN_EXPIRES_PATH,
160
+ saveTokenExpiry,
161
+ getTokenExpiresAt,
162
+ getAuthToken,
163
+ getSiteUrl,
164
+ getClient
165
+ };
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getClient
4
+ } from "../chunk-3APLWTLB.js";
5
+
6
+ // src/hooks/pre-commit.ts
7
+ import { execSync } from "child_process";
8
+ function getStagedFiles() {
9
+ const output = execSync("git diff --cached --name-only", {
10
+ encoding: "utf-8"
11
+ });
12
+ return output.trim().split("\n").filter((f) => f.length > 0);
13
+ }
14
+ function matchPattern(filePath, pattern) {
15
+ const regexStr = pattern.replaceAll("**", "\xA7GLOBSTAR\xA7").replaceAll("*", "[^/]*").replaceAll("\xA7GLOBSTAR\xA7", ".*");
16
+ const regex = new RegExp(`^${regexStr}$`);
17
+ return regex.test(filePath);
18
+ }
19
+ function findAffectedContexts(stagedFiles, specs) {
20
+ const affected = /* @__PURE__ */ new Map();
21
+ for (const file of stagedFiles) {
22
+ for (const spec of specs) {
23
+ for (const pattern of spec.filePatterns) {
24
+ if (matchPattern(file, pattern)) {
25
+ const existing = affected.get(spec._id);
26
+ if (existing) {
27
+ existing.matchedFiles.push(file);
28
+ } else {
29
+ affected.set(spec._id, {
30
+ contextId: spec._id,
31
+ matchedFiles: [file]
32
+ });
33
+ }
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ }
39
+ return affected;
40
+ }
41
+ var HOOK_TIMEOUT_MS = 1e4;
42
+ async function main() {
43
+ const stagedFiles = getStagedFiles();
44
+ if (stagedFiles.length === 0) {
45
+ process.exit(0);
46
+ }
47
+ const client = getClient();
48
+ let mappings = [];
49
+ try {
50
+ mappings = await client.get("/api/cli/specs/mappings", {
51
+ signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
52
+ });
53
+ } catch {
54
+ process.exit(0);
55
+ }
56
+ if (mappings.length === 0) {
57
+ process.exit(0);
58
+ }
59
+ const affectedContexts = findAffectedContexts(stagedFiles, mappings);
60
+ if (affectedContexts.size === 0) {
61
+ process.exit(0);
62
+ }
63
+ console.log(`[prim] ${String(affectedContexts.size)} spec(s) affected by staged changes:`);
64
+ for (const [contextId] of affectedContexts) {
65
+ try {
66
+ const ctx = await client.get(`/api/cli/contexts/${contextId}`, {
67
+ signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
68
+ });
69
+ if (!ctx._id) {
70
+ console.log(` [skip] ${contextId} \u2014 not found`);
71
+ continue;
72
+ }
73
+ if (!ctx.isSpecDocument) {
74
+ console.log(` [skip] ${contextId} \u2014 not a spec document`);
75
+ continue;
76
+ }
77
+ await client.post(`/api/cli/contexts/${contextId}/sync`, void 0, {
78
+ signal: AbortSignal.timeout(HOOK_TIMEOUT_MS)
79
+ });
80
+ console.log(` [synced] ${contextId} \u2014 ${ctx.name ?? "(unnamed)"}`);
81
+ } catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ console.error(` [error] ${contextId} \u2014 ${message}`);
84
+ }
85
+ }
86
+ process.exit(0);
87
+ }
88
+ main().catch((error) => {
89
+ console.error("[prim] Pre-commit hook error:", error);
90
+ process.exit(0);
91
+ });
package/dist/index.js ADDED
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ REFRESH_TOKEN_PATH,
4
+ TOKEN_EXPIRES_PATH,
5
+ TOKEN_FILE_PATH,
6
+ getAuthToken,
7
+ getClient,
8
+ getSiteUrl,
9
+ getTokenExpiresAt,
10
+ saveTokenExpiry
11
+ } from "./chunk-3APLWTLB.js";
12
+
13
+ // src/index.ts
14
+ import { Command } from "commander";
15
+
16
+ // src/commands/auth.ts
17
+ import { exec } from "child_process";
18
+ import { createHash, randomBytes } from "crypto";
19
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
20
+ import { createServer } from "http";
21
+ import { platform } from "os";
22
+ import { dirname } from "path";
23
+ var FILE_MODE = 384;
24
+ var LOCALHOST = "127.0.0.1";
25
+ var CALLBACK_PORT = 19876;
26
+ var CALLBACK_TIMEOUT_MS = 12e4;
27
+ var BASE64_PLUS_RE = /\+/g;
28
+ var BASE64_SLASH_RE = /\//g;
29
+ var BASE64_PAD_RE = /=+$/;
30
+ function base64url(buffer) {
31
+ return buffer.toString("base64").replace(BASE64_PLUS_RE, "-").replace(BASE64_SLASH_RE, "_").replace(BASE64_PAD_RE, "");
32
+ }
33
+ function generatePkce() {
34
+ const verifier = base64url(randomBytes(32));
35
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
36
+ return { verifier, challenge };
37
+ }
38
+ function openBrowser(url) {
39
+ const os = platform();
40
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
41
+ exec(`${cmd} "${url}"`);
42
+ }
43
+ function saveToken(token) {
44
+ const dir = dirname(TOKEN_FILE_PATH);
45
+ if (!existsSync(dir)) {
46
+ mkdirSync(dir, { recursive: true });
47
+ }
48
+ writeFileSync(TOKEN_FILE_PATH, token, { mode: FILE_MODE });
49
+ }
50
+ function registerAuthCommands(program2) {
51
+ const auth = program2.command("auth").description("Manage CLI authentication");
52
+ auth.command("login").description("Authenticate via browser (WorkOS OAuth)").action(async () => {
53
+ const siteUrl = getSiteUrl();
54
+ let config;
55
+ try {
56
+ const res = await fetch(`${siteUrl}/mcp/config`);
57
+ config = await res.json();
58
+ } catch {
59
+ console.error("Failed to fetch MCP config. Is the Convex backend running?");
60
+ process.exit(1);
61
+ }
62
+ if (!config.authorization_server || !config.client_id) {
63
+ console.error("MCP broker is not configured on the server.");
64
+ process.exit(1);
65
+ }
66
+ const { verifier, challenge } = generatePkce();
67
+ const state = base64url(randomBytes(16));
68
+ const server = createServer((req, res) => {
69
+ const url = new URL(req.url ?? "/", `http://${LOCALHOST}`);
70
+ if (url.pathname !== "/callback") {
71
+ res.writeHead(404);
72
+ res.end("Not found");
73
+ return;
74
+ }
75
+ const code = url.searchParams.get("code");
76
+ const returnedState = url.searchParams.get("state");
77
+ if (returnedState !== state) {
78
+ res.writeHead(400, { "Content-Type": "text/html" });
79
+ res.end("<h1>State mismatch. Authentication failed.</h1>");
80
+ server.close();
81
+ process.exit(1);
82
+ }
83
+ if (!code) {
84
+ const error = url.searchParams.get("error_description") ?? "No authorization code received";
85
+ res.writeHead(400, { "Content-Type": "text/html" });
86
+ res.end(`<h1>Authentication failed: ${error}</h1>`);
87
+ server.close();
88
+ process.exit(1);
89
+ }
90
+ res.writeHead(200, { "Content-Type": "text/html" });
91
+ res.end("<h1>Authentication successful!</h1><p>You can close this tab.</p>");
92
+ exchangeCode(siteUrl, code, verifier, `http://${LOCALHOST}:${port}/callback`).then((token) => {
93
+ saveToken(token);
94
+ console.log(`Authenticated! Token saved to ${TOKEN_FILE_PATH}`);
95
+ server.close();
96
+ process.exit(0);
97
+ }).catch((err) => {
98
+ console.error("Token exchange failed:", err);
99
+ server.close();
100
+ process.exit(1);
101
+ });
102
+ });
103
+ const port = await new Promise((resolve2) => {
104
+ server.listen(CALLBACK_PORT, LOCALHOST, () => {
105
+ const addr = server.address();
106
+ resolve2(typeof addr === "object" && addr ? addr.port : 0);
107
+ });
108
+ });
109
+ const redirectUri = `http://${LOCALHOST}:${port}/callback`;
110
+ const authorizeUrl = config.authorization_endpoint ?? "https://api.workos.com/user_management/authorize";
111
+ const authUrl = new URL(authorizeUrl);
112
+ authUrl.searchParams.set("client_id", config.client_id);
113
+ authUrl.searchParams.set("redirect_uri", redirectUri);
114
+ authUrl.searchParams.set("response_type", "code");
115
+ authUrl.searchParams.set("provider", "authkit");
116
+ authUrl.searchParams.set("scope", config.default_scopes.join(" "));
117
+ authUrl.searchParams.set("state", state);
118
+ authUrl.searchParams.set("code_challenge", challenge);
119
+ authUrl.searchParams.set("code_challenge_method", "S256");
120
+ console.log("Opening browser for authentication...");
121
+ openBrowser(authUrl.toString());
122
+ console.log(`If the browser doesn't open, visit:
123
+ ${authUrl.toString()}
124
+ `);
125
+ console.log("Waiting for callback...");
126
+ setTimeout(() => {
127
+ console.error("Authentication timed out.");
128
+ server.close();
129
+ process.exit(1);
130
+ }, CALLBACK_TIMEOUT_MS);
131
+ });
132
+ auth.command("set-token <token>").description("Save a bearer token for authenticated CLI calls").action((token) => {
133
+ saveToken(token);
134
+ console.log(`Token saved to ${TOKEN_FILE_PATH}`);
135
+ });
136
+ auth.command("clear").description("Remove the saved authentication token").action(async () => {
137
+ if (existsSync(REFRESH_TOKEN_PATH)) {
138
+ const refreshTokenValue = readFileSync(REFRESH_TOKEN_PATH, "utf-8").trim();
139
+ if (refreshTokenValue) {
140
+ try {
141
+ const siteUrl = getSiteUrl();
142
+ const res = await fetch(`${siteUrl}/mcp/broker/revoke`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ refresh_token: refreshTokenValue })
146
+ });
147
+ if (res.ok) {
148
+ console.log("Server token revoked.");
149
+ } else {
150
+ console.warn(
151
+ "Server revocation failed (status %d) \u2014 clearing local files anyway.",
152
+ res.status
153
+ );
154
+ }
155
+ } catch {
156
+ console.warn("Could not reach server for revocation \u2014 clearing local files anyway.");
157
+ }
158
+ }
159
+ }
160
+ let removed = false;
161
+ for (const filePath of [TOKEN_FILE_PATH, REFRESH_TOKEN_PATH, TOKEN_EXPIRES_PATH]) {
162
+ if (existsSync(filePath)) {
163
+ rmSync(filePath);
164
+ removed = true;
165
+ }
166
+ }
167
+ if (removed) {
168
+ console.log("Local tokens removed.");
169
+ } else {
170
+ console.log("No saved tokens found.");
171
+ }
172
+ });
173
+ auth.command("status").description("Check authentication status and token expiry").action(() => {
174
+ const token = getAuthToken();
175
+ if (!token) {
176
+ console.log("Not authenticated. Run `prim auth login` to authenticate.");
177
+ process.exit(1);
178
+ }
179
+ console.log("Authenticated.");
180
+ console.log(`Token file: ${TOKEN_FILE_PATH}`);
181
+ const expiresAt = getTokenExpiresAt();
182
+ if (expiresAt) {
183
+ const remaining = expiresAt - Date.now();
184
+ if (remaining <= 0) {
185
+ console.log("Access token: expired");
186
+ } else {
187
+ const minutes = Math.floor(remaining / 6e4);
188
+ const seconds = Math.floor(remaining % 6e4 / 1e3);
189
+ console.log(`Access token expires in: ${minutes}m ${seconds}s`);
190
+ }
191
+ } else {
192
+ console.log("Access token expiry: unknown (no metadata)");
193
+ }
194
+ const hasRefresh = existsSync(REFRESH_TOKEN_PATH);
195
+ console.log(`Refresh token: ${hasRefresh ? "present" : "missing"}`);
196
+ if (!hasRefresh) {
197
+ console.log(
198
+ "Warning: No refresh token. Re-run `prim auth login` when access token expires."
199
+ );
200
+ }
201
+ });
202
+ }
203
+ async function exchangeCode(siteUrl, code, codeVerifier, redirectUri) {
204
+ const response = await fetch(`${siteUrl}/mcp/broker/token`, {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({
208
+ code,
209
+ code_verifier: codeVerifier,
210
+ redirect_uri: redirectUri
211
+ })
212
+ });
213
+ if (!response.ok) {
214
+ const body = await response.text();
215
+ throw new Error(`Token exchange failed (${response.status}): ${body}`);
216
+ }
217
+ const data = await response.json();
218
+ if (!data.access_token) {
219
+ throw new Error("No access token in response");
220
+ }
221
+ if (data.refresh_token) {
222
+ const refreshPath = TOKEN_FILE_PATH.replace("/token", "/refresh_token");
223
+ const dir = dirname(refreshPath);
224
+ if (!existsSync(dir)) {
225
+ mkdirSync(dir, { recursive: true });
226
+ }
227
+ writeFileSync(refreshPath, data.refresh_token, { mode: FILE_MODE });
228
+ }
229
+ saveTokenExpiry(data.access_token, data.expires_in);
230
+ return data.access_token;
231
+ }
232
+
233
+ // src/commands/context.ts
234
+ import { readFileSync as readFileSync2 } from "fs";
235
+ function registerContextCommands(program2) {
236
+ const context = program2.command("context").description("Manage contexts");
237
+ context.command("list").description("List contexts").option("-s, --scope <scope>", "Filter by scope: task, global, external").option("-t, --task-id <taskId>", "List contexts linked to a specific task").action(async (opts) => {
238
+ const client = getClient();
239
+ const params = new URLSearchParams();
240
+ if (opts.taskId) {
241
+ params.set("taskId", opts.taskId);
242
+ }
243
+ if (opts.scope) {
244
+ params.set("scope", opts.scope);
245
+ }
246
+ const contexts = await client.get(`/api/cli/contexts?${params.toString()}`);
247
+ printContextList(contexts);
248
+ });
249
+ context.command("get <contextId>").description("Get a context by ID").action(async (contextId) => {
250
+ const client = getClient();
251
+ const ctx = await client.get(`/api/cli/contexts/${contextId}`);
252
+ console.log(JSON.stringify(ctx, null, 2));
253
+ });
254
+ context.command("create").description("Create a new context").requiredOption("-s, --scope <scope>", "Scope: task, 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("--task-id <taskId>", "Link to task(s), comma-separated").option("--spec", "Mark as a spec document").action(
255
+ async (opts) => {
256
+ const client = getClient();
257
+ let text = opts.text;
258
+ if (opts.file) {
259
+ text = readFileSync2(opts.file, "utf-8");
260
+ }
261
+ const taskIds = opts.taskId ? opts.taskId.split(",").map((id) => id.trim()) : void 0;
262
+ const result = await client.post("/api/cli/contexts", {
263
+ scope: opts.scope,
264
+ name: opts.name,
265
+ text,
266
+ taskIds,
267
+ isSpecDocument: opts.spec ?? false
268
+ });
269
+ console.log(`Created context: ${result._id}`);
270
+ }
271
+ );
272
+ 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) => {
273
+ const client = getClient();
274
+ let text = opts.text;
275
+ if (opts.file) {
276
+ text = readFileSync2(opts.file, "utf-8");
277
+ }
278
+ await client.patch(`/api/cli/contexts/${contextId}`, {
279
+ name: opts.name,
280
+ text
281
+ });
282
+ console.log(`Updated context: ${contextId}`);
283
+ });
284
+ context.command("delete <contextId>").description("Delete a context").action(async (contextId) => {
285
+ const client = getClient();
286
+ await client.delete(`/api/cli/contexts/${contextId}`);
287
+ console.log(`Deleted context: ${contextId}`);
288
+ });
289
+ context.command("link <contextId>").description("Link a context to a task").requiredOption("--task <taskId>", "Task ID to link to").action(async (contextId, opts) => {
290
+ const client = getClient();
291
+ await client.post(`/api/cli/contexts/${contextId}/link`, {
292
+ taskId: opts.task
293
+ });
294
+ console.log(`Linked context ${contextId} to task ${opts.task}`);
295
+ });
296
+ context.command("unlink <contextId>").description("Unlink a context from a task").requiredOption("--task <taskId>", "Task ID to unlink from").action(async (contextId, opts) => {
297
+ const client = getClient();
298
+ await client.post(`/api/cli/contexts/${contextId}/unlink`, {
299
+ taskId: opts.task
300
+ });
301
+ console.log(`Unlinked context ${contextId} from task ${opts.task}`);
302
+ });
303
+ }
304
+ function printContextList(contexts) {
305
+ if (contexts.length === 0) {
306
+ console.log("No contexts found.");
307
+ return;
308
+ }
309
+ for (const ctx of contexts) {
310
+ const scope = ctx.scope ?? "task";
311
+ const spec = ctx.isSpecDocument ? " [SPEC]" : "";
312
+ const name = ctx.name ?? ctx.title ?? "(unnamed)";
313
+ console.log(`${ctx._id} ${scope.padEnd(8)} ${name}${spec}`);
314
+ }
315
+ console.log(`
316
+ ${contexts.length} context(s)`);
317
+ }
318
+
319
+ // src/commands/hooks.ts
320
+ import { execSync } from "child_process";
321
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
322
+ import { resolve } from "path";
323
+ var HOOK_SCRIPT = `#!/bin/sh
324
+ # prim pre-commit hook \u2014 auto-syncs affected specs on commit
325
+ # Installed by: prim hooks install
326
+
327
+ # Find the nearest node_modules/.bin with prim, or use npx
328
+ if command -v prim-pre-commit >/dev/null 2>&1; then
329
+ prim-pre-commit
330
+ elif [ -f "./node_modules/.bin/prim-pre-commit" ]; then
331
+ ./node_modules/.bin/prim-pre-commit
332
+ else
333
+ npx --yes @primitive.ai/prim pre-commit-hook 2>/dev/null || true
334
+ fi
335
+ `;
336
+ var PRIM_BLOCK_START = "# >>> prim pre-commit hook >>>";
337
+ var PRIM_BLOCK_END = "# <<< prim pre-commit hook <<<";
338
+ var PRIM_HUSKY_BLOCK = `${PRIM_BLOCK_START}
339
+ if command -v prim-pre-commit >/dev/null 2>&1; then
340
+ prim-pre-commit
341
+ elif [ -f "./node_modules/.bin/prim-pre-commit" ]; then
342
+ ./node_modules/.bin/prim-pre-commit
343
+ else
344
+ npx --yes @primitive.ai/prim pre-commit-hook 2>/dev/null || true
345
+ fi
346
+ ${PRIM_BLOCK_END}`;
347
+ function getGitRoot() {
348
+ return execSync("git rev-parse --show-toplevel", {
349
+ encoding: "utf-8"
350
+ }).trim();
351
+ }
352
+ function detectHusky(gitRoot) {
353
+ const huskyDir = resolve(gitRoot, ".husky");
354
+ if (!existsSync2(huskyDir)) return false;
355
+ if (existsSync2(resolve(huskyDir, "_"))) return true;
356
+ if (existsSync2(resolve(huskyDir, "pre-commit"))) return true;
357
+ const pkgPath = resolve(gitRoot, "package.json");
358
+ if (existsSync2(pkgPath)) {
359
+ try {
360
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
361
+ const scripts = pkg.scripts ?? {};
362
+ if (/husky/i.test(scripts.prepare ?? "") || /husky/i.test(scripts.postinstall ?? "")) {
363
+ return true;
364
+ }
365
+ } catch {
366
+ }
367
+ }
368
+ return false;
369
+ }
370
+ function containsPrimHook(content) {
371
+ return content.includes("prim-pre-commit");
372
+ }
373
+ async function askConfirmation(question) {
374
+ if (!process.stdin.isTTY) return false;
375
+ const { createInterface } = await import("readline/promises");
376
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
377
+ try {
378
+ const answer = await rl.question(`${question} [y/N] `);
379
+ const normalized = answer.trim().toLowerCase();
380
+ return normalized === "y" || normalized === "yes";
381
+ } finally {
382
+ rl.close();
383
+ }
384
+ }
385
+ function installToHusky(gitRoot) {
386
+ const hookPath = resolve(gitRoot, ".husky", "pre-commit");
387
+ if (existsSync2(hookPath)) {
388
+ const existing = readFileSync3(hookPath, "utf-8");
389
+ if (containsPrimHook(existing)) {
390
+ console.log("Prim pre-commit hook is already installed in .husky/pre-commit.");
391
+ return;
392
+ }
393
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
394
+ writeFileSync2(hookPath, `${existing}${separator}${PRIM_HUSKY_BLOCK}
395
+ `, {
396
+ mode: 493
397
+ });
398
+ console.log("Appended prim hook block to .husky/pre-commit.");
399
+ } else {
400
+ writeFileSync2(hookPath, `#!/bin/sh
401
+
402
+ ${PRIM_HUSKY_BLOCK}
403
+ `, {
404
+ mode: 493
405
+ });
406
+ console.log("Created .husky/pre-commit with prim hook block.");
407
+ }
408
+ }
409
+ function installToDotGit(gitRoot) {
410
+ const hooksDir = resolve(gitRoot, ".git", "hooks");
411
+ const hookPath = resolve(hooksDir, "pre-commit");
412
+ if (!existsSync2(hooksDir)) {
413
+ mkdirSync2(hooksDir, { recursive: true });
414
+ }
415
+ if (existsSync2(hookPath)) {
416
+ const existing = readFileSync3(hookPath, "utf-8");
417
+ if (containsPrimHook(existing)) {
418
+ console.log("Prim pre-commit hook is already installed at .git/hooks/pre-commit.");
419
+ return;
420
+ }
421
+ console.log(`A pre-commit hook already exists at ${hookPath}.`);
422
+ console.log("To replace it, run: prim hooks uninstall && prim hooks install");
423
+ return;
424
+ }
425
+ writeFileSync2(hookPath, HOOK_SCRIPT, { mode: 493 });
426
+ console.log(`Installed pre-commit hook at ${hookPath}`);
427
+ }
428
+ function registerHooksCommands(program2) {
429
+ const hooks = program2.command("hooks").description("Manage git hooks");
430
+ hooks.command("install").description("Install the prim pre-commit hook").action(async () => {
431
+ const gitRoot = getGitRoot();
432
+ if (detectHusky(gitRoot)) {
433
+ const confirmed = await askConfirmation(
434
+ "Husky detected. Install prim hook into .husky/pre-commit instead of .git/hooks/pre-commit?"
435
+ );
436
+ if (confirmed) {
437
+ installToHusky(gitRoot);
438
+ return;
439
+ }
440
+ console.log("Falling back to .git/hooks/pre-commit install.");
441
+ }
442
+ installToDotGit(gitRoot);
443
+ });
444
+ hooks.command("uninstall").description("Remove the prim pre-commit hook").action(() => {
445
+ const gitRoot = getGitRoot();
446
+ const hookPath = resolve(gitRoot, ".git", "hooks", "pre-commit");
447
+ if (!existsSync2(hookPath)) {
448
+ console.log("No pre-commit hook found.");
449
+ return;
450
+ }
451
+ unlinkSync(hookPath);
452
+ console.log(`Removed pre-commit hook at ${hookPath}`);
453
+ });
454
+ }
455
+
456
+ // src/commands/spec.ts
457
+ import { readFileSync as readFileSync4 } from "fs";
458
+ function registerSpecCommands(program2) {
459
+ const spec = program2.command("spec").description("Manage spec documents");
460
+ spec.command("list").description("List spec documents").option("-t, --task-id <taskId>", "List spec for a specific root task").action(async (opts) => {
461
+ const client = getClient();
462
+ if (opts.taskId) {
463
+ const specs = await client.get(`/api/cli/specs?rootTaskId=${opts.taskId}`);
464
+ if (specs.length === 0) {
465
+ console.log("No spec document found for this task.");
466
+ return;
467
+ }
468
+ printSpec(specs[0]);
469
+ return;
470
+ }
471
+ const contexts = await client.get("/api/cli/specs");
472
+ if (contexts.length === 0) {
473
+ console.log("No spec documents found.");
474
+ return;
475
+ }
476
+ for (const ctx of contexts) {
477
+ const scope = ctx.scope ?? "task";
478
+ const review = ctx.specReviewStatus ?? "\u2014";
479
+ const name = ctx.name ?? "(unnamed)";
480
+ console.log(`${ctx._id} ${scope.padEnd(8)} ${String(review).padEnd(10)} ${name}`);
481
+ }
482
+ console.log(`
483
+ ${contexts.length} spec(s)`);
484
+ });
485
+ 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) => {
486
+ const client = getClient();
487
+ const ctx = await client.get(`/api/cli/contexts/${contextId}`);
488
+ if (opts.textOnly) {
489
+ console.log(ctx.text ?? "");
490
+ return;
491
+ }
492
+ printSpec(ctx);
493
+ });
494
+ 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) => {
495
+ const client = getClient();
496
+ let text = opts.text;
497
+ if (opts.file) {
498
+ text = readFileSync4(opts.file, "utf-8");
499
+ }
500
+ if (!(text || opts.name)) {
501
+ console.error("Provide --text, --file, or --name to update.");
502
+ process.exit(1);
503
+ }
504
+ await client.patch(`/api/cli/contexts/${contextId}`, {
505
+ name: opts.name,
506
+ text,
507
+ skipTiptapLifecycle: !!text
508
+ });
509
+ if (text) {
510
+ await client.post(`/api/cli/contexts/${contextId}/inject`);
511
+ }
512
+ console.log(`Updated spec: ${contextId}`);
513
+ });
514
+ spec.command("sync <contextId>").description("Trigger spec \u2194 task DAG synchronization").action(async (contextId) => {
515
+ const client = getClient();
516
+ const ctx = await client.get(`/api/cli/contexts/${contextId}`);
517
+ if (!ctx.isSpecDocument) {
518
+ console.error("Context is not a spec document. Use `prim context` instead.");
519
+ process.exit(1);
520
+ }
521
+ await client.post(`/api/cli/contexts/${contextId}/sync`);
522
+ console.log(`Triggered sync for spec: ${contextId}`);
523
+ if (ctx.specRootTaskId) {
524
+ console.log(`Root task: ${ctx.specRootTaskId}`);
525
+ }
526
+ });
527
+ spec.command("map <contextId>").description("Map file patterns to a spec (used by pre-commit hook to detect affected specs)").requiredOption(
528
+ "-p, --pattern <patterns...>",
529
+ 'Glob pattern(s) to associate, e.g. "src/auth/**"'
530
+ ).action(async (contextId, opts) => {
531
+ const client = getClient();
532
+ const result = await client.post(`/api/cli/contexts/${contextId}/map`, {
533
+ patterns: opts.pattern
534
+ });
535
+ console.log(`Mapped patterns to spec ${contextId}:`);
536
+ for (const p of result.filePatterns) {
537
+ console.log(` ${p}`);
538
+ }
539
+ });
540
+ 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) => {
541
+ const client = getClient();
542
+ const result = await client.post(`/api/cli/contexts/${contextId}/unmap`, {
543
+ patterns: opts.pattern
544
+ });
545
+ if (result.filePatterns.length === 0) {
546
+ console.log(`Cleared all file patterns from spec ${contextId}`);
547
+ } else {
548
+ console.log(`Updated patterns for spec ${contextId}:`);
549
+ for (const p of result.filePatterns) {
550
+ console.log(` ${p}`);
551
+ }
552
+ }
553
+ });
554
+ spec.command("auto-map <contextId>").description("Trigger auto-mapping of file patterns for a spec").action(async (contextId) => {
555
+ const client = getClient();
556
+ await client.post(`/api/cli/contexts/${contextId}/auto-map`);
557
+ console.log(`Auto-mapping triggered for spec: ${contextId}`);
558
+ });
559
+ }
560
+ function printSpec(ctx) {
561
+ const name = ctx.name ?? ctx.title ?? "(unnamed)";
562
+ const review = ctx.specReviewStatus ?? "\u2014";
563
+ const patterns = ctx.filePatterns;
564
+ console.log(`ID: ${ctx._id}`);
565
+ console.log(`Name: ${name}`);
566
+ console.log(`Scope: ${ctx.scope ?? "task"}`);
567
+ console.log(`Review Status: ${review}`);
568
+ console.log(`Root Task: ${ctx.specRootTaskId ?? "\u2014"}`);
569
+ console.log(`Sync Version: ${ctx.syncVersion ?? 0}`);
570
+ console.log(`Index Status: ${ctx.indexStatus ?? "\u2014"}`);
571
+ console.log(`File Patterns: ${patterns?.length ? patterns.join(", ") : "\u2014"}`);
572
+ if (ctx.text) {
573
+ const text = ctx.text;
574
+ const preview = text.length > 500 ? `${text.slice(0, 500)}\u2026` : text;
575
+ console.log(`
576
+ --- Text ---
577
+ ${preview}`);
578
+ }
579
+ }
580
+
581
+ // src/commands/task.ts
582
+ function registerTaskCommands(program2) {
583
+ const task = program2.command("task").description("Manage tasks");
584
+ task.command("create").description("Create a new task").requiredOption("-n, --name <name>", "Task name").option("-d, --description <description>", "Task description").option("--spec <contextId>", "Link an existing spec as this task's spec").action(async (opts) => {
585
+ const client = getClient();
586
+ const result = await client.post("/api/cli/tasks", {
587
+ name: opts.name,
588
+ description: opts.description,
589
+ specContextId: opts.spec
590
+ });
591
+ console.log(`Created task: ${result._id}`);
592
+ if (opts.spec) {
593
+ console.log(`Linked spec: ${opts.spec}`);
594
+ }
595
+ });
596
+ }
597
+
598
+ // src/index.ts
599
+ var program = new Command();
600
+ program.name("prim").description("CLI for managing Primitive specs and contexts").version("0.1.0-alpha.1");
601
+ registerAuthCommands(program);
602
+ registerContextCommands(program);
603
+ registerSpecCommands(program);
604
+ registerTaskCommands(program);
605
+ registerHooksCommands(program);
606
+ process.on("unhandledRejection", (err) => {
607
+ const msg = err instanceof Error ? err.message : String(err);
608
+ console.error(msg);
609
+ process.exit(1);
610
+ });
611
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@primitive.ai/prim",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "CLI for managing Primitive specs, contexts, and git hooks",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/campus-ai/prim.git"
10
+ },
11
+ "homepage": "https://github.com/campus-ai/prim#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/campus-ai/prim/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "keywords": [
19
+ "primitive",
20
+ "prim",
21
+ "cli",
22
+ "specs",
23
+ "contexts",
24
+ "pre-commit"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20.0.0"
28
+ },
29
+ "bin": {
30
+ "prim": "./dist/index.js",
31
+ "prim-pre-commit": "./dist/hooks/pre-commit.js"
32
+ },
33
+ "main": "./dist/index.js",
34
+ "files": [
35
+ "dist",
36
+ "LICENSE",
37
+ "README.md"
38
+ ],
39
+ "dependencies": {
40
+ "commander": "^12.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "@biomejs/biome": "^1.9.0",
44
+ "@types/node": "^25.5.0",
45
+ "@vitest/coverage-v8": "^3.1.0",
46
+ "tsup": "^8.0.0",
47
+ "typescript": "^5.5.0",
48
+ "vitest": "^3.1.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsup src/index.ts src/hooks/pre-commit.ts --format esm --clean",
52
+ "postbuild": "chmod +x ./dist/index.js ./dist/hooks/pre-commit.js",
53
+ "dev": "tsup src/index.ts src/hooks/pre-commit.ts --format esm --watch --clean",
54
+ "clean": "rm -rf dist coverage",
55
+ "lint": "biome check src/",
56
+ "format": "biome check --fix src/",
57
+ "format:check": "biome check src/",
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "test:coverage": "vitest run --coverage"
62
+ }
63
+ }