@litterthanlit/hypher-cli 0.2.0 → 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 +17 -1
- package/package.json +1 -1
- package/src/api.mjs +31 -6
- package/src/claude.mjs +22 -1
- package/src/cli.mjs +4 -1
- package/src/cloud.mjs +145 -0
- package/src/commands/doctor.mjs +41 -6
- package/src/commands/hook.mjs +1 -1
- package/src/commands/init.mjs +54 -3
- package/src/commands/status.mjs +37 -1
- package/src/commands/uninstall.mjs +24 -0
- package/src/env.mjs +1 -1
- package/src/hookConfig.mjs +19 -9
- package/src/hooks/index.mjs +78 -12
- package/src/outbox.mjs +37 -1
- package/src/repoKit.mjs +275 -0
- package/src/session.mjs +28 -11
- package/src/throttle.mjs +46 -0
- package/vendor/handoffNote.mjs +18 -4
- package/vendor/ledgerNoteLocal.mjs +1 -1
package/src/hookConfig.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* { hooks: { <Event>: [{ matcher?, hooks: [{ type: "command", command, timeout }] }] } }.
|
|
4
4
|
* Hypher's entries carry a tag in their command, so they can be found again,
|
|
5
5
|
* and are appended after the builder's own, so removing them never shifts
|
|
6
|
-
* the position of anything the builder added.
|
|
6
|
+
* the position of anything the builder added. An event can have several specs
|
|
7
|
+
* (Claude Code's PostToolUse has one per tool matcher); each is one group.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
/** @param {string} tag e.g. "hook claude-code" */
|
|
@@ -34,16 +35,19 @@ function withoutTagged(groups, tag) {
|
|
|
34
35
|
* @param {{ event: string, matcher?: string, timeout: number }[]} specs
|
|
35
36
|
* @param {string} shim absolute path of the `hypher` shim
|
|
36
37
|
* @param {string} tag
|
|
38
|
+
* @param {(shim: string, tag: string, event: string) => string} [build] how a spec becomes a command; the repo kit's differs
|
|
37
39
|
*/
|
|
38
|
-
export function mergeHooks(config, specs, shim, tag) {
|
|
40
|
+
export function mergeHooks(config, specs, shim, tag, build = hookCommand) {
|
|
39
41
|
const next = { ...config, hooks: { ...(config.hooks ?? {}) } };
|
|
42
|
+
// Hypher's old groups for an event all go first, so two specs of one event never remove each other.
|
|
43
|
+
for (const event of new Set(specs.map((spec) => spec.event))) {
|
|
44
|
+
next.hooks[event] = withoutTagged(next.hooks[event], tag);
|
|
45
|
+
}
|
|
40
46
|
for (const spec of specs) {
|
|
41
|
-
|
|
42
|
-
groups.push({
|
|
47
|
+
next.hooks[spec.event].push({
|
|
43
48
|
...(spec.matcher ? { matcher: spec.matcher } : {}),
|
|
44
|
-
hooks: [{ type: "command", command:
|
|
49
|
+
hooks: [{ type: "command", command: build(shim, tag, spec.event), timeout: spec.timeout }],
|
|
45
50
|
});
|
|
46
|
-
next.hooks[spec.event] = groups;
|
|
47
51
|
}
|
|
48
52
|
return next;
|
|
49
53
|
}
|
|
@@ -61,11 +65,17 @@ export function removeHooks(config, tag) {
|
|
|
61
65
|
return next;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
/** The specs whose hook is in the file: a tagged command in a group of the spec's event (with the spec's matcher, when it has one). */
|
|
69
|
+
export function installedSpecs(config, specs, tag) {
|
|
65
70
|
return specs.filter((spec) =>
|
|
66
71
|
(config?.hooks?.[spec.event] ?? []).some((group) =>
|
|
67
|
-
(group?.
|
|
68
|
-
|
|
72
|
+
(!spec.matcher || group?.matcher === spec.matcher)
|
|
73
|
+
&& (group?.hooks ?? []).some((hook) => isTaggedCommand(hook?.command, tag))));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The events with at least one spec installed, each once, in the specs' order. */
|
|
77
|
+
export function installedEvents(config, specs, tag) {
|
|
78
|
+
return [...new Set(installedSpecs(config, specs, tag).map((spec) => spec.event))];
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/** Where each tagged hook sits: [event, groupIndex, hookIndex]. Codex keys its trust by this position. */
|
package/src/hooks/index.mjs
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent hooks, for Claude Code and Codex alike (both send the same payload
|
|
3
3
|
* shape). SessionStart injects the note (after startup, resume, clear, and
|
|
4
|
-
* compact
|
|
4
|
+
* compact; startup and resume move the seal baseline to the tree now, compact and clear keep it, so edits made
|
|
5
|
+
* before a compaction still make the next Stop ask for a seal); Stop, PreCompact, and SessionEnd record a checkpoint; Stop asks
|
|
5
6
|
* once for a seal when the tree changed. Hooks never acknowledge, never block
|
|
6
7
|
* twice in a row, and never block on Hypher being down. In a repository no
|
|
7
8
|
* human linked they record and upload nothing.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code alone (Codex parity is M4) also records a checkpoint after the note is read at SessionStart, and
|
|
11
|
+
* after a tool ran (PostToolUse: a file edit at most every 10 seconds, a command at most every 60), so a session
|
|
12
|
+
* killed in the middle of a turn still leaves one. PostToolUse reads only `cwd` and `tool_name` from its payload:
|
|
13
|
+
* the rest is the tool's input, which can be file contents, and is never looked at.
|
|
14
|
+
*
|
|
15
|
+
* In a Claude Code cloud session (cloud.mjs: `CLAUDE_CODE_REMOTE=true`, key from `HYPHER_API_KEY`) the container's
|
|
16
|
+
* disk dies with it, so every checkpoint also uploads at once (at most one a minute per repository and branch; `Stop`,
|
|
17
|
+
* `PreCompact`, and `SessionEnd` always try), a repository a human linked in Hypher is linked without a click, and the
|
|
18
|
+
* seal request names the shell command first. On a laptop none of that applies.
|
|
8
19
|
*/
|
|
9
20
|
import { createClient } from "../api.mjs";
|
|
10
21
|
import { linkFor } from "../config.mjs";
|
|
22
|
+
import { cloudMode, findLink, uploadNow } from "../cloud.mjs";
|
|
11
23
|
import { enqueue, flushOutbox, shouldFlush } from "../outbox.mjs";
|
|
12
24
|
import { resumeHere } from "../session.mjs";
|
|
25
|
+
import { claimCheckpoint, toolKind } from "../throttle.mjs";
|
|
13
26
|
import { takeSnapshot } from "../snapshot.mjs";
|
|
14
27
|
import { branchState, readWorktreeState, updateWorktreeState, withBranchState } from "../state.mjs";
|
|
15
28
|
import { findSecret } from "../secrets.mjs";
|
|
@@ -18,11 +31,14 @@ export const ASK_AGAIN_AFTER_MS = 15 * 60_000;
|
|
|
18
31
|
const HOOK_TIMEOUT_MS = 2_500;
|
|
19
32
|
const MAX_FILES = 200;
|
|
20
33
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The linked snapshot at cwd, or null: not git, no remote, or not linked. In a cloud session with no link file yet,
|
|
36
|
+
* a repository a human linked in Hypher is linked here first (see cloud.mjs).
|
|
37
|
+
*/
|
|
38
|
+
async function linkedSnapshot(env, cwd) {
|
|
23
39
|
const snapshot = takeSnapshot(cwd);
|
|
24
40
|
if (!snapshot?.repository || !snapshot.tree) return null;
|
|
25
|
-
const link =
|
|
41
|
+
const { link } = await findLink(env, snapshot.repository);
|
|
26
42
|
return link ? { snapshot, link } : null;
|
|
27
43
|
}
|
|
28
44
|
|
|
@@ -52,7 +68,7 @@ export function recordCheckpoint(env, snapshot, event, now = Date.now(), agent =
|
|
|
52
68
|
* What the Stop hook asks for. The words are the same for every project, except that when the last resume found
|
|
53
69
|
* the decision ledger on, IDs are named: a decision is replaced by its ID, and the lists are said to be added to.
|
|
54
70
|
*/
|
|
55
|
-
function sealRequest(firstHandoff, ledgerOn = false) {
|
|
71
|
+
function sealRequest(firstHandoff, ledgerOn = false, cloud = false) {
|
|
56
72
|
const goal = firstHandoff ? ' --goal "<the goal>"' : "";
|
|
57
73
|
const pairs = ledgerOn
|
|
58
74
|
? "Add one --decision/--why pair per changed decision (--replaces d-3 names the earlier decision it replaces, by the ID in the note), or none if no decision changed; --next is always needed. What you list is added to the ledger, never replaced: close finished work by listing it under completed (--completed \"…\"). Report only checks you ran (evidence, or --evidence \"<command>=<exit code>\")."
|
|
@@ -60,31 +76,59 @@ function sealRequest(firstHandoff, ledgerOn = false) {
|
|
|
60
76
|
return [
|
|
61
77
|
"Hypher: the working tree changed since the last handoff, and no one saved why.",
|
|
62
78
|
"Before you stop, record the decisions that changed, the next action for the next agent, and the checks you ran with their exit codes:",
|
|
63
|
-
|
|
79
|
+
cloud
|
|
80
|
+
// A cloud session has no signed-in hypher MCP server, so the shell command comes first.
|
|
81
|
+
? `run \`hypher seal${goal} --decision "<a decision that changed>" --why "<why>" --next "<the next action>"\` in the shell (a cloud session has no signed-in Hypher MCP server), or, only if that command is not available, call the \`seal\` tool of the hypher MCP server.`
|
|
82
|
+
: `call the \`seal\` tool of the hypher MCP server, or, only if that tool is not available, run \`hypher seal${goal} --decision "<a decision that changed>" --why "<why>" --next "<the next action>"\` in the terminal.`,
|
|
64
83
|
firstHandoff ? "This is the first handoff here, so give the goal too." : "If the work moved on from the note's goal, give the new goal too (goal, or --goal \"…\").",
|
|
65
84
|
pairs,
|
|
66
85
|
"It sends only your text, file names, and commit identity. Hypher asks once; if you cannot save now, say so and stop.",
|
|
67
86
|
].join(" ");
|
|
68
87
|
}
|
|
69
88
|
|
|
89
|
+
/** The resume statuses whose one line is also shown to the person (`systemMessage`), not only given to the model. */
|
|
90
|
+
const PERSON_SEES = new Set(["unavailable", "not-connected", "unlinked"]);
|
|
91
|
+
|
|
92
|
+
/** The SessionStart checkpoint, from the snapshot the read already took. It never fails a session start. */
|
|
93
|
+
async function recordStartCheckpoint(env, snapshot, agent) {
|
|
94
|
+
try {
|
|
95
|
+
if (!snapshot?.repository || !snapshot.tree || !linkFor(snapshot.repository, env)) return;
|
|
96
|
+
recordCheckpoint(env, snapshot, "SessionStart", Date.now(), agent);
|
|
97
|
+
// A container's disk dies with it, so the checkpoint leaves at once (at most once a minute).
|
|
98
|
+
if (cloudMode(env)) await uploadNow(env, snapshot);
|
|
99
|
+
} catch {
|
|
100
|
+
// The note is already read; a checkpoint that could not be written is not worth a failed start.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
async function sessionStart(agent, env, input) {
|
|
71
105
|
const result = await resumeHere({
|
|
72
106
|
env,
|
|
73
107
|
cwd: input.cwd || process.cwd(),
|
|
74
108
|
destination: agent,
|
|
75
109
|
sessionId: typeof input.session_id === "string" ? input.session_id : undefined,
|
|
110
|
+
// Claude Code and Codex send the same names. After `compact` or `clear` the work goes on, so the seal baseline stays.
|
|
111
|
+
source: typeof input.source === "string" ? input.source : undefined,
|
|
76
112
|
timeoutMs: HOOK_TIMEOUT_MS,
|
|
77
113
|
});
|
|
78
114
|
if (result.status === "not-git" || !result.note) return null;
|
|
79
|
-
|
|
115
|
+
// After the note is read and rendered, so the note never names its own checkpoint. Queued, not uploaded:
|
|
116
|
+
// the next session on this computer sends it.
|
|
117
|
+
if (agent === "claude-code") await recordStartCheckpoint(env, result.snapshot, agent);
|
|
118
|
+
const output = { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.note } };
|
|
119
|
+
// The person sees these three too: the model alone may never mention that Hypher is not there.
|
|
120
|
+
return PERSON_SEES.has(result.status) ? { systemMessage: result.note, ...output } : output;
|
|
80
121
|
}
|
|
81
122
|
|
|
82
123
|
async function stop(agent, env, input, now = Date.now()) {
|
|
83
|
-
const found = linkedSnapshot(env, input.cwd || process.cwd());
|
|
124
|
+
const found = await linkedSnapshot(env, input.cwd || process.cwd());
|
|
84
125
|
if (!found) return null;
|
|
85
126
|
const { snapshot } = found;
|
|
86
127
|
recordCheckpoint(env, snapshot, "Stop", now, agent);
|
|
87
|
-
|
|
128
|
+
const cloud = cloudMode(env);
|
|
129
|
+
if (cloud) {
|
|
130
|
+
await uploadNow(env, snapshot, { always: true, now });
|
|
131
|
+
} else if (shouldFlush(env, now)) {
|
|
88
132
|
await flushOutbox(createClient({ env, timeoutMs: HOOK_TIMEOUT_MS }), { env, now }).catch(() => null);
|
|
89
133
|
}
|
|
90
134
|
|
|
@@ -103,14 +147,34 @@ async function stop(agent, env, input, now = Date.now()) {
|
|
|
103
147
|
updateWorktreeState(env, snapshot.root, (current) => withBranchState(current, snapshot.branch, {
|
|
104
148
|
askedAt: now, askedTree: snapshot.tree, blockedLast: true,
|
|
105
149
|
}));
|
|
106
|
-
return { decision: "block", reason: sealRequest(!state.proposal, state.ledger?.on === true) };
|
|
150
|
+
return { decision: "block", reason: sealRequest(!state.proposal, state.ledger?.on === true, cloud) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A tool just ran. Only Claude Code has this hook. The payload carries the tool's input, which can be file
|
|
155
|
+
* contents, so nothing but `cwd` and `tool_name` is read from it. A call inside its window answers from one
|
|
156
|
+
* small file, with no git call. Queued, not uploaded: a laptop's next session sends it.
|
|
157
|
+
*/
|
|
158
|
+
async function postToolUse(agent, env, input, now = Date.now()) {
|
|
159
|
+
if (agent !== "claude-code") return null;
|
|
160
|
+
const kind = toolKind(input.tool_name);
|
|
161
|
+
if (!kind) return null;
|
|
162
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
163
|
+
if (!claimCheckpoint(env, cwd, kind, now)) return null;
|
|
164
|
+
const found = await linkedSnapshot(env, cwd);
|
|
165
|
+
if (!found) return null;
|
|
166
|
+
recordCheckpoint(env, found.snapshot, "PostToolUse", now, agent);
|
|
167
|
+
// In a cloud session the checkpoint leaves at once, at most once a minute; on a laptop the next session sends it.
|
|
168
|
+
if (cloudMode(env)) await uploadNow(env, found.snapshot, { now });
|
|
169
|
+
return null;
|
|
107
170
|
}
|
|
108
171
|
|
|
109
172
|
async function checkpointAndUpload(agent, env, input, event, now = Date.now()) {
|
|
110
|
-
const found = linkedSnapshot(env, input.cwd || process.cwd());
|
|
173
|
+
const found = await linkedSnapshot(env, input.cwd || process.cwd());
|
|
111
174
|
if (!found) return null;
|
|
112
175
|
recordCheckpoint(env, found.snapshot, event, now, agent);
|
|
113
|
-
|
|
176
|
+
if (cloudMode(env)) await uploadNow(env, found.snapshot, { always: true, now });
|
|
177
|
+
else await flushOutbox(createClient({ env, timeoutMs: HOOK_TIMEOUT_MS }), { env, now }).catch(() => null);
|
|
114
178
|
return null;
|
|
115
179
|
}
|
|
116
180
|
|
|
@@ -129,6 +193,8 @@ export async function handleAgentHook(agent, event, input, env) {
|
|
|
129
193
|
return await sessionStart(agent, env, input);
|
|
130
194
|
case "Stop":
|
|
131
195
|
return await stop(agent, env, input);
|
|
196
|
+
case "PostToolUse":
|
|
197
|
+
return await postToolUse(agent, env, input);
|
|
132
198
|
case "PreCompact":
|
|
133
199
|
case "SessionEnd":
|
|
134
200
|
return await checkpointAndUpload(agent, env, input, event);
|
package/src/outbox.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { mkdirSync, readdirSync } from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
10
|
import { paths } from "./env.mjs";
|
|
11
|
-
import { readJson, removeFile, writeJson } from "./fsutil.mjs";
|
|
11
|
+
import { readJson, removeFile, sha256, writeJson } from "./fsutil.mjs";
|
|
12
12
|
import { HypherUnavailable } from "./api.mjs";
|
|
13
13
|
|
|
14
14
|
export const FLUSH_AGE_MS = 5 * 60_000;
|
|
@@ -51,6 +51,41 @@ export function enqueue(input, { env = process.env, now = Date.now() } = {}) {
|
|
|
51
51
|
return item;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function uploadedFile(env, key) {
|
|
55
|
+
return path.join(paths(env).stateDir, "uploaded", `${sha256(String(key)).slice(0, 20)}.json`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The newest checkpoint Hypher accepted for a key, kept so `hypher status` can say it was uploaded. Never throws. */
|
|
59
|
+
function rememberUploaded(env, item, now) {
|
|
60
|
+
try {
|
|
61
|
+
const file = uploadedFile(env, item.key);
|
|
62
|
+
const before = readJson(file, null);
|
|
63
|
+
if (typeof before?.body?.capturedAt === "number" && before.body.capturedAt > item.body?.capturedAt) return;
|
|
64
|
+
writeJson(file, { key: item.key, uploadedAt: now, body: item.body }, { mode: 0o600 });
|
|
65
|
+
} catch {
|
|
66
|
+
// Only a convenience for `hypher status`.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The newest checkpoint on this computer for a repository and branch: waiting in the outbox, or the last one
|
|
72
|
+
* Hypher accepted. `null` when there is none.
|
|
73
|
+
* @returns {{ state: "queued" | "uploaded", body: any, queuedAt?: number, uploadedAt?: number } | null}
|
|
74
|
+
*/
|
|
75
|
+
export function newestCheckpoint(env, repository, branch) {
|
|
76
|
+
const key = `${String(repository).toLowerCase()}#${branch}`;
|
|
77
|
+
const queued = listOutbox(env)
|
|
78
|
+
.filter((entry) => entry.item.kind === "checkpoint" && entry.item.key === key)
|
|
79
|
+
.map((entry) => ({ state: "queued", body: entry.item.body, queuedAt: entry.item.queuedAt }))
|
|
80
|
+
.sort((a, b) => (b.body?.capturedAt ?? 0) - (a.body?.capturedAt ?? 0))[0];
|
|
81
|
+
const saved = readJson(uploadedFile(env, key), null);
|
|
82
|
+
const uploaded = typeof saved?.body?.capturedAt === "number"
|
|
83
|
+
? { state: "uploaded", body: saved.body, uploadedAt: saved.uploadedAt }
|
|
84
|
+
: null;
|
|
85
|
+
if (queued && (!uploaded || (queued.body?.capturedAt ?? 0) >= uploaded.body.capturedAt)) return queued;
|
|
86
|
+
return uploaded ?? queued ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
export function oldestAgeMs(env = process.env, now = Date.now()) {
|
|
55
90
|
const [oldest] = listOutbox(env);
|
|
56
91
|
return oldest ? now - oldest.item.queuedAt : 0;
|
|
@@ -75,6 +110,7 @@ export async function flushOutbox(client, { env = process.env, now = Date.now(),
|
|
|
75
110
|
try {
|
|
76
111
|
const response = await client.send("POST", "/api/cli/checkpoints", { body: item.body });
|
|
77
112
|
if (response.ok) {
|
|
113
|
+
rememberUploaded(env, item, now);
|
|
78
114
|
removeFile(file);
|
|
79
115
|
sent += 1;
|
|
80
116
|
continue;
|
package/src/repoKit.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repo kit (M3 slice 5): what `hypher init --repo` writes into a repository so a Claude Code cloud session runs
|
|
3
|
+
* Hypher's hooks. A cloud session runs the repository's `.claude/settings.json` hooks, not the user's, and a fresh
|
|
4
|
+
* container has no `hypher`, so the kit is three files a person commits:
|
|
5
|
+
*
|
|
6
|
+
* - `.claude/settings.json`: Hypher's hooks (the same events as the laptop's), each running `hypher.sh <Event>`,
|
|
7
|
+
* merged into whatever is there. They are found again by the script's path in their command.
|
|
8
|
+
* - `.claude/hooks/hypher.sh`: does nothing outside a cloud session, installs the exact CLI version that wrote it when
|
|
9
|
+
* `hypher` is missing, says which variable to add when the key is missing, else runs `hypher hook claude-code`.
|
|
10
|
+
* - `AGENTS.md`: the marked block `init` writes today.
|
|
11
|
+
*
|
|
12
|
+
* `init --repo` never commits or pushes, and never writes `.mcp.json`: a project-level `hypher` entry overrides the
|
|
13
|
+
* user's own, and a container has no browser for the sign-in. What was there before is kept in the repository's git
|
|
14
|
+
* directory (never committed), so `uninstall --repo` can give a file back byte for byte; without that record (a fresh
|
|
15
|
+
* clone) it removes only Hypher's entries and deletes nothing it cannot prove it created.
|
|
16
|
+
*/
|
|
17
|
+
import { chmodSync, existsSync, statSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { removeBlock } from "./agentsBlock.mjs";
|
|
20
|
+
import { CLAUDE_HOOKS } from "./claude.mjs";
|
|
21
|
+
import { installedEvents, installedSpecs, mergeHooks, removeHooks } from "./hookConfig.mjs";
|
|
22
|
+
import { readBytes, readJson, removeFile, writeAtomic, writeJson } from "./fsutil.mjs";
|
|
23
|
+
import { undoTrackedFile, writeTrackedFile } from "./manifest.mjs";
|
|
24
|
+
import { addRepositoryBlock } from "./repoBlock.mjs";
|
|
25
|
+
import { git } from "../vendor/hypher-git.mjs";
|
|
26
|
+
|
|
27
|
+
export const KIT_SCRIPT = ".claude/hooks/hypher.sh";
|
|
28
|
+
export const KIT_SETTINGS = ".claude/settings.json";
|
|
29
|
+
export const KIT_AGENTS = "AGENTS.md";
|
|
30
|
+
/** What marks a hook command as Hypher's: the script it runs. */
|
|
31
|
+
export const KIT_TAG = KIT_SCRIPT;
|
|
32
|
+
const SCRIPT_MARKER = "`hypher init --repo`";
|
|
33
|
+
/** SessionStart may install the CLI on a first run; the other hooks keep slice 3's timeouts. */
|
|
34
|
+
export const KIT_SESSION_START_TIMEOUT = 120;
|
|
35
|
+
|
|
36
|
+
export const REPO_HOOKS = CLAUDE_HOOKS.map((spec) => (spec.event === "SessionStart" ? { ...spec, timeout: KIT_SESSION_START_TIMEOUT } : spec));
|
|
37
|
+
|
|
38
|
+
/** The command a settings entry runs. CLAUDE_PROJECT_DIR is the repository root; `.` covers a version that does not set it. */
|
|
39
|
+
export function kitCommand(_shim, _tag, event) {
|
|
40
|
+
return `"\${CLAUDE_PROJECT_DIR:-.}/${KIT_SCRIPT}" ${event}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const VERSION_SHAPE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/;
|
|
44
|
+
const PACKAGE = "@litterthanlit/hypher-cli";
|
|
45
|
+
|
|
46
|
+
/** The script, pinned to one exact version. Never "latest": this file runs on every clone. */
|
|
47
|
+
export function kitScript(version) {
|
|
48
|
+
if (!VERSION_SHAPE.test(String(version))) throw new Error(`not an exact version: ${version}`);
|
|
49
|
+
const pinned = `${PACKAGE}@${version}`;
|
|
50
|
+
return [
|
|
51
|
+
"#!/bin/sh",
|
|
52
|
+
`# Hypher project memory for Claude Code cloud sessions. Written by ${SCRIPT_MARKER}; removed by \`hypher uninstall --repo\`.`,
|
|
53
|
+
"# It does nothing outside a cloud session, so a laptop with Hypher's user-level hooks never fires twice.",
|
|
54
|
+
'[ "$CLAUDE_CODE_REMOTE" = "true" ] || exit 0',
|
|
55
|
+
'if [ -z "$HYPHER_API_KEY" ]; then',
|
|
56
|
+
' [ "$1" = "SessionStart" ] && echo "Hypher: add HYPHER_API_KEY (a Hypher API key) to this cloud environment to turn on project memory. Nothing is sent without it."',
|
|
57
|
+
" exit 0",
|
|
58
|
+
"fi",
|
|
59
|
+
"if ! command -v hypher >/dev/null 2>&1; then",
|
|
60
|
+
" # The exact version that wrote this file: a floating tag would run new code on every clone.",
|
|
61
|
+
` npm install -g --silent --no-audit --no-fund ${pinned} </dev/null >/dev/null 2>&1`,
|
|
62
|
+
" if ! command -v hypher >/dev/null 2>&1; then",
|
|
63
|
+
` [ "$1" = "SessionStart" ] && echo "Hypher: could not install ${pinned} (npm install failed); starting without Hypher."`,
|
|
64
|
+
" exit 0",
|
|
65
|
+
" fi",
|
|
66
|
+
"fi",
|
|
67
|
+
'exec hypher hook claude-code "$1"',
|
|
68
|
+
"",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The version a script pins, or null when it is not Hypher's script. */
|
|
73
|
+
export function kitPinnedVersion(text) {
|
|
74
|
+
return String(text ?? "").match(/npm install [^\n]*@litterthanlit\/hypher-cli@([0-9A-Za-z.+-]+)/)?.[1] ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------- settings.json
|
|
78
|
+
|
|
79
|
+
export function mergeRepoHooks(settings) {
|
|
80
|
+
return mergeHooks(settings, REPO_HOOKS, "", KIT_TAG, kitCommand);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function removeRepoHooks(settings) {
|
|
84
|
+
return removeHooks(settings, KIT_TAG);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isPlainObject(value) {
|
|
88
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when every hook is there with exactly the command and timeout `init --repo` writes: nothing to change. */
|
|
92
|
+
export function repoHooksCurrent(settings) {
|
|
93
|
+
return REPO_HOOKS.every((spec) =>
|
|
94
|
+
(settings?.hooks?.[spec.event] ?? []).some((group) =>
|
|
95
|
+
(group?.matcher ?? undefined) === (spec.matcher ?? undefined)
|
|
96
|
+
&& (group?.hooks ?? []).some((hook) => hook?.type === "command" && hook.command === kitCommand("", KIT_TAG, spec.event) && hook.timeout === spec.timeout)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The events with a Hypher repo hook, each once. */
|
|
100
|
+
export function installedRepoEvents(settings) {
|
|
101
|
+
return installedEvents(settings, REPO_HOOKS, KIT_TAG);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The hooks the kit needs that are not in these settings, by event (and matcher for an event with two). */
|
|
105
|
+
export function missingRepoHooks(settings) {
|
|
106
|
+
const present = new Set(installedSpecs(settings, REPO_HOOKS, KIT_TAG));
|
|
107
|
+
return REPO_HOOKS.filter((spec) => !present.has(spec)).map((spec) => (REPO_HOOKS.filter((other) => other.event === spec.event).length > 1 ? `${spec.event} (${spec.matcher})` : spec.event));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The indentation a JSON file uses, so a merge does not reformat the builder's file. */
|
|
111
|
+
export function detectIndent(text) {
|
|
112
|
+
const match = String(text ?? "").match(/^([ \t]+)\S/m);
|
|
113
|
+
return match ? match[1] : " ";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Render settings the way the original was written: its indentation, and whether it ended with a newline. */
|
|
117
|
+
export function renderRepoSettings(value, original) {
|
|
118
|
+
const endsWithNewline = original === undefined || original === null || original === "" || /\n$/.test(original);
|
|
119
|
+
return `${JSON.stringify(value, null, detectIndent(original))}${endsWithNewline ? "\n" : ""}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Settings text without Hypher's repo hooks, keeping everything else and its formatting. `""` when nothing else is
|
|
124
|
+
* left and `created` says Hypher made the file (so it can be deleted); `null` when the text is not JSON we can
|
|
125
|
+
* rewrite.
|
|
126
|
+
*/
|
|
127
|
+
export function cleanRepoSettingsText(text, { created = false } = {}) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(text);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
if (!isPlainObject(parsed)) return null;
|
|
135
|
+
const cleaned = removeRepoHooks(parsed);
|
|
136
|
+
// Nothing of Hypher's in it: the text is not ours to reformat.
|
|
137
|
+
if (JSON.stringify(cleaned) === JSON.stringify(parsed)) return text;
|
|
138
|
+
if (Object.keys(cleaned).length === 0) return created ? "" : "{}\n";
|
|
139
|
+
return renderRepoSettings(cleaned, text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------- the record of what was there
|
|
143
|
+
|
|
144
|
+
/** Where the original bytes are kept: the repository's git directory, never committed or pushed. */
|
|
145
|
+
export function kitRecordFile(root) {
|
|
146
|
+
const common = git(root, ["rev-parse", "--git-common-dir"]);
|
|
147
|
+
return common ? path.resolve(root, common, "hypher-repo-kit.json") : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readRecord(file) {
|
|
151
|
+
const record = file ? readJson(file, null) : null;
|
|
152
|
+
return record && Array.isArray(record.entries) ? record : { version: 1, entries: [] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function saveRecord(file, record) {
|
|
156
|
+
if (!file) return;
|
|
157
|
+
if (record.entries.length > 0) writeJson(file, record, { mode: 0o600 });
|
|
158
|
+
else removeFile(file);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------- install, uninstall, inspect
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write the kit into the repository at `root`. Never commits, never pushes.
|
|
165
|
+
* @param {string} root
|
|
166
|
+
* @param {string} version the exact CLI version the script pins
|
|
167
|
+
* @returns {{ ok: true, changes: [string, string][], files: string[] } | { ok: false, error: string }}
|
|
168
|
+
*/
|
|
169
|
+
export function installRepoKit(root, version) {
|
|
170
|
+
const recordFile = kitRecordFile(root);
|
|
171
|
+
const record = readRecord(recordFile);
|
|
172
|
+
const changes = [];
|
|
173
|
+
|
|
174
|
+
const settingsFile = path.join(root, KIT_SETTINGS);
|
|
175
|
+
const original = readBytes(settingsFile)?.toString("utf8");
|
|
176
|
+
let settings = {};
|
|
177
|
+
if (original !== undefined && original.trim() !== "") {
|
|
178
|
+
try {
|
|
179
|
+
settings = JSON.parse(original);
|
|
180
|
+
} catch {
|
|
181
|
+
return { ok: false, error: `${KIT_SETTINGS} is not valid JSON, so Hypher left it alone. Fix it, then run \`hypher init --repo\` again.` };
|
|
182
|
+
}
|
|
183
|
+
if (!isPlainObject(settings)) return { ok: false, error: `${KIT_SETTINGS} is not a JSON object, so Hypher left it alone.` };
|
|
184
|
+
}
|
|
185
|
+
if (!repoHooksCurrent(settings)) {
|
|
186
|
+
const change = writeTrackedFile(record, settingsFile, renderRepoSettings(mergeRepoHooks(settings), original), "repo-claude-settings");
|
|
187
|
+
if (change !== "unchanged") changes.push([KIT_SETTINGS, `+ hooks ${installedRepoEvents(mergeRepoHooks(settings)).join(", ")}, each running ${KIT_SCRIPT}`]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const scriptFile = path.join(root, KIT_SCRIPT);
|
|
191
|
+
const script = writeTrackedFile(record, scriptFile, kitScript(version), "repo-script");
|
|
192
|
+
try {
|
|
193
|
+
chmodSync(scriptFile, 0o755);
|
|
194
|
+
} catch {
|
|
195
|
+
// Not fatal; doctor says when the script is not executable.
|
|
196
|
+
}
|
|
197
|
+
if (script !== "unchanged") changes.push([KIT_SCRIPT, `${script === "added" ? "+" : "updated:"} ${PACKAGE}@${version} pinned, runs only in a cloud session`]);
|
|
198
|
+
|
|
199
|
+
const block = addRepositoryBlock(record, root);
|
|
200
|
+
if (block.change !== "unchanged" && block.change !== "skipped") changes.push([KIT_AGENTS, "+ Hypher block (resume first, save at the end)"]);
|
|
201
|
+
|
|
202
|
+
saveRecord(recordFile, record);
|
|
203
|
+
const files = [KIT_SETTINGS, KIT_SCRIPT, ...(existsSync(path.join(root, KIT_AGENTS)) ? [KIT_AGENTS] : [])];
|
|
204
|
+
return { ok: true, changes, files, agents: block.change };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Take the kit out. A file Hypher recorded, and nobody changed since, goes back byte for byte (or is deleted if Hypher
|
|
209
|
+
* created it). A file changed since keeps those changes and loses only Hypher's entries. With no record, only
|
|
210
|
+
* Hypher's entries come out and no file is deleted.
|
|
211
|
+
* @returns {string[]} what was done, one line each
|
|
212
|
+
*/
|
|
213
|
+
export function uninstallRepoKit(root) {
|
|
214
|
+
const recordFile = kitRecordFile(root);
|
|
215
|
+
const record = readRecord(recordFile);
|
|
216
|
+
const undone = [];
|
|
217
|
+
// The reverse of the install order, so a folder is empty by the time its last file is gone.
|
|
218
|
+
const targets = [
|
|
219
|
+
[KIT_AGENTS, () => removeBlock],
|
|
220
|
+
// Edited since, and not Hypher's to delete if it was there before: leave it.
|
|
221
|
+
[KIT_SCRIPT, (entry) => (text) => (entry?.originalBase64 === null && text.includes(SCRIPT_MARKER) ? "" : null)],
|
|
222
|
+
[KIT_SETTINGS, (entry) => (text) => cleanRepoSettingsText(text, { created: entry?.originalBase64 === null })],
|
|
223
|
+
];
|
|
224
|
+
for (const [rel, cleaner] of targets) {
|
|
225
|
+
const file = path.join(root, rel);
|
|
226
|
+
const entry = record.entries.find((item) => item.kind === "file" && item.path === file);
|
|
227
|
+
const current = readBytes(file)?.toString("utf8");
|
|
228
|
+
if (entry) {
|
|
229
|
+
const result = undoTrackedFile(entry, cleaner(entry));
|
|
230
|
+
record.entries = record.entries.filter((item) => item !== entry);
|
|
231
|
+
undone.push(`${rel}: ${result === "cleaned" ? "kept your edits, removed Hypher's entries" : result}`);
|
|
232
|
+
} else if (current !== undefined) {
|
|
233
|
+
// Not recorded here (a fresh clone): take out Hypher's entries, delete nothing.
|
|
234
|
+
if (rel === KIT_SCRIPT) {
|
|
235
|
+
if (current.includes(SCRIPT_MARKER)) {
|
|
236
|
+
removeFile(file);
|
|
237
|
+
undone.push(`${rel}: removed`);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const cleaned = cleaner(null)(current);
|
|
242
|
+
if (cleaned !== null && cleaned !== current) {
|
|
243
|
+
writeAtomic(file, cleaned);
|
|
244
|
+
undone.push(`${rel}: removed Hypher's entries`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
saveRecord(recordFile, record);
|
|
249
|
+
return undone;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What a repository has of the kit, for `hypher doctor`.
|
|
254
|
+
* @returns {{ state: "none" | "present" | "partial", version: string | null, events: string[], problems: string[] }}
|
|
255
|
+
*/
|
|
256
|
+
export function inspectRepoKit(root) {
|
|
257
|
+
const scriptFile = path.join(root, KIT_SCRIPT);
|
|
258
|
+
const script = readBytes(scriptFile)?.toString("utf8");
|
|
259
|
+
const settings = readJson(path.join(root, KIT_SETTINGS), {}) ?? {};
|
|
260
|
+
const events = installedRepoEvents(settings);
|
|
261
|
+
if (script === undefined && events.length === 0) return { state: "none", version: null, events, problems: [] };
|
|
262
|
+
const problems = [];
|
|
263
|
+
if (script === undefined) problems.push(`${KIT_SCRIPT} is missing`);
|
|
264
|
+
else {
|
|
265
|
+
if (!script.includes(SCRIPT_MARKER)) problems.push(`${KIT_SCRIPT} is not Hypher's script`);
|
|
266
|
+
try {
|
|
267
|
+
if ((statSync(scriptFile).mode & 0o111) === 0) problems.push(`${KIT_SCRIPT} is not executable (run chmod +x, then commit it)`);
|
|
268
|
+
} catch {
|
|
269
|
+
// Gone between the read and the stat.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const missing = missingRepoHooks(settings);
|
|
273
|
+
if (missing.length) problems.push(`hooks missing in ${KIT_SETTINGS}: ${missing.join(", ")}`);
|
|
274
|
+
return { state: problems.length ? "partial" : "present", version: script === undefined ? null : kitPinnedVersion(script), events, problems };
|
|
275
|
+
}
|