@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 CHANGED
@@ -3,7 +3,9 @@
3
3
  Pilot · not yet recorded. The bridge between coding agents and Hypher project memory.
4
4
 
5
5
  - `hypher init` connects this computer (one browser approval), installs a pinned copy under `~/.hypher/`, and adds Claude Code hooks and Codex hooks (Codex asks once to trust them: `/hooks`). It keeps M0a's `hypher` sign-in entry wherever it finds one.
6
- - `hypher doctor` checks each piece and prints the fix for anything red.
6
+ - `hypher doctor` checks each piece and prints the fix for anything red. When Hypher is unreachable, or something between this computer and it answers instead (a proxy's `403 Host not in allowlist`, an HTML page, an empty body), it names the host it tried and the status and content type that came back, never the body.
7
+ - An answer that is not Hypher's own JSON counts as Hypher being unavailable, not as Hypher saying no: a queued checkpoint stays in the outbox until it uploads, and the session starts with "Hypher unavailable" (built, released with 0.2.1; not recorded). When Hypher is unavailable, not connected, or the repository is not linked, the `SessionStart` hook also puts that one line in front of you (`systemMessage`), not only in front of the model.
8
+ - **When checkpoints are written (0.2.1; built and rehearsed, not recorded).** In Claude Code a checkpoint (identity, tree hash, file names, never contents) is queued when a session starts, after the note is read; after a file edit (`Edit`, `Write`, `MultiEdit`, `NotebookEdit`), at most every 10 seconds; after a command (`Bash`), at most every 60 seconds; and at turn end, before compaction, and at exit as before. So a session killed mid-turn leaves a checkpoint at most about 10 seconds behind its last edit. The new `PostToolUse` hook reads only `cwd` and the tool's name, never the tool's input, and, on a laptop, does not touch the network; checkpoints upload when the next session starts here (in a Claude Code cloud session they upload as they go: see Cloud sessions below). `hypher status` prints the newest checkpoint of this worktree: its event, the clock time (local and UTC), its age, and whether it is still queued or uploaded. Re-run `npx @litterthanlit/hypher-cli@latest init` to add the hook to a 0.2.0 install. Codex is unchanged (no `PostToolUse` hook, no `SessionStart` checkpoint): that is M4. The note's `Last checkpoint:` line gives the age and the capture time in UTC, for example `2 min ago (14:22 UTC, 2026-11-02)`, in every note the CLI prints (built, released with 0.3.0; the server's own text has no clock time). A `SessionStart` with source `compact` or `clear` keeps the seal baseline, so edits made before a compaction still make the next `Stop` ask for a seal; `startup` and `resume` move it to the tree now, as before (built, released with 0.3.0; Claude Code and Codex send the same source names, so both hooks do this).
7
9
  - `hypher resume` prints the note for this repository; `hypher seal` saves a handoff. `hypher resume --full` prints every item the note cut ("N more"); with `--json` it adds the whole decision ledger under `ledger`.
8
10
  - `hypher seal --evidence "<command>=<exit code>"` (repeatable, split at the last `=`) reports a check you ran. Hypher binds each to the working tree at the save and labels it an agent report (only agent reports are accepted until hooks capture checks in Baton M3); the next note says whether that tree still matches. It never runs the command. Only the command text, exit code, tree hash, and time leave the machine, never output. Against a server that does not accept evidence yet, the handoff saves without it and says so.
9
11
  - `hypher mcp` is the bridge for any MCP client: `resume`, `seal`, and `acknowledge`, with no project id, revision, or key to fill in. It also fills the repository and branch into a bare `read_ledger` call.
@@ -12,6 +14,20 @@ Pilot · not yet recorded. The bridge between coding agents and Hypher project m
12
14
  - `init` also puts `~/.hypher/bin` on PATH with one marked block in your shell's startup file (`~/.zshenv` for zsh, `~/.bash_profile` or `~/.bashrc` for bash, a `conf.d` file for fish), so `hypher` runs by name in new terminals and in agents' shells. `--no-path` skips it; hooks and the MCP bridge use the full path either way.
13
15
  - Inside Codex's sandbox the Keychain and the network are blocked. Commands run there say so instead of reporting that this computer is not connected.
14
16
 
17
+ ## Cloud sessions: `hypher init --repo` (0.3.0; built and rehearsed, not recorded)
18
+
19
+ A Claude Code cloud session runs the repository's `.claude/settings.json` hooks, not your user-level ones, and starts in a fresh container with no `hypher`. Run `hypher init --repo` on your laptop, inside the repository. It needs no sign-in, writes three files, and never commits or pushes; it prints what to commit.
20
+
21
+ - `.claude/settings.json`: Hypher's hooks (`SessionStart`, `Stop`, `PreCompact`, `SessionEnd`, and the two `PostToolUse` entries), each running `.claude/hooks/hypher.sh <Event>`, merged after your own hooks and settings (a second run changes nothing). `SessionStart` has a 120-second timeout, for a first-run install.
22
+ - `.claude/hooks/hypher.sh`: does nothing unless `CLAUDE_CODE_REMOTE=true`, so a laptop with the user-level hooks never fires twice; installs `@litterthanlit/hypher-cli` at the exact version that wrote it (never `latest`) if `hypher` is missing; prints one line naming `HYPHER_API_KEY` at session start if that variable is not set; otherwise runs `hypher hook claude-code <Event>`. It never prints the key.
23
+ - `AGENTS.md`: the marked block `init` writes. No `.mcp.json` is written.
24
+
25
+ **Commit** `.claude/settings.json`, `.claude/hooks/hypher.sh` (it must stay executable), and `AGENTS.md`. A committed hook runs a script at session start for anyone who clones, so Claude Code asks them to trust it: use it on your own repository first. **In the cloud environment** add `HYPHER_API_KEY`, a Hypher API key from Settings → API keys, named for that environment and revoked when you are done (it reaches your whole account). The environment must let `npm` reach the registry and `www.hypher.app`.
26
+
27
+ In a cloud session (`CLAUDE_CODE_REMOTE=true`, key from `HYPHER_API_KEY`) the CLI also: links a repository a person linked in Settings → Integrations without a click, keeping the answer in the container (a repository nobody linked uploads nothing and gets one line saying so; Hypher never makes a project); uploads each checkpoint as it is made, at most once a minute per repository and branch, with the newest queued one winning, and always at `Stop`, `PreCompact`, and `SessionEnd`; and names `hypher seal` in the shell first in the `Stop` request, since a container has no signed-in MCP entry. A laptop behaves as before.
28
+
29
+ `hypher doctor` inside the repository reports whether the kit is present and which version it pins. `hypher uninstall --repo` removes the kit and gives your files back as they were; plain `hypher uninstall` is unchanged. To move the pin, run `npx @litterthanlit/hypher-cli@latest init --repo` and commit the result. Details: `docs/doors/claude-code.md` in the repository.
30
+
15
31
  Only hashes, file names, repository and commit identity, and agent-written handoff text leave the machine. Never code, diffs, or transcripts.
16
32
 
17
33
  Canonical git helpers are vendored from `packages/hypher-git` (`node script/sync-hypher-git.mjs`). The note renderer, validators, secret scan, and the ledger's op checks, seal mapping, and local note are vendored from `hypher-web/shared` (`node script/sync-hypher-shared.mjs`). Do not edit `vendor/` by hand.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litterthanlit/hypher-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Hypher bridge for coding agents: hooks, a local outbox, and a truth check against the working tree. Pilot, not yet recorded.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -9,12 +9,21 @@ import { apiBase, CLI_VERSION, paths } from "./env.mjs";
9
9
  import { readConfig } from "./config.mjs";
10
10
  import { loadCredential } from "./credentials.mjs";
11
11
 
12
+ /**
13
+ * Hypher could not be reached, or what answered is not Hypher: a timeout, a 5xx, or any answer that is not
14
+ * Hypher's own JSON (a proxy's 403 in plain text, an HTML page, an empty body). `answer` says what came back,
15
+ * as a status and a content type, never a body, so `hypher doctor` can name it.
16
+ * @param {string} base
17
+ * @param {unknown} cause
18
+ * @param {{ status: number, contentType: string } | null} [answer]
19
+ */
12
20
  export class HypherUnavailable extends Error {
13
- constructor(base, cause) {
21
+ constructor(base, cause, answer = null) {
14
22
  super(`Hypher unavailable at ${base}`);
15
23
  this.name = "HypherUnavailable";
16
24
  this.base = base;
17
25
  this.causeText = cause instanceof Error ? cause.message : String(cause ?? "");
26
+ this.answer = answer;
18
27
  }
19
28
  }
20
29
 
@@ -29,6 +38,16 @@ export class HypherRejected extends Error {
29
38
 
30
39
  const MAX_LOG_BYTES = 20_000;
31
40
 
41
+ /** The parsed body when it is a JSON object (Hypher's shape for every answer), else null. */
42
+ function ownJson(text) {
43
+ try {
44
+ const value = JSON.parse(text);
45
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
32
51
  function logSent(env, entry) {
33
52
  try {
34
53
  const file = paths(env).sentLog;
@@ -70,13 +89,19 @@ export function createClient({ env = process.env, apiKey, timeoutMs = 4_000 } =
70
89
  } catch (error) {
71
90
  throw new HypherUnavailable(base, error);
72
91
  }
73
- let json = null;
92
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
93
+ const answer = { status: response.status, contentType };
94
+ let text;
74
95
  try {
75
- json = await response.json();
76
- } catch {
77
- json = null;
96
+ text = await response.text();
97
+ } catch (error) {
98
+ throw new HypherUnavailable(base, error, answer);
78
99
  }
79
- if (response.status >= 500) throw new HypherUnavailable(base, `HTTP ${response.status}`);
100
+ if (response.status >= 500) throw new HypherUnavailable(base, `HTTP ${response.status}`, answer);
101
+ const json = ownJson(text);
102
+ // The one rule: Hypher answers in JSON, every time, refusals included. Anything else came from something
103
+ // between this computer and Hypher (a proxy, a captive portal), so Hypher is unavailable, not saying no.
104
+ if (json === null) throw new HypherUnavailable(base, `HTTP ${response.status} that is not Hypher's answer`, answer);
80
105
  return { status: response.status, ok: response.ok, json };
81
106
  }
82
107
 
package/src/claude.mjs CHANGED
@@ -3,18 +3,32 @@
3
3
  * merges them into ~/.claude/settings.json without touching the builder's
4
4
  * own entries, and `uninstall` takes out only these.
5
5
  */
6
- import { hookCommand as taggedCommand, installedEvents, isTaggedCommand, mergeHooks, removeHooks, renderJson } from "./hookConfig.mjs";
6
+ import { hookCommand as taggedCommand, installedEvents, installedSpecs, isTaggedCommand, mergeHooks, removeHooks, renderJson } from "./hookConfig.mjs";
7
7
 
8
8
  /** Every Hypher hook command carries this, so it can be found again. */
9
9
  export const HOOK_TAG = "hook claude-code";
10
10
 
11
+ /**
12
+ * PostToolUse is two entries, one per tool matcher: a file edit (a checkpoint at most every 10 seconds) and a
13
+ * command (at most every 60). The handler tells them apart by the payload's `tool_name`. The 5-second timeout is
14
+ * a ceiling; a throttled call answers in well under a quarter of a second. Codex has no PostToolUse hook (M4).
15
+ */
16
+ export const EDIT_TOOLS_MATCHER = "Edit|Write|MultiEdit|NotebookEdit";
11
17
  export const CLAUDE_HOOKS = [
12
18
  { event: "SessionStart", matcher: "startup|resume|clear|compact", timeout: 10 },
13
19
  { event: "Stop", timeout: 10 },
14
20
  { event: "PreCompact", timeout: 15 },
15
21
  { event: "SessionEnd", timeout: 15 },
22
+ { event: "PostToolUse", matcher: EDIT_TOOLS_MATCHER, timeout: 5 },
23
+ { event: "PostToolUse", matcher: "Bash", timeout: 5 },
16
24
  ];
17
25
 
26
+ /** A spec's name for a person: its event, and for an event with several entries, its matcher too. */
27
+ export function hookLabel(spec) {
28
+ const shared = CLAUDE_HOOKS.filter((other) => other.event === spec.event).length > 1;
29
+ return shared ? `${spec.event} (${spec.matcher})` : spec.event;
30
+ }
31
+
18
32
  export function hookCommand(shim, event) {
19
33
  return taggedCommand(shim, HOOK_TAG, event);
20
34
  }
@@ -35,10 +49,17 @@ export function removeClaudeHooks(settings) {
35
49
  return removeHooks(settings, HOOK_TAG);
36
50
  }
37
51
 
52
+ /** The events Hypher has a hook for, each once. */
38
53
  export function installedHookEvents(settings) {
39
54
  return installedEvents(settings, CLAUDE_HOOKS, HOOK_TAG);
40
55
  }
41
56
 
57
+ /** The hooks `init` would add that are not in these settings, by name. Empty when everything is there. */
58
+ export function missingHooks(settings) {
59
+ const present = new Set(installedSpecs(settings, CLAUDE_HOOKS, HOOK_TAG));
60
+ return CLAUDE_HOOKS.filter((spec) => !present.has(spec)).map(hookLabel);
61
+ }
62
+
42
63
  export const renderSettings = renderJson;
43
64
 
44
65
  /** Clean edited settings text; null when it is not JSON we can safely rewrite. */
package/src/cli.mjs CHANGED
@@ -25,6 +25,7 @@ const OPTIONS = {
25
25
  full: { type: "boolean" },
26
26
  "no-browser": { type: "boolean" },
27
27
  "no-path": { type: "boolean" },
28
+ repo: { type: "boolean" },
28
29
  help: { type: "boolean", short: "h" },
29
30
  version: { type: "boolean", short: "v" },
30
31
  goal: { type: "string" },
@@ -51,7 +52,8 @@ Usage: hypher <command>
51
52
  init Connect this computer (one browser approval); add Claude Code and Codex hooks
52
53
  [--yes: never ask in the terminal] [--json: one JSON summary] [--no-browser]
53
54
  [--no-path: leave your shell's PATH alone]
54
- link Bind this repository to a Hypher project (one browser approval)
55
+ [--repo: write the cloud kit into this repository for Claude Code cloud sessions; commit it yourself]
56
+ link Bind this repository to a Hypher project (one browser approval)
55
57
  doctor Check every piece and print the fix for anything red
56
58
  status What is installed; --last shows exactly what was last sent
57
59
  resume Print the handoff note for this repository and its truth check
@@ -62,6 +64,7 @@ Usage: hypher <command>
62
64
  ack Acknowledge the handoff this session read
63
65
  mcp Run the stdio MCP bridge: resume, seal, acknowledge (for any MCP client)
64
66
  uninstall Remove exactly what init added and revoke the key
67
+ [--repo: remove only the cloud kit from this repository]
65
68
 
66
69
  Only hashes, file names, repository and commit identity, and agent-written
67
70
  handoff text leave this machine. Never code, diffs, or transcripts.`;
package/src/cloud.mjs ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Cloud mode (M3 slice 5): a Claude Code cloud session, started by the repository's committed kit
3
+ * (`hypher init --repo`). It is on when `CLAUDE_CODE_REMOTE=true` and the key is the one from
4
+ * `HYPHER_API_KEY` (a container has no Keychain). Three things differ from a laptop, and nothing else does:
5
+ *
6
+ * - No link file exists in a fresh container. A repository a human linked in Hypher is linked here without a click
7
+ * (`resolve_project_for_repo`, as `hypher link` already does when Hypher knows the repository), and the answer is
8
+ * kept in the container's `~/.hypher`. A repository nobody linked uploads nothing and says so in one line. Hypher
9
+ * never creates a project.
10
+ * - The container's disk dies with the session, so a checkpoint uploads at once instead of at the next session: at
11
+ * most one upload a minute per repository and branch (a newer queued checkpoint replaces an older one, so the
12
+ * newest wins), and `Stop`, `PreCompact`, and `SessionEnd` always try.
13
+ * - The seal request names `hypher seal` in the shell first (see hooks/index.mjs): no signed-in MCP entry exists.
14
+ */
15
+ import path from "node:path";
16
+ import { createClient, HypherRejected, HypherUnavailable } from "./api.mjs";
17
+ import { linkFor, saveLink } from "./config.mjs";
18
+ import { loadCredential } from "./credentials.mjs";
19
+ import { paths } from "./env.mjs";
20
+ import { readJson, sha256, writeJson } from "./fsutil.mjs";
21
+ import { flushOutbox } from "./outbox.mjs";
22
+
23
+ /** One upload a minute per repository and branch, outside the three events that always try. */
24
+ export const UPLOAD_WINDOW_MS = 60_000;
25
+ /** After an unmatched or unreachable answer, how long a hook that is not a session start waits before asking again. */
26
+ const UNAVAILABLE_RETRY_MS = 60_000;
27
+
28
+ /** True in any Claude Code cloud session, with or without a key. */
29
+ export function inCloud(env = process.env) {
30
+ return env?.CLAUDE_CODE_REMOTE === "true";
31
+ }
32
+
33
+ /** Cloud mode: a cloud session whose key is `HYPHER_API_KEY`. The cheap test goes first: a laptop never reads a credential here. */
34
+ export function cloudMode(env = process.env) {
35
+ return inCloud(env) && loadCredential(env).source === "HYPHER_API_KEY";
36
+ }
37
+
38
+ export const CLOUD_NOT_CONNECTED_LINE =
39
+ "Hypher is not connected in this cloud session; starting without it. Add HYPHER_API_KEY (a Hypher API key) to the cloud environment.";
40
+
41
+ export function cloudUnlinkedLine(repository) {
42
+ return `Hypher: ${String(repository).slice(0, 200)} is not linked to a Hypher project, so nothing is uploaded from this session. A person links it in Settings → Integrations at hypher.app; Hypher never creates a project on its own.`;
43
+ }
44
+
45
+ function cloudFile(env, name) {
46
+ return path.join(paths(env).stateDir, "cloud", name);
47
+ }
48
+
49
+ function readMap(file) {
50
+ const value = readJson(file, null);
51
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
52
+ }
53
+
54
+ /**
55
+ * The project this repository is linked to. On a laptop that is the link file, as always. In cloud mode a
56
+ * repository with no link asks Hypher whether a human linked it, and keeps the answer. A session start (`force`)
57
+ * always asks; any other hook asks only if the last answer was neither "no project" nor a failure in the last minute,
58
+ * so a repository nobody linked costs one request per session start, not one per edit.
59
+ * @param {NodeJS.ProcessEnv} env
60
+ * @param {string} repository owner/repo
61
+ * @param {{ force?: boolean, now?: number, timeoutMs?: number }} [options]
62
+ * @returns {Promise<{ link: object | null, status: "linked" | "unlinked" | "unmatched" | "not-connected" | "unavailable" | "rejected", error?: string }>}
63
+ */
64
+ export async function findLink(env, repository, { force = false, now = Date.now(), timeoutMs = 2_500 } = {}) {
65
+ const known = linkFor(repository, env);
66
+ if (known) return { link: known, status: "linked" };
67
+ if (!inCloud(env)) return { link: null, status: "unlinked" };
68
+ const client = createClient({ env, timeoutMs });
69
+ if (!client.apiKey) return { link: null, status: "not-connected" };
70
+
71
+ const file = cloudFile(env, "resolve.json");
72
+ const key = String(repository).toLowerCase();
73
+ const before = readMap(file)[key];
74
+ if (!force && before) {
75
+ if (before.status === "unmatched") return { link: null, status: "unmatched" };
76
+ if (now >= before.at && now - before.at < UNAVAILABLE_RETRY_MS) return { link: null, status: before.status };
77
+ }
78
+ const remember = (status) => {
79
+ try {
80
+ writeJson(file, { ...readMap(file), [key]: { status, at: now } }, { mode: 0o600 });
81
+ } catch {
82
+ // Only saves a request; never fail a hook over it.
83
+ }
84
+ };
85
+ try {
86
+ const { structured } = await client.tool("resolve_project_for_repo", { repo: repository });
87
+ if (structured.matched && structured.projectId) {
88
+ const link = { projectId: structured.projectId, projectName: structured.projectName };
89
+ saveLink(repository, link, env);
90
+ return { link: linkFor(repository, env), status: "linked" };
91
+ }
92
+ remember("unmatched");
93
+ return { link: null, status: "unmatched" };
94
+ } catch (error) {
95
+ if (error instanceof HypherUnavailable) {
96
+ remember("unavailable");
97
+ return { link: null, status: "unavailable" };
98
+ }
99
+ if (error instanceof HypherRejected) {
100
+ remember("rejected");
101
+ return { link: null, status: "rejected", error: error.message };
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ function uploadWindowFile(env) {
108
+ return cloudFile(env, "upload.json");
109
+ }
110
+
111
+ /**
112
+ * True, and counted, when a checkpoint of this repository and branch may upload now; false inside the minute after the
113
+ * last one. Counted before the request, so two hooks started together do not both upload. A time in the future (a
114
+ * clock that moved back) does not block.
115
+ */
116
+ export function claimUpload(env, key, now) {
117
+ const file = uploadWindowFile(env);
118
+ const all = readMap(file);
119
+ const hash = sha256(String(key)).slice(0, 20);
120
+ const last = all[hash];
121
+ if (typeof last === "number" && now >= last && now - last < UPLOAD_WINDOW_MS) return false;
122
+ writeJson(file, { ...all, [hash]: now }, { mode: 0o600 });
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Upload what is queued. `always` is for `Stop`, `PreCompact`, and `SessionEnd`: no window, no wait for a retry
128
+ * time. Anything else uploads at most once a minute per repository and branch; a checkpoint that waits stays
129
+ * queued, and a newer one replaces it there.
130
+ * @param {NodeJS.ProcessEnv} env
131
+ * @param {{ repository: string, branch: string }} snapshot
132
+ * @param {{ always?: boolean, now?: number, timeoutMs?: number }} [options]
133
+ */
134
+ export async function uploadNow(env, snapshot, { always = false, now = Date.now(), timeoutMs = 2_500 } = {}) {
135
+ const key = `${snapshot.repository.toLowerCase()}#${snapshot.branch}`;
136
+ if (!always && !claimUpload(env, key, now)) return null;
137
+ if (always) {
138
+ try {
139
+ claimUpload(env, key, now);
140
+ } catch {
141
+ // The window is a convenience here.
142
+ }
143
+ }
144
+ return await flushOutbox(createClient({ env, timeoutMs }), { env, now, force: always }).catch(() => null);
145
+ }
@@ -7,18 +7,38 @@ import { CLI_VERSION, INTEGRATIONS_URL, inCodexSandbox, paths } from "../env.mjs
7
7
  import { linkFor, readConfig } from "../config.mjs";
8
8
  import { loadCredential } from "../credentials.mjs";
9
9
  import { createClient, HypherRejected, HypherUnavailable } from "../api.mjs";
10
- import { installedHookEvents, CLAUDE_HOOKS } from "../claude.mjs";
10
+ import { installedHookEvents, missingHooks } from "../claude.mjs";
11
11
  import { claudeHypherServer, detectTools, isSignInEntry, projectLevelHypherEntry } from "../tools.mjs";
12
12
  import { listOutbox } from "../outbox.mjs";
13
13
  import { readBytes, readJson } from "../fsutil.mjs";
14
14
  import { CODEX_HOOKS, codexHookTrust, codexHypherEntry, codexSupportsHooks, installedCodexHookEvents, readCodexHooks } from "../codex.mjs";
15
15
  import { hasBlock } from "../agentsBlock.mjs";
16
+ import { inspectRepoKit } from "../repoKit.mjs";
16
17
  import { remotesExposingHypherRefs, repoIdentity } from "../../vendor/hypher-git.mjs";
17
18
 
18
19
  function pad(text, width = 22) {
19
20
  return text.length >= width ? `${text} ` : text + " ".repeat(width - text.length);
20
21
  }
21
22
 
23
+ function hostOf(base) {
24
+ try {
25
+ return new URL(base).host;
26
+ } catch {
27
+ return base;
28
+ }
29
+ }
30
+
31
+ /** What was tried and what came back: status and content type, never a body. */
32
+ function unavailableDetail(base, error) {
33
+ const host = hostOf(base);
34
+ const answer = error.answer;
35
+ if (!answer) return `${base} (${error.causeText || "no answer"})`;
36
+ const type = answer.contentType || "no content type";
37
+ return answer.status >= 500
38
+ ? `${host} answered HTTP ${answer.status} (${type})`
39
+ : `${host} answered HTTP ${answer.status} (${type}), which is not Hypher's answer`;
40
+ }
41
+
22
42
  export async function run({ io }) {
23
43
  const env = io.env;
24
44
  const p = paths(env);
@@ -46,11 +66,13 @@ export async function run({ io }) {
46
66
 
47
67
  const tools = detectTools(env);
48
68
  if (tools.claude.found) {
49
- const events = installedHookEvents(readJson(p.claudeSettings, {}) ?? {});
69
+ const settings = readJson(p.claudeSettings, {}) ?? {};
70
+ const events = installedHookEvents(settings);
71
+ const missing = missingHooks(settings);
50
72
  const entry = claudeHypherServer(env);
51
73
  const mcp = entry ? (isSignInEntry(entry) ? "MCP hypher (sign-in)" : "MCP hypher (bridge)") : "no hypher MCP entry";
52
- if (events.length === CLAUDE_HOOKS.length) add("✓", `Claude Code ${tools.claude.version}`, `hooks ${events.join(", ")} · ${mcp}`);
53
- else add("✗", `Claude Code ${tools.claude.version}`, `hooks missing: ${CLAUDE_HOOKS.map((hook) => hook.event).filter((event) => !events.includes(event)).join(", ")}`, "run `hypher init`.");
74
+ if (missing.length === 0) add("✓", `Claude Code ${tools.claude.version}`, `hooks ${events.join(", ")} · ${mcp}`);
75
+ else add("✗", `Claude Code ${tools.claude.version}`, `hooks missing: ${missing.join(", ")}`, "run `hypher init`.");
54
76
  } else {
55
77
  add("–", "Claude Code", "not found");
56
78
  }
@@ -103,6 +125,19 @@ export async function run({ io }) {
103
125
  add("✓", "refs/hypher", "stay local (no remote pushes them)");
104
126
  }
105
127
  }
128
+ if (identity.root) {
129
+ const kit = inspectRepoKit(identity.root);
130
+ if (kit.state === "none") {
131
+ add("–", "Repo kit", "not in this repository (`hypher init --repo` adds it for Claude Code cloud sessions)");
132
+ } else if (kit.state === "partial") {
133
+ add("✗", "Repo kit", kit.problems.join("; "), "run `hypher init --repo` here and commit the result.");
134
+ } else if (kit.version === CLI_VERSION) {
135
+ add("✓", "Repo kit", `present · pinned @litterthanlit/hypher-cli@${kit.version} · hooks ${kit.events.join(", ")}`);
136
+ } else {
137
+ add("!", "Repo kit", `present · pinned @litterthanlit/hypher-cli@${kit.version ?? "unknown"}; this copy is ${CLI_VERSION} · hooks ${kit.events.join(", ")}`,
138
+ "to move the pin, run `npx @litterthanlit/hypher-cli@latest init --repo` here and commit the result.");
139
+ }
140
+ }
106
141
 
107
142
  const client = createClient({ env, timeoutMs: 5_000 });
108
143
  if (credential.apiKey) {
@@ -115,8 +150,8 @@ export async function run({ io }) {
115
150
  if (error instanceof HypherUnavailable && sandboxed) {
116
151
  add("!", "Hypher reachable", `not reachable inside Codex's sandbox (${client.base})`);
117
152
  } else if (error instanceof HypherUnavailable) {
118
- add("✗", "Hypher unavailable", `${client.base} (${error.causeText || "no answer"})`,
119
- "check your network, VPN, or proxy, then run `hypher doctor` again. Sessions start without Hypher until then.");
153
+ add("✗", "Hypher unavailable", unavailableDetail(client.base, error),
154
+ `${error.answer && error.answer.status < 500 ? `something between this computer and ${hostOf(client.base)} (a proxy, VPN, or firewall) is answering for Hypher: allow ${hostOf(client.base)}, then` : "check your network, VPN, or proxy, then"} run \`hypher doctor\` again. Sessions start without Hypher until then, and queued checkpoints are kept.`);
120
155
  } else if (error instanceof HypherRejected) {
121
156
  add("✗", "Hypher reachable", error.message, "run `hypher init` to connect this computer again.");
122
157
  } else {
@@ -47,7 +47,7 @@ export async function run({ args, io }) {
47
47
  } catch (error) {
48
48
  logError(io.env, event, error);
49
49
  if (event === "SessionStart") {
50
- io.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: HYPHER_UNAVAILABLE_LINE } })}\n`);
50
+ io.stdout.write(`${JSON.stringify({ systemMessage: HYPHER_UNAVAILABLE_LINE, hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: HYPHER_UNAVAILABLE_LINE } })}\n`);
51
51
  }
52
52
  } finally {
53
53
  // A client that keeps stdin open must not keep this hook alive.
@@ -14,7 +14,7 @@ import { credentialPromptLine, credentialStore, loadCredential, saveCredential }
14
14
  import { createClient, HypherUnavailable } from "../api.mjs";
15
15
  import { runDeviceFlow } from "../device.mjs";
16
16
  import { readManifest, recordEntry, writeManifest, writeTrackedFile } from "../manifest.mjs";
17
- import { mergeClaudeHooks, renderSettings, CLAUDE_HOOKS } from "../claude.mjs";
17
+ import { mergeClaudeHooks, renderSettings, installedHookEvents } from "../claude.mjs";
18
18
  import { claudeHypherServer, claudeMcpAdd, detectTools, isSignInEntry } from "../tools.mjs";
19
19
  import { readBytes, readJson } from "../fsutil.mjs";
20
20
  import { CODEX_HOOKS, CODEX_TRUST_LINE, addBridge, codexHookTrust, codexHypherEntry, codexSupportsHooks, mergeCodexHooks, readCodexHooks } from "../codex.mjs";
@@ -22,7 +22,8 @@ import { renderJson } from "../hookConfig.mjs";
22
22
  import { addBlock, agentsBlock, alreadyUsesHypher } from "../agentsBlock.mjs";
23
23
  import { addRepositoryBlock, displayPath } from "../repoBlock.mjs";
24
24
  import { addPathBlock, manualPathLine, pathBlock, shellStartupFile } from "../shellPath.mjs";
25
- import { repoIdentity } from "../../vendor/hypher-git.mjs";
25
+ import { installRepoKit } from "../repoKit.mjs";
26
+ import { git, repoIdentity } from "../../vendor/hypher-git.mjs";
26
27
 
27
28
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
28
29
 
@@ -52,12 +53,62 @@ export function installPinnedCopy(env, manifest) {
52
53
  return change;
53
54
  }
54
55
 
56
+ /**
57
+ * `hypher init --repo`: write the repo kit (repoKit.mjs) into the repository you are in, so Claude Code cloud sessions
58
+ * run Hypher's hooks. Needs no sign-in and touches nothing outside the repository. Never commits and never pushes:
59
+ * it prints what to commit.
60
+ */
61
+ async function runRepo({ flags, io }) {
62
+ const identity = repoIdentity(io.cwd);
63
+ if (!identity.root) {
64
+ io.err("hypher init --repo: run this inside a git repository.");
65
+ return 2;
66
+ }
67
+ const done = installRepoKit(identity.root, CLI_VERSION);
68
+ if (!done.ok) {
69
+ io.err(`hypher init --repo: ${done.error}`);
70
+ return 1;
71
+ }
72
+ // Only files git sees as new or modified need committing; a rerun on a committed kit says so.
73
+ const dirty = (git(identity.root, ["status", "--porcelain", "--untracked-files=all", "--", ...done.files]) ?? "").split("\n").filter(Boolean);
74
+ const toCommit = done.files.filter((file) => dirty.some((line) => line.slice(3) === file));
75
+ const say = flags.json ? (line = "") => io.err(line) : (line = "") => io.out(line);
76
+ say(`Hypher · cloud kit (hypher-cli ${CLI_VERSION}, pinned in the script)`);
77
+ say("");
78
+ if (done.changes.length) {
79
+ say(" Changed");
80
+ for (const [file, what] of done.changes) say(` ${pad(file, 28)}${what}`);
81
+ } else {
82
+ say(" Already set up. Nothing changed.");
83
+ }
84
+ say("");
85
+ if (toCommit.length) {
86
+ say(" Hypher did not commit or push anything. To turn it on for cloud sessions, commit these files:");
87
+ say(` git add ${toCommit.join(" ")}`);
88
+ say(' git commit -m "Add Hypher project memory for Claude Code cloud sessions"');
89
+ say(" hypher.sh must stay executable; git add records that. A committed hook runs a script at session start for anyone who clones, so Claude Code asks them to trust it.");
90
+ } else {
91
+ say(" These files are already committed.");
92
+ }
93
+ say("");
94
+ say(" In the Claude Code cloud environment for this repository, add a variable:");
95
+ say(" HYPHER_API_KEY = a Hypher API key (Settings → API keys, named for that environment, revoked when you are done)");
96
+ say(` ${API_KEYS_URL}`);
97
+ say(` Hypher uploads nothing until a person has linked ${identity.repository ?? "this repository"} to a project in Settings → Integrations.${identity.repository ? "" : " (This repository has no GitHub remote, so it cannot be linked.)"}`);
98
+ say(" Undo: hypher uninstall --repo");
99
+ if (flags.json) {
100
+ io.out(JSON.stringify({ ok: true, version: CLI_VERSION, alreadySetUp: done.changes.length === 0, changed: done.changes.map(([file, what]) => ({ file, what })), commit: toCommit, env: ["HYPHER_API_KEY"] }));
101
+ }
102
+ return 0;
103
+ }
104
+
55
105
  /**
56
106
  * `--yes` never asks in the terminal (today init asks nothing there; the one
57
107
  * approval is in the browser). `--json` prints one JSON summary on stdout.
58
108
  * @param {{ args: string[], flags: Record<string, any>, io: any }} input
59
109
  */
60
110
  export async function run({ flags, io }) {
111
+ if (flags.repo) return runRepo({ flags, io });
61
112
  const env = io.env;
62
113
  const started = Date.now();
63
114
  let approvalMs = 0;
@@ -194,7 +245,7 @@ export async function run({ flags, io }) {
194
245
  const settings = readJson(p.claudeSettings, {}) ?? {};
195
246
  const merged = mergeClaudeHooks(settings && typeof settings === "object" ? settings : {}, p.shim);
196
247
  const change = writeTrackedFile(manifest, p.claudeSettings, renderSettings(merged), "claude-settings");
197
- if (change !== "unchanged") changed.push([p.claudeSettings, `+ hooks ${CLAUDE_HOOKS.map((hook) => hook.event).join(", ")}`]);
248
+ if (change !== "unchanged") changed.push([p.claudeSettings, `+ hooks ${installedHookEvents(merged).join(", ")}`]);
198
249
  writeManifest(manifest, env);
199
250
  if (!claudeEntry) {
200
251
  const added = claudeMcpAdd(env, p.shim);
@@ -6,7 +6,7 @@ import { readFileSync } from "node:fs";
6
6
  import { CLI_VERSION, paths } from "../env.mjs";
7
7
  import { readConfig, linkFor } from "../config.mjs";
8
8
  import { loadCredential } from "../credentials.mjs";
9
- import { listOutbox } from "../outbox.mjs";
9
+ import { listOutbox, newestCheckpoint } from "../outbox.mjs";
10
10
  import { repoIdentity } from "../../vendor/hypher-git.mjs";
11
11
 
12
12
  export function lastSent(env) {
@@ -18,6 +18,39 @@ export function lastSent(env) {
18
18
  }
19
19
  }
20
20
 
21
+ const pad2 = (n) => String(n).padStart(2, "0");
22
+
23
+ /** The computer's clock time, like 14:32:05, and the same instant in UTC as an ISO string. */
24
+ export function clockTimes(ms) {
25
+ const at = new Date(ms);
26
+ return { local: `${pad2(at.getHours())}:${pad2(at.getMinutes())}:${pad2(at.getSeconds())}`, utc: at.toISOString() };
27
+ }
28
+
29
+ /** How long ago, in words a person reads at a glance. */
30
+ export function ageWords(ms) {
31
+ const seconds = Math.max(0, Math.round(ms / 1000));
32
+ if (seconds < 90) return `${seconds} s ago`;
33
+ const minutes = Math.round(seconds / 60);
34
+ if (minutes < 90) return `${minutes} min ago`;
35
+ return `${Math.round(minutes / 60)} h ago`;
36
+ }
37
+
38
+ /** The lines for the newest checkpoint of this worktree: its event, clock time, age, and whether it has left this computer. */
39
+ export function checkpointLines(found, now) {
40
+ if (!found) return [" Newest checkpoint: none yet for this worktree (one is written when a session starts and as files change)"];
41
+ const { body } = found;
42
+ const times = clockTimes(body.capturedAt);
43
+ const files = Array.isArray(body.changedFiles) ? body.changedFiles : [];
44
+ const where = found.state === "queued"
45
+ ? "Queued on this computer, not uploaded yet: it uploads at the next session start in this repository."
46
+ : `Uploaded to Hypher at ${clockTimes(found.uploadedAt).local} local.`;
47
+ return [
48
+ ` Newest checkpoint: ${body.event} · ${times.local} local (${times.utc} UTC) · ${ageWords(now - body.capturedAt)}`,
49
+ ` ${where}`,
50
+ ` ${files.length ? `files: ${files.slice(0, 12).join(", ")}${files.length > 12 ? ` (+${files.length - 12} more)` : ""}` : "no changed files"}`,
51
+ ];
52
+ }
53
+
21
54
  export async function run({ flags, io }) {
22
55
  const env = io.env;
23
56
  if (flags.last) {
@@ -34,5 +67,8 @@ export async function run({ flags, io }) {
34
67
  io.out(` Connected: ${credential.apiKey ? `yes · key "${config?.keyName ?? "Hypher CLI"}" (${credential.source})` : "no"}`);
35
68
  io.out(` Repository: ${identity.repository ?? "none"}${identity.repository ? (link ? ` → "${link.projectName}"` : " (not linked; run hypher link)") : ""}`);
36
69
  io.out(` Outbox: ${listOutbox(env).length} checkpoint(s) waiting`);
70
+ if (identity.repository && identity.branch) {
71
+ for (const line of checkpointLines(newestCheckpoint(env, identity.repository, identity.branch), Date.now())) io.out(line);
72
+ }
37
73
  return 0;
38
74
  }
@@ -16,6 +16,8 @@ import { removeBlock } from "../agentsBlock.mjs";
16
16
  import { removePathBlock } from "../shellPath.mjs";
17
17
  import { claudeHypherServer, claudeMcpRemove, detectTools } from "../tools.mjs";
18
18
  import { readJson, writeAtomic } from "../fsutil.mjs";
19
+ import { uninstallRepoKit } from "../repoKit.mjs";
20
+ import { repoIdentity } from "../../vendor/hypher-git.mjs";
19
21
 
20
22
  /**
21
23
  * How to take only Hypher's entries out of a file edited since install.
@@ -43,7 +45,29 @@ function readLine(stdin) {
43
45
  });
44
46
  }
45
47
 
48
+ /**
49
+ * `hypher uninstall --repo`: take the cloud kit out of this repository and nothing else. It needs no manifest in
50
+ * ~/.hypher and does not touch the key, the credential, or the hooks on this computer. Plain `uninstall` is unchanged.
51
+ */
52
+ function runRepo({ io }) {
53
+ const identity = repoIdentity(io.cwd);
54
+ if (!identity.root) {
55
+ io.err("hypher uninstall --repo: run this inside a git repository.");
56
+ return 2;
57
+ }
58
+ const undone = uninstallRepoKit(identity.root);
59
+ if (undone.length === 0) {
60
+ io.out("The Hypher cloud kit is not in this repository. Nothing to undo.");
61
+ return 0;
62
+ }
63
+ io.out("Hypher cloud kit removed from this repository.");
64
+ for (const line of undone) io.out(` ✓ ${line}`);
65
+ io.out(" Nothing was committed. Review with `git status`, then commit the removal.");
66
+ return 0;
67
+ }
68
+
46
69
  export async function run({ flags, io }) {
70
+ if (flags.repo) return runRepo({ io });
47
71
  const env = io.env;
48
72
  const p = paths(env);
49
73
  const manifest = readManifest(env);
package/src/env.mjs CHANGED
@@ -7,7 +7,7 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
 
9
9
  // Keep equal to package.json; a test checks it.
10
- export const CLI_VERSION = "0.2.0";
10
+ export const CLI_VERSION = "0.3.0";
11
11
  export const DEFAULT_API_BASE = "https://www.hypher.app";
12
12
  export const INTEGRATIONS_URL = "https://hypher.app/app/settings/integrations";
13
13
  export const API_KEYS_URL = "https://hypher.app/app/settings/api-keys";