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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ brew tap ArdurAI/tap
brew trust --formula ArdurAI/tap/sith
brew install sith
sith version
sith launch
```

Homebrew 6 requires explicit trust for third-party taps. The formula-scoped command keeps the trust
boundary narrower than trusting every current and future formula in `ArdurAI/tap`. Older Homebrew
versions that do not implement tap trust can omit that line.

`sith launch` is the one-command graphical entry point: it opens the native desktop on macOS and
the loopback-only browser UI on other supported platforms. Use `--mode desktop` or `--mode ui` to
override that selection. A demo machine without a default kubeconfig can import an existing bounded
directory for the session with `sith launch --kubeconfig-dir /path/to/kubeconfigs`; Sith neither
persists the selected path nor deploys anything to those clusters.

Release archives are also available for `darwin/amd64`, `darwin/arm64`, `linux/amd64`, and
`linux/arm64`. Every archive has a checksum, an SPDX SBOM, a keyless Sigstore bundle, SLSA build
provenance, and a platform-specific SBOM attestation. Verify those materials before installing;
Expand All @@ -40,6 +47,8 @@ Sith requires a supported Go 1.26 toolchain.

```bash
make build
./bin/sith launch # native desktop on macOS; loopback UI elsewhere
./bin/sith launch --mode ui --kubeconfig-dir /path/to/kubeconfigs
./bin/sith # interactive terminal: cache-first fleet view
./bin/sith tui # explicit equivalent
./bin/sith version
Expand Down
98 changes: 98 additions & 0 deletions internal/cli/launch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: Apache-2.0

package cli

import (
"fmt"
"runtime"

"github.com/spf13/cobra"

"github.com/ArdurAI/sith/internal/connector"
"github.com/ArdurAI/sith/internal/localops"
)

type launchMode string

const (
launchModeAuto launchMode = "auto"
launchModeDesktop launchMode = "desktop"
launchModeUI launchMode = "ui"
)

type launchOptions struct {
mode string
ui uiOptions
}

func newLaunchCommand(reader connector.Reader, local localops.Client) *cobra.Command {
options := &launchOptions{
mode: string(launchModeAuto),
ui: uiOptions{address: "127.0.0.1"},
}
command := &cobra.Command{
Use: "launch",
Short: "Open the local fleet IDE",
Long: "Open the local fleet IDE using the native desktop on macOS and the loopback-only UI " +
"on other supported platforms. Listener and browser flags require --mode ui.",
Example: " sith launch\n" +
" sith launch --kubeconfig-dir /path/to/kubeconfigs\n" +
" sith launch --mode ui --no-open",
Args: cobra.NoArgs,
RunE: func(command *cobra.Command, _ []string) error {
mode, err := resolveLaunchMode(options.mode, runtime.GOOS)
if err != nil {
return err
}
switch mode {
case launchModeDesktop:
if err := rejectDesktopUIFlags(command); err != nil {
return err
}
if err := validateDesktopDependencies(reader, local, options.ui.kubeconfigDir); err != nil {
return err
}
return runDesktop(command.Context(), reader, local, options.ui.kubeconfigDir)
case launchModeUI:
selectedReader, selectedLocal, err := selectUIBackend(reader, local, options.ui.kubeconfigDir)
if err != nil {
return err
}
return runWebUI(command.Context(), command, selectedReader, selectedLocal, &options.ui)
default:
return fmt.Errorf("launch mode %q is unsupported", mode)
}
},
}
command.Flags().StringVar(&options.mode, "mode", options.mode, "launch mode: auto, desktop, or ui")
bindUIFlags(command, &options.ui)
return command
}

func resolveLaunchMode(requested, goos string) (launchMode, error) {
switch launchMode(requested) {
case launchModeAuto:
if goos == "darwin" {
return launchModeDesktop, nil
}
return launchModeUI, nil
case launchModeDesktop:
if goos != "darwin" {
return "", fmt.Errorf("launch mode %q is available only on macOS", requested)
}
return launchModeDesktop, nil
case launchModeUI:
return launchModeUI, nil
default:
return "", fmt.Errorf("invalid launch mode %q: expected auto, desktop, or ui", requested)
}
}

func rejectDesktopUIFlags(command *cobra.Command) error {
for _, name := range []string{"address", "port", "no-open"} {
if command.Flags().Changed(name) {
return fmt.Errorf("--%s requires --mode ui", name)
}
}
return nil
}
157 changes: 157 additions & 0 deletions internal/cli/launch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: Apache-2.0

package cli

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/ArdurAI/sith/internal/fleet"
)

func TestResolveLaunchMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requested string
goos string
want launchMode
wantError bool
}{
{name: "macOS auto", requested: "auto", goos: "darwin", want: launchModeDesktop},
{name: "Linux auto", requested: "auto", goos: "linux", want: launchModeUI},
{name: "Windows auto", requested: "auto", goos: "windows", want: launchModeUI},
{name: "macOS desktop", requested: "desktop", goos: "darwin", want: launchModeDesktop},
{name: "Linux desktop", requested: "desktop", goos: "linux", wantError: true},
{name: "explicit UI", requested: "ui", goos: "darwin", want: launchModeUI},
{name: "unknown", requested: "browser", goos: "darwin", wantError: true},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got, err := resolveLaunchMode(test.requested, test.goos)
if test.wantError {
if err == nil {
t.Fatalf("resolveLaunchMode(%q, %q) = %q, want error", test.requested, test.goos, got)
}
return
}
if err != nil || got != test.want {
t.Fatalf("resolveLaunchMode(%q, %q) = %q, %v, want %q", test.requested, test.goos, got, err, test.want)
}
})
}
}

func TestLaunchCommandIsRegistered(t *testing.T) {
stdout, stderr, exitCode := runCLI(t, []string{"--help"}, fleet.StubSource{})
if exitCode != 0 || stderr != "" || !strings.Contains(stdout, "launch") ||
!strings.Contains(stdout, "Open the local fleet IDE") {
t.Fatalf("root help exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr)
}
}

func TestLaunchRejectsUnknownModeBeforeBackendSelection(t *testing.T) {
stdout, stderr, exitCode := runCLI(t, []string{"launch", "--mode", "browser"}, fleet.StubSource{})
if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "invalid launch mode") {
t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr)
}
}

func TestLaunchUIRequiresLocalBackend(t *testing.T) {
stdout, stderr, exitCode := runCLI(t, []string{"launch", "--mode", "ui", "--no-open"}, fleet.StubSource{})
if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "requires a Kubernetes reader") {
t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr)
}
}

func TestLaunchUIStartsOnLoopback(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
stdout, stderr, exitCode := runUICLI(ctx, t, []string{
"launch", "--mode", "ui", "--port", "0", "--no-open",
}, &cacheReader{}, &fakeLocalClient{})
if exitCode != 0 || stderr != "" || !strings.Contains(stdout, "sith ui listening on http://127.0.0.1:") {
t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr)
}
}

func TestSelectUIBackendAcceptsExplicitDirectoryWithoutDefaultBackend(t *testing.T) {
directory := writeLaunchKubeconfig(t)
reader, local, err := selectUIBackend(nil, nil, directory)
if err != nil || reader == nil || local == nil {
t.Fatalf("selectUIBackend() = %#v, %#v, %v, want explicit directory backend", reader, local, err)
}
}

func TestLaunchUIStartsFromExplicitDirectoryWithoutDefaultBackend(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("SITH_KUBECONFIG", "")
ctx, cancel := context.WithCancel(context.Background())
cancel()
var stdout, stderr bytes.Buffer
exitCode := executeBackendContext(ctx, []string{
"launch", "--mode", "ui", "--port", "0", "--no-open",
"--kubeconfig-dir", writeLaunchKubeconfig(t),
}, backend{source: fleet.StubSource{}}, &stdout, &stderr)
if exitCode != 0 || stderr.Len() != 0 ||
!strings.Contains(stdout.String(), "sith ui listening on http://127.0.0.1:") {
t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout.String(), stderr.String())
}
}

func writeLaunchKubeconfig(t *testing.T) string {
t.Helper()
directory := t.TempDir()
config := []byte(`apiVersion: v1
kind: Config
clusters:
- name: demo
cluster:
server: https://demo.invalid
users:
- name: demo
user: {}
contexts:
- name: demo
context:
cluster: demo
user: demo
current-context: demo
`)
if err := os.WriteFile(filepath.Join(directory, "config.yaml"), config, 0o600); err != nil {
t.Fatal(err)
}
return directory
}

func TestDesktopLaunchRejectsUIOnlyFlags(t *testing.T) {
command := &cobra.Command{Use: "launch"}
options := &uiOptions{address: "127.0.0.1"}
bindUIFlags(command, options)
if err := command.ParseFlags([]string{"--no-open"}); err != nil {
t.Fatal(err)
}
if err := rejectDesktopUIFlags(command); err == nil || !strings.Contains(err.Error(), "--mode ui") {
t.Fatalf("rejectDesktopUIFlags() error = %v, want explicit UI-mode guidance", err)
}
}

func TestDesktopLaunchAcceptsSharedKubeconfigDirectoryFlag(t *testing.T) {
command := &cobra.Command{Use: "launch"}
options := &uiOptions{address: "127.0.0.1"}
bindUIFlags(command, options)
if err := command.ParseFlags([]string{"--kubeconfig-dir", t.TempDir()}); err != nil {
t.Fatal(err)
}
if err := rejectDesktopUIFlags(command); err != nil {
t.Fatalf("rejectDesktopUIFlags() error = %v, want shared directory flag accepted", err)
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command {
newClustersCommand(options, runtime.source),
newUICommand(runtime.reader, runtime.local),
newDesktopCommand(runtime.reader, runtime.local),
newLaunchCommand(runtime.reader, runtime.local),
newHubCommand(),
}
if runtime.reader != nil {
Expand Down
38 changes: 27 additions & 11 deletions internal/cli/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,40 @@ func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command
Short: "Start the loopback-only local fleet IDE",
Args: cobra.NoArgs,
RunE: func(command *cobra.Command, _ []string) error {
if reader == nil || local == nil {
return fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client")
selectedReader, selectedLocal, err := selectUIBackend(reader, local, options.kubeconfigDir)
if err != nil {
return err
}
if options.kubeconfigDir != "" {
adapter, err := kubeconfig.New(kubeconfig.WithDirectory(options.kubeconfigDir))
if err != nil {
return fmt.Errorf("import kubeconfig directory: %w", err)
}
reader, local = adapter, adapter
}
return runWebUI(command.Context(), command, reader, local, options)
return runWebUI(command.Context(), command, selectedReader, selectedLocal, options)
},
}
bindUIFlags(command, options)
return command
}

func bindUIFlags(command *cobra.Command, options *uiOptions) {
command.Flags().StringVar(&options.address, "address", options.address, "loopback listen address")
command.Flags().IntVar(&options.port, "port", 0, "loopback listen port; 0 selects an available port")
command.Flags().BoolVar(&options.noOpen, "no-open", false, "do not open the system browser")
command.Flags().StringVar(&options.kubeconfigDir, "kubeconfig-dir", "", "import kubeconfig files from this directory for this local UI session")
return command
}

func selectUIBackend(
reader connector.Reader,
local localops.Client,
kubeconfigDirectory string,
) (connector.Reader, localops.Client, error) {
if kubeconfigDirectory != "" {
adapter, err := kubeconfig.New(kubeconfig.WithDirectory(kubeconfigDirectory))
if err != nil {
return nil, nil, fmt.Errorf("import kubeconfig directory: %w", err)
}
return adapter, adapter, nil
}
if reader == nil || local == nil {
return nil, nil, fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client")
}
return reader, local, nil
}

func runWebUI(
Expand Down
18 changes: 18 additions & 0 deletions sessions/2026-07-23-demo-launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Session — 2026-07-23 — demo-launch

**Builder:** Gnani Rahul Nutakki · **Model/effort:** Codex, high · **Branch:** gnanirahulnutakki/feat/demo-launch
**Slice(s):** Phase-L demo readiness · #315 · **Status:** done

---

[G] Goal: Make the completed local fleet client discoverable through one safe graphical launch command for issue #315.
[S] Scope: `internal/cli`, README first-run guidance, command and launch smoke tests, and this journal. Out of scope: demo fixture deployment, Hub release policy, GHCR visibility, governed writes, and cluster mutation.
[A] Action: Reconciled live `dev`, releases, roadmap/build-sequence demo criteria, existing command surfaces, duplicate issues, and release blockers; selected orchestration over the existing desktop and loopback UI surfaces.
[A] Action: Added `sith launch` with closed `auto|desktop|ui` selection, explicit UI-only flag validation, shared bounded directory import, root registration, and first-run documentation. Refactored the existing UI command to accept an explicit directory without requiring a default kubeconfig backend.
[T] Test: Focused CLI race tests and twenty repeated package runs passed. The built binary launched the loopback UI from the provided self-managed test kubeconfig directory, served HTTP 200, and shut down cleanly without creating a cluster workload.
[T] Test: `make ci`, `make e2e-isolation`, both 50,000-case tenancy fuzzers, Helm and multi-architecture OCI contracts, the real two-cluster Kind suite, `go mod tidy -diff`, `go mod verify`, and the double-build `make release-check` all passed; Kind cleanup left no clusters.
[C] Checkpoint #1: this commit — one-command local demo launcher and complete local verification; next: publish PR into `dev`, require green PR and exact post-merge checks, then resume the broader demo-readiness gap audit.

---

**Session close:** implementation and local verification complete · **Open questions touched:** Q12 — preserve the existing graphical surfaces and add an auto-selecting entry point rather than creating a new UI