@builtbyberry/prymer-cli 0.2.0
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/README.md +74 -0
- package/bin/prymer.js +303 -0
- package/lib/args.js +34 -0
- package/lib/checkpoint.js +0 -0
- package/lib/hooks.js +73 -0
- package/lib/http.js +96 -0
- package/lib/store.js +124 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# `prymer` — Prymer executing-hook helper
|
|
2
|
+
|
|
3
|
+
A small, dependency-free CLI for the **executing-hook power mode**. The web
|
|
4
|
+
connectors install _instructional_ hooks that nudge the model to call the Prymer
|
|
5
|
+
MCP tools. This CLI instead runs the ritual itself, so the load step happens
|
|
6
|
+
deterministically — even when the model forgets.
|
|
7
|
+
|
|
8
|
+
It is built on Node's standard library only (no npm dependencies).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g @builtbyberry/prymer-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Authenticate
|
|
17
|
+
|
|
18
|
+
1. In Prymer, open **Settings → Connected tools** and create a **hook credential**
|
|
19
|
+
for the channel you want this machine to use. The token (`swhk_…`) is shown
|
|
20
|
+
once.
|
|
21
|
+
2. Store it:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
prymer login --url https://your-prymer-host --token swhk_xxx
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The token reaches exactly one channel and only the load + checkpoint verbs, so a
|
|
28
|
+
leak is contained to that channel — never your whole account. On macOS it is kept
|
|
29
|
+
in the login keychain; elsewhere in a `~/.prymer/*.json` file with `0600`
|
|
30
|
+
permissions. Use `--profile <name>` to keep tokens for several channels side by
|
|
31
|
+
side.
|
|
32
|
+
|
|
33
|
+
## Use it in hooks
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
prymer install-hooks --client codex # writes .codex/hooks.json
|
|
37
|
+
prymer install-hooks --client claude # writes .claude/settings.json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Both write a `SessionStart` hook that runs `prymer inject`. Codex and Claude Code
|
|
41
|
+
both feed `SessionStart` stdout to the model as additional context, so the
|
|
42
|
+
channel's context document loads automatically at the start of every session.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | What it does |
|
|
47
|
+
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| `prymer login` | Store a hook credential (verifies it before saving). |
|
|
49
|
+
| `prymer logout` | Remove a stored credential. |
|
|
50
|
+
| `prymer whoami` | Show the stored host + a masked token. |
|
|
51
|
+
| `prymer inject` | Print the channel's context document (for `SessionStart`). Fails quiet so it never breaks a session; use `--strict` to surface errors. |
|
|
52
|
+
| `prymer checkpoint --title <t> --summary <s>` | Append a checkpoint. Idempotent per session + state; only shares when the channel's capture mode is **Auto**. |
|
|
53
|
+
| `prymer install-hooks --client <codex\|claude>` | Write the executing `SessionStart` hook for a client. |
|
|
54
|
+
|
|
55
|
+
### Checkpointing
|
|
56
|
+
|
|
57
|
+
`inject` is fully deterministic — no model needed. A checkpoint, by contrast,
|
|
58
|
+
needs a summary worth keeping, so it's a step the agent (or you) takes when there
|
|
59
|
+
is something to record rather than a blind Stop hook:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
prymer checkpoint --session "$SESSION_ID" \
|
|
63
|
+
--title "Finished the billing fix" \
|
|
64
|
+
--summary "Switched to Stripe; tests green."
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The idempotency key is derived from the session id + title + summary, so re-running
|
|
68
|
+
the same checkpoint for the same session is a no-op rather than a duplicate.
|
|
69
|
+
|
|
70
|
+
## Test
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cd cli/prymer && npm test
|
|
74
|
+
```
|
package/bin/prymer.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { parseArgs } from '../lib/args.js';
|
|
7
|
+
import { deriveIdempotencyKey } from '../lib/checkpoint.js';
|
|
8
|
+
import { hookFiles, SUPPORTED_CLIENTS } from '../lib/hooks.js';
|
|
9
|
+
import { HttpError, request } from '../lib/http.js';
|
|
10
|
+
import {
|
|
11
|
+
deleteCredentials,
|
|
12
|
+
loadCredentials,
|
|
13
|
+
saveCredentials,
|
|
14
|
+
} from '../lib/store.js';
|
|
15
|
+
|
|
16
|
+
const USAGE = `prymer — Prymer executing-hook helper
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
prymer login [--url <base-url>] [--token <hook-token>] [--profile <name>]
|
|
20
|
+
prymer logout [--profile <name>]
|
|
21
|
+
prymer whoami [--profile <name>]
|
|
22
|
+
prymer inject [--profile <name>] [--strict]
|
|
23
|
+
prymer checkpoint --title <t> --summary <s> [--content <c>] [--session <id>] [--key <k>] [--profile <name>]
|
|
24
|
+
prymer install-hooks --client <codex|claude> [--dir <path>]
|
|
25
|
+
|
|
26
|
+
Create a hook token under Settings → Connected tools in Prymer, then run
|
|
27
|
+
\`prymer login\`. Each token reaches one channel and only the load + checkpoint
|
|
28
|
+
verbs. Profiles let you keep tokens for several channels side by side (default:
|
|
29
|
+
"default").`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} baseUrl
|
|
33
|
+
* @param {string} path
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function endpoint(baseUrl, path) {
|
|
37
|
+
return `${baseUrl.replace(/\/+$/, '')}${path}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} question
|
|
42
|
+
* @returns {Promise<string>}
|
|
43
|
+
*/
|
|
44
|
+
async function prompt(question) {
|
|
45
|
+
const rl = createInterface({
|
|
46
|
+
input: process.stdin,
|
|
47
|
+
output: process.stderr,
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
return (await rl.question(question)).trim();
|
|
51
|
+
} finally {
|
|
52
|
+
rl.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {Record<string, string|boolean>} flags
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function profileOf(flags) {
|
|
61
|
+
return typeof flags.profile === 'string' ? flags.profile : 'default';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} value
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function mask(value) {
|
|
69
|
+
return value.length <= 8
|
|
70
|
+
? '••••'
|
|
71
|
+
: `${value.slice(0, 6)}…${value.slice(-2)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function login(flags) {
|
|
75
|
+
const profile = profileOf(flags);
|
|
76
|
+
|
|
77
|
+
const url =
|
|
78
|
+
typeof flags.url === 'string'
|
|
79
|
+
? flags.url
|
|
80
|
+
: await prompt('Prymer URL (e.g. https://prymer.example.com): ');
|
|
81
|
+
const token =
|
|
82
|
+
typeof flags.token === 'string'
|
|
83
|
+
? flags.token
|
|
84
|
+
: await prompt('Hook token (swhk_…): ');
|
|
85
|
+
|
|
86
|
+
if (!url || !token) {
|
|
87
|
+
process.stderr.write('A URL and a token are both required.\n');
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Verify the credential before storing it, so a typo fails here, not later
|
|
94
|
+
// inside a hook where the error is invisible.
|
|
95
|
+
try {
|
|
96
|
+
await request('GET', endpoint(url, '/api/hooks/inject'), { token });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof HttpError && error.status === 401) {
|
|
99
|
+
process.stderr.write(
|
|
100
|
+
'That token was rejected. Check it and try again.\n',
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
process.stderr.write(`Could not reach Prymer: ${error.message}\n`);
|
|
104
|
+
}
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
saveCredentials(profile, { base_url: url, token });
|
|
111
|
+
process.stderr.write(`Saved credentials for profile "${profile}".\n`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function logout(flags) {
|
|
115
|
+
const profile = profileOf(flags);
|
|
116
|
+
deleteCredentials(profile);
|
|
117
|
+
process.stderr.write(`Removed credentials for profile "${profile}".\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function whoami(flags) {
|
|
121
|
+
const profile = profileOf(flags);
|
|
122
|
+
const creds = loadCredentials(profile);
|
|
123
|
+
|
|
124
|
+
if (!creds) {
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
`No credentials for profile "${profile}". Run \`prymer login\`.\n`,
|
|
127
|
+
);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
process.stdout.write(`${creds.base_url} (token ${mask(creds.token)})\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function inject(flags) {
|
|
137
|
+
const profile = profileOf(flags);
|
|
138
|
+
const strict = flags.strict === true;
|
|
139
|
+
const creds = loadCredentials(profile);
|
|
140
|
+
|
|
141
|
+
// A SessionStart hook must never break the session: with no credential (or
|
|
142
|
+
// any failure), inject stays quiet and exits 0 unless --strict is set.
|
|
143
|
+
if (!creds) {
|
|
144
|
+
process.stderr.write(
|
|
145
|
+
`prymer: no credential for profile "${profile}" — skipping context load.\n`,
|
|
146
|
+
);
|
|
147
|
+
process.exitCode = strict ? 1 : 0;
|
|
148
|
+
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const { json } = await request(
|
|
154
|
+
'GET',
|
|
155
|
+
endpoint(creds.base_url, '/api/hooks/inject'),
|
|
156
|
+
{
|
|
157
|
+
token: creds.token,
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const text =
|
|
162
|
+
json && typeof json.formatted === 'string' ? json.formatted : '';
|
|
163
|
+
if (text) {
|
|
164
|
+
process.stdout.write(`${text}\n`);
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
`prymer: could not load context (${error.message}).\n`,
|
|
169
|
+
);
|
|
170
|
+
process.exitCode = strict ? 1 : 0;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function checkpoint(flags) {
|
|
175
|
+
const profile = profileOf(flags);
|
|
176
|
+
const creds = loadCredentials(profile);
|
|
177
|
+
|
|
178
|
+
if (!creds) {
|
|
179
|
+
process.stderr.write(
|
|
180
|
+
`No credential for profile "${profile}". Run \`prymer login\`.\n`,
|
|
181
|
+
);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const title = typeof flags.title === 'string' ? flags.title : '';
|
|
188
|
+
const summary = typeof flags.summary === 'string' ? flags.summary : '';
|
|
189
|
+
|
|
190
|
+
if (!title || !summary) {
|
|
191
|
+
process.stderr.write('checkpoint needs --title and --summary.\n');
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const sessionId =
|
|
198
|
+
typeof flags.session === 'string'
|
|
199
|
+
? flags.session
|
|
200
|
+
: process.env.SWARM_SESSION_ID;
|
|
201
|
+
const idempotencyKey =
|
|
202
|
+
typeof flags.key === 'string'
|
|
203
|
+
? flags.key
|
|
204
|
+
: deriveIdempotencyKey({ sessionId, title, summary });
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const { json } = await request(
|
|
208
|
+
'POST',
|
|
209
|
+
endpoint(creds.base_url, '/api/hooks/checkpoint'),
|
|
210
|
+
{
|
|
211
|
+
token: creds.token,
|
|
212
|
+
body: {
|
|
213
|
+
idempotency_key: idempotencyKey,
|
|
214
|
+
title,
|
|
215
|
+
summary,
|
|
216
|
+
...(typeof flags.content === 'string'
|
|
217
|
+
? { content: flags.content }
|
|
218
|
+
: {}),
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (json && json.shared === false) {
|
|
224
|
+
process.stderr.write(
|
|
225
|
+
`Not shared (${json.reason ?? 'unknown'}). The channel is not set to Auto capture.\n`,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const idempotent = json && json.idempotent ? ' (already recorded)' : '';
|
|
232
|
+
process.stderr.write(`Checkpoint shared${idempotent}.\n`);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const detail =
|
|
235
|
+
error instanceof HttpError
|
|
236
|
+
? JSON.stringify(error.body)
|
|
237
|
+
: error.message;
|
|
238
|
+
process.stderr.write(`Checkpoint failed: ${detail}\n`);
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function installHooks(flags) {
|
|
244
|
+
const client = typeof flags.client === 'string' ? flags.client : '';
|
|
245
|
+
|
|
246
|
+
if (!SUPPORTED_CLIENTS.includes(/** @type {any} */ (client))) {
|
|
247
|
+
process.stderr.write(
|
|
248
|
+
`--client must be one of: ${SUPPORTED_CLIENTS.join(', ')}.\n`,
|
|
249
|
+
);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const baseDir =
|
|
256
|
+
typeof flags.dir === 'string' ? resolve(flags.dir) : process.cwd();
|
|
257
|
+
const files = hookFiles(/** @type {'codex'|'claude'} */ (client));
|
|
258
|
+
|
|
259
|
+
for (const [relativePath, contents] of Object.entries(files)) {
|
|
260
|
+
const target = join(baseDir, relativePath);
|
|
261
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
262
|
+
writeFileSync(target, contents);
|
|
263
|
+
process.stderr.write(`Wrote ${relativePath}\n`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
process.stderr.write(
|
|
267
|
+
'Executing hooks installed. Run `prymer login` if you have not yet.\n',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function main() {
|
|
272
|
+
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
273
|
+
|
|
274
|
+
switch (command) {
|
|
275
|
+
case 'login':
|
|
276
|
+
return login(flags);
|
|
277
|
+
case 'logout':
|
|
278
|
+
return logout(flags);
|
|
279
|
+
case 'whoami':
|
|
280
|
+
return whoami(flags);
|
|
281
|
+
case 'inject':
|
|
282
|
+
return inject(flags);
|
|
283
|
+
case 'checkpoint':
|
|
284
|
+
return checkpoint(flags);
|
|
285
|
+
case 'install-hooks':
|
|
286
|
+
return installHooks(flags);
|
|
287
|
+
case undefined:
|
|
288
|
+
case 'help':
|
|
289
|
+
case '--help':
|
|
290
|
+
case '-h':
|
|
291
|
+
process.stdout.write(`${USAGE}\n`);
|
|
292
|
+
|
|
293
|
+
return;
|
|
294
|
+
default:
|
|
295
|
+
process.stderr.write(`Unknown command: ${command}\n\n${USAGE}\n`);
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
main().catch((error) => {
|
|
301
|
+
process.stderr.write(`${error.message}\n`);
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
});
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, dependency-free argv parser. `prymer <command> [--flag value] [--bool]
|
|
3
|
+
* [positional]`. A flag with no following value (or followed by another `--flag`)
|
|
4
|
+
* is treated as a boolean `true`.
|
|
5
|
+
*
|
|
6
|
+
* @param {string[]} argv The args after the node binary + script (i.e. process.argv.slice(2)).
|
|
7
|
+
* @returns {{ command: string|undefined, flags: Record<string, string|boolean>, positionals: string[] }}
|
|
8
|
+
*/
|
|
9
|
+
export function parseArgs(argv) {
|
|
10
|
+
const [command, ...rest] = argv;
|
|
11
|
+
/** @type {Record<string, string|boolean>} */
|
|
12
|
+
const flags = {};
|
|
13
|
+
const positionals = [];
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < rest.length; i++) {
|
|
16
|
+
const arg = rest[i];
|
|
17
|
+
|
|
18
|
+
if (arg.startsWith('--')) {
|
|
19
|
+
const key = arg.slice(2);
|
|
20
|
+
const next = rest[i + 1];
|
|
21
|
+
|
|
22
|
+
if (next === undefined || next.startsWith('--')) {
|
|
23
|
+
flags[key] = true;
|
|
24
|
+
} else {
|
|
25
|
+
flags[key] = next;
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
positionals.push(arg);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { command, flags, positionals };
|
|
34
|
+
}
|
|
Binary file
|
package/lib/hooks.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executing-hook templates. Unlike the *instructional* hooks the web connectors
|
|
3
|
+
* ship (which only print a nudge for the model to call an MCP tool), these run
|
|
4
|
+
* the `prymer` CLI directly, so the load step happens deterministically — even if
|
|
5
|
+
* the model forgets the ritual.
|
|
6
|
+
*
|
|
7
|
+
* SessionStart runs `prymer inject`, which prints the channel's context document
|
|
8
|
+
* to stdout; both Codex and Claude Code feed SessionStart stdout to the model as
|
|
9
|
+
* additional context. Checkpointing stays a step the agent takes when it has a
|
|
10
|
+
* summary to write (a blind Stop hook has nothing meaningful to checkpoint), so
|
|
11
|
+
* the templates document `prymer checkpoint` rather than firing it blindly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} json
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function pretty(json) {
|
|
19
|
+
return `${JSON.stringify(json, null, 2)}\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The files to write for a given client, keyed by project-relative path.
|
|
24
|
+
*
|
|
25
|
+
* @param {'codex'|'claude'} client
|
|
26
|
+
* @returns {Record<string, string>}
|
|
27
|
+
*/
|
|
28
|
+
export function hookFiles(client) {
|
|
29
|
+
if (client === 'codex') {
|
|
30
|
+
return {
|
|
31
|
+
'.codex/hooks.json': pretty({
|
|
32
|
+
hooks: {
|
|
33
|
+
SessionStart: [
|
|
34
|
+
{
|
|
35
|
+
matcher: 'startup|resume',
|
|
36
|
+
hooks: [
|
|
37
|
+
{
|
|
38
|
+
type: 'command',
|
|
39
|
+
command: 'prymer inject',
|
|
40
|
+
statusMessage: 'Loading Prymer context',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (client === 'claude') {
|
|
51
|
+
return {
|
|
52
|
+
'.claude/settings.json': pretty({
|
|
53
|
+
hooks: {
|
|
54
|
+
SessionStart: [
|
|
55
|
+
{
|
|
56
|
+
hooks: [
|
|
57
|
+
{
|
|
58
|
+
type: 'command',
|
|
59
|
+
command: 'prymer inject',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new Error(`Unknown client: ${client} (expected codex or claude)`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @type {ReadonlyArray<'codex'|'claude'>} */
|
|
73
|
+
export const SUPPORTED_CLIENTS = ['codex', 'claude'];
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An HTTP error carrying the parsed body, so callers can read the server's
|
|
6
|
+
* `reason` (e.g. `capture_mode_not_auto`) or message.
|
|
7
|
+
*/
|
|
8
|
+
export class HttpError extends Error {
|
|
9
|
+
/**
|
|
10
|
+
* @param {number} status
|
|
11
|
+
* @param {any} body
|
|
12
|
+
*/
|
|
13
|
+
constructor(status, body) {
|
|
14
|
+
super(`HTTP ${status}`);
|
|
15
|
+
this.name = 'HttpError';
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.body = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} data
|
|
23
|
+
* @returns {any}
|
|
24
|
+
*/
|
|
25
|
+
function safeJson(data) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(data);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Make a JSON request with no third-party dependencies.
|
|
35
|
+
*
|
|
36
|
+
* @param {'GET'|'POST'} method
|
|
37
|
+
* @param {string} urlString
|
|
38
|
+
* @param {{ token?: string, body?: object }} [options]
|
|
39
|
+
* @returns {Promise<{ status: number, json: any }>}
|
|
40
|
+
*/
|
|
41
|
+
export function request(method, urlString, { token, body } = {}) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let url;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(urlString);
|
|
46
|
+
} catch {
|
|
47
|
+
reject(new Error(`Invalid URL: ${urlString}`));
|
|
48
|
+
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lib = url.protocol === 'http:' ? http : https;
|
|
53
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
54
|
+
|
|
55
|
+
const req = lib.request(
|
|
56
|
+
url,
|
|
57
|
+
{
|
|
58
|
+
method,
|
|
59
|
+
headers: {
|
|
60
|
+
Accept: 'application/json',
|
|
61
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
62
|
+
...(payload
|
|
63
|
+
? {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
66
|
+
}
|
|
67
|
+
: {}),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
(res) => {
|
|
71
|
+
let data = '';
|
|
72
|
+
res.on('data', (chunk) => {
|
|
73
|
+
data += chunk;
|
|
74
|
+
});
|
|
75
|
+
res.on('end', () => {
|
|
76
|
+
const json = data ? safeJson(data) : null;
|
|
77
|
+
const status = res.statusCode ?? 0;
|
|
78
|
+
|
|
79
|
+
if (status >= 200 && status < 300) {
|
|
80
|
+
resolve({ status, json });
|
|
81
|
+
} else {
|
|
82
|
+
reject(new HttpError(status, json ?? data));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
req.on('error', reject);
|
|
89
|
+
|
|
90
|
+
if (payload) {
|
|
91
|
+
req.write(payload);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
req.end();
|
|
95
|
+
});
|
|
96
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { homedir, platform } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Credential storage for the executing-hook power mode.
|
|
15
|
+
*
|
|
16
|
+
* A hook credential is a high-value secret (one channel, append-capable), so the
|
|
17
|
+
* gate's blast-radius decision is to keep it out of plaintext dotfiles where we
|
|
18
|
+
* can. On macOS we use the login keychain via the `security` CLI; everywhere else
|
|
19
|
+
* we fall back to a `~/.prymer` file with `0600` perms. Set `SWARM_NO_KEYCHAIN=1`
|
|
20
|
+
* to force the file path (used by the test suite, and by anyone who prefers it).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SERVICE = 'prymer';
|
|
24
|
+
const FILE_DIR = join(homedir(), '.prymer');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} profile
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function filePath(profile) {
|
|
31
|
+
return join(FILE_DIR, `credentials-${profile}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function useKeychain() {
|
|
38
|
+
return platform() === 'darwin' && process.env.SWARM_NO_KEYCHAIN !== '1';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} profile
|
|
43
|
+
* @param {{ base_url: string, token: string }} data
|
|
44
|
+
*/
|
|
45
|
+
export function saveCredentials(profile, data) {
|
|
46
|
+
const json = JSON.stringify(data);
|
|
47
|
+
|
|
48
|
+
if (useKeychain()) {
|
|
49
|
+
// -U updates the entry if it already exists rather than erroring.
|
|
50
|
+
execFileSync('security', [
|
|
51
|
+
'add-generic-password',
|
|
52
|
+
'-U',
|
|
53
|
+
'-s',
|
|
54
|
+
SERVICE,
|
|
55
|
+
'-a',
|
|
56
|
+
profile,
|
|
57
|
+
'-w',
|
|
58
|
+
json,
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
mkdirSync(FILE_DIR, { recursive: true });
|
|
65
|
+
writeFileSync(filePath(profile), json, { mode: 0o600 });
|
|
66
|
+
chmodSync(filePath(profile), 0o600);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} profile
|
|
71
|
+
* @returns {{ base_url: string, token: string } | null}
|
|
72
|
+
*/
|
|
73
|
+
export function loadCredentials(profile) {
|
|
74
|
+
if (useKeychain()) {
|
|
75
|
+
try {
|
|
76
|
+
const out = execFileSync(
|
|
77
|
+
'security',
|
|
78
|
+
['find-generic-password', '-s', SERVICE, '-a', profile, '-w'],
|
|
79
|
+
{ encoding: 'utf8' },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return JSON.parse(out.trim());
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!existsSync(filePath(profile))) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(filePath(profile), 'utf8'));
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} profile
|
|
101
|
+
*/
|
|
102
|
+
export function deleteCredentials(profile) {
|
|
103
|
+
if (useKeychain()) {
|
|
104
|
+
try {
|
|
105
|
+
execFileSync('security', [
|
|
106
|
+
'delete-generic-password',
|
|
107
|
+
'-s',
|
|
108
|
+
SERVICE,
|
|
109
|
+
'-a',
|
|
110
|
+
profile,
|
|
111
|
+
]);
|
|
112
|
+
} catch {
|
|
113
|
+
// Nothing stored — deleting is a no-op.
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
rmSync(filePath(profile));
|
|
121
|
+
} catch {
|
|
122
|
+
// Nothing stored — deleting is a no-op.
|
|
123
|
+
}
|
|
124
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@builtbyberry/prymer-cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The Prymer executing-hook helper: load context at session start and checkpoint at session stop, deterministically, from a scoped channel credential.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"prymer": "bin/prymer.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test",
|
|
19
|
+
"prepublishOnly": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/builtbyberry/swarmcloud.git",
|
|
24
|
+
"directory": "cli/prymer"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/builtbyberry/swarmcloud/tree/main/cli/prymer#readme",
|
|
27
|
+
"bugs": "https://github.com/builtbyberry/swarmcloud/issues",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"prymer",
|
|
30
|
+
"mcp",
|
|
31
|
+
"context",
|
|
32
|
+
"hooks",
|
|
33
|
+
"cli"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|