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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@v6.4.0
with:
go-version: "1.25.x"
Expand All @@ -39,6 +41,8 @@ jobs:
goarch: amd64
steps:
- uses: actions/checkout@v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@v6.4.0
with:
go-version: "1.25.x"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
goarch: amd64
steps:
- uses: actions/checkout@v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@v6.4.0
with:
go-version: "1.25.x"
Expand Down Expand Up @@ -65,6 +67,8 @@ jobs:
needs: build
steps:
- uses: actions/checkout@v7.0.0
with:
persist-credentials: false
- uses: actions/download-artifact@v8.0.1
with:
path: dist
Expand Down
47 changes: 34 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ showagent # open the interactive picker
showagent list # plain table of every session
showagent list --json # the same, machine-readable
showagent resume latest # reopen the most recent session, any agent
showagent convert latest --to claude --dry-run
# preview exactly what a hand-off would carry/drop
showagent info latest # exact resume command + storage location
showagent update # install the latest GitHub release
showagent --help # full CLI help
```

Expand All @@ -97,7 +101,7 @@ showagent --help # full CLI help
| `space` | Collapse or expand the selected workspace group |
| `o` | Cycle the convert target for the selected session |
| `t` | Cycle the convert scope: all turns, or latest 200/100/50/20/10 |
| `x` | Convert to the target agent and select the new session |
| `x` | Preview convert; press `x` again to write and select the new session |
| `n` | Branch: create a full local copy of the session |
| `y` | Toggle yolo resume (skip the agent's permission prompts) |
| `C` | Compound: resume with a learnings-capture prompt (see below) |
Expand Down Expand Up @@ -126,9 +130,20 @@ are a stable contract:
```

`showagent resume <id|latest> [--yolo]` resumes without the picker, so a shell
alias can reopen your last session in one keystroke. Exit codes: `0` success,
`1` error (including "no sessions found"), `2` usage. When stdout is not a
terminal, plain `showagent` prints the `list` table, so pipes just work.
alias can reopen your last session in one keystroke.

`showagent convert <id|latest> --to <provider> --dry-run` prints the hand-off
before writing anything: source session, target provider, workspace, scope,
transferable turn count, last user ask, and the agent-specific state that will
be dropped. Remove `--dry-run` to write the converted session, then showagent
prints the resume recipe for the new row.

`showagent info <id|latest> [--yolo]` prints the exact resume command,
working directory, and storage location for a session.

Exit codes: `0` success, `1` error (including "no sessions found"), `2` usage.
When stdout is not a terminal, plain `showagent` prints the `list` table, so
pipes just work.

## How it compares

Expand Down Expand Up @@ -162,10 +177,12 @@ only installs what is missing.
## FAQ

**Is my session data sent anywhere?**
No. showagent is fully local: it reads session files where the agents left
them and makes no network calls. There is no server, no telemetry, and no
account. Message previews additionally redact password-like strings and
API keys before rendering (covered by tests in
No session content leaves your machine. showagent reads session files where the
agents left them; there is no server, no telemetry, and no account. The only
network path is the optional release updater: `showagent update`, and the
startup "update available?" check for release builds (disable with
`SHOWAGENT_NO_UPDATE_CHECK=1`). Message previews additionally redact
password-like strings and API keys before rendering (covered by tests in
[`internal/session/session_test.go`](internal/session/session_test.go)).
Release archives ship with a `SHA256SUMS` file, and releases after v0.7.0
also carry GitHub build provenance — verify with
Expand All @@ -176,11 +193,15 @@ Conversion extracts the user and assistant turns from the source transcript
and writes a brand-new session in the target agent's native format (for
OpenCode, via `opencode import`), so the target's own resume command picks it
up. The original session is never modified, and files are written atomically —
a crash cannot leave a half-written session in another tool's store. It
intentionally does **not** copy tool-call internals, approval history,
encrypted reasoning blobs, or provider attachments: those are private to the
source agent and would not replay correctly anyway. `t` trims the scope to the
latest N turns before converting.
a crash cannot leave a half-written session in another tool's store.

Trust is explicit: in the TUI, the first `x` shows the hand-off preview and the
second `x` writes it. In scripts, use `showagent convert ... --dry-run` for
the same preview. Conversion intentionally does **not** copy tool-call
internals, approval history, encrypted reasoning blobs, or provider
attachments: those are private to the source agent and would not replay
correctly anyway. `t` / `--scope` trims the scope to the latest N turns before
converting.

**What does delete actually do?**
Codex sessions are deleted through `codex delete --force`; OpenCode through
Expand Down
192 changes: 188 additions & 4 deletions cmd/showagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"io"
"os"
"runtime/debug"
"strconv"
"strings"
"time"

"github.com/charmbracelet/x/term"
Expand All @@ -17,7 +19,7 @@ import (
// version is stamped by the release build via -ldflags "-X main.version=...".
var version = "dev"

const usageLine = "usage: showagent [list [--json] | resume <id|latest> [--yolo] | setup]"
const usageLine = "usage: showagent [list [--json] | resume <id|latest> [--yolo] | convert <id|latest> --to <provider> [--dry-run] | info <id|latest> | update | setup]"

func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
Expand All @@ -41,6 +43,12 @@ func run(args []string, stdout, stderr io.Writer) int {
return runList(args[1:], stdout, stderr)
case "resume":
return runResume(args[1:], stderr)
case "convert":
return runConvert(args[1:], stdout, stderr)
case "info":
return runInfo(args[1:], stdout, stderr)
case "update":
return runUpdate(args[1:], stdout, stderr)
case "setup":
if len(args) != 1 {
return usageError(stderr, fmt.Sprintf("setup takes no arguments, got %q", args[1]))
Expand All @@ -63,6 +71,10 @@ func runDefault(stdout, stderr io.Writer) int {
return 0
}

if code, updated := maybePromptForUpdate(os.Stdin, stderr); updated {
return code
}

selection, err := tui.Run()
if err != nil {
_, _ = fmt.Fprintf(stderr, "showagent: %v\n", err)
Expand Down Expand Up @@ -152,7 +164,7 @@ func printNoSessions(stderr io.Writer) int {
}
_, _ = fmt.Fprintln(stderr, line)
}
_, _ = fmt.Fprintln(stderr, "start a conversation with codex or claude, then run showagent again")
_, _ = fmt.Fprintln(stderr, "start a conversation with a supported agent (codex, claude, gemini, opencode, jcode), then run showagent again")
return 1
}

Expand Down Expand Up @@ -185,6 +197,109 @@ func runResume(args []string, stderr io.Writer) int {
return 0
}

func runConvert(args []string, stdout, stderr io.Writer) int {
id := ""
targetName := ""
dryRun := false
handoff := session.HandoffOptions{}
resumeOptions := session.ResumeOptions{}

for index := 0; index < len(args); index++ {
arg := args[index]
switch arg {
case "--to":
index++
if index >= len(args) {
return usageError(stderr, "convert --to needs a provider")
}
targetName = args[index]
case "--dry-run":
dryRun = true
case "--scope":
index++
if index >= len(args) {
return usageError(stderr, "convert --scope needs a value")
}
parsed, err := parseHandoffScope(args[index])
if err != nil {
return usageError(stderr, err.Error())
}
handoff = parsed
case "--yolo":
resumeOptions.Dangerous = true
default:
if strings.HasPrefix(arg, "-") {
return usageError(stderr, fmt.Sprintf("unknown convert argument %q", arg))
}
if id != "" {
return usageError(stderr, fmt.Sprintf("unexpected convert argument %q", arg))
}
id = arg
}
}
if id == "" {
return usageError(stderr, "convert needs a session id or 'latest'")
}
if targetName == "" {
return usageError(stderr, "convert needs --to <provider>")
}
target, err := session.ParseProvider(targetName)
if err != nil {
return usageError(stderr, err.Error())
}

row, err := resolveSession(session.Discover(), id)
if err != nil {
_, _ = fmt.Fprintf(stderr, "showagent: %v\n", err)
return 1
}
preview, err := session.PreviewConversion(row, target, handoff)
if err != nil {
_, _ = fmt.Fprintf(stderr, "showagent: %v\n", err)
return 1
}
if dryRun {
printConversionPreview(stdout, preview)
return 0
}

converted, err := session.Convert(row, target, handoff)
if err != nil {
_, _ = fmt.Fprintf(stderr, "showagent: %v\n", err)
return 1
}
_, _ = fmt.Fprintf(stdout, "converted %s %s -> %s %s\n\n", row.Provider, row.ID, converted.Provider, converted.ID)
printResumeRecipe(stdout, session.RecipeFor(converted, resumeOptions))
return 0
}

func runInfo(args []string, stdout, stderr io.Writer) int {
id := ""
options := session.ResumeOptions{}
for _, arg := range args {
switch {
case arg == "--yolo":
options.Dangerous = true
case strings.HasPrefix(arg, "-"):
return usageError(stderr, fmt.Sprintf("unknown info argument %q", arg))
case id == "":
id = arg
default:
return usageError(stderr, fmt.Sprintf("unexpected info argument %q", arg))
}
}
if id == "" {
return usageError(stderr, "info needs a session id or 'latest'")
}
row, err := resolveSession(session.Discover(), id)
if err != nil {
_, _ = fmt.Fprintf(stderr, "showagent: %v\n", err)
return 1
}
printResumeRecipe(stdout, session.RecipeFor(row, options))
return 0
}

// resolveSession maps a user-supplied id (or the literal "latest") to a
// discovered session row. Rows are expected newest-first, as returned by
// session.Discover.
Expand Down Expand Up @@ -219,6 +334,67 @@ func runSetup(stdout, stderr io.Writer) int {
return 0
}

func parseHandoffScope(value string) (session.HandoffOptions, error) {
value = strings.TrimSpace(value)
if value == "" || value == "all" {
return session.HandoffOptions{}, nil
}
switch {
case strings.HasPrefix(value, "last:"):
value = strings.TrimPrefix(value, "last:")
case strings.HasPrefix(value, "last-"):
value = strings.TrimPrefix(value, "last-")
default:
return session.HandoffOptions{}, fmt.Errorf("scope must be 'all' or 'last:<turns>'")
}
turns, err := strconv.Atoi(value)
if err != nil || turns <= 0 {
return session.HandoffOptions{}, fmt.Errorf("scope must be 'all' or 'last:<turns>'")
}
return session.HandoffOptions{MaxTurns: turns}, nil
}

func printConversionPreview(w io.Writer, preview session.ConversionPreview) {
_, _ = fmt.Fprintf(w, "conversion preview\n")
_, _ = fmt.Fprintf(w, " source: %s %s\n", preview.SourceProvider, preview.SourceID)
_, _ = fmt.Fprintf(w, " read from: %s\n", preview.SourceLocation)
_, _ = fmt.Fprintf(w, " target: %s\n", preview.TargetProvider)
_, _ = fmt.Fprintf(w, " workspace: %s\n", preview.Workspace)
_, _ = fmt.Fprintf(w, " scope: %s (%d transferable turns)\n", preview.Scope, preview.TransferTurns)
if preview.LastUser != "" {
_, _ = fmt.Fprintf(w, " last user: %s\n", preview.LastUser)
}
_, _ = fmt.Fprintln(w, " dropped:")
for _, dropped := range preview.Dropped {
_, _ = fmt.Fprintf(w, " - %s\n", dropped)
}
if len(preview.Warnings) > 0 {
_, _ = fmt.Fprintln(w, " warnings:")
for _, warning := range preview.Warnings {
_, _ = fmt.Fprintf(w, " - %s\n", warning)
}
}
}

func printResumeRecipe(w io.Writer, recipe session.ResumeRecipe) {
_, _ = fmt.Fprintf(w, "resume recipe\n")
_, _ = fmt.Fprintf(w, " provider: %s\n", recipe.Provider)
_, _ = fmt.Fprintf(w, " session: %s\n", recipe.ID)
_, _ = fmt.Fprintf(w, " command: %s\n", recipe.CommandString)
_, _ = fmt.Fprintf(w, " cwd: %s\n", emptyFallback(recipe.CWD))
_, _ = fmt.Fprintf(w, " storage: %s\n", recipe.StorageLocation)
if recipe.Note != "" {
_, _ = fmt.Fprintf(w, " note: %s\n", recipe.Note)
}
}

func emptyFallback(value string) string {
if strings.TrimSpace(value) == "" {
return "(none)"
}
return value
}

func usageError(stderr io.Writer, message string) int {
_, _ = fmt.Fprintf(stderr, "showagent: %s\n%s\nrun 'showagent --help' for details\n", message, usageLine)
return 2
Expand All @@ -232,17 +408,25 @@ Usage:
showagent list [--json] print sessions (plain table, or JSON with --json)
showagent resume <id|latest> [--yolo]
resume a session directly, without the picker
showagent convert <id|latest> --to <provider> [--scope all|last:50] [--dry-run]
preview or write a native session for another agent
showagent info <id|latest> [--yolo]
print the exact resume command and storage location
showagent update install the latest GitHub release
showagent setup install the compound-engineering plugin for supported agents

Flags:
-h, --help show this help
-v, --version print version
--json (list) emit a JSON array of sessions
--yolo (resume) skip the agent's permission prompts
--to (convert) target provider: %s
--scope (convert) all, or last:N / last-N
--dry-run (convert) preview without writing anything

Picker keys:
enter resume · y yolo · space collapse group · / search · t scope
x hand off to another agent · n branch a copy · C compound · d/del delete
x preview/confirm hand-off · n branch a copy · C compound · d/del delete
p cycle preview (first/latest/both) · 1..9 toggle providers · r rescan
? full help · esc clear search/overlay · q quit

Expand All @@ -255,7 +439,7 @@ Session locations:
gemini ~/.gemini/tmp (override with GEMINI_CLI_HOME)

When stdout is not a terminal, 'showagent' prints the plain table (same as 'showagent list').
`)
`, strings.Join(session.ProviderNames(), ", "))
}

// versionString prefers the release-stamped version and falls back to Go
Expand Down
Loading