@blogic-cz/agent-tools 1.11.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +17 -43
  2. package/dist/credential-guard/bash.d.ts +3 -0
  3. package/dist/credential-guard/bash.d.ts.map +1 -0
  4. package/dist/credential-guard/claude-hook.d.ts.map +1 -1
  5. package/dist/credential-guard/engine.test.d.ts +5 -0
  6. package/dist/credential-guard/engine.test.d.ts.map +1 -0
  7. package/dist/credential-guard/index.d.ts +8 -35
  8. package/dist/credential-guard/index.d.ts.map +1 -1
  9. package/dist/credential-guard/ir.d.ts +150 -0
  10. package/dist/credential-guard/ir.d.ts.map +1 -0
  11. package/dist/credential-guard/literal-output.d.ts +2 -0
  12. package/dist/credential-guard/literal-output.d.ts.map +1 -0
  13. package/dist/credential-guard/passive-awk.d.ts +2 -0
  14. package/dist/credential-guard/passive-awk.d.ts.map +1 -0
  15. package/dist/credential-guard/passive-jq.d.ts.map +1 -1
  16. package/dist/credential-guard/passive-sed.d.ts +2 -0
  17. package/dist/credential-guard/passive-sed.d.ts.map +1 -0
  18. package/dist/credential-guard/passive.test.d.ts +2 -0
  19. package/dist/credential-guard/passive.test.d.ts.map +1 -0
  20. package/dist/credential-guard/paths.d.ts +14 -0
  21. package/dist/credential-guard/paths.d.ts.map +1 -0
  22. package/dist/credential-guard/policy.d.ts +47 -0
  23. package/dist/credential-guard/policy.d.ts.map +1 -0
  24. package/dist/credential-guard/powershell.d.ts +3 -0
  25. package/dist/credential-guard/powershell.d.ts.map +1 -0
  26. package/dist/credential-guard/registry.d.ts +89 -0
  27. package/dist/credential-guard/registry.d.ts.map +1 -0
  28. package/dist/credential-guard/review4.test.d.ts +2 -0
  29. package/dist/credential-guard/review4.test.d.ts.map +1 -0
  30. package/dist/credential-guard/review7.test.d.ts +2 -0
  31. package/dist/credential-guard/review7.test.d.ts.map +1 -0
  32. package/dist/credential-guard/shell-oracle.d.ts +2 -0
  33. package/dist/credential-guard/shell-oracle.d.ts.map +1 -0
  34. package/dist/credential-guard/xargs.d.ts +7 -0
  35. package/dist/credential-guard/xargs.d.ts.map +1 -0
  36. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  37. package/dist/gh-tool/read/api.d.ts +8 -0
  38. package/dist/gh-tool/read/api.d.ts.map +1 -1
  39. package/dist/gh-tool/read/cache.d.ts.map +1 -1
  40. package/dist/gh-tool/read/commands.d.ts +5 -0
  41. package/dist/gh-tool/read/commands.d.ts.map +1 -1
  42. package/dist/gh-tool/read/reads.d.ts +38 -0
  43. package/dist/gh-tool/read/reads.d.ts.map +1 -1
  44. package/dist/gh-tool/read/telemetry-command.d.ts +7 -0
  45. package/dist/gh-tool/read/telemetry-command.d.ts.map +1 -0
  46. package/dist/gh-tool/read/telemetry-store.d.ts +3 -0
  47. package/dist/gh-tool/read/telemetry-store.d.ts.map +1 -0
  48. package/dist/gh-tool/read/telemetry.d.ts +59 -0
  49. package/dist/gh-tool/read/telemetry.d.ts.map +1 -0
  50. package/dist/gh-tool/read/transport.d.ts.map +1 -1
  51. package/package.json +2 -1
  52. package/schemas/agent-tools.schema.json +1 -1
  53. package/src/credential-guard/bash.ts +769 -0
  54. package/src/credential-guard/claude-hook.ts +14 -14
  55. package/src/credential-guard/engine.test.ts +2050 -0
  56. package/src/credential-guard/index.ts +285 -4268
  57. package/src/credential-guard/ir.ts +139 -0
  58. package/src/credential-guard/literal-output.ts +8 -0
  59. package/src/credential-guard/passive-awk.ts +239 -0
  60. package/src/credential-guard/passive-jq.ts +183 -24
  61. package/src/credential-guard/passive-sed.ts +78 -0
  62. package/src/credential-guard/passive.test.ts +141 -0
  63. package/src/credential-guard/paths.ts +108 -0
  64. package/src/credential-guard/policy.ts +1576 -0
  65. package/src/credential-guard/powershell.ts +220 -0
  66. package/src/credential-guard/registry.ts +4720 -0
  67. package/src/credential-guard/review4.test.ts +83 -0
  68. package/src/credential-guard/review7.test.ts +51 -0
  69. package/src/credential-guard/rg-types.json +389 -0
  70. package/src/credential-guard/shell-oracle.ts +187 -0
  71. package/src/credential-guard/xargs.ts +65 -0
  72. package/src/gh-tool/main.ts +30 -6
  73. package/src/gh-tool/pr/commands.ts +9 -2
  74. package/src/gh-tool/pr/core.ts +2 -2
  75. package/src/gh-tool/read/api.ts +73 -6
  76. package/src/gh-tool/read/cache.ts +13 -4
  77. package/src/gh-tool/read/commands.ts +17 -1
  78. package/src/gh-tool/read/reads.ts +84 -1
  79. package/src/gh-tool/read/telemetry-command.ts +45 -0
  80. package/src/gh-tool/read/telemetry-store.ts +85 -0
  81. package/src/gh-tool/read/telemetry.ts +191 -0
  82. package/src/gh-tool/read/transport.ts +10 -5
  83. package/src/gh-tool/workflow.ts +2 -2
@@ -0,0 +1,187 @@
1
+ import { formatLiteralPrintf } from "./literal-output";
2
+ import { parseBash } from "./bash";
3
+ import { judge } from "./policy";
4
+ import { splitXargsInput, type XargsInput } from "./xargs";
5
+
6
+ const quote = (text: string): string => "'" + text.replaceAll("'", "'\\''") + "'";
7
+ const receipt: Record<string, { checked: number; refused: number; mismatches: unknown[] }> = {};
8
+ async function compare(
9
+ family: string,
10
+ shell: string,
11
+ source: string,
12
+ modeled: readonly string[] | null,
13
+ ) {
14
+ const count = (receipt[family] ??= { checked: 0, refused: 0, mismatches: [] });
15
+ const child = Bun.spawn([shell, "-c", source], {
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ env: { PATH: "/usr/bin:/bin", HOME: "/tmp" },
19
+ });
20
+ const actual = await new Response(child.stdout).text();
21
+ await new Response(child.stderr).text();
22
+ await child.exited;
23
+ count.checked++;
24
+ if (modeled === null) count.refused++;
25
+ else if (family === "termination") {
26
+ const reached = actual.split("\n").includes("[reached]");
27
+ if (reached && !modeled.some((output) => output.includes("[reached]")))
28
+ count.mismatches.push({ shell, source, actual, modeled });
29
+ } else if (!modeled.includes(actual)) count.mismatches.push({ shell, source, actual, modeled });
30
+ }
31
+ function modeledOutput(source: string): readonly string[] | null {
32
+ const outputs: string[] = [];
33
+ let unresolved = false;
34
+ const decision = judge(parseBash(source), {
35
+ home: "/home/dev",
36
+ cwd: "/tmp",
37
+ onInvocation(executable, argv) {
38
+ if (executable === "/usr/bin/printf" && argv[1] === "[%s]\\n") {
39
+ if (argv.some((word) => word === undefined)) unresolved = true;
40
+ outputs.push(
41
+ (argv.length > 2 ? argv.slice(2) : [""]).map((word) => `[${word}]\n`).join(""),
42
+ );
43
+ }
44
+ },
45
+ });
46
+ return decision.kind === "pass" && !unresolved
47
+ ? outputs.length
48
+ ? [...outputs, outputs.join("")]
49
+ : [""]
50
+ : null;
51
+ }
52
+ const jobs: Array<() => Promise<void>> = [];
53
+ const shells = ["/bin/bash", "/bin/sh", "/bin/zsh", "/bin/dash"];
54
+ const available = await Promise.all(
55
+ shells.map(async (shell) => ({ shell, exists: await Bun.file(shell).exists() })),
56
+ );
57
+ for (const { shell, exists } of available) {
58
+ if (!exists) {
59
+ console.log(`SKIP missing shell ${shell}`);
60
+ continue;
61
+ }
62
+ const braces = [
63
+ "{a,}",
64
+ "{,}",
65
+ "{,b}",
66
+ "x{,b}",
67
+ "{a,b}{,c}",
68
+ "'{a,b}'",
69
+ '"{a,b}"',
70
+ "{a,{b,c}}",
71
+ "{a,}''",
72
+ "''{,b}",
73
+ ];
74
+ for (const left of ["a", "", "b"])
75
+ for (const right of ["", "c", "d"]) braces.push(`{${left},${right}}`, `p{${left},${right}}q`);
76
+ for (const words of braces) {
77
+ const source = `/usr/bin/printf '[%s]\\n' seed ${words}`;
78
+ const model =
79
+ shell.endsWith("/sh") || shell.endsWith("/dash")
80
+ ? `sh -c ${quote(source)}`
81
+ : shell.endsWith("/bash")
82
+ ? `bash -c ${quote(source)}`
83
+ : source;
84
+ jobs.push(() => compare("brace", shell, source, modeledOutput(model)));
85
+ }
86
+ for (const format of ["%s\\n", "%s ", "%s", "%s:%s\\n"])
87
+ for (const words of [[], ["alpha"], ["alpha", "beta"], ["", "beta"], ["a b", "c"]]) {
88
+ const source = `printf ${quote(format)} ${words.map(quote).join(" ")}`;
89
+ const modeled = formatLiteralPrintf([format, ...words]);
90
+ jobs.push(() => compare("printf", shell, source, modeled === null ? null : [modeled]));
91
+ }
92
+ for (const compound of [
93
+ "{ exit 0; }",
94
+ "(exit 0)",
95
+ "if true; then exit 0; fi",
96
+ "case x in x) exit 0;; esac",
97
+ "for x in a; do exit 0; done",
98
+ "while true; do exit 0; done",
99
+ "exit 0 && true",
100
+ "exit 0 || true",
101
+ "exit 0 && false || true",
102
+ "exit 0 || false && true",
103
+ "{ exit 0; } && true",
104
+ "! { exit 0; } || true",
105
+ ])
106
+ for (const redirect of ["&>/dev/null", "&>>/dev/null"]) {
107
+ const source = `${compound} ${redirect}; /usr/bin/printf '[%s]\\n' reached`;
108
+ jobs.push(() =>
109
+ compare(
110
+ "termination",
111
+ shell,
112
+ source,
113
+ modeledOutput(
114
+ `${shell.endsWith("/bash") || shell.endsWith("/zsh") ? "bash" : "sh"} -c ${quote(source)}`,
115
+ ),
116
+ ),
117
+ );
118
+ }
119
+ for (const command of ["exit", "return"])
120
+ for (const redirect of [
121
+ "",
122
+ " 2>/dev/null",
123
+ " &>/dev/null",
124
+ " &>>/dev/null",
125
+ " </tmp/guard-oracle-missing/input",
126
+ " >/dev/null/x",
127
+ " 2>&1",
128
+ " 2>&99",
129
+ " 1>&- 2>&1",
130
+ ])
131
+ for (const op of [";", "||"]) {
132
+ const source = `${command} 0${redirect} ${op} /usr/bin/printf '[%s]\\n' reached`;
133
+ jobs.push(() =>
134
+ compare(
135
+ "termination",
136
+ shell,
137
+ source,
138
+ modeledOutput(
139
+ `${shell.endsWith("/bash") || shell.endsWith("/zsh") ? "bash" : "sh"} -c ${quote(source)}`,
140
+ ),
141
+ ),
142
+ );
143
+ }
144
+ }
145
+ const modes: XargsInput[] = [
146
+ { replacement: null, maxArgs: null, maxLines: null },
147
+ { replacement: "{}", maxArgs: null, maxLines: null },
148
+ { replacement: null, maxArgs: 1, maxLines: null },
149
+ { replacement: null, maxArgs: null, maxLines: 1 },
150
+ ];
151
+ const inputs = [
152
+ "a\nb\n",
153
+ "a b\n",
154
+ "'' word\n",
155
+ '"" word\n',
156
+ "a b\n",
157
+ "a\tb\n",
158
+ " a\n",
159
+ "a\n\nb\n",
160
+ "'a b'\n",
161
+ "a\\ b\n",
162
+ ];
163
+ for (const a of ["a", "''", '""', " "])
164
+ for (const b of [" ", " ", "\t", "\n"]) inputs.push(a + b + "b\n");
165
+ for (const mode of modes)
166
+ for (const input of inputs) {
167
+ const groups = splitXargsInput(input, mode);
168
+ const output =
169
+ groups === null
170
+ ? null
171
+ : groups
172
+ .flatMap((words) => (words.length ? words : [""]).map((word) => `[${word}]\n`))
173
+ .join("");
174
+ const flags = mode.replacement ? "-I{}" : mode.maxArgs ? "-n1" : mode.maxLines ? "-L1" : "";
175
+ const source = `printf '%s' ${quote(input)} | /usr/bin/xargs ${flags} /usr/bin/printf '[%s]\\n' ${mode.replacement ? "'{}'" : ""}`;
176
+ jobs.push(() =>
177
+ compare(
178
+ `xargs-${flags || "default"}`,
179
+ "/bin/bash",
180
+ source,
181
+ output === null ? null : [output],
182
+ ),
183
+ );
184
+ }
185
+ await jobs.reduce((previous, run) => previous.then(run), Promise.resolve());
186
+ console.log(JSON.stringify(receipt, null, 2));
187
+ if (Object.values(receipt).some((family) => family.mismatches.length)) process.exitCode = 1;
@@ -0,0 +1,65 @@
1
+ export type XargsInput = {
2
+ readonly replacement: string | null;
3
+ readonly maxArgs: number | null;
4
+ readonly maxLines: number | null;
5
+ };
6
+
7
+ export function splitXargsInput(text: string, mode: XargsInput): readonly string[][] | null {
8
+ if (/[^\t\n\x20-\x7e]/.test(text)) return null;
9
+ if (mode.replacement !== null && /['"\t]| {2}|^ |\n |\\/.test(text)) return null;
10
+ const lines: string[][] = [];
11
+ let words: string[] = [];
12
+ let word = "";
13
+ let started = false;
14
+ let quote = "";
15
+ const finishWord = () => {
16
+ if (started) words.push(word);
17
+ word = "";
18
+ started = false;
19
+ };
20
+ for (let index = 0; index < text.length; index++) {
21
+ const ch = text[index] ?? "";
22
+ if (ch === "\n") {
23
+ if (
24
+ quote ||
25
+ ((mode.maxLines !== null || mode.replacement !== null) &&
26
+ /[ \t]/.test(text[index - 1] ?? ""))
27
+ )
28
+ return null;
29
+ finishWord();
30
+ if (words.length) lines.push(words);
31
+ words = [];
32
+ } else if (ch === "\\" && !quote) {
33
+ const next = text[++index];
34
+ if (next === undefined || next === "\n") return null;
35
+ word += next;
36
+ started = true;
37
+ } else if ((ch === "'" || ch === '"') && (!quote || ch === quote)) {
38
+ quote = quote ? "" : ch;
39
+ started = true;
40
+ } else if (!quote && /[ \t]/.test(ch) && mode.replacement === null) {
41
+ finishWord();
42
+ } else if (mode.replacement !== null && !started && /[ \t]/.test(ch)) {
43
+ continue;
44
+ } else {
45
+ word += ch;
46
+ started = true;
47
+ }
48
+ }
49
+ if (quote) return null;
50
+ finishWord();
51
+ if (words.length) lines.push(words);
52
+ const result: string[][] = [];
53
+ if (mode.replacement !== null) result.push(...lines);
54
+ else if (mode.maxLines !== null) {
55
+ for (let index = 0; index < lines.length; index += mode.maxLines)
56
+ result.push(lines.slice(index, index + mode.maxLines).flat());
57
+ } else {
58
+ const all = lines.flat();
59
+ const size = mode.maxArgs ?? Math.max(all.length, 1);
60
+ for (let index = 0; index < all.length; index += size)
61
+ result.push(all.slice(index, index + size));
62
+ }
63
+ if (result.length > 64 || result.some((args) => args.length > 64)) return null;
64
+ return result.length ? result : null;
65
+ }
@@ -4,7 +4,8 @@ import { BunRuntime, BunServices } from "@effect/platform-bun";
4
4
  import { Effect, Layer } from "effect";
5
5
 
6
6
  import { makeSchemaCommand, renderCauseToStderr, VERSION } from "#shared";
7
- import { AuditServiceLayer, withAudit } from "#shared/audit";
7
+ import { AuditService, AuditServiceLayer, withAudit } from "#shared/audit";
8
+ import { openRequestTelemetry } from "#gh/read/telemetry-store";
8
9
  import { withRedactedOutput } from "#shared/output-boundary";
9
10
  import { ConfigServiceLayer } from "#config";
10
11
  import {
@@ -80,7 +81,9 @@ import {
80
81
  import { labelCreateCommand } from "./label";
81
82
  import { GitHubService } from "./read/transport";
82
83
  import { withReadCache } from "./read";
83
- import { cacheCommand } from "./read/commands";
84
+ import { Telemetry } from "./read/telemetry";
85
+ import { telemetryCommand } from "./read/telemetry-command";
86
+ import { cacheCommand, rateLimitCommand } from "./read/commands";
84
87
  import {
85
88
  workflowAnnotationsCommand,
86
89
  workflowCancelCommand,
@@ -221,6 +224,7 @@ const mainCommand = Command.make("gh-tool", {}).pipe(
221
224
 
222
225
  Wraps the GitHub CLI (gh) with structured output for AI agents.
223
226
  Supports PR management, issue management, reviews, CI checks, and repo info.
227
+ Use 'rate-limit' to inspect GitHub API quota without consuming it.
224
228
 
225
229
  WORKFLOW FOR AI AGENTS:
226
230
  1. Use 'pr view' to inspect current PR
@@ -250,6 +254,8 @@ WORKFLOW FOR AI AGENTS:
250
254
  24. Use 'gist list/view' to read gists and 'gist create --files a.ts --desc "..."' to share snippets`,
251
255
  ),
252
256
  Command.withSubcommands([
257
+ rateLimitCommand,
258
+ telemetryCommand,
253
259
  prCommand,
254
260
  issueCommand,
255
261
  repoCommand,
@@ -274,10 +280,28 @@ const MainLayer = GitHubService.layer.pipe(
274
280
  Layer.provideMerge(ConfigServiceLayer),
275
281
  );
276
282
 
277
- const program = withReadCache(withRedactedOutput(withAudit("gh", cli))).pipe(
278
- Effect.provide(MainLayer),
279
- Effect.tapCause(renderCauseToStderr),
280
- );
283
+ const observed = Effect.gen(function* () {
284
+ const audit = yield* AuditService;
285
+ let store = null;
286
+ try {
287
+ store = openRequestTelemetry(audit.dbPath);
288
+ } catch {
289
+ /* Telemetry is optional. */
290
+ }
291
+ return yield* withReadCache(withRedactedOutput(withAudit("gh", cli))).pipe(
292
+ Effect.provideService(Telemetry, store),
293
+ Effect.ensuring(
294
+ Effect.sync(() => {
295
+ try {
296
+ store?.close();
297
+ } catch {
298
+ /* Best effort. */
299
+ }
300
+ }),
301
+ ),
302
+ );
303
+ });
304
+ const program = observed.pipe(Effect.provide(MainLayer), Effect.tapCause(renderCauseToStderr));
281
305
 
282
306
  BunRuntime.runMain(program, {
283
307
  disableErrorReporting: true,
@@ -524,8 +524,15 @@ export const prStatusCommand = Command.make(
524
524
  withRepo(
525
525
  repo,
526
526
  Effect.gen(function* () {
527
- const result: PRStatusResult = yield* detectPRStatus();
528
- yield* logFormatted(result, format);
527
+ const [result, rateLimit] = yield* Effect.all([
528
+ detectPRStatus(),
529
+ reads.getPRStatusRateLimit(),
530
+ ]);
531
+ const status: PRStatusResult & { readonly rateLimit: typeof rateLimit } = {
532
+ ...result,
533
+ rateLimit,
534
+ };
535
+ yield* logFormatted(status, format);
529
536
  }),
530
537
  ),
531
538
  ).pipe(
@@ -248,12 +248,12 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
248
248
 
249
249
  // `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
250
250
  // is an ordinary state, so it is not an error, but it is also not an empty list: a PR with green
251
- // checks (sabservis/nexus-be#1396) once came back empty.
251
+ // checks (example/example-be#1396) once came back empty.
252
252
  export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
253
253
  // `gh` reports "no checks reported" both for the seconds after a push, before its checks
254
254
  // register, and forever for a PR that has none — the message cannot tell them apart. So the
255
255
  // wait for registration gets its own short budget: long enough to cover the race (measured
256
- // at roughly 40s on blogic-cz/agent-tools#134), short enough that a check-less PR is not held
256
+ // at roughly 40 seconds), short enough that a check-less PR is not held
257
257
  // for the caller's whole --timeout.
258
258
  const CHECK_REGISTRATION_POLL_SECONDS = 5;
259
259
  const CHECK_REGISTRATION_GRACE_SECONDS = 60;
@@ -1,3 +1,5 @@
1
+ import { CacheRequest, recordRequest } from "./telemetry";
2
+
1
3
  import { Clock, Context, Effect } from "effect";
2
4
 
3
5
  import { registerResolvedSecret } from "#shared/content-security";
@@ -24,6 +26,36 @@ export type GitHubApiResponse<T> = {
24
26
  rateLimitUsed?: string;
25
27
  };
26
28
 
29
+ export type GitHubRateLimitHeaders = {
30
+ readonly resource?: string;
31
+ readonly limit?: number;
32
+ readonly used?: number;
33
+ readonly remaining?: number;
34
+ readonly reset?: number;
35
+ };
36
+
37
+ const headerInteger = (headers: Headers, name: string) => {
38
+ const value = headers.get(name);
39
+ if (value === null || !/^\d+$/.test(value)) return undefined;
40
+ const parsed = Number(value);
41
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
42
+ };
43
+
44
+ export const parseRateLimitHeaders = (headers: Headers): GitHubRateLimitHeaders => {
45
+ const resource = headers.get("x-ratelimit-resource");
46
+ const limit = headerInteger(headers, "x-ratelimit-limit");
47
+ const used = headerInteger(headers, "x-ratelimit-used");
48
+ const remaining = headerInteger(headers, "x-ratelimit-remaining");
49
+ const reset = headerInteger(headers, "x-ratelimit-reset");
50
+ return {
51
+ ...(resource === null ? {} : { resource }),
52
+ ...(limit === undefined ? {} : { limit }),
53
+ ...(used === undefined ? {} : { used }),
54
+ ...(remaining === undefined ? {} : { remaining }),
55
+ ...(reset === undefined ? {} : { reset }),
56
+ };
57
+ };
58
+
27
59
  const authFailure = (message: string) =>
28
60
  new GitHubAuthError({
29
61
  message,
@@ -106,6 +138,8 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
106
138
  const token = yield* resolveGitHubToken();
107
139
  const url = `${GITHUB_API_ROOT}/${opts.path.replace(/^\//, "")}`;
108
140
 
141
+ const cached = yield* CacheRequest;
142
+ const started = new Date().toISOString();
109
143
  const response = yield* Effect.tryPromise({
110
144
  try: () =>
111
145
  fetch(url, {
@@ -128,8 +162,41 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
128
162
  retryable: true,
129
163
  hint: "Check network connectivity and VPN state, then retry.",
130
164
  }),
131
- });
165
+ }).pipe(
166
+ Effect.tapError(() =>
167
+ recordRequest({
168
+ ts: started,
169
+ method,
170
+ endpoint: opts.path,
171
+ status: null,
172
+ outcome: "error",
173
+ resource: null,
174
+ used: null,
175
+ remaining: null,
176
+ reset: null,
177
+ }),
178
+ ),
179
+ );
132
180
 
181
+ const quota = parseRateLimitHeaders(response.headers);
182
+ yield* recordRequest({
183
+ ts: started,
184
+ method,
185
+ endpoint: opts.path,
186
+ status: response.status,
187
+ outcome:
188
+ response.status === 304
189
+ ? "304"
190
+ : response.status >= 400
191
+ ? "error"
192
+ : cached
193
+ ? "200"
194
+ : "bypass",
195
+ resource: quota.resource ?? null,
196
+ used: quota.used ?? null,
197
+ remaining: quota.remaining ?? null,
198
+ reset: quota.reset ?? null,
199
+ });
133
200
  const text = yield* Effect.tryPromise({
134
201
  try: () => response.text(),
135
202
  catch: (cause) =>
@@ -176,7 +243,8 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
176
243
  }
177
244
 
178
245
  const now = yield* Clock.currentTimeMillis;
179
- const resetSeconds = Number(response.headers.get("x-ratelimit-reset"));
246
+ const rateLimitHeaders = parseRateLimitHeaders(response.headers);
247
+ const resetSeconds = rateLimitHeaders.reset ?? Number.NaN;
180
248
  const resetDelay =
181
249
  Number.isFinite(resetSeconds) && resetSeconds > 0
182
250
  ? Math.max(0, resetSeconds * 1000 - now + 1000)
@@ -200,18 +268,17 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
200
268
  /rate limit|secondary rate limit|abuse detection/i.test(
201
269
  apiErrorMessage(parsed, response.status),
202
270
  )));
203
-
204
271
  const error = new GitHubCommandError({
205
272
  message: rateLimit
206
- ? `${apiErrorMessage(parsed, response.status)}; reset time: ${resetDelay === undefined ? "unavailable" : new Date(resetSeconds * 1000).toISOString()}; remaining quota: ${response.headers.get("x-ratelimit-remaining") ?? "unavailable"}`
273
+ ? `${apiErrorMessage(parsed, response.status)}; resource: ${rateLimitHeaders.resource ?? "unavailable"}; reset time: ${rateLimitHeaders.reset === undefined ? "unavailable" : new Date(resetSeconds * 1000).toISOString()}; remaining quota: ${rateLimitHeaders.remaining ?? "unavailable"}`
207
274
  : apiErrorMessage(parsed, response.status),
208
275
  command: `${method} ${opts.path}`,
209
276
  exitCode: response.status,
210
277
  stderr: text,
211
278
  ...(rateLimit
212
279
  ? {
213
- hint: "Wait for the reset before retrying. Check the remaining quota once with the shown command; it does not consume the quota.",
214
- nextCommand: "gh api rate_limit",
280
+ hint: "Wait for the reset before retrying. Check the named resource with the shown command; the lookup does not consume quota.",
281
+ nextCommand: "gh-tool rate-limit",
215
282
  }
216
283
  : {}),
217
284
  ...(response.status >= 500 || (rateLimit && canWait) ? { retryable: true } : {}),
@@ -1,3 +1,4 @@
1
+ import { CacheRequest, cacheEvent, recordRequest } from "./telemetry";
1
2
  import { Console, Context, Effect } from "effect";
2
3
  import { resolveGitHubToken, SessionCredential } from "#gh/read/api";
3
4
  import { GitHubCommandError } from "#gh/errors";
@@ -86,16 +87,23 @@ export const cachedRead = <A, E, R>(opts: {
86
87
  ),
87
88
  );
88
89
  if (prepared === null) return yield* direct();
89
- if (prepared.kind === "hit") return prepared.value;
90
+ if (prepared.kind === "hit") {
91
+ yield* recordRequest(cacheEvent(opts.key.op));
92
+ return prepared.value;
93
+ }
90
94
  return yield* Effect.gen(function* () {
91
95
  yield* safe(session, () => store.record(opts.key.op, "fetch"));
92
96
  const previousValid =
93
97
  prepared.previous === null
94
98
  ? false
95
99
  : yield* safe(session, () => store.canServeStale(prepared));
96
- const fetched = yield* opts.fetch(
97
- prepared.previous === null ? null : { ...prepared.previous, valid: previousValid === true },
98
- );
100
+ const fetched = yield* opts
101
+ .fetch(
102
+ prepared.previous === null
103
+ ? null
104
+ : { ...prepared.previous, valid: previousValid === true },
105
+ )
106
+ .pipe(Effect.provideService(CacheRequest, true));
99
107
  if (fetched.kind === "unchanged") {
100
108
  if (prepared.previous === null)
101
109
  return yield* Effect.die(new Error("304 without a cached value"));
@@ -131,6 +139,7 @@ export const cachedRead = <A, E, R>(opts: {
131
139
  session.staleWarned = true;
132
140
  yield* Console.error(`gh-tool: served cached ${opts.key.op} (rate limited)`);
133
141
  }
142
+ yield* recordRequest(cacheEvent(opts.key.op));
134
143
  yield* safe(session, () => store.record(opts.key.op, "stale"));
135
144
  return prepared.previous.value;
136
145
  }),
@@ -2,7 +2,23 @@ import { Effect, Option } from "effect";
2
2
  import { Command, Flag } from "effect/cli";
3
3
  import { formatOption, logFormatted } from "#shared";
4
4
  import { cacheStats, clearCache } from "./cache";
5
- import { resolveRepo } from "./reads";
5
+ import { getRateLimit, resolveRepo } from "./reads";
6
+
7
+ export const rateLimitCommand = Command.make(
8
+ "rate-limit",
9
+ {
10
+ resource: Flag.String("resource").pipe(
11
+ Flag.withDescription("Show one GitHub rate-limit resource"),
12
+ Flag.optional,
13
+ ),
14
+ format: formatOption,
15
+ },
16
+ ({ resource, format }) =>
17
+ Effect.gen(function* () {
18
+ const summary = yield* getRateLimit(Option.getOrUndefined(resource));
19
+ yield* logFormatted(summary, format);
20
+ }),
21
+ ).pipe(Command.withDescription("Show GitHub API rate-limit resources and reset times"));
6
22
 
7
23
  const stats = Command.make("stats", { format: formatOption }, ({ format }) =>
8
24
  Effect.gen(function* () {
@@ -1,4 +1,4 @@
1
- import { Effect, Schema } from "effect";
1
+ import { Clock, Effect, Result, Schema } from "effect";
2
2
  import { GitHubService } from "#gh/read/transport";
3
3
  import { GitHubCommandError } from "#gh/errors";
4
4
  import type { CheckRunAnnotation } from "#gh/types";
@@ -21,6 +21,87 @@ import {
21
21
 
22
22
  export type JobRef = { readonly id: number; readonly status: string };
23
23
 
24
+ const RateLimitResponse = Schema.Struct({
25
+ resources: Schema.Record(
26
+ Schema.String,
27
+ Schema.Struct({
28
+ limit: Schema.Int,
29
+ used: Schema.Int,
30
+ remaining: Schema.Int,
31
+ reset: Schema.Int,
32
+ }),
33
+ ),
34
+ });
35
+
36
+ export type RateLimitResource = {
37
+ readonly limit: number;
38
+ readonly used: number;
39
+ readonly remaining: number;
40
+ readonly reset: string;
41
+ readonly secondsUntilReset: number;
42
+ };
43
+
44
+ export type RateLimitSummary = Readonly<Record<string, RateLimitResource>>;
45
+ export type PRStatusRateLimit =
46
+ | Readonly<{ core: RateLimitResource; graphql: RateLimitResource }>
47
+ | Readonly<{ error: string }>;
48
+
49
+ export const getRateLimit = Effect.fn("reads.getRateLimit")(function* (resource?: string) {
50
+ const gh = yield* GitHubService;
51
+ const response = yield* gh.runGhJson<unknown>(["api", "rate_limit"]);
52
+ const parsed = yield* Effect.try({
53
+ try: () => parsePreserving(RateLimitResponse)(response),
54
+ catch: (cause) =>
55
+ new GitHubCommandError({
56
+ message: `GitHub rate-limit response was invalid: ${String(cause)}`,
57
+ command: "gh-tool rate-limit",
58
+ exitCode: 1,
59
+ stderr: String(cause),
60
+ }),
61
+ });
62
+ const names = Object.keys(parsed.resources).toSorted();
63
+ const now = (yield* Clock.currentTimeMillis) / 1000;
64
+ const selected = resource === undefined ? names : [resource];
65
+ const entries: Array<[string, RateLimitResource]> = [];
66
+ for (const name of selected) {
67
+ const bucket = parsed.resources[name];
68
+ if (bucket === undefined) {
69
+ return yield* new GitHubCommandError({
70
+ message: `Unknown rate-limit resource '${name}'. Available resources: ${names.join(", ")}`,
71
+ command: "gh-tool rate-limit",
72
+ exitCode: 1,
73
+ stderr: "",
74
+ hint: "Select a resource returned by GitHub, or omit --resource to show all resources.",
75
+ });
76
+ }
77
+ entries.push([
78
+ name,
79
+ {
80
+ limit: bucket.limit,
81
+ used: bucket.used,
82
+ remaining: bucket.remaining,
83
+ reset: new Date(bucket.reset * 1000).toISOString(),
84
+ secondsUntilReset: Math.max(0, Math.ceil(bucket.reset - now)),
85
+ },
86
+ ]);
87
+ }
88
+ return Object.fromEntries(entries) satisfies RateLimitSummary;
89
+ });
90
+
91
+ export const getPRStatusRateLimit = Effect.fn("reads.getPRStatusRateLimit")(function* () {
92
+ const result = yield* getRateLimit().pipe(Effect.result);
93
+ if (Result.isFailure(result))
94
+ return { error: result.failure.message } satisfies PRStatusRateLimit;
95
+
96
+ const { core, graphql } = result.success;
97
+ if (core === undefined || graphql === undefined) {
98
+ return {
99
+ error: "GitHub rate-limit response omitted the core or graphql resource.",
100
+ } satisfies PRStatusRateLimit;
101
+ }
102
+ return { core, graphql } satisfies PRStatusRateLimit;
103
+ });
104
+
24
105
  const completedAttemptJobs = Effect.fn("reads.completedAttemptJobs")(function* (
25
106
  repo: RepoRef,
26
107
  runId: number,
@@ -282,6 +363,8 @@ const jobIdentity = Effect.fn("reads.jobIdentity")(function* (repo: RepoRef, job
282
363
  });
283
364
 
284
365
  export const reads = {
366
+ getRateLimit,
367
+ getPRStatusRateLimit,
285
368
  completedAttemptJobs,
286
369
  jobIdentity,
287
370
  reviewGuards,