@litterthanlit/hypher-cli 0.1.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 +13 -0
- package/bin/hypher.mjs +4 -0
- package/package.json +25 -0
- package/src/agentsBlock.mjs +60 -0
- package/src/api.mjs +103 -0
- package/src/claude.mjs +51 -0
- package/src/cli.mjs +101 -0
- package/src/codex.mjs +305 -0
- package/src/commands/ack.mjs +38 -0
- package/src/commands/doctor.mjs +130 -0
- package/src/commands/hook.mjs +57 -0
- package/src/commands/init.mjs +252 -0
- package/src/commands/link.mjs +65 -0
- package/src/commands/mcp.mjs +304 -0
- package/src/commands/resume.mjs +25 -0
- package/src/commands/seal.mjs +85 -0
- package/src/commands/status.mjs +38 -0
- package/src/commands/uninstall.mjs +131 -0
- package/src/config.mjs +48 -0
- package/src/credentials.mjs +134 -0
- package/src/device.mjs +62 -0
- package/src/env.mjs +75 -0
- package/src/fsutil.mjs +70 -0
- package/src/hookConfig.mjs +86 -0
- package/src/hooks/index.mjs +134 -0
- package/src/manifest.mjs +126 -0
- package/src/outbox.mjs +103 -0
- package/src/repoBlock.mjs +28 -0
- package/src/secrets.mjs +2 -0
- package/src/session.mjs +211 -0
- package/src/snapshot.mjs +56 -0
- package/src/state.mjs +53 -0
- package/src/tools.mjs +60 -0
- package/vendor/handoffNote.mjs +142 -0
- package/vendor/handoffSeal.mjs +149 -0
- package/vendor/hypher-git.mjs +429 -0
- package/vendor/secretScan.mjs +47 -0
- package/vendor/structuredHandoff.mjs +796 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook credential: a named, revocable Hypher API key.
|
|
3
|
+
*
|
|
4
|
+
* Stores: the macOS Keychain (through `security -i`, so the key goes in over
|
|
5
|
+
* stdin and never into an argument list), libsecret (`secret-tool store`
|
|
6
|
+
* reads stdin), else a 0600 file. HYPHER_CREDENTIAL_STORE picks one for
|
|
7
|
+
* tests (`file`, `keychain`, `libsecret`, `none`). With no store at all, as
|
|
8
|
+
* in a container, HYPHER_API_KEY is read. No command prints the key.
|
|
9
|
+
*
|
|
10
|
+
* Self-contained on purpose: the Cursor plugin vendors this file
|
|
11
|
+
* (extensions/cursor/scripts/vendor/hypher-credentials.mjs, refreshed with
|
|
12
|
+
* `node script/sync-hypher-git.mjs`), so its hooks use the same key.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
const SERVICE = "hypher-cli";
|
|
20
|
+
const ACCOUNT = "api-key";
|
|
21
|
+
const KEY_SHAPE = /^hyp_[A-Za-z0-9]{16,128}$/;
|
|
22
|
+
|
|
23
|
+
/** ~/.hypher/credentials, honoring HYPHER_HOME and HOME. */
|
|
24
|
+
export function credentialsFile(env = process.env) {
|
|
25
|
+
const home = env.HOME || os.homedir();
|
|
26
|
+
return path.join(env.HYPHER_HOME || path.join(home, ".hypher"), "credentials");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function onPath(bin, env) {
|
|
30
|
+
const result = spawnSync("sh", ["-c", `command -v ${bin}`], { env, encoding: "utf8" });
|
|
31
|
+
return result.status === 0 && Boolean(result.stdout.trim());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {NodeJS.ProcessEnv} env @returns {"keychain" | "libsecret" | "file" | "none"} */
|
|
35
|
+
export function credentialStore(env = process.env) {
|
|
36
|
+
const chosen = String(env.HYPHER_CREDENTIAL_STORE || "").trim();
|
|
37
|
+
if (["keychain", "libsecret", "file", "none"].includes(chosen)) return /** @type {any} */ (chosen);
|
|
38
|
+
if (process.platform === "darwin" && onPath("security", env)) return "keychain";
|
|
39
|
+
if (process.platform === "linux" && env.DBUS_SESSION_BUS_ADDRESS && onPath("secret-tool", env)) return "libsecret";
|
|
40
|
+
return "file";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The human step a store needs, as one line, or null. */
|
|
44
|
+
export function credentialPromptLine(store) {
|
|
45
|
+
if (store === "keychain") return "macOS may ask once to let Hypher use the keychain. Click Always Allow.";
|
|
46
|
+
if (store === "libsecret") return "Your keyring may ask once to unlock. Unlock it to store the Hypher key.";
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isApiKeyShape(value) {
|
|
51
|
+
return KEY_SHAPE.test(String(value ?? ""));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readFileKey(env) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(credentialsFile(env), "utf8"))?.apiKey ?? null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeFileKey(apiKey, env) {
|
|
63
|
+
const file = credentialsFile(env);
|
|
64
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
65
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
66
|
+
writeFileSync(tmp, `${JSON.stringify({ apiKey }, null, 2)}\n`, { mode: 0o600 });
|
|
67
|
+
chmodSync(tmp, 0o600);
|
|
68
|
+
renameSync(tmp, file);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} apiKey
|
|
73
|
+
* @param {NodeJS.ProcessEnv} env
|
|
74
|
+
* @returns {{ ok: boolean, store: string, error?: string }}
|
|
75
|
+
*/
|
|
76
|
+
export function saveCredential(apiKey, env = process.env) {
|
|
77
|
+
if (!isApiKeyShape(apiKey)) return { ok: false, store: "none", error: "The server returned a key in an unexpected shape." };
|
|
78
|
+
const store = credentialStore(env);
|
|
79
|
+
if (store === "none") return { ok: false, store, error: "No credential store. Set HYPHER_API_KEY in the hook environment." };
|
|
80
|
+
if (store === "keychain") {
|
|
81
|
+
const result = spawnSync("security", ["-i"], {
|
|
82
|
+
input: `add-generic-password -U -s ${SERVICE} -a ${ACCOUNT} -l "Hypher CLI" -w ${apiKey}\n`,
|
|
83
|
+
env,
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
timeout: 60_000,
|
|
86
|
+
});
|
|
87
|
+
return result.status === 0 ? { ok: true, store } : { ok: false, store, error: "The keychain refused the key." };
|
|
88
|
+
}
|
|
89
|
+
if (store === "libsecret") {
|
|
90
|
+
const result = spawnSync("secret-tool", ["store", "--label=Hypher CLI", "service", SERVICE, "account", ACCOUNT], {
|
|
91
|
+
input: apiKey,
|
|
92
|
+
env,
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
timeout: 60_000,
|
|
95
|
+
});
|
|
96
|
+
return result.status === 0 ? { ok: true, store } : { ok: false, store, error: "The keyring refused the key." };
|
|
97
|
+
}
|
|
98
|
+
writeFileKey(apiKey, env);
|
|
99
|
+
return { ok: true, store };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @returns {{ apiKey: string | null, source: string }} */
|
|
103
|
+
export function loadCredential(env = process.env) {
|
|
104
|
+
const store = credentialStore(env);
|
|
105
|
+
let apiKey = null;
|
|
106
|
+
if (store === "keychain") {
|
|
107
|
+
const result = spawnSync("security", ["find-generic-password", "-s", SERVICE, "-a", ACCOUNT, "-w"], {
|
|
108
|
+
env, encoding: "utf8", timeout: 5_000,
|
|
109
|
+
});
|
|
110
|
+
if (result.status === 0) apiKey = result.stdout.trim();
|
|
111
|
+
} else if (store === "libsecret") {
|
|
112
|
+
const result = spawnSync("secret-tool", ["lookup", "service", SERVICE, "account", ACCOUNT], {
|
|
113
|
+
env, encoding: "utf8", timeout: 5_000,
|
|
114
|
+
});
|
|
115
|
+
if (result.status === 0) apiKey = result.stdout.trim();
|
|
116
|
+
} else if (store === "file") {
|
|
117
|
+
apiKey = readFileKey(env);
|
|
118
|
+
}
|
|
119
|
+
if (isApiKeyShape(apiKey)) return { apiKey, source: store };
|
|
120
|
+
const fromEnv = String(env.HYPHER_API_KEY ?? "").trim();
|
|
121
|
+
if (isApiKeyShape(fromEnv)) return { apiKey: fromEnv, source: "HYPHER_API_KEY" };
|
|
122
|
+
return { apiKey: null, source: store };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function deleteCredential(env = process.env) {
|
|
126
|
+
const store = credentialStore(env);
|
|
127
|
+
if (store === "keychain") {
|
|
128
|
+
spawnSync("security", ["delete-generic-password", "-s", SERVICE, "-a", ACCOUNT], { env, stdio: "ignore", timeout: 10_000 });
|
|
129
|
+
} else if (store === "libsecret") {
|
|
130
|
+
spawnSync("secret-tool", ["clear", "service", SERVICE, "account", ACCOUNT], { env, stdio: "ignore", timeout: 10_000 });
|
|
131
|
+
}
|
|
132
|
+
rmSync(credentialsFile(env), { force: true });
|
|
133
|
+
return store;
|
|
134
|
+
}
|
package/src/device.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-click connect, device-style (RFC 8628). The CLI shows a short user code
|
|
3
|
+
* and a link, and polls with a separate device code it never shows. The
|
|
4
|
+
* builder compares the code in the browser and clicks Approve. The browser
|
|
5
|
+
* opens only when a terminal is attached; otherwise the link is printed.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { machineName } from "./env.mjs";
|
|
9
|
+
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function openBrowser(url, env) {
|
|
15
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
16
|
+
try {
|
|
17
|
+
const child = spawn(opener, [url], { env, stdio: "ignore", detached: true });
|
|
18
|
+
child.on("error", () => {});
|
|
19
|
+
child.unref();
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {ReturnType<import("./api.mjs").createClient>} client
|
|
28
|
+
* @param {{ purpose: "connect" | "link", repository?: string | null, io: any, noBrowser?: boolean }} options
|
|
29
|
+
* @returns {Promise<{ ok: true, result: any } | { ok: false, error: string }>}
|
|
30
|
+
*/
|
|
31
|
+
export async function runDeviceFlow(client, { purpose, repository, io, noBrowser }) {
|
|
32
|
+
const started = await client.send("POST", "/api/cli/device", {
|
|
33
|
+
body: { purpose, machineName: machineName(io.env), ...(repository ? { repository } : {}) },
|
|
34
|
+
auth: purpose === "link",
|
|
35
|
+
});
|
|
36
|
+
if (!started.ok || !started.json?.deviceCode) {
|
|
37
|
+
return { ok: false, error: started.json?.error ?? `Hypher could not start the connection (HTTP ${started.status}).` };
|
|
38
|
+
}
|
|
39
|
+
const { deviceCode, userCode, verificationUri, verificationUriComplete, expiresIn = 600 } = started.json;
|
|
40
|
+
let interval = Math.max(1, Number(started.json.interval) || 5) * 1000;
|
|
41
|
+
const label = purpose === "link" ? `Link ${repository}` : "Connect this computer";
|
|
42
|
+
io.out(` ${label} → ${verificationUri} code ${userCode}`);
|
|
43
|
+
io.out(" Open the link, check that the code matches, and click Approve.");
|
|
44
|
+
if (io.isTTY && !noBrowser) openBrowser(verificationUriComplete ?? verificationUri, io.env);
|
|
45
|
+
|
|
46
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
47
|
+
while (Date.now() < deadline) {
|
|
48
|
+
await sleep(interval);
|
|
49
|
+
const polled = await client.send("POST", "/api/cli/device/token", { body: { deviceCode }, auth: false });
|
|
50
|
+
if (polled.ok && polled.json?.status === "approved") return { ok: true, result: polled.json };
|
|
51
|
+
const error = polled.json?.error;
|
|
52
|
+
if (error === "authorization_pending") continue;
|
|
53
|
+
if (error === "slow_down") {
|
|
54
|
+
interval += 5_000;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (error === "access_denied") return { ok: false, error: "The request was cancelled in the browser." };
|
|
58
|
+
if (error === "expired_token") return { ok: false, error: "The code expired. Run the command again." };
|
|
59
|
+
return { ok: false, error: polled.json?.error_description ?? error ?? `Unexpected answer (HTTP ${polled.status}).` };
|
|
60
|
+
}
|
|
61
|
+
return { ok: false, error: "The code expired. Run the command again." };
|
|
62
|
+
}
|
package/src/env.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where everything lives. Every path honors HOME, HYPHER_HOME,
|
|
3
|
+
* CLAUDE_CONFIG_DIR, and CODEX_HOME, so tests and the rehearsal never touch
|
|
4
|
+
* the builder's real configuration.
|
|
5
|
+
*/
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export const CLI_VERSION = "0.1.0";
|
|
10
|
+
export const DEFAULT_API_BASE = "https://www.hypher.app";
|
|
11
|
+
export const INTEGRATIONS_URL = "https://hypher.app/app/settings/integrations";
|
|
12
|
+
export const API_KEYS_URL = "https://hypher.app/app/settings/api-keys";
|
|
13
|
+
|
|
14
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
15
|
+
export function paths(env = process.env) {
|
|
16
|
+
const home = env.HOME || os.homedir();
|
|
17
|
+
const hypherHome = env.HYPHER_HOME || path.join(home, ".hypher");
|
|
18
|
+
const claudeDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
|
|
19
|
+
const codexHome = env.CODEX_HOME || path.join(home, ".codex");
|
|
20
|
+
return {
|
|
21
|
+
home,
|
|
22
|
+
hypherHome,
|
|
23
|
+
binDir: path.join(hypherHome, "bin"),
|
|
24
|
+
shim: path.join(hypherHome, "bin", "hypher"),
|
|
25
|
+
libDir: path.join(hypherHome, "lib"),
|
|
26
|
+
config: path.join(hypherHome, "config.json"),
|
|
27
|
+
links: path.join(hypherHome, "links.json"),
|
|
28
|
+
manifest: path.join(hypherHome, "manifest.json"),
|
|
29
|
+
credentials: path.join(hypherHome, "credentials"),
|
|
30
|
+
stateDir: path.join(hypherHome, "state"),
|
|
31
|
+
outboxDir: path.join(hypherHome, "state", "outbox"),
|
|
32
|
+
sessionsDir: path.join(hypherHome, "state", "sessions"),
|
|
33
|
+
repoStateDir: path.join(hypherHome, "state", "repos"),
|
|
34
|
+
sentLog: path.join(hypherHome, "state", "sent.jsonl"),
|
|
35
|
+
claudeDir,
|
|
36
|
+
claudeSettings: path.join(claudeDir, "settings.json"),
|
|
37
|
+
// Claude Code keeps user-scope MCP servers in ~/.claude.json, or inside CLAUDE_CONFIG_DIR when set.
|
|
38
|
+
claudeJson: env.CLAUDE_CONFIG_DIR
|
|
39
|
+
? path.join(env.CLAUDE_CONFIG_DIR, ".claude.json")
|
|
40
|
+
: path.join(home, ".claude.json"),
|
|
41
|
+
codexHome,
|
|
42
|
+
codexConfig: path.join(codexHome, "config.toml"),
|
|
43
|
+
codexHooks: path.join(codexHome, "hooks.json"),
|
|
44
|
+
codexAgents: path.join(codexHome, "AGENTS.md"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for http URLs on this machine; everything else must be https. */
|
|
49
|
+
export function isLoopbackUrl(value) {
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(value);
|
|
52
|
+
return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The Hypher API base. HYPHER_API_URL wins, then the value `init` stored.
|
|
60
|
+
* Plain http is refused except on loopback, so the key never crosses a network in the clear.
|
|
61
|
+
* @param {NodeJS.ProcessEnv} env
|
|
62
|
+
* @param {{ apiBase?: string } | null} config
|
|
63
|
+
*/
|
|
64
|
+
export function apiBase(env, config) {
|
|
65
|
+
const raw = String(env.HYPHER_API_URL || config?.apiBase || DEFAULT_API_BASE).trim().replace(/\/+$/, "");
|
|
66
|
+
if (raw.startsWith("https://")) return raw;
|
|
67
|
+
if (raw.startsWith("http://") && isLoopbackUrl(raw)) return raw;
|
|
68
|
+
return DEFAULT_API_BASE;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A short, stable machine label for the key name. No user names. */
|
|
72
|
+
export function machineName(env = process.env) {
|
|
73
|
+
const host = (env.HYPHER_MACHINE_NAME || os.hostname() || "this computer").replace(/\.local$/i, "");
|
|
74
|
+
return host.replace(/[^\w.-]+/g, "-").slice(0, 60) || "this-computer";
|
|
75
|
+
}
|
package/src/fsutil.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
export function sha256(data) {
|
|
14
|
+
return createHash("sha256").update(data).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fileSha256(file) {
|
|
18
|
+
const bytes = readBytes(file);
|
|
19
|
+
return bytes ? sha256(bytes) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @returns {Buffer | null} */
|
|
23
|
+
export function readBytes(file) {
|
|
24
|
+
try {
|
|
25
|
+
return readFileSync(file);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readJson(file, fallback = null) {
|
|
32
|
+
const bytes = readBytes(file);
|
|
33
|
+
if (!bytes) return fallback;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Write through a temp file and rename, so a crash never leaves half a file. */
|
|
42
|
+
export function writeAtomic(file, data, { mode } = {}) {
|
|
43
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
44
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
45
|
+
writeFileSync(tmp, data, mode ? { mode } : undefined);
|
|
46
|
+
if (mode) chmodSync(tmp, mode);
|
|
47
|
+
renameSync(tmp, file);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeJson(file, value, options) {
|
|
51
|
+
writeAtomic(file, `${JSON.stringify(value, null, 2)}\n`, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function removeFile(file) {
|
|
55
|
+
rmSync(file, { force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function exists(file) {
|
|
59
|
+
return existsSync(file);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stable JSON: object keys sorted, so equal content hashes equally. */
|
|
63
|
+
export function canonicalJson(value) {
|
|
64
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
65
|
+
if (value && typeof value === "object") {
|
|
66
|
+
return `{${Object.keys(value).sort().filter((key) => value[key] !== undefined)
|
|
67
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
68
|
+
}
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook lists in the shape Claude Code and Codex share:
|
|
3
|
+
* { hooks: { <Event>: [{ matcher?, hooks: [{ type: "command", command, timeout }] }] } }.
|
|
4
|
+
* Hypher's entries carry a tag in their command, so they can be found again,
|
|
5
|
+
* and are appended after the builder's own, so removing them never shifts
|
|
6
|
+
* the position of anything the builder added.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @param {string} tag e.g. "hook claude-code" */
|
|
10
|
+
export function hookCommand(shim, tag, event) {
|
|
11
|
+
return `"${shim}" ${tag} ${event}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isTaggedCommand(command, tag) {
|
|
15
|
+
return typeof command === "string" && command.includes(tag) && /hypher/.test(command);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function withoutTagged(groups, tag) {
|
|
19
|
+
const kept = [];
|
|
20
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
21
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
22
|
+
kept.push(group);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const hooks = group.hooks.filter((hook) => !isTaggedCommand(hook?.command, tag));
|
|
26
|
+
if (hooks.length === group.hooks.length) kept.push(group);
|
|
27
|
+
else if (hooks.length > 0) kept.push({ ...group, hooks });
|
|
28
|
+
}
|
|
29
|
+
return kept;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Record<string, any>} config the parsed file
|
|
34
|
+
* @param {{ event: string, matcher?: string, timeout: number }[]} specs
|
|
35
|
+
* @param {string} shim absolute path of the `hypher` shim
|
|
36
|
+
* @param {string} tag
|
|
37
|
+
*/
|
|
38
|
+
export function mergeHooks(config, specs, shim, tag) {
|
|
39
|
+
const next = { ...config, hooks: { ...(config.hooks ?? {}) } };
|
|
40
|
+
for (const spec of specs) {
|
|
41
|
+
const groups = withoutTagged(next.hooks[spec.event], tag);
|
|
42
|
+
groups.push({
|
|
43
|
+
...(spec.matcher ? { matcher: spec.matcher } : {}),
|
|
44
|
+
hooks: [{ type: "command", command: hookCommand(shim, tag, spec.event), timeout: spec.timeout }],
|
|
45
|
+
});
|
|
46
|
+
next.hooks[spec.event] = groups;
|
|
47
|
+
}
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function removeHooks(config, tag) {
|
|
52
|
+
if (!config?.hooks || typeof config.hooks !== "object") return config;
|
|
53
|
+
const hooks = {};
|
|
54
|
+
for (const [event, groups] of Object.entries(config.hooks)) {
|
|
55
|
+
const kept = withoutTagged(groups, tag);
|
|
56
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
57
|
+
}
|
|
58
|
+
const next = { ...config };
|
|
59
|
+
if (Object.keys(hooks).length > 0) next.hooks = hooks;
|
|
60
|
+
else delete next.hooks;
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function installedEvents(config, specs, tag) {
|
|
65
|
+
return specs.filter((spec) =>
|
|
66
|
+
(config?.hooks?.[spec.event] ?? []).some((group) =>
|
|
67
|
+
(group?.hooks ?? []).some((hook) => isTaggedCommand(hook?.command, tag))))
|
|
68
|
+
.map((spec) => spec.event);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where each tagged hook sits: [event, groupIndex, hookIndex]. Codex keys its trust by this position. */
|
|
72
|
+
export function taggedPositions(config, tag) {
|
|
73
|
+
const found = [];
|
|
74
|
+
for (const [event, groups] of Object.entries(config?.hooks ?? {})) {
|
|
75
|
+
(Array.isArray(groups) ? groups : []).forEach((group, groupIndex) => {
|
|
76
|
+
(group?.hooks ?? []).forEach((hook, hookIndex) => {
|
|
77
|
+
if (isTaggedCommand(hook?.command, tag)) found.push([event, groupIndex, hookIndex]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderJson(value) {
|
|
85
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent hooks, for Claude Code and Codex alike (both send the same payload
|
|
3
|
+
* shape). SessionStart injects the note (after startup, resume, clear, and
|
|
4
|
+
* compact); Stop, PreCompact, and SessionEnd record a checkpoint; Stop asks
|
|
5
|
+
* once for a seal when the tree changed. Hooks never acknowledge, never block
|
|
6
|
+
* twice in a row, and never block on Hypher being down. In a repository no
|
|
7
|
+
* human linked they record and upload nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { createClient } from "../api.mjs";
|
|
10
|
+
import { linkFor } from "../config.mjs";
|
|
11
|
+
import { enqueue, flushOutbox, shouldFlush } from "../outbox.mjs";
|
|
12
|
+
import { resumeHere } from "../session.mjs";
|
|
13
|
+
import { takeSnapshot } from "../snapshot.mjs";
|
|
14
|
+
import { branchState, readWorktreeState, updateWorktreeState, withBranchState } from "../state.mjs";
|
|
15
|
+
import { findSecret } from "../secrets.mjs";
|
|
16
|
+
|
|
17
|
+
export const ASK_AGAIN_AFTER_MS = 15 * 60_000;
|
|
18
|
+
const HOOK_TIMEOUT_MS = 2_500;
|
|
19
|
+
const MAX_FILES = 200;
|
|
20
|
+
|
|
21
|
+
/** The linked snapshot at cwd, or null: not git, no remote, or not linked. */
|
|
22
|
+
function linkedSnapshot(env, cwd) {
|
|
23
|
+
const snapshot = takeSnapshot(cwd);
|
|
24
|
+
if (!snapshot?.repository || !snapshot.tree) return null;
|
|
25
|
+
const link = linkFor(snapshot.repository, env);
|
|
26
|
+
return link ? { snapshot, link } : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Record a deterministic checkpoint in the outbox: identity, tree hash, file names. */
|
|
30
|
+
export function recordCheckpoint(env, snapshot, event, now = Date.now(), agent = "claude-code") {
|
|
31
|
+
const changedFiles = snapshot.changedFiles
|
|
32
|
+
.slice(0, MAX_FILES)
|
|
33
|
+
.map((name) => (findSecret(name) ? "[file name withheld]" : name));
|
|
34
|
+
return enqueue({
|
|
35
|
+
kind: "checkpoint",
|
|
36
|
+
key: `${snapshot.repository.toLowerCase()}#${snapshot.branch}`,
|
|
37
|
+
body: {
|
|
38
|
+
repository: snapshot.repository,
|
|
39
|
+
branch: snapshot.branch,
|
|
40
|
+
commit: snapshot.commit,
|
|
41
|
+
treeHash: snapshot.tree,
|
|
42
|
+
dirty: snapshot.dirty,
|
|
43
|
+
changedFiles: [...new Set(changedFiles)],
|
|
44
|
+
source: agent,
|
|
45
|
+
event,
|
|
46
|
+
capturedAt: now,
|
|
47
|
+
},
|
|
48
|
+
}, { env, now });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sealRequest(firstHandoff) {
|
|
52
|
+
const goal = firstHandoff ? ' --goal "<the goal>"' : "";
|
|
53
|
+
return [
|
|
54
|
+
"Hypher: the working tree changed since the last handoff, and no one saved why.",
|
|
55
|
+
"Before you stop, record the decisions that changed and the next action for the next agent:",
|
|
56
|
+
`run \`hypher seal${goal} --decision "<a decision that changed>" --why "<why>" --next "<the next action>"\` in the terminal, or call the \`seal\` tool of the hypher MCP server with the same fields.`,
|
|
57
|
+
"Add one --decision/--why pair per changed decision (--replaces D1 names one it replaces), or none if no decision changed; --next is always needed.",
|
|
58
|
+
"It sends only your text, file names, and commit identity. Hypher asks once; if you cannot save now, say so and stop.",
|
|
59
|
+
].join(" ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sessionStart(agent, env, input) {
|
|
63
|
+
const result = await resumeHere({
|
|
64
|
+
env,
|
|
65
|
+
cwd: input.cwd || process.cwd(),
|
|
66
|
+
destination: agent,
|
|
67
|
+
sessionId: typeof input.session_id === "string" ? input.session_id : undefined,
|
|
68
|
+
timeoutMs: HOOK_TIMEOUT_MS,
|
|
69
|
+
});
|
|
70
|
+
if (result.status === "not-git" || !result.note) return null;
|
|
71
|
+
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.note } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function stop(agent, env, input, now = Date.now()) {
|
|
75
|
+
const found = linkedSnapshot(env, input.cwd || process.cwd());
|
|
76
|
+
if (!found) return null;
|
|
77
|
+
const { snapshot } = found;
|
|
78
|
+
recordCheckpoint(env, snapshot, "Stop", now, agent);
|
|
79
|
+
if (shouldFlush(env, now)) {
|
|
80
|
+
await flushOutbox(createClient({ env, timeoutMs: HOOK_TIMEOUT_MS }), { env, now }).catch(() => null);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const state = readWorktreeState(env, snapshot.root) ?? {};
|
|
84
|
+
const branch = branchState(state, snapshot.branch);
|
|
85
|
+
const pass = (patch = {}) => {
|
|
86
|
+
updateWorktreeState(env, snapshot.root, (current) => withBranchState(current, snapshot.branch, { blockedLast: false, ...patch }));
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
// Never block twice in a row: the version's flag when it sends one, else local state.
|
|
90
|
+
if (input.stop_hook_active === true || branch.blockedLast) return pass();
|
|
91
|
+
if (!branch.sealTree) return pass({ sealTree: snapshot.tree });
|
|
92
|
+
const changed = snapshot.tree !== branch.sealTree;
|
|
93
|
+
const askedRecently = typeof branch.askedAt === "number" && now - branch.askedAt < ASK_AGAIN_AFTER_MS;
|
|
94
|
+
if (!changed || askedRecently) return pass();
|
|
95
|
+
updateWorktreeState(env, snapshot.root, (current) => withBranchState(current, snapshot.branch, {
|
|
96
|
+
askedAt: now, askedTree: snapshot.tree, blockedLast: true,
|
|
97
|
+
}));
|
|
98
|
+
return { decision: "block", reason: sealRequest(!state.proposal) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function checkpointAndUpload(agent, env, input, event, now = Date.now()) {
|
|
102
|
+
const found = linkedSnapshot(env, input.cwd || process.cwd());
|
|
103
|
+
if (!found) return null;
|
|
104
|
+
recordCheckpoint(env, found.snapshot, event, now, agent);
|
|
105
|
+
await flushOutbox(createClient({ env, timeoutMs: HOOK_TIMEOUT_MS }), { env, now }).catch(() => null);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The agents that get hooks. Every other tool uses the standard door. */
|
|
110
|
+
export const HOOK_AGENTS = ["claude-code", "codex"];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {"claude-code" | "codex"} agent
|
|
114
|
+
* @param {string} event
|
|
115
|
+
* @param {Record<string, any>} input the hook payload
|
|
116
|
+
* @param {NodeJS.ProcessEnv} env
|
|
117
|
+
*/
|
|
118
|
+
export async function handleAgentHook(agent, event, input, env) {
|
|
119
|
+
switch (event) {
|
|
120
|
+
case "SessionStart":
|
|
121
|
+
return await sessionStart(agent, env, input);
|
|
122
|
+
case "Stop":
|
|
123
|
+
return await stop(agent, env, input);
|
|
124
|
+
case "PreCompact":
|
|
125
|
+
case "SessionEnd":
|
|
126
|
+
return await checkpointAndUpload(agent, env, input, event);
|
|
127
|
+
default:
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function handleClaudeHook(event, input, env) {
|
|
133
|
+
return handleAgentHook("claude-code", event, input, env);
|
|
134
|
+
}
|