@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.
- package/README.md +169 -0
- package/dist/cli.js +84576 -0
- package/dist/hooks/powerplant-inbox.mjs +147 -0
- package/dist/hooks/powerplant-session-lifecycle.mjs +82 -0
- package/package.json +37 -0
|
@@ -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
|
+
}
|