Skip to content

Commit 7fba591

Browse files
committed
feat(cli): agentctl plan command (#26)
- Add plan: prepare graph (shared load.go), resolve SQLite path (statepath.go), ComputePlan + FormatPlan; json/yaml/table output - Default state DB .agentic/state.db; override --state or project.spec.state.dsn - Plan env from -e or "local"; exit 2 on validation, 1 on store errors - Tests: first plan 3 creates; after apply empty plan; policy cost bump + risk - Refactor validate to use prepareProjectGraph Closes #26 Made-with: Cursor
1 parent b2b522f commit 7fba591

11 files changed

Lines changed: 472 additions & 20 deletions

File tree

internal/cli/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,8 @@
33
//
44
// The validate command (section 10.2) loads the project, applies defaults and optional environment
55
// overlays, then runs [spec.ValidateProjectGraph].
6+
//
7+
// The plan command compares that prepared graph to the SQLite deployment store (default
8+
// .agentic/state.db, or project.spec.state.dsn / --state) and prints a diff plus risk delta
9+
// via [plan.ComputePlan] and [plan.FormatPlan].
610
package cli

internal/cli/load.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/project"
8+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/runtime/local"
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
10+
)
11+
12+
// prepareProjectGraph loads the project from disk, applies defaults and optional environment
13+
// overlays, and validates. projectRoot is the directory containing project.yaml (typically
14+
// [Global.ProjectRoot]).
15+
func prepareProjectGraph(projectRoot string, g *Global) (*spec.ProjectGraph, string, error) {
16+
root, err := filepath.Abs(filepath.Clean(projectRoot))
17+
if err != nil {
18+
return nil, "", fmt.Errorf("project root: %w", err)
19+
}
20+
graph, err := project.LoadProject(root)
21+
if err != nil {
22+
return nil, root, err
23+
}
24+
spec.NormalizeProjectGraph(graph)
25+
graph, err = local.ApplyEnvironment(graph, g.Env)
26+
if err != nil {
27+
return nil, root, err
28+
}
29+
if err := spec.ValidateProjectGraph(graph, root); err != nil {
30+
return nil, root, err
31+
}
32+
return graph, root, nil
33+
}

internal/cli/plan.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
func newPlanCmd() *cobra.Command {
18+
return &cobra.Command{
19+
Use: "plan",
20+
Short: "Show desired vs applied deployment diff",
21+
SilenceUsage: true,
22+
Long: `Compare the validated project graph to rows in the SQLite deployment store and print
23+
a summary with create/update/delete lines plus a risk delta (design doc section 10.2).
24+
25+
The state database defaults to .agentic/state.db under --project, or project.spec.state.dsn,
26+
unless overridden by global --state.
27+
28+
Environment for stored rows is taken from -e / --env when set, otherwise "local".
29+
30+
Exit codes (section 11.2):
31+
0 — success
32+
1 — generic failure (e.g. cannot open SQLite)
33+
2 — validation failure (invalid project)
34+
3 — plan/apply conflict (reserved; not used in this MVP)`,
35+
RunE: runPlan,
36+
}
37+
}
38+
39+
func planEnvironment(g *Global) string {
40+
if g == nil {
41+
return "local"
42+
}
43+
if s := strings.TrimSpace(g.Env); s != "" {
44+
return s
45+
}
46+
return "local"
47+
}
48+
49+
func runPlan(cmd *cobra.Command, args []string) error {
50+
_ = args
51+
ctx := context.Background()
52+
g := Globals()
53+
54+
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
55+
if err != nil {
56+
return NewExitError(ExitValidationError, err)
57+
}
58+
59+
env := planEnvironment(g)
60+
dsn, err := resolveStateSQLitePath(root, graph, g.StatePath)
61+
if err != nil {
62+
return fmt.Errorf("plan: resolve state path: %w", err)
63+
}
64+
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
65+
return fmt.Errorf("plan: create state directory: %w", err)
66+
}
67+
68+
st, err := sqlite.Open(ctx, dsn)
69+
if err != nil {
70+
return fmt.Errorf("plan: open sqlite %q: %w", dsn, err)
71+
}
72+
defer func() { _ = st.Close() }()
73+
74+
pl, err := plan.NewPlanner(st).ComputePlan(ctx, env, graph)
75+
if err != nil {
76+
return fmt.Errorf("plan: compute: %w", err)
77+
}
78+
79+
return writePlanOutput(cmd, env, dsn, pl, g)
80+
}
81+
82+
func writePlanOutput(cmd *cobra.Command, env, dsn string, p *plan.Plan, g *Global) error {
83+
out := cmd.OutOrStdout()
84+
switch g.Output {
85+
case render.FormatJSON:
86+
return writePlanJSON(out, env, dsn, p)
87+
case render.FormatYAML:
88+
return render.WriteYAML(out, planJSONModel(env, dsn, p))
89+
default:
90+
if _, err := fmt.Fprintf(out, "Environment: %s\nState: %s\n\n", env, dsn); err != nil {
91+
return err
92+
}
93+
_, err := fmt.Fprint(out, plan.FormatPlan(p))
94+
return err
95+
}
96+
}
97+
98+
func planJSONModel(env, dsn string, p *plan.Plan) map[string]any {
99+
if p == nil {
100+
return map[string]any{
101+
"environment": env,
102+
"statePath": dsn,
103+
"summary": map[string]any{"add": 0, "change": 0, "delete": 0},
104+
"operations": []map[string]any{},
105+
"risk": []string{},
106+
}
107+
}
108+
nC, nU, nD := planCounts(p)
109+
ops := make([]map[string]any, 0, len(p.Operations))
110+
for _, op := range p.Operations {
111+
entry := map[string]any{
112+
"action": op.Action,
113+
"target": op.Target.String(),
114+
}
115+
if len(op.Diff) > 0 {
116+
diffs := make([]map[string]any, len(op.Diff))
117+
for i, d := range op.Diff {
118+
diffs[i] = map[string]any{"path": d.Path, "old": d.Old, "new": d.New}
119+
}
120+
entry["diff"] = diffs
121+
}
122+
ops = append(ops, entry)
123+
}
124+
return map[string]any{
125+
"environment": env,
126+
"statePath": dsn,
127+
"summary": map[string]any{
128+
"add": nC,
129+
"change": nU,
130+
"delete": nD,
131+
},
132+
"operations": ops,
133+
"risk": riskStrings(p),
134+
}
135+
}
136+
137+
func riskStrings(p *plan.Plan) []string {
138+
if p == nil || len(p.Risk.Messages) == 0 {
139+
return []string{}
140+
}
141+
return p.Risk.Messages
142+
}
143+
144+
func writePlanJSON(w io.Writer, env, dsn string, p *plan.Plan) error {
145+
return render.WriteJSON(w, planJSONModel(env, dsn, p))
146+
}
147+
148+
func planCounts(p *plan.Plan) (create, update, delete int) {
149+
if p == nil {
150+
return 0, 0, 0
151+
}
152+
for _, op := range p.Operations {
153+
switch op.Action {
154+
case plan.ActionCreate:
155+
create++
156+
case plan.ActionUpdate:
157+
update++
158+
case plan.ActionDelete:
159+
delete++
160+
}
161+
}
162+
return create, update, delete
163+
}

internal/cli/plan_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/apply"
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
14+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
15+
)
16+
17+
func copyPlanFixture(t *testing.T, dstDir string) {
18+
t.Helper()
19+
src := filepath.Join("testdata", "plan_project")
20+
entries, err := os.ReadDir(src)
21+
if err != nil {
22+
t.Fatal(err)
23+
}
24+
for _, e := range entries {
25+
if e.IsDir() {
26+
continue
27+
}
28+
b, err := os.ReadFile(filepath.Join(src, e.Name()))
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
if err := os.WriteFile(filepath.Join(dstDir, e.Name()), b, 0o644); err != nil {
33+
t.Fatal(err)
34+
}
35+
}
36+
}
37+
38+
func TestPlan_firstPlan_allCreates(t *testing.T) {
39+
root := t.TempDir()
40+
copyPlanFixture(t, root)
41+
db := filepath.Join(t.TempDir(), "plan1.db")
42+
43+
ResetGlobalsForTest()
44+
cmd := NewRootCmd()
45+
var out bytes.Buffer
46+
cmd.SetOut(&out)
47+
cmd.SetErr(&out)
48+
cmd.SetArgs([]string{"plan", "--project", root, "--state", db})
49+
if err := cmd.Execute(); err != nil {
50+
t.Fatal(err)
51+
}
52+
s := out.String()
53+
if !strings.Contains(s, "Plan: 3 to add, 0 to change, 0 to delete") {
54+
t.Fatalf("summary missing in:\n%s", s)
55+
}
56+
for _, line := range []string{"+ create Project/plan-fixture", "+ create Policy/default", "+ create Tool/helper"} {
57+
if !strings.Contains(s, line) {
58+
t.Fatalf("missing %q in:\n%s", line, s)
59+
}
60+
}
61+
}
62+
63+
func TestPlan_afterApply_noChanges(t *testing.T) {
64+
ctx := context.Background()
65+
root := t.TempDir()
66+
copyPlanFixture(t, root)
67+
db := filepath.Join(t.TempDir(), "plan2.db")
68+
69+
g := &Global{ProjectRoot: root}
70+
graph, _, err := prepareProjectGraph(root, g)
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
st, err := sqlite.Open(ctx, db)
75+
if err != nil {
76+
t.Fatal(err)
77+
}
78+
t.Cleanup(func() { _ = st.Close() })
79+
pl, err := plan.NewPlanner(st).ComputePlan(ctx, "local", graph)
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
if err := apply.NewApplier(st).ApplyPlan(ctx, "local", graph, pl, time.Now().UTC()); err != nil {
84+
t.Fatal(err)
85+
}
86+
_ = st.Close()
87+
88+
ResetGlobalsForTest()
89+
cmd := NewRootCmd()
90+
var out bytes.Buffer
91+
cmd.SetOut(&out)
92+
cmd.SetErr(&out)
93+
cmd.SetArgs([]string{"plan", "--project", root, "--state", db})
94+
if err := cmd.Execute(); err != nil {
95+
t.Fatal(err)
96+
}
97+
s := out.String()
98+
if !strings.Contains(s, "Plan: 0 to add, 0 to change, 0 to delete") {
99+
t.Fatalf("expected empty plan:\n%s", s)
100+
}
101+
}
102+
103+
func TestPlan_policyCostIncrease_riskDelta(t *testing.T) {
104+
ctx := context.Background()
105+
root := t.TempDir()
106+
copyPlanFixture(t, root)
107+
db := filepath.Join(t.TempDir(), "plan3.db")
108+
109+
g := &Global{ProjectRoot: root}
110+
graph, _, err := prepareProjectGraph(root, g)
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
st, err := sqlite.Open(ctx, db)
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
pl0, err := plan.NewPlanner(st).ComputePlan(ctx, "local", graph)
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
if err := apply.NewApplier(st).ApplyPlan(ctx, "local", graph, pl0, time.Now().UTC()); err != nil {
123+
t.Fatal(err)
124+
}
125+
_ = st.Close()
126+
127+
policyPath := filepath.Join(root, "policy.yaml")
128+
b, err := os.ReadFile(policyPath)
129+
if err != nil {
130+
t.Fatal(err)
131+
}
132+
updated := strings.Replace(string(b), "maxTotalCostUsd: 3", "maxTotalCostUsd: 10", 1)
133+
if err := os.WriteFile(policyPath, []byte(updated), 0o644); err != nil {
134+
t.Fatal(err)
135+
}
136+
137+
ResetGlobalsForTest()
138+
cmd := NewRootCmd()
139+
var out bytes.Buffer
140+
cmd.SetOut(&out)
141+
cmd.SetErr(&out)
142+
cmd.SetArgs([]string{"plan", "--project", root, "--state", db})
143+
if err := cmd.Execute(); err != nil {
144+
t.Fatal(err)
145+
}
146+
s := out.String()
147+
if !strings.Contains(s, "~ update Policy/default") {
148+
t.Fatalf("expected policy update in:\n%s", s)
149+
}
150+
if !strings.Contains(s, "maxTotalCostUsd") {
151+
t.Fatalf("expected field diff in:\n%s", s)
152+
}
153+
if !strings.Contains(s, "Cost ceiling increased") {
154+
t.Fatalf("expected risk line in:\n%s", s)
155+
}
156+
}

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func NewRootCmd() *cobra.Command {
3434
BindPersistentFlags(root)
3535
root.AddCommand(newVersionCmd())
3636
root.AddCommand(newValidateCmd())
37+
root.AddCommand(newPlanCmd())
3738
return root
3839
}
3940

0 commit comments

Comments
 (0)