@goodandready/dsh-github-ops 0.1.2 → 0.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/lib/github.js CHANGED
@@ -96,7 +96,10 @@ export function createGitHubClient({
96
96
  userAgent = DEFAULT_USER_AGENT,
97
97
  fetchImpl = globalThis.fetch,
98
98
  sleepImpl,
99
+ signalProvider,
99
100
  timeoutMs = 30000,
101
+ cacheTtlMs = 60000,
102
+ cacheLimit = 200,
100
103
  maxRetries = 2,
101
104
  retryBaseMs = 500,
102
105
  maxRetryDelayMs = 10000,
@@ -108,7 +111,30 @@ export function createGitHubClient({
108
111
  ? sleepImpl
109
112
  : (ms) => new Promise((resolve) => setTimeout(resolve, ms))
110
113
 
111
- async function attemptOnce({ method, path, query, body, url, accept, redirect }) {
114
+ // A short TTL cache for reads only: a composed report or a repeated list must not spend
115
+ // the rate limit twice. Writes always go to the network.
116
+ let lastScopesValue = ''
117
+ const cache = new Map()
118
+ const cacheKey = (method, target) => `${method} ${target}`
119
+ function cacheGet(key) {
120
+ const hit = cache.get(key)
121
+ if (!hit) return null
122
+ if (hit.expiresAt <= Date.now()) {
123
+ cache.delete(key)
124
+ return null
125
+ }
126
+ return hit.value
127
+ }
128
+ function cacheSet(key, value) {
129
+ if (!(cacheTtlMs > 0)) return
130
+ if (cache.size >= cacheLimit) {
131
+ const oldest = cache.keys().next().value
132
+ cache.delete(oldest)
133
+ }
134
+ cache.set(key, { value, expiresAt: Date.now() + cacheTtlMs })
135
+ }
136
+
137
+ async function attemptOnce({ method, path, query, body, url, accept, redirect, signal }) {
112
138
  const target = url || buildUrl(baseUrl, path, query)
113
139
  const headers = {
114
140
  accept,
@@ -122,6 +148,16 @@ export function createGitHubClient({
122
148
  const timer = controller && timeoutMs > 0
123
149
  ? setTimeout(() => controller.abort(), timeoutMs)
124
150
  : null
151
+ // The caller's signal (a cancelled turn) must abort the request too, not just the clock.
152
+ const combined = (() => {
153
+ if (!controller) return signal
154
+ if (!signal) return controller.signal
155
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
156
+ return AbortSignal.any([controller.signal, signal])
157
+ }
158
+ signal.addEventListener('abort', () => controller.abort(), { once: true })
159
+ return controller.signal
160
+ })()
125
161
 
126
162
  let response
127
163
  try {
@@ -129,19 +165,29 @@ export function createGitHubClient({
129
165
  method,
130
166
  headers,
131
167
  body: body === undefined ? undefined : JSON.stringify(body),
132
- signal: controller ? controller.signal : undefined,
168
+ signal: combined || undefined,
133
169
  ...(redirect ? { redirect } : {}),
134
170
  })
135
171
  } catch (err) {
136
172
  if (timer) clearTimeout(timer)
137
173
  const aborted = err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')
138
- throw new GitHubError(aborted ? `request timed out after ${timeoutMs}ms` : String((err && err.message) || err), {
139
- code: aborted ? 'timeout' : 'network',
140
- })
174
+ // A caller cancellation (the turn was stopped) is not a timeout: saying "timed out"
175
+ // there sends the model looking for a network problem that does not exist.
176
+ const cancelled = aborted && signal && signal.aborted
177
+ const message = cancelled
178
+ ? 'request cancelled by the caller'
179
+ : aborted
180
+ ? `request timed out after ${timeoutMs}ms`
181
+ : String((err && err.message) || err)
182
+ throw new GitHubError(message, { code: cancelled ? 'cancelled' : aborted ? 'timeout' : 'network' })
141
183
  }
142
184
  if (timer) clearTimeout(timer)
143
185
 
144
186
  const rateLimit = rateLimitFrom(response.headers)
187
+ if (typeof response.headers.get === 'function') {
188
+ const scopes = response.headers.get('x-oauth-scopes')
189
+ if (scopes) lastScopesValue = String(scopes)
190
+ }
145
191
  const text = typeof response.text === 'function' ? await response.text() : ''
146
192
  let payload = null
147
193
  if (text) {
@@ -154,16 +200,25 @@ export function createGitHubClient({
154
200
  return { response, payload, rateLimit }
155
201
  }
156
202
 
157
- async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect }) {
203
+ async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect, signal }) {
204
+ // The signal may come from the caller's turn: the shared client asks the provider, so a
205
+ // cancelled turn aborts the request in flight instead of waiting for the timeout.
206
+ const turnSignal = signal || (typeof signalProvider === 'function' ? signalProvider() : undefined)
158
207
  const upper = method.toUpperCase()
159
208
  const retryable = SAFE_RETRY_METHODS.has(upper)
209
+ const target = url || buildUrl(baseUrl, path, query)
210
+ const key = cacheKey(upper, target)
211
+ if (retryable) {
212
+ const cached = cacheGet(key)
213
+ if (cached) return { ...cached, cached: true }
214
+ }
160
215
  const attemptsAllowed = Math.max(1, Number(maxRetries) + 1)
161
216
  let lastError = null
162
217
 
163
218
  for (let attempt = 1; attempt <= attemptsAllowed; attempt += 1) {
164
219
  let result
165
220
  try {
166
- result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect })
221
+ result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect, signal: turnSignal })
167
222
  } catch (err) {
168
223
  lastError = err
169
224
  if (!retryable || attempt >= attemptsAllowed) {
@@ -176,7 +231,9 @@ export function createGitHubClient({
176
231
 
177
232
  const { response, payload, rateLimit } = result
178
233
  if (response.ok) {
179
- return { data: payload, status: response.status, rateLimit, attempts: attempt }
234
+ const value = { data: payload, status: response.status, rateLimit, attempts: attempt }
235
+ if (retryable) cacheSet(key, value)
236
+ return value
180
237
  }
181
238
 
182
239
  const message = (payload && (payload.message || payload.error)) || `HTTP ${response.status}`
@@ -210,6 +267,21 @@ export function createGitHubClient({
210
267
  return call({ method: 'GET', path, ...(opts || {}) })
211
268
  },
212
269
 
270
+ /** The OAuth scopes GitHub reported on the last answer, when it reported any. */
271
+ lastScopes() {
272
+ return lastScopesValue
273
+ },
274
+
275
+ /** Cache counters, for the settings card and for tests. */
276
+ cacheStats() {
277
+ return { size: cache.size, ttlMs: cacheTtlMs }
278
+ },
279
+
280
+ /** Drop the cache (a write may have invalidated what a read returned). */
281
+ cacheClear() {
282
+ cache.clear()
283
+ },
284
+
213
285
  post(path, body, opts) {
214
286
  return call({ method: 'POST', path, body, ...(opts || {}) })
215
287
  },
@@ -298,6 +370,28 @@ export function parseRepoSpec(spec) {
298
370
  return { owner: match[1], repo: match[2], number: match[3] ? Number(match[3]) : null }
299
371
  }
300
372
 
373
+ /**
374
+ * What a human should try next for a given failure. An error that only says "HTTP 401" makes
375
+ * the model guess; a named next step makes it act.
376
+ */
377
+ export function hintFor(err) {
378
+ if (!err) return ''
379
+ const status = err.status || 0
380
+ if (status === 401) return 'the access value is missing or expired: check the credential, the environment variable or `gh auth status`'
381
+ if (status === 403 && err.rateLimit && err.rateLimit.remaining === 0) {
382
+ return `the rate limit is spent and resets at ${err.rateLimit.resetAt || 'an unknown time'}: wait, or use a token with a higher limit`
383
+ }
384
+ if (status === 403) return 'the access value lacks the scope this call needs'
385
+ if (status === 404) return 'the repository, tag, issue or release does not exist, or the access value cannot see it (a draft release is only visible by id)'
386
+ if (status === 422) return 'GitHub rejected the payload: check the required fields'
387
+ if (status === 409) return 'the state changed under us (a conflict): read the object again and retry'
388
+ if (err.code === 'cancelled') return 'the turn was cancelled before GitHub answered'
389
+ if (err.code === 'timeout') return 'the request timed out: raise timeoutMs or retry'
390
+ if (err.code === 'network') return 'the network failed: retry, and check whether github.com resolves — a wrong /etc/hosts entry for github.com or api.github.com breaks every call'
391
+ if (err.code === 'rate_limited') return 'the rate limit is spent: wait for the reset before retrying'
392
+ return ''
393
+ }
394
+
301
395
  /** Human-readable one-liner for a failure, used by every tool renderer. */
302
396
  export function describeError(err) {
303
397
  if (!err) return 'unknown error'
@@ -309,7 +403,9 @@ export function describeError(err) {
309
403
  if (err.rateLimit && err.rateLimit.remaining === 0) {
310
404
  parts.push(`rate limit exhausted, resets at ${err.rateLimit.resetAt || 'unknown'}`)
311
405
  }
312
- return parts.length ? `${err.message} (${parts.join(', ')})` : err.message
406
+ const base = parts.length ? `${err.message} (${parts.join(', ')})` : err.message
407
+ const hint = hintFor(err)
408
+ return hint ? `${base} — ${hint}` : base
313
409
  }
314
410
  return String(err.message || err)
315
411
  }
@@ -0,0 +1,26 @@
1
+ // What the model is told about this plugin.
2
+ //
3
+ // A plugin with 50+ tools needs one short paragraph about how to use them, otherwise the
4
+ // model guesses: which tool for which question, when a repository argument may be omitted,
5
+ // and that approvals are the host's business rather than something these tools invent.
6
+
7
+ /** Section name in the system prompt; unique, so a reload replaces it instead of clashing. */
8
+ export const GUIDANCE_SECTION = 'dsh-github-ops'
9
+
10
+ /** Placement among the other sections. */
11
+ export const GUIDANCE_ORDER = 60
12
+
13
+ /** The default text. Keep it short: it is paid for on every turn. */
14
+ export const GUIDANCE_TEXT = [
15
+ 'GitHub (dsh-github-ops):',
16
+ '- Unsure which tool fits? gh_help lists every one of them, grouped by area.',
17
+ '- Inside a checkout the repository argument can be omitted: the origin remote decides. Both "owner/repo#12" and a bare "12" are accepted.',
18
+ '- Prefer one composed call over many plain ones: gh_repo_report (what a repository is), gh_weekly_digest (what happened lately), gh_repo_health (what to fix), gh_review (a pull request as one pack), ci_run (a deterministic verdict).',
19
+ '- Reads are free. Deleting a file, a branch, a tag, a release, a secret or a ruleset, forcing a ref, and publishing a mirror need confirm: true; approvals themselves belong to the host, not to these tools.',
20
+ '- A failure states the next step; gh_auth_status says where the access comes from.',
21
+ ].join('\n')
22
+
23
+ /** The text to register: the configured override, or the default. */
24
+ export function guidanceText(configured) {
25
+ return configured && String(configured).trim() ? String(configured).trim() : GUIDANCE_TEXT
26
+ }
package/lib/index.js CHANGED
@@ -25,11 +25,21 @@ import { registerCoreTools } from './tools/register-core.js'
25
25
  import { registerCollabTools } from './tools/register-collab.js'
26
26
  import { registerMirrorTools } from './tools/register-mirror.js'
27
27
  import { registerOpsTools } from './tools/register-ops.js'
28
+ import { registerInsightTools } from './tools/register-insights.js'
29
+ import { registerWriteTools } from './tools/register-write.js'
30
+ import { accessFilePath, readAccessFile } from './auth-device.js'
31
+ import { DEFAULT_CLIENT_ID, DEFAULT_SCOPE } from './auth-device.js'
32
+ import { GUIDANCE_SECTION, GUIDANCE_ORDER, guidanceText } from './guidance.js'
33
+ import { registerRoutes, STATUS_PATH, SCM_PATH, PANEL_PATH } from './routes.js'
34
+ import { AsyncLocalStorage } from 'node:async_hooks'
28
35
  import { execFile } from 'node:child_process'
29
36
  import { promisify } from 'node:util'
30
37
 
31
38
  const execFileAsync = promisify(execFile)
32
39
 
40
+ // Carries the current tool call's cancellation signal to the shared client.
41
+ const callContext = new AsyncLocalStorage()
42
+
33
43
  /** git runner: never throws, always answers with a code and both streams. */
34
44
  function makeGitRunner() {
35
45
  return async (args, { cwd, env } = {}) => {
@@ -64,6 +74,31 @@ export const OUTPUT_SCHEMA = {
64
74
  },
65
75
  }
66
76
 
77
+ /** Which area a tool belongs to, derived from its name so a new tool lands somewhere sane. */
78
+ export function groupOf(toolName) {
79
+ const name = String(toolName || '')
80
+ const rules = [
81
+ [/^gh_release_/, 'releases'],
82
+ [/^gh_tag_/, 'tags'],
83
+ [/^gh_run_/, 'runs'],
84
+ [/^gh_secret_/, 'secrets'],
85
+ [/^gh_variable_/, 'variables'],
86
+ [/^gh_ruleset_/, 'rulesets'],
87
+ [/^gh_branch_protection_/, 'branch-protection'],
88
+ [/^gh_mirror_/, 'mirror'],
89
+ [/^gh_review_job|^gh_review$|^review_post$|^ci_run$|^gh_checks$|^pr_/, 'pull-requests'],
90
+ [/^issue_|^gh_issue$|^gh_search$/, 'issues'],
91
+ [/^gh_repo_report|^gh_weekly_digest|^gh_notifications|^gh_repo_health|^gh_compare|^gh_trending|^gh_contributors|^gh_user_repos|^gh_commits/, 'insights'],
92
+ [/^gh_repo|^gh_file/, 'repositories'],
93
+ [/^gh_push_|^gh_upload_|^gh_delete_/, 'write-files'],
94
+ [/^gh_auth_/, 'auth'],
95
+ [/^gh_api$/, 'api'],
96
+ [/^gh_help$/, 'meta'],
97
+ ]
98
+ for (const [pattern, group] of rules) if (pattern.test(name)) return group
99
+ return 'other'
100
+ }
101
+
67
102
  const MAX_RENDER_CHARS = 20000
68
103
 
69
104
  /** Render a tool result for the model: readable text, bounded so one call cannot flood the turn. */
@@ -104,6 +139,22 @@ export const Config = Schema.object({
104
139
  .description('Retries for a read (GET/HEAD) on a network failure, a timeout or a 5xx. Writes are never retried automatically.'),
105
140
  reviewRulesJson: Schema.string().default('')
106
141
  .description('Optional review-rule overrides as JSON: sensitivePaths, sensitiveSeverity, attentionPaths, migrationPaths, testsRequired, sourcePatterns, testPatterns, largeDiffLines.'),
142
+ oauthClientId: Schema.string().default(DEFAULT_CLIENT_ID)
143
+ .description('OAuth application id used by gh_auth_login. The default is the public GitHub CLI application.'),
144
+ oauthScope: Schema.string().default(DEFAULT_SCOPE)
145
+ .description('OAuth scope requested by gh_auth_login.'),
146
+ oauthClientSecretRef: Schema.string().role('credential-ref').default('')
147
+ .description('Optional credential holding the OAuth client secret, for apps that require one.'),
148
+ accessFile: Schema.string().default('')
149
+ .description('Where gh_auth_login stores its sign-in (default: $DSH_HOME/github-ops-auth.json, mode 0600).'),
150
+ interceptLinks: Schema.boolean().default(false)
151
+ .description('Let a github.com link clicked in the conversation also select that repository for the Source control tab and the sidebar panel. Off by default: the link always opens in the browser either way.'),
152
+ guidance: Schema.boolean().default(true)
153
+ .description('Add a short paragraph about this plugin to the system prompt: which tool for which question, when the repository argument can be omitted, and that approvals belong to the host.'),
154
+ guidanceText: Schema.string().default('')
155
+ .description('Replace the default guidance paragraph with your own text.'),
156
+ cacheTtlMs: Schema.number().default(60000)
157
+ .description('How long a read stays cached, in milliseconds. A composed report then costs one request set instead of many; writes always go to the network.'),
107
158
  reviewJobTimeoutMs: Schema.number().default(120000)
108
159
  .description('How long a background review job may run before it is cancelled.'),
109
160
  })
@@ -142,6 +193,12 @@ export function apply(ctx, rawConfig) {
142
193
  }
143
194
  }
144
195
 
196
+ /** Where the plugin's own sign-in lives. */
197
+ function accessPath() {
198
+ const configured = liveConfig().accessFile
199
+ return configured && String(configured).trim() ? String(configured).trim() : accessFilePath()
200
+ }
201
+
145
202
  /** The gh CLI session, when the machine already has `gh auth login` done. */
146
203
  async function ghCliAccess() {
147
204
  const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 10000, maxBuffer: 1024 * 1024 })
@@ -152,6 +209,11 @@ export function apply(ctx, rawConfig) {
152
209
  async function resolveToken() {
153
210
  const cfg = liveConfig()
154
211
  return resolveAccess({
212
+ // the sign-in this plugin stored itself (gh_auth_login) is one of the sources
213
+ runFile: async () => {
214
+ const stored = await readAccessFile(accessPath())
215
+ return stored && stored.token ? stored.token : ''
216
+ },
155
217
  // the credentials service takes a branded reference, not a bare name
156
218
  credentials: {
157
219
  resolve: async (name) => {
@@ -169,18 +231,40 @@ export function apply(ctx, rawConfig) {
169
231
  })
170
232
  }
171
233
 
234
+ // One client per configuration: the read cache lives in it, and the signal for the
235
+ // current tool call arrives through the async context.
236
+ let sharedClient = null
237
+ let sharedKey = ''
238
+
172
239
  async function client() {
173
240
  const cfg = liveConfig()
174
241
  const access = await resolveToken()
175
242
  if (!access.value) {
176
243
  throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
177
244
  }
178
- return createGitHubClient({
179
- token: access.value,
180
- baseUrl: cfg.baseUrl || 'https://api.github.com',
181
- timeoutMs: Number(cfg.timeoutMs) || 30000,
182
- maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
183
- })
245
+ const key = [
246
+ access.source,
247
+ access.value.length,
248
+ cfg.baseUrl || '',
249
+ cfg.timeoutMs || '',
250
+ cfg.cacheTtlMs || '',
251
+ cfg.maxRetries ?? '',
252
+ ].join('|')
253
+ if (!sharedClient || sharedKey !== key) {
254
+ sharedClient = createGitHubClient({
255
+ token: access.value,
256
+ baseUrl: cfg.baseUrl || 'https://api.github.com',
257
+ timeoutMs: Number(cfg.timeoutMs) || 30000,
258
+ cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
259
+ maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
260
+ signalProvider: () => {
261
+ const store = callContext.getStore()
262
+ return store && store.signal ? store.signal : undefined
263
+ },
264
+ })
265
+ sharedKey = key
266
+ }
267
+ return sharedClient
184
268
  }
185
269
 
186
270
  /**
@@ -217,6 +301,10 @@ export function apply(ctx, rawConfig) {
217
301
  * plugin must never be the reason a deployment cannot start.
218
302
  */
219
303
  const skippedTools = []
304
+ /** Every registered tool, so gh_help never drifts from reality. */
305
+ const catalog = []
306
+ const WRITE_PREFIXES = ['pr_', 'issue_', 'review_post', 'gh_api', 'gh_run_rerun', 'gh_run_cancel', 'gh_mirror_publish', 'gh_release_', 'gh_tag_', 'gh_repo_create', 'gh_repo_edit', 'gh_secret_', 'gh_variable_', 'gh_ruleset_', 'gh_branch_protection_set', 'gh_branch_protection_delete', 'gh_push_']
307
+
220
308
  function tool(toolName, description, parameters, run) {
221
309
  const definition = defineTool({
222
310
  name: toolName,
@@ -227,9 +315,17 @@ export function apply(ctx, rawConfig) {
227
315
  render: (_args, value) => renderToolResult(toolName, value),
228
316
  },
229
317
  ...presentationFor(toolName),
230
- execute: async (args) => {
318
+ execute: async (args, exec) => {
231
319
  try {
232
- const value = await run(args || {})
320
+ const value = await callContext.run({ signal: exec && exec.signal }, () => run(args || {}))
321
+ // A write may invalidate what a cached read returned.
322
+ if (WRITE_PREFIXES.some((prefix) => toolName === prefix || toolName.startsWith(prefix))) {
323
+ try {
324
+ sharedClient?.cacheClear?.()
325
+ } catch {
326
+ // a cache reset must never fail a successful call
327
+ }
328
+ }
233
329
  return { ok: true, data: value }
234
330
  } catch (err) {
235
331
  const out = { ok: false, error: describeError(err) }
@@ -244,6 +340,7 @@ export function apply(ctx, rawConfig) {
244
340
  })
245
341
  try {
246
342
  ctx.tools.register(definition)
343
+ catalog.push({ name: toolName, description, group: groupOf(toolName) })
247
344
  } catch (err) {
248
345
  if (!/is already registered/.test(String((err && err.message) || err))) throw err
249
346
  skippedTools.push(toolName)
@@ -251,10 +348,35 @@ export function apply(ctx, rawConfig) {
251
348
  }
252
349
  }
253
350
 
254
- const deps = { tool, asRepo, client, liveConfig, reviewRules }
351
+ const pendingFlow = { current: null }
352
+ const authSeam = {
353
+ config: () => {
354
+ const cfg = liveConfig()
355
+ return { clientId: cfg.oauthClientId || DEFAULT_CLIENT_ID, scope: cfg.oauthScope || DEFAULT_SCOPE, secretRef: cfg.oauthClientSecretRef || '' }
356
+ },
357
+ path: accessPath,
358
+ remember: (flow) => { pendingFlow.current = flow },
359
+ pending: () => pendingFlow.current,
360
+ forget: () => { pendingFlow.current = null },
361
+ resolve: resolveToken,
362
+ secret: async () => {
363
+ const ref = liveConfig().oauthClientSecretRef
364
+ if (!ref) return ''
365
+ try {
366
+ const resolved = await ctx.credentials.resolve(credentialRef(ref))
367
+ return (resolved && resolved.value) || ''
368
+ } catch {
369
+ return ''
370
+ }
371
+ },
372
+ }
373
+
374
+ const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam }
255
375
  registerCoreTools(deps)
256
376
  registerCollabTools(deps)
257
377
  registerOpsTools(deps)
378
+ registerInsightTools(deps)
379
+ registerWriteTools(deps)
258
380
  registerMirrorTools({
259
381
  tool,
260
382
  git: makeGitRunner(),
@@ -283,6 +405,30 @@ export function apply(ctx, rawConfig) {
283
405
  },
284
406
  })
285
407
 
408
+ registerRoutes(ctx, {
409
+ resolveToken,
410
+ client,
411
+ liveConfig,
412
+ describeError,
413
+ makeGitRunner,
414
+ cacheStats: () => (sharedClient && typeof sharedClient.cacheStats === 'function' ? sharedClient.cacheStats() : null),
415
+ })
416
+
417
+ // One short paragraph in the system prompt: 50+ tools otherwise leave the model guessing.
418
+ ctx.inject(['systemPrompt'], (sctx) => {
419
+ try {
420
+ const cfg = liveConfig()
421
+ if (cfg.guidance === false) return
422
+ sctx.systemPrompt.section({
423
+ name: GUIDANCE_SECTION,
424
+ order: GUIDANCE_ORDER,
425
+ text: guidanceText(cfg.guidanceText),
426
+ })
427
+ } catch (err) {
428
+ ctx.logger?.warn?.('[dsh-github-ops] system prompt guidance not registered:', err && err.message)
429
+ }
430
+ })
431
+
286
432
  // Slash commands are a fast path for a human: they only hand the model an instruction, so
287
433
  // the write still passes through the host approval contour.
288
434
  ctx.inject(['commands'], (cctx) => {
@@ -308,6 +454,31 @@ export function apply(ctx, rawConfig) {
308
454
  })
309
455
 
310
456
 
457
+ // The catalog is built from the registrations above, so it cannot go stale.
458
+ tool(
459
+ 'gh_help',
460
+ 'Catalog of every tool this plugin provides, grouped by area, with one line each. Call it when unsure which tool fits — it lists what exists instead of guessing.',
461
+ { group: { type: 'string', description: 'Optional: only this group (repositories, releases, tags, issues, pull-requests, runs, secrets, variables, rulesets, branch-protection, mirror, jobs, api, meta).' } },
462
+ async (args) => {
463
+ const groups = {}
464
+ for (const entry of catalog) {
465
+ if (entry.name === 'gh_help') continue
466
+ groups[entry.group] = groups[entry.group] || []
467
+ groups[entry.group].push({ name: entry.name, description: entry.description })
468
+ }
469
+ if (args.group) {
470
+ const only = groups[args.group]
471
+ if (!only) return { error: `unknown group "${args.group}"`, groups: Object.keys(groups).sort() }
472
+ return { group: args.group, count: only.length, tools: only }
473
+ }
474
+ return {
475
+ count: Object.values(groups).reduce((sum, list) => sum + list.length, 0),
476
+ groups: Object.keys(groups).sort().map((name) => ({ name, count: groups[name].length })),
477
+ tools: groups,
478
+ }
479
+ },
480
+ )
481
+
311
482
  if (skippedTools.length) {
312
483
  ctx.logger?.warn?.(
313
484
  `[dsh-github-ops] ${skippedTools.length} tool(s) left to the plugin that already provides them: `