From 36fb0bfe5845a9b5a9ec8217d82ad2695b0bea93 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:54:47 +0000 Subject: [PATCH] feat(statusline): link PRs from gh pr create to the session A classic.PostToolUse hook watches Bash calls that run `gh pr create`. It takes the PR URLs printed on stdout, including several from one chained command, and runs `shelltime cc pr --session-id ...`. The CLI hands them to the ShellTime daemon, which links them to the session on shelltime.xyz. The hook runs after the tool returns and in the background, so it never delays or changes the Bash result. It looks for ~/.shelltime/bin/shelltime first, then `shelltime` on PATH. Each URL is sent once per session; one the CLI could not take is tried again the next time it is printed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DVr1QVVUbfrjJSrsj9Nxc1 --- .claude-plugin/marketplace.json | 2 +- README.md | 4 +- .../.claude-plugin/plugin.json | 2 +- plugins/shelltime-statusline/README.md | 18 ++- .../hooks/pullRequests.test.ts | 128 ++++++++++++++++++ .../hooks/pullRequests.ts | 34 +++++ .../shelltime-statusline/hooks/register.tsx | 46 +++++++ 7 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 plugins/shelltime-statusline/hooks/pullRequests.test.ts create mode 100644 plugins/shelltime-statusline/hooks/pullRequests.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a446975..d147f91 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "shelltime-statusline", "source": "./plugins/shelltime-statusline", - "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app.", + "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app. Also links PRs opened with gh pr create to the session.", "category": "productivity" } ] diff --git a/README.md b/README.md index d538e36..c1f1ba1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A mod is a Claude Code plugin made of function hooks: a small TypeScript module | Mod | What it does | | --- | --- | -| [`shelltime-statusline`](plugins/shelltime-statusline) | The ShellTime statusline (git, model, session and daily cost, quota, agent time, context) above the prompt, in the terminal and the desktop app | +| [`shelltime-statusline`](plugins/shelltime-statusline) | The ShellTime statusline (git, model, session and daily cost, quota, agent time, context) above the prompt, in the terminal and the desktop app. Also links PRs opened with `gh pr create` to the session on shelltime.xyz | ## What you get @@ -28,6 +28,8 @@ Quota and context turn yellow from 50% and red from 80%. The costs, quota and ag Before you run `shelltime init`, it still shows git, model, session cost, quota and context. Daily cost and agent time show `-`, and nothing is sent to ShellTime. The line refreshes as the conversation moves on, not while the session is idle. +When Claude opens a pull request with `gh pr create`, the mod links that PR to the session on shelltime.xyz. This goes through the `shelltime` CLI and its daemon. + The [mod's README](plugins/shelltime-statusline/README.md) has every segment's colors and where each number comes from. ## Install diff --git a/plugins/shelltime-statusline/.claude-plugin/plugin.json b/plugins/shelltime-statusline/.claude-plugin/plugin.json index ce16f29..afc1bc0 100644 --- a/plugins/shelltime-statusline/.claude-plugin/plugin.json +++ b/plugins/shelltime-statusline/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "shelltime-statusline", "version": "0.1.2", - "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app.", + "description": "ShellTime statusline for Claude Code: git, model, session and daily cost, quota, agent time and context, in the terminal and the desktop app. Also links PRs opened with gh pr create to the session.", "author": { "name": "ShellTime", "url": "https://shelltime.xyz" diff --git a/plugins/shelltime-statusline/README.md b/plugins/shelltime-statusline/README.md index ec84eb7..a2aa994 100644 --- a/plugins/shelltime-statusline/README.md +++ b/plugins/shelltime-statusline/README.md @@ -37,7 +37,7 @@ Desktop sessions on the same machine load the same installed plugins. There is nothing to configure in the mod. It reads the ShellTime CLI's own config file, `~/.shelltime/config.yaml` (or `.yml` / `.toml`, with `config.local.*` merged over it), in the same order the CLI does. Run `shelltime init` once and the mod picks up your token. Changes to the file are picked up on the next refresh. -The mod doesn't need the `shelltime` binary or its daemon. Without a token it still shows git, model, session cost, quota and context. Daily cost and agent time show `-`, and nothing is sent to ShellTime. +The statusline doesn't need the `shelltime` binary or its daemon. Without a token it still shows git, model, session cost, quota and context. Daily cost and agent time show `-`, and nothing is sent to ShellTime. Linking pull requests (below) is the one feature that uses the CLI. ## Where the numbers come from @@ -48,9 +48,25 @@ The mod doesn't need the `shelltime` binary or its daemon. Without a token it st | quota | the daemon calls Anthropic's usage API with the OAuth token from the Keychain | the rate limits Claude Code already read from its last API response. No Keychain access, works on any OS | | daily cost, agent time | the daemon queries ShellTime's API | the same GraphQL query, at most once every 15 s | | session → project mapping | sent to ShellTime's API | the same request, once per session and directory | +| session → pull requests | | `shelltime cc pr`, after `gh pr create` prints a PR URL | Like the native statusline, it refreshes as the conversation changes: when you send a prompt, after each tool call, and when a turn ends. It doesn't poll while the session is idle. +## Pull request links + +When a Bash call runs `gh pr create`, the mod reads the PR URLs that `gh` printed on stdout. Commands that chain several `gh pr create` calls are covered too. It then runs: + +```sh +shelltime cc pr --session-id ... +``` + +The CLI hands the URLs to the ShellTime daemon, which sends them to ShellTime, and they show up on the session. Without a daemon the CLI sends them itself. + +- The mod looks for `~/.shelltime/bin/shelltime` first, then `shelltime` on `PATH`. +- Each URL is sent once per session. If the CLI can't be started, the next `gh pr create` that prints the URL tries again, and the failure goes to Claude Code's debug log. +- If the CLI can't be found, or is too old to have `cc pr`, nothing is linked. Errors from the CLI itself (not logged in, server unreachable) go to `~/.shelltime/log.log`. +- This runs from a `PostToolUse` hook, in the background, after the tool returns. It never delays or changes the Bash result Claude sees. + ## Terminal If `~/.claude/settings.json` also has a `statusLine` running `shelltime cc statusline`, the terminal shows both lines. Keep both, or remove one. diff --git a/plugins/shelltime-statusline/hooks/pullRequests.test.ts b/plugins/shelltime-statusline/hooks/pullRequests.test.ts new file mode 100644 index 0000000..6138492 --- /dev/null +++ b/plugins/shelltime-statusline/hooks/pullRequests.test.ts @@ -0,0 +1,128 @@ +import { expect, mock, test } from 'claude-code/testing' +import type { Engine } from 'claude-code/testing' +import type { On } from 'claude-code' + +import { createdPullRequestUrls, extractPullRequestUrls, isPrCreateCommand } from './pullRequests' + +const CLI = '/home/me/.shelltime/bin/shelltime' +const PR_1 = 'https://github.com/shelltime/cli/pull/318' +const PR_2 = 'https://github.com/shelltime/web/pull/77' + +function ran(exitCode = 0, stderr = '') { + return { value: { exitCode, stdout: '', stderr, isStdoutTruncated: false, isStderrTruncated: false } } +} + +// Beneath the plugin: HOME, a clock, and no settings hooks to run. +function base(on: On) { + mock.env(on, { HOME: '/home/me' }) + on('classic.PostToolUse', () => ({})) + return mock.clock(on, { now: Date.UTC(2026, 9, 8, 12) }) +} + +// The world beneath the plugin, with every command it runs recorded; `missing` +// names executables that cannot start. +function world(on: On, missing: string[] = []) { + const clock = base(on) + const runs: string[][] = [] + on('process.run', ($, e) => { + runs.push([...e.argv]) + if (missing.includes(e.argv[0] ?? '')) throw new Error(`spawn ${e.argv[0]} ENOENT`) + return ran() + }) + return { clock, runs } +} + +function bash($: Engine, command: string, stdout: string, session_id = 'sess-1') { + return $.classic.PostToolUse({ + session_id, + tool_name: 'Bash', + tool_input: { command, description: 'Open a PR' }, + tool_response: { stdout, stderr: '', interrupted: false }, + tool_use_id: 'toolu_1', + }) +} + +test('spots gh pr create and the PR URLs it printed', () => { + expect(isPrCreateCommand('gh pr create --title x --body y')).toBe(true) + expect(isPrCreateCommand('cd ../web && gh pr create --fill')).toBe(true) + expect(isPrCreateCommand('gh pr view 1')).toBe(false) + expect(isPrCreateCommand('gh pr list')).toBe(false) + + expect( + extractPullRequestUrls(`Creating pull request for x into main\n\n${PR_1}\n${PR_2}\n${PR_1}\n`), + ).toEqual([PR_1, PR_2]) + expect(extractPullRequestUrls('https://ghe.example.com/team/app/pull/42')).toEqual([ + 'https://ghe.example.com/team/app/pull/42', + ]) + expect(extractPullRequestUrls('https://github.com/o/r/issues/1')).toEqual([]) + + expect(createdPullRequestUrls('Bash', { command: 'gh pr create' }, { stdout: `${PR_1}\n` })).toEqual([PR_1]) + expect(createdPullRequestUrls('Bash', { command: 'gh pr view 1' }, { stdout: `${PR_1}\n` })).toEqual([]) + expect(createdPullRequestUrls('Read', { command: 'gh pr create' }, { stdout: `${PR_1}\n` })).toEqual([]) + expect(createdPullRequestUrls('Bash', null, undefined)).toEqual([]) +}) + +test('links every PR a gh pr create printed to the session, once', async ($, on) => { + const { clock, runs } = world(on) + + await bash($, `gh pr create --fill && cd ../web && gh pr create --fill`, `${PR_1}\n${PR_2}\n`) + await clock.settle() + expect(runs).toEqual([[CLI, 'cc', 'pr', '--session-id', 'sess-1', PR_1, PR_2]]) + + // The same PR printed again (say, by a retry) is not sent twice. + await bash($, 'gh pr create --fill', `${PR_1}\n`) + await clock.settle() + expect(runs).toHaveLength(1) + + // Another session that opens the same PR still gets it. + await bash($, 'gh pr create --fill', `${PR_1}\n`, 'sess-2') + await clock.settle() + expect(runs[1]).toEqual([CLI, 'cc', 'pr', '--session-id', 'sess-2', PR_1]) +}) + +test('leaves other commands and tools alone', async ($, on) => { + const { clock, runs } = world(on) + + await bash($, `gh pr view ${PR_1}`, `${PR_1}\n`) + await bash($, 'gh pr create --fill', 'no url here\n') + await $.classic.PostToolUse({ + session_id: 'sess-1', + tool_name: 'Read', + tool_input: { file_path: '/work/notes.md' }, + tool_response: { stdout: `gh pr create ${PR_1}` }, + tool_use_id: 'toolu_2', + }) + await clock.settle() + + expect(runs).toEqual([]) +}) + +test('falls back to shelltime on PATH when ~/.shelltime/bin has none', async ($, on) => { + const { clock, runs } = world(on, [CLI]) + + await bash($, 'gh pr create --fill', `${PR_1}\n`) + await clock.settle() + expect(runs).toEqual([ + [CLI, 'cc', 'pr', '--session-id', 'sess-1', PR_1], + ['shelltime', 'cc', 'pr', '--session-id', 'sess-1', PR_1], + ]) +}) + +test('a PR the CLI could not link is tried again next time', async ($, on) => { + const clock = base(on) + const runs: string[][] = [] + on('process.run', ($, e) => { + runs.push([...e.argv]) + return runs.length === 1 ? ran(1, 'not logged in') : ran() + }) + + await bash($, 'gh pr create --fill', `${PR_1}\n`) + await clock.settle() + await bash($, 'gh pr create --fill', `${PR_1}\n`) + await clock.settle() + + expect(runs).toEqual([ + [CLI, 'cc', 'pr', '--session-id', 'sess-1', PR_1], + [CLI, 'cc', 'pr', '--session-id', 'sess-1', PR_1], + ]) +}) diff --git a/plugins/shelltime-statusline/hooks/pullRequests.ts b/plugins/shelltime-statusline/hooks/pullRequests.ts new file mode 100644 index 0000000..0fdd257 --- /dev/null +++ b/plugins/shelltime-statusline/hooks/pullRequests.ts @@ -0,0 +1,34 @@ +// Pull requests a Bash call opened with `gh pr create`, linked to the session +// through `shelltime cc pr` (shelltime/cli commands/cc_pr.go), which hands them +// to the daemon. + +const PR_CREATE = /\bgh\s+pr\s+create\b/ +const PR_URL = /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/g + +export function isPrCreateCommand(command: string): boolean { + return PR_CREATE.test(command) +} + +// `gh pr create` prints the new PR's URL on stdout; a command chaining several +// prints one each. Distinct URLs, in the order they were printed. +export function extractPullRequestUrls(stdout: string): string[] { + return [...new Set(stdout.match(PR_URL) ?? [])] +} + +function stringField(value: unknown, key: string): string { + if (typeof value !== 'object' || value === null) return '' + const field = (value as Record)[key] + return typeof field === 'string' ? field : '' +} + +// The URLs a PostToolUse event's Bash call printed when it ran `gh pr create`; +// none for any other tool or command. stdout only: `gh` reports an existing PR +// for the branch on stderr, and that one wasn't opened here. +export function createdPullRequestUrls(toolName: string, toolInput: unknown, toolResponse: unknown): string[] { + if (toolName !== 'Bash' || !isPrCreateCommand(stringField(toolInput, 'command'))) return [] + return extractPullRequestUrls(stringField(toolResponse, 'stdout')) +} + +export function ccPrArgs(sessionId: string, urls: readonly string[]): string[] { + return ['cc', 'pr', '--session-id', sessionId, ...urls] +} diff --git a/plugins/shelltime-statusline/hooks/register.tsx b/plugins/shelltime-statusline/hooks/register.tsx index 5c47eef..19e4025 100644 --- a/plugins/shelltime-statusline/hooks/register.tsx +++ b/plugins/shelltime-statusline/hooks/register.tsx @@ -13,6 +13,7 @@ import { import type { ApiRequest, ApiResponse } from './api' import { BASE_FILES, LOCAL_FILES, formatOf, mergeConfig, parseShellTimeConfig, resolveConfig } from './config' import type { ShellTimeConfig } from './config' +import { ccPrArgs, createdPullRequestUrls } from './pullRequests' import { buildSegments, displayModelName } from './segments' import { StatusRow } from './ui/desktop' import { StatusLine } from './ui/terminal' @@ -48,6 +49,7 @@ let lastRemoteAt = Number.NEGATIVE_INFINITY let login = '' let loginToken = '' const sentProjects = new Set() +const sentPullRequests = new Set() const loggedErrors = new Set() function logOnce($: EngineInterface, what: string, err: unknown) { @@ -117,6 +119,43 @@ async function sendSessionProject($: EngineInterface, config: ShellTimeConfig, s } } +// The CLI as install.bash lays it out, then whatever PATH has: a desktop host's +// PATH often lacks ~/.shelltime/bin. +async function runShelltime($: EngineInterface, args: readonly string[]) { + const home = await $.env.get('HOME') + const bins = home === undefined || home === '' ? ['shelltime'] : [`${home}/.shelltime/bin/shelltime`, 'shelltime'] + let notFound: unknown + for (const bin of bins) { + let ran + try { + ran = await $.process.run([bin, ...args], { timeoutMs: 10_000 }) + } catch (err) { + // not there (or hung): try the next one + notFound = err + continue + } + if (ran.exitCode !== 0) { + throw new Error(`shelltime exited with ${ran.exitCode}: ${ran.stderr.trim()}`) + } + return + } + throw notFound +} + +// Links PRs opened by `gh pr create` to the session: `shelltime cc pr` hands +// them to the daemon, which sends them to ShellTime. +async function linkPullRequests($: EngineInterface, sessionId: string, urls: readonly string[]) { + const fresh = urls.filter(url => !sentPullRequests.has(`${sessionId}\n${url}`)) + if (sessionId === '' || fresh.length === 0) return + for (const url of fresh) sentPullRequests.add(`${sessionId}\n${url}`) + try { + await runShelltime($, ccPrArgs(sessionId, fresh)) + } catch (err) { + for (const url of fresh) sentPullRequests.delete(`${sessionId}\n${url}`) + logOnce($, 'pull-request link', err) + } +} + // Daily cost, agent time and login from ShellTime's API. async function refreshRemote($: EngineInterface, sessionId: string, cwd: string) { if (isRemoteRunning) return @@ -230,6 +269,13 @@ export const register: Register = on => { return result }) + on('classic.PostToolUse', async ($, e, next) => { + const result = await next(e) + // In the background: the hook never holds up or changes the tool's result. + void linkPullRequests($, e.session_id, createdPullRequestUrls(e.tool_name, e.tool_input, e.tool_response)) + return result + }) + on('turn.complete', async ($, e, next) => { const result = await next(e) schedule($)