gigarag-cursor 0.1.2 → 0.2.1

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.
@@ -0,0 +1,367 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { resolveLauncher } from '../clients/launcher.js';
5
+ import { git, gitInfoExcludeFile, hooksDir, isGitRepo } from '../git/git.js';
6
+ import { HOOKS, classify, hasBlock, renderBlock, withBlock, withoutBlock } from '../git/hookScript.js';
7
+ import { findRepoRoot } from '../scan/repo.js';
8
+ import { State } from '../state.js';
9
+ import { err, out, table } from '../ui.js';
10
+ /**
11
+ * The hooks directory git reports resolves outside both `.git` and the work tree: usually a
12
+ * global `core.hooksPath` shared by every repository on the machine. Installing there would start
13
+ * a worker for all of them, and uninstalling here would remove it for all of them too, so `target`
14
+ * refuses instead.
15
+ */
16
+ export class HooksOutsideRepoError extends Error {
17
+ }
18
+ const HELP = `Usage: gigarag hooks install|uninstall|status [path]
19
+
20
+ Git hooks that keep GigaRAG in step with this repository. After a commit, merge, checkout or
21
+ rebase on the branch GigaRAG syncs, they start gigarag sync in the background. They never slow
22
+ or fail git. /gigaindex installs them.
23
+
24
+ install Add our lines to post-commit, post-merge, post-checkout and post-rewrite
25
+ uninstall Remove only our lines, and any hook file that held nothing else
26
+ status Which hooks carry our lines`;
27
+ /** The argv prefix the hook runs, or undefined when only a gigarag on PATH will do (npx is too slow for a hook). */
28
+ function command() {
29
+ const launcher = resolveLauncher();
30
+ if (launcher.kind === 'npx')
31
+ return undefined;
32
+ return [launcher.command, ...launcher.args.filter(a => a !== 'mcp')];
33
+ }
34
+ function read(file) {
35
+ try {
36
+ return readFileSync(file, 'utf8');
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** Temporary name then rename, like every config write here, so a crash never leaves half a hook. */
43
+ function writeHook(file, text) {
44
+ const tmpFile = `${file}.gigarag-tmp`;
45
+ writeFileSync(tmpFile, text, { mode: 0o755 });
46
+ try {
47
+ chmodSync(tmpFile, 0o755);
48
+ }
49
+ catch {
50
+ /* Windows */
51
+ }
52
+ renameSync(tmpFile, file);
53
+ }
54
+ function target(root) {
55
+ const found = hooksDir(root);
56
+ if (!found)
57
+ throw new Error('unreachable: isGitRepo was checked');
58
+ if (!found.insideGitDir && !found.insideWorkTree) {
59
+ throw new HooksOutsideRepoError(`This repository's hooks directory (${found.dir}) is outside both .git and the work tree, which is what a global core.hooksPath looks like: it is shared by every repository on this machine, so installing here would start GigaRAG's background sync for all of them, and gigarag hooks uninstall here would remove it for all of them too. Give this repository its own hooks path first, for example git config --local core.hooksPath .githooks (or git config --unset core.hooksPath to fall back to .git/hooks), then run gigarag hooks install again.`);
60
+ }
61
+ mkdirSync(found.dir, { recursive: true });
62
+ return { dir: found.dir, insideWorkTree: found.insideWorkTree };
63
+ }
64
+ /**
65
+ * Whether a hook file is already committed. Only meaningful inside the work tree (`.husky` or a
66
+ * repo-relative `core.hooksPath`): `.git/hooks` is never tracked. A team that already committed
67
+ * its own copy of the file, husky's included, is common, and editing it in place would put this
68
+ * machine's absolute launcher path into whatever a plain `git commit -a` ships next, to every
69
+ * teammate who pulls it, none of whom ran `gigarag hooks install` themselves.
70
+ */
71
+ function isTracked(root, file) {
72
+ const rel = relative(root, file).split(sep).join('/');
73
+ return git(root, ['ls-files', '--error-unmatch', '--', rel]).ok;
74
+ }
75
+ /**
76
+ * The repository's top level, canonicalised so install, uninstall and the hook-mode check all
77
+ * land on the same key regardless of which of the repository's directories each was given:
78
+ * `realpathSync` follows symlinks the way git itself would, and the result is lowercased on
79
+ * Windows, where two paths differing only in a drive letter's or a directory's case name the same
80
+ * checkout. Falls back to a plain `resolve` when the path doesn't exist (nothing to realpath) or
81
+ * isn't inside a repository at all, so this never throws.
82
+ */
83
+ function repoTopLevel(root) {
84
+ const { root: top } = findRepoRoot(resolve(root));
85
+ let real;
86
+ try {
87
+ real = realpathSync.native(top);
88
+ }
89
+ catch {
90
+ real = top;
91
+ }
92
+ return process.platform === 'win32' ? real.toLowerCase() : real;
93
+ }
94
+ /**
95
+ * The key under which this machine records that a person ran `gigarag hooks install` in this
96
+ * repository. Hook mode reads it through `hooksOptedIn` before doing anything, instead of
97
+ * trusting the bucket's shared `initialised` flag, because that flag is the same for every
98
+ * teammate: a hook file that reaches a machine without this person ever running install here (
99
+ * checked into git, or copied from a teammate) must not start a background sync on their behalf.
100
+ * Keyed on the repository's top level (see `repoTopLevel`), never on whatever directory install,
101
+ * uninstall or the hook itself happened to run from: installing from a subfolder, and a hook that
102
+ * always runs at the root, must agree on one opt-in, not keep two.
103
+ */
104
+ export function hooksOptInKey(root) {
105
+ return `hooks_opt_in:${repoTopLevel(root)}`;
106
+ }
107
+ /**
108
+ * The pre-fix key: the raw, unresolved-to-the-top-level `resolve(root)`, with no realpath and no
109
+ * case-folding. `hooksOptedIn` still reads it for a machine that opted in before this fix, and
110
+ * both it and `setHooksOptIn` clear it once it's no longer needed, so it can't migrate an opt-in
111
+ * back after an uninstall has cleared the normalised key.
112
+ */
113
+ function legacyHooksOptInKey(root) {
114
+ return `hooks_opt_in:${resolve(root)}`;
115
+ }
116
+ function withState(state, fn) {
117
+ if (state)
118
+ return fn(state);
119
+ const s = new State();
120
+ try {
121
+ return fn(s);
122
+ }
123
+ finally {
124
+ s.close();
125
+ }
126
+ }
127
+ /** Whether this machine ran `gigarag hooks install` (directly, or through `sync end --init`) in
128
+ * this checkout. See `hooksOptInKey`.
129
+ *
130
+ * Also accepts the key a pre-fix install left behind (`legacyHooksOptInKey`). From the repository
131
+ * root, which is where install overwhelmingly ran, that legacy key is identical to today's for a
132
+ * plain `.git/hooks` checkout on a case-preserving path, so a machine that opted in before this
133
+ * fix keeps working without re-running install. Found once, it's copied onto the new key and then
134
+ * deleted, so every check after this one is a single read, and so a later `uninstall`, which only
135
+ * clears the normalised key, can't have this migration bring the opt-in straight back afterwards. */
136
+ export function hooksOptedIn(root, state) {
137
+ try {
138
+ return withState(state, s => {
139
+ const key = hooksOptInKey(root);
140
+ if (s.getMeta(key) === '1')
141
+ return true;
142
+ const legacyKey = legacyHooksOptInKey(root);
143
+ if (legacyKey !== key && s.getMeta(legacyKey) === '1') {
144
+ s.setMeta(key, '1');
145
+ s.setMeta(legacyKey, '');
146
+ return true;
147
+ }
148
+ return false;
149
+ });
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ /**
156
+ * Clearing must reach the legacy key too, or `hooksOptedIn` migrates a pre-fix opt-in straight
157
+ * back the next time anything checks it: a machine that opted in before this fix, and never wrote
158
+ * the normalised key any other way, would otherwise survive `uninstall`.
159
+ */
160
+ function setHooksOptIn(root, value, state) {
161
+ try {
162
+ withState(state, s => {
163
+ s.setMeta(hooksOptInKey(root), value ? '1' : '');
164
+ if (!value) {
165
+ const legacyKey = legacyHooksOptInKey(root);
166
+ if (legacyKey !== hooksOptInKey(root))
167
+ s.setMeta(legacyKey, '');
168
+ }
169
+ });
170
+ }
171
+ catch {
172
+ /* a machine-local convenience; never a reason to fail install or uninstall */
173
+ }
174
+ }
175
+ /**
176
+ * Keeps our own hook files, once they live inside the work tree (`.husky` or a repo-relative
177
+ * core.hooksPath), out of `git status` for everyone who runs it directly, not only our own
178
+ * isClean check. Best effort: a courtesy, never a reason to fail an install.
179
+ */
180
+ function excludeFromGitStatus(root, dir, hooks) {
181
+ if (hooks.length === 0)
182
+ return;
183
+ try {
184
+ const file = gitInfoExcludeFile(root);
185
+ if (!file)
186
+ return;
187
+ const existing = read(file) ?? '';
188
+ const already = new Set(existing.split(/\r?\n/).filter(Boolean));
189
+ const rels = hooks.map(h => relative(root, join(dir, h)).split(sep).join('/'));
190
+ const missing = rels.filter(r => !already.has(r));
191
+ if (missing.length === 0)
192
+ return;
193
+ mkdirSync(dirname(file), { recursive: true });
194
+ const spacer = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
195
+ writeFileSync(file, `${existing}${spacer}# added by gigarag hooks install\n${missing.join('\n')}\n`);
196
+ }
197
+ catch {
198
+ /* a courtesy for a plain `git status`; never a reason to fail install */
199
+ }
200
+ }
201
+ export function installHooks(root, onlyWhereInstalled = false, state) {
202
+ const { dir, insideWorkTree } = target(root);
203
+ const argv = command();
204
+ const reports = HOOKS.map(hook => {
205
+ const file = join(dir, hook);
206
+ if (insideWorkTree && isTracked(root, file))
207
+ return { hook, file, result: 'tracked' };
208
+ const existing = read(file);
209
+ if (onlyWhereInstalled && !(existing && hasBlock(existing)))
210
+ return { hook, file, result: 'absent' };
211
+ const kind = classify(existing);
212
+ if (kind === 'foreign')
213
+ return { hook, file, result: 'foreign' };
214
+ const next = withBlock(existing, renderBlock(hook, argv));
215
+ if (next === existing)
216
+ return { hook, file, result: 'unchanged' };
217
+ writeHook(file, next);
218
+ return { hook, file, result: kind === 'missing' ? 'written' : 'updated' };
219
+ });
220
+ if (insideWorkTree) {
221
+ excludeFromGitStatus(root, dir, reports.filter(r => r.result === 'written' || r.result === 'updated' || r.result === 'unchanged').map(r => r.hook));
222
+ }
223
+ // Recorded only when install actually put our block in a file it controls, never merely for
224
+ // having been run: an interactive `sync begin` calls this (through refreshHooks) before every
225
+ // /gigasync, and a virgin machine where nothing is installed yet must not opt itself in that way.
226
+ if (reports.some(r => r.result === 'written' || r.result === 'updated' || r.result === 'unchanged')) {
227
+ setHooksOptIn(root, true, state);
228
+ }
229
+ return reports;
230
+ }
231
+ /** Rewrites our block where it already is, so a moved CLI path heals. Never installs where it was removed. */
232
+ export function refreshHooks(root, state) {
233
+ try {
234
+ if (isGitRepo(root))
235
+ installHooks(root, true, state);
236
+ }
237
+ catch {
238
+ /* a refresh is a courtesy, never a reason to stop a sync */
239
+ }
240
+ }
241
+ /**
242
+ * The reverse of `excludeFromGitStatus`: drops the line for each hook file uninstall removed, but
243
+ * only the lines that sit under our own `# added by gigarag hooks install` marker, so anything a
244
+ * person or another tool put in this file, before or after that marker, is left exactly as it was.
245
+ * A courtesy, like the install side: never a reason to fail uninstall.
246
+ */
247
+ function unexcludeFromGitStatus(root, dir, hooks) {
248
+ if (hooks.length === 0)
249
+ return;
250
+ try {
251
+ const file = gitInfoExcludeFile(root);
252
+ if (!file)
253
+ return;
254
+ const existing = read(file);
255
+ if (!existing)
256
+ return;
257
+ const rels = new Set(hooks.map(h => relative(root, join(dir, h)).split(sep).join('/')));
258
+ const lines = existing.split(/\r?\n/);
259
+ const markerAt = lines.findIndex(l => l.trim() === '# added by gigarag hooks install');
260
+ if (markerAt === -1)
261
+ return;
262
+ const kept = lines.filter((line, i) => i <= markerAt || !rels.has(line));
263
+ const next = kept.join('\n');
264
+ if (next !== existing)
265
+ writeFileSync(file, next);
266
+ }
267
+ catch {
268
+ /* a courtesy for a plain `git status`; never a reason to fail uninstall */
269
+ }
270
+ }
271
+ export function uninstallHooks(root, state) {
272
+ const { dir, insideWorkTree } = target(root);
273
+ const reports = HOOKS.map(hook => {
274
+ const file = join(dir, hook);
275
+ if (insideWorkTree && isTracked(root, file))
276
+ return { hook, file, result: 'tracked' };
277
+ const existing = read(file);
278
+ if (!existing || !hasBlock(existing))
279
+ return { hook, file, result: 'absent' };
280
+ const next = withoutBlock(existing);
281
+ if (next === undefined)
282
+ unlinkSync(file);
283
+ else
284
+ writeHook(file, next);
285
+ return { hook, file, result: 'removed' };
286
+ });
287
+ unexcludeFromGitStatus(root, dir, reports.filter(r => r.result === 'removed').map(r => r.hook));
288
+ setHooksOptIn(root, false, state);
289
+ return reports;
290
+ }
291
+ export function hookStatus(root) {
292
+ const { dir } = target(root);
293
+ return HOOKS.map(hook => {
294
+ const file = join(dir, hook);
295
+ const existing = read(file);
296
+ if (existing && hasBlock(existing))
297
+ return { hook, file, result: 'installed' };
298
+ return { hook, file, result: classify(existing) === 'foreign' ? 'foreign' : 'absent' };
299
+ });
300
+ }
301
+ const LABEL = {
302
+ written: 'written',
303
+ updated: 'updated',
304
+ unchanged: 'unchanged',
305
+ foreign: 'not ours',
306
+ tracked: 'tracked',
307
+ removed: 'removed',
308
+ absent: 'absent',
309
+ installed: 'installed',
310
+ };
311
+ export async function hooks(argv) {
312
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { help: { type: 'boolean', short: 'h' } } });
313
+ const [action, path] = positionals;
314
+ if (values.help || !action || !['install', 'uninstall', 'status'].includes(action)) {
315
+ out(HELP);
316
+ return action ? 2 : 0;
317
+ }
318
+ const root = resolve(path ?? '.');
319
+ if (!isGitRepo(root)) {
320
+ err(`${root} is not inside a git repository, so there is nothing to hook. Run this from the repository you indexed.`);
321
+ return 1;
322
+ }
323
+ let reports;
324
+ const state = new State();
325
+ try {
326
+ try {
327
+ reports = action === 'install' ? installHooks(root, false, state) : action === 'uninstall' ? uninstallHooks(root, state) : hookStatus(root);
328
+ }
329
+ catch (e) {
330
+ if (e instanceof HooksOutsideRepoError) {
331
+ err(e.message);
332
+ return 1;
333
+ }
334
+ throw e;
335
+ }
336
+ }
337
+ finally {
338
+ state.close();
339
+ }
340
+ for (const line of table(reports.map(r => [r.hook, LABEL[r.result], r.file])))
341
+ out(line);
342
+ const foreign = reports.filter(r => r.result === 'foreign');
343
+ if (action === 'install' && foreign.length > 0) {
344
+ out('');
345
+ out('These hooks are not shell scripts, so they were left as they are. To sync after them too, add the');
346
+ out('matching lines below to each one yourself, or call them from it:');
347
+ for (const r of foreign) {
348
+ out('');
349
+ out(`${r.file}:`);
350
+ out(renderBlock(r.hook, command()));
351
+ }
352
+ }
353
+ const tracked = reports.filter(r => r.result === 'tracked');
354
+ if (tracked.length > 0) {
355
+ out('');
356
+ out('These hook files are already tracked by git, so editing them was left alone: a plain git commit -a would');
357
+ out('ship this machine\'s own path into a file every teammate who pulls it gets, whether or not they run');
358
+ out('gigarag hooks install themselves. Add the lines below by hand if this repository wants that, and commit');
359
+ out('them once everyone who pulls this branch has agreed to it:');
360
+ for (const r of tracked) {
361
+ out('');
362
+ out(`${r.file}:`);
363
+ out(renderBlock(r.hook, command()));
364
+ }
365
+ }
366
+ return 0;
367
+ }
@@ -1,3 +1,4 @@
1
+ import { parseArgs } from 'node:util';
1
2
  import { GigaRag } from '../sdk.js';
2
3
  import { State } from '../state.js';
3
4
  const MAX_NODES = 5000;
@@ -8,7 +9,9 @@ const LOCK_MS = 2 * 60_000;
8
9
  * so it is what makes memos saved from other clients, a chat app or another
9
10
  * machine, show up in the next session. Runs detached from a hook, never in one.
10
11
  */
11
- export async function indexSync() {
12
+ export async function indexSync(argv = []) {
13
+ const { values } = parseArgs({ args: argv, options: { workspace: { type: 'string' } } });
14
+ const workspace = values.workspace;
12
15
  const state = new State();
13
16
  let held = false;
14
17
  try {
@@ -16,7 +19,7 @@ export async function indexSync() {
16
19
  held = state.tryAcquire('sync_lock', LOCK_MS);
17
20
  if (!held)
18
21
  return 0;
19
- const rag = new GigaRag();
22
+ const rag = new GigaRag(workspace ? { workspaceId: workspace } : {});
20
23
  const rows = [];
21
24
  for await (const n of rag.iterateNodes({ max: MAX_NODES })) {
22
25
  rows.push({
@@ -26,16 +29,17 @@ export async function indexSync() {
26
29
  type: n.node_type ?? null,
27
30
  tokens: null,
28
31
  touched_at: Date.parse(n.updated_at ?? '') || 0,
32
+ stale: n.stale ? 1 : 0,
29
33
  });
30
34
  }
31
- state.replaceNodes(rows);
32
- state.setMeta('last_sync', String(Date.now()));
35
+ state.replaceNodes(rows, workspace ?? '');
36
+ state.setMeta(`last_sync:${workspace ?? ''}`, String(Date.now()));
33
37
  return 0;
34
38
  }
35
39
  catch {
36
40
  // Silent on purpose: nobody is watching a detached worker. The attempt is recorded so a machine
37
41
  // that cannot sync is not retried by every session start and every Stop.
38
- state.setMeta('last_attempt', String(Date.now()));
42
+ state.setMeta(`last_attempt:${workspace ?? ''}`, String(Date.now()));
39
43
  return 1;
40
44
  }
41
45
  finally {
@@ -1,23 +1,26 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { updateConfig, readConfig } from '../config.js';
3
3
  import { DEFAULT_ISSUER } from '../constants.js';
4
- import { clearCredential, envKey, looksLikeAgentKey, mask, saveCredential } from '../auth/credentials.js';
4
+ import { ACCOUNT_SLOT, clearCredential, clearWorkspaceSlots, envKey, looksLikeAgentKey, mask, saveCredential, workspaceSlot, writeSlot } from '../auth/credentials.js';
5
5
  import { runOAuthLogin } from '../auth/oauth.js';
6
+ import { listAccountWorkspaces } from '../auth/account.js';
6
7
  import { GigaRag, McpHttpError } from '../sdk.js';
7
8
  import { assertSecureUrl } from '../secureUrl.js';
8
9
  import { err, out, promptHidden, readStdinLine, UsageError } from '../ui.js';
9
- const HELP = `Usage: gigarag login [--paste] [--key <key>] [--url <mcp url>] [--no-verify]
10
+ const HELP = `Usage: gigarag login [--paste] [--key <key>] [--workspace <id>] [--url <mcp url>] [--no-verify]
10
11
 
11
12
  Signs this machine in to GigaRAG. With no flags it opens your browser, and nothing
12
13
  holds a key afterwards: the gateway swaps your sign-in for one behind the scenes.
13
14
 
14
15
  Piped input still takes a key, so scripts keep working: echo $KEY | gigarag login
15
16
 
16
- --paste Paste an agent key instead of opening a browser
17
- --browser Open the browser. The default, accepted so older scripts still run
18
- --key <key> Give the key on the command line (it will sit in your shell history)
19
- --url <url> Use a different MCP endpoint
20
- --no-verify Store the key without checking it against GigaRAG first`;
17
+ --paste Paste an agent key instead of opening a browser
18
+ --browser Open the browser. The default, accepted so older scripts still run
19
+ --key <key> Give the key on the command line (it will sit in your shell history)
20
+ --workspace <id> Store the key for that workspace (with --key or --paste)
21
+ --url <url> Use a different MCP endpoint
22
+ --no-verify Store the key without checking it against GigaRAG first`;
23
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
24
  export async function login(argv) {
22
25
  const { values } = parseArgs({
23
26
  args: argv,
@@ -25,6 +28,7 @@ export async function login(argv) {
25
28
  browser: { type: 'boolean' },
26
29
  paste: { type: 'boolean' },
27
30
  key: { type: 'string' },
31
+ workspace: { type: 'string' },
28
32
  url: { type: 'string' },
29
33
  issuer: { type: 'string' },
30
34
  'no-verify': { type: 'boolean' },
@@ -98,7 +102,12 @@ export async function login(argv) {
98
102
  return 1;
99
103
  }
100
104
  }
101
- const where = saveCredential({ type: 'key', key });
105
+ if (values.workspace && !UUID.test(values.workspace)) {
106
+ throw new UsageError(`--workspace wants a workspace id, not "${values.workspace}". Find it with: gigarag workspaces`);
107
+ }
108
+ const where = values.workspace
109
+ ? writeSlot(workspaceSlot(values.workspace.toLowerCase()), { type: 'key', key })
110
+ : saveCredential({ type: 'key', key });
102
111
  updateConfig(c => void (c.auth = 'key'));
103
112
  out(`Connected. ${mask(key)} is stored in ${where}.`);
104
113
  out('Next: gigarag connect');
@@ -107,19 +116,42 @@ export async function login(argv) {
107
116
  async function browserLogin(issuer, mcpUrl) {
108
117
  try {
109
118
  const config = readConfig();
119
+ // The account scope: one sign-in that lists every workspace and is traded
120
+ // for a token per workspace, instead of a grant tied to one workspace. Its
121
+ // tokens are for /api/cli, and the app refuses them at /mcp.
110
122
  const result = await runOAuthLogin({
111
123
  issuer,
112
- resource: mcpUrl,
124
+ scope: 'account offline_access',
125
+ resource: `${issuer.replace(/\/$/, '')}/api/cli`,
113
126
  clientId: config.issuer === issuer ? config.oauthClientId : undefined,
114
127
  log: line => out(line),
115
128
  });
116
- const where = saveCredential(result.credential);
129
+ // A new account sign-in never keeps the previous one's workspace tokens: each
130
+ // slot is keyed by workspace id, and a token minted for another person's
131
+ // account must not be presented as this one's, or a repository bound to a
132
+ // workspace id the old account reached would silently work again.
133
+ clearWorkspaceSlots();
134
+ const where = writeSlot(ACCOUNT_SLOT, result.credential);
117
135
  updateConfig(c => {
118
136
  c.auth = 'oauth';
119
137
  c.issuer = issuer;
120
138
  c.oauthClientId = result.clientId;
139
+ if (mcpUrl)
140
+ c.mcpUrl = mcpUrl;
121
141
  });
122
- out(`Connected through your browser. The sign-in is stored in ${where}.`);
142
+ out(`Signed in through your browser. The sign-in is stored in ${where}.`);
143
+ try {
144
+ const workspaces = await listAccountWorkspaces({ issuer });
145
+ const own = workspaces.find(w => w.access_via === 'owner' && w.is_default) ?? workspaces[0];
146
+ if (own && !readConfig().workspaceId)
147
+ updateConfig(c => void (c.workspaceId = own.id));
148
+ out(`You can reach ${workspaces.length} ${workspaces.length === 1 ? 'workspace' : 'workspaces'}.${own ? ` Folders with no link use ${own.name}.` : ''}`);
149
+ out('To link a repository to a workspace, run this in it: gigarag use <workspace>');
150
+ out('To see them all: gigarag workspaces');
151
+ }
152
+ catch (e) {
153
+ err(`Signed in, but could not list your workspaces: ${e.message}`);
154
+ }
123
155
  out('Next: gigarag connect');
124
156
  return 0;
125
157
  }
@@ -135,7 +167,10 @@ export async function logout(argv) {
135
167
  return 0;
136
168
  }
137
169
  clearCredential();
138
- updateConfig(c => void (c.auth = undefined));
170
+ updateConfig(c => {
171
+ c.auth = undefined;
172
+ c.workspaceId = undefined;
173
+ });
139
174
  out('Signed out. The stored credential is deleted.');
140
175
  const written = Object.entries(readConfig().clients);
141
176
  if (written.length > 0) {
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
+ import { resolveWorkspace, untrustedBinding, untrustedBindingNotice } from '../binding.js';
2
3
  import { runBridge } from '../mcp/bridge.js';
3
- import { createClient } from '../mcp/session.js';
4
+ import { createClient, workspaceNotice } from '../mcp/session.js';
4
5
  import { err } from '../ui.js';
5
6
  /**
6
7
  * The stdio bridge. A client's config file runs this, so stdout belongs to the
@@ -13,6 +14,14 @@ export async function mcp(argv) {
13
14
  url: values.url,
14
15
  onRetry: (wait, attempt) => err(`gigarag: rate limited, waiting ${wait}s (retry ${attempt})`),
15
16
  });
17
+ // An untrusted binding is never used silently: resolveWorkspace already
18
+ // skipped it in favour of the machine default, and this names why.
19
+ const untrusted = untrustedBinding();
20
+ if (untrusted)
21
+ err(`gigarag: ${untrustedBindingNotice(untrusted)}`);
22
+ const notice = workspaceNotice(resolveWorkspace());
23
+ if (notice && !untrusted)
24
+ err(`gigarag: ${notice}`);
16
25
  await runBridge(client, {
17
26
  input: process.stdin,
18
27
  output: process.stdout,
@@ -10,6 +10,7 @@ const HELP = `Usage: gigarag repo [path] [--slug]
10
10
  Prints which GigaRAG bucket a directory belongs to: {slug, root, remote, indexedAt, dirty}.
11
11
  The slug comes from the git remote when there is one, so two checkouts of one repository share
12
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
+ When .gigarag.json names a bucket, that is the slug, and "bound" is true.
13
14
 
14
15
  --slug Print only the slug`;
15
16
  export async function repo(argv) {
@@ -1,8 +1,22 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { parseArgs } from 'node:util';
3
+ import { untrustedBinding } from '../binding.js';
3
4
  import { scanTree } from '../scan/scan.js';
4
5
  import { State } from '../state.js';
5
6
  import { out, table, UsageError } from '../ui.js';
7
+ /** Exit status that tells /gigaindex to stop and relay stdout, the same convention as `gigarag sync`. */
8
+ const STOP = 3;
9
+ /**
10
+ * The instructive refusal for a repository whose `.gigarag.json` has not been trusted. `scan`
11
+ * checks this itself, rather than silently falling back to the local slug or the default
12
+ * workspace: a binding an attacker committed can name another of the person's own buckets, and a
13
+ * scan that ran anyway (see `repo.ts`) is what let it report that bucket's files as deleted and
14
+ * offer to delete their memos. Refusing here means /gigaindex never gets that far.
15
+ */
16
+ function untrustedScanMessage(untrusted) {
17
+ return (`${untrusted.root}'s .gigarag.json links this repository to workspace ${untrusted.workspace}, which has not been trusted, so the scan did not run. ` +
18
+ 'Retrying will not help. If you placed or reviewed this file yourself, run: gigarag trust. To use a different workspace instead: gigarag use <workspace>.');
19
+ }
6
20
  /** A whole number of at least 1. Number('abc') is NaN, which would have meant no limit at all. */
7
21
  function parseLimit(raw) {
8
22
  if (raw === undefined || raw === '')
@@ -37,9 +51,15 @@ export async function scan(argv) {
37
51
  out(HELP);
38
52
  return 0;
39
53
  }
54
+ const start = resolve(positionals[0] ?? '.');
55
+ const untrusted = untrustedBinding(start);
56
+ if (untrusted) {
57
+ out(untrustedScanMessage(untrusted));
58
+ return STOP;
59
+ }
40
60
  const state = new State();
41
61
  try {
42
- const manifest = scanTree(resolve(positionals[0] ?? '.'), {
62
+ const manifest = scanTree(start, {
43
63
  state,
44
64
  all: values.all,
45
65
  verbose: values.verbose,