gigarag-claude-code 0.1.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gigarag-indexer.md +6 -3
- package/cli/auth/account.js +106 -0
- package/cli/auth/credentials.js +125 -16
- package/cli/binding.js +117 -0
- package/cli/cli.js +10 -0
- package/cli/commands/authHeader.js +5 -8
- package/cli/commands/connect.js +4 -2
- package/cli/commands/gitHooks.js +367 -0
- package/cli/commands/indexSync.js +9 -5
- package/cli/commands/login.js +47 -12
- package/cli/commands/mcp.js +10 -1
- package/cli/commands/repo.js +1 -0
- package/cli/commands/scan.js +21 -1
- package/cli/commands/status.js +33 -15
- package/cli/commands/sync.js +223 -0
- package/cli/commands/trust.js +31 -0
- package/cli/commands/use.js +81 -0
- package/cli/commands/workspaces.js +40 -0
- package/cli/git/git.js +187 -0
- package/cli/git/hookScript.js +83 -0
- package/cli/hooks.js +32 -15
- package/cli/mcp/client.js +4 -0
- package/cli/mcp/session.js +66 -7
- package/cli/package.json +1 -1
- package/cli/prompts.generated.js +2 -2
- package/cli/scan/repo.js +16 -2
- package/cli/scan/scan.js +1 -1
- package/cli/state.js +71 -11
- package/cli/sync/notes.js +50 -0
- package/cli/sync/plan.js +76 -0
- package/cli/sync/remote.js +72 -0
- package/cli/sync/rewriter.js +125 -0
- package/cli/sync/slug.js +30 -0
- package/cli/sync/stage.js +323 -0
- package/cli/sync/target.js +14 -0
- package/cli/sync/worker.js +159 -0
- package/commands/gigaindex.md +7 -1
- package/commands/gigasync.md +5 -3
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gigarag",
|
|
3
3
|
"displayName": "GigaRAG",
|
|
4
|
-
"version": "0.
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/cli/auth/credentials.js
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
11
|
+
const authorization = await createClient().authorizationHeader();
|
|
12
|
+
out(JSON.stringify({ Authorization: authorization }));
|
|
16
13
|
return 0;
|
|
17
14
|
}
|
|
18
15
|
catch (e) {
|
package/cli/commands/connect.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|