Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
501 lines (469 loc) · 18.4 KB
/
Copy pathmain.go
File metadata and controls
501 lines (469 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
// Command browserctrl lists Chromium browser profiles that have the Claude
// browser extension installed, together with the extension's device id — the
// value the Claude Code `claude-in-chrome` MCP needs for `select_browser`.
//
// Why it exists: with several browsers/profiles open, Claude Code can only
// show "Browser 1 / 2 / 3" and asks the user to click through a confirmation
// screen in each. `browserctrl list` answers "which id is my legable Chrome?"
// from disk in one shot; `browserctrl find legable` prints just that id so an
// agent can pipe it straight into the tool call.
//
// Exit codes (closed set, see exitCode constants): 0 ok, 1 error / no match,
// 2 ambiguous match.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"github.com/spf13/cobra"
"github.com/ubgo/buildinfo"
"github.com/khanakia/browserctrl/browser"
)
// Exit codes. 2 (ambiguous) is distinct from 1 so a script can decide to
// prompt the user rather than treat it as a hard failure.
const (
exitOK = 0
exitFailure = 1
exitAmbiguous = 2
)
// Flag names — shared between subcommands so `--json` / `--running` behave
// identically everywhere.
const (
flagJSON = "json"
flagRunning = "running"
// flagReachable keeps only browsers on the account THIS session is signed
// in as — the ones select_browser can actually attach to.
flagReachable = "reachable"
flagAll = "all"
flagRoot = "root"
// flagProfiles is on `list` only: `find` resolves a device id, and a
// profile without an extension has none to resolve.
flagProfiles = "profiles"
// flagAccountAlias names a Claude account uuid that this machine cannot
// name by itself (see accountAliases).
flagAccountAlias = "account-alias"
)
// version is the binary's release version, used by the generated
// skills_gen.go (volt gen skills) to pick the release tag whose skills bundle
// matches this exact binary. buildinfo.DevVersion switches the skills command
// to serving the working tree's skills/ directory instead of fetching.
var version = releaseVersion(buildinfo.Get())
// pseudoVersionPrefix is what Go stamps into Main.Version for a `go build`
// from a git checkout with no tag (e.g. v0.0.0-20260912061725-90d9f421ae81
// +dirty). It is a real-looking version that no release ever carries.
const pseudoVersionPrefix = "v0.0.0-"
// releaseVersion maps build provenance to the version the skills bundle is
// keyed by: a volt ldflags stamp or a `go install …@vX.Y.Z` module version
// is returned as is; anything else — no version at all, or Go's untagged
// pseudo-version — is buildinfo.DevVersion.
//
// Why: without this, a plain `go build` in the repo produced a binary whose
// `skills` command tried to download a bundle for a pseudo-version and 404ed
// (seen 2026-09-12); only `go run`, which embeds no VCS data, said "dev".
func releaseVersion(info buildinfo.Info) string {
if !info.HasVersion() || strings.HasPrefix(info.Version, pseudoVersionPrefix) {
return buildinfo.DevVersion
}
return info.Version
}
// Sentinel errors mapped to exit codes in main.
var (
errNoMatch = errors.New("no browser matches the query")
errAmbiguous = errors.New("query matches more than one browser")
// errNoSessionAccount: --reachable was asked for but the account this
// session runs as cannot be determined. An error rather than an empty
// list, because "nothing is reachable" and "cannot tell" call for
// different actions and an agent would otherwise conclude the former.
errNoSessionAccount = errors.New("--" + flagReachable + ": cannot tell which Claude account this session is signed in as (no account in Claude Code's config)")
)
// listFlags is the parsed flag set shared by `list` and `find`.
type listFlags struct {
json bool
running bool
// reachable narrows to browsers on the session's own Claude account.
reachable bool
all bool
// roots, when non-empty, REPLACES the well-known install locations with
// the given user-data dirs (labelled browser=custom).
roots []string
// profiles widens the listing to profiles with no Claude extension, which
// are otherwise invisible — the "why is my profile missing?" answer.
profiles bool
// aliases are `<uuid>=<label>` pairs naming Claude accounts, in the order
// given; see accountAliases for why they are needed.
aliases []string
}
// accountAliases parses `--account-alias <uuid>=<label>` into uuid → label.
//
// Why the flag exists: only ONE Claude account can be named from disk — the
// one Claude Code is signed in as (browser.ReadClaudeAccount). Every other
// account exists on this machine as a bare uuid, in the extension store, in
// claude.ai's site data and in past transcripts, with its email stored
// nowhere (verified 2026-09-25). So naming a second account is something only
// the user can supply, once, from their shell profile or a wrapper.
//
// A pair without "=" is an error rather than a silently ignored argument: a
// typo here would otherwise show up as an unexplained uuid in the table.
func accountAliases(pairs []string) (map[string]string, error) {
out := make(map[string]string, len(pairs))
for _, p := range pairs {
uuid, label, ok := strings.Cut(p, aliasSeparator)
if !ok || uuid == "" || label == "" {
return nil, fmt.Errorf("--%s %q: want <account-uuid>%s<label>", flagAccountAlias, p, aliasSeparator)
}
out[uuid] = label
}
return out, nil
}
// aliasSeparator splits an --account-alias pair. labelOtherAccount is what an
// account that cannot be named is called: it is not this session's account,
// which is the only fact that changes what the reader should do.
const (
aliasSeparator = "="
labelOtherAccount = "other account"
)
// newAccountLabeler builds the column's formatter.
//
// Why not just print the uuid: a Claude account uuid tells the reader
// nothing. The column names the account wherever a name exists — the email
// of any account a Claude Code profile on this machine is signed in as, then
// a label the user gave — and otherwise says "other account", numbered only
// when there are several so the common case reads as plain English.
//
// known maps account uuid → email, gathered from every profile config rather
// than only the current process's. The label must not depend on which shell
// asked: keying it on one profile made the same browser read as an email
// inside a Claude Code session and as "other account" from a plain terminal.
//
// entries is the unfiltered scan; numbering follows its order so the same
// machine always labels the same account the same way.
func newAccountLabeler(known, aliases map[string]string, entries []browser.Entry) accountLabeler {
others := otherAccountNumbers(known, aliases, entries)
return func(e browser.Entry) string {
if e.AccountUUID == "" {
return ""
}
if email, ok := known[e.AccountUUID]; ok && email != "" {
return email
}
if label, ok := aliases[e.AccountUUID]; ok {
return label
}
if n, ok := others[e.AccountUUID]; ok && n > 0 {
return fmt.Sprintf("%s %d", labelOtherAccount, n)
}
return labelOtherAccount
}
}
// otherAccountNumbers assigns 1..N to the accounts that have neither a known
// email nor an alias, in the order they appear. A single such account
// maps to 0, meaning "do not number it": "other account" beats "other
// account 1" when there is nothing to tell it apart from.
func otherAccountNumbers(known, aliases map[string]string, entries []browser.Entry) map[string]int {
order := make([]string, 0, len(entries))
seen := make(map[string]bool, len(entries))
for _, e := range entries {
if e.AccountUUID == "" || seen[e.AccountUUID] {
continue
}
if email := known[e.AccountUUID]; email != "" {
continue
}
if _, aliased := aliases[e.AccountUUID]; aliased {
continue
}
seen[e.AccountUUID] = true
order = append(order, e.AccountUUID)
}
out := make(map[string]int, len(order))
for i, u := range order {
if len(order) == 1 {
out[u] = 0
break
}
out[u] = i + 1
}
return out
}
// knownAccounts gathers uuid → email for every Claude account that some
// Claude Code profile on this machine is signed in as.
//
// Any failure degrades to fewer names, never to an error: the listing still
// works, unnamed accounts just show as "other account". A browser inventory
// must not fail because Claude Code is missing, logged out, or has one
// profile with a broken config — so the error from ReadClaudeAccounts, which
// reports exactly that last case alongside the accounts that did load, is
// deliberately dropped here.
func knownAccounts() map[string]string {
paths, err := browser.ClaudeConfigPaths()
if err != nil {
return nil
}
accounts, _ := browser.ReadClaudeAccounts(paths)
out := make(map[string]string, len(accounts))
for _, a := range accounts {
out[a.UUID] = a.Email
}
return out
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
// run is main without the process exit so tests can drive it end to end.
func run(args []string, stdout, stderr io.Writer) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
root := newRootCmd(stdout, stderr)
root.SetArgs(args)
err := root.ExecuteContext(ctx)
switch {
case err == nil:
return exitOK
case errors.Is(err, errAmbiguous):
return exitAmbiguous
default:
// stderr write failure has no further reporting channel; the exit
// code already carries the outcome.
_, _ = fmt.Fprintln(stderr, "error:", err)
return exitFailure
}
}
func newRootCmd(stdout, stderr io.Writer) *cobra.Command {
root := &cobra.Command{
Use: "browserctrl",
Short: "List Claude-connected Chromium browser profiles and their device ids",
Version: versionString(buildinfo.Get()),
SilenceUsage: true,
SilenceErrors: true,
}
root.SetOut(stdout)
root.SetErr(stderr)
root.AddCommand(newListCmd(), newFindCmd(), newSkillsCommand())
return root
}
// versionString renders `--version`: the raw buildinfo version (so a
// pseudo-version still identifies the exact source build) plus, when the
// build carries a real commit, its short hash and a dirty marker. The marker
// is skipped when Go already encoded it as a "+dirty" suffix.
func versionString(info buildinfo.Info) string {
if !info.HasCommit() {
return info.Version
}
s := info.Version + " (" + shortCommit(info.Commit) + ")"
if info.Modified && !strings.Contains(info.Version, dirtySuffix) {
s += " " + dirtyMarker
}
return s
}
// dirtySuffix is Go's own uncommitted-tree marker inside a pseudo-version;
// dirtyMarker is ours for stamped versions that carry no such suffix.
const (
dirtySuffix = "+dirty"
dirtyMarker = "dirty"
)
// shortCommitLen is git's conventional abbreviated hash length.
const shortCommitLen = 7
func shortCommit(c string) string {
if len(c) > shortCommitLen {
return c[:shortCommitLen]
}
return c
}
func addListFlags(cmd *cobra.Command, f *listFlags) {
cmd.Flags().BoolVar(&f.json, flagJSON, false, "emit JSON instead of a table")
cmd.Flags().BoolVar(&f.running, flagRunning, false, "only profiles currently open in a running browser")
cmd.Flags().BoolVar(&f.reachable, flagReachable, false, "only browsers signed in to the Claude account this session runs as")
cmd.Flags().BoolVar(&f.all, flagAll, false, "scan the Claude desktop-app extension ids too, not just Claude Code's")
cmd.Flags().StringArrayVar(&f.roots, flagRoot, nil, "scan this Chromium user-data dir instead of the well-known ones (repeatable)")
}
func newListCmd() *cobra.Command {
var f listFlags
cmd := &cobra.Command{
Use: "list",
Short: "Show every profile with the Claude extension, running ones first",
Long: `Lists one row per profile that has a Claude extension store on disk, which
is the only place a device id exists. A profile where the extension is not
installed has no id, cannot be selected by the MCP, and is not listed unless
you pass --profiles.`,
Example: ` browserctrl list
browserctrl list --running --json
browserctrl list --profiles
browserctrl list --account-alias 2f7c1b90-...=work@example.com`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
res, err := scan(cmd.Context(), f)
if err != nil {
return err
}
entries, all, session := res.entries, res.all, res.session
if f.json {
return writeJSON(cmd.OutOrStdout(), entries)
}
aliases, err := accountAliases(f.aliases)
if err != nil {
return err
}
if f.reachable && len(entries) == 0 {
return writeNoneReachable(cmd.OutOrStdout(), session)
}
label := newAccountLabeler(knownAccounts(), aliases, all)
if f.profiles {
return writeProfilesTable(cmd.OutOrStdout(), entries, label)
}
return writeTable(cmd.OutOrStdout(), entries, label)
},
}
addListFlags(cmd, &f)
cmd.Flags().BoolVar(&f.profiles, flagProfiles, false, "also list profiles that do NOT have the Claude extension installed")
cmd.Flags().StringArrayVar(&f.aliases, flagAccountAlias, nil, "name a Claude account: <account-uuid>=<label> (repeatable)")
return cmd
}
func newFindCmd() *cobra.Command {
var f listFlags
cmd := &cobra.Command{
Use: "find <term>...",
Short: "Print the device id of the single profile matching all terms",
Long: `Every term must match (case-insensitive substring) one of: display name,
profile name, email, profile dir, browser kind, device id. When several
profiles match but exactly one is running, that one wins. Otherwise the
candidates are listed on stderr and the exit code is 2.`,
Example: ` browserctrl find legable
browserctrl find vivaldi work
DEVICE=$(browserctrl find analyzify)`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
res, err := scan(cmd.Context(), f)
if err != nil {
return err
}
entries, all := res.entries, res.all
matches := browser.Match(entries, strings.Join(args, " "))
hit, err := pickOne(matches)
if err != nil {
if errors.Is(err, errAmbiguous) {
// Best-effort diagnostics on stderr; the ambiguity error is
// the result, so a failure to print candidates is not promoted.
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "error:", err)
_ = writeTable(cmd.ErrOrStderr(), matches, newAccountLabeler(knownAccounts(), nil, all))
}
return err
}
if f.json {
return writeJSON(cmd.OutOrStdout(), hit)
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), hit.DeviceID)
return err
},
}
addListFlags(cmd, &f)
return cmd
}
// scan runs browser.Scan with the flag-derived options and applies the
// --running filter. --all widens the extension set to every known id.
//
// It returns the filtered entries AND the unfiltered scan (see scanned). The
// unfiltered one is what account labels are computed from: numbering accounts over the rows
// being printed would let `--running` rename an account between two commands
// on the same machine, which is worse than no label at all.
func scan(ctx context.Context, f listFlags) (scanned, error) {
opts := browser.Options{Extensions: []browser.ExtensionID{browser.ExtClaudeCode}}
if f.all {
opts.Extensions = browser.ExtensionValues
}
opts.IncludeAllProfiles = f.profiles
for _, r := range f.roots {
opts.Roots = append(opts.Roots, browser.Root{Kind: browser.KindCustom, Path: r})
}
all, err := browser.Scan(ctx, opts)
if err != nil {
return scanned{}, err
}
res := scanned{entries: all, all: all}
if f.running {
res.entries = browser.OnlyRunning(res.entries)
}
if f.reachable {
if res.session, err = sessionAccount(); err != nil {
return scanned{}, err
}
res.entries = browser.OnlyAccount(res.entries, res.session.UUID)
}
return res, nil
}
// scanned is what scan hands back: the rows to print, the unfiltered scan
// the account labels are numbered over, and — only under --reachable — the
// session account the rows were filtered by, so the "none reachable" hint
// can name it without reading the config a second time.
type scanned struct {
entries []browser.Entry
all []browser.Entry
session browser.ClaudeAccount
}
// hintNoneReachable replaces the generic empty-table hint under --reachable:
// "is the extension installed?" would send the reader the wrong way when the
// extension is installed everywhere and simply signed in to another account.
const hintNoneReachable = "no matching browser is signed in to %s, the Claude account this session runs as (plain `browserctrl list` shows which account each browser is on)\n"
// writeNoneReachable prints hintNoneReachable naming the session account by
// email, or by uuid for an account whose config carries no email.
func writeNoneReachable(w io.Writer, session browser.ClaudeAccount) error {
name := session.Email
if name == "" {
name = session.UUID
}
_, err := fmt.Fprintf(w, hintNoneReachable, name)
return err
}
// sessionAccount is the Claude account THIS process's Claude Code profile is
// signed in as: the config CLAUDE_CONFIG_DIR selects, else ~/.claude.json.
//
// Unlike knownAccounts, which names every account on the machine, this one
// must be specific to the asking shell — reachability is a fact about the
// session, so the same command rightly answers differently from a work
// profile and a personal one. Run from a plain terminal it describes the
// session a `claude` started there would get.
func sessionAccount() (browser.ClaudeAccount, error) {
path, err := browser.DefaultClaudeConfigPath()
if err != nil {
return browser.ClaudeAccount{}, fmt.Errorf("%w: %w", errNoSessionAccount, err)
}
acct, err := browser.ReadClaudeAccount(path)
if err != nil {
return browser.ClaudeAccount{}, fmt.Errorf("%w: %w", errNoSessionAccount, err)
}
return acct, nil
}
// pickOne resolves a match set to exactly one entry.
//
// Tie-break: several matches but exactly one Running → that one (the user
// almost always means the window that is open). Entries without a DeviceID
// are never picked — an empty id is useless to select_browser.
func pickOne(matches []browser.Entry) (browser.Entry, error) {
var withID []browser.Entry
for _, m := range matches {
if m.DeviceID != "" {
withID = append(withID, m)
}
}
switch len(withID) {
case 0:
return browser.Entry{}, errNoMatch
case 1:
return withID[0], nil
}
if running := browser.OnlyRunning(withID); len(running) == 1 {
return running[0], nil
}
return browser.Entry{}, errAmbiguous
}
// writeJSON pretty-prints v. Generic rather than `any`-typed so the call
// site's static type is preserved (no boxing through an untyped parameter).
func writeJSON[T []browser.Entry | browser.Entry](w io.Writer, v T) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}