pi-creel 0.2.0 → 0.2.2
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 +17 -0
- package/package.json +1 -1
- package/src/capture.test.ts +12 -0
- package/src/capture.ts +9 -2
- package/src/index.ts +21 -6
- package/src/watch.test.ts +57 -8
package/README.md
CHANGED
|
@@ -17,6 +17,23 @@ user pastes once, and the value goes straight from the popup into the target
|
|
|
17
17
|
- `name` — the environment-variable name, e.g. `OPENAI_API_KEY`.
|
|
18
18
|
- `dest` — path to the `.env` (relative to cwd), default `.env`.
|
|
19
19
|
|
|
20
|
+
## Consuming the secret
|
|
21
|
+
|
|
22
|
+
The value is written to `.env` mid-session, but the pi process env is frozen at
|
|
23
|
+
launch, so the model can't just read `process.env.NAME`. Two supported paths,
|
|
24
|
+
both of which keep the value out of the chat/context:
|
|
25
|
+
|
|
26
|
+
- **`creel exec NAME[,NAME2,...] -- <command>`** runs `<command>` with each
|
|
27
|
+
value in its environment **only** — never printed, never in argv, never in an
|
|
28
|
+
error (a missing key is a hard error naming just the key). This is the
|
|
29
|
+
language-agnostic way to hand a mid-session key to a child process
|
|
30
|
+
(`creel exec OPENAI_API_KEY -- node run.mjs`).
|
|
31
|
+
- **`process.env.NAME` after a relaunch**, once the new process inherits the
|
|
32
|
+
updated `.env`.
|
|
33
|
+
|
|
34
|
+
The model should **not** read the `.env` itself. The `added` / `updated` reply
|
|
35
|
+
from `request_secret` states this consumption path directly.
|
|
36
|
+
|
|
20
37
|
## Requirements
|
|
21
38
|
|
|
22
39
|
- A tmux session (`$TMUX`). Without one the tool fails fast and asks the user to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-creel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "request_secret for the pi coding agent: capture an API key into a local .env via a tmux popup (the creel tool) so it never enters the chat/context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/capture.test.ts
CHANGED
|
@@ -33,3 +33,15 @@ test("tokenToText maps every outcome and never echoes a value", () => {
|
|
|
33
33
|
assert.match(tokenToText("", "K", ".env"), /Timed out/);
|
|
34
34
|
assert.match(tokenToText("weird", "K", ".env"), /unexpected status \(weird\)/);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
test("a successful capture tells the model how to consume the secret", () => {
|
|
38
|
+
for (const token of ["added", "updated"]) {
|
|
39
|
+
const text = tokenToText(token, "TYPESAFE_API_KEY", ".env");
|
|
40
|
+
// Points at the containment path (creel exec) with the real var name...
|
|
41
|
+
assert.match(text, /creel exec TYPESAFE_API_KEY -- /);
|
|
42
|
+
// ...and the relaunch fallback...
|
|
43
|
+
assert.match(text, /process\.env\.TYPESAFE_API_KEY/);
|
|
44
|
+
// ...and steers the model away from reading the .env itself.
|
|
45
|
+
assert.match(text, /do not read|don't read/i);
|
|
46
|
+
}
|
|
47
|
+
});
|
package/src/capture.ts
CHANGED
|
@@ -46,11 +46,18 @@ export function tokenToText(
|
|
|
46
46
|
if (token === undefined || token === "") {
|
|
47
47
|
return `Timed out waiting for the creel popup; nothing was recorded for ${name}.`;
|
|
48
48
|
}
|
|
49
|
+
// howToUse tells the model the two supported ways to consume a mid-session
|
|
50
|
+
// secret without ever handling the value itself. It must never suggest
|
|
51
|
+
// reading the .env directly (the value must not enter the harness/context).
|
|
52
|
+
const howToUse =
|
|
53
|
+
` The value was not shown here. To use it, run \`creel exec ${name} -- <command>\`` +
|
|
54
|
+
` (that puts ${name} in the command's environment only), or read \`process.env.${name}\`` +
|
|
55
|
+
` after a relaunch. Do not read ${dest} yourself.`;
|
|
49
56
|
switch (token) {
|
|
50
57
|
case "added":
|
|
51
|
-
return `Added ${name} to ${dest} (chmod 600)
|
|
58
|
+
return `Added ${name} to ${dest} (chmod 600).` + howToUse;
|
|
52
59
|
case "updated":
|
|
53
|
-
return `Updated ${name} in ${dest}
|
|
60
|
+
return `Updated ${name} in ${dest}.` + howToUse;
|
|
54
61
|
case "cancelled":
|
|
55
62
|
return `Capture cancelled; nothing was written for ${name}.`;
|
|
56
63
|
default:
|
package/src/index.ts
CHANGED
|
@@ -182,11 +182,15 @@ function defaultResolveSessionId(tmux: TmuxContext): string | undefined {
|
|
|
182
182
|
|
|
183
183
|
// startCreelWatch notifies THIS pi session when the user saves a secret via
|
|
184
184
|
// creel outside a request_secret call (the tmux keybind runs creel with
|
|
185
|
-
// --event-file <
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
185
|
+
// --event-file <root>/<socket>/<session>.json). Routing must be GLOBALLY
|
|
186
|
+
// unique: a tmux session id (`$0`, `$1`...) is only unique within one tmux
|
|
187
|
+
// server, and we run one socket per project against a shared events root, so
|
|
188
|
+
// the key is scoped by socket name first, session id second. Without this a
|
|
189
|
+
// save on one socket wakes a same-numbered session on every other socket. It
|
|
190
|
+
// watches this socket's subdir, and on a NEW event delivers a value-free note -
|
|
191
|
+
// steering a fresh turn when idle, queuing after the current turn when busy
|
|
192
|
+
// (mirroring pi-wakeup). It is a no-op outside tmux, where there is no keybind
|
|
193
|
+
// to hear.
|
|
190
194
|
export function startCreelWatch(pi: any, deps: Deps = {}): void {
|
|
191
195
|
const resolveT = deps.resolveTmux ?? resolveTmux;
|
|
192
196
|
const resolveSid = deps.resolveSessionId ?? defaultResolveSessionId;
|
|
@@ -201,7 +205,9 @@ export function startCreelWatch(pi: any, deps: Deps = {}): void {
|
|
|
201
205
|
const sid = resolveSid(tmux);
|
|
202
206
|
if (!sid) return;
|
|
203
207
|
|
|
204
|
-
|
|
208
|
+
// Scope by socket so `$0` on proj-foo and `$0` on proj-bar never collide in
|
|
209
|
+
// the shared events root.
|
|
210
|
+
const dir = join(home, EVENTS_SUBDIR, tmux.socket);
|
|
205
211
|
try {
|
|
206
212
|
ensureDir(dir);
|
|
207
213
|
} catch {
|
|
@@ -218,7 +224,16 @@ export function startCreelWatch(pi: any, deps: Deps = {}): void {
|
|
|
218
224
|
// Dedupe by mtime: one write can fire the watcher twice (rename+change), and
|
|
219
225
|
// two saves with identical name/dest/action have identical CONTENT, so mtime
|
|
220
226
|
// - which advances on every write - is the reliable "is this a new save" key.
|
|
227
|
+
// Seed from any file left by a PRIOR session at this same sid so a stray dir
|
|
228
|
+
// event (macOS can deliver a null filename, bypassing the name filter) can
|
|
229
|
+
// never re-deliver that stale note - only a fresh write, with a new mtime,
|
|
230
|
+
// fires.
|
|
221
231
|
let lastMtimeMs = -1;
|
|
232
|
+
try {
|
|
233
|
+
lastMtimeMs = mtimeMs(target);
|
|
234
|
+
} catch {
|
|
235
|
+
// no pre-existing file - the first real save is a genuine new event
|
|
236
|
+
}
|
|
222
237
|
const handle = startWatch(dir, (_event: string, changed: string | null) => {
|
|
223
238
|
if (changed !== null && changed !== fileName) return;
|
|
224
239
|
let m: number;
|
package/src/watch.test.ts
CHANGED
|
@@ -23,29 +23,79 @@ function fakePi() {
|
|
|
23
23
|
// watchDeps wires a fully-injected Deps: no real fs, no real tmux. `fire`
|
|
24
24
|
// invokes the captured watch callback for this session's file; `closeFake`
|
|
25
25
|
// reports whether the handle was closed.
|
|
26
|
-
|
|
26
|
+
// watchDeps models a file that is ABSENT until a save creates it: eventMtimeMs
|
|
27
|
+
// throws while `present` is false (statSync's real behavior on a missing file),
|
|
28
|
+
// so the startup mtime-seed only kicks in when the file already exists. `fire`
|
|
29
|
+
// marks the file present (a save wrote it) before invoking the watch callback,
|
|
30
|
+
// mirroring reality. Pass presentAtStartup:true to simulate a stale file left
|
|
31
|
+
// by a prior session.
|
|
32
|
+
function watchDeps(
|
|
33
|
+
overrides: Partial<Deps> = {},
|
|
34
|
+
opts: { presentAtStartup?: boolean; mtime?: number } = {},
|
|
35
|
+
) {
|
|
27
36
|
let cb: ((e: string, f: string | null) => void) | undefined;
|
|
28
37
|
let closed = false;
|
|
38
|
+
let watchedDir: string | undefined;
|
|
39
|
+
const mkdirCalls: string[] = [];
|
|
40
|
+
let present = opts.presentAtStartup ?? false;
|
|
41
|
+
let mt = opts.mtime ?? 1000;
|
|
29
42
|
const deps: Deps = {
|
|
30
43
|
resolveTmux: () => ({ socket: "s", pane: "%1" }),
|
|
31
44
|
resolveSessionId: () => "$3",
|
|
32
45
|
homedir: () => "/home/u",
|
|
33
|
-
mkdir: () => {
|
|
34
|
-
|
|
46
|
+
mkdir: (d) => {
|
|
47
|
+
mkdirCalls.push(d);
|
|
48
|
+
},
|
|
49
|
+
watch: (dir, c) => {
|
|
50
|
+
watchedDir = dir;
|
|
35
51
|
cb = c;
|
|
36
52
|
return { close() { closed = true; } } as WatchHandle;
|
|
37
53
|
},
|
|
38
54
|
readEventFile: () => '{"name":"STRIPE_KEY","dest":".env","action":"added"}',
|
|
39
|
-
eventMtimeMs: () =>
|
|
55
|
+
eventMtimeMs: () => {
|
|
56
|
+
if (!present) throw new Error("ENOENT");
|
|
57
|
+
return mt;
|
|
58
|
+
},
|
|
40
59
|
...overrides,
|
|
41
60
|
};
|
|
42
61
|
return {
|
|
43
62
|
deps,
|
|
44
|
-
fire: (file: string | null = "$3.json") =>
|
|
63
|
+
fire: (file: string | null = "$3.json") => {
|
|
64
|
+
present = true;
|
|
65
|
+
cb?.("change", file);
|
|
66
|
+
},
|
|
67
|
+
setMtime: (v: number) => {
|
|
68
|
+
mt = v;
|
|
69
|
+
},
|
|
45
70
|
isClosed: () => closed,
|
|
71
|
+
watchedDir: () => watchedDir,
|
|
72
|
+
mkdirCalls: () => mkdirCalls,
|
|
46
73
|
};
|
|
47
74
|
}
|
|
48
75
|
|
|
76
|
+
test("watches (and creates) a socket-scoped events dir, not the shared root", () => {
|
|
77
|
+
// session ids (`$0`, `$1`...) are unique only within one tmux server, so the
|
|
78
|
+
// routing key must include the socket name or a save on one socket wakes a
|
|
79
|
+
// same-id session on another. The watcher scopes to <root>/<socket>.
|
|
80
|
+
const { pi } = fakePi();
|
|
81
|
+
const { deps, watchedDir, mkdirCalls } = watchDeps();
|
|
82
|
+
startCreelWatch(pi, deps);
|
|
83
|
+
assert.equal(watchedDir(), "/home/u/.pi/agent/creel-events/s");
|
|
84
|
+
assert.ok(mkdirCalls().includes("/home/u/.pi/agent/creel-events/s"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a pre-existing stale event file does not fire on the first spurious event", () => {
|
|
88
|
+
// If this session's file already exists at startup (a prior save at the same
|
|
89
|
+
// sid), a stray dir event (macOS can deliver a null filename) must not
|
|
90
|
+
// re-deliver the old note. Seeding lastMtimeMs from the file at startup guards
|
|
91
|
+
// this: an unchanged mtime is skipped.
|
|
92
|
+
const { pi, sent } = fakePi();
|
|
93
|
+
const { deps, fire } = watchDeps({}, { presentAtStartup: true, mtime: 1000 });
|
|
94
|
+
startCreelWatch(pi, deps);
|
|
95
|
+
fire(null); // null filename falls through the name filter; mtime is unchanged
|
|
96
|
+
assert.equal(sent.length, 0);
|
|
97
|
+
});
|
|
98
|
+
|
|
49
99
|
test("delivers a value-free note on a new event, queued after the turn", () => {
|
|
50
100
|
const { pi, sent } = fakePi();
|
|
51
101
|
const { deps, fire } = watchDeps();
|
|
@@ -69,13 +119,12 @@ test("an idle session steers a fresh turn", () => {
|
|
|
69
119
|
|
|
70
120
|
test("the same mtime does not re-notify; a new mtime does", () => {
|
|
71
121
|
const { pi, sent } = fakePi();
|
|
72
|
-
|
|
73
|
-
const { deps, fire } = watchDeps({ eventMtimeMs: () => mt });
|
|
122
|
+
const { deps, fire, setMtime } = watchDeps();
|
|
74
123
|
startCreelWatch(pi, deps);
|
|
75
124
|
fire();
|
|
76
125
|
fire(); // double-fire of one write -> same mtime -> skipped
|
|
77
126
|
assert.equal(sent.length, 1);
|
|
78
|
-
|
|
127
|
+
setMtime(2000); // a genuinely new save
|
|
79
128
|
fire();
|
|
80
129
|
assert.equal(sent.length, 2);
|
|
81
130
|
});
|