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
79 changes: 14 additions & 65 deletions cmd/agentop/cmd_bob.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,72 +151,21 @@ var bobConfirm = confirm
// per-half: one consent covers every write the preview lists, and a refusal
// declines both halves together.
func runBob(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, bobUsage)
return 2
}
action := args[0]
// Before anything else: --help asks for this command's usage, and reading it
// as an action name would send someone looking for the command list to the one
// branch that refuses to print it. Same split as claude-code's.
switch action {
case "-h", "--help", "help":
fmt.Fprint(stdout, bobUsage)
return 0
}

// The verb is validated HERE, before any environment or filesystem work, for
// the reason the halves' plans document at length: a step below can answer
// successfully on its own — bobSettingsPath's off-macOS arm prints advice —
// so validating the action last would report success for a verb that does not
// exist. Nothing downstream can reach this check, so it comes first.
switch action {
case "enable", "disable", "status":
default:
fmt.Fprintf(stderr, "agentop: unknown bob action %q (enable, disable, status)\n", action)
return 2
}

fs := flag.NewFlagSet("configure bob "+action, flag.ContinueOnError)
fs.SetOutput(stderr)
settingsPath := fs.String("settings", "", "IBM Bob settings file")
// Registered for all three verbs, status included: status reports whether the
// value still matches the proxy the config names now, which needs the config.
cortexCfgPath := fs.String("config", "", "Cortex config file")
// --yes for enable and disable ONLY. status writes nothing, so it has nothing
// to confirm; registering it unconditionally would make `status --yes` parse
// and be silently ignored, which is the same "accepted and did nothing" that
// `status extra` is already a usage error for. Read back into a plain bool
// below so the call sites cannot nil-deref.
var yesFlag *bool
if action == "enable" || action == "disable" {
yesFlag = fs.Bool("yes", false, "do not prompt for confirmation")
}
// The FlagSet's own usage would print a bare header and a short flag list;
// this command's usage is the useful answer, and suppressing it here keeps
// -h's single copy on stdout below.
fs.Usage = func() {}
if err := fs.Parse(args[1:]); err != nil {
// -h and --help arrive as flag.ErrHelp, and asking for help is not a usage
// error: stdout and 0. Any other parse failure is real, and Parse has
// already named it on stderr.
if errors.Is(err, flag.ErrHelp) {
fmt.Fprint(stdout, bobUsage)
return 0
}
return 2
}
yes := yesFlag != nil && *yesFlag
if fs.NArg() > 0 {
fmt.Fprintf(stderr, "agentop: bob %s takes no arguments (got %q)\n", action, fs.Arg(0))
return 2
}

home, err := os.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(stderr, "agentop: cannot determine your home directory: %v\n", err)
return 1
// parseAgentArgs checks the verb before any environment or filesystem work, which
// matters most here: a step below can answer successfully on its own (bobSettingsPath's
// off-macOS arm prints advice), so a late check would report success for a verb that
// does not exist.
var settingsPath, cortexCfgPath *string
a, code, done := parseAgentArgs("bob", bobUsage, args, stdout, stderr, func(fs *flag.FlagSet) {
settingsPath = fs.String("settings", "", "IBM Bob settings file")
// For all three verbs: status reports whether the value still matches the proxy
// the config names now, which needs the config.
cortexCfgPath = fs.String("config", "", "Cortex config file")
})
if done {
return code
}
action, yes, home := a.action, a.yes, a.home
if *cortexCfgPath == "" {
*cortexCfgPath = filepath.Join(home, cortexCfgRel)
}
Expand Down
54 changes: 12 additions & 42 deletions cmd/agentop/cmd_claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,42 +224,15 @@ Flags:
`

func runClaudeCode(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, claudeCodeUsage)
return 2
}
action := args[0]
// `agentop claude-code --help` used to be read as an action name and fall through to
// the unknown-action branch below, which sends someone looking for the command
// list to the one place that refuses to print it. Same fix, and same reason, as
// `agentop service --help`.
//
// Answered before the flag set is built rather than through fs.Usage: --help asks
// for the whole command's usage, and a flag set named "claude-code --help" would
// print only the flags of an action that does not exist.
//
// Explicit help goes to stdout with exit 0; a missing or bad action keeps going to
// stderr with exit 2. That split is what makes `--help` pipeable.
switch action {
case "-h", "--help", "help":
fmt.Fprint(stdout, claudeCodeUsage)
return 0
}

fs := flag.NewFlagSet("claude-code "+action, flag.ContinueOnError)
fs.SetOutput(stderr)
yes := fs.Bool("yes", false, "do not prompt for confirmation")
settingsPath := fs.String("settings", "", "Claude Code settings file")
cortexCfg := fs.String("config", "", "Cortex config file")
if err := fs.Parse(args[1:]); err != nil {
return 2
}

home, err := os.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(stderr, "agentop: cannot determine your home directory: %v\n", err)
return 1
}
var settingsPath, cortexCfg *string
a, code, done := parseAgentArgs("claude-code", claudeCodeUsage, args, stdout, stderr, func(fs *flag.FlagSet) {
settingsPath = fs.String("settings", "", "Claude Code settings file")
cortexCfg = fs.String("config", "", "Cortex config file")
})
if done {
return code
}
action, yes, home := a.action, a.yes, a.home
if *settingsPath == "" {
*settingsPath = filepath.Join(home, settingsRel)
}
Expand All @@ -270,14 +243,11 @@ func runClaudeCode(args []string, stdout, stderr io.Writer) int {

switch action {
case "enable":
return claudeCodeEnable2(*settingsPath, *cortexCfg, statePath, *yes, stdout, stderr)
return claudeCodeEnable2(*settingsPath, *cortexCfg, statePath, yes, stdout, stderr)
case "disable":
return claudeCodeDisable2(*settingsPath, statePath, *yes, stdout, stderr)
case "status":
return claudeCodeStatus(*settingsPath, stdout)
return claudeCodeDisable2(*settingsPath, statePath, yes, stdout, stderr)
default:
fmt.Fprintf(stderr, "agentop: unknown claude-code action %q (enable, disable, status)\n", action)
return 2
return claudeCodeStatus(*settingsPath, stdout)
}
}

Expand Down
57 changes: 9 additions & 48 deletions cmd/agentop/cmd_codex.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"errors"
"flag"
"fmt"
"io"
Expand Down Expand Up @@ -187,53 +186,15 @@ Flags:
`

func runCodex(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, codexUsage)
return 2
}
action := args[0]
// Help before the verb check, and the verb before anything touches the
// machine: the same order runClaudeCode and runOpenCode use, for their
// reasons.
switch action {
case "-h", "--help", "help":
fmt.Fprint(stdout, codexUsage)
return 0
case "enable", "disable", "status":
default:
fmt.Fprintf(stderr, "agentop: unknown codex action %q (enable, disable, status)\n", action)
return 2
}

fs := flag.NewFlagSet("configure codex "+action, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {}
envPath := fs.String("env", "", "Codex's dotenv file")
cortexCfgPath := fs.String("config", "", "Cortex config file")
// Not for status, which changes nothing: accepting --yes there would be a
// flag that parses and does nothing, same reasoning as runOpenCode's.
var yesFlag *bool
if action != "status" {
yesFlag = fs.Bool("yes", false, "do not prompt for confirmation")
}
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
fmt.Fprint(stdout, codexUsage)
return 0
}
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(stderr, "agentop: codex %s takes no arguments (got %q)\n", action, fs.Arg(0))
return 2
}
yes := yesFlag != nil && *yesFlag

home, err := os.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(stderr, "agentop: cannot determine your home directory: %v\n", err)
return 1
}
var envPath, cortexCfgPath *string
a, code, done := parseAgentArgs("codex", codexUsage, args, stdout, stderr, func(fs *flag.FlagSet) {
envPath = fs.String("env", "", "Codex's dotenv file")
cortexCfgPath = fs.String("config", "", "Cortex config file")
})
if done {
return code
}
action, yes, home := a.action, a.yes, a.home
if *envPath == "" {
*envPath = defaultCodexEnvPath(home)
}
Expand Down
92 changes: 92 additions & 0 deletions cmd/agentop/cmd_configure_args.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
)

// agentArgs is what parseAgentArgs read from `agentop configure <agent> <action> [flags]`.
type agentArgs struct {
action string
yes bool
home string
}

// parseAgentArgs is the front half every `configure <agent>` command shares: help, the
// verb check, the flags, and the home directory, in that order, so nothing touches the
// machine before the arguments are known good. flags adds the agent's own flags to the
// set; everything after the home directory stays per-agent.
//
// done reports that the command is finished and code is its exit code: explicit help
// goes to stdout with 0, a missing or bad action or flag to stderr with 2, and no home
// directory with 1. --yes exists only for enable and disable, because status changes
// nothing and a flag that parses and does nothing would mislead.
func parseAgentArgs(agent, usage string, args []string, stdout, stderr io.Writer, flags func(*flag.FlagSet)) (a agentArgs, code int, done bool) {
if len(args) == 0 {
_, _ = fmt.Fprint(stderr, usage)
return a, 2, true
}
a.action = args[0]
switch a.action {
case "-h", "--help", "help":
_, _ = fmt.Fprint(stdout, usage)
return a, 0, true
case "enable", "disable", "status":
default:
_, _ = fmt.Fprintf(stderr, "agentop: unknown %s action %q (enable, disable, status)\n", agent, a.action)
return a, 2, true
}

fs := flag.NewFlagSet("configure "+agent+" "+a.action, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {}
if flags != nil {
flags(fs)
}
var yes *bool
if a.action != "status" {
yes = fs.Bool("yes", false, "do not prompt for confirmation")
}
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
_, _ = fmt.Fprint(stdout, usage)
return a, 0, true
}
return a, 2, true
}
if fs.NArg() > 0 {
_, _ = fmt.Fprintf(stderr, "agentop: %s %s takes no arguments (got %q)\n", agent, a.action, fs.Arg(0))
return a, 2, true
}
a.yes = yes != nil && *yes

home, err := os.UserHomeDir()
if err != nil || home == "" {
_, _ = fmt.Fprintf(stderr, "agentop: cannot determine your home directory: %v\n", err)
return a, 1, true
}
a.home = home
return a, 0, false
}

// askYesNo prints prompt to w and reads one answer from r. y or yes (any case) is yes, and
// an empty line is enterYes. EOF before any answer is never consent.
func askYesNo(r io.Reader, w io.Writer, prompt string, enterYes bool) bool {
_, _ = fmt.Fprint(w, prompt)
line, err := bufio.NewReader(r).ReadString('\n')
if err != nil && line == "" {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
case "":
return enterYes
}
return false
}
37 changes: 37 additions & 0 deletions cmd/agentop/cmd_configure_args_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package main

import (
"bytes"
"strings"
"testing"
)

// claude-code used to take `status --yes`, a stray argument, and `--help` after the action,
// and checked the action only after the home directory. It now parses as the other agents do.
func TestRunClaudeCode_ArgumentsParseLikeTheOtherAgents(t *testing.T) {
t.Setenv("HOME", "") // a late action check would exit 1 here, not 2
for _, tc := range []struct {
name string
args []string
code int
onStdout bool
}{
{name: "no args", args: nil, code: 2},
{name: "--help", args: []string{"--help"}, code: 0, onStdout: true},
{name: "help after the action", args: []string{"status", "--help"}, code: 0, onStdout: true},
{name: "unknown action", args: []string{"enabel"}, code: 2},
{name: "status --yes", args: []string{"status", "--yes"}, code: 2},
{name: "a stray argument", args: []string{"enable", "now"}, code: 2},
} {
t.Run(tc.name, func(t *testing.T) {
var out, errb bytes.Buffer
code := runClaudeCode(tc.args, &out, &errb)
if code != tc.code {
t.Errorf("exit %d, want %d; stderr %q", code, tc.code, errb.String())
}
if got := strings.Contains(out.String(), claudeCodeUsage); got != tc.onStdout {
t.Errorf("usage on stdout = %v, want %v:\n%s", got, tc.onStdout, out.String())
}
})
}
}
Loading
Loading