gigarag-claude-code 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 (59) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.mcp.json +8 -0
  3. package/README.md +34 -0
  4. package/agents/gigarag-indexer.md +72 -0
  5. package/bin/gigarag +4 -0
  6. package/cli/auth/credentials.js +106 -0
  7. package/cli/auth/oauth.js +203 -0
  8. package/cli/auth/page.js +68 -0
  9. package/cli/bin.js +8 -0
  10. package/cli/cli.js +90 -0
  11. package/cli/clients/commands.js +160 -0
  12. package/cli/clients/connect.js +217 -0
  13. package/cli/clients/inspect.js +74 -0
  14. package/cli/clients/json.js +135 -0
  15. package/cli/clients/launcher.js +71 -0
  16. package/cli/clients/registry.js +40 -0
  17. package/cli/clients/toml.js +169 -0
  18. package/cli/clients/tomlarray.js +121 -0
  19. package/cli/clients/yaml.js +146 -0
  20. package/cli/clients.json +1226 -0
  21. package/cli/commands/authHeader.js +22 -0
  22. package/cli/commands/connect.js +285 -0
  23. package/cli/commands/indexSync.js +46 -0
  24. package/cli/commands/login.js +129 -0
  25. package/cli/commands/mcp.js +22 -0
  26. package/cli/commands/record.js +72 -0
  27. package/cli/commands/repo.js +48 -0
  28. package/cli/commands/scan.js +72 -0
  29. package/cli/commands/status.js +115 -0
  30. package/cli/config.js +69 -0
  31. package/cli/connect.js +8 -0
  32. package/cli/constants.js +24 -0
  33. package/cli/hooks.js +151 -0
  34. package/cli/index.js +3 -0
  35. package/cli/mcp/bridge.js +123 -0
  36. package/cli/mcp/client.js +156 -0
  37. package/cli/mcp/session.js +79 -0
  38. package/cli/package.json +5 -0
  39. package/cli/paths.js +34 -0
  40. package/cli/prompts.generated.js +44 -0
  41. package/cli/prompts.js +48 -0
  42. package/cli/scan/chunk.js +43 -0
  43. package/cli/scan/ignore.js +117 -0
  44. package/cli/scan/repo.js +99 -0
  45. package/cli/scan/scan.js +262 -0
  46. package/cli/scan.js +5 -0
  47. package/cli/sdk.js +130 -0
  48. package/cli/secrets.js +192 -0
  49. package/cli/secureUrl.js +18 -0
  50. package/cli/state.js +210 -0
  51. package/cli/ui.js +66 -0
  52. package/commands/gigadocs.md +19 -0
  53. package/commands/gigaindex.md +10 -0
  54. package/commands/gigarecall.md +15 -0
  55. package/commands/gigasave.md +24 -0
  56. package/commands/gigasync.md +9 -0
  57. package/hooks/hooks.json +38 -0
  58. package/package.json +26 -0
  59. package/scripts/run.mjs +64 -0
package/cli/state.js ADDED
@@ -0,0 +1,210 @@
1
+ import { createRequire } from 'node:module';
2
+ import { chmodSync, mkdirSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { stateDir } from './paths.js';
5
+ /**
6
+ * node:sqlite prints an ExperimentalWarning the first time it loads. A hook's
7
+ * stderr lands in the middle of somebody's terminal session, so the warning is
8
+ * swallowed for the one require and the original handler put back.
9
+ */
10
+ function loadSqlite() {
11
+ const original = process.emitWarning;
12
+ process.emitWarning = ((warning, ...rest) => {
13
+ const type = typeof rest[0] === 'string' ? rest[0] : rest[0]?.type;
14
+ if (type === 'ExperimentalWarning' || (warning instanceof Error && warning.name === 'ExperimentalWarning'))
15
+ return;
16
+ return original.call(process, warning, ...rest);
17
+ });
18
+ try {
19
+ return createRequire(import.meta.url)('node:sqlite');
20
+ }
21
+ finally {
22
+ process.emitWarning = original;
23
+ }
24
+ }
25
+ const SCHEMA = `
26
+ CREATE TABLE IF NOT EXISTS repos (
27
+ slug TEXT PRIMARY KEY, root TEXT NOT NULL, indexed_at INTEGER
28
+ );
29
+ CREATE TABLE IF NOT EXISTS files (
30
+ repo TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL,
31
+ dirty INTEGER NOT NULL DEFAULT 0, indexed_at INTEGER,
32
+ PRIMARY KEY (repo, path)
33
+ );
34
+ CREATE TABLE IF NOT EXISTS links (
35
+ repo TEXT NOT NULL, path TEXT NOT NULL, node_id TEXT NOT NULL,
36
+ PRIMARY KEY (repo, path, node_id)
37
+ );
38
+ CREATE TABLE IF NOT EXISTS nodes (
39
+ node_id TEXT PRIMARY KEY, ref TEXT, title TEXT NOT NULL, type TEXT,
40
+ tokens INTEGER, touched_at INTEGER NOT NULL
41
+ );
42
+ CREATE INDEX IF NOT EXISTS nodes_touched ON nodes (touched_at DESC);
43
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
44
+ `;
45
+ export class State {
46
+ db;
47
+ cache = new Map();
48
+ constructor(path = join(stateDir(), 'state.db')) {
49
+ // Private, like the key file beside it: memo titles and repository paths are not for other users of the machine.
50
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
51
+ const { DatabaseSync } = loadSqlite();
52
+ this.db = new DatabaseSync(path);
53
+ // Several Claude Code sessions write to this file at once, each from its own hook process.
54
+ this.db.exec('PRAGMA busy_timeout = 2000; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;');
55
+ this.db.exec(SCHEMA);
56
+ try {
57
+ chmodSync(path, 0o600);
58
+ }
59
+ catch {
60
+ /* Windows has no chmod that means anything */
61
+ }
62
+ }
63
+ stmt(sql) {
64
+ let s = this.cache.get(sql);
65
+ if (!s) {
66
+ s = this.db.prepare(sql);
67
+ this.cache.set(sql, s);
68
+ }
69
+ return s;
70
+ }
71
+ close() {
72
+ this.db.close();
73
+ }
74
+ transaction(fn) {
75
+ this.db.exec('BEGIN IMMEDIATE');
76
+ try {
77
+ const out = fn();
78
+ this.db.exec('COMMIT');
79
+ return out;
80
+ }
81
+ catch (err) {
82
+ this.db.exec('ROLLBACK');
83
+ throw err;
84
+ }
85
+ }
86
+ // --- meta ---------------------------------------------------------------
87
+ getMeta(key) {
88
+ return this.stmt('SELECT value FROM meta WHERE key = ?').get(key)?.value;
89
+ }
90
+ setMeta(key, value) {
91
+ this.stmt('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
92
+ }
93
+ /**
94
+ * Takes a lock that lapses after `ttlMs`, in one transaction, so two workers starting together
95
+ * cannot both see it free. Returns whether this caller now holds it.
96
+ */
97
+ tryAcquire(key, ttlMs, now = Date.now()) {
98
+ return this.transaction(() => {
99
+ if (now - Number(this.getMeta(key) ?? 0) < ttlMs)
100
+ return false;
101
+ this.setMeta(key, String(now));
102
+ return true;
103
+ });
104
+ }
105
+ // --- repos and files ------------------------------------------------------
106
+ upsertRepo(slug, root, indexedAt) {
107
+ this.stmt(`INSERT INTO repos (slug, root, indexed_at) VALUES (?, ?, ?)
108
+ ON CONFLICT(slug) DO UPDATE SET root = excluded.root, indexed_at = COALESCE(excluded.indexed_at, indexed_at)`).run(slug, root, indexedAt ?? null);
109
+ }
110
+ listRepos() {
111
+ return this.stmt('SELECT slug, root, indexed_at FROM repos ORDER BY slug').all();
112
+ }
113
+ hasRepo(slug) {
114
+ return this.stmt('SELECT 1 AS x FROM repos WHERE slug = ?').get(slug) !== undefined;
115
+ }
116
+ repoBySlug(slug) {
117
+ return this.stmt('SELECT slug, root, indexed_at FROM repos WHERE slug = ?').get(slug);
118
+ }
119
+ files(repo) {
120
+ const rows = this.stmt('SELECT path, hash, dirty FROM files WHERE repo = ?').all(repo);
121
+ return new Map(rows.map(r => [r.path, r]));
122
+ }
123
+ nodesOf(repo, path) {
124
+ return this.stmt('SELECT node_id FROM links WHERE repo = ? AND path = ?').all(repo, path).map(r => r.node_id);
125
+ }
126
+ /** Nodes that only these paths point at, so deleting the files means deleting them. */
127
+ orphansIfRemoved(repo, paths) {
128
+ const removing = new Set(paths);
129
+ const out = new Map();
130
+ for (const path of paths) {
131
+ const orphaned = [];
132
+ for (const id of this.nodesOf(repo, path)) {
133
+ const holders = this.stmt('SELECT repo, path FROM links WHERE node_id = ?').all(id);
134
+ const remaining = holders.filter(h => !(h.repo === repo && removing.has(h.path)));
135
+ if (remaining.length === 0)
136
+ orphaned.push(id);
137
+ }
138
+ out.set(path, orphaned);
139
+ }
140
+ return out;
141
+ }
142
+ /** Records that `path` was indexed at `hash` and now lives in `nodeIds`. */
143
+ recordFile(repo, path, hash, nodeIds, now = Date.now()) {
144
+ this.transaction(() => {
145
+ this.stmt(`INSERT INTO files (repo, path, hash, dirty, indexed_at) VALUES (?, ?, ?, 0, ?)
146
+ ON CONFLICT(repo, path) DO UPDATE SET hash = excluded.hash, dirty = 0, indexed_at = excluded.indexed_at`).run(repo, path, hash, now);
147
+ for (const id of nodeIds) {
148
+ this.stmt('INSERT OR IGNORE INTO links (repo, path, node_id) VALUES (?, ?, ?)').run(repo, path, id);
149
+ }
150
+ });
151
+ }
152
+ forgetFile(repo, path) {
153
+ this.transaction(() => {
154
+ this.stmt('DELETE FROM files WHERE repo = ? AND path = ?').run(repo, path);
155
+ this.stmt('DELETE FROM links WHERE repo = ? AND path = ?').run(repo, path);
156
+ });
157
+ }
158
+ markDirty(repo, path) {
159
+ const res = this.stmt('UPDATE files SET dirty = 1 WHERE repo = ? AND path = ?').run(repo, path);
160
+ return Number(res.changes) > 0;
161
+ }
162
+ /** A file saved without changing has nothing to re-index, so its flag goes. */
163
+ clearDirty(repo, path) {
164
+ this.stmt('UPDATE files SET dirty = 0 WHERE repo = ? AND path = ? AND dirty = 1').run(repo, path);
165
+ }
166
+ dirtyCount(repo) {
167
+ return this.stmt('SELECT COUNT(*) AS n FROM files WHERE repo = ? AND dirty = 1').get(repo).n;
168
+ }
169
+ dirtyFiles(repo) {
170
+ return this.stmt('SELECT path FROM files WHERE repo = ? AND dirty = 1 ORDER BY path').all(repo).map(r => r.path);
171
+ }
172
+ // --- the session index ------------------------------------------------------
173
+ upsertNode(row) {
174
+ this.stmt(`INSERT INTO nodes (node_id, ref, title, type, tokens, touched_at) VALUES (?, ?, ?, ?, ?, ?)
175
+ ON CONFLICT(node_id) DO UPDATE SET
176
+ ref = COALESCE(excluded.ref, ref), title = excluded.title, type = COALESCE(excluded.type, type),
177
+ tokens = COALESCE(excluded.tokens, tokens), touched_at = excluded.touched_at`).run(row.node_id, row.ref ?? null, row.title, row.type ?? null, row.tokens ?? null, row.touched_at ?? Date.now());
178
+ }
179
+ deleteNode(nodeId) {
180
+ this.transaction(() => {
181
+ this.stmt('DELETE FROM nodes WHERE node_id = ?').run(nodeId);
182
+ this.stmt('DELETE FROM links WHERE node_id = ?').run(nodeId);
183
+ });
184
+ }
185
+ /** Replaces the synced set: rows not in `keep` are dropped, so deletions elsewhere disappear here. */
186
+ replaceNodes(rows) {
187
+ this.transaction(() => {
188
+ const known = new Map(this.stmt('SELECT node_id, tokens FROM nodes').all().map(r => [r.node_id, r.tokens]));
189
+ this.db.exec('DELETE FROM nodes');
190
+ for (const row of rows) {
191
+ this.stmt('INSERT INTO nodes (node_id, ref, title, type, tokens, touched_at) VALUES (?, ?, ?, ?, ?, ?)').run(row.node_id, row.ref, row.title, row.type, row.tokens ?? known.get(row.node_id) ?? null, row.touched_at);
192
+ }
193
+ });
194
+ }
195
+ recentNodes(limit) {
196
+ return this.stmt('SELECT node_id, ref, title, type, tokens, touched_at FROM nodes ORDER BY touched_at DESC LIMIT ?').all(limit);
197
+ }
198
+ nodeCount() {
199
+ return this.stmt('SELECT COUNT(*) AS n FROM nodes').get().n;
200
+ }
201
+ }
202
+ /** Opens the state, or returns undefined when it cannot be opened, so a hook never fails a session. */
203
+ export function tryOpenState(path) {
204
+ try {
205
+ return new State(path);
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
package/cli/ui.js ADDED
@@ -0,0 +1,66 @@
1
+ import { stdin, stdout } from 'node:process';
2
+ export const out = (line = '') => stdout.write(`${line}\n`);
3
+ export const err = (line = '') => process.stderr.write(`${line}\n`);
4
+ /** Pads columns so a list reads as a table without box drawing. */
5
+ export function table(rows, gap = 2) {
6
+ const widths = [];
7
+ for (const row of rows)
8
+ row.forEach((cell, i) => (widths[i] = Math.max(widths[i] ?? 0, cell.length)));
9
+ return rows.map(row => row
10
+ .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i] + gap)))
11
+ .join('')
12
+ .trimEnd());
13
+ }
14
+ /** Reads a line from the terminal without echoing it, for a key that should not land in scrollback. */
15
+ export function promptHidden(question) {
16
+ return new Promise((resolve, reject) => {
17
+ if (!stdin.isTTY) {
18
+ reject(new Error('No terminal to prompt on.'));
19
+ return;
20
+ }
21
+ stdout.write(question);
22
+ let value = '';
23
+ stdin.setRawMode(true);
24
+ stdin.resume();
25
+ stdin.setEncoding('utf8');
26
+ const done = (fn) => {
27
+ stdin.setRawMode(false);
28
+ stdin.pause();
29
+ stdin.off('data', onData);
30
+ stdout.write('\n');
31
+ fn();
32
+ };
33
+ const onData = (chunk) => {
34
+ for (const ch of chunk) {
35
+ if (ch === '\r' || ch === '\n')
36
+ return done(() => resolve(value));
37
+ if (ch === '\u0003')
38
+ return done(() => reject(new Error('Cancelled.')));
39
+ if (ch === '\u007f' || ch === '\b')
40
+ value = value.slice(0, -1);
41
+ else if (ch >= ' ')
42
+ value += ch;
43
+ }
44
+ };
45
+ stdin.on('data', onData);
46
+ });
47
+ }
48
+ /** First line of piped input, for `echo $KEY | gigarag login`. */
49
+ export async function readStdinLine() {
50
+ const chunks = [];
51
+ for await (const chunk of stdin)
52
+ chunks.push(chunk);
53
+ return Buffer.concat(chunks).toString('utf8').split(/\r?\n/)[0]?.trim() ?? '';
54
+ }
55
+ export async function readStdinAll() {
56
+ const chunks = [];
57
+ for await (const chunk of stdin)
58
+ chunks.push(chunk);
59
+ return Buffer.concat(chunks).toString('utf8');
60
+ }
61
+ export class UsageError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = 'UsageError';
65
+ }
66
+ }
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Ingest external documentation into GigaRAG so it can be searched later
3
+ argument-hint: <url>
4
+ ---
5
+
6
+ Ingest the documentation at $ARGUMENTS into GigaRAG.
7
+
8
+ If that is empty or is not a URL, ask me for one and stop.
9
+
10
+ The bucket is named for the site: the hostname with dots turned into hyphens and any leading `www` dropped, so docs.stripe.com becomes `docs-stripe-com`. Find it with `list_buckets`, and create it if it is missing, with the site name as the title.
11
+
12
+ Then:
13
+ 1. Fetch the page. Follow links that stay on the same host and sit under the same path prefix as the starting URL, up to two levels deep and at most 30 pages. Skip anchors, downloads, login pages and anything that repeats a page you already have. Say so if you hit the 30 page limit.
14
+ 2. For each page, first call `search_nodes` for its URL. If a memo already carries that URL, update it in place with `update_node` instead of creating a duplicate, so running this twice does not double the bucket.
15
+ 3. Otherwise `create_node`. The title is the page's own title. The summary is one or two sentences on what the page covers. The body opens with the line `Source: <url>, retrieved <today's date>`, then the page's content as clean markdown: keep headings, code blocks and tables, drop navigation, footers, cookie banners and repeated menus.
16
+ 4. A memo body holds at most 10,000 characters. For a longer page, split at a heading into several memos titled "Page title, part 2" and so on, and link each part to the next in its text as `[Page title, part 2](N:12)`.
17
+ 5. Set `node_type` to `doc` and `actor` to `agent:claude-code`.
18
+
19
+ When you finish, tell me the bucket, how many pages you created and updated, how many you skipped and why, and one example query I could try with `/gigarecall`.
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Index a codebase into GigaRAG, one bucket per repository
3
+ argument-hint: [path]
4
+ ---
5
+
6
+ Index the codebase at "$ARGUMENTS" into GigaRAG. If that is empty, use the current directory.
7
+
8
+ Hand the work to the `gigarag:gigarag-indexer` subagent, so the repository walk happens in its own context and not in this conversation. Run it in the foreground and wait for it to finish, because a background run ends with nothing indexed if this session closes first. Give it the path exactly as I gave it, or the current directory, and tell it to run a full index.
9
+
10
+ When it finishes, tell me in five lines or fewer: the bucket it used, how many memos it created, updated and deleted, anything it could not index and why, and whether the index is now up to date. Do not paste the file list.
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Search GigaRAG and load what it knows into this conversation
3
+ argument-hint: <what to recall>
4
+ ---
5
+
6
+ Recall what GigaRAG knows about: $ARGUMENTS
7
+
8
+ If that is empty, ask me what to recall and stop.
9
+
10
+ 1. Call `search_nodes` with my question in plain words, limit 8. It combines exact-term and meaning-based search, so a paraphrase works as well as an identifier.
11
+ 2. Read the two to four most relevant hits in full with `fetch_node`. Search returns only a snippet, so do not answer from snippets alone.
12
+ 3. If a memo links to another that looks necessary to answer, read that one too with `fetch_node`, or use `neighbors`. Stop after about six memos in total.
13
+ 4. Give me what you found: the facts that answer the question, each with the ref of the memo it came from, like N:12. Say which memos disagree, if any do, and which is newer. If a memo is marked superseded, say so and prefer what replaced it.
14
+
15
+ If nothing relevant came back, say that plainly and suggest one different phrasing to try. Do not fill the gap from general knowledge, and do not save anything.
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Save this session's decisions and discoveries to GigaRAG as memos
3
+ argument-hint: [what to focus on]
4
+ ---
5
+
6
+ Save what this session decided or learned into GigaRAG, so the next session, on any machine or in any client, can find it. If I gave a focus ("$ARGUMENTS"), save only that. Otherwise go through the whole conversation.
7
+
8
+ Find the bucket first: run `gigarag repo --slug` and use that slug. If no bucket has it yet, create one with that slug.
9
+
10
+ What is worth a memo:
11
+ - A decision, with why it was made and what was rejected. "We chose X over Y because Z" is the best kind.
12
+ - A discovery that took effort: why something behaves as it does, a constraint nobody wrote down, a bug's real cause.
13
+ - A convention agreed for this codebase.
14
+ - An open question somebody still has to answer.
15
+
16
+ What is not: chatter, status updates, anything already obvious from the code or the git log, and anything secret. Never save keys, tokens, passwords or personal data, even if they appeared in the conversation.
17
+
18
+ For each one:
19
+ 1. Call `search_nodes` with the topic first. If a memo already covers it, update that memo with `update_node`, keeping the links already in its text, instead of creating a second one.
20
+ 2. Otherwise `create_node` in a thread called `decisions` in that bucket (create the thread if it is missing). Use the type `decision` for decisions and `note` for everything else. Set `actor` to `agent:claude-code`.
21
+ 3. The title states the point ("Use cursor pagination for /events"), not the topic ("Pagination"). The summary is one or two sentences. The body has the context, the decision, why, and what was rejected.
22
+ 4. Where a memo relates to another, link it in the text as `[Title](N:12)`, using the ref from `find_nodes`. Never write a UUID as a link target.
23
+
24
+ At the end, list what you saved as one line each: the ref, the title, and whether it was created or updated. If there was nothing worth saving, say so and save nothing.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Bring GigaRAG up to date with the files that changed since the last index
3
+ ---
4
+
5
+ Run `gigarag scan --summary` in the current directory and read the counts.
6
+
7
+ If nothing is new, changed or gone, say "GigaRAG is up to date with this repository." and stop. Do not start the indexer for nothing.
8
+
9
+ Otherwise hand the work to the `gigarag:gigarag-indexer` subagent, in the foreground and waiting for it to finish, for the current directory, and tell it this is an incremental sync: only the files the scan reports. When it finishes, tell me in three lines or fewer what changed: memos created, updated and deleted.
@@ -0,0 +1,38 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/run.mjs\" hook SessionStart",
9
+ "timeout": 5
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PostToolUse": [
15
+ {
16
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/run.mjs\" hook PostToolUse",
21
+ "timeout": 5
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "Stop": [
27
+ {
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/run.mjs\" hook Stop",
32
+ "timeout": 5
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ }
38
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "gigarag-claude-code",
3
+ "version": "0.1.0",
4
+ "description": "The GigaRAG plugin for Claude Code. Commands, an indexing agent, hooks and an MCP server that carry the gigarag CLI inside them.",
5
+ "license": "UNLICENSED",
6
+ "files": [
7
+ ".claude-plugin",
8
+ "commands",
9
+ "agents",
10
+ "hooks",
11
+ "scripts",
12
+ "bin",
13
+ "cli",
14
+ ".mcp.json",
15
+ "README.md"
16
+ ],
17
+ "bin": {
18
+ "gigarag": "bin/gigarag"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/gigarag/gigarag-cli.git",
23
+ "directory": "plugins/claude-code"
24
+ },
25
+ "homepage": "https://gigarag.com"
26
+ }
@@ -0,0 +1,64 @@
1
+ // Finds the gigarag CLI and runs it, so every hook, command and MCP entry in this
2
+ // plugin calls one thing. Lookup order:
3
+ // 1. GIGARAG_CLI, a path to bin.js, for development
4
+ // 2. ../cli/bin.js, the build vendored into this package at release time
5
+ // 3. a gigarag on PATH, such as npm i -g gigarag or the standalone installer
6
+ // 4. npx, which downloads the version this plugin was released with
7
+ // The CLI is vendored because Claude Code does not run install scripts and npm never
8
+ // publishes a lockfile, so a declared dependency would not be there on first run.
9
+ import { spawnSync } from 'node:child_process';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ const args = process.argv.slice(2);
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const isHook = args[0] === 'hook';
17
+ const windows = process.platform === 'win32';
18
+
19
+ const local = [process.env.GIGARAG_CLI, join(here, '..', 'cli', 'bin.js')].find(p => p && existsSync(p));
20
+
21
+ /** The version this plugin shipped with, so npx runs the CLI it was tested against and not whatever is newest. */
22
+ const pinned = () => {
23
+ try {
24
+ const { version } = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
25
+ return /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version) ? `gigarag@${version}` : 'gigarag';
26
+ } catch {
27
+ return 'gigarag';
28
+ }
29
+ };
30
+
31
+ /** True when the command exists. A probe, because a shell reports a missing command as an ordinary exit code. */
32
+ const exists = (cmd, probe) => {
33
+ const r = spawnSync(cmd, probe, { stdio: 'ignore', shell: windows });
34
+ return !r.error && r.status === 0;
35
+ };
36
+
37
+ /**
38
+ * On Windows gigarag and npx are .cmd scripts, which Node will only start through cmd.exe. What a
39
+ * user typed after a slash command ends up in these arguments, so anything cmd.exe would read as
40
+ * syntax is refused rather than passed on.
41
+ */
42
+ const unsafeForCmd = list => windows && list.some(a => /[&|<>^%"\r\n`]/.test(a));
43
+
44
+ if (local) {
45
+ // In this process: no second Node start, which matters inside a 100ms hook budget.
46
+ await import(pathToFileURL(local).href);
47
+ } else {
48
+ const attempts = [
49
+ ['gigarag', ['--version'], 'gigarag', args],
50
+ ['npx', ['--version'], 'npx', ['-y', pinned(), ...args]],
51
+ ];
52
+ const found = attempts.find(([probeCmd, probeArgs]) => exists(probeCmd, probeArgs));
53
+ if (found && unsafeForCmd(found[3])) {
54
+ if (!isHook) process.stderr.write('gigarag: an argument holds a character that cmd.exe would treat as syntax, so nothing was run.\n');
55
+ process.exitCode = isHook ? 0 : 1;
56
+ } else if (found) {
57
+ const r = spawnSync(found[2], found[3], { stdio: 'inherit', shell: windows });
58
+ // A hook must never break the session it runs in.
59
+ process.exitCode = isHook ? 0 : (r.status ?? 1);
60
+ } else {
61
+ if (!isHook) process.stderr.write('gigarag is not installed. Run: npm install -g gigarag\n');
62
+ process.exitCode = isHook ? 0 : 1;
63
+ }
64
+ }