@usepowerplant/cli 0.3.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.
@@ -0,0 +1,147 @@
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
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Powerplant session lifecycle — SessionStart / SessionEnd hook.
4
+ *
5
+ * Claude Code fires these at real session boundaries (start, resume, clear,
6
+ * quit). This hook appends one line per event to a local journal
7
+ * (~/.config/powerplant/session-lifecycle.jsonl) so the collector's scan can
8
+ * mark a session "completed" the moment its SessionEnd arrives, with the
9
+ * observed end time — instead of waiting out the transcript-quiet grace
10
+ * window. The `at` stamp is when THIS process observed the hook fire; it is
11
+ * never a claimed or reconstructed time.
12
+ *
13
+ * This file is the CANONICAL SOURCE; the collector's watch service copies it
14
+ * to ~/.config/powerplant/hooks/ and wires it into the user's
15
+ * ~/.claude/settings.json (hookInstall.ts), so every repo's Claude sessions
16
+ * report — not just this checkout's. This hook NEVER blocks or breaks a
17
+ * session: every failure path exits 0 with no output.
18
+ *
19
+ * Logic lives in the exported functions so the collector workspace can unit
20
+ * test it (apps/collector/src/lifecycleHook.test.ts); only direct execution
21
+ * runs main().
22
+ */
23
+
24
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { pathToFileURL } from "node:url";
28
+
29
+ /** The collector's watcher and scan read this same path (lifecycleJournal.ts). */
30
+ export function journalPath(home) {
31
+ return path.join(home, ".config", "powerplant", "session-lifecycle.jsonl");
32
+ }
33
+
34
+ /**
35
+ * One journal line from the hook's stdin JSON; null when the input isn't a
36
+ * lifecycle event we can attribute to a session. SessionStart carries a
37
+ * `source` (startup|resume|clear|compact|fork), SessionEnd a `reason`
38
+ * (clear|resume|logout|prompt_input_exit|other) — both land as `reason`.
39
+ */
40
+ export function journalLineOf(inputText, at) {
41
+ try {
42
+ const input = JSON.parse(inputText);
43
+ const sessionId = input?.session_id;
44
+ if (typeof sessionId !== "string" || sessionId === "") return null;
45
+ const event =
46
+ input?.hook_event_name === "SessionStart"
47
+ ? "start"
48
+ : input?.hook_event_name === "SessionEnd"
49
+ ? "end"
50
+ : null;
51
+ if (event === null) return null;
52
+ const reason = event === "start" ? input?.source : input?.reason;
53
+ return JSON.stringify({
54
+ sessionId,
55
+ event,
56
+ ...(typeof reason === "string" && reason !== "" && { reason }),
57
+ at,
58
+ });
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function main() {
65
+ try {
66
+ const line = journalLineOf(readFileSync(0, "utf8"), new Date().toISOString());
67
+ if (line === null) return;
68
+ // The path is the user's homedir plus fixed segments — nothing from the
69
+ // hook input can steer where the journal lands.
70
+ const file = journalPath(os.homedir());
71
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
72
+ mkdirSync(path.dirname(file), { recursive: true });
73
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
74
+ appendFileSync(file, `${line}\n`);
75
+ } catch {
76
+ // never surface an error into someone's session
77
+ }
78
+ }
79
+
80
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
81
+ main();
82
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@usepowerplant/cli",
3
+ "version": "0.3.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Powerplant machine setup: enroll this machine, run the session collector in the background, and manage it.",
8
+ "license": "UNLICENSED",
9
+ "homepage": "https://powerplant.sh",
10
+ "bugs": {
11
+ "email": "support@forestwalk.ai"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "powerplant": "dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "scripts": {
24
+ "build": "node build.mjs",
25
+ "type-check": "tsc --noEmit",
26
+ "test": "vitest run"
27
+ },
28
+ "devDependencies": {
29
+ "@powerplant/collector": "workspace:*",
30
+ "@powerplant/core": "workspace:*",
31
+ "@types/node": "^26.3.0",
32
+ "esbuild": "^0.28.2",
33
+ "typescript": "^5.9.3",
34
+ "vitest": "^4.1.11",
35
+ "zod": "^4.4.3"
36
+ }
37
+ }