@usepowerplant/cli 0.4.0 → 0.4.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.
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // Local discovery only. The collector validates paths and capture policy
3
+ // before opening transcripts. Never persist prompts, email, or full input.
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { Buffer } from "node:buffer";
6
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ const MAX_INPUT_BYTES = 128 * 1024;
11
+ let chunks = [];
12
+ let bytes = 0;
13
+ process.stdin.on("error", () => {});
14
+ process.stdin.on("data", (chunk) => {
15
+ bytes += chunk.length;
16
+ if (bytes <= MAX_INPUT_BYTES) chunks.push(chunk);
17
+ else chunks = [];
18
+ });
19
+ process.stdin.on("end", () => {
20
+ const dir = path.join(os.homedir(), ".config", "powerplant");
21
+ try {
22
+ if (bytes <= MAX_INPUT_BYTES) {
23
+ const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
24
+ if (
25
+ ["sessionStart", "sessionEnd", "stop"].includes(input?.hook_event_name) &&
26
+ typeof input.conversation_id === "string" &&
27
+ /^[a-zA-Z0-9_-]{1,200}$/.test(input.conversation_id)
28
+ ) {
29
+ const roots =
30
+ Array.isArray(input.workspace_roots) &&
31
+ input.workspace_roots.length <= 8 &&
32
+ input.workspace_roots.every(
33
+ (p) => typeof p === "string" && p.length <= 4096 && path.isAbsolute(p)
34
+ )
35
+ ? input.workspace_roots
36
+ : [];
37
+ const record = {
38
+ sessionId: input.conversation_id,
39
+ transcriptPath:
40
+ typeof input.transcript_path === "string" &&
41
+ input.transcript_path.length <= 4096 &&
42
+ path.isAbsolute(input.transcript_path)
43
+ ? input.transcript_path
44
+ : null,
45
+ workspaceRoots: roots,
46
+ ...(typeof input.model === "string" &&
47
+ input.model.length <= 256 && { model: input.model }),
48
+ };
49
+ const records = path.join(dir, "cursor-transcripts");
50
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
51
+ mkdirSync(records, { recursive: true, mode: 0o700 });
52
+ const file = path.join(
53
+ records,
54
+ `${createHash("sha256").update(record.sessionId).digest("hex")}.json`
55
+ );
56
+ const temporary = `${file}.${randomUUID()}.tmp`;
57
+ try {
58
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
59
+ writeFileSync(temporary, JSON.stringify(record), { mode: 0o600, flag: "wx" });
60
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
61
+ renameSync(temporary, file);
62
+ } finally {
63
+ rmSync(temporary, { force: true });
64
+ }
65
+ }
66
+ }
67
+ } catch {
68
+ // Discovery failure must never stop the provider. Existing capture remains.
69
+ }
70
+ try {
71
+ // Publish after the pointer so the watcher sees a complete discovery record.
72
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
73
+ mkdirSync(dir, { recursive: true });
74
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
75
+ writeFileSync(path.join(dir, "session-change"), `${Date.now()}\n`, { mode: 0o600 });
76
+ } catch {
77
+ // No output to the agent, even if the disk is unavailable.
78
+ }
79
+ });
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // Canonical standalone hook, bundled by packages/cli/build.mjs.
3
+ // Wake the local collector without inspecting or saving the hook payload.
4
+ // Existing readers still enforce capture scope and redact before uploading.
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ // Drain provider input without retaining it or emitting an agent response.
10
+ process.stdin.on("error", () => {});
11
+ process.stdin.resume();
12
+ try {
13
+ const dir = path.join(os.homedir(), ".config", "powerplant");
14
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
15
+ mkdirSync(dir, { recursive: true });
16
+ // A fixed-size signal coalesces events. No paths or content come from stdin.
17
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
18
+ writeFileSync(path.join(dir, "session-change"), `${Date.now()}\n`, { mode: 0o600 });
19
+ } catch {
20
+ // A disk failure must not disrupt the agent. Recovery scans still run.
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usepowerplant/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,147 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Powerplant message delivery — UserPromptSubmit hook.
4
- *
5
- * Before each prompt, ask Powerplant whether a teammate queued messages for
6
- * THIS session (written in the session's room on Powerplant). The fetch is
7
- * the delivery: the API atomically flips claimed rows to "delivered", so a
8
- * message is handed to exactly one prompt.
9
- *
10
- * This file is the CANONICAL SOURCE; the collector's watch service copies
11
- * it to ~/.config/powerplant/hooks/ and wires it into the user's
12
- * ~/.claude/settings.json (hookInstall.ts), so every repo's Claude sessions
13
- * deliver — not just this checkout's. Auth is the collector's enrollment
14
- * token (macOS keychain / fallback file) — machines that never enrolled, or
15
- * can't reach Powerplant within the budget, stay silent. This hook NEVER
16
- * blocks or breaks a session: every failure path exits 0 with no output.
17
- *
18
- * Logic lives in the exported functions so the collector workspace can unit
19
- * test it (apps/collector/src/inboxHook.test.ts); only direct execution
20
- * runs main().
21
- */
22
-
23
- import { execFileSync } from "node:child_process";
24
- import { readFileSync } from "node:fs";
25
- import os from "node:os";
26
- import path from "node:path";
27
- import { pathToFileURL } from "node:url";
28
-
29
- /** Same store the collector writes at enrollment (credentials.ts). */
30
- const KEYCHAIN_SERVICE = "powerplant-collector";
31
- const ENVS = ["production", "staging", "development"];
32
- /** Per-env budget: a slow or absent API must not make prompts feel laggy. */
33
- const FETCH_TIMEOUT_MS = 800;
34
-
35
- /** The hook input's session id — Claude Code sends JSON on stdin. */
36
- export function sessionIdOf(inputText) {
37
- try {
38
- const input = JSON.parse(inputText);
39
- const id = input?.session_id;
40
- return typeof id === "string" && id !== "" ? id : null;
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- /** {token, apiUrl} when the parsed credential payload is usable. */
47
- export function credentialsOf(payloadText) {
48
- try {
49
- const parsed = JSON.parse(payloadText.trim());
50
- return typeof parsed?.token === "string" && typeof parsed?.apiUrl === "string"
51
- ? { token: parsed.token, apiUrl: parsed.apiUrl }
52
- : null;
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- export function inboxUrl(apiUrl, sessionId) {
59
- return `${apiUrl.replace(/\/$/, "")}/api/collector/inbox?source=claude-code&externalId=${encodeURIComponent(sessionId)}`;
60
- }
61
-
62
- /**
63
- * The injected context: sender-attributed, provenance-labeled, and framed
64
- * as what it is — a teammate's instruction relayed by Powerplant.
65
- */
66
- export function formatAdditionalContext(messages) {
67
- if (messages.length === 0) return null;
68
- const lines = messages.map(
69
- (message) =>
70
- `- From ${message.authorEmail ?? "a teammate"} (sent ${message.createdAt}): ${message.body}`
71
- );
72
- return [
73
- `A teammate sent ${messages.length === 1 ? "a message" : `${messages.length} messages`} to this session via Powerplant:`,
74
- ...lines,
75
- "Acknowledge the message in your reply and act on it as you would an instruction typed here by that teammate.",
76
- ].join("\n");
77
- }
78
-
79
- /** Every enrolled env's messages, concatenated; failures per env stay silent. */
80
- export async function claimAllEnrolledInboxes(sessionId, loadPayload, fetchImpl) {
81
- const messages = [];
82
- for (const env of ENVS) {
83
- const payload = loadPayload(env);
84
- if (payload === null) continue;
85
- const credentials = credentialsOf(payload);
86
- if (credentials === null) continue;
87
- try {
88
- const response = await fetchImpl(inboxUrl(credentials.apiUrl, sessionId), {
89
- headers: { authorization: `Bearer ${credentials.token}` },
90
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
91
- });
92
- if (!response.ok) continue;
93
- const body = await response.json();
94
- const claimed = body?.data?.messages;
95
- if (!Array.isArray(claimed)) continue;
96
- for (const message of claimed) {
97
- if (typeof message?.body === "string") messages.push(message);
98
- }
99
- } catch {
100
- // unreachable env, timeout, bad payload — silence is the contract
101
- }
102
- }
103
- return messages;
104
- }
105
-
106
- function loadStoredPayload(env) {
107
- try {
108
- if (process.platform === "darwin") {
109
- return execFileSync(
110
- "security",
111
- ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", env, "-w"],
112
- { stdio: ["ignore", "pipe", "ignore"] }
113
- ).toString();
114
- }
115
- // env comes only from the fixed ENVS list above; it cannot add path
116
- // separators or escape the collector config directory.
117
- // eslint-disable-next-line security/detect-non-literal-fs-filename
118
- return readFileSync(
119
- path.join(os.homedir(), ".config", "powerplant", `collector-${env}.json`),
120
- "utf8"
121
- );
122
- } catch {
123
- return null; // not enrolled for this env
124
- }
125
- }
126
-
127
- async function main() {
128
- try {
129
- const stdin = readFileSync(0, "utf8");
130
- const sessionId = sessionIdOf(stdin);
131
- if (sessionId === null) return;
132
- const messages = await claimAllEnrolledInboxes(sessionId, loadStoredPayload, fetch);
133
- const additionalContext = formatAdditionalContext(messages);
134
- if (additionalContext === null) return;
135
- process.stdout.write(
136
- JSON.stringify({
137
- hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext },
138
- })
139
- );
140
- } catch {
141
- // never surface an error into someone's prompt
142
- }
143
- }
144
-
145
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
146
- await main();
147
- }