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.
- package/.claude-plugin/plugin.json +10 -0
- package/.mcp.json +8 -0
- package/README.md +34 -0
- package/agents/gigarag-indexer.md +72 -0
- package/bin/gigarag +4 -0
- package/cli/auth/credentials.js +106 -0
- package/cli/auth/oauth.js +203 -0
- package/cli/auth/page.js +68 -0
- package/cli/bin.js +8 -0
- package/cli/cli.js +90 -0
- package/cli/clients/commands.js +160 -0
- package/cli/clients/connect.js +217 -0
- package/cli/clients/inspect.js +74 -0
- package/cli/clients/json.js +135 -0
- package/cli/clients/launcher.js +71 -0
- package/cli/clients/registry.js +40 -0
- package/cli/clients/toml.js +169 -0
- package/cli/clients/tomlarray.js +121 -0
- package/cli/clients/yaml.js +146 -0
- package/cli/clients.json +1226 -0
- package/cli/commands/authHeader.js +22 -0
- package/cli/commands/connect.js +285 -0
- package/cli/commands/indexSync.js +46 -0
- package/cli/commands/login.js +129 -0
- package/cli/commands/mcp.js +22 -0
- package/cli/commands/record.js +72 -0
- package/cli/commands/repo.js +48 -0
- package/cli/commands/scan.js +72 -0
- package/cli/commands/status.js +115 -0
- package/cli/config.js +69 -0
- package/cli/connect.js +8 -0
- package/cli/constants.js +24 -0
- package/cli/hooks.js +151 -0
- package/cli/index.js +3 -0
- package/cli/mcp/bridge.js +123 -0
- package/cli/mcp/client.js +156 -0
- package/cli/mcp/session.js +79 -0
- package/cli/package.json +5 -0
- package/cli/paths.js +34 -0
- package/cli/prompts.generated.js +44 -0
- package/cli/prompts.js +48 -0
- package/cli/scan/chunk.js +43 -0
- package/cli/scan/ignore.js +117 -0
- package/cli/scan/repo.js +99 -0
- package/cli/scan/scan.js +262 -0
- package/cli/scan.js +5 -0
- package/cli/sdk.js +130 -0
- package/cli/secrets.js +192 -0
- package/cli/secureUrl.js +18 -0
- package/cli/state.js +210 -0
- package/cli/ui.js +66 -0
- package/commands/gigadocs.md +19 -0
- package/commands/gigaindex.md +10 -0
- package/commands/gigarecall.md +15 -0
- package/commands/gigasave.md +24 -0
- package/commands/gigasync.md +9 -0
- package/hooks/hooks.json +38 -0
- package/package.json +26 -0
- package/scripts/run.mjs +64 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { authorizationHeader, loadCredential } from '../auth/credentials.js';
|
|
2
|
+
import { err, out } from '../ui.js';
|
|
3
|
+
/**
|
|
4
|
+
* What `headersHelper` runs. Claude Code expects one JSON object of string
|
|
5
|
+
* pairs on stdout, and gives up after 10 seconds, so nothing else may print here
|
|
6
|
+
* and nothing slow may run.
|
|
7
|
+
*/
|
|
8
|
+
export async function authHeader() {
|
|
9
|
+
const credential = loadCredential();
|
|
10
|
+
if (!credential) {
|
|
11
|
+
err('Not signed in to GigaRAG. Run: gigarag login');
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
out(JSON.stringify({ Authorization: await authorizationHeader(credential) }));
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
err(e.message);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { updateConfig, readConfig } from '../config.js';
|
|
3
|
+
import { loadCredential } from '../auth/credentials.js';
|
|
4
|
+
import { resolveUrl } from '../mcp/session.js';
|
|
5
|
+
import { tildify } from '../paths.js';
|
|
6
|
+
import { connectClient, removeClientEntry, renderConfig } from '../clients/connect.js';
|
|
7
|
+
import { installCommands, planTargets, removeCommands, targetDir } from '../clients/commands.js';
|
|
8
|
+
import { resolveLauncher } from '../clients/launcher.js';
|
|
9
|
+
import { configPathFor, detect, findClient, hereNow, loadRegistry } from '../clients/registry.js';
|
|
10
|
+
import { out, table, UsageError } from '../ui.js';
|
|
11
|
+
const HELP = `Usage: gigarag connect [client...] [--print] [--remove] [--dry-run]
|
|
12
|
+
|
|
13
|
+
With no client it detects what is installed and adds GigaRAG to each. Your other servers
|
|
14
|
+
are left alone, and the first change to any file is copied to <file>.gigarag.bak.
|
|
15
|
+
|
|
16
|
+
It also installs the /gigaindex, /gigasync, /gigasave, /gigarecall and /gigadocs commands
|
|
17
|
+
where a client reads them, as skills or command files that carry a gigarag:managed marker.
|
|
18
|
+
A file of yours with the same name is never replaced.
|
|
19
|
+
|
|
20
|
+
gigarag connect every installed client
|
|
21
|
+
gigarag connect cursor zed just those, even if they were not detected
|
|
22
|
+
gigarag connect --print cursor show the config instead of writing it
|
|
23
|
+
gigarag connect --remove take GigaRAG out of every client we wrote to
|
|
24
|
+
gigarag connect --list every client we know, and whether it is installed
|
|
25
|
+
|
|
26
|
+
--no-commands write the MCP config only
|
|
27
|
+
--commands-only write the commands only
|
|
28
|
+
--launcher node|npx|binary how a client starts the bridge (default: this install; binary needs the standalone build)
|
|
29
|
+
--dry-run --json`;
|
|
30
|
+
export async function connect(argv) {
|
|
31
|
+
const { values, positionals } = parseArgs({
|
|
32
|
+
args: argv,
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
options: {
|
|
35
|
+
print: { type: 'boolean' },
|
|
36
|
+
remove: { type: 'boolean' },
|
|
37
|
+
'dry-run': { type: 'boolean' },
|
|
38
|
+
list: { type: 'boolean' },
|
|
39
|
+
json: { type: 'boolean' },
|
|
40
|
+
'no-commands': { type: 'boolean' },
|
|
41
|
+
'commands-only': { type: 'boolean' },
|
|
42
|
+
launcher: { type: 'string' },
|
|
43
|
+
help: { type: 'boolean', short: 'h' },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
if (values.help) {
|
|
47
|
+
out(HELP);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
if (values['no-commands'] && values['commands-only'])
|
|
51
|
+
throw new UsageError('--no-commands and --commands-only cancel each other out.');
|
|
52
|
+
const registry = loadRegistry();
|
|
53
|
+
const where = hereNow();
|
|
54
|
+
const named = positionals.map(slug => {
|
|
55
|
+
const client = findClient(registry, slug);
|
|
56
|
+
if (!client) {
|
|
57
|
+
const close = registry.clients
|
|
58
|
+
.filter(c => c.slug.includes(slug) || slug.includes(c.slug) || editDistance(c.slug, slug) <= 2)
|
|
59
|
+
.map(c => c.slug);
|
|
60
|
+
throw new UsageError(`Unknown client "${slug}".${close.length ? ` Did you mean ${close.slice(0, 3).join(', ')}?` : ''} Run: gigarag connect --list`);
|
|
61
|
+
}
|
|
62
|
+
return client;
|
|
63
|
+
});
|
|
64
|
+
const kind = values.launcher;
|
|
65
|
+
if (kind && !['node', 'npx', 'binary'].includes(kind))
|
|
66
|
+
throw new UsageError('--launcher takes node, npx or binary.');
|
|
67
|
+
const launcher = resolveLauncher(kind);
|
|
68
|
+
const url = resolveUrl();
|
|
69
|
+
if (values.list)
|
|
70
|
+
return list(registry, where, !!values.json);
|
|
71
|
+
if (values.print) {
|
|
72
|
+
if (named.length === 0)
|
|
73
|
+
throw new UsageError('Name a client to print for, e.g. gigarag connect --print cursor');
|
|
74
|
+
for (const client of named) {
|
|
75
|
+
const path = configPathFor(client, where);
|
|
76
|
+
out(`# ${client.name}${path ? `: ${tildify(path)}` : ''}`);
|
|
77
|
+
out(client.mode === 'print' ? `# ${client.hint ?? 'Set up by hand.'}` : `# ${client.note ?? 'Merge this into the file.'}`);
|
|
78
|
+
out(renderConfig(client, launcher, url));
|
|
79
|
+
const dirs = (client.commands ?? []).map(id => registry.commandTargets[id]).filter(Boolean).map(t => targetDir(t, where));
|
|
80
|
+
if (dirs.length > 0)
|
|
81
|
+
out(`# Commands go in: ${dirs.map(d => tildify(d ?? '')).join(', ')}`);
|
|
82
|
+
if (client.docs)
|
|
83
|
+
out(`# Docs: ${client.docs}`);
|
|
84
|
+
out('');
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (values.remove)
|
|
89
|
+
return remove(registry, named, where, !!values['commands-only'], !!values['no-commands']);
|
|
90
|
+
const targets = named.length > 0
|
|
91
|
+
? named
|
|
92
|
+
: registry.clients.filter(c => (c.mode === 'auto' || (c.commands?.length ?? 0) > 0) && detect(c, where).installed);
|
|
93
|
+
if (targets.length === 0) {
|
|
94
|
+
out('No supported clients were found on this machine.');
|
|
95
|
+
out('Run gigarag connect --list to see what is supported, or name one: gigarag connect cursor');
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (values['dry-run']) {
|
|
99
|
+
out('Would write to:');
|
|
100
|
+
for (const c of targets) {
|
|
101
|
+
const cmds = (c.commands ?? []).map(id => tildify(targetDir(registry.commandTargets[id], where) ?? '')).join(', ');
|
|
102
|
+
out(` ${c.slug} ${tildify(configPathFor(c, where) ?? '(by hand)')}${cmds ? ` commands: ${cmds}` : ''}`);
|
|
103
|
+
}
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const results = [];
|
|
107
|
+
if (!values['commands-only']) {
|
|
108
|
+
for (const client of targets) {
|
|
109
|
+
if (client.mode === 'print') {
|
|
110
|
+
// A print-only client may still take commands. It only appears in the MCP table when named.
|
|
111
|
+
if (named.length > 0) {
|
|
112
|
+
results.push({ slug: client.slug, name: client.name, status: 'skipped', reason: `${client.hint ?? 'set up by hand'} Run: gigarag connect --print ${client.slug}` });
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
results.push(connectClient(client, where, launcher, url));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const commandResults = [];
|
|
120
|
+
const planned = values['no-commands'] ? new Map() : planTargets(registry, targets);
|
|
121
|
+
for (const [id, plan] of planned)
|
|
122
|
+
commandResults.push(...installCommands(id, plan.target, where));
|
|
123
|
+
updateConfig(config => {
|
|
124
|
+
for (const r of results) {
|
|
125
|
+
const def = findClient(registry, r.slug);
|
|
126
|
+
if (!def?.config || !r.path)
|
|
127
|
+
continue;
|
|
128
|
+
if (r.status === 'written' || r.status === 'replaced' || r.status === 'unchanged') {
|
|
129
|
+
config.clients[r.slug] = { path: r.path, at: def.config.at, format: def.config.format, writtenAt: new Date().toISOString() };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const [id, plan] of planned) {
|
|
133
|
+
const wrote = commandResults.some(r => r.target === id && r.status !== 'skipped');
|
|
134
|
+
if (!wrote)
|
|
135
|
+
continue;
|
|
136
|
+
const prior = config.commands?.[id]?.clients ?? [];
|
|
137
|
+
config.commands ??= {};
|
|
138
|
+
config.commands[id] = {
|
|
139
|
+
dir: targetDir(plan.target, where) ?? '',
|
|
140
|
+
clients: [...new Set([...prior, ...plan.clients.map(c => c.slug)])],
|
|
141
|
+
writtenAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
if (values.json) {
|
|
146
|
+
out(JSON.stringify({ launcher, results, commands: commandResults }, null, 2));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
if (results.length > 0) {
|
|
150
|
+
for (const line of table(results.map(r => [r.slug, r.status, r.path ? tildify(r.path) : '', r.reason && r.status !== 'failed' ? `(${r.reason})` : '']))) {
|
|
151
|
+
out(line);
|
|
152
|
+
}
|
|
153
|
+
for (const r of results)
|
|
154
|
+
if (r.status === 'failed' && r.reason)
|
|
155
|
+
out(`\n${r.slug}: ${r.reason}`);
|
|
156
|
+
for (const r of results)
|
|
157
|
+
if (r.backup)
|
|
158
|
+
out(`\nBacked up ${tildify(r.path ?? '')} to ${tildify(r.backup)}`);
|
|
159
|
+
}
|
|
160
|
+
if (commandResults.length > 0) {
|
|
161
|
+
out(`\n${results.length > 0 ? '' : ''}Commands:`);
|
|
162
|
+
for (const line of table(summariseCommands(commandResults, planned)))
|
|
163
|
+
out(line);
|
|
164
|
+
for (const r of commandResults)
|
|
165
|
+
if (r.status === 'skipped')
|
|
166
|
+
out(` ${r.name} in ${tildify(r.path)}: ${r.reason}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// With --json the output is for a program, and one stray line after the JSON would break its parser.
|
|
170
|
+
if (!values.json) {
|
|
171
|
+
if (!loadCredential()) {
|
|
172
|
+
out('\nYou are not signed in yet, so these clients cannot reach GigaRAG. Run: gigarag login');
|
|
173
|
+
}
|
|
174
|
+
if (named.length === 0) {
|
|
175
|
+
const manual = registry.clients.filter(c => c.mode === 'print').length;
|
|
176
|
+
out(`\n${manual} more clients are set up by hand. Run: gigarag connect --list`);
|
|
177
|
+
}
|
|
178
|
+
out('\nRestart each client so it picks up the change.');
|
|
179
|
+
}
|
|
180
|
+
return results.some(r => r.status === 'failed') ? 1 : 0;
|
|
181
|
+
}
|
|
182
|
+
/** One line per target: how many commands ended each way, where, and which clients read them. */
|
|
183
|
+
function summariseCommands(results, planned) {
|
|
184
|
+
const ids = [...new Set(results.map(r => r.target))];
|
|
185
|
+
return ids.map(id => {
|
|
186
|
+
const mine = results.filter(r => r.target === id);
|
|
187
|
+
const counts = ['written', 'replaced', 'unchanged', 'skipped']
|
|
188
|
+
.map(s => [s, mine.filter(r => r.status === s).length])
|
|
189
|
+
.filter(([, n]) => n > 0)
|
|
190
|
+
.map(([s, n]) => `${n} ${s}`)
|
|
191
|
+
.join(', ');
|
|
192
|
+
const dir = mine[0] ? tildify(dirnameOf(mine[0].path, id, planned)) : '';
|
|
193
|
+
const clients = planned.get(id)?.clients.map(c => c.slug).join(', ') ?? '';
|
|
194
|
+
return [` ${id}`, counts, dir, clients ? `(${clients})` : ''];
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function dirnameOf(path, id, planned) {
|
|
198
|
+
const kind = planned.get(id)?.target.kind;
|
|
199
|
+
const parts = path.split(/[\\/]/);
|
|
200
|
+
// A skill lives one folder deeper than the directory the client reads.
|
|
201
|
+
parts.pop();
|
|
202
|
+
if (kind === 'skill')
|
|
203
|
+
parts.pop();
|
|
204
|
+
return parts.join(process.platform === 'win32' ? '\\' : '/');
|
|
205
|
+
}
|
|
206
|
+
function list(registry, where, json) {
|
|
207
|
+
const rows = registry.clients.map(c => {
|
|
208
|
+
const d = detect(c, where);
|
|
209
|
+
const state = c.mode === 'print' && !(c.commands?.length) ? 'by hand' : d.installed ? 'installed' : 'not found';
|
|
210
|
+
return {
|
|
211
|
+
slug: c.slug,
|
|
212
|
+
name: c.name,
|
|
213
|
+
state,
|
|
214
|
+
mcp: c.mode === 'auto' ? 'auto' : 'print',
|
|
215
|
+
commands: c.commands ?? [],
|
|
216
|
+
path: configPathFor(c, where),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
if (json) {
|
|
220
|
+
out(JSON.stringify(rows, null, 2));
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
for (const line of table(rows.map(r => [r.slug, r.state, r.mcp, r.commands.join(',') || '-', r.path ? tildify(r.path) : ''])))
|
|
224
|
+
out(line);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
function remove(registry, named, where, commandsOnly, keepCommands) {
|
|
228
|
+
const config = readConfig();
|
|
229
|
+
const recorded = new Set([...Object.keys(config.clients), ...Object.values(config.commands ?? {}).flatMap(c => c.clients)]);
|
|
230
|
+
const targets = named.length > 0 ? named : registry.clients.filter(c => recorded.has(c.slug));
|
|
231
|
+
if (targets.length === 0) {
|
|
232
|
+
out('Nothing to remove: gigarag has not written to any client on this machine.');
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
const rows = [];
|
|
236
|
+
let failed = false;
|
|
237
|
+
if (!commandsOnly) {
|
|
238
|
+
const results = targets.filter(c => c.config).map(c => removeClientEntry(c, where));
|
|
239
|
+
updateConfig(c => {
|
|
240
|
+
for (const r of results)
|
|
241
|
+
if (r.status !== 'failed')
|
|
242
|
+
delete c.clients[r.slug];
|
|
243
|
+
});
|
|
244
|
+
for (const r of results)
|
|
245
|
+
rows.push([r.slug, r.status === 'written' ? 'removed' : r.status, r.path ? tildify(r.path) : '', r.reason ? `(${r.reason})` : '']);
|
|
246
|
+
failed = results.some(r => r.status === 'failed');
|
|
247
|
+
}
|
|
248
|
+
if (!keepCommands) {
|
|
249
|
+
const gone = new Set(targets.map(c => c.slug));
|
|
250
|
+
const cmdRows = [];
|
|
251
|
+
updateConfig(c => {
|
|
252
|
+
for (const [id, rec] of Object.entries(c.commands ?? {})) {
|
|
253
|
+
const left = rec.clients.filter(s => !gone.has(s));
|
|
254
|
+
// Another client that still uses this target keeps it.
|
|
255
|
+
if (left.length > 0) {
|
|
256
|
+
rec.clients = left;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const target = registry.commandTargets[id];
|
|
260
|
+
if (target)
|
|
261
|
+
cmdRows.push(...removeCommands(id, target, where));
|
|
262
|
+
delete c.commands[id];
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
for (const r of cmdRows)
|
|
266
|
+
rows.push([` ${r.target}/${r.name}`, r.status, tildify(r.path), r.reason ? `(${r.reason})` : '']);
|
|
267
|
+
}
|
|
268
|
+
for (const line of table(rows))
|
|
269
|
+
out(line);
|
|
270
|
+
return failed ? 1 : 0;
|
|
271
|
+
}
|
|
272
|
+
/** Levenshtein distance, for suggesting a client when somebody mistypes one. */
|
|
273
|
+
export function editDistance(a, b) {
|
|
274
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
275
|
+
for (let i = 1; i <= a.length; i++) {
|
|
276
|
+
let prev = row[0];
|
|
277
|
+
row[0] = i;
|
|
278
|
+
for (let j = 1; j <= b.length; j++) {
|
|
279
|
+
const tmp = row[j];
|
|
280
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
281
|
+
prev = tmp;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return row[b.length];
|
|
285
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { GigaRag } from '../sdk.js';
|
|
2
|
+
import { State } from '../state.js';
|
|
3
|
+
const MAX_NODES = 5000;
|
|
4
|
+
/** A worker that started less than this long ago owns the sync. */
|
|
5
|
+
const LOCK_MS = 2 * 60_000;
|
|
6
|
+
/**
|
|
7
|
+
* Refreshes the local memo index from the server. It is what SessionStart reads,
|
|
8
|
+
* so it is what makes memos saved from other clients, a chat app or another
|
|
9
|
+
* machine, show up in the next session. Runs detached from a hook, never in one.
|
|
10
|
+
*/
|
|
11
|
+
export async function indexSync() {
|
|
12
|
+
const state = new State();
|
|
13
|
+
let held = false;
|
|
14
|
+
try {
|
|
15
|
+
// Only the worker that took the lock may let go of it. A worker that finds it held leaves it alone.
|
|
16
|
+
held = state.tryAcquire('sync_lock', LOCK_MS);
|
|
17
|
+
if (!held)
|
|
18
|
+
return 0;
|
|
19
|
+
const rag = new GigaRag();
|
|
20
|
+
const rows = [];
|
|
21
|
+
for await (const n of rag.iterateNodes({ max: MAX_NODES })) {
|
|
22
|
+
rows.push({
|
|
23
|
+
node_id: n.id,
|
|
24
|
+
ref: n.ref ?? null,
|
|
25
|
+
title: n.title,
|
|
26
|
+
type: n.node_type ?? null,
|
|
27
|
+
tokens: null,
|
|
28
|
+
touched_at: Date.parse(n.updated_at ?? '') || 0,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
state.replaceNodes(rows);
|
|
32
|
+
state.setMeta('last_sync', String(Date.now()));
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Silent on purpose: nobody is watching a detached worker. The attempt is recorded so a machine
|
|
37
|
+
// that cannot sync is not retried by every session start and every Stop.
|
|
38
|
+
state.setMeta('last_attempt', String(Date.now()));
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (held)
|
|
43
|
+
state.setMeta('sync_lock', '0');
|
|
44
|
+
state.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { updateConfig, readConfig } from '../config.js';
|
|
3
|
+
import { DEFAULT_ISSUER } from '../constants.js';
|
|
4
|
+
import { clearCredential, envKey, looksLikeAgentKey, mask, saveCredential } from '../auth/credentials.js';
|
|
5
|
+
import { runOAuthLogin } from '../auth/oauth.js';
|
|
6
|
+
import { GigaRag, McpHttpError } from '../sdk.js';
|
|
7
|
+
import { assertSecureUrl } from '../secureUrl.js';
|
|
8
|
+
import { err, out, promptHidden, readStdinLine, UsageError } from '../ui.js';
|
|
9
|
+
const HELP = `Usage: gigarag login [--browser] [--key <key>] [--url <mcp url>] [--no-verify]
|
|
10
|
+
|
|
11
|
+
Stores a credential for this machine. With no flags it asks you to paste an agent key
|
|
12
|
+
from gigarag.com/connect. Piped input works too: echo $KEY | gigarag login
|
|
13
|
+
|
|
14
|
+
--browser Sign in through your browser instead of pasting a key
|
|
15
|
+
--key <key> Give the key on the command line (it will sit in your shell history)
|
|
16
|
+
--url <url> Use a different MCP endpoint
|
|
17
|
+
--no-verify Store the key without checking it against GigaRAG first`;
|
|
18
|
+
export async function login(argv) {
|
|
19
|
+
const { values } = parseArgs({
|
|
20
|
+
args: argv,
|
|
21
|
+
options: {
|
|
22
|
+
browser: { type: 'boolean' },
|
|
23
|
+
key: { type: 'string' },
|
|
24
|
+
url: { type: 'string' },
|
|
25
|
+
issuer: { type: 'string' },
|
|
26
|
+
'no-verify': { type: 'boolean' },
|
|
27
|
+
help: { type: 'boolean', short: 'h' },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
if (values.help) {
|
|
31
|
+
out(HELP);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
// Checked before it is stored: a typo saved here would break every later command.
|
|
35
|
+
if (values.url) {
|
|
36
|
+
try {
|
|
37
|
+
assertSecureUrl(values.url, 'The --url value');
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
throw new UsageError(e.message);
|
|
41
|
+
}
|
|
42
|
+
updateConfig(c => void (c.mcpUrl = values.url));
|
|
43
|
+
}
|
|
44
|
+
if (values.browser)
|
|
45
|
+
return browserLogin(values.issuer ?? readConfig().issuer ?? DEFAULT_ISSUER, values.url);
|
|
46
|
+
let key = values.key?.trim();
|
|
47
|
+
if (!key && envKey()) {
|
|
48
|
+
out('GIGARAG_API_KEY is set, so every command already uses it. Nothing to store.');
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
if (!key) {
|
|
52
|
+
key = process.stdin.isTTY
|
|
53
|
+
? (await promptHidden('Paste your GigaRAG agent key (from gigarag.com/connect): ')).trim()
|
|
54
|
+
: await readStdinLine();
|
|
55
|
+
}
|
|
56
|
+
if (!key)
|
|
57
|
+
throw new UsageError('No key given. Copy one from gigarag.com/connect, then run gigarag login again.');
|
|
58
|
+
if (!looksLikeAgentKey(key)) {
|
|
59
|
+
err('That does not look like an agent key. They start with gr_live_. Storing it anyway would only fail later.');
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
if (!values['no-verify']) {
|
|
63
|
+
try {
|
|
64
|
+
await new GigaRag({ apiKey: key, url: values.url }).usage();
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (e instanceof McpHttpError && e.status === 401) {
|
|
68
|
+
err('GigaRAG did not accept that key. If it was revoked, copy a new one from gigarag.com/connect.');
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
if (e instanceof McpHttpError && e.status === 403) {
|
|
72
|
+
err('That key is valid but not allowed to read this workspace. Ask an owner or admin for access.');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
err(`Could not check the key: ${e.message}\nRun again with --no-verify to store it without checking.`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const where = saveCredential({ type: 'key', key });
|
|
80
|
+
updateConfig(c => void (c.auth = 'key'));
|
|
81
|
+
out(`Connected. ${mask(key)} is stored in ${where}.`);
|
|
82
|
+
out('Next: gigarag connect');
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
async function browserLogin(issuer, mcpUrl) {
|
|
86
|
+
try {
|
|
87
|
+
const config = readConfig();
|
|
88
|
+
const result = await runOAuthLogin({
|
|
89
|
+
issuer,
|
|
90
|
+
resource: mcpUrl,
|
|
91
|
+
clientId: config.issuer === issuer ? config.oauthClientId : undefined,
|
|
92
|
+
log: line => out(line),
|
|
93
|
+
});
|
|
94
|
+
const where = saveCredential(result.credential);
|
|
95
|
+
updateConfig(c => {
|
|
96
|
+
c.auth = 'oauth';
|
|
97
|
+
c.issuer = issuer;
|
|
98
|
+
c.oauthClientId = result.clientId;
|
|
99
|
+
});
|
|
100
|
+
out(`Connected through your browser. The sign-in is stored in ${where}.`);
|
|
101
|
+
out('Next: gigarag connect');
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
err(e.message);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const LOGOUT_HELP = 'Usage: gigarag logout\n\nDeletes the stored credential, then lists the clients still configured.';
|
|
110
|
+
export async function logout(argv) {
|
|
111
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
112
|
+
out(LOGOUT_HELP);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
clearCredential();
|
|
116
|
+
updateConfig(c => void (c.auth = undefined));
|
|
117
|
+
out('Signed out. The stored credential is deleted.');
|
|
118
|
+
const written = Object.entries(readConfig().clients);
|
|
119
|
+
if (written.length > 0) {
|
|
120
|
+
out('');
|
|
121
|
+
out('These clients still have GigaRAG configured and will report an auth error until you sign in again:');
|
|
122
|
+
for (const [slug, info] of written)
|
|
123
|
+
out(` ${slug} ${info.path}`);
|
|
124
|
+
out('');
|
|
125
|
+
out('Sign back in with: gigarag login');
|
|
126
|
+
out('Or take GigaRAG out of all of them with: gigarag connect --remove');
|
|
127
|
+
}
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { runBridge } from '../mcp/bridge.js';
|
|
3
|
+
import { createClient } from '../mcp/session.js';
|
|
4
|
+
import { err } from '../ui.js';
|
|
5
|
+
/**
|
|
6
|
+
* The stdio bridge. A client's config file runs this, so stdout belongs to the
|
|
7
|
+
* protocol and nothing else may write to it: every message for a person goes to
|
|
8
|
+
* stderr, which clients surface in their MCP logs.
|
|
9
|
+
*/
|
|
10
|
+
export async function mcp(argv) {
|
|
11
|
+
const { values } = parseArgs({ args: argv, options: { url: { type: 'string' } } });
|
|
12
|
+
const client = createClient({
|
|
13
|
+
url: values.url,
|
|
14
|
+
onRetry: (wait, attempt) => err(`gigarag: rate limited, waiting ${wait}s (retry ${attempt})`),
|
|
15
|
+
});
|
|
16
|
+
await runBridge(client, {
|
|
17
|
+
input: process.stdin,
|
|
18
|
+
output: process.stdout,
|
|
19
|
+
log: line => err(`gigarag: ${line}`),
|
|
20
|
+
});
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { repoInfo } from '../scan/repo.js';
|
|
4
|
+
import { State } from '../state.js';
|
|
5
|
+
import { err, out, readStdinAll, UsageError } from '../ui.js';
|
|
6
|
+
const HELP = `Usage: gigarag record < result.json
|
|
7
|
+
|
|
8
|
+
Tells the local index what the indexing agent just did, so the next scan updates memos in
|
|
9
|
+
place instead of duplicating them. Reads JSON on stdin:
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"repo": "acme-api",
|
|
13
|
+
"files": [
|
|
14
|
+
{ "path": "src/auth.ts", "hash": "<hash from the manifest>",
|
|
15
|
+
"nodes": [ { "id": "<node uuid>", "ref": "N:12", "title": "Auth", "type": "code", "tokens": 800 } ] }
|
|
16
|
+
],
|
|
17
|
+
"removed": [ { "path": "src/old.ts", "deletedNodeIds": [ "<node uuid>" ] } ]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
--root <dir> The repository root, when it is not the current directory`;
|
|
21
|
+
export async function record(argv) {
|
|
22
|
+
const { values } = parseArgs({
|
|
23
|
+
args: argv,
|
|
24
|
+
options: { root: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
|
|
25
|
+
});
|
|
26
|
+
if (values.help) {
|
|
27
|
+
out(HELP);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
let input;
|
|
31
|
+
try {
|
|
32
|
+
input = JSON.parse(await readStdinAll());
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new UsageError('Expected JSON on stdin. Run gigarag record --help for the shape.');
|
|
36
|
+
}
|
|
37
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
38
|
+
throw new UsageError('Expected a JSON object on stdin. Run gigarag record --help for the shape.');
|
|
39
|
+
}
|
|
40
|
+
const info = repoInfo(resolve(values.root ?? '.'));
|
|
41
|
+
const slug = input.repo ?? info.slug;
|
|
42
|
+
const state = new State();
|
|
43
|
+
try {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
let files = 0;
|
|
46
|
+
let forgotten = 0;
|
|
47
|
+
state.upsertRepo(slug, info.root, now);
|
|
48
|
+
for (const f of input.files ?? []) {
|
|
49
|
+
if (!f.path || !f.hash) {
|
|
50
|
+
err(`Skipped an entry with no path or hash.`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const nodes = f.nodes ?? [];
|
|
54
|
+
for (const n of nodes) {
|
|
55
|
+
state.upsertNode({ node_id: n.id, ref: n.ref, title: n.title ?? f.path, type: n.type, tokens: n.tokens, touched_at: now });
|
|
56
|
+
}
|
|
57
|
+
state.recordFile(slug, f.path, f.hash, nodes.map(n => n.id), now);
|
|
58
|
+
files++;
|
|
59
|
+
}
|
|
60
|
+
for (const r of input.removed ?? []) {
|
|
61
|
+
state.forgetFile(slug, r.path);
|
|
62
|
+
for (const id of r.deletedNodeIds ?? [])
|
|
63
|
+
state.deleteNode(id);
|
|
64
|
+
forgotten++;
|
|
65
|
+
}
|
|
66
|
+
out(`Recorded ${files} files and forgot ${forgotten} in ${slug}.`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
state.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { stateDir } from '../paths.js';
|
|
5
|
+
import { repoInfo } from '../scan/repo.js';
|
|
6
|
+
import { tryOpenState } from '../state.js';
|
|
7
|
+
import { out } from '../ui.js';
|
|
8
|
+
const HELP = `Usage: gigarag repo [path] [--slug]
|
|
9
|
+
|
|
10
|
+
Prints which GigaRAG bucket a directory belongs to: {slug, root, remote, indexedAt, dirty}.
|
|
11
|
+
The slug comes from the git remote when there is one, so two checkouts of one repository share
|
|
12
|
+
a bucket, and from the directory name plus a short hash of its path when there is not. It walks no files and costs nothing.
|
|
13
|
+
|
|
14
|
+
--slug Print only the slug`;
|
|
15
|
+
export async function repo(argv) {
|
|
16
|
+
const { values, positionals } = parseArgs({
|
|
17
|
+
args: argv,
|
|
18
|
+
allowPositionals: true,
|
|
19
|
+
options: { slug: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
20
|
+
});
|
|
21
|
+
if (values.help) {
|
|
22
|
+
out(HELP);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
const info = repoInfo(resolve(positionals[0] ?? '.'));
|
|
26
|
+
if (values.slug) {
|
|
27
|
+
out(info.slug);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
let indexedAt;
|
|
31
|
+
let dirty = 0;
|
|
32
|
+
if (existsSync(join(stateDir(), 'state.db'))) {
|
|
33
|
+
const state = tryOpenState();
|
|
34
|
+
if (state) {
|
|
35
|
+
try {
|
|
36
|
+
const row = state.repoBySlug(info.slug);
|
|
37
|
+
if (row?.indexed_at)
|
|
38
|
+
indexedAt = new Date(row.indexed_at).toISOString();
|
|
39
|
+
dirty = row ? state.dirtyCount(info.slug) : 0;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
state.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
out(JSON.stringify({ ...info, ...(indexedAt ? { indexedAt } : {}), dirty }, null, 2));
|
|
47
|
+
return 0;
|
|
48
|
+
}
|