@videlic/connect 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.
Files changed (4) hide show
  1. package/README.md +28 -0
  2. package/hook.mjs +204 -0
  3. package/install.mjs +135 -0
  4. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @videlic/connect
2
+
3
+ Connects your coding agent to [Videlic](https://videlic.dev). One cross-platform
4
+ command — works the same in PowerShell, cmd, bash, and zsh:
5
+
6
+ ```
7
+ npx @videlic/connect ao_your_key
8
+ ```
9
+
10
+ It registers a Claude Code **Stop hook** so every finished session is sent to
11
+ Videlic for review. No bash, no `jq`, no `curl | sh` — just the Node that Claude
12
+ Code already ships with, so macOS, Windows, and Linux use the identical command.
13
+
14
+ ## What it does
15
+
16
+ 1. Confirms Claude Code is present (`~/.claude`).
17
+ 2. Validates your key against the Videlic API.
18
+ 3. Copies the hook to `~/.claude/videlic-hook.mjs`.
19
+ 4. Writes `~/.claude/videlic.json` (`key`, `apiUrl`, `installedAt`).
20
+ 5. Registers the hook in `~/.claude/settings.json` (idempotent — re-running
21
+ upgrades in place and migrates off the older shell hook).
22
+
23
+ ## Notes
24
+
25
+ - The hook only runs when a session **ends** — nothing in the background.
26
+ - Sessions that started **before** install are skipped (consent guard).
27
+ - Remove it by deleting the `Stop` entry in `~/.claude/settings.json`.
28
+ - `VIDELIC_API_URL` overrides the API endpoint (defaults to production).
package/hook.mjs ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // Managed by @videlic/connect — do not edit by hand.
3
+ //
4
+ // Runs as a Claude Code Stop hook (`node videlic-hook.mjs`). Reads the stop
5
+ // event JSON on stdin, resolves the transcript, attaches git metadata, and
6
+ // POSTs to Videlic's /v1/ingest. Cross-platform: pure Node, no bash/jq/curl.
7
+ //
8
+ // Resilient, like the original shell hook:
9
+ // • skips sessions that predate install (consent guard),
10
+ // • tail-truncates transcripts over 8 MB so edge gateways don't 413,
11
+ // • retries 5xx up to 3× with backoff; gives up on 4xx,
12
+ // • logs HTTP code + body to ~/.claude/videlic-hook.log on failure.
13
+
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import {
17
+ existsSync,
18
+ readFileSync,
19
+ statSync,
20
+ openSync,
21
+ readSync,
22
+ closeSync,
23
+ appendFileSync,
24
+ } from "node:fs";
25
+ import { execFileSync } from "node:child_process";
26
+
27
+ const claudeDir = join(homedir(), ".claude");
28
+ const logPath = join(claudeDir, "videlic-hook.log");
29
+ const log = (m) => {
30
+ try {
31
+ appendFileSync(logPath, `[${new Date().toISOString()}] videlic: ${m}\n`);
32
+ } catch {
33
+ /* logging is best-effort */
34
+ }
35
+ };
36
+
37
+ const MAX_BYTES = 8_000_000;
38
+
39
+ function readConfig() {
40
+ const p = join(claudeDir, "videlic.json");
41
+ if (!existsSync(p)) return null;
42
+ try {
43
+ const cfg = JSON.parse(readFileSync(p, "utf8"));
44
+ return cfg?.key && cfg?.apiUrl ? cfg : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ async function readStdin() {
51
+ const chunks = [];
52
+ for await (const chunk of process.stdin) chunks.push(chunk);
53
+ return Buffer.concat(chunks).toString("utf8");
54
+ }
55
+
56
+ function readFirstLine(path) {
57
+ const fd = openSync(path, "r");
58
+ try {
59
+ const buf = Buffer.alloc(65536);
60
+ const n = readSync(fd, buf, 0, buf.length, 0);
61
+ const s = buf.toString("utf8", 0, n);
62
+ const nl = s.indexOf("\n");
63
+ return nl === -1 ? s : s.slice(0, nl);
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+
69
+ function readTail(path, maxBytes) {
70
+ const size = statSync(path).size;
71
+ const start = Math.max(0, size - maxBytes);
72
+ const fd = openSync(path, "r");
73
+ try {
74
+ const buf = Buffer.alloc(size - start);
75
+ readSync(fd, buf, 0, buf.length, start);
76
+ let s = buf.toString("utf8");
77
+ // Drop the partial first line so the parser never sees half a JSON object.
78
+ if (start > 0) {
79
+ const nl = s.indexOf("\n");
80
+ if (nl !== -1) s = s.slice(nl + 1);
81
+ }
82
+ return s;
83
+ } finally {
84
+ closeSync(fd);
85
+ }
86
+ }
87
+
88
+ function gitMeta(cwd) {
89
+ const out = { branch: "", commitSha: "", repoUrl: "" };
90
+ if (!cwd) return out;
91
+ const git = (args) => {
92
+ try {
93
+ return execFileSync("git", ["-C", cwd, ...args], {
94
+ stdio: ["ignore", "pipe", "ignore"],
95
+ })
96
+ .toString()
97
+ .trim();
98
+ } catch {
99
+ return "";
100
+ }
101
+ };
102
+ if (git(["rev-parse", "--is-inside-work-tree"]) === "true") {
103
+ out.branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
104
+ out.commitSha = git(["rev-parse", "HEAD"]);
105
+ out.repoUrl = git(["config", "--get", "remote.origin.url"]);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ async function main() {
111
+ const cfg = readConfig();
112
+ if (!cfg) process.exit(0); // not configured — nothing to do
113
+
114
+ const raw = await readStdin();
115
+ let payload;
116
+ try {
117
+ payload = JSON.parse(raw);
118
+ } catch {
119
+ log("skip: unparseable stop payload");
120
+ return;
121
+ }
122
+
123
+ const transcript = payload.transcript_path;
124
+ const sessionId = payload.session_id || "";
125
+ const cwd = payload.cwd || "";
126
+
127
+ if (!transcript || !existsSync(transcript)) {
128
+ log("skip: no transcript_path");
129
+ return;
130
+ }
131
+ const size = statSync(transcript).size;
132
+ if (size === 0) {
133
+ log("skip: empty transcript");
134
+ return;
135
+ }
136
+
137
+ // Consent guard: a chat that started before install was never opted in.
138
+ if (cfg.installedAt) {
139
+ try {
140
+ const ts = JSON.parse(readFirstLine(transcript))?.timestamp;
141
+ if (ts && new Date(ts).getTime() < new Date(cfg.installedAt).getTime()) {
142
+ log(`skip: pre-install session (${ts} < ${cfg.installedAt})`);
143
+ return;
144
+ }
145
+ } catch {
146
+ /* if we can't read the timestamp, fall through and send */
147
+ }
148
+ }
149
+
150
+ let sessionData;
151
+ let truncated = false;
152
+ if (size > MAX_BYTES) {
153
+ sessionData = readTail(transcript, MAX_BYTES);
154
+ truncated = true;
155
+ log(`truncate: ${size} -> ~${sessionData.length} bytes (tail)`);
156
+ } else {
157
+ sessionData = readFileSync(transcript, "utf8");
158
+ }
159
+
160
+ const { branch, commitSha, repoUrl } = gitMeta(cwd);
161
+
162
+ const body = { agent: "claude-code", sessionData };
163
+ if (sessionId) body.sessionFileId = sessionId;
164
+ if (branch) body.branch = branch;
165
+ if (commitSha) body.commitSha = commitSha;
166
+ if (repoUrl) body.repoUrl = repoUrl;
167
+ if (truncated) body.truncated = true;
168
+
169
+ const url = `${cfg.apiUrl.replace(/\/+$/, "")}/v1/ingest`;
170
+ for (let attempt = 1; attempt <= 3; attempt++) {
171
+ let status = 0;
172
+ let detail = "";
173
+ try {
174
+ const res = await fetch(url, {
175
+ method: "POST",
176
+ headers: {
177
+ Authorization: `Bearer ${cfg.key}`,
178
+ "Content-Type": "application/json",
179
+ },
180
+ body: JSON.stringify(body),
181
+ signal: AbortSignal.timeout(60_000),
182
+ });
183
+ status = res.status;
184
+ if (status >= 200 && status < 300) {
185
+ log(`ingest OK (${status})`);
186
+ return;
187
+ }
188
+ detail = (await res.text().catch(() => "")).slice(0, 300);
189
+ } catch (e) {
190
+ detail = String(e?.message ?? e);
191
+ }
192
+ if (status >= 400 && status < 500) {
193
+ log(`ingest failed ${status}: ${detail}`); // client error — don't retry
194
+ return;
195
+ }
196
+ log(`ingest attempt ${attempt} -> ${status || "error"} (${detail})`);
197
+ if (attempt < 3) await new Promise((r) => setTimeout(r, attempt * 2000));
198
+ }
199
+ }
200
+
201
+ main().catch((e) => {
202
+ log(`fatal: ${e?.message ?? e}`);
203
+ process.exit(0); // never break the user's session on our account
204
+ });
package/install.mjs ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // @videlic/connect — connects your coding agent to Videlic.
3
+ //
4
+ // One cross-platform command (run via `npx @videlic/connect <key>`) that:
5
+ // 1. confirms Claude Code is present (~/.claude),
6
+ // 2. validates the key against the Videlic API,
7
+ // 3. copies the Node Stop-hook into ~/.claude,
8
+ // 4. writes ~/.claude/videlic.json (key + apiUrl + installedAt),
9
+ // 5. registers the hook in ~/.claude/settings.json — idempotently, migrating
10
+ // off any older bash hook so it never double-fires,
11
+ // 6. pings once so the install page flips to "All set".
12
+ //
13
+ // No bash, no jq, no curl | sh. Same behaviour on macOS, Windows, and Linux.
14
+
15
+ import { homedir } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ copyFileSync,
23
+ chmodSync,
24
+ unlinkSync,
25
+ } from "node:fs";
26
+
27
+ const API_URL = (process.env.VIDELIC_API_URL || "https://api.videlic.dev").replace(/\/+$/, "");
28
+ const KEY = (process.argv[2] || process.env.VIDELIC_KEY || "").trim();
29
+
30
+ const C = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", red: "\x1b[31m" };
31
+ const ok = (m) => console.log(`${C.green}✓${C.reset} ${m}`);
32
+ const fail = (m) => {
33
+ console.error(`${C.red}✗${C.reset} ${m}`);
34
+ process.exit(1);
35
+ };
36
+
37
+ console.log(`\n${C.bold}Videlic${C.reset}\n${C.dim}───────${C.reset}`);
38
+
39
+ if (!KEY) fail("Missing key.\n Usage: npx @videlic/connect ao_xxxxxxxx");
40
+ if (!/^ao_[A-Za-z0-9]{8,}$/.test(KEY)) {
41
+ fail("That doesn't look like a Videlic key (expected ao_…).");
42
+ }
43
+
44
+ const claudeDir = join(homedir(), ".claude");
45
+ if (!existsSync(claudeDir)) {
46
+ fail(
47
+ "~/.claude not found — install Claude Code first:\n" +
48
+ " https://docs.anthropic.com/claude-code",
49
+ );
50
+ }
51
+ ok("Coding agent detected");
52
+
53
+ // 1. Validate the key.
54
+ try {
55
+ const res = await fetch(`${API_URL}/v1/ping`, {
56
+ headers: { Authorization: `Bearer ${KEY}` },
57
+ signal: AbortSignal.timeout(15_000),
58
+ });
59
+ if (res.status !== 200) {
60
+ fail(
61
+ `Key validation failed (HTTP ${res.status}).\n` +
62
+ " It was likely rotated or deleted — grab a fresh one at https://videlic.dev/install",
63
+ );
64
+ }
65
+ } catch (e) {
66
+ fail(`Couldn't reach Videlic (${e?.message ?? e}). Check your connection and retry.`);
67
+ }
68
+ ok("Key valid");
69
+
70
+ // 2. Copy the Node hook + write config (the hook reads key/apiUrl/installedAt here).
71
+ const here = dirname(fileURLToPath(import.meta.url));
72
+ const hookDest = join(claudeDir, "videlic-hook.mjs");
73
+ copyFileSync(join(here, "hook.mjs"), hookDest);
74
+
75
+ const configPath = join(claudeDir, "videlic.json");
76
+ writeFileSync(
77
+ configPath,
78
+ JSON.stringify({ key: KEY, apiUrl: API_URL, installedAt: new Date().toISOString() }, null, 2),
79
+ );
80
+ try {
81
+ chmodSync(configPath, 0o600);
82
+ } catch {
83
+ /* best-effort (no-op on some Windows filesystems) */
84
+ }
85
+ ok("Hook installed");
86
+
87
+ // 3. Register the Stop hook in settings.json — idempotent + migrates off any
88
+ // older entry (marked `videlic: true`, or any command mentioning videlic-hook,
89
+ // including the legacy videlic-hook.sh) so it never fires twice.
90
+ const settingsPath = join(claudeDir, "settings.json");
91
+ let settings = {};
92
+ if (existsSync(settingsPath)) {
93
+ try {
94
+ settings = JSON.parse(readFileSync(settingsPath, "utf8") || "{}");
95
+ } catch {
96
+ settings = {};
97
+ }
98
+ }
99
+ settings.hooks ??= {};
100
+ const prevStop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
101
+ const command = `node "${hookDest}"`;
102
+ const isVidelic = (entry) =>
103
+ entry?.videlic === true ||
104
+ (Array.isArray(entry?.hooks) &&
105
+ entry.hooks.some((h) => typeof h?.command === "string" && /videlic-hook/.test(h.command)));
106
+ settings.hooks.Stop = [
107
+ ...prevStop.filter((e) => !isVidelic(e)),
108
+ { matcher: "", videlic: true, hooks: [{ type: "command", command }] },
109
+ ];
110
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
111
+ ok("Registered with your agent");
112
+
113
+ // 4. Remove the legacy bash hook so it can't double-fire alongside the Node one.
114
+ try {
115
+ const legacy = join(claudeDir, "videlic-hook.sh");
116
+ if (existsSync(legacy)) unlinkSync(legacy);
117
+ } catch {
118
+ /* best-effort */
119
+ }
120
+
121
+ // 5. Final ping — stamps last-used so /install flips to "All set" right away.
122
+ try {
123
+ await fetch(`${API_URL}/v1/ping`, {
124
+ method: "POST",
125
+ headers: { Authorization: `Bearer ${KEY}` },
126
+ signal: AbortSignal.timeout(10_000),
127
+ });
128
+ } catch {
129
+ /* non-fatal */
130
+ }
131
+
132
+ console.log(
133
+ `\n${C.green}All set.${C.reset} Finish your next session and Videlic will post a review\n` +
134
+ "on the pull request that follows.\n",
135
+ );
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@videlic/connect",
3
+ "version": "0.1.0",
4
+ "description": "Connect your coding agent to Videlic — installs a Stop hook that sends each finished session for review. Works on macOS, Windows, and Linux.",
5
+ "type": "module",
6
+ "bin": {
7
+ "connect": "./install.mjs"
8
+ },
9
+ "files": [
10
+ "install.mjs",
11
+ "hook.mjs",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "license": "UNLICENSED",
18
+ "homepage": "https://videlic.dev",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ }
22
+ }