gigarag-claude-code 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gigarag",
3
3
  "displayName": "GigaRAG",
4
- "version": "0.1.2",
4
+ "version": "0.2.1",
5
5
  "description": "Your GigaRAG knowledge base inside Claude Code: index a codebase, save decisions, recall them later, and start every session knowing what memory exists.",
6
6
  "author": { "name": "GigaRAG", "url": "https://gigarag.com" },
7
7
  "homepage": "https://gigarag.com/connect/claude-code",
@@ -15,7 +15,7 @@ The work is split on purpose. The `gigarag` command does the mechanical half: it
15
15
 
16
16
  ## 1. Scan
17
17
 
18
- Run `gigarag scan <path>` with the path you were given, or the current directory. It prints a JSON manifest. Read these fields:
18
+ Run `gigarag scan <path>` with the path you were given, or the current directory. If it exits nonzero and prints a line about an untrusted workspace instead of JSON, stop immediately: do not run `gigarag use` yourself, and do not fall back to indexing without a manifest. Report the line it printed and end there. Otherwise it prints a JSON manifest. Read these fields:
19
19
 
20
20
  - `repo.slug` is the bucket. `repo.root` is the repository root.
21
21
  - `files` lists new and changed files, each with `path`, `hash`, `status`, `lines`, `language`, `nodeIds` (memos already made from it) and sometimes `chunks` (line ranges, when the file is too long for one memo).
@@ -26,7 +26,7 @@ If `files` and `removed` are both empty, report "Up to date" and stop.
26
26
 
27
27
  ## 2. Find or create the bucket and threads
28
28
 
29
- Call `list_buckets` with `q` set to the slug. Use the bucket whose slug matches exactly, or `create_bucket` with that slug, the repository name as the title, and a one line description. Slugs are lowercase letters, digits and hyphens, at most 64 characters.
29
+ Call `list_buckets` with `q` set to the slug and use the bucket whose slug matches exactly. If `repo.bound` is true in the manifest, the bucket was chosen in `.gigarag.json`: never call `create_bucket`. If it is not in the list, stop and tell the person that the bucket named in .gigarag.json is not reachable with their access, and that `gigarag use <workspace> --bucket <slug>` links another. Otherwise, when no bucket matches, `create_bucket` with that slug, the repository name as the title, and a one line description. Slugs are lowercase letters, digits and hyphens, at most 64 characters.
30
30
 
31
31
  Group memos into threads by the top level area of the repository: `src`, `docs`, `tests`, `config`. Look them up with `list_threads` for the bucket and create the missing ones. Keep the number of threads under about ten.
32
32
 
@@ -40,13 +40,16 @@ Work in batches of about ten files. For each file:
40
40
  - The title says what the module is, for example "Session token refresh". The summary is one or two sentences. `node_type` is `code` for source, `doc` for markdown and prose, `config` for configuration. The slug comes from the path, for example `src-auth-session-ts`. Set `actor` to `agent:gigarag-indexer`.
41
41
  - A body holds at most 10,000 characters. For a long file with `chunks`, write one memo per chunk and a short parent memo that links to them.
42
42
  - If `nodeIds` is not empty, the file was indexed before. Call `update_node` on that node instead of creating a second one, and keep the `[Title](N:12)` links already in its text unless the thing they point at is gone.
43
+ - A memo whose source changed carries `stale: true`. Rewriting its content with `update_node` clears the mark. If you read the file and the memo is still right, send its body back unchanged, which clears the mark too.
43
44
  - Where this memo depends on another module, link it in the text as `[Module name](N:12)`. Get the ref from `find_nodes` with part of the title, or from a memo you have already written this run. Never write a UUID as a link target. After each write, look at `unresolved_links` in the reply and fix or remove any ref that did not resolve.
44
45
 
45
46
  ## 4. Record progress after every batch
46
47
 
47
- After each batch, pipe the result to `gigarag record` so a later run updates in place and an interrupted run can resume. Send JSON on stdin:
48
+ After each batch, pipe the result to `gigarag record` so a later run updates in place and an interrupted run can resume. Send JSON on stdin with a heredoc, so the command line stays `gigarag record` and nothing else:
48
49
 
50
+ gigarag record <<'JSON'
49
51
  {"repo":"<slug>","files":[{"path":"src/auth.ts","hash":"<hash from the manifest>","nodes":[{"id":"<node id>","ref":"N:12","title":"Session token refresh","type":"code","tokens":600}]}]}
52
+ JSON
50
53
 
51
54
  `tokens` is your rough estimate of what fetching the memo costs, at about four characters a token. Do this per batch, not once at the end.
52
55
 
@@ -0,0 +1,106 @@
1
+ import { updateConfig } from '../config.js';
2
+ import { DEFAULT_MCP_URL } from '../constants.js';
3
+ import { assertSecureUrl } from '../secureUrl.js';
4
+ import { ACCOUNT_SLOT, configuredIssuer, loadAccountCredential, refreshOAuth, workspaceSlot, writeSlot, currentGeneration, } from './credentials.js';
5
+ /**
6
+ * The account sign-in: list every workspace the person can reach, and trade
7
+ * the account token for one workspace's token.
8
+ *
9
+ * The account token opens no workspace by itself. The app refuses it at /mcp
10
+ * and says so, so everything that talks to a workspace goes through
11
+ * `exchangeForWorkspace` first. See section 9 of the app's sharing levels spec.
12
+ */
13
+ export const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
14
+ const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
15
+ const REFRESH_MARGIN_MS = 60_000;
16
+ export class AccountError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'AccountError';
20
+ }
21
+ }
22
+ const NOT_SIGNED_IN = 'This computer has no GigaRAG account sign-in. Run: gigarag login';
23
+ /** The account credential, refreshed when it is about to lapse. */
24
+ async function accountCredential(fetchImpl, now, force = false) {
25
+ const credential = loadAccountCredential();
26
+ if (!credential)
27
+ throw new AccountError(NOT_SIGNED_IN);
28
+ const due = credential.expiresAt !== undefined && credential.expiresAt - now() < REFRESH_MARGIN_MS;
29
+ if ((force || due) && credential.refreshToken) {
30
+ try {
31
+ return await refreshOAuth(credential, fetchImpl, now, ACCOUNT_SLOT);
32
+ }
33
+ catch (err) {
34
+ throw new AccountError(err.message);
35
+ }
36
+ }
37
+ return credential;
38
+ }
39
+ export async function listAccountWorkspaces(opts = {}) {
40
+ const fetchImpl = opts.fetch ?? fetch;
41
+ const now = opts.now ?? Date.now;
42
+ const issuer = (opts.issuer ?? configuredIssuer()).replace(/\/$/, '');
43
+ const url = assertSecureUrl(`${issuer}/api/cli/workspaces`, 'The GigaRAG app');
44
+ const call = async (credential) => fetchImpl(url, {
45
+ headers: { authorization: `Bearer ${credential.accessToken}`, accept: 'application/json' },
46
+ signal: AbortSignal.timeout(30_000),
47
+ });
48
+ let res = await call(await accountCredential(fetchImpl, now));
49
+ if (res.status === 401)
50
+ res = await call(await accountCredential(fetchImpl, now, true));
51
+ const body = (await res.json().catch(() => ({})));
52
+ if (!res.ok || !Array.isArray(body.workspaces)) {
53
+ throw new AccountError(body.error ?? `Could not list your workspaces (HTTP ${res.status}). Run: gigarag login`);
54
+ }
55
+ updateConfig(c => {
56
+ c.workspaceNames = Object.fromEntries(body.workspaces.map(w => [w.id, w.name]));
57
+ });
58
+ return body.workspaces;
59
+ }
60
+ export async function exchangeForWorkspace(workspaceId, opts = {}) {
61
+ const fetchImpl = opts.fetch ?? fetch;
62
+ const now = opts.now ?? Date.now;
63
+ // Read before the account credential: a browser sign-in that lands while this exchange is in
64
+ // flight moves the generation on, and the old account's token must not be stored under the new one.
65
+ const generation = currentGeneration();
66
+ const account = await accountCredential(fetchImpl, now);
67
+ assertSecureUrl(account.tokenEndpoint, 'The token endpoint');
68
+ const res = await fetchImpl(account.tokenEndpoint, {
69
+ method: 'POST',
70
+ signal: AbortSignal.timeout(30_000),
71
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
72
+ body: new URLSearchParams({
73
+ grant_type: TOKEN_EXCHANGE,
74
+ subject_token: account.accessToken,
75
+ subject_token_type: ACCESS_TOKEN_TYPE,
76
+ resource: opts.mcpUrl ?? DEFAULT_MCP_URL,
77
+ workspace_id: workspaceId,
78
+ client_id: account.clientId,
79
+ }),
80
+ });
81
+ const body = (await res.json().catch(() => ({})));
82
+ if (!res.ok || typeof body['access_token'] !== 'string') {
83
+ const said = typeof body['error_description'] === 'string' ? body['error_description'] : undefined;
84
+ throw new AccountError(said ??
85
+ `Could not connect to workspace ${workspaceId} (HTTP ${res.status}). Retrying may help if GigaRAG was down; ` +
86
+ 'otherwise run: gigarag workspaces, to check your account can still reach it, or gigarag login to sign in again.');
87
+ }
88
+ const fresh = {
89
+ type: 'oauth',
90
+ accessToken: body['access_token'],
91
+ refreshToken: typeof body['refresh_token'] === 'string' ? body['refresh_token'] : undefined,
92
+ expiresAt: typeof body['expires_in'] === 'number' ? now() + body['expires_in'] * 1000 : undefined,
93
+ clientId: account.clientId,
94
+ tokenEndpoint: account.tokenEndpoint,
95
+ };
96
+ // Hand back the stamped copy, not `fresh`: a bridge keeps this credential in memory, and one
97
+ // without a stamp would pass as current after a later sign-in and be written back on refresh.
98
+ const slot = workspaceSlot(workspaceId);
99
+ const credential = { ...fresh, gen: generation };
100
+ // Stored only if no sign-in happened meanwhile. Otherwise this call still gets a working token,
101
+ // stamped with the old generation, so nothing refreshes it back into the slot later.
102
+ if (currentGeneration() === generation)
103
+ writeSlot(slot, credential);
104
+ const ws = (body['workspace'] ?? {});
105
+ return { credential, workspace: { id: ws.id ?? workspaceId, name: ws.name ?? workspaceId, role: ws.role ?? '' } };
106
+ }
@@ -1,8 +1,7 @@
1
- import { readConfig } from '../config.js';
1
+ import { readConfig, updateConfig } from '../config.js';
2
2
  import { assertSecureUrl } from '../secureUrl.js';
3
3
  import { DEFAULT_ISSUER } from '../constants.js';
4
4
  import { deleteSecret, getSecret, setSecret } from '../secrets.js';
5
- const ACCOUNT = 'default';
6
5
  /**
7
6
  * The shape agent keys take, `gr_live_<region>_<32 base62>_<6 hex>`. Checked
8
7
  * loosely: a format that changes must not lock people out.
@@ -19,16 +18,50 @@ export function envKey(env = process.env) {
19
18
  const value = env['GIGARAG_API_KEY']?.trim();
20
19
  return value && !value.startsWith('${') ? value : undefined;
21
20
  }
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);
21
+ /**
22
+ * Where each credential lives in the keychain.
23
+ *
24
+ * `account` holds the GigaRAG CLI's account sign-in, which lists workspaces and
25
+ * is exchanged for one token per workspace. Each workspace's token pair, or a
26
+ * pasted agent key for it, lives in its own slot. `default` is the single slot
27
+ * every version before this used, read so a machine signed in before still
28
+ * works until it signs in again.
29
+ *
30
+ * The slot is `workspace-<id>`: the file store names its file after the slot,
31
+ * and Windows refuses a colon in a filename.
32
+ */
33
+ export const ACCOUNT_SLOT = 'account';
34
+ export const LEGACY_SLOT = 'default';
35
+ export const workspaceSlot = (id) => `workspace-${id}`;
36
+ const isWorkspaceSlot = (slot) => slot.startsWith('workspace-');
37
+ /** The sign-in generation this machine is on, bumped by `clearWorkspaceSlots`. */
38
+ export function currentGeneration() {
39
+ return readConfig().authGeneration ?? 0;
40
+ }
41
+ /**
42
+ * True for a workspace slot stamped with a generation that isn't the current one: a credential a
43
+ * previous account's sign-in wrote or refreshed, left behind after a fresh browser sign-in cleared
44
+ * the slot it came from. Only workspace slots carry this check; the account and legacy slots are
45
+ * rewritten in full on every sign-in and are never subject to `clearWorkspaceSlots`.
46
+ */
47
+ function isStale(slot, credential) {
48
+ // An unstamped credential counts as generation 0: slots written before stamping existed stay
49
+ // readable until the first new sign-in, which deletes them anyway, and a bridge holding one in
50
+ // memory cannot write it back after that sign-in.
51
+ return isWorkspaceSlot(slot) && (credential.gen ?? 0) !== currentGeneration();
52
+ }
53
+ /** The credential as `writeSlot` stores it: stamped with this sign-in generation for a workspace slot. */
54
+ export function stampForSlot(slot, credential) {
55
+ return isWorkspaceSlot(slot) ? { ...credential, gen: currentGeneration() } : credential;
56
+ }
57
+ export function readSlot(slot) {
58
+ const stored = getSecret(slot);
28
59
  if (!stored)
29
60
  return undefined;
30
61
  try {
31
62
  const parsed = JSON.parse(stored.value);
63
+ if (isStale(slot, parsed))
64
+ return undefined;
32
65
  if (parsed.type === 'key' && parsed.key)
33
66
  return parsed;
34
67
  if (parsed.type === 'oauth' && parsed.accessToken)
@@ -39,17 +72,86 @@ export function loadCredential(env = process.env) {
39
72
  }
40
73
  return undefined;
41
74
  }
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));
75
+ /** Stores a credential in a slot and returns where it went, for the "connected" line. */
76
+ export function writeSlot(slot, credential) {
77
+ const stamped = stampForSlot(slot, credential);
78
+ const where = setSecret(slot, JSON.stringify(stamped));
79
+ updateConfig(c => {
80
+ const slots = new Set(c.credentialSlots ?? []);
81
+ slots.add(slot);
82
+ c.credentialSlots = [...slots];
83
+ });
84
+ return where;
85
+ }
86
+ export function clearSlot(slot) {
87
+ deleteSecret(slot);
88
+ }
89
+ /**
90
+ * Deletes every `workspace-<id>` slot, and the machine default and cached
91
+ * workspace names that go with them, without touching the account slot or the
92
+ * legacy one.
93
+ *
94
+ * Called before a fresh browser sign-in is stored. Without it, a workspace
95
+ * token minted for a previous account keeps working after a different person
96
+ * signs in on the same machine, because each slot is keyed by workspace id and
97
+ * a new account's own workspace could reuse an id the old one also happened to
98
+ * reach, or the stale slot is simply presented for a workspace the new account
99
+ * was never granted, either of which sends the new person's work to a
100
+ * workspace their sign-in never chose.
101
+ *
102
+ * Deleting the slots isn't enough by itself: a bridge process started before this
103
+ * runs can still hold one of them in memory, and its normal refresh-on-expiry
104
+ * logic would write the rotated pair straight back afterwards. So this also bumps
105
+ * `authGeneration`, and `refreshOAuth` refuses to write a workspace slot back
106
+ * under a generation that has moved on (see `isStale` above).
107
+ */
108
+ export function clearWorkspaceSlots() {
109
+ const config = readConfig();
110
+ const kept = [];
111
+ for (const slot of config.credentialSlots ?? []) {
112
+ if (isWorkspaceSlot(slot))
113
+ deleteSecret(slot);
114
+ else
115
+ kept.push(slot);
116
+ }
117
+ updateConfig(c => {
118
+ c.credentialSlots = kept;
119
+ c.workspaceId = undefined;
120
+ c.workspaceNames = undefined;
121
+ c.authGeneration = (c.authGeneration ?? 0) + 1;
122
+ });
123
+ }
124
+ export function loadAccountCredential() {
125
+ const credential = readSlot(ACCOUNT_SLOT);
126
+ return credential?.type === 'oauth' ? credential : undefined;
127
+ }
128
+ /**
129
+ * `GIGARAG_API_KEY` wins, so CI and containers need no login step. Then the
130
+ * workspace's own slot when a workspace is named, and the legacy slot only when
131
+ * none is: answering a workspace with another workspace's credential would send
132
+ * a person's memos to the wrong place without a word.
133
+ */
134
+ export function loadCredential(env = process.env, workspaceId) {
135
+ const fromEnv = envKey(env);
136
+ if (fromEnv)
137
+ return { type: 'key', key: fromEnv };
138
+ return readSlot(workspaceId ? workspaceSlot(workspaceId) : LEGACY_SLOT);
139
+ }
140
+ /** Stores a credential and returns where it went. The legacy slot unless told otherwise. */
141
+ export function saveCredential(credential, slot = LEGACY_SLOT) {
142
+ return writeSlot(slot, credential);
45
143
  }
144
+ /** Deletes every credential this machine holds: the account, each workspace, and the legacy slot. */
46
145
  export function clearCredential() {
47
- deleteSecret(ACCOUNT);
146
+ const slots = new Set([...(readConfig().credentialSlots ?? []), ACCOUNT_SLOT, LEGACY_SLOT]);
147
+ for (const slot of slots)
148
+ deleteSecret(slot);
149
+ updateConfig(c => void (c.credentialSlots = []));
48
150
  }
49
- export function credentialSource(env = process.env) {
151
+ export function credentialSource(env = process.env, slot = LEGACY_SLOT) {
50
152
  if (envKey(env))
51
153
  return 'the GIGARAG_API_KEY environment variable';
52
- return getSecret(ACCOUNT)?.store;
154
+ return getSecret(slot)?.store;
53
155
  }
54
156
  /** `gr_live_eu_*****`, for printing. */
55
157
  export function mask(key) {
@@ -72,7 +174,7 @@ export async function authorizationHeader(credential, fetchImpl = fetch, now = D
72
174
  }
73
175
  return `Bearer ${credential.accessToken}`;
74
176
  }
75
- export async function refreshOAuth(credential, fetchImpl = fetch, now = Date.now) {
177
+ export async function refreshOAuth(credential, fetchImpl = fetch, now = Date.now, slot = LEGACY_SLOT) {
76
178
  if (!credential.refreshToken)
77
179
  throw new Error('The sign-in has expired. Run: gigarag login');
78
180
  assertSecureUrl(credential.tokenEndpoint, 'The token endpoint');
@@ -98,7 +200,14 @@ export async function refreshOAuth(credential, fetchImpl = fetch, now = Date.now
98
200
  refreshToken: typeof body['refresh_token'] === 'string' ? body['refresh_token'] : credential.refreshToken,
99
201
  expiresAt: typeof body['expires_in'] === 'number' ? now() + body['expires_in'] * 1000 : undefined,
100
202
  };
101
- saveCredential(next);
203
+ // `credential` was loaded (or held in memory by a long-running bridge) under an older sign-in
204
+ // generation than this machine is now on: a fresh browser sign-in cleared this workspace slot
205
+ // and moved the generation forward since. Writing the refreshed pair back would resurrect the
206
+ // previous account's access for every later process that reads the slot, so it is dropped
207
+ // instead. The caller here still gets a working pair for this one call.
208
+ if (isStale(slot, credential))
209
+ return next;
210
+ saveCredential(next, slot);
102
211
  return next;
103
212
  }
104
213
  export function configuredIssuer() {
package/cli/binding.js ADDED
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readConfig, updateConfig } from './config.js';
4
+ import { findRepoRoot } from './scan/repo.js';
5
+ /**
6
+ * Which GigaRAG workspace a repository belongs to.
7
+ *
8
+ * `.gigarag.json` at the repository root names a workspace, and optionally the
9
+ * bucket the repository is indexed into. It holds no secret, so it can be
10
+ * committed, and a teammate's checkout then reaches the same workspace with
11
+ * their own sign-in. `gigarag use` writes it.
12
+ */
13
+ export const BINDING_FILE = '.gigarag.json';
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
16
+ /** The binding above `start`, or undefined when there is none or it does not validate. */
17
+ export function readBinding(start) {
18
+ const { root } = findRepoRoot(start);
19
+ const path = join(root, BINDING_FILE);
20
+ if (!existsSync(path))
21
+ return undefined;
22
+ try {
23
+ const text = readFileSync(path, 'utf8');
24
+ const raw = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
25
+ if (typeof raw.workspace !== 'string' || !UUID.test(raw.workspace))
26
+ return undefined;
27
+ const binding = { workspace: raw.workspace.toLowerCase() };
28
+ if (typeof raw.workspaceName === 'string')
29
+ binding.workspaceName = raw.workspaceName.slice(0, 200);
30
+ if (typeof raw.bucket === 'string' && SLUG.test(raw.bucket))
31
+ binding.bucket = raw.bucket;
32
+ return { binding, root };
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /** Writes the binding at `root`, atomically, and returns its path. */
39
+ export function writeBinding(root, binding) {
40
+ const path = join(root, BINDING_FILE);
41
+ const tmp = `${path}.${process.pid}.tmp`;
42
+ writeFileSync(tmp, `${JSON.stringify(binding, null, 2)}\n`);
43
+ renameSync(tmp, path);
44
+ return path;
45
+ }
46
+ /**
47
+ * The repository root, canonicalised the way trust is keyed: symlinks and
48
+ * junctions resolved, and lowercased on Windows, where the same folder can be
49
+ * reached under more than one case. Trust must not be foolable by either.
50
+ */
51
+ function trustKey(root) {
52
+ const real = realpathSync(root);
53
+ return process.platform === 'win32' ? real.toLowerCase() : real;
54
+ }
55
+ /**
56
+ * Whether `root`'s binding to `workspace` has been trusted: recorded when the
57
+ * person ran `gigarag use` or `gigarag trust` for this exact pair. Trust-on-
58
+ * first-use, the way `direnv` treats a new `.envrc`, because `.gigarag.json` is
59
+ * committed and read before anyone has reviewed it, and it otherwise silently
60
+ * redirects a clone to whatever workspace the file names, including one an
61
+ * attacker invited the victim into.
62
+ */
63
+ export function isTrusted(root, workspace) {
64
+ return readConfig().trustedBindings?.[trustKey(root)] === workspace;
65
+ }
66
+ /**
67
+ * The binding at `root`, but only once it has been trusted; an untrusted one reads exactly like
68
+ * no binding at all. `repoInfo` (`scan/repo.ts`) and `syncSlug` (`sync/slug.ts`) both resolve a
69
+ * bucket slug and workspace through this one function so they cannot drift on what "trusted"
70
+ * means: before this existed, `repoInfo` read `readBinding` directly and skipped the trust check
71
+ * entirely, so a committed `.gigarag.json` an attacker pointed at another of the victim's buckets
72
+ * was followed on sight by `/gigaindex`, `gigarag record` and the PostToolUse dirty-marking hook.
73
+ */
74
+ export function trustedBinding(root) {
75
+ const found = readBinding(root);
76
+ if (!found || !isTrusted(found.root, found.binding.workspace))
77
+ return undefined;
78
+ return found;
79
+ }
80
+ /** Records that `root`'s binding to `workspace` is trusted. A changed workspace id needs trusting again. */
81
+ export function trustBinding(root, workspace) {
82
+ const key = trustKey(root);
83
+ updateConfig(c => {
84
+ c.trustedBindings = { ...(c.trustedBindings ?? {}), [key]: workspace };
85
+ });
86
+ }
87
+ /** The binding above `cwd`, when there is one but it has not been trusted yet. */
88
+ export function untrustedBinding(cwd = process.cwd()) {
89
+ const found = readBinding(cwd);
90
+ if (!found || isTrusted(found.root, found.binding.workspace))
91
+ return undefined;
92
+ return { workspace: found.binding.workspace, root: found.root };
93
+ }
94
+ /** The one-line notice for an untrusted binding: which workspace it names, and the exact command to trust it. */
95
+ export function untrustedBindingNotice(untrusted) {
96
+ return (`${BINDING_FILE} at ${untrusted.root} links this folder to workspace ${untrusted.workspace}, which has not been trusted, ` +
97
+ 'so GigaRAG used the default workspace instead. If you placed or reviewed this file yourself, run: gigarag trust');
98
+ }
99
+ /**
100
+ * The workspace a process in `cwd` should use: `GIGARAG_WORKSPACE`, then the
101
+ * repository's binding when it has been trusted, then the machine default that
102
+ * `gigarag login` set. An untrusted binding is treated as absent here; callers
103
+ * that want to tell the person about it call `untrustedBinding` themselves,
104
+ * since a library call and a background hook must not print anything, and a
105
+ * bridge or hook is what decides where that notice goes.
106
+ */
107
+ export function resolveWorkspace(cwd = process.cwd(), env = process.env) {
108
+ const fromEnv = env['GIGARAG_WORKSPACE']?.trim();
109
+ if (fromEnv && UUID.test(fromEnv))
110
+ return { id: fromEnv.toLowerCase(), source: 'env' };
111
+ const found = readBinding(cwd);
112
+ if (found && isTrusted(found.root, found.binding.workspace)) {
113
+ return { id: found.binding.workspace, source: 'binding', binding: found.binding };
114
+ }
115
+ const fallback = readConfig().workspaceId;
116
+ return fallback && UUID.test(fallback) ? { id: fallback.toLowerCase(), source: 'default' } : undefined;
117
+ }
package/cli/cli.js CHANGED
@@ -8,10 +8,15 @@ Connect GigaRAG to your AI clients.
8
8
  gigarag logout Delete the stored credential
9
9
  gigarag connect [client...] Add GigaRAG to your installed clients
10
10
  gigarag status What is configured, and whether the key works
11
+ gigarag workspaces Every workspace your account can reach
12
+ gigarag use <workspace> Link this repository to a workspace
13
+ gigarag trust [path] Trust a .gigarag.json a teammate already committed
11
14
 
12
15
  gigarag scan [path] What changed in a repo since it was last indexed
13
16
  gigarag repo [path] Which bucket a directory belongs to
14
17
  gigarag record Tell the local index what was indexed (reads JSON on stdin)
18
+ gigarag hooks install [path] Git hooks that keep GigaRAG in step after each commit
19
+ gigarag sync begin|end|status Keep a repository's memos in step with git (/gigasync runs it)
15
20
 
16
21
  gigarag mcp The stdio bridge a client's config runs
17
22
  gigarag auth-header The auth header, for headersHelper
@@ -24,9 +29,14 @@ const COMMANDS = {
24
29
  logout: async () => (await import('./commands/login.js')).logout,
25
30
  connect: async () => (await import('./commands/connect.js')).connect,
26
31
  status: async () => (await import('./commands/status.js')).status,
32
+ workspaces: async () => (await import('./commands/workspaces.js')).workspaces,
33
+ use: async () => (await import('./commands/use.js')).use,
34
+ trust: async () => (await import('./commands/trust.js')).trust,
27
35
  scan: async () => (await import('./commands/scan.js')).scan,
28
36
  repo: async () => (await import('./commands/repo.js')).repo,
29
37
  record: async () => (await import('./commands/record.js')).record,
38
+ hooks: async () => (await import('./commands/gitHooks.js')).hooks,
39
+ sync: async () => (await import('./commands/sync.js')).sync,
30
40
  mcp: async () => (await import('./commands/mcp.js')).mcp,
31
41
  'auth-header': async () => (await import('./commands/authHeader.js')).authHeader,
32
42
  'index-sync': async () => (await import('./commands/indexSync.js')).indexSync,
@@ -1,18 +1,15 @@
1
- import { authorizationHeader, loadCredential } from '../auth/credentials.js';
1
+ import { createClient } from '../mcp/session.js';
2
2
  import { err, out } from '../ui.js';
3
3
  /**
4
4
  * What `headersHelper` runs. Claude Code expects one JSON object of string
5
5
  * pairs on stdout, and gives up after 10 seconds, so nothing else may print here
6
- * and nothing slow may run.
6
+ * and nothing slow may run. The workspace comes from the folder Claude Code
7
+ * starts it in, the same way the bridge picks it.
7
8
  */
8
9
  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
10
  try {
15
- out(JSON.stringify({ Authorization: await authorizationHeader(credential) }));
11
+ const authorization = await createClient().authorizationHeader();
12
+ out(JSON.stringify({ Authorization: authorization }));
16
13
  return 0;
17
14
  }
18
15
  catch (e) {
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { updateConfig, readConfig } from '../config.js';
3
- import { loadCredential } from '../auth/credentials.js';
3
+ import { loadAccountCredential, loadCredential } from '../auth/credentials.js';
4
+ import { resolveWorkspace } from '../binding.js';
4
5
  import { resolveUrl } from '../mcp/session.js';
5
6
  import { tildify } from '../paths.js';
6
7
  import { connectClient, removeClientEntry, renderConfig } from '../clients/connect.js';
@@ -168,7 +169,8 @@ export async function connect(argv) {
168
169
  }
169
170
  // With --json the output is for a program, and one stray line after the JSON would break its parser.
170
171
  if (!values.json) {
171
- if (!loadCredential()) {
172
+ const signedIn = loadCredential(process.env, resolveWorkspace()?.id) || loadAccountCredential();
173
+ if (!signedIn) {
172
174
  out('\nYou are not signed in yet, so these clients cannot reach GigaRAG. Run: gigarag login');
173
175
  }
174
176
  if (named.length === 0) {