@tokenoftrust/cli 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tot.mjs CHANGED
@@ -55,6 +55,7 @@ const COMMANDS = [
55
55
  { invocation: "link", blurb: "link your identity so your stores resolve" },
56
56
  { invocation: "grants", blurb: "capability/tier/expiry per store you can act on" },
57
57
  { invocation: "clone [<tenant>]", blurb: "clone a store you can build on" },
58
+ { invocation: "clone agency-kit [dir]", blurb: "clone the Agency Kit, read-only (needs agency access from Token of Trust)" },
58
59
  { invocation: "validate", blurb: "lint your store before you submit" },
59
60
  { invocation: "capabilities", blurb: "what the platform supports, refuses, and has not built yet" },
60
61
  { invocation: "report go-live --pages <dir>", blurb: "your store's go-live readiness report, from its page-check results" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -10,6 +10,12 @@
10
10
  * tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
11
11
  * tot clone <tenant> <dir> clone into <dir>
12
12
  * tot clone <tenant> --remote-only just mint + print the remote, don't materialize
13
+ * tot clone agency-kit [dir] READ-ONLY clone of the Agency Kit (ADR 0037)
14
+ *
15
+ * AGENCY KIT. `agency-kit` is an alias, not a tenant: it mints through
16
+ * `repo_read_credential` (see READ_ONLY_ALIASES / kitReadCredentialInput) and skips
17
+ * every store-only step. The checkout records its kind in local git config so
18
+ * `tot git-credential` re-mints it the same way.
13
19
  *
14
20
  * This is the SAME MCP code path a developer gets when they switch to a tenant:
15
21
  * sign-in → client_switch(tenant) → tenant_checkout, where the MCP derives your
@@ -54,7 +60,9 @@ import { offerSignIn } from "./login.mjs";
54
60
  import { CliError, fail, formatError } from "../errors.mjs";
55
61
  import { writeNvmrc } from "../sample.mjs";
56
62
  import { emitObstacle } from "../obstacle.mjs";
57
- import { splitAuthedRemote, basicAuthExtraHeader, installForgeCredentialHelper } from "../git-credential.mjs";
63
+ import {
64
+ splitAuthedRemote, basicAuthExtraHeader, installForgeCredentialHelper, recordReadOnlyKitCheckout,
65
+ } from "../git-credential.mjs";
58
66
  import { recordDiagnostic } from "../diagnostics.mjs";
59
67
 
60
68
  const execFileP = promisify(execFile);
@@ -115,6 +123,8 @@ const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`gi
115
123
  (works even on a tenant you don't own/aren't a
116
124
  member of, when authorized; dir defaults to
117
125
  <tenant>-pr<N>)
126
+ tot clone agency-kit [dir] clone the Agency Kit, READ-ONLY, into ./agency-kit
127
+ (needs agency access — ask Token of Trust)
118
128
 
119
129
  Options:
120
130
  --tag <tag> deprecated compatibility input; tenant repos use the bare domain.
@@ -140,14 +150,22 @@ function makeRedactor(env) {
140
150
  * @param {string[]} argv - args AFTER `checkout`
141
151
  * @param {import("../context.mjs").detectContext extends (...a:any)=>infer R ? R : any} ctx
142
152
  */
143
- export async function run(argv, ctx) {
144
- const env = process.env;
153
+ export async function run(argv, ctx, deps = {}) {
154
+ const {
155
+ env = process.env, createClient = createMcpClient, establish = establishSession,
156
+ signIn = offerSignIn, cloneDeps = {},
157
+ } = deps;
145
158
  const args = parseArgs(argv);
146
159
  if (args.help) {
147
160
  console.log(USAGE);
148
161
  return 0;
149
162
  }
150
163
 
164
+ const alias = resolveReadOnlyAlias(args.tenant);
165
+ if (alias) {
166
+ return runReadOnlyAlias(alias, args, { env, createClient, establish, signIn, cloneDeps });
167
+ }
168
+
151
169
  // Infer the tenant from a standalone checkout when run without one.
152
170
  if (!args.tenant && ctx.mode === "checkout" && ctx.tenant) {
153
171
  args.tenant = ctx.tenant;
@@ -169,24 +187,10 @@ export async function run(argv, ctx) {
169
187
 
170
188
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
171
189
  const redact = makeRedactor(env);
172
- const client = createMcpClient(baseUrl);
190
+ const client = createClient(baseUrl);
173
191
 
174
192
  try {
175
- // Attach auth in the right order relative to the handshake (developer bearer
176
- // BEFORE initialize) — see establishSession. When there's no session yet and
177
- // we're on a TTY, offer to sign in inline and retry once, so a not-signed-in
178
- // developer isn't dead-ended at "run tot login, then re-run".
179
- try {
180
- await establishSession(client, { env });
181
- } catch (e) {
182
- if (e instanceof AuthUnavailableError && e.reason === "missing") {
183
- const signedIn = await offerSignIn(client.mcpUrl, env, {});
184
- if (!signedIn) throw e; // declined/non-TTY → the crisp error below
185
- await establishSession(client, { env }); // retry once, in-flow
186
- } else {
187
- throw e;
188
- }
189
- }
193
+ await signInOrOffer(client, { env, establish, signIn });
190
194
  console.error(`~ signed in → ${client.mcpUrl}`);
191
195
 
192
196
  // No tenant → list the stores this identity can build on and stop.
@@ -262,17 +266,80 @@ export async function run(argv, ctx) {
262
266
  }
263
267
  return 0;
264
268
  } catch (e) {
265
- if (e instanceof AuthUnavailableError) {
266
- console.error(formatError(e));
267
- return 1;
268
- }
269
- if (e instanceof CliError) {
270
- console.error(formatError(e));
271
- return e.exitCode ?? 1;
269
+ return reportFailure(e, redact);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Attach auth in the right order relative to the handshake (developer bearer BEFORE
275
+ * initialize) — see establishSession. When there's no session yet and we're on a TTY,
276
+ * offer to sign in inline and retry once, so a not-signed-in developer isn't dead-ended
277
+ * at "run tot login, then re-run".
278
+ */
279
+ async function signInOrOffer(client, { env, establish, signIn }) {
280
+ try {
281
+ await establish(client, { env });
282
+ } catch (e) {
283
+ if (e instanceof AuthUnavailableError && e.reason === "missing") {
284
+ const signedIn = await signIn(client.mcpUrl, env, {});
285
+ if (!signedIn) throw e; // declined/non-TTY → the crisp error below
286
+ await establish(client, { env }); // retry once, in-flow
287
+ } else {
288
+ throw e;
272
289
  }
273
- console.error(fail(`checkout failed: ${redact(String(e?.message || e))}`));
290
+ }
291
+ }
292
+
293
+ /** Map a thrown error to the CLI's exit + message, the same way for every clone path. */
294
+ function reportFailure(e, redact) {
295
+ if (e instanceof AuthUnavailableError) {
296
+ console.error(formatError(e));
274
297
  return 1;
275
298
  }
299
+ if (e instanceof CliError) {
300
+ console.error(formatError(e));
301
+ return e.exitCode ?? 1;
302
+ }
303
+ console.error(fail(`checkout failed: ${redact(String(/** @type {any} */ (e)?.message || e))}`));
304
+ return 1;
305
+ }
306
+
307
+ /**
308
+ * `tot clone <alias> [dir]` for a read-only alias (the agency kit). Only the steps a
309
+ * non-store repo needs: sign-in, the read mint, the token-free clone, the helper. No store
310
+ * listing, client_switch, identity_bind, tenant_checkout, tenant inference, `.nvmrc` or
311
+ * `tot dev` guidance.
312
+ */
313
+ async function runReadOnlyAlias(alias, args, { env, createClient, establish, signIn, cloneDeps }) {
314
+ const redact = makeRedactor(env);
315
+ if (args.pr) {
316
+ console.error(fail(`\`--pr\` does not apply to ${alias.repo}`, `tot clone ${alias.repo} [dir]`));
317
+ return 2;
318
+ }
319
+ const cloneDir = args.remoteOnly ? null : args.dir || alias.defaultDir;
320
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
321
+ const client = createClient(baseUrl);
322
+ try {
323
+ await signInOrOffer(client, { env, establish, signIn });
324
+ console.error(`~ signed in → ${client.mcpUrl}`);
325
+ console.error(`~ repo_read_credential (${alias.target}) — MCP mints a read-only credential`);
326
+ const res = await cloneReadOnlyAlias(client, { alias, cloneDir, redact }, cloneDeps);
327
+ console.log(`\n+ ready (read-only). repo: ${res.publicUrl}`);
328
+ if (res.cloned) {
329
+ console.log(`+ cloned. HEAD: ${res.head}`);
330
+ console.log(`\nYour read-only ${alias.repo} checkout is at ${res.dir}.`);
331
+ console.log(` (it can pull, never push — \`git pull\` mints a fresh read credential each time)`);
332
+ return 0;
333
+ }
334
+ console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
335
+ console.log(`Drop --remote-only to clone ${alias.repo} with the read-only remote configured.`);
336
+ if (args.printRemote) {
337
+ console.log(`\nREAD-ONLY remote (contains a live token — handle carefully):\n${res.gitRemote}`);
338
+ }
339
+ return 0;
340
+ } catch (e) {
341
+ return reportFailure(e, redact);
342
+ }
276
343
  }
277
344
 
278
345
  /**
@@ -428,8 +495,25 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
428
495
  * `Promise.all([...])` alongside the renderer prefetch, and a synchronous
429
496
  * clone would serialize what's meant to overlap. Throws CliError on failure.
430
497
  */
431
- async function cloneRepo(gitRemote, dir, redact) {
432
- const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
498
+ /**
499
+ * @param {string} gitRemote
500
+ * @param {string} dir
501
+ * @param {(s: string) => string} redact
502
+ * @param {{
503
+ * store?: boolean,
504
+ * git?: (cargs: string[]) => Promise<string>,
505
+ * gitSync?: (cargs: string[]) => string,
506
+ * installHelper?: (git: (cargs: string[]) => string, opts: { host?: string }) => boolean,
507
+ * record?: ((gitSync: (cargs: string[]) => string) => void) | null,
508
+ * }} [opts]
509
+ */
510
+ async function cloneRepo(gitRemote, dir, redact, {
511
+ store = true,
512
+ git = async (/** @type {string[]} */ cargs) => (await execFileP("git", cargs)).stdout.toString(),
513
+ gitSync = (/** @type {string[]} */ cargs) => execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" }),
514
+ installHelper = installForgeCredentialHelper,
515
+ record = null,
516
+ } = {}) {
433
517
  console.log(`+ git clone → ${dir}`);
434
518
  const cred = splitAuthedRemote(gitRemote);
435
519
  const cloneArgs = cred
@@ -458,9 +542,7 @@ async function cloneRepo(gitRemote, dir, redact) {
458
542
  // like a missing repo rather than a credential problem. The host-scoped form writes
459
543
  // ["", <helper>]: the empty reset clears the inherited helper for this host only.
460
544
  try {
461
- const gitSync = (/** @type {string[]} */ cargs) =>
462
- execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" });
463
- installForgeCredentialHelper(gitSync, { host: new URL(gitRemote).host });
545
+ installHelper(gitSync, { host: new URL(gitRemote).host });
464
546
  } catch (error) {
465
547
  // Best-effort — never fail a good clone over credential plumbing. The next
466
548
  // `tot preview`/`tot sync` installs it, and the fetch that needs it says why.
@@ -468,11 +550,136 @@ async function cloneRepo(gitRemote, dir, redact) {
468
550
  command: "clone", operation: "credential_helper_install", required: false, degraded: true,
469
551
  });
470
552
  }
553
+ if (record) {
554
+ // Not best-effort: without the record, the credential helper cannot tell what this
555
+ // checkout is, so every later fetch would fail with nothing to say why.
556
+ try {
557
+ record(gitSync);
558
+ } catch (e) {
559
+ throw new CliError(`couldn't record the checkout kind in ${dir}: ${redact(String(e?.message || e))}`, {
560
+ next: `remove ${dir} and re-run`,
561
+ cause: e,
562
+ });
563
+ }
564
+ }
471
565
  const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
472
- writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
566
+ if (store) writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
473
567
  return { dir, head };
474
568
  }
475
569
 
570
+ // ─── read-only aliases: `tot clone agency-kit` ─────────────────────────────────
571
+
572
+ /**
573
+ * Repos `tot clone` resolves by NAME rather than as a tenant store. Each is a read-only
574
+ * checkout minted through `repo_read_credential`; the server maps the target to its repo
575
+ * (ADR 0037), so the CLI never names an owner or an org. `repo` is what the minted
576
+ * credential must be for — anything else is refused rather than cloned.
577
+ */
578
+ export const READ_ONLY_ALIASES = Object.freeze({
579
+ "agency-kit": Object.freeze({ target: "agency-kit", repo: "agency-kit", defaultDir: "agency-kit" }),
580
+ });
581
+
582
+ /** @param {string|null|undefined} name */
583
+ export function resolveReadOnlyAlias(name) {
584
+ return name && Object.hasOwn(READ_ONLY_ALIASES, name) ? READ_ONLY_ALIASES[name] : null;
585
+ }
586
+
587
+ /** The `repo_read_credential` input for a read-only alias. The one place the wire shape
588
+ * lives. `alias` is the ONLY key: the server refuses it alongside `tenant`/`repo`, and a
589
+ * key the server does not declare is dropped by the MCP SDK, which then mints the caller's
590
+ * own store. */
591
+ export function kitReadCredentialInput(target) {
592
+ return { alias: target };
593
+ }
594
+
595
+ export const KIT_DENIED_MESSAGE = "your agency does not have kit access; ask Token of Trust";
596
+ export const KIT_INVALID_MESSAGE = "the kit could not be requested";
597
+
598
+ /** Did the server REFUSE the request (as opposed to a transport fault or an outage)?
599
+ * Returns `"forbidden"`, `"invalid_input"`, or null. Reads the tool's typed status, from a
600
+ * thrown tool error or a returned result; message text is never the signal. Pure. */
601
+ export function readRefusal(source) {
602
+ const e = /** @type {any} */ (source);
603
+ if (!e) return null;
604
+ const category = e?.diagnostic?.category ?? e?.category;
605
+ if (category && category !== "tool_error") return null;
606
+ const status = e?.diagnostic?.serverStatus ?? e?.serverStatus ?? e?.status;
607
+ return status === "forbidden" || status === "invalid_input" ? status : null;
608
+ }
609
+
610
+ /** The server's own words from a failed mint, never an invented reason. */
611
+ function serverReason(error) {
612
+ const e = /** @type {any} */ (error);
613
+ const m = e?.diagnostic?.message ?? e?.message ?? (typeof e?.error === "string" ? e.error : e?.error?.message);
614
+ return typeof m === "string" && m.trim() ? m.trim() : null;
615
+ }
616
+
617
+ /**
618
+ * Mint a read-only credential for a read-only alias. Calls ONLY `repo_read_credential` —
619
+ * no store tool — and refuses anything that is not a credential for the alias's own repo
620
+ * (a server that dropped the `alias` key would otherwise hand back the caller's own store).
621
+ * The server reports no expiry — it reaps the reader token after ~24h — so the helper's TTL
622
+ * and erase→re-mint are what bound a cached copy.
623
+ * @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
624
+ * @param {{ target: string, repo: string }} alias
625
+ * @param {(s: string) => string} [redact]
626
+ * @returns {Promise<{ gitRemote: string, publicUrl: string }>}
627
+ */
628
+ export async function mintReadOnlyAlias(client, alias, redact = (s) => s) {
629
+ /** @param {unknown} source the thrown error or the returned non-mint result */
630
+ const fail = (source, cause) => {
631
+ const reason = serverReason(source);
632
+ const said = reason ? `Token of Trust said: ${redact(reason)}` : undefined;
633
+ const refusal = readRefusal(source);
634
+ if (refusal === "forbidden") return new CliError(KIT_DENIED_MESSAGE, { next: said, cause });
635
+ if (refusal === "invalid_input") return new CliError(KIT_INVALID_MESSAGE, { next: said, cause });
636
+ return new CliError(`the ${alias.repo} credential couldn't be minted${reason ? `: ${redact(reason)}` : ""}`, {
637
+ next: "check your connection and `tot whoami`, then re-run",
638
+ cause,
639
+ });
640
+ };
641
+ let minted;
642
+ try {
643
+ minted = await client.callTool("repo_read_credential", kitReadCredentialInput(alias.target));
644
+ } catch (e) {
645
+ throw fail(e, e);
646
+ }
647
+ const m = /** @type {any} */ (minted && typeof minted === "object" ? minted : {});
648
+ const repos = Array.isArray(m.repos) ? m.repos : [];
649
+ if (!repos.some((r) => r && r.gitRemote)) throw fail(m, undefined);
650
+ const target = repos.find((r) => r && r.repo === alias.repo && r.gitRemote);
651
+ if (!target) {
652
+ throw new CliError(`Token of Trust returned no credential for ${alias.repo}`, {
653
+ next: "this server may not support kit checkout yet — ask Token of Trust",
654
+ });
655
+ }
656
+ const gitRemote = String(target.gitRemote);
657
+ const u = new URL(gitRemote);
658
+ return {
659
+ gitRemote,
660
+ publicUrl: `${u.protocol}//${u.host}${u.pathname}`,
661
+ };
662
+ }
663
+
664
+ /**
665
+ * `tot clone agency-kit [dir]`: mint the read-only credential, clone token-free through a
666
+ * one-shot `http.extraheader`, install the host-scoped credential helper, and record the
667
+ * checkout as a read-only kit clone so the helper re-mints it the same way.
668
+ * @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
669
+ * @param {{ alias: { target: string, repo: string }, cloneDir?: string|null, redact?: (s:string)=>string }} opts
670
+ * @param {Parameters<typeof cloneRepo>[3]} [deps]
671
+ */
672
+ export async function cloneReadOnlyAlias(client, { alias, cloneDir = null, redact = (s) => s }, deps = {}) {
673
+ const { gitRemote, publicUrl } = await mintReadOnlyAlias(client, alias, redact);
674
+ if (!cloneDir) return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
675
+ const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, {
676
+ ...deps,
677
+ store: false,
678
+ record: (gitSync) => recordReadOnlyKitCheckout(gitSync, alias.target),
679
+ });
680
+ return { gitRemote, publicUrl, cloned: true, dir, head };
681
+ }
682
+
476
683
  /**
477
684
  * The cross-tenant PR-clone core, composable in-process exactly like
478
685
  * `checkoutTenant`: mint a READ-ONLY credential via `repo_read_credential`, then
@@ -12,11 +12,15 @@
12
12
  * a checkout's remote URL at clone time.
13
13
  *
14
14
  * git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
15
- * request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
16
- * does real work — this CLI never persists a forge credential of its own
17
- * beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
18
- * are no-ops (git calls them after a successful/failed auth respectively; we
19
- * just drain stdin and exit 0, the correct behavior for a stateless helper).
15
+ * request on stdin (key=value lines, blank-line/EOF terminated). `get` mints
16
+ * (or reuses the short-lived cache in ../git-credential.mjs); `erase`, which git
17
+ * sends when the forge rejected the credential, drops that cache so the next
18
+ * `get` re-mints; `store` is a no-op.
19
+ *
20
+ * Two checkout kinds: a tenant store (its `.tot/config.json`) mints a push
21
+ * credential through `tenant_checkout`; a read-only kit clone (recorded in local
22
+ * git config by `tot clone agency-kit`) mints through `repo_read_credential`,
23
+ * cached under a key a store's can never take.
20
24
  *
21
25
  * FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
22
26
  * problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
@@ -34,12 +38,13 @@ import { readFileSync } from "node:fs";
34
38
  import { execFileSync } from "node:child_process";
35
39
  import { createMcpClient } from "../mcp.mjs";
36
40
  import { establishSession } from "../auth.mjs";
37
- import { checkoutTenant } from "./clone.mjs";
41
+ import { checkoutTenant, mintReadOnlyAlias, resolveReadOnlyAlias } from "./clone.mjs";
38
42
  import { detectContext } from "../context.mjs";
39
43
  import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
40
44
  import {
41
45
  parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
42
46
  credentialCachePath, readCachedCredential, writeCachedCredential,
47
+ kitCredentialCachePath, dropCachedCredential, readCheckoutKind, READ_ONLY_KIT_KIND,
43
48
  } from "../git-credential.mjs";
44
49
 
45
50
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -97,13 +102,38 @@ export function readStdin() {
97
102
  * createClient?: typeof createMcpClient,
98
103
  * establish?: typeof establishSession,
99
104
  * checkout?: typeof checkoutTenant,
105
+ * mintReadOnly?: typeof mintReadOnlyAlias,
100
106
  * }} [opts]
101
107
  * @returns {Promise<{username:string,password:string}|null>}
102
108
  */
103
109
  export async function mintOrCacheCredential({
104
110
  env = process.env, cwd = process.cwd(), expectedHost = null,
105
111
  createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
112
+ mintReadOnly = mintReadOnlyAlias,
106
113
  } = {}) {
114
+ const kit = kitCheckout(cwd);
115
+ if (kit) {
116
+ if (expectedHost && !hostMatches(kit.originUrl, expectedHost)) return null;
117
+ const alias = resolveReadOnlyAlias(kit.target);
118
+ if (!alias) return null; // a recorded target this CLI does not know — never guess a mint
119
+ const cachePath = kitCredentialCachePath(alias.target, env);
120
+ const cached = readCachedCredential(cachePath);
121
+ if (cached) return { username: cached.username, password: cached.password };
122
+ const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
123
+ const client = createClient(baseUrl);
124
+ try {
125
+ await establish(client, { env });
126
+ const res = await mintReadOnly(client, alias);
127
+ const cred = splitAuthedRemote(res.gitRemote || "");
128
+ if (!cred) return null;
129
+ const fresh = { username: cred.username, password: cred.token };
130
+ writeCachedCredential(cachePath, fresh);
131
+ return fresh;
132
+ } catch {
133
+ return null; // not signed in / MCP unreachable / no kit access — say nothing
134
+ }
135
+ }
136
+
107
137
  const ctx = detectContext(cwd);
108
138
  if (ctx.mode !== "checkout" || !ctx.tenant) return null;
109
139
 
@@ -143,6 +173,52 @@ export async function mintOrCacheCredential({
143
173
  }
144
174
  }
145
175
 
176
+ /**
177
+ * The read-only kit checkout `cwd` is in, from the kind `tot clone` recorded, or null when
178
+ * it is not one (a store, a loose dir, not a repo). Never throws.
179
+ * @param {string} cwd
180
+ * @returns {{ target: string, originUrl: string|null }|null}
181
+ */
182
+ export function kitCheckout(cwd) {
183
+ const git = (cargs) =>
184
+ execFileSync("git", ["-C", cwd, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
185
+ const kind = readCheckoutKind(git);
186
+ if (kind.kind !== READ_ONLY_KIT_KIND) return null;
187
+ let originUrl = null;
188
+ try { originUrl = git(["remote", "get-url", "origin"]).trim(); } catch { /* host check fails closed */ }
189
+ return { target: kind.target, originUrl };
190
+ }
191
+
192
+ /**
193
+ * git reported the credential rejected (`erase`): drop this checkout's cached credential so
194
+ * the next `get` mints a fresh one instead of replaying the rejected token. Scoped to the
195
+ * checkout's own origin host, like `get`. Never throws.
196
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string|null }} [opts]
197
+ */
198
+ export function forgetCredential({ env = process.env, cwd = process.cwd(), expectedHost = null } = {}) {
199
+ try {
200
+ const kit = kitCheckout(cwd);
201
+ if (kit) {
202
+ if (expectedHost && !hostMatches(kit.originUrl, expectedHost)) return;
203
+ dropCachedCredential(kitCredentialCachePath(kit.target, env));
204
+ return;
205
+ }
206
+ const ctx = detectContext(cwd);
207
+ if (ctx.mode !== "checkout" || !ctx.tenant || !ctx.workspacePath) return;
208
+ let originUrl = null;
209
+ try {
210
+ originUrl = execFileSync("git", ["-C", ctx.workspacePath, "remote", "get-url", "origin"], {
211
+ stdio: ["ignore", "pipe", "pipe"],
212
+ }).toString().trim();
213
+ } catch { /* no origin */ }
214
+ if (expectedHost && !hostMatches(originUrl, expectedHost)) return;
215
+ const tag = tagFromRepoName(originUrl ? repoNameFromRemote(originUrl) : null, ctx.tenant);
216
+ dropCachedCredential(credentialCachePath(ctx.tenant, tag, env));
217
+ } catch {
218
+ /* erase is advisory — never fail git over it */
219
+ }
220
+ }
221
+
146
222
  const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
147
223
 
148
224
  Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
@@ -169,7 +245,12 @@ export async function run(argv, _ctx, opts = {}) {
169
245
  // so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
170
246
  const request = parseCredentialInput(read());
171
247
 
172
- if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
248
+ if (op === "erase") {
249
+ // git rejected the credential it was given: forget it, so the next `get` re-mints.
250
+ forgetCredential({ env, cwd: opts.cwd, expectedHost: request.host });
251
+ return 0;
252
+ }
253
+ if (op !== "get") return 0; // store: stateless, nothing to persist.
173
254
 
174
255
  const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
175
256
  if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
@@ -27,7 +27,7 @@ import { validateTenant, ERROR } from "../validate.mjs";
27
27
  import { fail } from "../errors.mjs";
28
28
  import { tenantDirSegments } from "../tenant-dirs.mjs";
29
29
 
30
- export const TEASER = "Site-wide validation with triage across every page, and the full migration method, are in the Agency Kit.";
30
+ export const TEASER = "Site-wide validation with triage across every page, and the full migration method, are in the Agency Kit. To get it, ask Token of Trust for agency access, then run `tot clone agency-kit`.";
31
31
 
32
32
  export const USAGE = `tot report go-live — the go-live readiness report for your store
33
33
 
@@ -20,7 +20,7 @@
20
20
  import { homedir } from "node:os";
21
21
  import { createHash } from "node:crypto";
22
22
  import { join, dirname } from "node:path";
23
- import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
23
+ import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
24
24
  import { readCredentials, writeCredentials } from "./token-store.mjs";
25
25
 
26
26
  /** The git config value that routes credential requests through `tot` — the
@@ -122,12 +122,69 @@ export function credentialCachePath(tenant, tag, env = process.env) {
122
122
  return join(home, ".tot", "git-credentials", `${credentialCacheKey(tenant, tag)}.json`);
123
123
  }
124
124
 
125
+ /** Cache key for a READ-ONLY kit checkout's credential. The `kit-` prefix is the
126
+ * separation: a tenant store's key is always 32 bare hex characters, so no tenant or tag
127
+ * (however named) can produce a file name this function produces, or the reverse. Pure. */
128
+ export function kitCredentialCacheKey(target) {
129
+ return `kit-${createHash("sha256").update(`kit|${target}`).digest("hex").slice(0, 32)}`;
130
+ }
131
+
132
+ /** Absolute path to a read-only kit checkout's cached credential. */
133
+ export function kitCredentialCachePath(target, env = process.env) {
134
+ const home = env.TOT_HOME || homedir();
135
+ return join(home, ".tot", "git-credentials", `${kitCredentialCacheKey(target)}.json`);
136
+ }
137
+
125
138
  /** Is a cached credential still trusted? A cache with no `mintedAt` is
126
139
  * treated as stale (mint fresh rather than trust an unknown age). Pure. */
127
140
  export function isFreshCredential(cred, { now = Date.now(), ttlMs = CREDENTIAL_TTL_MS } = {}) {
128
141
  return Boolean(cred && cred.username && cred.password && cred.mintedAt && now - cred.mintedAt < ttlMs);
129
142
  }
130
143
 
144
+ /** Drop a cached credential (git reported it rejected). Never throws. */
145
+ export function dropCachedCredential(filePath) {
146
+ try {
147
+ rmSync(filePath, { force: true });
148
+ } catch {
149
+ /* nothing cached, or unremovable — the next `get` re-mints either way */
150
+ }
151
+ }
152
+
153
+ /** The checkout kinds `tot` records in a checkout's local git config. A tenant store
154
+ * carries none (it is recognized by its `.tot/config.json`); a read-only kit clone is
155
+ * recorded explicitly, because nothing else in it says what to mint. */
156
+ export const CHECKOUT_KIND_KEY = "tot.checkoutKind";
157
+ export const READ_TARGET_KEY = "tot.readTarget";
158
+ export const READ_ONLY_KIT_KIND = "read-only-kit";
159
+
160
+ /**
161
+ * Record in a freshly cloned checkout that it is a read-only kit clone of `target`, so
162
+ * `tot git-credential` re-mints it through `repo_read_credential` instead of treating it
163
+ * as a store. Throws on a git failure (the caller decides how loud to be).
164
+ * @param {(cargs: string[]) => unknown} git a `git -C <checkout>` runner
165
+ * @param {string} target
166
+ */
167
+ export function recordReadOnlyKitCheckout(git, target) {
168
+ git(["config", "--local", CHECKOUT_KIND_KEY, READ_ONLY_KIT_KIND]);
169
+ git(["config", "--local", READ_TARGET_KEY, target]);
170
+ }
171
+
172
+ /**
173
+ * Read which kind of checkout `git` runs in: `{ kind: "read-only-kit", target }` for a
174
+ * recorded kit clone, else `{ kind: "store" }` (the default; the store path then applies
175
+ * its own checks). An unrecorded or unreadable config is a store, never a kit.
176
+ * @param {(cargs: string[]) => string} git
177
+ * @returns {{ kind: "read-only-kit", target: string } | { kind: "store" }}
178
+ */
179
+ export function readCheckoutKind(git) {
180
+ const get = (key) => {
181
+ try { return String(git(["config", "--local", "--get", key])).trim(); } catch { return ""; }
182
+ };
183
+ if (get(CHECKOUT_KIND_KEY) !== READ_ONLY_KIT_KIND) return { kind: "store" };
184
+ const target = get(READ_TARGET_KEY);
185
+ return target ? { kind: READ_ONLY_KIT_KIND, target } : { kind: "store" };
186
+ }
187
+
131
188
  /** Read a cached credential (reusing token-store.mjs's generic reader), or
132
189
  * null if absent/unreadable/malformed/stale. Never throws. */
133
190
  export function readCachedCredential(filePath, opts = {}) {
package/src/mcp.mjs CHANGED
@@ -315,6 +315,9 @@ export function createMcpClient(baseUrl, opts = {}) {
315
315
  throw diagnosticError(`${name} failed: ${message}`, {
316
316
  operation: name, category: "tool_error",
317
317
  serverCode: payload?.code || payload?.error?.code,
318
+ // The tool's own outcome (`forbidden`, `unauthenticated`, …) — the typed signal a
319
+ // caller classifies a refusal by, instead of matching message text.
320
+ serverStatus: typeof payload?.status === "string" ? payload.status : undefined,
318
321
  message,
319
322
  traceId: payload?.traceId || responseContext.traceId,
320
323
  requestId: payload?.requestId || responseContext.requestId,