@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 +21 -0
- package/README.md +111 -0
- package/dist/chunk-3APLWTLB.js +165 -0
- package/dist/hooks/pre-commit.js +91 -0
- package/dist/index.js +611 -0
- package/package.json +63 -0
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
|
+
}
|