Skip to content

Commit d2f6781

Browse files
authored
Merge pull request #86 from LAA-Software-Engineering/feat/cli-fmt-command
feat(cli): agentctl fmt — normalize project YAML
2 parents 3e6d2cf + 9730feb commit d2f6781

14 files changed

Lines changed: 423 additions & 2 deletions

File tree

docs/EXAMPLES.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ Short, runnable patterns for **`apiVersion: agentic.dev/v0`**. For the full YAML
44

55
A checked-in copy of the **OpenAI `support_snippet`** project from **section 4** lives under [**`examples/example1/`**](../examples/example1/). Its **`metadata.name`** is **`example1`**, matching that folder. From the repository root, pass **`--project examples/example1`** to **`agentctl`** (or **`cd` there** and use **`--project .`**).
66

7+
### Formatting YAML (`agentctl fmt`)
8+
9+
Normalize indentation (2 spaces) for **`project.yaml`** / **`project.yml`** and every file in **`spec.imports`** (same closure as validate/load). **`--check`** exits **1** if any file would change (CI). **YAML comments may be lost** on rewrite—commit or branch before running.
10+
11+
```bash
12+
agentctl fmt --project my-agent-system
13+
agentctl fmt --check --project .
14+
```
15+
716
---
817

918
## 1. Scaffold with `agentctl init`

internal/cli/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
// The validate command (section 10.2) loads the project, applies defaults and optional environment
77
// overlays, then runs [spec.ValidateProjectGraph].
88
//
9+
// The fmt command (section 10.2) rewrites YAML files in the project import closure via [project.ListProjectYAMLFiles]
10+
// and [project.NormalizeYAML], with optional --check for CI.
11+
//
912
// The inspect command (section 10.2) uses the same preparation pipeline and prints one effective
1013
// normalized resource envelope (Kind/name) as JSON, YAML, or indented JSON for table output.
1114
//

internal/cli/fmt.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/project"
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func newFmtCmd() *cobra.Command {
15+
var check bool
16+
cmd := &cobra.Command{
17+
Use: "fmt",
18+
Short: "Normalize YAML formatting for project.yaml and imports",
19+
Long: `Reformat every YAML file in the project closure (root project.yaml or project.yml plus
20+
all paths from spec.imports), using the same discovery rules as validate/load (design doc §10.2).
21+
22+
Writes 2-space indented YAML. Running fmt twice should make no further changes (idempotent).
23+
24+
WARNING: commit or back up your work before formatting. YAML comments may be dropped or moved
25+
because formatting round-trips through gopkg.in/yaml.v3.
26+
27+
With --check, no files are modified; the command exits with status 1 if any file would change
28+
(useful in CI).
29+
30+
Exit codes (§11.2): 0 success, 1 check failed or I/O error, 2 invalid project or unparseable YAML.`,
31+
SilenceUsage: true,
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
if len(args) > 0 {
34+
return NewExitError(ExitValidationError, fmt.Errorf("fmt: unexpected arguments"))
35+
}
36+
return runFmt(cmd, check)
37+
},
38+
}
39+
cmd.Flags().BoolVar(&check, "check", false, "do not write; exit 1 if any file would be reformatted")
40+
return cmd
41+
}
42+
43+
func runFmt(cmd *cobra.Command, check bool) error {
44+
g := Globals()
45+
root, err := filepath.Abs(filepath.Clean(g.ProjectRoot))
46+
if err != nil {
47+
return NewExitError(ExitValidationError, fmt.Errorf("fmt: project root: %w", err))
48+
}
49+
paths, err := project.ListProjectYAMLFiles(root)
50+
if err != nil {
51+
return NewExitError(ExitValidationError, fmt.Errorf("fmt: %w", err))
52+
}
53+
54+
wouldChange := 0
55+
written := 0
56+
for _, p := range paths {
57+
b, err := os.ReadFile(p)
58+
if err != nil {
59+
return fmt.Errorf("fmt: read %s: %w", p, err)
60+
}
61+
norm, err := project.NormalizeYAML(b)
62+
if err != nil {
63+
return NewExitError(ExitValidationError, fmt.Errorf("fmt: %s: %w", p, err))
64+
}
65+
if bytes.Equal(b, norm) {
66+
continue
67+
}
68+
wouldChange++
69+
if check {
70+
continue
71+
}
72+
info, err := os.Stat(p)
73+
if err != nil {
74+
return fmt.Errorf("fmt: stat %s: %w", p, err)
75+
}
76+
mode := info.Mode().Perm()
77+
if err := os.WriteFile(p, norm, mode); err != nil {
78+
return fmt.Errorf("fmt: write %s: %w", p, err)
79+
}
80+
written++
81+
}
82+
83+
out := cmd.OutOrStdout()
84+
switch g.Output {
85+
case render.FormatJSON:
86+
if err := render.WriteJSON(out, map[string]any{
87+
"projectRoot": root,
88+
"check": check,
89+
"files": len(paths),
90+
"changed": wouldChange,
91+
"written": written,
92+
}); err != nil {
93+
return err
94+
}
95+
case render.FormatYAML:
96+
if err := render.WriteYAML(out, map[string]any{
97+
"projectRoot": root,
98+
"check": check,
99+
"files": len(paths),
100+
"changed": wouldChange,
101+
"written": written,
102+
}); err != nil {
103+
return err
104+
}
105+
default:
106+
if check {
107+
if wouldChange > 0 {
108+
_, _ = fmt.Fprintf(out, "%d file(s) would be reformatted\n", wouldChange)
109+
} else {
110+
_, _ = fmt.Fprintln(out, "All YAML files already formatted.")
111+
}
112+
} else {
113+
_, _ = fmt.Fprintf(out, "Formatted %d file(s); %d unchanged (%d total).\n", written, len(paths)-wouldChange, len(paths))
114+
}
115+
}
116+
117+
if check && wouldChange > 0 {
118+
return NewExitError(ExitGenericFailure, fmt.Errorf("fmt: %d file(s) need formatting (run without --check)", wouldChange))
119+
}
120+
return nil
121+
}

internal/cli/fmt_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestFmt_checkDetectsChange(t *testing.T) {
13+
root := testdataPath(t, "fmt_messy")
14+
15+
ResetGlobalsForTest()
16+
cmd := NewRootCmd()
17+
cmd.SetOut(io.Discard)
18+
cmd.SetErr(io.Discard)
19+
cmd.SetArgs([]string{"fmt", "--check", "--project", root})
20+
err := cmd.Execute()
21+
if err == nil {
22+
t.Fatal("expected check failure")
23+
}
24+
if ExitCodeOf(err) != ExitGenericFailure {
25+
t.Fatalf("code=%d err=%v", ExitCodeOf(err), err)
26+
}
27+
}
28+
29+
func TestFmt_writeThenCheckClean(t *testing.T) {
30+
srcRoot := testdataPath(t, "fmt_messy")
31+
root := t.TempDir()
32+
entries, err := os.ReadDir(srcRoot)
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
for _, e := range entries {
37+
if e.IsDir() {
38+
continue
39+
}
40+
b, err := os.ReadFile(filepath.Join(srcRoot, e.Name()))
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if err := os.WriteFile(filepath.Join(root, e.Name()), b, 0o644); err != nil {
45+
t.Fatal(err)
46+
}
47+
}
48+
49+
ResetGlobalsForTest()
50+
cmd := NewRootCmd()
51+
cmd.SetOut(io.Discard)
52+
cmd.SetErr(io.Discard)
53+
cmd.SetArgs([]string{"fmt", "--project", root})
54+
if err := cmd.Execute(); err != nil {
55+
t.Fatal(err)
56+
}
57+
58+
ResetGlobalsForTest()
59+
cmd = NewRootCmd()
60+
cmd.SetOut(io.Discard)
61+
cmd.SetErr(io.Discard)
62+
cmd.SetArgs([]string{"fmt", "--check", "--project", root})
63+
if err := cmd.Execute(); err != nil {
64+
t.Fatalf("second check: %v", err)
65+
}
66+
67+
b, err := os.ReadFile(filepath.Join(root, "policy.yaml"))
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
if strings.Contains(string(b), " name:") {
72+
t.Fatalf("expected 2-space indent, got:\n%s", b)
73+
}
74+
}
75+
76+
func TestFmt_secondRunNoop(t *testing.T) {
77+
root := t.TempDir()
78+
for _, name := range []string{"project.yaml", "tool.yaml", "policy.yaml"} {
79+
src, err := os.ReadFile(filepath.Join(testdataPath(t, "fmt_messy"), name))
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
if err := os.WriteFile(filepath.Join(root, name), src, 0o644); err != nil {
84+
t.Fatal(err)
85+
}
86+
}
87+
88+
ResetGlobalsForTest()
89+
cmd := NewRootCmd()
90+
var out1 bytes.Buffer
91+
cmd.SetOut(&out1)
92+
cmd.SetErr(&out1)
93+
cmd.SetArgs([]string{"fmt", "--project", root})
94+
if err := cmd.Execute(); err != nil {
95+
t.Fatal(err)
96+
}
97+
98+
ResetGlobalsForTest()
99+
cmd = NewRootCmd()
100+
var out2 bytes.Buffer
101+
cmd.SetOut(&out2)
102+
cmd.SetErr(&out2)
103+
cmd.SetArgs([]string{"fmt", "--project", root})
104+
if err := cmd.Execute(); err != nil {
105+
t.Fatal(err)
106+
}
107+
if !strings.Contains(out2.String(), "0 unchanged") && !strings.Contains(out2.String(), "3 unchanged") {
108+
t.Fatalf("expected noop summary, got:\n%s", out2.String())
109+
}
110+
}
111+
112+
func TestFmt_jsonOutput(t *testing.T) {
113+
root := testdataPath(t, "fmt_messy")
114+
ResetGlobalsForTest()
115+
cmd := NewRootCmd()
116+
var out bytes.Buffer
117+
cmd.SetOut(&out)
118+
cmd.SetErr(&out)
119+
cmd.SetArgs([]string{"fmt", "--check", "-o", "json", "--project", root})
120+
_ = cmd.Execute()
121+
if !strings.Contains(out.String(), `"changed"`) {
122+
t.Fatalf("%s", out.String())
123+
}
124+
}

internal/cli/root.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func NewRootCmd() *cobra.Command {
2222
root := &cobra.Command{
2323
Use: "agentctl",
2424
Short: "Declarative control plane for agent systems",
25-
Long: "agentctl validates, inspects, plans, diffs, applies, runs, tests, and reads deployment state for declarative agent systems defined as YAML.",
25+
Long: "agentctl validates, formats, inspects, plans, diffs, applies, runs, tests, and reads deployment state for declarative agent systems defined as YAML.",
2626
SilenceErrors: true,
2727
Run: func(cmd *cobra.Command, args []string) {
2828
_ = cmd.Help()
@@ -35,6 +35,7 @@ func NewRootCmd() *cobra.Command {
3535
root.AddCommand(newVersionCmd())
3636
root.AddCommand(newInitCmd())
3737
root.AddCommand(newValidateCmd())
38+
root.AddCommand(newFmtCmd())
3839
root.AddCommand(newInspectCmd())
3940
root.AddCommand(newPlanCmd())
4041
root.AddCommand(newDiffCmd())

internal/cli/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestRootHelp_listsGlobalFlags(t *testing.T) {
1919
t.Fatal(err)
2020
}
2121
out := buf.String()
22-
for _, sub := range []string{"diff", "inspect", "state", "test"} {
22+
for _, sub := range []string{"diff", "fmt", "inspect", "state", "test"} {
2323
if !strings.Contains(out, sub) {
2424
t.Fatalf("help should mention %q subcommand:\n%s", sub, out)
2525
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Policy
3+
metadata:
4+
name: default
5+
spec:
6+
execution:
7+
maxTotalCostUsd: 3
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Project
3+
metadata:
4+
name: fmt-messy
5+
spec:
6+
imports:
7+
- ./policy.yaml
8+
- ./tool.yaml
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Tool
3+
metadata:
4+
name: helper
5+
spec:
6+
type: native

internal/project/doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// Package project loads the root project.yaml, expands spec.imports, merges resources
22
// into a spec.ProjectGraph. Reference checks use [ResolveReferences] (delegates to spec);
33
// full MVP validation is [spec.ValidateProjectGraph].
4+
//
5+
// [ListProjectYAMLFiles] and [NormalizeYAML] support agentctl fmt (issue #74).
46
package project

0 commit comments

Comments
 (0)