gigarag-cursor 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 (56) hide show
  1. package/.cursor-plugin/plugin.json +9 -0
  2. package/README.md +29 -0
  3. package/cli/auth/credentials.js +106 -0
  4. package/cli/auth/oauth.js +203 -0
  5. package/cli/auth/page.js +68 -0
  6. package/cli/bin.js +8 -0
  7. package/cli/cli.js +90 -0
  8. package/cli/clients/commands.js +160 -0
  9. package/cli/clients/connect.js +217 -0
  10. package/cli/clients/inspect.js +74 -0
  11. package/cli/clients/json.js +135 -0
  12. package/cli/clients/launcher.js +71 -0
  13. package/cli/clients/registry.js +40 -0
  14. package/cli/clients/toml.js +169 -0
  15. package/cli/clients/tomlarray.js +121 -0
  16. package/cli/clients/yaml.js +146 -0
  17. package/cli/clients.json +1226 -0
  18. package/cli/commands/authHeader.js +22 -0
  19. package/cli/commands/connect.js +285 -0
  20. package/cli/commands/indexSync.js +46 -0
  21. package/cli/commands/login.js +129 -0
  22. package/cli/commands/mcp.js +22 -0
  23. package/cli/commands/record.js +72 -0
  24. package/cli/commands/repo.js +48 -0
  25. package/cli/commands/scan.js +72 -0
  26. package/cli/commands/status.js +115 -0
  27. package/cli/config.js +69 -0
  28. package/cli/connect.js +8 -0
  29. package/cli/constants.js +24 -0
  30. package/cli/hooks.js +151 -0
  31. package/cli/index.js +3 -0
  32. package/cli/mcp/bridge.js +123 -0
  33. package/cli/mcp/client.js +156 -0
  34. package/cli/mcp/session.js +79 -0
  35. package/cli/package.json +5 -0
  36. package/cli/paths.js +34 -0
  37. package/cli/prompts.generated.js +44 -0
  38. package/cli/prompts.js +48 -0
  39. package/cli/scan/chunk.js +43 -0
  40. package/cli/scan/ignore.js +117 -0
  41. package/cli/scan/repo.js +99 -0
  42. package/cli/scan/scan.js +262 -0
  43. package/cli/scan.js +5 -0
  44. package/cli/sdk.js +130 -0
  45. package/cli/secrets.js +192 -0
  46. package/cli/secureUrl.js +18 -0
  47. package/cli/state.js +210 -0
  48. package/cli/ui.js +66 -0
  49. package/mcp.json +8 -0
  50. package/package.json +20 -0
  51. package/scripts/run.mjs +64 -0
  52. package/skills/gigadocs/SKILL.md +21 -0
  53. package/skills/gigaindex/SKILL.md +74 -0
  54. package/skills/gigarecall/SKILL.md +17 -0
  55. package/skills/gigasave/SKILL.md +26 -0
  56. package/skills/gigasync/SKILL.md +76 -0
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "gigarag",
3
+ "description": "Your GigaRAG knowledge base inside Cursor: index a codebase, save decisions, recall them later.",
4
+ "version": "0.1.0",
5
+ "author": { "name": "GigaRAG", "url": "https://gigarag.com" },
6
+ "homepage": "https://gigarag.com/connect/cursor",
7
+ "license": "UNLICENSED",
8
+ "keywords": ["memory", "rag", "knowledge-base", "mcp"]
9
+ }
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # GigaRAG for Cursor
2
+
3
+ Your GigaRAG knowledge base inside Cursor.
4
+
5
+ ## Install
6
+
7
+ Install the CLI first, with `npm install -g gigarag` or the standalone installer, because the plugin's own copy is not on your terminal's PATH. Then run `gigarag connect cursor` for the MCP server alone. For the skills as well, install this folder as a plugin: copy it to `~/.cursor/plugins/local/gigarag` for local use, or import the repository through a team marketplace.
8
+
9
+ Sign in once with `gigarag login`.
10
+
11
+ ## Skills
12
+
13
+ | Skill | Does |
14
+ |---|---|
15
+ | `/gigaindex [path]` | Index a codebase into a bucket named for the repository |
16
+ | `/gigasync` | Update only the files that changed since the last index |
17
+ | `/gigasave` | Write this session's decisions into memos |
18
+ | `/gigarecall <query>` | Search, and load the results into the conversation |
19
+ | `/gigadocs <url>` | Ingest external documentation |
20
+
21
+ These are generated from the Claude Code plugin by `scripts/build-plugins.mjs`, so the prompts match.
22
+
23
+ ## Not included yet
24
+
25
+ Hooks. Cursor's hook payloads are supported by `gigarag hook sessionStart|afterFileEdit|stop`, but where a plugin declares them is not documented well enough to ship, so wire them by hand in `.cursor/hooks.json` if you want them.
26
+
27
+ ## How it finds the CLI
28
+
29
+ `scripts/run.mjs` uses, in order: the `GIGARAG_CLI` path, the copy in the plugin's `cli/` folder, a `gigarag` on your PATH, then `npx`.
@@ -0,0 +1,106 @@
1
+ import { readConfig } from '../config.js';
2
+ import { assertSecureUrl } from '../secureUrl.js';
3
+ import { DEFAULT_ISSUER } from '../constants.js';
4
+ import { deleteSecret, getSecret, setSecret } from '../secrets.js';
5
+ const ACCOUNT = 'default';
6
+ /**
7
+ * The shape agent keys take, `gr_live_<region>_<32 base62>_<6 hex>`. Checked
8
+ * loosely: a format that changes must not lock people out.
9
+ */
10
+ export function looksLikeAgentKey(value) {
11
+ return /^gr_[a-z]+_[a-z0-9]+_[A-Za-z0-9_]{16,}$/.test(value.trim());
12
+ }
13
+ /**
14
+ * The key from `GIGARAG_API_KEY`, or undefined. An empty value is nothing, and so is an
15
+ * unfilled `${...}` template: a host that failed to substitute its own setting must not
16
+ * have that text sent to the server as a credential.
17
+ */
18
+ export function envKey(env = process.env) {
19
+ const value = env['GIGARAG_API_KEY']?.trim();
20
+ return value && !value.startsWith('${') ? value : undefined;
21
+ }
22
+ /** `GIGARAG_API_KEY` wins, so CI and containers need no login step. */
23
+ export function loadCredential(env = process.env) {
24
+ const fromEnv = envKey(env);
25
+ if (fromEnv)
26
+ return { type: 'key', key: fromEnv };
27
+ const stored = getSecret(ACCOUNT);
28
+ if (!stored)
29
+ return undefined;
30
+ try {
31
+ const parsed = JSON.parse(stored.value);
32
+ if (parsed.type === 'key' && parsed.key)
33
+ return parsed;
34
+ if (parsed.type === 'oauth' && parsed.accessToken)
35
+ return parsed;
36
+ }
37
+ catch {
38
+ /* fall through */
39
+ }
40
+ return undefined;
41
+ }
42
+ /** Stores a credential and returns where it went, for the "connected" line. */
43
+ export function saveCredential(credential) {
44
+ return setSecret(ACCOUNT, JSON.stringify(credential));
45
+ }
46
+ export function clearCredential() {
47
+ deleteSecret(ACCOUNT);
48
+ }
49
+ export function credentialSource(env = process.env) {
50
+ if (envKey(env))
51
+ return 'the GIGARAG_API_KEY environment variable';
52
+ return getSecret(ACCOUNT)?.store;
53
+ }
54
+ /** `gr_live_eu_*****`, for printing. */
55
+ export function mask(key) {
56
+ const m = /^(gr_[a-z]+_[a-z0-9]+_)/.exec(key);
57
+ return `${m ? m[1] : key.slice(0, 6)}*****`;
58
+ }
59
+ const REFRESH_MARGIN_MS = 60_000;
60
+ /**
61
+ * The Authorization header value, refreshing an OAuth token that is about to
62
+ * lapse and writing the rotated pair back. Refresh tokens are single use on the
63
+ * server, so a refresh that succeeds but is not saved would lock the next call
64
+ * out.
65
+ */
66
+ export async function authorizationHeader(credential, fetchImpl = fetch, now = Date.now) {
67
+ if (credential.type === 'key')
68
+ return `Bearer ${credential.key}`;
69
+ if (credential.expiresAt && credential.refreshToken && credential.expiresAt - now() < REFRESH_MARGIN_MS) {
70
+ const refreshed = await refreshOAuth(credential, fetchImpl, now);
71
+ return `Bearer ${refreshed.accessToken}`;
72
+ }
73
+ return `Bearer ${credential.accessToken}`;
74
+ }
75
+ export async function refreshOAuth(credential, fetchImpl = fetch, now = Date.now) {
76
+ if (!credential.refreshToken)
77
+ throw new Error('The sign-in has expired. Run: gigarag login');
78
+ assertSecureUrl(credential.tokenEndpoint, 'The token endpoint');
79
+ const res = await fetchImpl(credential.tokenEndpoint, {
80
+ signal: AbortSignal.timeout(30_000),
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
83
+ body: new URLSearchParams({
84
+ grant_type: 'refresh_token',
85
+ refresh_token: credential.refreshToken,
86
+ client_id: credential.clientId,
87
+ }),
88
+ });
89
+ const body = (await res.json().catch(() => ({})));
90
+ if (!res.ok || typeof body['access_token'] !== 'string') {
91
+ throw new Error(body['error'] === 'invalid_grant'
92
+ ? 'The sign-in has expired or was revoked. Run: gigarag login'
93
+ : `Could not refresh the sign-in (HTTP ${res.status}).`);
94
+ }
95
+ const next = {
96
+ ...credential,
97
+ accessToken: body['access_token'],
98
+ refreshToken: typeof body['refresh_token'] === 'string' ? body['refresh_token'] : credential.refreshToken,
99
+ expiresAt: typeof body['expires_in'] === 'number' ? now() + body['expires_in'] * 1000 : undefined,
100
+ };
101
+ saveCredential(next);
102
+ return next;
103
+ }
104
+ export function configuredIssuer() {
105
+ return readConfig().issuer ?? DEFAULT_ISSUER;
106
+ }
@@ -0,0 +1,203 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { spawn } from 'node:child_process';
4
+ import { DEFAULT_MCP_URL } from '../constants.js';
5
+ import { assertSecureUrl } from '../secureUrl.js';
6
+ import { PAGE_HEADERS, pageConnected, pageFailed, pageForeign } from './page.js';
7
+ /** Ports the CLI tries, in order. The app accepts a redirect on any loopback port in this range. */
8
+ export const LOGIN_PORTS = [8765, 8766, 8767, 8768, 8769, 8770, 8771, 8772, 8773, 8774, 8775];
9
+ const b64url = (buf) => buf.toString('base64url');
10
+ export function pkcePair() {
11
+ const verifier = b64url(randomBytes(48));
12
+ return { verifier, challenge: b64url(createHash('sha256').update(verifier).digest()) };
13
+ }
14
+ export async function discover(issuer, fetchImpl) {
15
+ const url = `${issuer.replace(/\/$/, '')}/.well-known/oauth-authorization-server`;
16
+ assertSecureUrl(issuer, 'The sign-in server');
17
+ const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(30_000) });
18
+ if (!res.ok)
19
+ throw new Error(`Could not reach GigaRAG sign-in at ${issuer} (HTTP ${res.status}).`);
20
+ const meta = (await res.json().catch(() => undefined));
21
+ if (!meta || typeof meta.authorization_endpoint !== 'string' || typeof meta.token_endpoint !== 'string') {
22
+ throw new Error('GigaRAG sign-in metadata is incomplete.');
23
+ }
24
+ // The metadata names where a code and a refresh token get sent, so it must name the server that was asked
25
+ // and only endpoints that are encrypted.
26
+ if (meta.issuer !== undefined && meta.issuer.replace(/\/$/, '') !== issuer.replace(/\/$/, '')) {
27
+ throw new Error(`The sign-in server at ${issuer} says it is ${meta.issuer}, so it was not trusted.`);
28
+ }
29
+ assertSecureUrl(meta.authorization_endpoint, 'The sign-in page');
30
+ assertSecureUrl(meta.token_endpoint, 'The token endpoint');
31
+ if (meta.registration_endpoint)
32
+ assertSecureUrl(meta.registration_endpoint, 'The registration endpoint');
33
+ return meta;
34
+ }
35
+ /** Tries each port until one is free. The machine that already has 8765 bound is somebody's daily driver. */
36
+ export function listenOnFirstFree(server, ports) {
37
+ return new Promise((resolve, reject) => {
38
+ const attempt = (i) => {
39
+ const port = ports[i];
40
+ if (port === undefined) {
41
+ reject(new Error(`No free port for the sign-in callback (tried ${ports[0]} to ${ports[ports.length - 1]}).`));
42
+ return;
43
+ }
44
+ const onError = (err) => {
45
+ server.off('listening', onListening);
46
+ if (err.code === 'EADDRINUSE' || err.code === 'EACCES')
47
+ attempt(i + 1);
48
+ else
49
+ reject(err);
50
+ };
51
+ const onListening = () => {
52
+ server.off('error', onError);
53
+ resolve(server.address().port);
54
+ };
55
+ server.once('error', onError);
56
+ server.once('listening', onListening);
57
+ server.listen(port, '127.0.0.1');
58
+ };
59
+ attempt(0);
60
+ });
61
+ }
62
+ export function openBrowser(url) {
63
+ const [cmd, args] = process.platform === 'win32'
64
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
65
+ : process.platform === 'darwin'
66
+ ? ['open', [url]]
67
+ : ['xdg-open', [url]];
68
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
69
+ child.on('error', () => {
70
+ /* the URL is printed too, so a missing opener is not fatal */
71
+ });
72
+ child.unref();
73
+ }
74
+ /**
75
+ * Authorization code with PKCE, redirecting to a loopback port.
76
+ *
77
+ * Nothing here handles a password or a key. The browser does the signing in,
78
+ * and what comes back is a single-use code that only this process can redeem,
79
+ * because only it holds the verifier.
80
+ */
81
+ export async function runOAuthLogin(opts) {
82
+ const fetchImpl = opts.fetch ?? fetch;
83
+ const log = opts.log ?? (() => undefined);
84
+ const now = opts.now ?? Date.now;
85
+ const meta = await discover(opts.issuer, fetchImpl);
86
+ const resource = opts.resource ?? DEFAULT_MCP_URL;
87
+ const scope = opts.scope ?? 'read write offline_access';
88
+ const state = b64url(randomBytes(24));
89
+ const { verifier, challenge } = pkcePair();
90
+ let settle;
91
+ const outcome = new Promise(resolve => {
92
+ settle = resolve;
93
+ });
94
+ const server = createServer((req, res) => {
95
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
96
+ if (url.pathname !== '/callback') {
97
+ res.writeHead(404).end();
98
+ return;
99
+ }
100
+ const fail = (message) => {
101
+ // Settle only once the page has been sent. The caller closes this server as soon as it is
102
+ // settled, and closing first cut the connection before the browser got the explanation.
103
+ res.once('close', () => settle({ error: message }));
104
+ res.writeHead(400, PAGE_HEADERS).end(pageFailed(message));
105
+ };
106
+ // Anything on this machine can hit the port. A request that does not carry this login's state is
107
+ // turned away without ending the login, or a stray request could cancel it.
108
+ if (url.searchParams.get('state') !== state) {
109
+ res.writeHead(400, PAGE_HEADERS).end(pageForeign());
110
+ return;
111
+ }
112
+ const err = url.searchParams.get('error');
113
+ if (err)
114
+ return fail(`GigaRAG reported: ${url.searchParams.get('error_description') ?? err}.`);
115
+ const iss = url.searchParams.get('iss');
116
+ if (meta.authorization_response_iss_parameter_supported && iss !== meta.issuer) {
117
+ return fail('The sign-in response came from an unexpected server.');
118
+ }
119
+ const code = url.searchParams.get('code');
120
+ if (!code)
121
+ return fail('The sign-in response carried no code.');
122
+ res
123
+ .writeHead(200, PAGE_HEADERS)
124
+ .end(pageConnected());
125
+ settle({ code });
126
+ });
127
+ const port = await listenOnFirstFree(server, opts.ports ?? LOGIN_PORTS);
128
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
129
+ try {
130
+ let clientId = opts.clientId;
131
+ if (!clientId) {
132
+ if (!meta.registration_endpoint)
133
+ throw new Error('GigaRAG sign-in does not allow new clients.');
134
+ const reg = await fetchImpl(meta.registration_endpoint, {
135
+ signal: AbortSignal.timeout(30_000),
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
138
+ body: JSON.stringify({
139
+ client_name: 'GigaRAG CLI',
140
+ redirect_uris: [redirectUri],
141
+ grant_types: ['authorization_code', 'refresh_token'],
142
+ response_types: ['code'],
143
+ token_endpoint_auth_method: 'none',
144
+ scope,
145
+ }),
146
+ });
147
+ const regBody = (await reg.json().catch(() => ({})));
148
+ if (!reg.ok || !regBody.client_id)
149
+ throw new Error(`Could not register with GigaRAG sign-in (HTTP ${reg.status}).`);
150
+ clientId = regBody.client_id;
151
+ }
152
+ const authorize = new URL(meta.authorization_endpoint);
153
+ authorize.search = new URLSearchParams({
154
+ response_type: 'code',
155
+ client_id: clientId,
156
+ redirect_uri: redirectUri,
157
+ code_challenge: challenge,
158
+ code_challenge_method: 'S256',
159
+ state,
160
+ scope,
161
+ resource,
162
+ }).toString();
163
+ log(`Opening your browser. If nothing opens, go to:\n${authorize.toString()}`);
164
+ await (opts.open ?? openBrowser)(authorize.toString());
165
+ const timeout = new Promise(resolve => setTimeout(() => resolve({ error: 'Timed out waiting for the browser. Run gigarag login again.' }), opts.timeoutMs ?? 300_000).unref());
166
+ const result = await Promise.race([outcome, timeout]);
167
+ if ('error' in result)
168
+ throw new Error(result.error);
169
+ const tokenRes = await fetchImpl(meta.token_endpoint, {
170
+ signal: AbortSignal.timeout(30_000),
171
+ method: 'POST',
172
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
173
+ body: new URLSearchParams({
174
+ grant_type: 'authorization_code',
175
+ code: result.code,
176
+ redirect_uri: redirectUri,
177
+ client_id: clientId,
178
+ code_verifier: verifier,
179
+ resource,
180
+ }),
181
+ });
182
+ const tokens = (await tokenRes.json().catch(() => ({})));
183
+ if (!tokenRes.ok || typeof tokens['access_token'] !== 'string') {
184
+ throw new Error(`GigaRAG would not issue a token (${String(tokens['error_description'] ?? tokens['error'] ?? tokenRes.status)}).`);
185
+ }
186
+ return {
187
+ clientId,
188
+ scope: typeof tokens['scope'] === 'string' ? tokens['scope'] : undefined,
189
+ credential: {
190
+ type: 'oauth',
191
+ accessToken: tokens['access_token'],
192
+ refreshToken: typeof tokens['refresh_token'] === 'string' ? tokens['refresh_token'] : undefined,
193
+ expiresAt: typeof tokens['expires_in'] === 'number' ? now() + tokens['expires_in'] * 1000 : undefined,
194
+ clientId,
195
+ tokenEndpoint: meta.token_endpoint,
196
+ },
197
+ };
198
+ }
199
+ finally {
200
+ server.close();
201
+ server.closeAllConnections?.();
202
+ }
203
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The page the browser lands on after sign-in, served from the loopback port.
3
+ *
4
+ * It follows the app's design guide (DESIGNS.md in the GigaRAG repo): dark only, the #040709 ground,
5
+ * a flat #111111 card with a 1px #242424 border, the slate accent, white for the title and the
6
+ * ink ramp below it, weights 400 and 500, and the brand logomark. No scripts and no external
7
+ * files, so the content security policy that goes with it can forbid both.
8
+ */
9
+ /** The text comes from the URL, and any page in a browser can open this port, so it is escaped. */
10
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
11
+ /** The logomark from the app's brand folder, verbatim, with its one fill lifted onto the svg. */
12
+ const LOGOMARK = '<svg class="mark" role="img" aria-label="GigaRAG" width="60" height="60" viewBox="9.75 9.75 38.5 38.25" fill="#84B8D0" xmlns="http://www.w3.org/2000/svg"><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 32.6445 10.3429)"/><path d="M17.8742 22.7966C18.2924 24.2419 18.8644 24.7166 20.3359 25.1135C18.5624 25.4869 18.1457 26.2802 17.5845 27.5753C17.3025 26.2873 16.6843 25.8317 15.5572 25.2584C16.9728 24.7429 17.6397 24.1232 17.8742 22.7966Z"/><path d="M15.3443 32.3496C16.7767 31.8888 17.2343 31.303 17.5874 29.8204C18.0132 31.5821 18.8784 32.1211 20.1896 32.6437C18.9104 32.9637 18.4123 33.5274 17.8726 34.6711C17.3155 33.2713 16.6773 32.5448 15.3443 32.3496Z"/><path d="M22.817 39.8814C24.2494 39.4206 24.7069 38.8348 25.0601 37.3522C25.4858 39.1139 26.3511 39.6529 27.6622 40.1755C26.3831 40.4955 25.9719 41.0576 25.4321 42.2012C24.8751 40.8015 24.1499 40.0765 22.817 39.8814Z"/><path d="M30.3463 32.3513C31.7786 31.8905 32.2362 31.3047 32.5894 29.822C33.0151 31.5837 33.8804 32.1228 35.1915 32.6454C33.9123 32.9654 33.5012 33.5275 32.9614 34.6711C32.4043 33.2713 31.6792 32.5464 30.3463 32.3513Z"/><path d="M32.933 22.507C33.3512 23.9523 33.7784 24.427 35.25 24.8239C33.4765 25.1973 33.2046 26.1354 32.6434 27.4305C32.3614 26.1424 31.5984 25.6869 30.4712 25.1136C31.8869 24.5981 32.6985 23.8335 32.933 22.507Z"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 25.1133 17.873)"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 17.584 25.1136)"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 25.084 32.6437)"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 32.6445 40.1739)"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 47.7051 25.1136)"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 40.1738 32.6437)"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 32.6445 25.1136)"/><rect width="7.24054" height="7.24054" rx="2.31697" transform="matrix(-1 0 0 1 40.1738 17.5834)"/><path d="M25.4037 15.1216C25.8219 16.567 26.2491 17.1865 27.7207 17.5834C25.9472 17.9568 25.6753 18.8949 25.1141 20.19C24.8321 18.9019 24.0691 18.4464 22.9419 17.873C24.3576 17.3576 25.1692 16.4482 25.4037 15.1216Z"/></svg>';
13
+ /**
14
+ * The mark is the status. Each cell is numbered so the page can bring them in one after another,
15
+ * and so a failed sign-in can dim the whole mark and leave a single cell in the danger colour.
16
+ */
17
+ const cells = (svg) => {
18
+ let i = 0;
19
+ return svg.replace(/<(rect|path)\b/g, m => `${m} style="--i:${i++}"`);
20
+ };
21
+ const CSS = `
22
+ :root{color-scheme:dark}
23
+ *{box-sizing:border-box}
24
+ html{background:#040709}
25
+ body{margin:0;min-height:100vh;display:flex;align-items:flex-start;justify-content:center;padding:18vh 16px 48px;background:#040709;color:#c7cbce;font-family:Geist,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}
26
+ main{width:100%;max-width:440px;background:#111111;border:1px solid #242424;border-radius:16px;padding:36px 32px 32px}
27
+ .mark{display:block;width:60px;height:60px;margin-bottom:30px;overflow:visible}
28
+ .ok .mark *{opacity:0;animation:cell .5s ease-out forwards;animation-delay:calc(var(--i) * 55ms + 120ms)}
29
+ @keyframes cell{to{opacity:1}}
30
+ .error .mark{fill:#3a4043}
31
+ .error .mark rect:first-of-type{fill:#ef4444}
32
+ h1{margin:0 0 10px;font-size:22px;font-weight:500;line-height:1.25;letter-spacing:-0.02em;color:#fff}
33
+ p{margin:0;font-size:15px;color:#c7cbce}
34
+ p+p{margin-top:12px;font-size:14px;color:#9ba0a4}
35
+ code{font-family:"Geist Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:14px;color:#c7cbce;background:#1a1e22;border-radius:6px;padding:1px 6px}
36
+ @media (prefers-reduced-motion:reduce){.ok .mark *{opacity:1;animation:none}}
37
+ `;
38
+ export function renderPage({ kind, title, body, hint }) {
39
+ const hintHtml = hint ? `<p>${hint}</p>` : '';
40
+ return (`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
41
+ `<meta name="color-scheme" content="dark"><title>${escapeHtml(title)}</title><style>${CSS}</style></head>` +
42
+ `<body><main class="${kind}">${cells(LOGOMARK)}` +
43
+ `<h1>${escapeHtml(title)}</h1><p>${escapeHtml(body)}</p>${hintHtml}</main></body></html>`);
44
+ }
45
+ /** Set with the page: nothing may run and nothing may load, so text from the URL cannot do either. */
46
+ export const PAGE_HEADERS = {
47
+ 'content-type': 'text/html; charset=utf-8',
48
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
49
+ 'cache-control': 'no-store',
50
+ 'x-content-type-options': 'nosniff',
51
+ };
52
+ export const pageConnected = () => renderPage({
53
+ kind: 'ok',
54
+ title: 'Connected to GigaRAG',
55
+ body: 'The CLI can now read and write your workspace.',
56
+ hint: 'You can close this tab and go back to your terminal.',
57
+ });
58
+ export const pageFailed = (message) => renderPage({
59
+ kind: 'error',
60
+ title: 'Sign-in did not finish',
61
+ body: message,
62
+ hint: 'Run <code>gigarag login</code> in your terminal to try again.',
63
+ });
64
+ export const pageForeign = () => renderPage({
65
+ kind: 'error',
66
+ title: 'This is not your sign-in',
67
+ body: 'That request does not belong to the sign-in that is in progress, so it was ignored.',
68
+ });
package/cli/bin.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+ main(process.argv.slice(2)).then(code => {
4
+ process.exitCode = code;
5
+ }, (e) => {
6
+ process.stderr.write(`gigarag: ${e instanceof Error ? e.message : String(e)}\n`);
7
+ process.exitCode = 1;
8
+ });
package/cli/cli.js ADDED
@@ -0,0 +1,90 @@
1
+ import { version } from './constants.js';
2
+ import { err, out, UsageError } from './ui.js';
3
+ const HELP = `gigarag ${version()}
4
+
5
+ Connect GigaRAG to your AI clients.
6
+
7
+ gigarag login Sign in (paste a key, or --browser)
8
+ gigarag logout Delete the stored credential
9
+ gigarag connect [client...] Add GigaRAG to your installed clients
10
+ gigarag status What is configured, and whether the key works
11
+
12
+ gigarag scan [path] What changed in a repo since it was last indexed
13
+ gigarag repo [path] Which bucket a directory belongs to
14
+ gigarag record Tell the local index what was indexed (reads JSON on stdin)
15
+
16
+ gigarag mcp The stdio bridge a client's config runs
17
+ gigarag auth-header The auth header, for headersHelper
18
+ gigarag hook <event> Hook handler for Claude Code (SessionStart, PostToolUse, Stop)
19
+
20
+ Run gigarag <command> --help for details.`;
21
+ /** Loaded on demand so a hook, which runs on every prompt of every session, pays for nothing it does not use. */
22
+ const COMMANDS = {
23
+ login: async () => (await import('./commands/login.js')).login,
24
+ logout: async () => (await import('./commands/login.js')).logout,
25
+ connect: async () => (await import('./commands/connect.js')).connect,
26
+ status: async () => (await import('./commands/status.js')).status,
27
+ scan: async () => (await import('./commands/scan.js')).scan,
28
+ repo: async () => (await import('./commands/repo.js')).repo,
29
+ record: async () => (await import('./commands/record.js')).record,
30
+ mcp: async () => (await import('./commands/mcp.js')).mcp,
31
+ 'auth-header': async () => (await import('./commands/authHeader.js')).authHeader,
32
+ 'index-sync': async () => (await import('./commands/indexSync.js')).indexSync,
33
+ };
34
+ export async function main(argv) {
35
+ const [command, ...rest] = argv;
36
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
37
+ out(HELP);
38
+ return 0;
39
+ }
40
+ if (command === '--version' || command === '-v' || command === 'version') {
41
+ out(version());
42
+ return 0;
43
+ }
44
+ if (command === 'hook')
45
+ return hook(rest);
46
+ const load = COMMANDS[command];
47
+ if (!load) {
48
+ err(`Unknown command "${command}".\n`);
49
+ err(HELP);
50
+ return 2;
51
+ }
52
+ try {
53
+ return await (await load())(rest);
54
+ }
55
+ catch (e) {
56
+ if (e instanceof UsageError) {
57
+ err(e.message);
58
+ return 2;
59
+ }
60
+ if (process.env['GIGARAG_DEBUG'])
61
+ throw e;
62
+ err(`gigarag: ${e.message}`);
63
+ return 1;
64
+ }
65
+ }
66
+ async function hook(rest) {
67
+ const event = rest[0];
68
+ if (!event) {
69
+ err('Usage: gigarag hook <SessionStart|PostToolUse|Stop>');
70
+ return 2;
71
+ }
72
+ // A hook must never break the session it runs in, so every failure ends as exit 0 and silence.
73
+ try {
74
+ const { runHook } = await import('./hooks.js');
75
+ const raw = process.stdin.isTTY ? '' : await readAll();
76
+ const output = await runHook(event, raw);
77
+ if (output)
78
+ out(output);
79
+ }
80
+ catch {
81
+ /* swallowed on purpose */
82
+ }
83
+ return 0;
84
+ }
85
+ async function readAll() {
86
+ const chunks = [];
87
+ for await (const chunk of process.stdin)
88
+ chunks.push(chunk);
89
+ return Buffer.concat(chunks).toString('utf8');
90
+ }