Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/shelltime-statusline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ The CLI hands the URLs to the ShellTime daemon, which sends them to ShellTime, a
- 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.
- This runs from a `tool.call` hook on Bash, in the background, after the tool returns. It never delays or changes the Bash result Claude sees. Only a call that succeeded counts.
- It is not a `PostToolUse` hook because on Team and Enterprise plans Claude Code's built-in `cc-plugin-sec-default` runs before every installed plugin and sends `PostToolUse` (every `classic.*` event) on past them, so such a hook never runs there.

### Session cost comment

Expand Down
100 changes: 60 additions & 40 deletions plugins/shelltime-statusline/hooks/pullRequests.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, mock, test } from 'claude-code/testing'
import type { Engine } from 'claude-code/testing'
import type { On } from 'claude-code'
import type { On, ToolCallResult } from 'claude-code'

import { createdPullRequestUrls, extractPullRequestUrls, isPrCreateCommand } from './pullRequests'

Expand All @@ -12,34 +12,38 @@ 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.
// What the next Bash call answers, and the session it runs in.
type Shell = { sessionId: string; answer: ToolCallResult }

// Beneath the plugin: HOME, a clock, the session, and a Bash that answers
// whatever the test last set.
function base(on: On) {
mock.env(on, { HOME: '/home/me' })
on('classic.PostToolUse', () => ({}))
return mock.clock(on, { now: Date.UTC(2026, 9, 8, 12) })
const shell: Shell = { sessionId: 'sess-1', answer: { result: {} } }
on('session.id', () => ({ value: shell.sessionId }))
on('tool.call', () => shell.answer)
return { clock: mock.clock(on, { now: Date.UTC(2026, 9, 8, 12) }), shell }
}

// The world beneath the plugin, with every command it runs recorded; `missing`
// names executables that cannot start.
// The world beneath the plugin, with every `shelltime` command it runs
// recorded (the statusline's own `git` refresh is answered and left out);
// `missing` names executables that cannot start.
function world(on: On, missing: string[] = []) {
const clock = base(on)
const { clock, shell } = base(on)
const runs: string[][] = []
on('process.run', ($, e) => {
if (e.argv[0] === 'git') return ran(128)
runs.push([...e.argv])
if (missing.includes(e.argv[0] ?? '')) throw new Error(`spawn ${e.argv[0]} ENOENT`)
return ran()
})
return { clock, runs }
return { clock, shell, 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',
})
function bash($: Engine, shell: Shell, command: string, stdout: string, sessionId = 'sess-1') {
shell.sessionId = sessionId
shell.answer = { result: { stdout, stderr: '', interrupted: false } }
return $.tool.call({ tool: 'Bash', command, description: 'Open a PR' })
}

test('spots gh pr create and the PR URLs it printed', () => {
Expand All @@ -56,51 +60,66 @@ test('spots gh pr create and the PR URLs it printed', () => {
])
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([])
expect(createdPullRequestUrls('gh pr create', `${PR_1}\n`)).toEqual([PR_1])
expect(createdPullRequestUrls('gh pr view 1', `${PR_1}\n`)).toEqual([])
expect(createdPullRequestUrls('gh pr create', '')).toEqual([])
})

test('links every PR a gh pr create printed to the session, once', async ($, on) => {
const { clock, runs } = world(on)
const { clock, shell, runs } = world(on)

await bash($, `gh pr create --fill && cd ../web && gh pr create --fill`, `${PR_1}\n${PR_2}\n`)
await bash($, shell, `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 bash($, shell, '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 bash($, shell, '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('the Bash result reaches the model unchanged', async ($, on) => {
const { clock, shell } = world(on)

const result = await bash($, shell, 'gh pr create --fill', `${PR_1}\n`)
await clock.settle()

expect(result).toEqual({ result: { stdout: `${PR_1}\n`, stderr: '', interrupted: false } })
})

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',
})
const { clock, shell, runs } = world(on)

await bash($, shell, `gh pr view ${PR_1}`, `${PR_1}\n`)
await bash($, shell, 'gh pr create --fill', 'no url here\n')
shell.answer = { result: { stdout: `gh pr create ${PR_1}` } }
await $.tool.call({ tool: 'Read', file_path: '/work/notes.md' })
await clock.settle()

expect(runs).toEqual([])
})

test('a gh pr create that failed or was refused links nothing', async ($, on) => {
const { clock, shell, runs } = world(on)

shell.answer = { isError: true, result: `${PR_1}\nExit code 1`, text: `${PR_1}\nExit code 1` }
await $.tool.call({ tool: 'Bash', command: 'gh pr create --fill && false', description: 'Open a PR' })
shell.answer = { deny: 'not here' }
await $.tool.call({ tool: 'Bash', command: 'gh pr create --fill', description: 'Open a PR' })
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])
const { clock, shell, runs } = world(on, [CLI])

await bash($, 'gh pr create --fill', `${PR_1}\n`)
await bash($, shell, 'gh pr create --fill', `${PR_1}\n`)
await clock.settle()
expect(runs).toEqual([
[CLI, 'cc', 'pr', '--session-id', 'sess-1', PR_1],
Expand All @@ -109,16 +128,17 @@ test('falls back to shelltime on PATH when ~/.shelltime/bin has none', async ($,
})

test('a PR the CLI could not link is tried again next time', async ($, on) => {
const clock = base(on)
const { clock, shell } = base(on)
const runs: string[][] = []
on('process.run', ($, e) => {
if (e.argv[0] === 'git') return ran(128)
runs.push([...e.argv])
return runs.length === 1 ? ran(1, 'not logged in') : ran()
})

await bash($, 'gh pr create --fill', `${PR_1}\n`)
await bash($, shell, 'gh pr create --fill', `${PR_1}\n`)
await clock.settle()
await bash($, 'gh pr create --fill', `${PR_1}\n`)
await bash($, shell, 'gh pr create --fill', `${PR_1}\n`)
await clock.settle()

expect(runs).toEqual([
Expand Down
18 changes: 6 additions & 12 deletions plugins/shelltime-statusline/hooks/pullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,12 @@ 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<string, unknown>)[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'))
// The URLs a Bash call printed when it ran `gh pr create`; none for any other
// command. stdout only: `gh` reports an existing PR for the branch on stderr,
// and that one wasn't opened here.
export function createdPullRequestUrls(command: string, stdout: string): string[] {
if (!isPrCreateCommand(command)) return []
return extractPullRequestUrls(stdout)
}

export function ccPrArgs(sessionId: string, urls: readonly string[]): string[] {
Expand Down
20 changes: 16 additions & 4 deletions plugins/shelltime-statusline/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,23 @@ export const register: Register = on => {
return result
})

on('classic.PostToolUse', async ($, e, next) => {
const result = await next(e)
// tool.call, not classic.PostToolUse: on Team and Enterprise plans the
// built-in cc-plugin-sec-default sits above every installed plugin and sends
// each classic.* event past them, so a classic hook here never runs.
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
const ran = await next(e)
// Only a call that succeeded, as PostToolUse saw it: a failed or refused
// one opened nothing to link.
const urls =
ran.deny === undefined && ran.isError !== true ? createdPullRequestUrls(e.command, ran.result.stdout) : []
// 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
if (urls.length > 0) {
void $.session.id().then(
sessionId => linkPullRequests($, sessionId, urls),
err => logOnce($, 'pull-request link', err),
)
}
return ran
})

on('session.end', async ($, e, next) => {
Expand Down
Loading