diff --git a/plugins/shelltime-statusline/README.md b/plugins/shelltime-statusline/README.md index 863e3c6..b5274f5 100644 --- a/plugins/shelltime-statusline/README.md +++ b/plugins/shelltime-statusline/README.md @@ -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 diff --git a/plugins/shelltime-statusline/hooks/pullRequests.test.ts b/plugins/shelltime-statusline/hooks/pullRequests.test.ts index 6138492..9bfb4b6 100644 --- a/plugins/shelltime-statusline/hooks/pullRequests.test.ts +++ b/plugins/shelltime-statusline/hooks/pullRequests.test.ts @@ -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' @@ -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', () => { @@ -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], @@ -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([ diff --git a/plugins/shelltime-statusline/hooks/pullRequests.ts b/plugins/shelltime-statusline/hooks/pullRequests.ts index 0fdd257..c536f65 100644 --- a/plugins/shelltime-statusline/hooks/pullRequests.ts +++ b/plugins/shelltime-statusline/hooks/pullRequests.ts @@ -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)[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[] { diff --git a/plugins/shelltime-statusline/hooks/register.tsx b/plugins/shelltime-statusline/hooks/register.tsx index 778c6c1..dbd8f3f 100644 --- a/plugins/shelltime-statusline/hooks/register.tsx +++ b/plugins/shelltime-statusline/hooks/register.tsx @@ -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) => {