@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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hypher ack`: acknowledge the handoff this session read. It records that
|
|
3
|
+
* the agent reports reading it, not that it used it correctly.
|
|
4
|
+
*/
|
|
5
|
+
import { createClient, HypherRejected, HypherUnavailable } from "../api.mjs";
|
|
6
|
+
import { readWorktreeState } from "../state.mjs";
|
|
7
|
+
import { repoIdentity } from "../../vendor/hypher-git.mjs";
|
|
8
|
+
|
|
9
|
+
/** @returns {Promise<{ ok: boolean, message: string }>} */
|
|
10
|
+
export async function acknowledgeHere({ env, cwd }) {
|
|
11
|
+
const root = repoIdentity(cwd).root;
|
|
12
|
+
const state = readWorktreeState(env, root);
|
|
13
|
+
const revision = state?.receiptRevision ?? state?.baseRevision;
|
|
14
|
+
if (!state?.receiptId || !revision) {
|
|
15
|
+
return { ok: false, message: "Nothing to acknowledge: no handoff was loaded in this repository. Run `hypher resume`." };
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const client = createClient({ env });
|
|
19
|
+
const { structured } = await client.tool("acknowledge_handoff", {
|
|
20
|
+
projectId: state.projectId,
|
|
21
|
+
receiptId: state.receiptId,
|
|
22
|
+
revision,
|
|
23
|
+
destination: state.destination ?? "agent",
|
|
24
|
+
});
|
|
25
|
+
if (!structured.ok) return { ok: false, message: `Hypher did not accept the acknowledgment: ${structured.error ?? "unknown error"}` };
|
|
26
|
+
return { ok: true, message: `Acknowledged handoff revision ${structured.revision}.` };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof HypherUnavailable) return { ok: false, message: "Hypher unavailable. Run `hypher doctor`." };
|
|
29
|
+
if (error instanceof HypherRejected) return { ok: false, message: error.message };
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function run({ io }) {
|
|
35
|
+
const result = await acknowledgeHere({ env: io.env, cwd: io.cwd });
|
|
36
|
+
io.out(result.message);
|
|
37
|
+
return result.ok ? 0 : 1;
|
|
38
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hypher doctor`: one line per piece, each with the fix when it is red.
|
|
3
|
+
* ✓ green, ✗ broken (exit 1), ! worth knowing, – not applicable.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { CLI_VERSION, INTEGRATIONS_URL, paths } from "../env.mjs";
|
|
7
|
+
import { linkFor, readConfig } from "../config.mjs";
|
|
8
|
+
import { loadCredential } from "../credentials.mjs";
|
|
9
|
+
import { createClient, HypherRejected, HypherUnavailable } from "../api.mjs";
|
|
10
|
+
import { installedHookEvents, CLAUDE_HOOKS } from "../claude.mjs";
|
|
11
|
+
import { claudeHypherServer, detectTools, isSignInEntry, projectLevelHypherEntry } from "../tools.mjs";
|
|
12
|
+
import { listOutbox } from "../outbox.mjs";
|
|
13
|
+
import { readBytes, readJson } from "../fsutil.mjs";
|
|
14
|
+
import { CODEX_HOOKS, codexHookTrust, codexHypherEntry, codexSupportsHooks, installedCodexHookEvents, readCodexHooks } from "../codex.mjs";
|
|
15
|
+
import { hasBlock } from "../agentsBlock.mjs";
|
|
16
|
+
import { remotesExposingHypherRefs, repoIdentity } from "../../vendor/hypher-git.mjs";
|
|
17
|
+
|
|
18
|
+
function pad(text, width = 22) {
|
|
19
|
+
return text.length >= width ? `${text} ` : text + " ".repeat(width - text.length);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function run({ io }) {
|
|
23
|
+
const env = io.env;
|
|
24
|
+
const p = paths(env);
|
|
25
|
+
const lines = [];
|
|
26
|
+
const add = (mark, label, detail, fix) => {
|
|
27
|
+
lines.push({ mark, text: ` ${mark} ${pad(label)}${detail}` });
|
|
28
|
+
if (fix) lines.push({ mark: " ", text: ` Fix: ${fix}` });
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const config = readConfig(env);
|
|
32
|
+
if (config?.installedVersion && existsSync(p.shim)) add("✓", `Hypher CLI ${config.installedVersion}`, p.shim);
|
|
33
|
+
else add("✗", `Hypher CLI ${CLI_VERSION}`, "not installed on this computer", "run `npx @litterthanlit/hypher-cli init` in a git repository.");
|
|
34
|
+
|
|
35
|
+
const credential = loadCredential(env);
|
|
36
|
+
if (credential.apiKey) add("✓", "Credential", `key "${config?.keyName ?? "Hypher CLI"}" (${credential.source})`);
|
|
37
|
+
else add("✗", "Credential", "no key for the hooks", "run `hypher init` and click Approve in the browser.");
|
|
38
|
+
|
|
39
|
+
const tools = detectTools(env);
|
|
40
|
+
if (tools.claude.found) {
|
|
41
|
+
const events = installedHookEvents(readJson(p.claudeSettings, {}) ?? {});
|
|
42
|
+
const entry = claudeHypherServer(env);
|
|
43
|
+
const mcp = entry ? (isSignInEntry(entry) ? "MCP hypher (sign-in)" : "MCP hypher (bridge)") : "no hypher MCP entry";
|
|
44
|
+
if (events.length === CLAUDE_HOOKS.length) add("✓", `Claude Code ${tools.claude.version}`, `hooks ${events.join(", ")} · ${mcp}`);
|
|
45
|
+
else add("✗", `Claude Code ${tools.claude.version}`, `hooks missing: ${CLAUDE_HOOKS.map((hook) => hook.event).filter((event) => !events.includes(event)).join(", ")}`, "run `hypher init`.");
|
|
46
|
+
} else {
|
|
47
|
+
add("–", "Claude Code", "not found");
|
|
48
|
+
}
|
|
49
|
+
if (tools.codex.found) {
|
|
50
|
+
const label = `Codex ${tools.codex.version}`;
|
|
51
|
+
const entry = codexHypherEntry(readBytes(p.codexConfig)?.toString("utf8") ?? "");
|
|
52
|
+
const mcp = entry ? `MCP hypher (${entry.kind === "sign-in" ? "sign-in" : entry.kind === "bridge" ? "bridge" : "custom"})` : "no hypher MCP entry";
|
|
53
|
+
const fallback = hasBlock(readBytes(p.codexAgents)?.toString("utf8") ?? "") ? " · AGENTS.md fallback" : "";
|
|
54
|
+
const events = installedCodexHookEvents(readCodexHooks(env));
|
|
55
|
+
if (events.length === CODEX_HOOKS.length) {
|
|
56
|
+
const trust = await codexHookTrust(env, io.cwd);
|
|
57
|
+
if (trust.known && trust.trusted) add("✓", label, `hooks ${events.join(", ")} (trusted) · ${mcp}${fallback}`);
|
|
58
|
+
else if (trust.known) {
|
|
59
|
+
add("!", label, `hooks ${events.join(", ")} are not trusted yet, so Codex skips them · ${mcp}${fallback}`,
|
|
60
|
+
"run /hooks in Codex and approve Hypher (once).");
|
|
61
|
+
} else {
|
|
62
|
+
add("!", label, `hooks ${events.join(", ")} · Codex did not say whether it trusts them · ${mcp}${fallback}`,
|
|
63
|
+
"run /hooks in Codex and check that Hypher's hooks are approved.");
|
|
64
|
+
}
|
|
65
|
+
} else if (codexSupportsHooks(env)) {
|
|
66
|
+
add("✗", label, `hooks missing: ${CODEX_HOOKS.map((hook) => hook.event).filter((event) => !events.includes(event)).join(", ")} · ${mcp}`, "run `hypher init`.");
|
|
67
|
+
} else if (entry && fallback) {
|
|
68
|
+
add("✓", label, `this version has no hooks; resume and seal through ${mcp}${fallback}`);
|
|
69
|
+
} else {
|
|
70
|
+
add("✗", label, `not set up (${mcp})`, "run `hypher init`.");
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
add("–", "Codex", "not found");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const identity = repoIdentity(io.cwd);
|
|
77
|
+
if (!identity.root) {
|
|
78
|
+
add("–", "Repository", "not in a git repository");
|
|
79
|
+
} else if (!identity.repository) {
|
|
80
|
+
add("–", "Repository", "no GitHub remote, so Hypher has no note for it");
|
|
81
|
+
} else {
|
|
82
|
+
const link = linkFor(identity.repository, env);
|
|
83
|
+
if (link) add("✓", "Repository", `${identity.repository} → project "${link.projectName ?? link.projectId}"`);
|
|
84
|
+
else add("✗", "Repository", `${identity.repository} is not linked; hooks send nothing here`, "run `hypher link` here, or link it in Settings → Integrations.");
|
|
85
|
+
const projectLevel = projectLevelHypherEntry(identity.root);
|
|
86
|
+
if (projectLevel) {
|
|
87
|
+
add("!", "Project-level MCP", `${projectLevel.file.slice(identity.root.length + 1)} has its own hypher entry, which overrides your user-level one in this repository (hooks still work)`,
|
|
88
|
+
"remove the hypher entry from .mcp.json if you want the user-level one here.");
|
|
89
|
+
}
|
|
90
|
+
const exposed = remotesExposingHypherRefs(identity.root);
|
|
91
|
+
if (exposed.length) {
|
|
92
|
+
add("!", "refs/hypher", `remote ${exposed.join(", ")} would push refs/hypher, which can hold untracked files`,
|
|
93
|
+
"drop the mirror setting or narrow the push refspec to refs/heads/*.");
|
|
94
|
+
} else {
|
|
95
|
+
add("✓", "refs/hypher", "stay local (no remote pushes them)");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const client = createClient({ env, timeoutMs: 5_000 });
|
|
100
|
+
if (credential.apiKey) {
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
try {
|
|
103
|
+
const { structured } = await client.tool("list_projects", {});
|
|
104
|
+
const count = Array.isArray(structured.projects) ? structured.projects.length : 0;
|
|
105
|
+
add("✓", "Hypher reachable", `${client.base} · ${Date.now() - started} ms · ${count} project${count === 1 ? "" : "s"}`);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof HypherUnavailable) {
|
|
108
|
+
add("✗", "Hypher unavailable", `${client.base} (${error.causeText || "no answer"})`,
|
|
109
|
+
"check your network, VPN, or proxy, then run `hypher doctor` again. Sessions start without Hypher until then.");
|
|
110
|
+
} else if (error instanceof HypherRejected) {
|
|
111
|
+
add("✗", "Hypher reachable", error.message, "run `hypher init` to connect this computer again.");
|
|
112
|
+
} else {
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
add("–", "Hypher reachable", `not checked without a key (${client.base})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const waiting = listOutbox(env);
|
|
121
|
+
const oldest = waiting[0] ? Math.round((Date.now() - waiting[0].item.queuedAt) / 60_000) : 0;
|
|
122
|
+
if (waiting.length === 0 || oldest < 30) add("✓", "Outbox", `${waiting.length} checkpoint${waiting.length === 1 ? "" : "s"} waiting`);
|
|
123
|
+
else add("!", "Outbox", `${waiting.length} checkpoints waiting, oldest ${oldest} min`, "run `hypher doctor` once Hypher is reachable; they upload on the next session start.");
|
|
124
|
+
|
|
125
|
+
io.out(`Hypher doctor · ${CLI_VERSION} · Pilot · not yet recorded`);
|
|
126
|
+
for (const line of lines) io.out(line.text);
|
|
127
|
+
const broken = lines.filter((line) => line.mark === "✗").length;
|
|
128
|
+
io.out(broken ? `${broken} problem${broken === 1 ? "" : "s"}. See the fix under each ✗.` : `All green. Integrations: ${INTEGRATIONS_URL}`);
|
|
129
|
+
return broken ? 1 : 0;
|
|
130
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hypher hook <agent> <event>`: the command every hook runs. It reads the
|
|
3
|
+
* payload on stdin, prints hook JSON when it has something to say, and always
|
|
4
|
+
* exits 0: a hook never breaks a session. Errors go to a local log.
|
|
5
|
+
*/
|
|
6
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { paths } from "../env.mjs";
|
|
9
|
+
import { HOOK_AGENTS, handleAgentHook } from "../hooks/index.mjs";
|
|
10
|
+
import { HYPHER_UNAVAILABLE_LINE } from "../../vendor/handoffNote.mjs";
|
|
11
|
+
|
|
12
|
+
const STDIN_TIMEOUT_MS = 2_000;
|
|
13
|
+
|
|
14
|
+
async function readPayload(stream) {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
const read = (async () => {
|
|
17
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
18
|
+
})();
|
|
19
|
+
let timer;
|
|
20
|
+
await Promise.race([read, new Promise((resolve) => { timer = setTimeout(resolve, STDIN_TIMEOUT_MS); })]);
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
24
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function logError(env, event, error) {
|
|
31
|
+
try {
|
|
32
|
+
const file = path.join(paths(env).stateDir, "hook-errors.log");
|
|
33
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
34
|
+
appendFileSync(file, `${new Date().toISOString()} ${event} ${error instanceof Error ? error.stack : String(error)}\n`);
|
|
35
|
+
} catch {
|
|
36
|
+
// Nothing else to do; the session must go on.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function run({ args, io }) {
|
|
41
|
+
const [agent, event] = args;
|
|
42
|
+
const payload = await readPayload(io.stdin);
|
|
43
|
+
try {
|
|
44
|
+
if (!HOOK_AGENTS.includes(agent)) return 0;
|
|
45
|
+
const output = await handleAgentHook(agent, event, payload, io.env);
|
|
46
|
+
if (output) io.stdout.write(`${JSON.stringify(output)}\n`);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logError(io.env, event, error);
|
|
49
|
+
if (event === "SessionStart") {
|
|
50
|
+
io.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: HYPHER_UNAVAILABLE_LINE } })}\n`);
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
// A client that keeps stdin open must not keep this hook alive.
|
|
54
|
+
io.stdin.destroy?.();
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hypher init`: one command and at most one browser approval.
|
|
3
|
+
* Installs a pinned copy under ~/.hypher, connects this computer, links this
|
|
4
|
+
* repository when a human picks its project, and adds Claude Code and Codex
|
|
5
|
+
* hooks.
|
|
6
|
+
* Every change goes into the manifest so `uninstall` can undo exactly it.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, cpSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { CLI_VERSION, API_KEYS_URL, apiBase, machineName, paths } from "../env.mjs";
|
|
12
|
+
import { readConfig, writeConfig, linkFor, saveLink } from "../config.mjs";
|
|
13
|
+
import { credentialPromptLine, credentialStore, loadCredential, saveCredential } from "../credentials.mjs";
|
|
14
|
+
import { createClient, HypherUnavailable } from "../api.mjs";
|
|
15
|
+
import { runDeviceFlow } from "../device.mjs";
|
|
16
|
+
import { readManifest, recordEntry, writeManifest, writeTrackedFile } from "../manifest.mjs";
|
|
17
|
+
import { mergeClaudeHooks, renderSettings, CLAUDE_HOOKS } from "../claude.mjs";
|
|
18
|
+
import { claudeHypherServer, claudeMcpAdd, detectTools, isSignInEntry } from "../tools.mjs";
|
|
19
|
+
import { readBytes, readJson } from "../fsutil.mjs";
|
|
20
|
+
import { CODEX_HOOKS, CODEX_TRUST_LINE, addBridge, codexHookTrust, codexHypherEntry, codexSupportsHooks, mergeCodexHooks, readCodexHooks } from "../codex.mjs";
|
|
21
|
+
import { renderJson } from "../hookConfig.mjs";
|
|
22
|
+
import { addBlock, agentsBlock, alreadyUsesHypher } from "../agentsBlock.mjs";
|
|
23
|
+
import { addRepositoryBlock, displayPath } from "../repoBlock.mjs";
|
|
24
|
+
import { repoIdentity } from "../../vendor/hypher-git.mjs";
|
|
25
|
+
|
|
26
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
27
|
+
|
|
28
|
+
function pad(text, width) {
|
|
29
|
+
return text.length >= width - 1 ? `${text} ` : text + " ".repeat(width - text.length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Copy this package to ~/.hypher/lib and point ~/.hypher/bin/hypher at it. */
|
|
33
|
+
export function installPinnedCopy(env, manifest) {
|
|
34
|
+
const p = paths(env);
|
|
35
|
+
const target = path.join(p.libDir, `cli-${CLI_VERSION}`);
|
|
36
|
+
if (path.resolve(PACKAGE_ROOT) !== path.resolve(target)) {
|
|
37
|
+
mkdirSync(p.libDir, { recursive: true });
|
|
38
|
+
cpSync(PACKAGE_ROOT, target, {
|
|
39
|
+
recursive: true,
|
|
40
|
+
filter: (source) => !/\/(node_modules|test)(\/|$)/.test(source.slice(PACKAGE_ROOT.length)),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const shim = [
|
|
44
|
+
"#!/bin/sh",
|
|
45
|
+
"# Hypher CLI. Installed by `hypher init`; removed by `hypher uninstall`.",
|
|
46
|
+
`exec "${process.execPath}" "${path.join(target, "bin", "hypher.mjs")}" "$@"`,
|
|
47
|
+
"",
|
|
48
|
+
].join("\n");
|
|
49
|
+
const change = writeTrackedFile(manifest, p.shim, shim, "shim");
|
|
50
|
+
chmodSync(p.shim, 0o755);
|
|
51
|
+
return change;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `--yes` never asks in the terminal (today init asks nothing there; the one
|
|
56
|
+
* approval is in the browser). `--json` prints one JSON summary on stdout.
|
|
57
|
+
* @param {{ args: string[], flags: Record<string, any>, io: any }} input
|
|
58
|
+
*/
|
|
59
|
+
export async function run({ flags, io }) {
|
|
60
|
+
const env = io.env;
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
let approvalMs = 0;
|
|
63
|
+
// --json: the human lines go to stderr, and stdout carries one JSON summary.
|
|
64
|
+
const say = flags.json ? (line = "") => io.err(line) : (line = "") => io.out(line);
|
|
65
|
+
const human = { ...io, out: say };
|
|
66
|
+
const p = paths(env);
|
|
67
|
+
const home = p.home;
|
|
68
|
+
const hypherHomeExisted = existsSync(p.hypherHome);
|
|
69
|
+
mkdirSync(p.hypherHome, { recursive: true });
|
|
70
|
+
const manifest = readManifest(env);
|
|
71
|
+
recordEntry(manifest, { kind: "hypher-home", id: "hypher-home", path: p.hypherHome,
|
|
72
|
+
createdByInit: manifest.entries.find((entry) => entry.id === "hypher-home")?.createdByInit ?? !hypherHomeExisted });
|
|
73
|
+
const changed = [];
|
|
74
|
+
const summary = { ok: true, tools: {}, changed, link: null, key: null };
|
|
75
|
+
|
|
76
|
+
say("Hypher · Baton (Pilot · not yet recorded)");
|
|
77
|
+
say("");
|
|
78
|
+
|
|
79
|
+
const tools = detectTools(env);
|
|
80
|
+
summary.tools = tools;
|
|
81
|
+
const claudeEntry = tools.claude.found ? claudeHypherServer(env) : null;
|
|
82
|
+
const codexText = readBytes(p.codexConfig)?.toString("utf8") ?? "";
|
|
83
|
+
const codexEntry = tools.codex.found ? codexHypherEntry(codexText) : null;
|
|
84
|
+
say(` ${tools.claude.found ? "✓" : "–"} ${pad(`Claude Code${tools.claude.version ? ` ${tools.claude.version}` : ""}`, 24)}${
|
|
85
|
+
tools.claude.found ? `found${isSignInEntry(claudeEntry) ? " · Hypher sign-in kept" : ""}` : "not found"}`);
|
|
86
|
+
say(` ${tools.codex.found ? "✓" : "–"} ${pad(`Codex${tools.codex.version ? ` ${tools.codex.version}` : ""}`, 24)}${
|
|
87
|
+
tools.codex.found ? `found${codexEntry?.kind === "sign-in" ? " · Hypher sign-in kept" : ""}` : "not found"}`);
|
|
88
|
+
say(` ${tools.cursor.found ? "✓" : "–"} ${pad("Cursor", 24)}${tools.cursor.found ? "found (use the Cursor plugin)" : "not found"}`);
|
|
89
|
+
say("");
|
|
90
|
+
|
|
91
|
+
// 1. Pinned copy, so nothing calls npx at session start.
|
|
92
|
+
const shimChange = installPinnedCopy(env, manifest);
|
|
93
|
+
if (shimChange !== "unchanged") changed.push([p.shim, `installed ${CLI_VERSION} (Hypher's own files stay in ~/.hypher)`]);
|
|
94
|
+
const config = { ...(readConfig(env) ?? {}) };
|
|
95
|
+
config.apiBase = apiBase(env, config);
|
|
96
|
+
config.machineName = config.machineName ?? machineName(env);
|
|
97
|
+
config.nodePath = process.execPath;
|
|
98
|
+
config.installedVersion = CLI_VERSION;
|
|
99
|
+
writeConfig(config, env);
|
|
100
|
+
writeManifest(manifest, env);
|
|
101
|
+
|
|
102
|
+
// 2. The hook credential.
|
|
103
|
+
const client = createClient({ env });
|
|
104
|
+
const identity = repoIdentity(io.cwd);
|
|
105
|
+
const repository = identity.repository;
|
|
106
|
+
let credential = loadCredential(env);
|
|
107
|
+
let approved = null;
|
|
108
|
+
if (credential.apiKey) {
|
|
109
|
+
say(` ✓ Connected · key "${config.keyName ?? "Hypher CLI"}" (${credential.source})`);
|
|
110
|
+
} else {
|
|
111
|
+
const store = credentialStore(env);
|
|
112
|
+
try {
|
|
113
|
+
const waitStarted = Date.now();
|
|
114
|
+
const flow = await runDeviceFlow(client, {
|
|
115
|
+
purpose: "connect",
|
|
116
|
+
repository: repository && !linkFor(repository, env) ? repository : null,
|
|
117
|
+
io: human,
|
|
118
|
+
noBrowser: flags["no-browser"],
|
|
119
|
+
});
|
|
120
|
+
approvalMs = Date.now() - waitStarted;
|
|
121
|
+
if (!flow.ok) {
|
|
122
|
+
say(` ✗ ${flow.error}`);
|
|
123
|
+
summary.ok = false;
|
|
124
|
+
} else {
|
|
125
|
+
approved = flow.result;
|
|
126
|
+
const promptLine = credentialPromptLine(store);
|
|
127
|
+
if (promptLine) say(` ${promptLine}`);
|
|
128
|
+
if (promptLine) summary.keychain = promptLine;
|
|
129
|
+
const saved = saveCredential(approved.apiKey, env);
|
|
130
|
+
if (!saved.ok) {
|
|
131
|
+
say(` ✗ ${saved.error}`);
|
|
132
|
+
summary.ok = false;
|
|
133
|
+
} else {
|
|
134
|
+
config.keyId = approved.keyId;
|
|
135
|
+
config.keyName = approved.keyName;
|
|
136
|
+
config.credentialStore = saved.store;
|
|
137
|
+
writeConfig(config, env);
|
|
138
|
+
recordEntry(manifest, { kind: "credential", id: "credential", store: saved.store });
|
|
139
|
+
recordEntry(manifest, { kind: "api-key", id: "api-key", keyId: approved.keyId, keyName: approved.keyName });
|
|
140
|
+
writeManifest(manifest, env);
|
|
141
|
+
summary.key = approved.keyName;
|
|
142
|
+
credential = loadCredential(env);
|
|
143
|
+
say(` ✓ Approved · key "${approved.keyName}" (revoke in Settings → API keys: ${API_KEYS_URL})`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!(error instanceof HypherUnavailable)) throw error;
|
|
148
|
+
say(` ✗ Hypher unavailable at ${client.base}. Check your connection, then run \`hypher init\` again.`);
|
|
149
|
+
summary.ok = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. This repository's project, picked by a human on the same page.
|
|
154
|
+
if (repository) {
|
|
155
|
+
const known = linkFor(repository, env);
|
|
156
|
+
if (known) {
|
|
157
|
+
say(` ✓ ${repository} → project "${known.projectName ?? known.projectId}"`);
|
|
158
|
+
summary.link = known;
|
|
159
|
+
} else if (approved?.link?.projectId) {
|
|
160
|
+
saveLink(repository, { projectId: approved.link.projectId, projectName: approved.link.projectName }, env);
|
|
161
|
+
say(` ✓ ${repository} → project "${approved.link.projectName ?? approved.link.projectId}"`);
|
|
162
|
+
summary.link = approved.link;
|
|
163
|
+
} else if (credential.apiKey) {
|
|
164
|
+
say(` – ${repository} is not linked. Run \`hypher link\` here to pick its project.`);
|
|
165
|
+
}
|
|
166
|
+
} else if (identity.root) {
|
|
167
|
+
say(" – This repository has no GitHub remote, so Hypher cannot link it.");
|
|
168
|
+
}
|
|
169
|
+
// The repository's AGENTS.md tells any agent to resume first and save at the end.
|
|
170
|
+
if (summary.link && identity.root) {
|
|
171
|
+
const block = addRepositoryBlock(manifest, identity.root);
|
|
172
|
+
writeManifest(manifest, env);
|
|
173
|
+
if (block.change === "skipped") say(" ✓ AGENTS.md already tells agents to use Hypher; left as is");
|
|
174
|
+
else if (block.change !== "unchanged") changed.push([block.file, "+ Hypher block (resume first, save at the end)"]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 4. Claude Code: hooks, and the bridge only when M0a's sign-in is absent.
|
|
178
|
+
if (tools.claude.found) {
|
|
179
|
+
const settings = readJson(p.claudeSettings, {}) ?? {};
|
|
180
|
+
const merged = mergeClaudeHooks(settings && typeof settings === "object" ? settings : {}, p.shim);
|
|
181
|
+
const change = writeTrackedFile(manifest, p.claudeSettings, renderSettings(merged), "claude-settings");
|
|
182
|
+
if (change !== "unchanged") changed.push([p.claudeSettings, `+ hooks ${CLAUDE_HOOKS.map((hook) => hook.event).join(", ")}`]);
|
|
183
|
+
writeManifest(manifest, env);
|
|
184
|
+
if (!claudeEntry) {
|
|
185
|
+
const added = claudeMcpAdd(env, p.shim);
|
|
186
|
+
if (added.status === 0) {
|
|
187
|
+
recordEntry(manifest, { kind: "claude-mcp", id: "claude-mcp", name: "hypher", command: p.shim });
|
|
188
|
+
writeManifest(manifest, env);
|
|
189
|
+
changed.push([p.claudeJson, "+ MCP server hypher (local bridge)"]);
|
|
190
|
+
} else {
|
|
191
|
+
say(" ✗ Could not add the Hypher MCP server to Claude Code. Run `hypher doctor`.");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 5. Codex: the bridge unless a hypher entry is there, hooks where this version has them,
|
|
197
|
+
// and the ~/.codex/AGENTS.md fallback for when the hooks are not trusted yet.
|
|
198
|
+
let codexHooks = false;
|
|
199
|
+
if (tools.codex.found) {
|
|
200
|
+
if (!codexEntry) {
|
|
201
|
+
const change = writeTrackedFile(manifest, p.codexConfig, addBridge(codexText, p.shim), "codex-config");
|
|
202
|
+
if (change !== "unchanged") changed.push([p.codexConfig, "+ MCP server hypher (bridge; resume, seal, acknowledge never prompt)"]);
|
|
203
|
+
}
|
|
204
|
+
if (codexSupportsHooks(env)) {
|
|
205
|
+
codexHooks = true;
|
|
206
|
+
const change = writeTrackedFile(manifest, p.codexHooks, renderJson(mergeCodexHooks(readCodexHooks(env), p.shim)), "codex-hooks");
|
|
207
|
+
if (change !== "unchanged") changed.push([p.codexHooks, `+ hooks ${CODEX_HOOKS.map((hook) => hook.event).join(", ")}`]);
|
|
208
|
+
}
|
|
209
|
+
const agentsText = readBytes(p.codexAgents)?.toString("utf8") ?? "";
|
|
210
|
+
if (!alreadyUsesHypher(agentsText)) {
|
|
211
|
+
const change = writeTrackedFile(manifest, p.codexAgents, addBlock(agentsText, agentsBlock("codex")), "codex-agents");
|
|
212
|
+
if (change !== "unchanged") changed.push([p.codexAgents, `+ Hypher block (${codexHooks ? "fallback until the hooks are trusted" : "this Codex has no hooks"})`]);
|
|
213
|
+
}
|
|
214
|
+
writeManifest(manifest, env);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
say("");
|
|
218
|
+
if (changed.length) {
|
|
219
|
+
say(" Changed");
|
|
220
|
+
for (const [file, what] of changed) say(` ${pad(displayPath(file, { cwd: io.cwd, home }), 28)}${what}`);
|
|
221
|
+
} else {
|
|
222
|
+
say(" Already set up. Nothing changed.");
|
|
223
|
+
}
|
|
224
|
+
say("");
|
|
225
|
+
say(" Any other agent: add this MCP server, or run `hypher resume` in its terminal.");
|
|
226
|
+
say(` "hypher": { "command": "${p.shim}", "args": ["mcp"] }`);
|
|
227
|
+
say("");
|
|
228
|
+
const humanSteps = [];
|
|
229
|
+
if (codexHooks) {
|
|
230
|
+
const trust = await codexHookTrust(env, io.cwd);
|
|
231
|
+
if (trust.known && trust.trusted) say(" ✓ Codex trusts Hypher's hooks.");
|
|
232
|
+
else {
|
|
233
|
+
say(` ${CODEX_TRUST_LINE}`);
|
|
234
|
+
humanSteps.push(CODEX_TRUST_LINE);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
say(" Undo everything: hypher uninstall");
|
|
238
|
+
const ms = Date.now() - started;
|
|
239
|
+
say(` Done in ${(ms / 1000).toFixed(1)} s${approvalMs ? ` (${((ms - approvalMs) / 1000).toFixed(1)} s without the wait for your approval)` : ""}.`);
|
|
240
|
+
if (flags.json) {
|
|
241
|
+
io.out(JSON.stringify({
|
|
242
|
+
...summary,
|
|
243
|
+
alreadySetUp: changed.length === 0,
|
|
244
|
+
changed: changed.map(([file, what]) => ({ file, what })),
|
|
245
|
+
mcpEntry: { hypher: { command: p.shim, args: ["mcp"] } },
|
|
246
|
+
humanSteps,
|
|
247
|
+
ms,
|
|
248
|
+
approvalMs,
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
return summary.ok ? 0 : 1;
|
|
252
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hypher link`: bind this repository to a Hypher project. A human picks the
|
|
3
|
+
* project in the browser; Hypher never creates one on its own.
|
|
4
|
+
*/
|
|
5
|
+
import { createClient, HypherUnavailable } from "../api.mjs";
|
|
6
|
+
import { linkFor, saveLink } from "../config.mjs";
|
|
7
|
+
import { runDeviceFlow } from "../device.mjs";
|
|
8
|
+
import { repoIdentity } from "../../vendor/hypher-git.mjs";
|
|
9
|
+
import { readManifest, writeManifest } from "../manifest.mjs";
|
|
10
|
+
import { addRepositoryBlock, displayPath } from "../repoBlock.mjs";
|
|
11
|
+
import { paths } from "../env.mjs";
|
|
12
|
+
|
|
13
|
+
/** Link saved: add the AGENTS.md block and say what changed. */
|
|
14
|
+
function linked(io, identity, link) {
|
|
15
|
+
saveLink(identity.repository, link, io.env);
|
|
16
|
+
io.out(`✓ ${identity.repository} → project "${link.projectName}"`);
|
|
17
|
+
const manifest = readManifest(io.env);
|
|
18
|
+
const block = addRepositoryBlock(manifest, identity.root);
|
|
19
|
+
writeManifest(manifest, io.env);
|
|
20
|
+
const shown = displayPath(block.file, { cwd: io.cwd, home: paths(io.env).home });
|
|
21
|
+
if (block.change === "skipped") io.out(` ${shown} already tells agents to use Hypher; left as is`);
|
|
22
|
+
else if (block.change !== "unchanged") io.out(` ${shown}: + Hypher block (resume first, save at the end). Commit it so teammates' agents see it; \`hypher uninstall\` removes it.`);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function run({ flags, io }) {
|
|
27
|
+
const identity = repoIdentity(io.cwd);
|
|
28
|
+
if (!identity.root) {
|
|
29
|
+
io.err("hypher link: run this inside a git repository.");
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
if (!identity.repository) {
|
|
33
|
+
io.err("hypher link: this repository has no GitHub remote, so Hypher cannot link it.");
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
const client = createClient({ env: io.env });
|
|
37
|
+
if (!client.apiKey) {
|
|
38
|
+
io.err("hypher link: this computer is not connected. Run `hypher init` first.");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
// Already linked in Hypher (for example in Settings → Integrations)? Then no click is needed.
|
|
43
|
+
const resolved = await client.tool("resolve_project_for_repo", { repo: identity.repository });
|
|
44
|
+
if (resolved.structured.matched && resolved.structured.projectId) {
|
|
45
|
+
return linked(io, identity, { projectId: resolved.structured.projectId, projectName: resolved.structured.projectName });
|
|
46
|
+
}
|
|
47
|
+
const known = linkFor(identity.repository, io.env);
|
|
48
|
+
if (known) io.out(`${identity.repository} was linked to "${known.projectName}" on this computer, but Hypher no longer has that link.`);
|
|
49
|
+
const flow = await runDeviceFlow(client, { purpose: "link", repository: identity.repository, io, noBrowser: flags["no-browser"] });
|
|
50
|
+
if (!flow.ok) {
|
|
51
|
+
io.out(`✗ ${flow.error}`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
const link = flow.result.link;
|
|
55
|
+
if (!link?.projectId) {
|
|
56
|
+
io.out("✗ No project was picked.");
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
return linked(io, identity, { projectId: link.projectId, projectName: link.projectName });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof HypherUnavailable)) throw error;
|
|
62
|
+
io.out("Hypher unavailable. Run `hypher doctor`.");
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
}
|