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
21 changes: 21 additions & 0 deletions .github/actions/nix-bun/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Bun from Nix
description: >
Install Nix if needed and put this flake's Bun on PATH. The version is
pkgs.bun (the overlay in nix/flake/overlays.nix). Do not pass a version.

runs:
using: composite
steps:
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
extra-conf: |
accept-flake-config = true

- name: Add the flake's Bun to PATH
shell: bash
run: |
set -euo pipefail
out="$(nix build --no-link --print-out-paths .#bun)"
echo "$out/bin" >> "$GITHUB_PATH"
echo "bun $($out/bin/bun --version) ($out)"
4 changes: 1 addition & 3 deletions .github/workflows/ci-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ jobs:
- uses: actions/checkout@v7

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.2"
uses: ./.github/actions/nix-bun

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/deploy-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,8 @@ jobs:
- name: Stage Fly secrets from SOPS
run: bash apps/api/scripts/push-secrets.sh

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
- name: Set up Bun
uses: ./.github/actions/nix-bun

- name: Build api bundle (produces apps/api/.output for the container)
if: inputs.skip_build != true
Expand Down
10 changes: 4 additions & 6 deletions .github/workflows/deploy-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,8 @@ jobs:
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.2"
- name: Set up Bun
uses: ./.github/actions/nix-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Stamp build info for memo invalidation
Expand Down Expand Up @@ -179,9 +178,8 @@ jobs:
pull-requests: write # sticky comment
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.2"
- name: Set up Bun
uses: ./.github/actions/nix-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Destroy PR preview
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/deploy-web.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ jobs:
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- name: Set up Bun
uses: ./.github/actions/nix-bun
- name: Install dependencies
run: bun install --frozen-lockfile
# Invalidate Cloudflare.Vite's content-hash memo on every push.
Expand Down Expand Up @@ -145,7 +146,8 @@ jobs:
pull-requests: write # sticky comment
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- name: Set up Bun
uses: ./.github/actions/nix-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Destroy PR preview
Expand Down
63 changes: 55 additions & 8 deletions apps/docs/content/docs/cli/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,41 @@ stack setup --experimental-agent=codex --yes # accept its onboarding plan autom

## Experimental agent onboarding

### Embedded Pi Go experiment

Use the pinned `sky-valley/pi` Go library directly, without launching an agent
CLI. Select a provider/model explicitly and supply its API key in the environment:

```bash
# OPENAI_API_KEY must be set in your environment (API usage is metered).
nix run .#stackpanel-go -- setup --experimental-agent=pi \
--agent-model=openai/gpt-5 --tmp --template minimal --restart

# Alternatively, set ANTHROPIC_API_KEY and select an Anthropic model.
stack setup --experimental-agent=pi --agent-model=anthropic/claude-sonnet-4-5
```

Pi reuses the same questions, plan review, deterministic doctor, and Studio
connection flow. Its conversation and tool results are checkpointed into the
private setup manifest; rerun the same command to resume. `--restart` discards
the conversation and reviews a fresh plan. The current setup instructions are
supplied as a system prompt instead of repeated in each saved user message.

This experiment exposes Pi's `read`, `ls`, `write`, and `edit` tools, with checks
for paths outside the repository, symlinks, private state, and protected user
files. It does not expose shell tools or load Pi hooks, skills, or parent
configuration automatically. These checks are application policy, not an OS
sandbox. Stackpanel runs Nix and doctor on the host.

The Go port currently requires Go 1.26 and does not implement Codex subscription
transport or OAuth login. This backend reads `OPENAI_API_KEY` or
`ANTHROPIC_API_KEY`; it never imports subscription tokens or saves API keys in the
manifest. Choose `--experimental-agent=codex` or `claude` explicitly to fall back
to an installed CLI. Plain `auto` retains CLI discovery; `auto` with
`--agent-model` selects Pi. There is no automatic backend switch after edits.

### Installed CLI backends

`--experimental-agent` delegates repository inspection and configuration to an
installed coding agent, then verifies the result with `stack doctor`.
Codex and Claude Code are supported when their installed versions expose the
Expand All @@ -70,11 +105,13 @@ With `auto`, a single supported agent is selected automatically. Multiple agents
produce a picker in an interactive terminal; noninteractive runs must select one
explicitly. `--yes` and `--non-interactive` accept the proposed onboarding plan.

The wizard uses the CLI's shared terminal theme, with a stage indicator, elapsed
time, and a scrollable plan. Use arrow keys to choose, Space to toggle multiple
selections, and Enter to continue. Text answers support cursor editing and paste.
PgUp/PgDn scroll the plan while Apply, Revise, and Cancel remain visible. Ctrl+D
shows recent agent activity during setup; Esc or Ctrl+C cancels.
The wizard runs full screen, centered in the terminal, and uses the CLI's shared
terminal theme, with a stage indicator, elapsed time, and a scrollable plan. Use
arrow keys to choose, Space to toggle multiple selections, and Enter to continue.
Text answers support cursor editing and paste. PgUp/PgDn scroll the plan while
Apply, Revise, and Cancel remain visible. Ctrl+D shows recent agent activity
during setup; Esc or Ctrl+C cancels. When the wizard closes, the terminal returns
to its previous contents, followed by the outcome and any warnings.

The wizard asks questions when choices are unresolved and lets you apply, revise,
or cancel a concrete plan. Question rounds preserve
Expand Down Expand Up @@ -112,13 +149,22 @@ independently of the target repository, so a broken configuration can still be
repaired. Older saved sessions gain this schema before the next agent invocation;
their answers, accepted plan, template, and framework revision are retained.

Stackpanel then enters a fresh Nix shell, reconciles generated files, and runs:
A plan can list host preparation commands, such as `bun install`, for the
dependencies and lockfiles a sandboxed agent cannot produce. The plan review shows
them, and accepting the plan approves them. Stackpanel runs them unchanged in the
repository devshell, with network access, after reconciliation and before every
doctor run. Lockfiles they create become visible to Git-backed Nix evaluation when
the plan lists them as files. Doctor itself never installs anything, so
`doctor --onboarding` does not run them.

Stackpanel then enters a fresh Nix shell, reconciles generated files, runs the
plan's host preparation, and runs:

```bash
stack doctor --strict --scope repo,build --build --expectations /path/to/expectations.json --json
```

Only doctor determines success. Failed input locking, reconciliation, or verification is sent
Only doctor determines success. Failed input locking, reconciliation, host preparation, or verification is sent
back for one repair attempt, followed by another fresh reconciliation and doctor
run. Checks observed on the first doctor run are also required during repair, so
removing a failing check cannot make repair pass. Unavailable required permissions,
Expand Down Expand Up @@ -265,7 +311,8 @@ denials stop the experiment with the provider's diagnostic.
| `--no-browser` | `bool` | `false` | Verify runtime without opening the browser |
| `--no-runtime` | `bool` | `false` | Verify repository only |
| `--agent-log` | `string` | _none_ | Private raw provider debug log; path must not exist |
| `--experimental-agent` | `string` | _none_ | Experimental repository onboarding using `auto`, `codex`, or `claude` |
| `--experimental-agent` | `string` | _none_ | Experimental repository onboarding using `auto`, `codex`, `claude`, or `pi` |
| `--agent-model` | `string` | _none_ | Pi API provider/model, saved for resume; requires an API key |
| `--restart` | `bool` | `false` | Review a new agent onboarding plan; with `--tmp`, create a fresh repository |

## The discovery ledger
Expand Down
7 changes: 6 additions & 1 deletion apps/stackpanel-go/cmd/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type setupFlags struct {
addonValues []string
build bool
experimentalAgent string
agentModel string
agentPort int
newDir string
studioURL string
Expand Down Expand Up @@ -101,7 +102,8 @@ Examples:

func init() {
f := setupCmd.Flags()
f.StringVar(&setupOpts.experimentalAgent, "experimental-agent", "", "Use an installed coding agent for repository onboarding (auto, codex, claude)")
f.StringVar(&setupOpts.experimentalAgent, "experimental-agent", "", "Repository onboarding backend (auto, codex, claude, pi)")
f.StringVar(&setupOpts.agentModel, "agent-model", "", "Pi API model: openai/<model> or anthropic/<model> (requires an API key)")
f.IntVar(&setupOpts.agentPort, "agent-port", 0, "Local agent port (defaults to agent configuration)")
f.StringVar(&setupOpts.newDir, "new", "", "Create a new repository in an absent or empty directory (requires --experimental-agent)")
f.StringVar(&setupOpts.studioURL, "studio-url", "", "Studio URL to open after experimental setup")
Expand Down Expand Up @@ -200,6 +202,9 @@ func runSetup(cmd *cobra.Command, args []string) error {
// runSetupWith is the body of `stack setup`, parameterized by flags so tests
// can drive it.
func runSetupWith(cmd *cobra.Command, opts setupFlags) error {
if opts.agentModel != "" && opts.experimentalAgent == "" {
return errors.New("--agent-model requires --experimental-agent=pi")
}
if opts.restart && (opts.experimentalAgent == "" || opts.json || opts.dryRun) {
return errors.New("--restart requires --experimental-agent and cannot be combined with --json or --dry-run")
}
Expand Down
56 changes: 54 additions & 2 deletions apps/stackpanel-go/cmd/cli/setup_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ func runAgentSetup(cmd *cobra.Command, opts setupFlags) (retErr error) {
if len(opts.only) > 0 || len(opts.skip) > 0 || opts.reconsider {
return errors.New("--experimental-agent cannot be combined with --only, --skip or --reconsider")
}
if opts.agentModel != "" && opts.experimentalAgent != "pi" && opts.experimentalAgent != "auto" {
return errors.New("--agent-model requires --experimental-agent=pi")
}
noTUI, _ := cmd.Flags().GetBool("no-tui")
daemon, _ := cmd.Flags().GetBool("daemon")
interactive := tui.IsInteractiveStdio() && !opts.yes && !opts.nonInteractive && !noTUI && !daemon
Expand Down Expand Up @@ -71,23 +74,45 @@ func runAgentSetup(cmd *cobra.Command, opts setupFlags) (retErr error) {
}
}
ui.Progress("Setup manifest: " + state.path)
if opts.experimentalAgent == "auto" && opts.agentModel != "" {
opts.experimentalAgent = "pi"
}
var agent setupagent.Agent
if opts.experimentalAgent == "pi" && (state.Stage == "inspection" || state.Stage == "apply") {
agent, err = setupagent.NewPiAgent(opts.agentModel)
if err != nil {
return err
}
}
agentReady := false
ensureCodingAgent := func() error {
if agent.Path != "" {
if agentReady {
return nil
}
if opts.experimentalAgent == "pi" && agent.ID == "" {
agent, err = setupagent.NewPiAgent(opts.agentModel)
if err != nil {
return err
}
}
ui.Stage(tui.SetupInspect, "Loading the pinned Stackpanel configuration schema…")
if err := ensureSetupOptionContext(cmd.Context(), &state.Request); err != nil {
return err
}
ui.Stage(tui.SetupInspect, "Finding available coding agents…")
var err error
agent, err = selectSetupAgent(cmd.Context(), opts.experimentalAgent, interactive, ui)
if agent.ID == "" {
agent, err = selectSetupAgent(cmd.Context(), opts.experimentalAgent, interactive, ui)
}
if err != nil {
return err
}
state.Agent = agent.ID
agentReady = true
ui.Identify(state.Root, agent.ID)
if agent.ID == "pi" {
ui.Progress("Pi · " + opts.agentModel + " · direct API (metered usage)")
}
return state.save()
}
root := state.Root
Expand Down Expand Up @@ -349,6 +374,21 @@ func verifyAgentSetup(ctx context.Context, root, work, executable string, plan *
if err := state.checkpoint(ctx, guard); err != nil {
return nil, err
}
for _, step := range plan.Prepare {
ui.Progress(fmt.Sprintf("Preparing %s · %s", step.ID, setupCommandLabel(step.Argv)))
if err := runSetupPrepare(ctx, root, step, debug); err != nil {
return nil, fmt.Errorf("host preparation %s failed: %w", step.ID, err)
}
}
if len(plan.Prepare) > 0 {
// Lockfiles and manifests the commands created are Nix inputs too.
if err := guard.AddNixInputs(ctx, plan.Expectations.Files...); err != nil {
return nil, err
}
if err := state.checkpoint(ctx, guard); err != nil {
return nil, err
}
}
// Never trust a file the coding agent could have modified during its turn.
frozen, err := json.Marshal(plan.Expectations)
if err != nil {
Expand Down Expand Up @@ -541,6 +581,18 @@ func runFreshReconciliation(ctx context.Context, root, stackExecutable string, o
return runSetupShell(ctx, root, out, stackExecutable, "setup", "--yes", "--only", "codegen,files,fileops")
}

// runSetupPrepare runs one plan-approved command unchanged in a fresh devshell,
// like doctor's acceptance commands. Setup owns it so doctor stays a verifier.
func runSetupPrepare(ctx context.Context, root string, step setupagent.PrepareCommand, out io.Writer) error {
dir, err := reconcile.AcceptancePath(root, step.Dir)
if err != nil {
return err
}
// The directory is a positional argument, never interpolated into shell code.
args := []string{"bash", "--noprofile", "--norc", "-c", `cd -- "$1" && shift && exec "$@"`, "stackpanel-prepare", dir}
return runSetupShell(ctx, root, out, append(args, step.Argv...)...)
}

// runSetupLock owns the daemon-dependent operation. A separate output file
// prevents Nix from staging flake.lock and lets us preserve existing user edits.
func runSetupLock(ctx context.Context, root, work string, guard *setupGitGuard, out io.Writer) error {
Expand Down
17 changes: 17 additions & 0 deletions apps/stackpanel-go/cmd/cli/setup_agent_conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ func runAgentPhase(ctx context.Context, agent setupagent.Agent, request *setupag
if state == nil {
state = &setupManifest{}
}
if agent.ID == "pi" && state.Pi == nil {
state.Pi = &setupagent.PiState{}
}
for round := 0; round < 8; round++ {
if err := answerSetupQuestions(state, request, ui); err != nil {
return nil, err
Expand All @@ -25,6 +28,14 @@ func runAgentPhase(ctx context.Context, agent setupagent.Agent, request *setupag
Dir: request.Root, Env: freshSetupEnvironment(os.Environ()), ReadOnly: phase == setupagent.Inspection,
Prompt: setupagent.BuildPrompt(*request, phase, plan, failure), Timeout: setupStageTimeout,
Stdout: debug, Stderr: debug,
ProtectedPaths: request.ProtectedPaths,
PiState: state.Pi,
SavePiState: func() error {
if state.path != "" {
return state.save()
}
return nil
},
OnEvent: func(event setupagent.Event) {
if event.Kind == "warning" {
ui.Warning(event.Text)
Expand Down Expand Up @@ -111,6 +122,12 @@ func renderSetupPlan(plan *setupagent.Plan, opts setupFlags) string {
fmt.Fprintf(&s, " %s%s\n", strings.Join(c.Path, "."), description)
}
}
if len(plan.Prepare) > 0 {
s.WriteString("\nHost preparation · runs on this machine before doctor\n")
for _, c := range plan.Prepare {
fmt.Fprintf(&s, " %s · %s\n %s\n", c.ID, c.Dir, setupCommandLabel(c.Argv))
}
}
if len(plan.Expectations.Commands) > 0 {
s.WriteString("\nBuild & test\n")
for _, c := range plan.Expectations.Commands {
Expand Down
5 changes: 5 additions & 0 deletions apps/stackpanel-go/cmd/cli/setup_agent_conversation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ func TestSetupPlanDescribesVerificationScope(t *testing.T) {
t.Fatalf("plan promises the wrong verification scope: %s", got)
}
}
prepared := renderSetupPlan(&setupagent.Plan{Summary: "Create the app", Prepare: []setupagent.PrepareCommand{
{ID: "deps", Dir: "apps/web", Argv: []string{"bun", "install", "--cwd", "a b"}}}}, setupFlags{})
if !strings.Contains(prepared, "Host preparation") || !strings.Contains(prepared, "deps · apps/web\n bun install --cwd \"a b\"") {
t.Fatalf("review hides the commands the host will run: %s", prepared)
}
if got := setupCommandLabel([]string{"go", "test", "./...", "a b"}); got != `go test ./... "a b"` {
t.Fatalf("command argument boundaries lost in review: %s", got)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/stackpanel-go/cmd/cli/setup_agent_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func runSetupAgentReply(ctx context.Context, agent setupagent.Agent, phase setup
result, err := setupagent.Run(ctx, agent, request)
var formatErr *setupagent.ReplyFormatError
if err != nil && !errors.As(err, &formatErr) {
return nil, fmt.Errorf("%s %s: %w (check the CLI's login and permissions; --agent-log records diagnostics)", agent.ID, phase, err)
return nil, fmt.Errorf("%s %s: %w (check the backend's credentials and permissions; --agent-log records diagnostics)", agent.ID, phase, err)
}
reply, parseErr := setupagent.ParseReply(result.Message)
if parseErr == nil {
Expand Down
Loading
Loading