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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ command reports the bare version (e.g. `0.1.0`).
### Added

* Docs: The roadmap now lives as GitHub issues. `ROADMAP.md` is a pointer to the issue-driven process, with labels (`priority:critical`, `priority:high`, `priority:low`, `chore`, `upstream`) and filtered search links.
* A global `--quiet`/`-q` flag suppresses stdout output (results and table output that `--log-level` does not gate). It can also be set via the `quiet` config key and `OPENCODE_SANDBOX_QUIET` env var.

### Changed

* **Breaking** CLI: the global `--verbose`/`--error` flags are replaced by a single monotonic `--log-level` flag (`error` | `warning` | `info` | `verbose`, default `info`, short `-l`). The `verbose`/`error` launcher-config keys and `OPENCODE_SANDBOX_VERBOSE`/`OPENCODE_SANDBOX_ERROR` env vars are replaced by `log-level` and `OPENCODE_SANDBOX_LOG_LEVEL`. The level selects the minimum severity shown on the console; `error < warning < info < verbose`, so a higher level is never hidden while a lower one is shown.
* **Breaking** CLI: `sandbox list --quiet`/`-q` (names-only mode) is renamed to the long-only `--names` flag. The `-q` shorthand now selects the global `--quiet` stdout-suppression flag.
* Docs: Pages are now published only after a successful release, rebuilding from the published tag rather than in parallel on tag push. Each page footer and the home page display the release (or branch) they were built from.
* Docs: Support for dark mode; follows the OS/browser color-scheme preference by default and expose a sun/moon toggle in the header (next to the GitHub link) that overrides and persists the choice.
* Docs: The README's Documentation section now links directly to the hosted GitHub Pages docs.
Expand Down
7 changes: 0 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,6 @@ See [Getting Started](/docs/getting-started.md) for prerequisites and a full set

Full [Commands Reference](/docs/commands.md).

> **Breaking change:** the global `-q/--quiet` flag was renamed to `--error`. `sandbox list`
> now supports `--label`, `--limit`, `--running`, `--stopped`, `-q/--quiet` (names only),
> and `--format json`.

opencode is pinned into the runner image at build time and does not auto-update inside sandboxes; rebuild the image
with `opencode-sandbox build` to upgrade (optionally pinning a specific version with `--opencode-version`).

## Documentation

The docs are also published to [GitHub Pages](https://inoio.github.io/opencode-sandbox/).
Expand Down
30 changes: 11 additions & 19 deletions cmd/opencode-sandbox/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,21 @@ func execute(args []string, ui termio.UI) error {
}

// applyCLISettings sets the terminal output level and assume-yes state on the UI
// based on the effective --verbose/--error/--yes flags of the running command.
// based on the effective --log-level/--yes flags of the running command.
//
// It must run after cobra parses the real command tree and after launcher
// config has been merged, so that flags work regardless of position or how
// short shorthands are grouped (e.g. "-nv").
func applyCLISettings(cmd *cobra.Command, ui termio.UI, r *launcherconfig.Resolver) {
// short shorthands are grouped (e.g. "-ny").
func applyCLISettings(cmd *cobra.Command, ui termio.UI, r *launcherconfig.Resolver) error {
if cmd == nil || r == nil {
return
return nil
}
quiet := r.Error()
verbose := r.Verbose()
yes := r.Yes()
ui.SetLevel(levelFrom(quiet, verbose))
ui.SetAssumeYes(yes)
}

func levelFrom(quiet, verbose bool) termio.Level {
switch {
case quiet:
return termio.LevelQuiet
case verbose:
return termio.LevelVerbose
default:
return termio.LevelNormal
level, err := termio.ParseLevel(r.LogLevel())
if err != nil {
return err
}
ui.SetLevel(level)
ui.SetAssumeYes(r.Yes())
ui.SetQuiet(r.Quiet())
return nil
}
2 changes: 1 addition & 1 deletion cmd/opencode-sandbox/cli_help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestRootHelpDescribesImpliedRun(t *testing.T) {
func TestRootHelpListsGlobalFlags(t *testing.T) {
out := commandOut(t, "--help")

for _, flag := range []string{"--yes", "--verbose", "--error", "--dry-run"} {
for _, flag := range []string{"--yes", "--quiet", "--log-level", "--dry-run"} {
if !strings.Contains(out, flag) {
t.Errorf("expected root help to list flag %q:\n%s", flag, out)
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/opencode-sandbox/cli_list_subcommand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ func TestListSandboxesQuietNames(t *testing.T) {
projectSandbox("opencode-sandbox-vm-alpha", nil),
projectSandbox("opencode-sandbox-vm-beta", nil),
}
cmd, ui := setupCommandFixtures(t, cmdList, "-q")
cmd, ui := setupCommandFixtures(t, cmdList, "--names")
sandboxmsb.WithMsbMock(t, mock)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -417,8 +417,8 @@ func TestListSandboxesFormatJSON(t *testing.T) {
}
}

func TestListSandboxesFormatJSONAndQuietConflict(t *testing.T) {
runListCmdTest(t, []string{cmdList, "-q", "--format", "json"},
func TestListSandboxesFormatJSONAndNamesConflict(t *testing.T) {
runListCmdTest(t, []string{cmdList, "--names", "--format", "json"},
func(_ *sandboxmsb.MockMsbClient) {}, nil, nil, true, "mutually exclusive")
}

Expand Down
69 changes: 38 additions & 31 deletions cmd/opencode-sandbox/cli_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,51 +9,58 @@ import (
launcherconfig "github.com/inoio/opencode-sandbox/internal/viperconfig"
)

func TestLevelFrom(t *testing.T) {
tests := []struct {
name string
quiet bool
verbose bool
want termio.Level
}{
{"quiet wins over verbose", true, true, termio.LevelQuiet},
{"quiet", true, false, termio.LevelQuiet},
{"verbose", false, true, termio.LevelVerbose},
{"normal default", false, false, termio.LevelNormal},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := levelFrom(tc.quiet, tc.verbose); got != tc.want {
t.Errorf("levelFrom(%v, %v) = %v, want %v", tc.quiet, tc.verbose, got, tc.want)
}
})
}
}

func TestApplyCLISettingsAppliesResolverValues(t *testing.T) {
ui := termio.NewTestMock(t)
r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{
Error: true,
Verbose: true,
Yes: true,
LogLevel: "error",
Yes: true,
Quiet: true,
})

applyCLISettings(&cobra.Command{}, &ui, r)
if err := applyCLISettings(&cobra.Command{}, &ui, r); err != nil {
t.Fatalf("applyCLISettings: %v", err)
}

if ui.Level() != termio.LevelQuiet {
t.Errorf("Level() = %v, want LevelQuiet", ui.Level())
if ui.Level() != termio.LevelError {
t.Errorf("Level() = %v, want LevelError", ui.Level())
}
if !ui.AssumeYes() {
t.Error("AssumeYes() = false, want true")
}
if !ui.Quiet() {
t.Error("Quiet() = false, want true")
}
}

func TestApplyCLISettingsSetsWarningLevel(t *testing.T) {
ui := termio.NewTestMock(t)
r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{LogLevel: "warning"})

if err := applyCLISettings(&cobra.Command{}, &ui, r); err != nil {
t.Fatalf("applyCLISettings: %v", err)
}
if ui.Level() != termio.LevelWarning {
t.Errorf("Level() = %v, want LevelWarning", ui.Level())
}
}

func TestApplyCLISettingsRejectsInvalidLevel(t *testing.T) {
ui := termio.NewTestMock(t)
r := launcherconfig.NewResolverWithConfig(launcherconfig.Config{LogLevel: "bogus"})

if err := applyCLISettings(&cobra.Command{}, &ui, r); err == nil {
t.Fatal("applyCLISettings with invalid level should error")
}
}

func TestApplyCLISettingsNilCommandOrResolverNoop(t *testing.T) {
ui := termio.NewTestMock(t)
applyCLISettings(nil, &ui, nil)
if err := applyCLISettings(nil, &ui, nil); err != nil {
t.Fatalf("applyCLISettings(nil): unexpected error %v", err)
}

// No panic, and the UI keeps its defaults.
if ui.Level() != termio.LevelNormal {
t.Errorf("Level() = %v, want default LevelNormal", ui.Level())
// No panic, and the UI level is left untouched.
if ui.Level() != termio.LevelError {
t.Errorf("Level() = %v, want untouched LevelError", ui.Level())
}
}
54 changes: 48 additions & 6 deletions cmd/opencode-sandbox/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
func TestRootHasGlobalFlags(t *testing.T) {
testUI := termio.NewTestMock(t)
root := buildRootCmd(&testUI)
flags := []string{"yes", "verbose", "error"}
flags := []string{"yes", "log-level", "quiet"}
for _, f := range flags {
if root.PersistentFlags().Lookup(f) == nil {
t.Errorf("expected persistent flag --%s on root", f)
Expand Down Expand Up @@ -51,7 +51,7 @@ func TestRunCommandFlagShortcuts(t *testing.T) {
shortcuts := map[string]string{
"w": "worktree", "c": "cpus", "m": "memory",
"r": "rebuild", "n": "dry-run", "y": "yes",
"v": "verbose",
"l": "log-level", "q": "quiet",
}
for short, long := range shortcuts {
f := runCmd.Flags().ShorthandLookup(short)
Expand All @@ -77,11 +77,10 @@ func TestImageBuildNounFormExists(t *testing.T) {
}
}

func TestCLICombinedShortFlagsActivateVerbose(t *testing.T) {
func TestCLILogLevelFlagSetsLevel(t *testing.T) {
for _, args := range [][]string{
{"prune", "--age", "1m", "-nv"},
{"prune", "--age", "1m", "-n", "-v"},
{"prune", "--age", "1m", "--dry-run", "--verbose"},
{"prune", "--age", "1m", "--log-level", "verbose"},
{"prune", "--age", "1m", "-l", "verbose"},
} {
t.Run(strings.Join(args, "_"), func(t *testing.T) {
configpaths.WithMockConfigPaths(t)
Expand Down Expand Up @@ -121,6 +120,49 @@ func TestCLIPersistentYesAffectsUIAfterSubcommand(t *testing.T) {
}
}

func TestCLIQuietFlagSetsQuiet(t *testing.T) {
for _, args := range [][]string{
{"prune", "--age", "1m", "--quiet"},
{"prune", "--age", "1m", "-q"},
} {
t.Run(strings.Join(args, "_"), func(t *testing.T) {
configpaths.WithMockConfigPaths(t)
ui := &termio.Mock{}
mock := &msb.MockMsbClient{}
msb.WithMsbMock(t, mock)
docker.WithNoopDockerMock(t)

root := buildRootCmd(ui)
root.SetArgs(args)

if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ui.Quiet() {
t.Errorf("expected Quiet=true, got %v", ui.Quiet())
}
})
}
}

func TestCLIQuietFlagFalseByDefault(t *testing.T) {
configpaths.WithMockConfigPaths(t)
ui := &termio.Mock{}
mock := &msb.MockMsbClient{}
msb.WithMsbMock(t, mock)
docker.WithNoopDockerMock(t)

root := buildRootCmd(ui)
root.SetArgs([]string{"prune", "--age", "1m"})

if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ui.Quiet() {
t.Error("expected Quiet=false by default")
}
}

func TestNewConfigSetsUserDirs(t *testing.T) {
t.Setenv("HOME", "/testhome")
t.Setenv("XDG_CONFIG_HOME", "")
Expand Down
9 changes: 3 additions & 6 deletions cmd/opencode-sandbox/cli_tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ func TestTree(t *testing.T) {
out := strings.Join(testUI.InfoCalls, "\n")
descs := []string{
"Assume yes to all prompts",
"Show debug-level output",
"Only show error output",
"Minimum log level to show (error, warning, info, verbose)",
"Run in an isolated opencode worktree named <name>, optionally starting from the local base ref <name>:<base>",
"Rebuild the runner image before starting",
"Dry run without starting anything",
Expand Down Expand Up @@ -128,8 +127,6 @@ func TestPrintTreeBoolFlagsHaveNoValuePlaceholders(t *testing.T) {
out := strings.Join(testUI.InfoCalls, "\n")
notExpected := []string{
"--yes <YES>",
"--verbose <VERBOSE>",
"--error <ERROR>",
"--tree <TREE>",
"--version <VERSION>",
"--rebuild <REBUILD>",
Expand All @@ -148,8 +145,8 @@ func TestPrintTreeFlagShortcuts(t *testing.T) {
out := strings.Join(testUI.InfoCalls, "\n")
expected := []string{
"-y, --yes",
"-v, --verbose",
"--error",
"-q, --quiet",
"-l, --log-level <LOG_LEVEL>",
"-w, --worktree <WORKTREE>",
"-r, --rebuild",
"-n, --dry-run",
Expand Down
8 changes: 4 additions & 4 deletions cmd/opencode-sandbox/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ func buildMinimalRootFlagsCmd() *cobra.Command {
}

rootFlagsCmd.PersistentFlags().BoolP(pFlagYes, pFlagYes[:1], false, "Assume yes to all prompts")
rootFlagsCmd.PersistentFlags().BoolP(pFlagVerbose, pFlagVerbose[:1], false, "Show debug-level output")
rootFlagsCmd.PersistentFlags().BoolP(pFlagError, "", false, "Only show error output")
rootFlagsCmd.PersistentFlags().BoolP(pFlagQuiet, pFlagQuiet[:1], false, "Suppress stdout output")
rootFlagsCmd.PersistentFlags().
StringP(pFlagLogLevel, pFlagLogLevel[:1], "info", "Minimum log level to show (error, warning, info, verbose)")

return rootFlagsCmd
}
Expand All @@ -161,8 +162,7 @@ func buildRootCmd(ui termio.UI) *cobra.Command {
return err
}
cmd.SetContext(context.WithValue(cmd.Context(), (*launcherConfigKey)(nil), r))
applyCLISettings(cmd, ui, r)
return nil
return applyCLISettings(cmd, ui, r)
}
extendRunCmd(ui, rootCmd)

Expand Down
4 changes: 2 additions & 2 deletions cmd/opencode-sandbox/commands_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func buildListCmd(ui termio.UI) *cobra.Command {
},
RunE: func(cmd *cobra.Command, _ []string) error {
if namesOnly && format != "" {
return errors.New("--quiet and --format are mutually exclusive")
return errors.New("--names and --format are mutually exclusive")
}
if format != "" && format != formatJSON {
return fmt.Errorf("unsupported format %q: only %q is supported", format, formatJSON)
Expand Down Expand Up @@ -194,7 +194,7 @@ func buildListCmd(ui termio.UI) *cobra.Command {
return nil
},
}
cmd.Flags().BoolVarP(&namesOnly, pFlagQuiet, pFlagQuiet[:1], false, "Print only sandbox names")
cmd.Flags().BoolVar(&namesOnly, pFlagNames, false, "Print only sandbox names")
cmd.Flags().
StringArrayVar(&labelsStr, flagLabel, nil, "Only show sandboxes carrying this label KEY=VALUE (repeatable, all must match)")
cmd.Flags().Uint32Var(&limit, flagLimit, 0, "Limit the number of sandboxes shown")
Expand Down
8 changes: 4 additions & 4 deletions cmd/opencode-sandbox/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ package main
import "github.com/inoio/opencode-sandbox/internal/sandbox/naming"

const (
pFlagYes = "yes"
pFlagVerbose = "verbose"
pFlagError = "error"
pFlagQuiet = "quiet"
pFlagYes = "yes"
pFlagLogLevel = "log-level"
pFlagQuiet = "quiet"
pFlagNames = "names"

cmdRun = "run"
cmdShell = "shell"
Expand Down
2 changes: 1 addition & 1 deletion cmd/opencode-sandbox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func main() {
args := os.Args[1:]
ui := termio.New(os.Stdin, os.Stdout, os.Stderr,
term.IsTerminal(int(os.Stderr.Fd())), termio.LevelNormal, false)
term.IsTerminal(int(os.Stderr.Fd())), termio.LevelInfo, false, false)

if err := execute(args, ui); err != nil {
var exitErr *sandbox.ExitError
Expand Down
12 changes: 6 additions & 6 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ This document lists all opencode-sandbox subcommands, aliases, and flags.

These flags are available on every command.

| Flag | Short | Default | Purpose |
|--------------|-------|---------|--------------------------------|
| `--yes` | `-y` | `false` | Assume yes to all prompts |
| `--verbose` | `-v` | `false` | Show debug-level output |
| `--error` | | `false` | Only show error output |
| Flag | Short | Default | Purpose |
|---------------|-------|---------|-----------------------------------------------------------------|
| `--yes` | `-y` | `false` | Assume yes to all prompts |
| `--quiet` | `-q` | `false` | Suppress stdout output |
| `--log-level` | `-l` | `info` | Minimum log level to show (`error`, `warning`, `info`, `verbose`) |

## Commands

Expand Down Expand Up @@ -240,7 +240,7 @@ plain text.
| `--limit` | — | `0` | Limit the number of sandboxes listed (`0` = no limit). |
| `--running` | — | `false` | Only list running sandboxes. |
| `--stopped` | — | `false` | Only list stopped sandboxes. |
| `--quiet` | `-q` | `false` | Print names only (no header, no status, image, or created columns). |
| `--names` | | `false` | Print names only (no header, no status, image, or created columns). |
| `--format` | — | `""` | Output format. `json` prints a top-level array of `{name,status,image,created,updated,labels}` objects. |

`--running` wins over `--stopped` when both are set.
Expand Down
Loading
Loading