@usepowerplant/cli 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usepowerplant/cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
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
- }