Skip to content
Closed
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,35 @@ agent sandbox variable list # list sandbox env variable names (values are
agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env/.json)
agent sandbox variable rm K # delete a variable

agent hooks install # install Claude Code Stop/SessionEnd hooks + enroll this repo
agent hooks enroll [owner/name] # opt a repo in to transcript sync (per-repo consent)
agent hooks unenroll [owner/name] # opt a repo out
agent hooks status # show installed hooks + enrolled repos
agent session sync # sync the local Claude Code transcript (hooks run this for you)
agent session handoff "finish the tests and open a PR" # hand this session off to a cloud agent

agent budget # current budget summary
agent usage # usage dashboard for the period
agent ping # check authenticated /v1 connectivity
```

## Local Claude Code session sync

`agent hooks install` writes `Stop` + `SessionEnd` hooks into
`~/.claude/settings.json`; each fires `agent session sync --hook`, which
uploads the session's transcript to your Ellipsis account so local sessions
appear alongside cloud ones. Consent is per-repo: the hook silently does
nothing outside repos you've enrolled with `agent hooks enroll`. Transcripts
are redacted client-side (token/key patterns never leave the machine
unredacted), gzipped, and synced after every turn; offline syncs are spooled
under `~/.config/ellipsis/spool/` and retried automatically.

`agent session handoff "<instructions>"` closes the loop: it captures your
dirty working tree as a WIP commit (without touching it), pushes it to
`refs/ellipsis/handoff/<id>`, syncs the transcript one last time, and starts a
cloud agent session that checks out your WIP commit and continues from your
instructions — chained to the local session so the history stays connected.

Most commands accept `--json` to print the raw API response. The CLI talks to
the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE_URL`
(or the legacy `ELLIPSIS_API_BASE`).
Expand Down
4 changes: 4 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Command } from 'commander'
import { registerLogin } from './commands/login'
import { registerMe } from './commands/me'
import { registerRun } from './commands/run'
import { registerSession } from './commands/session'
import { registerHooks } from './commands/hooks'
import { registerConfig } from './commands/config'
import { registerSandbox } from './commands/sandbox'
import { registerTemplate } from './commands/template'
Expand All @@ -19,6 +21,8 @@ program
registerLogin(program)
registerMe(program)
registerRun(program)
registerSession(program)
registerHooks(program)
registerConfig(program)
registerSandbox(program)
registerTemplate(program)
Expand Down
124 changes: 124 additions & 0 deletions src/commands/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// `agent hooks …` — install the Claude Code hooks that power laptop transcript
// sync, and manage per-repo enrollment (the consent gate `agent session sync`
// checks before uploading anything).

import type { Command } from 'commander'
import {
claudeSettingsPath,
installHooks,
installedHookEvents,
uninstallHooks,
} from '../lib/hooks'
import {
enrollRepo,
enrolledRepos,
isEnrolled,
repoForCwd,
unenrollRepo,
} from '../lib/enrollment'
import { runAction } from '../lib/output'

// Resolve the repo argument: explicit "owner/name", else the cwd's remote.
function resolveRepoArg(repo?: string): string {
if (repo) {
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
throw new Error(`"${repo}" is not an owner/name repo`)
}
return repo
}
const fromCwd = repoForCwd(process.cwd())
if (!fromCwd) {
throw new Error(
'not inside a git repo with a recognizable GitHub remote — pass the repo explicitly (owner/name)',
)
}
return fromCwd
}

export function registerHooks(program: Command): void {
const hooks = program
.command('hooks')
.description('Claude Code hooks for syncing local session transcripts to Ellipsis')

hooks
.command('install')
.description(
'Install the Stop + SessionEnd hooks in ~/.claude/settings.json (and enroll the current repo)',
)
.option('--no-enroll', 'install the hooks without enrolling the current repo')
.action(async (opts: { enroll?: boolean }) => {
await runAction(async () => {
const path = installHooks()
console.log(`installed Stop + SessionEnd sync hooks in ${path}`)
if (opts.enroll === false) return
const repo = repoForCwd(process.cwd())
if (!repo) {
console.log(
'no GitHub repo detected in the current directory — run `agent hooks enroll` inside each repo you want synced.',
)
return
}
if (isEnrolled(repo)) {
console.log(`repo ${repo} is already enrolled for transcript sync`)
return
}
enrollRepo(repo)
console.log(
`enrolled ${repo} for transcript sync — Claude Code sessions in this repo now sync to Ellipsis.`,
)
})
})

hooks
.command('uninstall')
.description('Remove the sync hooks from ~/.claude/settings.json (enrollment is kept)')
.action(async () => {
await runAction(async () => {
const path = uninstallHooks()
console.log(`removed sync hooks from ${path}`)
})
})

hooks
.command('status')
.description('Show installed sync hooks and enrolled repos')
.action(async () => {
await runAction(async () => {
const events = installedHookEvents()
console.log(
events.length > 0
? `hooks installed (${events.join(', ')}) in ${claudeSettingsPath()}`
: `no sync hooks installed (run \`agent hooks install\`)`,
)
const repos = enrolledRepos()
if (repos.length === 0) {
console.log('no repos enrolled for transcript sync')
} else {
console.log('enrolled repos:')
for (const repo of repos) console.log(` ${repo}`)
}
})
})

hooks
.command('enroll [repo]')
.description('Enroll a repo (default: the current one) for transcript sync')
.action(async (repo?: string) => {
await runAction(async () => {
const resolved = resolveRepoArg(repo)
enrollRepo(resolved)
console.log(`enrolled ${resolved} for transcript sync`)
})
})

hooks
.command('unenroll [repo]')
.description('Stop syncing a repo (default: the current one)')
.action(async (repo?: string) => {
await runAction(async () => {
const resolved = resolveRepoArg(repo)
unenrollRepo(resolved)
console.log(`unenrolled ${resolved} from transcript sync`)
})
})
}
Loading
Loading