octarin-cli 0.2.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/dist/init.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `octarin init` — personal-machine capture install.
3
+ *
4
+ * The TypeScript port of the old `install.sh`. Does everything locally from the
5
+ * package's bundled assets (no `curl`, no remote script, no sha256 dance):
6
+ *
7
+ * 1. Write the ingest key + URL to ~/.octarin/octarin.env (mode 600).
8
+ * 2. For each detected tool (Claude Code / Cursor / Codex), copy its hook
9
+ * files into the tool's global config dir and *merge* the hook
10
+ * registration into that tool's settings so capture turns on with no
11
+ * hand-editing.
12
+ * 3. Optionally import existing history via the bundled backfill.py (python3).
13
+ *
14
+ * Idempotent: re-running re-copies files, rewrites the env, and dedupes hook
15
+ * registrations (matched by an "octarin" marker in the command).
16
+ */
17
+ import { promises as fs } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, resolve as resolvePath } from "node:path";
20
+ import { spawn } from "node:child_process";
21
+ import { CliError, resolveBaseUrl } from "./client.js";
22
+ import { logErr } from "./output.js";
23
+ import { flagBool, flagStr } from "./args.js";
24
+ import { mergeUserEnv } from "./envfile.js";
25
+ import { assertAssets, assetPath } from "./assets.js";
26
+ export const INIT_HELP = `octarin init — install AI-coding capture on this machine
27
+
28
+ USAGE
29
+ octarin init <oct_ingest_key> [options]
30
+
31
+ Installs Claude Code / Cursor / Codex capture hooks into your global config
32
+ and streams your AI-coding sessions to your Octarin workspace. Copy the key
33
+ (and this command) from your Octarin dashboard's Settings → Capture install.
34
+
35
+ ARGUMENTS
36
+ <oct_ingest_key> Your project's write-only ingest key (oct_...). Or set
37
+ OCTARIN_API_KEY / pass --key.
38
+
39
+ OPTIONS
40
+ --tools <list> Comma-separated subset of claude,cursor,codex. Default:
41
+ whichever tools are detected on this machine.
42
+ --backfill <spec> Import existing history: 30d | 90d | all | off.
43
+ Default: 90d. Requires python3 (skipped if absent).
44
+ --base-url <url> API base URL. Or set OCTARIN_BASE_URL.
45
+ --url <url> Ingest URL. Default: <base-url>/v1/ingest.
46
+ --port <port> Override the port (self-host parity).
47
+ -h, --help Show this help.
48
+
49
+ WHAT IT TOUCHES
50
+ ~/.octarin/octarin.env your ingest key (mode 600, never committed)
51
+ ~/.claude, ~/.cursor, ~/.codex hook files + a merged hook registration
52
+
53
+ For a whole team, use \`octarin init-repo <org/project>\` instead — it commits
54
+ shared capture config to a repo so every teammate streams on clone.
55
+ `;
56
+ const HOME = homedir();
57
+ function tools() {
58
+ return [
59
+ {
60
+ name: "claude",
61
+ baseDir: resolvePath(HOME, ".claude"),
62
+ destDir: resolvePath(HOME, ".claude", "octarin"),
63
+ files: [["claude_code/hook.py", "hook.py"]],
64
+ exec: { bin: "python3", entry: "hook.py" },
65
+ register: (runSh) => mergeClaudeSettings(resolvePath(HOME, ".claude", "settings.json"), `bash "${runSh}"`),
66
+ },
67
+ {
68
+ name: "cursor",
69
+ baseDir: resolvePath(HOME, ".cursor"),
70
+ destDir: resolvePath(HOME, ".cursor", "octarin"),
71
+ files: [
72
+ ["cursor/hook-handler.js", "hook-handler.js"],
73
+ ["cursor/lib/utils.js", "lib/utils.js"],
74
+ ["cursor/lib/canonical.js", "lib/canonical.js"],
75
+ ],
76
+ exec: { bin: "node", entry: "hook-handler.js" },
77
+ register: (runSh) => mergeCursorHooks(resolvePath(HOME, ".cursor", "hooks.json"), `bash "${runSh}"`),
78
+ },
79
+ {
80
+ name: "codex",
81
+ baseDir: resolvePath(HOME, ".codex"),
82
+ destDir: resolvePath(HOME, ".codex", "octarin"),
83
+ files: [["codex/hook.mjs", "hook.mjs"]],
84
+ exec: { bin: "node", entry: "hook.mjs" },
85
+ register: (runSh) => mergeCodexConfig(resolvePath(HOME, ".codex", "config.toml"), runSh),
86
+ },
87
+ ];
88
+ }
89
+ // ─────────────────────────────── run.sh wrapper ──────────────────────────────
90
+ /**
91
+ * Generate the global hook wrapper. Sources ~/.octarin/octarin.env (so the
92
+ * hook sees the key without it being in the shell), then execs the hook. Fails
93
+ * open: any problem exits 0 so the editor is never blocked.
94
+ */
95
+ function runShContents(spec) {
96
+ const args = spec.exec.bin === "node" ? ' "$@"' : "";
97
+ return `#!/usr/bin/env bash
98
+ # Octarin ${spec.name} capture wrapper (global install). Fails open.
99
+ set +e
100
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
101
+ if [ -f "$HOME/.octarin/octarin.env" ]; then
102
+ set -a
103
+ # shellcheck disable=SC1091
104
+ . "$HOME/.octarin/octarin.env"
105
+ set +a
106
+ fi
107
+ if command -v ${spec.exec.bin} >/dev/null 2>&1; then
108
+ exec ${spec.exec.bin} "$SCRIPT_DIR/${spec.exec.entry}"${args}
109
+ fi
110
+ exit 0
111
+ `;
112
+ }
113
+ // ─────────────────────────────── config merges ───────────────────────────────
114
+ async function readJson(path) {
115
+ try {
116
+ const parsed = JSON.parse(await fs.readFile(path, "utf8"));
117
+ return parsed && typeof parsed === "object" ? parsed : {};
118
+ }
119
+ catch {
120
+ return {};
121
+ }
122
+ }
123
+ /**
124
+ * Does any command string in this hook structure already point at Octarin?
125
+ * Matched case-insensitively against the stable "octarin" path segment every
126
+ * registration carries (hooks live under ~/.<tool>/octarin/). This is the
127
+ * idempotency guard — it must catch our own prior registration on re-run.
128
+ */
129
+ function hasOctarin(value) {
130
+ return JSON.stringify(value ?? "").toLowerCase().includes("octarin");
131
+ }
132
+ /** Merge the Stop hook into ~/.claude/settings.json, preserving other settings. */
133
+ async function mergeClaudeSettings(path, command) {
134
+ const json = await readJson(path);
135
+ const hooks = (json.hooks ??= {});
136
+ const stop = (Array.isArray(hooks.Stop) ? hooks.Stop : (hooks.Stop = []));
137
+ if (!stop.some(hasOctarin)) {
138
+ stop.push({ hooks: [{ type: "command", command }] });
139
+ }
140
+ await fs.mkdir(dirname(path), { recursive: true });
141
+ await fs.writeFile(path, JSON.stringify(json, null, 2) + "\n");
142
+ return { path };
143
+ }
144
+ const CURSOR_EVENTS = [
145
+ "sessionStart",
146
+ "beforeSubmitPrompt",
147
+ "afterAgentResponse",
148
+ "postToolUse",
149
+ "postToolUseFailure",
150
+ "preCompact",
151
+ "stop",
152
+ "sessionEnd",
153
+ ];
154
+ /** Merge the capture events into ~/.cursor/hooks.json, preserving other hooks. */
155
+ async function mergeCursorHooks(path, command) {
156
+ const json = await readJson(path);
157
+ json.version ??= 1;
158
+ const hooks = (json.hooks ??= {});
159
+ for (const ev of CURSOR_EVENTS) {
160
+ const arr = (Array.isArray(hooks[ev]) ? hooks[ev] : (hooks[ev] = []));
161
+ if (!arr.some(hasOctarin))
162
+ arr.push({ command });
163
+ }
164
+ await fs.mkdir(dirname(path), { recursive: true });
165
+ await fs.writeFile(path, JSON.stringify(json, null, 2) + "\n");
166
+ return { path };
167
+ }
168
+ /**
169
+ * Merge the `notify` hook into ~/.codex/config.toml. Codex supports a single
170
+ * top-level `notify`, so if a non-Octarin one exists we replace it and flag
171
+ * `replaced` so the caller can warn the user.
172
+ */
173
+ async function mergeCodexConfig(path, runShAbs) {
174
+ let text = "";
175
+ try {
176
+ text = await fs.readFile(path, "utf8");
177
+ }
178
+ catch {
179
+ // first run
180
+ }
181
+ const line = `notify = ["bash", ${JSON.stringify(runShAbs)}]`;
182
+ let replaced = false;
183
+ if (hasOctarin(text)) {
184
+ // already ours — rewrite the line in case the path changed
185
+ text = text.replace(/^\s*notify\s*=.*$/m, line);
186
+ }
187
+ else if (/^\s*notify\s*=/m.test(text)) {
188
+ text = text.replace(/^\s*notify\s*=.*$/m, line);
189
+ replaced = true;
190
+ }
191
+ else {
192
+ const prefix = text.trimEnd();
193
+ text = `${prefix}${prefix ? "\n\n" : ""}# Octarin capture\n${line}\n`;
194
+ }
195
+ await fs.mkdir(dirname(path), { recursive: true });
196
+ await fs.writeFile(path, text);
197
+ return { path, replaced };
198
+ }
199
+ // ─────────────────────────────── install steps ───────────────────────────────
200
+ /** Copy a tool's hook files from bundled assets, write its run.sh, register it. */
201
+ async function installTool(spec) {
202
+ await fs.mkdir(spec.destDir, { recursive: true });
203
+ for (const [src, dest] of spec.files) {
204
+ const target = resolvePath(spec.destDir, dest);
205
+ await fs.mkdir(dirname(target), { recursive: true });
206
+ await fs.copyFile(assetPath(src), target);
207
+ }
208
+ const runSh = resolvePath(spec.destDir, "run.sh");
209
+ await fs.writeFile(runSh, runShContents(spec), { mode: 0o755 });
210
+ await fs.chmod(runSh, 0o755);
211
+ const result = await spec.register(runSh);
212
+ logErr(` ✓ ${spec.name}: hooks → ${spec.destDir}, registered in ${result.path}`);
213
+ if (result.replaced) {
214
+ logErr(` note: replaced an existing \`notify\` in config.toml. If you had a custom ` +
215
+ `Codex notify, re-add it (Codex allows only one).`);
216
+ }
217
+ }
218
+ /** Run the bundled backfill.py via python3 with the install's credentials. */
219
+ async function runBackfill(spec) {
220
+ const hasPython = await commandExists("python3");
221
+ if (!hasPython) {
222
+ logErr(" (skipping history import: python3 not found)");
223
+ return;
224
+ }
225
+ const args = [assetPath("backfill.py")];
226
+ if (spec.since && spec.since !== "all")
227
+ args.push("--since", spec.since);
228
+ logErr(`> importing existing history (--since ${spec.since}) ...`);
229
+ await new Promise((resolveDone) => {
230
+ const child = spawn("python3", args, {
231
+ stdio: "inherit",
232
+ env: {
233
+ ...process.env,
234
+ OCTARIN_INGEST_URL: spec.ingestUrl,
235
+ OCTARIN_API_KEY: spec.apiKey,
236
+ },
237
+ });
238
+ // Backfill is best-effort: a non-zero exit shouldn't fail the install.
239
+ child.on("error", () => {
240
+ logErr(" (history import could not start; skipping)");
241
+ resolveDone();
242
+ });
243
+ child.on("close", () => resolveDone());
244
+ });
245
+ }
246
+ /** Best-effort check that `bin` is runnable (resolves false on ENOENT). */
247
+ function commandExists(bin) {
248
+ return new Promise((res) => {
249
+ const child = spawn(bin, ["--version"], { stdio: "ignore" });
250
+ child.on("error", () => res(false)); // ENOENT etc.
251
+ child.on("close", () => res(true));
252
+ });
253
+ }
254
+ // ─────────────────────────────── command ─────────────────────────────────────
255
+ export async function cmdInit(positionals, flags) {
256
+ if (flagBool(flags, "help")) {
257
+ logErr(INIT_HELP);
258
+ return;
259
+ }
260
+ assertAssets();
261
+ // Resolve the ingest key: positional > --key > OCTARIN_API_KEY.
262
+ const apiKey = (positionals[0] || flagStr(flags, "key") || process.env.OCTARIN_API_KEY || "").trim();
263
+ if (!apiKey) {
264
+ throw new CliError("Missing ingest key. Usage: octarin init <oct_...> (copy it from your Octarin dashboard).", 2);
265
+ }
266
+ const baseUrl = resolveBaseUrl({ baseUrl: flagStr(flags, "base-url"), port: flagStr(flags, "port") });
267
+ const ingestUrl = flagStr(flags, "url") || `${baseUrl}/v1/ingest`;
268
+ // Resolve which tools to install for.
269
+ const all = tools();
270
+ const wanted = flagStr(flags, "tools");
271
+ let selected;
272
+ if (wanted) {
273
+ const names = new Set(wanted.split(",").map((s) => s.trim()).filter(Boolean));
274
+ selected = all.filter((t) => names.has(t.name));
275
+ if (selected.length === 0) {
276
+ throw new CliError(`--tools matched nothing. Choose from: claude, cursor, codex.`, 2);
277
+ }
278
+ }
279
+ else {
280
+ const detected = await detectTools(all);
281
+ // Nothing detected (fresh machine) → install all three so capture turns on
282
+ // as soon as any editor is used.
283
+ selected = detected.length > 0 ? detected : all;
284
+ }
285
+ // 1. Credentials.
286
+ const envPath = await mergeUserEnv({ OCTARIN_INGEST_URL: ingestUrl, OCTARIN_API_KEY: apiKey }, "# Octarin capture credentials. Written by `octarin init`. Do not commit.");
287
+ logErr(`> wrote ${envPath} (mode 600)`);
288
+ // 2. Hooks per tool.
289
+ logErr(`> installing capture hooks for: ${selected.map((t) => t.name).join(", ")}`);
290
+ for (const spec of selected) {
291
+ await installTool(spec);
292
+ }
293
+ // 3. History import (unless disabled).
294
+ const backfill = (flagStr(flags, "backfill") || "90d").trim().toLowerCase();
295
+ if (backfill !== "off" && backfill !== "0" && backfill !== "none") {
296
+ await runBackfill({ ingestUrl, apiKey, since: backfill });
297
+ }
298
+ logErr("");
299
+ logErr("✓ Octarin capture is on. New Claude Code / Cursor / Codex sessions will stream to your workspace.");
300
+ }
301
+ /** Return the subset of tools whose global config dir already exists. */
302
+ async function detectTools(all) {
303
+ const out = [];
304
+ for (const t of all) {
305
+ try {
306
+ await fs.access(t.baseDir);
307
+ out.push(t);
308
+ }
309
+ catch {
310
+ // not present
311
+ }
312
+ }
313
+ return out;
314
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * `octarin init-repo <org/project>` — team capture install (commits config).
3
+ *
4
+ * The TypeScript port of `repo-install.sh`. Run inside a git repo, it adds
5
+ * COMMITTED Cursor/Claude/Codex capture config so every teammate who clones the
6
+ * repo streams their AI-coding usage to the team's shared Octarin workspace.
7
+ *
8
+ * NO SECRETS ARE COMMITTED. It writes a public `.octarin/project` (deliberately
9
+ * not `.octarin.env`, which the *.env gitignore rule would swallow) carrying
10
+ * only the project slug + ingest URL. Each teammate runs `npx octarin-cli login`
11
+ * once to mint a per-user key into ~/.octarin/octarin.env.
12
+ *
13
+ * To avoid disturbing the user's checkout, when the repo has a remote we make
14
+ * all edits + the commit in a throwaway git worktree branched off
15
+ * origin/<default>, push that branch, and open a PR. Local-only repos commit in
16
+ * place on the current branch (no PR flow worth gating on).
17
+ */
18
+ import { promises as fs } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { spawn } from "node:child_process";
22
+ import { CliError, resolveBaseUrl } from "./client.js";
23
+ import { logErr } from "./output.js";
24
+ import { flagBool, flagStr } from "./args.js";
25
+ import { assertAssets, assetPath } from "./assets.js";
26
+ export const INIT_REPO_HELP = `octarin init-repo — commit team capture config to a repo
27
+
28
+ USAGE
29
+ octarin init-repo <org/project> [options]
30
+
31
+ Run inside a git repo. Adds committed Claude Code / Cursor / Codex capture
32
+ config so every teammate who clones streams their AI-coding usage to your
33
+ team's Octarin workspace. No secret is committed — each teammate runs
34
+ \`npx octarin-cli login\` once to mint their own per-user key.
35
+
36
+ ARGUMENTS
37
+ <org/project> Your public project slug (e.g. nace/default). Find it on
38
+ your Octarin dashboard's Settings → Capture install.
39
+ Or set OCTARIN_PROJECT.
40
+
41
+ OPTIONS
42
+ --base-url <url> API base URL. Or set OCTARIN_BASE_URL.
43
+ --url <url> Ingest URL written into .octarin/project.
44
+ Default: <base-url>/v1/ingest.
45
+ --port <port> Override the port (self-host parity).
46
+ -h, --help Show this help.
47
+
48
+ WHAT IT DOES
49
+ Branches off origin/<default> in a temp worktree (your checkout is untouched),
50
+ writes .claude/.cursor/.codex hook config + .octarin/project, commits, pushes,
51
+ and opens a PR (via \`gh\` if available, else prints a compare URL).
52
+ `;
53
+ /** Run a command, capturing output. Never throws — inspect `code`. */
54
+ function run(cmd, args, cwd) {
55
+ return new Promise((res) => {
56
+ const child = spawn(cmd, args, { cwd });
57
+ let stdout = "";
58
+ let stderr = "";
59
+ child.stdout?.on("data", (d) => (stdout += d.toString()));
60
+ child.stderr?.on("data", (d) => (stderr += d.toString()));
61
+ child.on("error", (e) => res({ code: -1, stdout, stderr: String(e) }));
62
+ child.on("close", (code) => res({ code: code ?? -1, stdout, stderr }));
63
+ });
64
+ }
65
+ const git = (args, cwd) => run("git", args, cwd);
66
+ /** True if the git invocation exits 0. */
67
+ async function gitOk(args, cwd) {
68
+ return (await git(args, cwd)).code === 0;
69
+ }
70
+ /** Trimmed stdout of a git invocation, or "" on failure. */
71
+ async function gitOut(args, cwd) {
72
+ const r = await git(args, cwd);
73
+ return r.code === 0 ? r.stdout.trim() : "";
74
+ }
75
+ function say(msg) {
76
+ logErr(`[octarin] ${msg}`);
77
+ }
78
+ /** Best-effort browser open; never blocks or throws (headless → no-op). */
79
+ function openUrl(url) {
80
+ if (!url)
81
+ return;
82
+ const [cmd, args] = process.platform === "darwin"
83
+ ? ["open", [url]]
84
+ : process.platform === "win32"
85
+ ? ["cmd", ["/c", "start", "", url]]
86
+ : ["xdg-open", [url]];
87
+ try {
88
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
89
+ child.on("error", () => { });
90
+ child.unref();
91
+ }
92
+ catch {
93
+ // ignore
94
+ }
95
+ }
96
+ /** Recursively chmod +x every *.sh under `dir`. */
97
+ async function chmodShExecutable(dir) {
98
+ const entries = await fs.readdir(dir, { withFileTypes: true });
99
+ for (const e of entries) {
100
+ const p = join(dir, e.name);
101
+ if (e.isDirectory())
102
+ await chmodShExecutable(p);
103
+ else if (e.name.endsWith(".sh"))
104
+ await fs.chmod(p, 0o755).catch(() => { });
105
+ }
106
+ }
107
+ // ──────────────────────────────── command ────────────────────────────────────
108
+ export async function cmdInitRepo(positionals, flags) {
109
+ if (flagBool(flags, "help")) {
110
+ logErr(INIT_REPO_HELP);
111
+ return;
112
+ }
113
+ assertAssets();
114
+ const project = (positionals[0] || process.env.OCTARIN_PROJECT || "").trim();
115
+ if (!project) {
116
+ throw new CliError("Missing project slug. Usage: octarin init-repo <org/project> (find it on your dashboard).", 2);
117
+ }
118
+ const baseUrl = resolveBaseUrl({ baseUrl: flagStr(flags, "base-url"), port: flagStr(flags, "port") });
119
+ const ingestUrl = flagStr(flags, "url") || `${baseUrl}/v1/ingest`;
120
+ // ── repo preconditions ──────────────────────────────────────────────────
121
+ if (!(await gitOk(["--version"]))) {
122
+ throw new CliError("git not found; run this inside a git repository.", 1);
123
+ }
124
+ if (!(await gitOk(["rev-parse", "--is-inside-work-tree"]))) {
125
+ throw new CliError("not a git repository. cd into your repo and re-run.", 1);
126
+ }
127
+ const repoRoot = await gitOut(["rev-parse", "--show-toplevel"]);
128
+ say(`installing committed team capture into: ${repoRoot}`);
129
+ // ── detect remote + default branch ──────────────────────────────────────
130
+ const currentBranch = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot);
131
+ let defaultBranch = "";
132
+ if (await gitOk(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], repoRoot)) {
133
+ defaultBranch = (await gitOut(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repoRoot))
134
+ .replace(/^origin\//, "");
135
+ }
136
+ if (!defaultBranch) {
137
+ for (const c of ["main", "master", "trunk"]) {
138
+ if (await gitOk(["show-ref", "--verify", "--quiet", `refs/heads/${c}`], repoRoot)) {
139
+ defaultBranch = c;
140
+ break;
141
+ }
142
+ }
143
+ }
144
+ const hasRemote = await gitOk(["remote", "get-url", "origin"], repoRoot);
145
+ // ── worktree dance (never disturb the user's checkout) ───────────────────
146
+ let pushBranch = "";
147
+ let worktreePath = "";
148
+ let workDir = repoRoot;
149
+ if (hasRemote) {
150
+ say(`fetching origin/${defaultBranch} (to branch off its current tip) ...`);
151
+ await git(["fetch", "--quiet", "origin", defaultBranch], repoRoot);
152
+ const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15);
153
+ pushBranch = `octarin/add-capture-${stamp}`;
154
+ worktreePath = await fs.mkdtemp(join(tmpdir(), "octarin-install-"));
155
+ say(`creating temp worktree (your '${currentBranch}' checkout stays untouched)`);
156
+ const fromRemote = await gitOk(["worktree", "add", "-q", "-b", pushBranch, worktreePath, `origin/${defaultBranch}`], repoRoot);
157
+ if (!fromRemote) {
158
+ const fromLocal = await gitOk(["worktree", "add", "-q", "-b", pushBranch, worktreePath, defaultBranch], repoRoot);
159
+ if (!fromLocal) {
160
+ throw new CliError("could not create worktree (out of disk? branch name collision?)", 1);
161
+ }
162
+ }
163
+ workDir = worktreePath;
164
+ say(` worktree branch: ${pushBranch} (off origin/${defaultBranch})`);
165
+ }
166
+ else if (!currentBranch || currentBranch === "HEAD") {
167
+ throw new CliError("detached HEAD with no remote — check out a branch first.", 1);
168
+ }
169
+ else {
170
+ say(`no remote configured — committing directly to '${currentBranch}' (no PR flow available)`);
171
+ }
172
+ try {
173
+ await applyAndCommit({
174
+ workDir,
175
+ project,
176
+ ingestUrl,
177
+ hasRemote,
178
+ pushBranch,
179
+ defaultBranch,
180
+ repoRoot,
181
+ });
182
+ }
183
+ finally {
184
+ // Always tear down the temp worktree, even on failure mid-install.
185
+ if (worktreePath) {
186
+ await git(["worktree", "remove", "--force", worktreePath], repoRoot);
187
+ await fs.rm(worktreePath, { recursive: true, force: true }).catch(() => { });
188
+ }
189
+ }
190
+ logErr("");
191
+ say("Done. Teammates run this ONCE on each of their machines (from any clone):");
192
+ say(" npx octarin-cli@latest login");
193
+ say("It opens a browser, signs them into Octarin, and writes their per-user");
194
+ say("ingest key to ~/.octarin/octarin.env. Nothing secret is committed.");
195
+ }
196
+ async function applyAndCommit(ctx) {
197
+ const { workDir, project, ingestUrl } = ctx;
198
+ const added = [];
199
+ // Extract each bundled dot-<tool> template into the repo as .<tool>.
200
+ const templateRoot = assetPath("repo-template");
201
+ for (const name of await fs.readdir(templateRoot)) {
202
+ if (!name.startsWith("dot-"))
203
+ continue;
204
+ const src = join(templateRoot, name);
205
+ const stat = await fs.stat(src);
206
+ if (!stat.isDirectory())
207
+ continue;
208
+ const dest = `.${name.slice("dot-".length)}`; // dot-claude → .claude
209
+ const destAbs = join(workDir, dest);
210
+ await fs.mkdir(destAbs, { recursive: true });
211
+ await fs.cp(src, destAbs, { recursive: true });
212
+ await chmodShExecutable(destAbs);
213
+ added.push(dest);
214
+ say(` wrote ${dest}/`);
215
+ }
216
+ if (added.length === 0) {
217
+ throw new CliError("bundled template contained no dot-<tool> directories.", 1);
218
+ }
219
+ // Committed public config — never a secret.
220
+ const octarinDir = join(workDir, ".octarin");
221
+ await fs.mkdir(octarinDir, { recursive: true });
222
+ const projectFile = join(octarinDir, "project");
223
+ await fs.writeFile(projectFile, [
224
+ "# Octarin team capture config (committed; SAFE to share — no secret here).",
225
+ "# Each teammate runs `npx octarin-cli login` once on their machine — the per-user",
226
+ "# ingest key lands in ~/.octarin/octarin.env (mode 600), never in git.",
227
+ `OCTARIN_PROJECT=${project}`,
228
+ `OCTARIN_INGEST_URL=${ingestUrl}`,
229
+ "",
230
+ ].join("\n"));
231
+ added.push(".octarin/project");
232
+ say(" wrote .octarin/project (project + ingest URL; no key)");
233
+ // Guard: never commit the per-machine personal install secret.
234
+ const gitignorePath = join(workDir, ".gitignore");
235
+ let gitignore = "";
236
+ try {
237
+ gitignore = await fs.readFile(gitignorePath, "utf8");
238
+ }
239
+ catch {
240
+ // none yet
241
+ }
242
+ if (!/^\.octarin\/octarin\.env$/m.test(gitignore)) {
243
+ const addition = "# Octarin: never commit per-machine personal install secrets\n.octarin/octarin.env\n";
244
+ gitignore = gitignore ? `${gitignore.replace(/\n*$/, "\n")}\n${addition}` : addition;
245
+ await fs.writeFile(gitignorePath, gitignore);
246
+ }
247
+ // ── git add -f (paths are often gitignored) + commit ──────────────────────
248
+ await git(["add", "-f", ...added, ".gitignore"], workDir);
249
+ if (await gitOk(["diff", "--cached", "--quiet"], workDir)) {
250
+ say("no changes to commit (config already present + up to date).");
251
+ return;
252
+ }
253
+ // Commit with a fallback identity only if the repo has none.
254
+ const hasIdentity = await gitOk(["config", "user.email"], workDir);
255
+ const commitArgs = hasIdentity
256
+ ? ["commit", "-q", "-m", "Add Octarin capture"]
257
+ : [
258
+ "-c",
259
+ "user.email=octarin@octarin.ai",
260
+ "-c",
261
+ "user.name=Octarin",
262
+ "commit",
263
+ "-q",
264
+ "-m",
265
+ "Add Octarin capture",
266
+ ];
267
+ if (!(await gitOk(commitArgs, workDir))) {
268
+ throw new CliError("commit failed.", 1);
269
+ }
270
+ say("committed: Add Octarin capture");
271
+ await pushAndOpenPr(ctx);
272
+ }
273
+ async function pushAndOpenPr(ctx) {
274
+ const { workDir, project, pushBranch, defaultBranch, hasRemote } = ctx;
275
+ if (pushBranch) {
276
+ if (!(await gitOk(["push", "-u", "origin", pushBranch], workDir))) {
277
+ say(`could not push automatically — run 'git push -u origin ${pushBranch}' when ready.`);
278
+ return;
279
+ }
280
+ say(`pushed: ${pushBranch} (NOT ${defaultBranch} — opening a PR)`);
281
+ const prBody = `Octarin team capture for ${project}.\n\n` +
282
+ "Per-user keys live in ~/.octarin/octarin.env (`npx octarin-cli login` on each " +
283
+ "teammate's machine); nothing secret is committed.";
284
+ let openTarget = "";
285
+ // Try `gh` first (auto-create the PR), else fall back to a compare URL.
286
+ const ghAvailable = (await run("gh", ["--version"])).code === 0;
287
+ const ghAuthed = ghAvailable && (await run("gh", ["auth", "status"])).code === 0;
288
+ if (ghAuthed) {
289
+ const r = await run("gh", ["pr", "create", "--base", defaultBranch, "--head", pushBranch, "--title", "Add Octarin capture", "--body", prBody], workDir);
290
+ const combined = `${r.stdout}\n${r.stderr}`;
291
+ if (r.code === 0) {
292
+ const url = (r.stdout.match(/https?:\/\/\S+/) || [""])[0];
293
+ say(`✓ PR opened: ${url || "(see gh output)"}`);
294
+ openTarget = url;
295
+ }
296
+ else {
297
+ const existing = (combined.match(/https?:\/\/\S+\/pull\/\d+/) || [""])[0];
298
+ if (existing) {
299
+ say(`PR already exists: ${existing}`);
300
+ openTarget = existing;
301
+ }
302
+ else {
303
+ say("(`gh pr create` failed — falling back to a URL you can open manually)");
304
+ }
305
+ }
306
+ }
307
+ else if (ghAvailable) {
308
+ say("(`gh` is installed but not signed in — run `gh auth login` to auto-create PRs)");
309
+ }
310
+ // Fallback: a compare/MR-new URL derived from the remote.
311
+ if (!openTarget) {
312
+ openTarget = await compareUrl(workDir, defaultBranch, pushBranch);
313
+ if (openTarget)
314
+ say(`Open the PR: ${openTarget}`);
315
+ else
316
+ say(`Open a PR for ${pushBranch} against ${defaultBranch} in your repo hosting.`);
317
+ }
318
+ openUrl(openTarget);
319
+ return;
320
+ }
321
+ // No push branch: local-only repo, or current branch with upstream.
322
+ if (!hasRemote) {
323
+ say("committed locally — no remote to push to (this is a local-only repo).");
324
+ return;
325
+ }
326
+ if (await gitOk(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], workDir)) {
327
+ if (await gitOk(["push"], workDir))
328
+ say("pushed to upstream.");
329
+ else
330
+ say("could not push automatically — run 'git push' when ready.");
331
+ }
332
+ else {
333
+ say("no upstream set for this branch — run 'git push -u origin <branch>' to share.");
334
+ }
335
+ }
336
+ /** Build a GitHub compare / GitLab MR-new URL from origin's remote URL. */
337
+ async function compareUrl(workDir, base, branch) {
338
+ const remote = await gitOut(["remote", "get-url", "origin"], workDir);
339
+ const gh = remote.match(/github\.com[:/](.+?)(?:\.git)?$/);
340
+ if (gh)
341
+ return `https://github.com/${gh[1]}/compare/${base}...${branch}?expand=1`;
342
+ const gl = remote.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
343
+ if (gl) {
344
+ return (`https://gitlab.com/${gl[1]}/-/merge_requests/new` +
345
+ `?merge_request%5Bsource_branch%5D=${branch}&merge_request%5Btarget_branch%5D=${base}`);
346
+ }
347
+ return "";
348
+ }