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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/shelltime-statusline/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
18 changes: 17 additions & 1 deletion plugins/shelltime-statusline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <session id> <pr url>...
```

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.
128 changes: 128 additions & 0 deletions plugins/shelltime-statusline/hooks/pullRequests.test.ts
Original file line number Diff line number Diff line change
@@ -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],
])
})
34 changes: 34 additions & 0 deletions plugins/shelltime-statusline/hooks/pullRequests.ts
Original file line number Diff line number Diff line change
@@ -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<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'))
}

export function ccPrArgs(sessionId: string, urls: readonly string[]): string[] {
return ['cc', 'pr', '--session-id', sessionId, ...urls]
}
46 changes: 46 additions & 0 deletions plugins/shelltime-statusline/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -48,6 +49,7 @@ let lastRemoteAt = Number.NEGATIVE_INFINITY
let login = ''
let loginToken = ''
const sentProjects = new Set<string>()
const sentPullRequests = new Set<string>()
const loggedErrors = new Set<string>()

function logOnce($: EngineInterface, what: string, err: unknown) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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($)
Expand Down
Loading