Skip to content

Commit ee15d71

Browse files
committed
feat(cli): agentctl apply command (#27)
- Validate, plan, then prompt on TTY or require --auto-approve / AGENTCTL_AUTO_APPROVE - Non-TTY with a non-empty plan errors with guidance (CI-safe) - JSON/YAML output requires approval flags when plan is non-empty - Table success/empty summaries; JSON includes applied + appliedAt - Tests: auto-approve and env approve persist 3 resources; non-interactive blocks with no rows; idempotent second apply; JSON validation Closes #27 Made-with: Cursor
1 parent 5e7eef9 commit ee15d71

5 files changed

Lines changed: 372 additions & 1 deletion

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.22
44

55
require (
66
github.com/google/uuid v1.6.0
7+
github.com/mattn/go-isatty v0.0.20
78
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
89
github.com/spf13/cobra v1.8.1
910
gopkg.in/yaml.v3 v3.0.1
@@ -14,7 +15,6 @@ require (
1415
github.com/dustin/go-humanize v1.0.1 // indirect
1516
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
1617
github.com/inconshreveable/mousetrap v1.1.0 // indirect
17-
github.com/mattn/go-isatty v0.0.20 // indirect
1818
github.com/ncruces/go-strftime v0.1.9 // indirect
1919
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
2020
github.com/spf13/pflag v1.0.5 // indirect

internal/cli/apply.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package cli
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/apply"
14+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
15+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
16+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
17+
"github.com/mattn/go-isatty"
18+
"github.com/spf13/cobra"
19+
)
20+
21+
// EnvAutoApprove is read when true-like to skip the apply confirmation prompt (non-TTY / CI).
22+
const EnvAutoApprove = "AGENTCTL_AUTO_APPROVE"
23+
24+
func newApplyCmd() *cobra.Command {
25+
var autoApprove bool
26+
cmd := &cobra.Command{
27+
Use: "apply",
28+
Short: "Apply desired project state to the deployment store",
29+
SilenceUsage: true,
30+
Long: `Load and validate the project, compute the plan against the SQLite deployment store,
31+
then persist changes unless you decline at the prompt.
32+
33+
Use --auto-approve to skip confirmation, or set ` + EnvAutoApprove + `=1 for non-interactive runs
34+
(CI, scripts). When stdin is not a terminal and the plan is non-empty, one of those is required.
35+
36+
The state database defaults to .agentic/state.db under --project, or project.spec.state.dsn,
37+
unless overridden by global --state.
38+
39+
Exit codes (section 11.2):
40+
0 — success (including nothing to apply)
41+
1 — generic failure (e.g. cannot open SQLite, non-interactive without approval, cancelled)
42+
2 — validation failure (invalid project), or non-table output without approval when the plan is non-empty
43+
3 — plan/apply conflict (reserved for optimistic concurrency; not used in this MVP)`,
44+
RunE: func(cmd *cobra.Command, args []string) error {
45+
_ = args
46+
return runApply(cmd, autoApprove)
47+
},
48+
}
49+
cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "apply without confirmation prompt")
50+
return cmd
51+
}
52+
53+
func envAutoApproveEnabled() bool {
54+
v := strings.TrimSpace(os.Getenv(EnvAutoApprove))
55+
switch strings.ToLower(v) {
56+
case "1", "true", "yes", "on":
57+
return true
58+
default:
59+
return false
60+
}
61+
}
62+
63+
func runApply(cmd *cobra.Command, flagAutoApprove bool) error {
64+
ctx := context.Background()
65+
g := Globals()
66+
approved := flagAutoApprove || envAutoApproveEnabled()
67+
68+
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
69+
if err != nil {
70+
return NewExitError(ExitValidationError, err)
71+
}
72+
73+
env := planEnvironment(g)
74+
dsn, err := resolveStateSQLitePath(root, graph, g.StatePath)
75+
if err != nil {
76+
return fmt.Errorf("apply: resolve state path: %w", err)
77+
}
78+
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
79+
return fmt.Errorf("apply: create state directory: %w", err)
80+
}
81+
82+
st, err := sqlite.Open(ctx, dsn)
83+
if err != nil {
84+
return fmt.Errorf("apply: open sqlite %q: %w", dsn, err)
85+
}
86+
defer func() { _ = st.Close() }()
87+
88+
pl, err := plan.NewPlanner(st).ComputePlan(ctx, env, graph)
89+
if err != nil {
90+
return fmt.Errorf("apply: compute plan: %w", err)
91+
}
92+
93+
if len(pl.Operations) == 0 {
94+
return writeApplyEmptyOutput(cmd, env, dsn, pl, g)
95+
}
96+
97+
if g.Output != render.FormatTable {
98+
if !approved {
99+
return NewExitErrorf(ExitValidationError, "apply: when the plan is non-empty, -o %s requires --auto-approve or %s=1", g.Output, EnvAutoApprove)
100+
}
101+
} else if !approved {
102+
if !isatty.IsTerminal(os.Stdin.Fd()) {
103+
return NewExitErrorf(ExitGenericFailure, "apply: not a terminal; use --auto-approve or set %s=1 to apply without confirmation", EnvAutoApprove)
104+
}
105+
if _, err := fmt.Fprint(cmd.OutOrStdout(), plan.FormatPlan(pl)); err != nil {
106+
return err
107+
}
108+
if _, err := fmt.Fprint(cmd.OutOrStdout(), "\n\n"); err != nil {
109+
return err
110+
}
111+
if _, err := fmt.Fprint(cmd.ErrOrStderr(), "Do you want to apply these changes? [y/N]: "); err != nil {
112+
return err
113+
}
114+
ok, err := readApplyConfirmation(cmd.InOrStdin())
115+
if err != nil {
116+
return fmt.Errorf("apply: read confirmation: %w", err)
117+
}
118+
if !ok {
119+
return NewExitErrorf(ExitGenericFailure, "apply: cancelled")
120+
}
121+
}
122+
123+
at := time.Now().UTC()
124+
if err := apply.NewApplier(st).ApplyPlan(ctx, env, graph, pl, at); err != nil {
125+
return fmt.Errorf("apply: %w", err)
126+
}
127+
128+
return writeApplySuccessOutput(cmd, env, dsn, pl, g, at)
129+
}
130+
131+
func readApplyConfirmation(r io.Reader) (bool, error) {
132+
line, err := bufio.NewReader(r).ReadString('\n')
133+
if err != nil && err != io.EOF {
134+
return false, err
135+
}
136+
s := strings.TrimSpace(strings.ToLower(line))
137+
return s == "y" || s == "yes", nil
138+
}
139+
140+
func writeApplyEmptyOutput(cmd *cobra.Command, env, dsn string, pl *plan.Plan, g *Global) error {
141+
out := cmd.OutOrStdout()
142+
switch g.Output {
143+
case render.FormatJSON:
144+
m := planJSONModel(env, dsn, pl)
145+
m["applied"] = false
146+
m["message"] = "no changes"
147+
return render.WriteJSON(out, m)
148+
case render.FormatYAML:
149+
m := planJSONModel(env, dsn, pl)
150+
m["applied"] = false
151+
m["message"] = "no changes"
152+
return render.WriteYAML(out, m)
153+
default:
154+
_, err := fmt.Fprintf(out, "Environment: %s\nState: %s\n\nNo changes. Deployment already matches the project.\n", env, dsn)
155+
return err
156+
}
157+
}
158+
159+
func writeApplySuccessOutput(cmd *cobra.Command, env, dsn string, pl *plan.Plan, g *Global, at time.Time) error {
160+
out := cmd.OutOrStdout()
161+
c, u, d := planCounts(pl)
162+
switch g.Output {
163+
case render.FormatJSON:
164+
m := planJSONModel(env, dsn, pl)
165+
m["applied"] = true
166+
m["appliedAt"] = at.Format(time.RFC3339Nano)
167+
return render.WriteJSON(out, m)
168+
case render.FormatYAML:
169+
m := planJSONModel(env, dsn, pl)
170+
m["applied"] = true
171+
m["appliedAt"] = at.Format(time.RFC3339Nano)
172+
return render.WriteYAML(out, m)
173+
default:
174+
_, err := fmt.Fprintf(out, "Environment: %s\nState: %s\n\nApply complete. (%d added, %d changed, %d deleted)\n", env, dsn, c, u, d)
175+
return err
176+
}
177+
}

internal/cli/apply_test.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"io"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
13+
)
14+
15+
func TestApply_autoApprove_updatesState(t *testing.T) {
16+
root := t.TempDir()
17+
copyPlanFixture(t, root)
18+
db := filepath.Join(t.TempDir(), "apply-auto.db")
19+
20+
ResetGlobalsForTest()
21+
cmd := NewRootCmd()
22+
cmd.SetOut(io.Discard)
23+
cmd.SetErr(io.Discard)
24+
cmd.SetArgs([]string{"apply", "--project", root, "--state", db, "--auto-approve"})
25+
if err := cmd.Execute(); err != nil {
26+
t.Fatal(err)
27+
}
28+
29+
ctx := context.Background()
30+
st, err := sqlite.Open(ctx, db)
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
t.Cleanup(func() { _ = st.Close() })
35+
list, err := st.ListAppliedResourcesByEnv(ctx, "local")
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
if len(list) != 3 {
40+
t.Fatalf("want 3 applied resources, got %d: %+v", len(list), list)
41+
}
42+
}
43+
44+
func TestApply_envAutoApprove_updatesState(t *testing.T) {
45+
t.Setenv(EnvAutoApprove, "1")
46+
root := t.TempDir()
47+
copyPlanFixture(t, root)
48+
db := filepath.Join(t.TempDir(), "apply-env.db")
49+
50+
ResetGlobalsForTest()
51+
cmd := NewRootCmd()
52+
cmd.SetOut(io.Discard)
53+
cmd.SetErr(io.Discard)
54+
cmd.SetArgs([]string{"apply", "--project", root, "--state", db})
55+
if err := cmd.Execute(); err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
ctx := context.Background()
60+
st, err := sqlite.Open(ctx, db)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
t.Cleanup(func() { _ = st.Close() })
65+
list, err := st.ListAppliedResourcesByEnv(ctx, "local")
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
if len(list) != 3 {
70+
t.Fatalf("want 3 applied resources, got %d", len(list))
71+
}
72+
}
73+
74+
func TestApply_nonInteractive_requiresApproval(t *testing.T) {
75+
t.Setenv(EnvAutoApprove, "")
76+
77+
root := t.TempDir()
78+
copyPlanFixture(t, root)
79+
db := filepath.Join(t.TempDir(), "apply-no-tty.db")
80+
81+
ResetGlobalsForTest()
82+
cmd := NewRootCmd()
83+
cmd.SetOut(io.Discard)
84+
cmd.SetErr(io.Discard)
85+
cmd.SetIn(strings.NewReader(""))
86+
cmd.SetArgs([]string{"apply", "--project", root, "--state", db})
87+
err := cmd.Execute()
88+
if err == nil {
89+
t.Fatal("expected error when non-TTY and no approval")
90+
}
91+
if ExitCodeOf(err) != ExitGenericFailure {
92+
t.Fatalf("exit code=%d err=%v", ExitCodeOf(err), err)
93+
}
94+
if !strings.Contains(err.Error(), "not a terminal") {
95+
t.Fatalf("unexpected error: %v", err)
96+
}
97+
98+
ctx := context.Background()
99+
st, err := sqlite.Open(ctx, db)
100+
if err != nil {
101+
t.Fatal(err)
102+
}
103+
t.Cleanup(func() { _ = st.Close() })
104+
list, err := st.ListAppliedResourcesByEnv(ctx, "local")
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
if len(list) != 0 {
109+
t.Fatalf("expected no applied rows, got %d", len(list))
110+
}
111+
}
112+
113+
func TestApply_emptyPlan_afterFirstApply(t *testing.T) {
114+
root := t.TempDir()
115+
copyPlanFixture(t, root)
116+
db := filepath.Join(t.TempDir(), "apply-idem.db")
117+
118+
ResetGlobalsForTest()
119+
cmd1 := NewRootCmd()
120+
cmd1.SetOut(io.Discard)
121+
cmd1.SetErr(io.Discard)
122+
cmd1.SetArgs([]string{"apply", "--project", root, "--state", db, "--auto-approve"})
123+
if err := cmd1.Execute(); err != nil {
124+
t.Fatal(err)
125+
}
126+
127+
ResetGlobalsForTest()
128+
var out bytes.Buffer
129+
cmd2 := NewRootCmd()
130+
cmd2.SetOut(&out)
131+
cmd2.SetErr(io.Discard)
132+
cmd2.SetArgs([]string{"apply", "--project", root, "--state", db, "--auto-approve"})
133+
if err := cmd2.Execute(); err != nil {
134+
t.Fatal(err)
135+
}
136+
if !strings.Contains(out.String(), "No changes") {
137+
t.Fatalf("expected no changes message, got:\n%s", out.String())
138+
}
139+
}
140+
141+
func TestApply_jsonNonEmpty_requiresAutoApprove(t *testing.T) {
142+
root := t.TempDir()
143+
copyPlanFixture(t, root)
144+
db := filepath.Join(t.TempDir(), "apply-json.db")
145+
146+
ResetGlobalsForTest()
147+
cmd := NewRootCmd()
148+
cmd.SetOut(io.Discard)
149+
cmd.SetErr(io.Discard)
150+
cmd.SetArgs([]string{"apply", "-o", "json", "--project", root, "--state", db})
151+
err := cmd.Execute()
152+
if err == nil {
153+
t.Fatal("expected error")
154+
}
155+
if ExitCodeOf(err) != ExitValidationError {
156+
t.Fatalf("exit code=%d err=%v", ExitCodeOf(err), err)
157+
}
158+
}
159+
160+
func TestApply_jsonAutoApprove_validJSON(t *testing.T) {
161+
root := t.TempDir()
162+
copyPlanFixture(t, root)
163+
db := filepath.Join(t.TempDir(), "apply-json2.db")
164+
165+
ResetGlobalsForTest()
166+
var out bytes.Buffer
167+
cmd := NewRootCmd()
168+
cmd.SetOut(&out)
169+
cmd.SetErr(io.Discard)
170+
cmd.SetArgs([]string{"apply", "-o", "json", "--project", root, "--state", db, "--auto-approve"})
171+
if err := cmd.Execute(); err != nil {
172+
t.Fatal(err)
173+
}
174+
raw := bytes.TrimSpace(out.Bytes())
175+
if !json.Valid(raw) {
176+
t.Fatalf("invalid json: %s", raw)
177+
}
178+
var m map[string]any
179+
if err := json.Unmarshal(raw, &m); err != nil {
180+
t.Fatal(err)
181+
}
182+
applied, ok := m["applied"].(bool)
183+
if !ok || !applied {
184+
t.Fatalf("applied: %v", m["applied"])
185+
}
186+
ops, ok := m["operations"].([]any)
187+
if !ok || len(ops) != 3 {
188+
t.Fatalf("operations: %v", m["operations"])
189+
}
190+
}

internal/cli/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,7 @@
77
// The plan command compares that prepared graph to the SQLite deployment store (default
88
// .agentic/state.db, or project.spec.state.dsn / --state) and prints a diff plus risk delta
99
// via [plan.ComputePlan] and [plan.FormatPlan].
10+
//
11+
// The apply command runs the same preparation and planning, then prompts on a TTY (unless
12+
// --auto-approve or AGENTCTL_AUTO_APPROVE) and persists via [apply.Applier.ApplyPlan].
1013
package cli

0 commit comments

Comments
 (0)