No description.

CLI to manage and install AI agent skills, agents, and workflows to various platforms.
pnpx @kurokeita/add-skill --help
List available items in the repository:
# List skills (default)
pnpx @kurokeita/add-skill list
# List agents
pnpx @kurokeita/add-skill list agent
# List workflows
pnpx @kurokeita/add-skill list workflow
List locally installed items:
pnpx @kurokeita/add-skill list --local
pnpx @kurokeita/add-skill list agent --local
# Pick a scope non-interactively (default: global)
pnpx @kurokeita/add-skill list --local --scope project
pnpx @kurokeita/add-skill list --local --scope both
Starts an interactive session to select and install items. Supports live search/filtering as you type.
# Add skills
pnpx @kurokeita/add-skill add
# Add agents
pnpx @kurokeita/add-skill add agent
# Add workflows
pnpx @kurokeita/add-skill add workflow
# Install at project scope (writes under the current directory's
# `.<platform>/...` paths instead of `~/...`). Project scope is
# refused at $HOME or outside a git work tree.
pnpx @kurokeita/add-skill add --scope project
Interactively select items and platforms to remove them from. Supports live search/filtering as you type.
# Remove skills
pnpx @kurokeita/add-skill remove skill
# Remove agents
pnpx @kurokeita/add-skill remove agent
# Pick a scope non-interactively
pnpx @kurokeita/add-skill remove skill --scope project
pnpx @kurokeita/add-skill remove skill --scope both
Install an item directly from a GitHub URL.
pnpx @kurokeita/add-skill add https://github.com/owner/repo/tree/main/skills/skill-name
Import a skill, agent, or workflow from GitHub into this repository.
pnpm dev import https://github.com/owner/repo/tree/main/skills/skill-name
add copies every item into a single canonical directory:
~/.agents/{skills,agents,commands}<project-root>/.agents/{skills,agents,commands}Skills go under skills, agents under agents, and workflows under commands. After copying, add can optionally wire each platform you select: it installs a session-start hook (under .agents/hooks/) and registers it in that platform's config. On each new session the hook symlinks the canonical .agents/ entries back into the platform's own directories, so every tool sees the same set of items from one source of truth.
A skill can also ship Claude Code hooks in a hooks/ dir. hooks/hooks.json holds the hooks object of .claude/settings.json, and ${HOOKS_DIR} in its commands resolves to .claude/hooks. When add installs a skill that has this manifest, it offers to copy the other files in hooks/ to .claude/hooks/ and merge the entries into .claude/settings.json (global or project, matching the install scope). git-commit uses this to install its commit-gate.py hook.
list and remove operate on the canonical .agents/ dir. Removing an entry also prunes any platform symlink left pointing at it.
The tables below show where each platform consumes items from — i.e. the symlink destinations the session-start hook creates.
| Platform | Agents Path | Skills Path | Workflows Path |
|---|---|---|---|
| Antigravity | Not Supported | ~/.gemini/antigravity/global_skills | ~/.gemini/antigravity/global_workflows |
| Claude Code | ~/.claude/agents | ~/.claude/skills | ~/.claude/commands |
| Codex | ~/.codex/skills | ~/.codex/skills | ~/.codex/skills |
| Gemini CLI | ~/.gemini/agents | ~/.gemini/skills | ~/.gemini/commands |
| GitHub Copilot | ~/.copilot/agents | ~/.copilot/skills | ~/.copilot/prompts |
| Windsurf | Not Supported | ~/.codeium/windsurf/skills | ~/.codeium/windsurf/global_workflows |
Codex consumes skills, agents, and workflows from a single ~/.codex/skills directory, so all three symlink there.
When you pick --scope project, the canonical .agents/ dir and the symlink destinations live under the current working directory instead of ~. The mapping below uses <project-root> to mean process.cwd() (no git-toplevel relocation: a monorepo subdirectory installs into that subdirectory, not the repo root).
| Platform | Agents Path | Skills Path | Workflows Path |
|---|---|---|---|
| Antigravity | Not Supported | <project-root>/.gemini/antigravity/skills | <project-root>/.gemini/antigravity/workflows |
| Claude Code | <project-root>/.claude/agents | <project-root>/.claude/skills | <project-root>/.claude/commands |
| Codex | <project-root>/.codex/skills | <project-root>/.codex/skills | <project-root>/.codex/skills |
| Gemini CLI | <project-root>/.gemini/agents | <project-root>/.gemini/skills | <project-root>/.gemini/commands |
| GitHub Copilot | <project-root>/.copilot/agents | <project-root>/.copilot/skills | <project-root>/.copilot/prompts |
| Windsurf | Not Supported | <project-root>/.codeium/windsurf/skills | <project-root>/.codeium/windsurf/workflows |
Project scope is refused when cwd equals your home directory or cwd is not inside a git work tree. When refused interactively, you can fall back to global scope without losing your already-selected items and platforms.
git clone https://github.com/kurokeita/ai-agent.git
cd ai-agent
pnpm install
pnpm dev
pnpm lint
pnpm format
pnpm build
This project is licensed under the GNU General Public License v3.0.
hooks/register.tsx 424 lines1import type { EngineInterface, Register } from "claude-code"
2import { atom, read, update } from "claude-code"
3
4import type { CheckState, Pr, Step } from "../types"
5import {
6 parseSummary,
7 platformOf,
8 prNumbersIn,
9 repoNameOf,
10 SUMMARY_SYSTEM,
11 transcriptOf,
12} from "./discover"
13import { fetchAzure, fetchGitHub, git, type Run } from "./fetch"
14
15const PANE = "work-items"
16const branches = atom({ plugin: "work-items", key: "branches" } as const, [])
17const work = atom({ plugin: "work-items", key: "work" } as const, null)
18const isFetching = atom(
19 { plugin: "work-items", key: "isFetching" } as const,
20 false,
21)
22const summary = atom({ plugin: "work-items", key: "summary" } as const, null)
23const isSummarizing = atom(
24 { plugin: "work-items", key: "isSummarizing" } as const,
25 false,
26)
27const collapsed = atom({ plugin: "work-items", key: "collapsed" } as const, [])
28
29let isFetchRunning = false
30let isAutoOpenWanted = false
31let isAutoOpened = false
32// Which conversation this process shows, and whether a prompt was sent in it here.
33let watchedId = ""
34let hasPromptedHere = false
35let isClearPending = false
36let sessionStartedAt = Date.now()
37
38const runner =
39 ($: EngineInterface): Run =>
40 (argv) =>
41 $.process
42 .run(argv, { timeoutMs: 60_000 })
43 .catch((error) => ({ exitCode: 1, stdout: "", stderr: String(error) }))
44
45const isPaneOpen = async ($: EngineInterface) =>
46 (await $.ui.panes()).some((pane) => pane.id === PANE)
47
48// The branches this session sat on at the end of a turn, newest first.
49async function noteBranch($: EngineInterface): Promise<string> {
50 const branch = await git(runner($), ["branch", "--show-current"])
51 if (branch)
52 await update($, branches, (list) =>
53 [branch, ...list.filter((b) => b !== branch)].slice(0, 10),
54 )
55 return branch
56}
57
58// The current branch, plus the branches this session committed to. A branch the
59// session only started on or passed through has no commits since it began.
60async function trackedBranches(
61 $: EngineInterface,
62 current: string,
63): Promise<string[]> {
64 const since = `--since=${new Date(sessionStartedAt).toISOString()}`
65 const tracked = current ? [current] : []
66 for (const branch of await read($, branches)) {
67 if (branch === current) continue
68 if (await git(runner($), ["log", "-1", "--format=%H", since, branch]))
69 tracked.push(branch)
70 }
71 return tracked
72}
73
74async function refreshWork($: EngineInterface) {
75 if (isFetchRunning) return
76 isFetchRunning = true
77 await update($, isFetching, () => true)
78 try {
79 const remote = await git(runner($), ["remote", "get-url", "origin"])
80 const platform = platformOf(remote)
81 if (!platform) {
82 await update($, work, () => ({
83 prs: [],
84 items: [],
85 fetchedAt: Date.now(),
86 error: "origin is neither GitHub nor Azure DevOps.",
87 }))
88 return
89 }
90 const current = await noteBranch($)
91 const prNumbers = prNumbersIn(
92 await $.session.messages(),
93 platform,
94 repoNameOf(remote),
95 )
96 const fetch = platform === "github" ? fetchGitHub : fetchAzure
97 const result = await fetch(
98 runner($),
99 await trackedBranches($, current),
100 prNumbers,
101 )
102 await update($, work, () => result)
103 } finally {
104 isFetchRunning = false
105 await update($, isFetching, () => false)
106 }
107}
108
109async function summarize($: EngineInterface) {
110 if (await read($, isSummarizing)) return
111 await update($, isSummarizing, () => true)
112 try {
113 const prompt = transcriptOf(await $.session.messages())
114 // The API refuses an empty user message with a 400.
115 if (!prompt.trim()) {
116 await update($, summary, () => ({
117 error: "Nothing to summarize yet. Send a prompt first.",
118 at: Date.now(),
119 }))
120 return
121 }
122 const reply = await $.model.complete({
123 model: "haiku",
124 system: SUMMARY_SYSTEM,
125 prompt,
126 maxTokens: 1500,
127 })
128 const parsed = reply.isAnswered ? parseSummary(reply.text) : null
129 const error = reply.isAnswered
130 ? "The summary came back unreadable."
131 : reply.reason === "api-error"
132 ? `Summary failed: api-error ${reply.status ?? ""} ${reply.error}`
133 : `Summary failed: ${reply.reason}`
134 await update($, summary, () =>
135 parsed ? { ...parsed, at: Date.now() } : { error, at: Date.now() },
136 )
137 } finally {
138 await update($, isSummarizing, () => false)
139 }
140}
141
142// Summarizes once when the pane shows and no summary exists yet, or the last try failed.
143async function summarizeIfMissing($: EngineInterface) {
144 const current = await read($, summary)
145 if (!current || "error" in current) await summarize($)
146}
147
148// Opens the pane once in a resumed session, the first time it has a PR or item.
149async function autoOpen($: EngineInterface) {
150 if (!isAutoOpenWanted || isAutoOpened) return
151 await refreshWork($)
152 const current = await read($, work)
153 if (
154 isAutoOpened ||
155 !current ||
156 current.prs.length + current.items.length === 0
157 )
158 return
159 isAutoOpened = true
160 // No focus: the prompt keeps the keyboard. Ctrl+X Tab or a click focuses it to scroll.
161 await $.ui.open({ id: PANE, title: "Work", columns: 64 })
162 await summarizeIfMissing($)
163}
164
165// A conversation is resumed when it has turns no prompt of this process made:
166// --resume and --continue load them before session.start, while the picker and
167// an in-session /resume load them later, under a new session id.
168async function checkResumed($: EngineInterface) {
169 const id = await $.session.id()
170 if (id !== watchedId) {
171 watchedId = id
172 // A /clear starts its conversation here, though the transcript's /clear row counts as a turn.
173 hasPromptedHere = isClearPending
174 isClearPending = false
175 isAutoOpenWanted = false
176 isAutoOpened = false
177 // Another conversation now: its branches start over from here.
178 sessionStartedAt = Date.now()
179 await update($, branches, () => [])
180 }
181 if (isAutoOpenWanted || hasPromptedHere || (await $.session.turns()) === 0)
182 return
183 isAutoOpenWanted = true
184 await autoOpen($)
185}
186
187function ago(at: number): string {
188 const s = Math.max(0, Math.round((Date.now() - at) / 1000))
189 if (s < 60) return `${s}s ago`
190 if (s < 3600) return `${Math.round(s / 60)}m ago`
191 return `${Math.round(s / 3600)}h ago`
192}
193
194const STATE_COLOR: Record<string, string> = {
195 open: "success",
196 draft: "subtle",
197 merged: "merged",
198 closed: "error",
199 done: "success",
200 resolved: "success",
201 removed: "subtle",
202 "to do": "subtle",
203 new: "subtle",
204 active: "suggestion",
205 committed: "suggestion",
206 "in progress": "suggestion",
207}
208const CHECKS: Record<CheckState, { text: string; color: string }> = {
209 passing: { text: "✓ checks", color: "success" },
210 failing: { text: "✗ checks", color: "error" },
211 pending: { text: "● checks", color: "warning" },
212 none: { text: "no checks", color: "subtle" },
213}
214const STEP: Record<Step["status"], { mark: string; color: string }> = {
215 done: { mark: "✓", color: "success" },
216 current: { mark: "●", color: "warning" },
217 todo: { mark: "○", color: "subtle" },
218}
219
220export const register: Register = (on) => {
221 on("session.start", async ($, e, next) => {
222 await $.command.register({
223 name: "work",
224 description: "Show this session's PRs, work items and summary in a pane",
225 })
226 void checkResumed($)
227 $.clock.every(5_000, () => checkResumed($))
228 $.clock.every(60_000, async () => {
229 if (await isPaneOpen($)) await refreshWork($)
230 })
231
232 return next(e)
233 })
234
235 on("turn.complete", async ($, e, next) => {
236 const result = await next(e)
237 if (e.agentId) return result
238 // A main-conversation turn is when the session pushes branches and opens PRs,
239 // and moves its goal and steps on.
240 void (async () => {
241 await noteBranch($)
242 if (isAutoOpenWanted && !isAutoOpened) return autoOpen($)
243 if (await isPaneOpen($)) await Promise.all([refreshWork($), summarize($)])
244 })()
245
246 return result
247 })
248
249 on("session.end", async (_$, e, next) => {
250 if (e.reason === "clear") isClearPending = true
251
252 return next(e)
253 })
254
255 on("prompt.submit", async (_$, e, next) => {
256 hasPromptedHere = true
257
258 return next(e)
259 })
260
261 on("command.run", { command: "work" }, async ($) => {
262 // Opened by hand: auto-open has nothing left to do, so turns refresh the pane.
263 isAutoOpened = true
264 void refreshWork($)
265 await $.ui.open({
266 id: PANE,
267 title: "Work",
268 columns: 64,
269 focus: true,
270 closeOnEscape: true,
271 })
272 void summarizeIfMissing($)
273
274 return {
275 text: "Work pane opened. Up/Down or PgUp/PgDn scroll, Esc closes.",
276 }
277 })
278
279 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
280 const { Box, Text, Link, Button } = $.ui.resolve(e)
281 const current = await read($, work)
282 const fetching = await read($, isFetching)
283 const sum = await read($, summary)
284 const summarizing = await read($, isSummarizing)
285 const shut = await read($, collapsed)
286
287 const toggle = (pr: Pr) =>
288 update($, collapsed, (list) =>
289 list.includes(pr.number)
290 ? list.filter((n) => n !== pr.number)
291 : [...list, pr.number],
292 )
293 const prs = current?.prs ?? []
294 const items = current?.items ?? []
295
296 return (
297 <Box flexDirection="column" rowGap={1}>
298 <Box flexDirection="column">
299 <Text>
300 <Text bold color="planMode">
301 Summary
302 </Text>
303 <Text dimColor>
304 {summarizing ? " updating…" : sum ? ` ${ago(sum.at)}` : ""}
305 </Text>
306 </Text>
307 {!sum && (
308 <Text dimColor>Press Refresh to summarize this session.</Text>
309 )}
310 {sum && "error" in sum && <Text color="error">{sum.error}</Text>}
311 {sum && "goal" in sum && <Text>{sum.goal}</Text>}
312 </Box>
313
314 {sum && "steps" in sum && sum.steps.length > 0 && (
315 <Box flexDirection="column">
316 <Text bold color="planMode">
317 Steps
318 </Text>
319 {sum.steps.map((step) => (
320 <Text
321 wrap="truncate-end"
322 color={
323 step.status === "todo" ? undefined : STEP[step.status].color
324 }
325 dimColor={step.status === "todo"}
326 bold={step.status === "current"}
327 >
328 {STEP[step.status].mark} {step.text}
329 </Text>
330 ))}
331 </Box>
332 )}
333
334 <Box flexDirection="column">
335 <Text>
336 <Text bold color="planMode">
337 Pull requests
338 </Text>
339 <Text dimColor>
340 {" "}
341 {prs.length}
342 {fetching ? " · updating…" : ""}
343 </Text>
344 </Text>
345 {current?.error && <Text color="error">{current.error}</Text>}
346 {prs.length === 0 && !current?.error && (
347 <Text dimColor>None found for this session yet.</Text>
348 )}
349 {prs.map((pr) => (
350 <Box flexDirection="column">
351 <Box flexDirection="row" columnGap={1}>
352 <Button
353 key={`toggle-${pr.number}`}
354 plain
355 label={shut.includes(pr.number) ? "▸" : "▾"}
356 onPress={() => void toggle(pr)}
357 />
358 <Text wrap="truncate-end">
359 <Link href={pr.url}>#{pr.number}</Link>
360 <Text color={STATE_COLOR[pr.state] ?? "text"}>
361 {" "}
362 {pr.state}
363 </Text>
364 <Text dimColor> [{pr.branch}]</Text> {pr.title}
365 </Text>
366 </Box>
367 {!shut.includes(pr.number) && (
368 <Text>
369 {" "}
370 {pr.review && (
371 <Text
372 color={pr.review === "approved" ? "success" : "warning"}
373 >
374 {pr.review}{" "}
375 </Text>
376 )}
377 <Text color={CHECKS[pr.checks].color}>
378 {CHECKS[pr.checks].text}
379 </Text>
380 <Text dimColor> {pr.comments} comments</Text>
381 </Text>
382 )}
383 </Box>
384 ))}
385 </Box>
386
387 <Box flexDirection="column">
388 <Text>
389 <Text bold color="planMode">
390 Work items
391 </Text>
392 <Text dimColor> {items.length}</Text>
393 </Text>
394 {items.length === 0 && <Text dimColor>None linked yet.</Text>}
395 {items.map((item) => (
396 <Text wrap="truncate-end">
397 <Link href={item.url}>#{item.id}</Link>
398 <Text color={STATE_COLOR[item.state] ?? "text"}>
399 {" "}
400 {item.state}
401 </Text>{" "}
402 {item.title}
403 </Text>
404 ))}
405 </Box>
406
407 <Box flexDirection="row" columnGap={1}>
408 <Button
409 key="refresh"
410 label="Refresh"
411 hotkey="r"
412 onPress={() => void Promise.all([refreshWork($), summarize($)])}
413 />
414 <Text dimColor>
415 {current
416 ? `PRs updated ${ago(current.fetchedAt)}`
417 : "PRs not fetched yet"}
418 </Text>
419 </Box>
420 </Box>
421 )
422 })
423}
424hooks/discover.ts 143 lines1import type { SessionMessage } from "claude-code"
2
3import type { CheckState, Step } from "../types"
4
5export type Platform = "github" | "azure"
6
7export function platformOf(remote: string): Platform | null {
8 if (/github\.com[:/]/.test(remote)) return "github"
9 if (/dev\.azure\.com[:/]|\.visualstudio\.com\//.test(remote)) return "azure"
10 return null
11}
12
13export function repoNameOf(remote: string): string {
14 return (
15 remote
16 .trim()
17 .replace(/\.git$/, "")
18 .split(/[/:]/)
19 .pop() ?? ""
20 ).toLowerCase()
21}
22
23function decoded(name: string): string {
24 try {
25 return decodeURIComponent(name).toLowerCase()
26 } catch {
27 return name.toLowerCase()
28 }
29}
30
31// PR links anywhere in the conversation: a command's output (gh pr create and
32// gh pr edit print the URL), the model's own text, or a link the person pasted.
33// Only links into this repository count, since the fetch runs against it.
34export function prNumbersIn(
35 messages: readonly SessionMessage[],
36 platform: Platform,
37 repo: string,
38): number[] {
39 const pattern =
40 platform === "github"
41 ? /github\.com\/[\w.-]+\/([\w.-]+)\/pull\/(\d{1,9})/g
42 : /_git\/([^/\s?#"']+)\/pullrequest\/(\d{1,9})/gi
43 const found = new Set<number>()
44 for (const message of messages) {
45 const texts = [message.text]
46 for (const use of message.toolUses)
47 texts.push(JSON.stringify(use.input), use.text ?? "")
48 for (const result of message.toolResults ?? []) texts.push(result.text)
49 for (const text of texts) {
50 for (const match of text.matchAll(pattern)) {
51 if (decoded(match[1] ?? "") === repo) found.add(Number(match[2]))
52 }
53 }
54 }
55 return [...found]
56}
57
58// Items a branch without a PR points at: a 3-9 digit segment of its name (plus
59// gh issue develop's "<n>-slug"), then AB#N or #N in its own commits. 9 digits
60// at most, as both APIs take 32-bit ids and one bad literal fails the query.
61export function itemRefs(
62 platform: Platform,
63 branch: string,
64 commitLog: string,
65): number[] {
66 const refs: string[] = []
67 const lead =
68 platform === "github" ? /^(\d{1,9})-/.exec(branch)?.[1] : undefined
69 if (lead) refs.push(lead)
70 refs.push(
71 ...branch.split(/[^A-Za-z0-9]+/).filter((part) => /^\d{3,9}$/.test(part)),
72 )
73 const pattern = platform === "github" ? /#(\d{1,9})/g : /AB#(\d{1,9})/g
74 for (const match of commitLog.matchAll(pattern)) refs.push(match[1] ?? "")
75 return [...new Set(refs.filter(Boolean).map(Number))].slice(0, 10)
76}
77
78export function githubChecks(rollup: string | null | undefined): CheckState {
79 if (rollup === "SUCCESS") return "passing"
80 if (rollup === "FAILURE" || rollup === "ERROR") return "failing"
81 if (rollup === "PENDING" || rollup === "EXPECTED") return "pending"
82 return "none"
83}
84
85export function azureChecks(statuses: readonly string[]): CheckState {
86 if (statuses.some((s) => s === "rejected" || s === "broken")) return "failing"
87 if (statuses.some((s) => s === "running" || s === "queued")) return "pending"
88 if (statuses.some((s) => s === "approved")) return "passing"
89 return "none"
90}
91
92export function azureReview(votes: readonly number[]): string {
93 if (votes.includes(-10)) return "rejected"
94 if (votes.includes(-5)) return "waiting for author"
95 if (votes.some((vote) => vote >= 5)) return "approved"
96 return ""
97}
98
99export const SUMMARY_SYSTEM = `You summarize a coding session for a side panel.
100Reply with JSON only, no prose and no code fence:
101{"goal": "<2-4 sentences: what the session is trying to achieve and where it stands now>",
102 "steps": [{"text": "<one short line>", "status": "done" | "current" | "todo"}]}
103Steps run in order, at most 12, and at most one is "current".`
104
105// The newest part of the conversation that fits the model's budget.
106export function transcriptOf(messages: readonly SessionMessage[]): string {
107 const lines = messages.map((m) => {
108 const tools = m.toolUses.map(
109 (use) => `[${use.tool}] ${JSON.stringify(use.input).slice(0, 200)}`,
110 )
111 return `${m.role}: ${[m.text.slice(0, 2000), ...tools].filter(Boolean).join("\n")}`
112 })
113 return lines.join("\n\n").slice(-60_000)
114}
115
116export function parseSummary(
117 text: string,
118): { goal: string; steps: Step[] } | null {
119 const start = text.indexOf("{")
120 const end = text.lastIndexOf("}")
121 if (start < 0 || end < start) return null
122 try {
123 const value = JSON.parse(text.slice(start, end + 1)) as {
124 goal?: unknown
125 steps?: unknown
126 }
127 if (typeof value.goal !== "string" || !Array.isArray(value.steps))
128 return null
129 const steps: Step[] = []
130 for (const step of value.steps as { text?: unknown; status?: unknown }[]) {
131 if (typeof step?.text !== "string") continue
132 const status =
133 step.status === "done" || step.status === "current"
134 ? step.status
135 : "todo"
136 steps.push({ text: step.text, status })
137 }
138 return { goal: value.goal, steps }
139 } catch {
140 return null
141 }
142}
143hooks/fetch.ts 328 lines1import type { Item, Pr, Work } from "../types"
2import {
3 azureChecks,
4 azureReview,
5 githubChecks,
6 itemRefs,
7 type Platform,
8} from "./discover"
9
10// Runs a command; register.tsx supplies it, since only that file may use $.
11export type Run = (
12 argv: string[],
13) => Promise<{ exitCode: number; stdout: string; stderr: string }>
14
15export async function git(run: Run, args: string[]): Promise<string> {
16 const { exitCode, stdout } = await run(["git", ...args])
17 return exitCode === 0 ? stdout.trim() : ""
18}
19
20function firstLine(text: string): string {
21 return text.trim().split("\n")[0] ?? ""
22}
23
24async function refsByBranch(
25 run: Run,
26 platform: Platform,
27 branches: readonly string[],
28) {
29 const base = await git(run, [
30 "symbolic-ref",
31 "--short",
32 "refs/remotes/origin/HEAD",
33 ])
34 const refs = new Map<string, number[]>()
35 for (const branch of branches) {
36 const log = base
37 ? await git(run, ["log", "--format=%B", `${base}..${branch}`])
38 : ""
39 refs.set(branch, itemRefs(platform, branch, log))
40 }
41 return refs
42}
43
44const PR_FIELDS = `number title url state isDraft reviewDecision headRefName
45 comments { totalCount } reviewThreads { totalCount }
46 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
47 closingIssuesReferences(first: 10) { nodes { number title url state } }`
48
49type GhIssue = { number: number; title: string; url: string; state: string }
50type GhPr = {
51 number: number
52 title: string
53 url: string
54 state: string
55 isDraft: boolean
56 reviewDecision: string | null
57 headRefName: string
58 comments: { totalCount: number }
59 reviewThreads: { totalCount: number }
60 commits: {
61 nodes: { commit: { statusCheckRollup: { state: string } | null } }[]
62 }
63 closingIssuesReferences: { nodes: GhIssue[] }
64}
65
66const ghItem = (issue: GhIssue): Item => ({
67 id: issue.number,
68 url: issue.url,
69 title: issue.title,
70 state: issue.state.toLowerCase(),
71})
72
73// One GraphQL call: each branch's newest PR, each PR the conversation linked,
74// and every item ref, as aliases. Branch names go in as variables, never into
75// the query text. gh exits 1 when an alias does not resolve yet still prints
76// the rest, so success is judged by data.repository.
77export async function fetchGitHub(
78 run: Run,
79 branches: readonly string[],
80 prNumbers: readonly number[],
81): Promise<Work> {
82 const refs = await refsByBranch(run, "github", branches)
83 const vars: string[] = []
84 const fields: string[] = []
85 const args: string[] = []
86 branches.forEach((branch, i) => {
87 vars.push(`, $b${i}: String!`)
88 args.push("-f", `b${i}=${branch}`)
89 fields.push(`b${i}: pullRequests(headRefName: $b${i}, first: 1, states: [OPEN, MERGED, CLOSED],
90 orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ...P } }`)
91 })
92 for (const n of prNumbers)
93 fields.push(`p${n}: pullRequest(number: ${n}) { ...P }`)
94 for (const n of new Set([...refs.values()].flat())) {
95 fields.push(
96 `i${n}: issueOrPullRequest(number: ${n}) { ... on Issue { number title url state } }`,
97 )
98 }
99 if (fields.length === 0) return { prs: [], items: [], fetchedAt: Date.now() }
100 const query = `query($owner: String!, $name: String!${vars.join("")}) {
101 repository(owner: $owner, name: $name) { ${fields.join("\n")} }
102 } fragment P on PullRequest { ${PR_FIELDS} }`
103 const out = await run([
104 "gh",
105 "api",
106 "graphql",
107 "-F",
108 "owner={owner}",
109 "-F",
110 "name={repo}",
111 ...args,
112 "-f",
113 `query=${query}`,
114 ])
115 let repo: Record<string, unknown> | undefined
116 try {
117 repo = (
118 JSON.parse(out.stdout) as {
119 data?: { repository?: Record<string, unknown> }
120 }
121 ).data?.repository
122 } catch {
123 repo = undefined
124 }
125 if (!repo)
126 return {
127 prs: [],
128 items: [],
129 fetchedAt: Date.now(),
130 error: `gh: ${firstLine(out.stderr) || "no data"}`,
131 }
132
133 const prs = new Map<number, Pr>()
134 const items = new Map<number, Item>()
135 const addPr = (pr: GhPr) => {
136 prs.set(pr.number, {
137 number: pr.number,
138 url: pr.url,
139 title: pr.title,
140 branch: pr.headRefName,
141 state:
142 pr.isDraft && pr.state === "OPEN" ? "draft" : pr.state.toLowerCase(),
143 review: (pr.reviewDecision ?? "").toLowerCase().replace(/_/g, " "),
144 checks: githubChecks(
145 pr.commits.nodes[0]?.commit.statusCheckRollup?.state,
146 ),
147 comments: pr.comments.totalCount + pr.reviewThreads.totalCount,
148 })
149 for (const issue of pr.closingIssuesReferences.nodes)
150 items.set(issue.number, ghItem(issue))
151 }
152 branches.forEach((branch, i) => {
153 const pr = (repo[`b${i}`] as { nodes: GhPr[] } | null)?.nodes[0]
154 if (pr) {
155 addPr(pr)
156 return
157 }
158 for (const n of refs.get(branch) ?? []) {
159 const issue = repo[`i${n}`] as GhIssue | null
160 if (issue?.number) items.set(issue.number, ghItem(issue))
161 }
162 })
163 for (const n of prNumbers) {
164 const pr = repo[`p${n}`] as GhPr | null
165 if (pr) addPr(pr)
166 }
167 return {
168 prs: [...prs.values()],
169 items: [...items.values()],
170 fetchedAt: Date.now(),
171 }
172}
173
174async function az(run: Run, args: string[]): Promise<unknown> {
175 const out = await run(["az", ...args, "--only-show-errors", "-o", "json"])
176 if (out.exitCode !== 0) return null
177 try {
178 return JSON.parse(out.stdout)
179 } catch {
180 return null
181 }
182}
183
184type AzPr = {
185 pullRequestId: number
186 title: string
187 status: string
188 isDraft: boolean
189 url: string
190 sourceRefName: string
191 reviewers?: { vote: number }[]
192 repository: { id: string; project: { id: string } }
193}
194type AzWorkItem = { id: number; url: string; fields: Record<string, string> }
195
196const AZ_STATE: Record<string, string> = {
197 active: "open",
198 completed: "merged",
199 abandoned: "closed",
200}
201
202const azItem = (w: AzWorkItem): Item => ({
203 id: w.id,
204 url: w.url.replace("/_apis/wit/workItems/", "/_workitems/edit/"),
205 title: w.fields["System.Title"] ?? "",
206 state: (w.fields["System.State"] ?? "").toLowerCase(),
207})
208
209// One az call per lookup, run in parallel: each branch's newest PR, each linked
210// PR, then each PR's work items, blocking policies (its checks) and threads.
211export async function fetchAzure(
212 run: Run,
213 branches: readonly string[],
214 prNumbers: readonly number[],
215): Promise<Work> {
216 const refs = await refsByBranch(run, "azure", branches)
217 const byBranch = await Promise.all(
218 branches.map((b) =>
219 az(run, [
220 "repos",
221 "pr",
222 "list",
223 "--source-branch",
224 b,
225 "--status",
226 "all",
227 "--top",
228 "1",
229 ]),
230 ),
231 )
232 const byNumber = await Promise.all(
233 prNumbers.map((n) => az(run, ["repos", "pr", "show", "--id", String(n)])),
234 )
235 if (
236 [...byBranch, ...byNumber].length > 0 &&
237 [...byBranch, ...byNumber].every((r) => r === null)
238 ) {
239 return {
240 prs: [],
241 items: [],
242 fetchedAt: Date.now(),
243 error: "az: every lookup failed (logged in?)",
244 }
245 }
246
247 const raw = new Map<number, AzPr>()
248 const refsWithoutPr: number[] = []
249 byBranch.forEach((list, i) => {
250 const pr = (list as AzPr[] | null)?.[0]
251 if (pr) raw.set(pr.pullRequestId, pr)
252 else refsWithoutPr.push(...(refs.get(branches[i] ?? "") ?? []))
253 })
254 for (const pr of byNumber as (AzPr | null)[])
255 if (pr) raw.set(pr.pullRequestId, pr)
256
257 const items = new Map<number, Item>()
258 const prs = await Promise.all(
259 [...raw.values()].map(async (pr): Promise<Pr> => {
260 const id = String(pr.pullRequestId)
261 const [workItems, policies, threads] = await Promise.all([
262 az(run, ["repos", "pr", "work-item", "list", "--id", id]),
263 az(run, ["repos", "pr", "policy", "list", "--id", id]),
264 az(run, [
265 "devops",
266 "invoke",
267 "--area",
268 "git",
269 "--resource",
270 "pullRequestThreads",
271 "--api-version",
272 "7.1",
273 "--route-parameters",
274 `project=${pr.repository.project.id}`,
275 `repositoryId=${pr.repository.id}`,
276 `pullRequestId=${id}`,
277 ]),
278 ])
279 for (const w of (workItems as AzWorkItem[] | null) ?? [])
280 items.set(w.id, azItem(w))
281 const blocking = (
282 (policies as
283 | { status: string; configuration: { isBlocking: boolean } }[]
284 | null) ?? []
285 )
286 .filter((p) => p.configuration.isBlocking)
287 .map((p) => p.status)
288 const comments = (
289 (
290 threads as {
291 value?: {
292 isDeleted: boolean
293 comments: { commentType: string }[]
294 }[]
295 } | null
296 )?.value ?? []
297 ).filter(
298 (t) => !t.isDeleted && t.comments[0]?.commentType === "text",
299 ).length
300 return {
301 number: pr.pullRequestId,
302 url: pr.url
303 .replace("/_apis/git/repositories/", "/_git/")
304 .replace("/pullRequests/", "/pullrequest/"),
305 title: pr.title,
306 branch: pr.sourceRefName.replace(/^refs\/heads\//, ""),
307 state:
308 pr.isDraft && pr.status === "active"
309 ? "draft"
310 : (AZ_STATE[pr.status] ?? pr.status),
311 review: azureReview((pr.reviewers ?? []).map((r) => r.vote)),
312 checks: azureChecks(blocking),
313 comments,
314 }
315 }),
316 )
317
318 const missing = [...new Set(refsWithoutPr)].filter((n) => !items.has(n))
319 if (missing.length > 0) {
320 const wiql = `SELECT [System.Id], [System.Title], [System.State] FROM WorkItems WHERE [System.Id] IN (${missing.join(", ")})`
321 for (const w of ((await az(run, ["boards", "query", "--wiql", wiql])) as
322 | AzWorkItem[]
323 | null) ?? [])
324 items.set(w.id, azItem(w))
325 }
326 return { prs, items: [...items.values()], fetchedAt: Date.now() }
327}
328types/index.d.ts 36 lines1export type CheckState = "passing" | "failing" | "pending" | "none"
2export type Pr = {
3 number: number
4 url: string
5 title: string
6 branch: string
7 state: string
8 review: string
9 checks: CheckState
10 comments: number
11}
12export type Item = { id: number; url: string; title: string; state: string }
13export type Work = {
14 prs: Pr[]
15 items: Item[]
16 fetchedAt: number
17 error?: string
18}
19export type Step = { text: string; status: "done" | "current" | "todo" }
20export type Summary =
21 | { goal: string; steps: Step[]; at: number }
22 | { error: string; at: number }
23
24declare module "claude-code" {
25 interface PluginState {
26 "work-items": {
27 branches: string[]
28 work: Work | null
29 isFetching: boolean
30 summary: Summary | null
31 isSummarizing: boolean
32 collapsed: number[]
33 }
34 }
35}
36