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($)