@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,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
|
-
}
|