T06: CLI — komandni interfejs

- 5 komandi: run, status, next, verify, history
- Config proširen sa KAOS_TASKS_DIR
- Tabelarni status sa emoji ikonama
- run: scan → find → move → run → verify → report → review
- 8 CLI testova, 59 ukupno — svi prolaze
- T05 premešten u done/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-20 11:56:06 +00:00
parent 028872be43
commit 38e1e1029c
8 changed files with 659 additions and 6 deletions

View File

@ -0,0 +1,47 @@
# T06 Izveštaj: CLI — komandni interfejs
**Agent:** coder
**Model:** Opus
**Datum:** 2026-02-20
---
## Šta je urađeno
Implementiran CLI u `code/cmd/kaos-supervisor/main.go`:
### Izmenjeni/kreirani fajlovi
| Fajl | Opis |
|------|------|
| `cmd/kaos-supervisor/main.go` | 5 komandi: run, status, next, verify, history |
| `cmd/kaos-supervisor/main_test.go` | 8 testova za CLI komande |
| `internal/config/config.go` | Dodat KAOS_TASKS_DIR |
| `internal/config/config_test.go` | Dodat test za KAOS_TASKS_DIR |
| `.env.example` | Dodat KAOS_TASKS_DIR |
### Komande
- **run [TASK_ID]** — pokreni task (ili sledeći), verify, report, premesti u review/
- **status** — tabelarni prikaz svih taskova sa emoji statusima
- **next** — sledeći task za rad
- **verify [PATH]** — verifikacija Go projekta
- **history** — lista izveštaja
### Testovi — 8/8 PASS (CLI)
```
TestCmdStatus_ShowsTasks PASS
TestCmdNext_FindsReadyTask PASS
TestCmdVerify_OnTestProject PASS (0.46s)
TestCmdHistory_ShowsReports PASS
TestUnknownCommand PASS
TestCmdRun_NoReadyTasks PASS
TestStatusIcon PASS
TestNextTask_Integration PASS
```
### Ukupno projekat: 59 testova, svi prolaze
- `go vet ./...` — čist
- `go build ./...` — prolazi

90
TASKS/review/T06.md Normal file
View File

@ -0,0 +1,90 @@
# T06: CLI — komandni interfejs
**Kreirao:** planer
**Datum:** 2026-02-20
**Agent:** coder
**Model:** Sonnet
**Zavisi od:** T02 ✅, T03 ✅, T04 ✅, T05
---
## Opis
Entry point za supervisor. Parsira komande, poziva supervisor funkcije.
Koristi sve module iz T02-T05.
## Fajlovi za izmenu
```
code/cmd/kaos-supervisor/main.go ← komande
```
## Komande
```bash
kaos-supervisor run [TASK_ID] # pokreni task (ili sledeći iz ready/)
kaos-supervisor status # prikaži status svih taskova
kaos-supervisor next # prikaži šta je sledeće za rad
kaos-supervisor verify [PATH] # pokreni verifikaciju na projektu
kaos-supervisor history # prikaži izvršene taskove iz reports/
```
## Detalji komandi
### run [TASK_ID]
1. ScanTasks() — učitaj sve taskove
2. Ako je TASK_ID dat → FindTask, proveri da je u ready/
3. Ako nije dat → NextTask (prvi iz ready/ sa ispunjenim zavisnostima)
4. MoveTask → active/
5. RunTask (iz runner.go)
6. Verify (iz checker.go)
7. WriteReport (iz reporter.go)
8. MoveTask → review/
9. Prikaži rezime
### status
- ScanTasks → tabelarni prikaz svih taskova
- Format: ID | Naslov | Folder | Zavisi od
### next
- NextTask → prikaži koji task je sledeći za rad
### verify [PATH]
- Verify(path) → prikaži rezultat
- Default path: code/
### history
- Čitaj fajlove iz TASKS/reports/ → prikaži listu
## Pravila
- Bez eksternih CLI biblioteka (flag package ili os.Args)
- Formatiran terminal output sa emoji za status
- Poruke na srpskom
- Config čita iz .env (putanja do TASKS/, timeout)
- Exit code: 0 uspeh, 1 greška
## Testovi
- status sa primer taskovima → ispravan output
- next → vraća pravi task
- verify na test projektu → prikaže rezultat
- Nepoznata komanda → help poruka
- run bez taskova u ready/ → poruka "nema taskova"
## Očekivani izlaz
`make build``bin/kaos-supervisor` radi sve komande.
`go test ./... -v` — svi testovi zeleni.
---
## Pitanja
*(agent piše pitanja ovde, planer odgovara)*
---
## Odgovori
*(planer piše odgovore ovde)*

View File

@ -1,2 +1,3 @@
KAOS_TIMEOUT=30m KAOS_TIMEOUT=30m
KAOS_PROJECT_PATH=. KAOS_PROJECT_PATH=.
KAOS_TASKS_DIR=../TASKS

View File

@ -1,22 +1,242 @@
// Package main is the entry point for the KAOS supervisor process. // Package main is the entry point for the KAOS supervisor process.
// It loads configuration and starts the supervisor. // It parses CLI commands and delegates to supervisor functions.
package main package main
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"path/filepath"
"strings"
"text/tabwriter"
"github.com/dal/kaos/internal/config" "github.com/dal/kaos/internal/config"
_ "github.com/dal/kaos/internal/supervisor" "github.com/dal/kaos/internal/supervisor"
) )
func main() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("Failed to load config: %v", err) fmt.Fprintf(os.Stderr, "Greška pri učitavanju konfiguracije: %v\n", err)
os.Exit(1)
} }
fmt.Fprintf(os.Stdout, "KAOS Supervisor started (timeout=%s, project_path=%s)\n", if len(os.Args) < 2 {
cfg.Timeout, cfg.ProjectPath) printHelp()
os.Exit(1)
}
command := os.Args[1]
switch command {
case "run":
os.Exit(cmdRun(cfg))
case "status":
os.Exit(cmdStatus(cfg))
case "next":
os.Exit(cmdNext(cfg))
case "verify":
os.Exit(cmdVerify(cfg))
case "history":
os.Exit(cmdHistory(cfg))
case "help", "--help", "-h":
printHelp()
default:
fmt.Fprintf(os.Stderr, "Nepoznata komanda: %s\n\n", command)
printHelp()
os.Exit(1)
}
}
func printHelp() {
fmt.Println("KAOS Supervisor")
fmt.Println()
fmt.Println("Komande:")
fmt.Println(" run [TASK_ID] Pokreni task (ili sledeći iz ready/)")
fmt.Println(" status Prikaži status svih taskova")
fmt.Println(" next Prikaži sledeći task za rad")
fmt.Println(" verify [PATH] Pokreni verifikaciju na projektu")
fmt.Println(" history Prikaži izvršene taskove iz reports/")
fmt.Println(" help Prikaži ovu pomoć")
}
func cmdRun(cfg *config.Config) int {
tasks, err := supervisor.ScanTasks(cfg.TasksDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Greška pri skeniranju taskova: %v\n", err)
return 1
}
var task *supervisor.Task
if len(os.Args) > 2 {
taskID := os.Args[2]
task = supervisor.FindTask(tasks, taskID)
if task == nil {
fmt.Fprintf(os.Stderr, "Task %s nije pronađen\n", taskID)
return 1
}
if task.Status != "ready" {
fmt.Fprintf(os.Stderr, "Task %s nije u ready/ (trenutno: %s)\n", taskID, task.Status)
return 1
}
} else {
task = supervisor.NextTask(tasks)
if task == nil {
fmt.Println("Nema taskova spremnih za rad")
return 0
}
}
fmt.Printf("▶ Pokrećem: %s — %s\n", task.ID, task.Title)
// Move to active
if err := supervisor.MoveTask(cfg.TasksDir, task.ID, "ready", "active"); err != nil {
fmt.Fprintf(os.Stderr, "Greška pri premještanju u active/: %v\n", err)
return 1
}
// Run the task
result := supervisor.RunTask(*task, cfg.ProjectPath, cfg.Timeout, nil)
fmt.Printf("⏱ Trajanje: %s\n", result.Duration.Round(1000000000)) // round to seconds
fmt.Printf("📤 Exit code: %d\n", result.ExitCode)
// Verify
fmt.Println("\n🔍 Verifikacija...")
verify := supervisor.Verify(cfg.ProjectPath)
fmt.Print(supervisor.FormatResult(verify))
// Write report
reportsDir := filepath.Join(cfg.TasksDir, "reports")
reportPath, err := supervisor.WriteReport(*task, result, verify, reportsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Greška pri pisanju izveštaja: %v\n", err)
} else {
fmt.Printf("\n📝 Izveštaj: %s\n", reportPath)
}
// Move to review
if err := supervisor.MoveTask(cfg.TasksDir, task.ID, "active", "review"); err != nil {
fmt.Fprintf(os.Stderr, "Greška pri premještanju u review/: %v\n", err)
return 1
}
fmt.Printf("\n✅ %s premešten u review/\n", task.ID)
if !verify.AllPassed {
return 1
}
return 0
}
func cmdStatus(cfg *config.Config) int {
tasks, err := supervisor.ScanTasks(cfg.TasksDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Greška pri skeniranju taskova: %v\n", err)
return 1
}
if len(tasks) == 0 {
fmt.Println("Nema taskova")
return 0
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNaslov\tFolder\tZavisi od")
fmt.Fprintln(w, "──\t──────\t──────\t─────────")
for _, t := range tasks {
deps := "—"
if len(t.DependsOn) > 0 {
deps = strings.Join(t.DependsOn, ", ")
}
icon := statusIcon(t.Status)
fmt.Fprintf(w, "%s\t%s\t%s %s\t%s\n", t.ID, t.Title, icon, t.Status, deps)
}
w.Flush()
return 0
}
func cmdNext(cfg *config.Config) int {
tasks, err := supervisor.ScanTasks(cfg.TasksDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Greška pri skeniranju taskova: %v\n", err)
return 1
}
task := supervisor.NextTask(tasks)
if task == nil {
fmt.Println("Nema taskova spremnih za rad")
return 0
}
fmt.Printf("Sledeći: %s — %s\n", task.ID, task.Title)
fmt.Printf("Agent: %s | Model: %s\n", task.Agent, task.Model)
if len(task.DependsOn) > 0 {
fmt.Printf("Zavisi od: %s\n", strings.Join(task.DependsOn, ", "))
}
return 0
}
func cmdVerify(cfg *config.Config) int {
path := cfg.ProjectPath
if len(os.Args) > 2 {
path = os.Args[2]
}
fmt.Printf("🔍 Verifikacija: %s\n\n", path)
result := supervisor.Verify(path)
fmt.Print(supervisor.FormatResult(result))
if !result.AllPassed {
return 1
}
return 0
}
func cmdHistory(cfg *config.Config) int {
reportsDir := filepath.Join(cfg.TasksDir, "reports")
entries, err := os.ReadDir(reportsDir)
if err != nil {
if os.IsNotExist(err) {
fmt.Println("Nema izveštaja")
return 0
}
fmt.Fprintf(os.Stderr, "Greška pri čitanju reports/: %v\n", err)
return 1
}
if len(entries) == 0 {
fmt.Println("Nema izveštaja")
return 0
}
fmt.Println("Izveštaji:")
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), "-report.md") {
continue
}
name := strings.TrimSuffix(entry.Name(), "-report.md")
fmt.Printf(" 📝 %s (%s)\n", name, filepath.Join(reportsDir, entry.Name()))
}
return 0
}
func statusIcon(status string) string {
switch status {
case "done":
return "✅"
case "active":
return "🔄"
case "ready":
return "📋"
case "review":
return "👀"
case "backlog":
return "📦"
default:
return "❓"
}
} }

View File

@ -0,0 +1,268 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/dal/kaos/internal/config"
"github.com/dal/kaos/internal/supervisor"
)
func setupTestTasks(t *testing.T) (string, *config.Config) {
t.Helper()
dir := t.TempDir()
tasksDir := filepath.Join(dir, "TASKS")
for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} {
os.MkdirAll(filepath.Join(tasksDir, folder), 0755)
}
// Create test tasks
t1 := `# T01: Prvi task
**Agent:** coder
**Model:** Sonnet
**Zavisi od:**
---
## Opis
Opis prvog taska.
---
`
t2 := `# T02: Drugi task
**Agent:** coder
**Model:** Sonnet
**Zavisi od:** T01
---
## Opis
Opis drugog taska.
---
`
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(t1), 0644)
os.WriteFile(filepath.Join(tasksDir, "ready", "T02.md"), []byte(t2), 0644)
cfg := &config.Config{
TasksDir: tasksDir,
ProjectPath: dir,
}
return dir, cfg
}
func TestCmdStatus_ShowsTasks(t *testing.T) {
_, cfg := setupTestTasks(t)
// Capture output by redirecting stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
exitCode := cmdStatus(cfg)
w.Close()
os.Stdout = old
var buf [4096]byte
n, _ := r.Read(buf[:])
output := string(buf[:n])
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(output, "T01") {
t.Errorf("expected T01 in output, got:\n%s", output)
}
if !strings.Contains(output, "T02") {
t.Errorf("expected T02 in output, got:\n%s", output)
}
if !strings.Contains(output, "done") {
t.Errorf("expected 'done' status in output, got:\n%s", output)
}
if !strings.Contains(output, "ready") {
t.Errorf("expected 'ready' status in output, got:\n%s", output)
}
}
func TestCmdNext_FindsReadyTask(t *testing.T) {
_, cfg := setupTestTasks(t)
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
exitCode := cmdNext(cfg)
w.Close()
os.Stdout = old
var buf [4096]byte
n, _ := r.Read(buf[:])
output := string(buf[:n])
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(output, "T02") {
t.Errorf("expected T02 as next task, got:\n%s", output)
}
}
func TestCmdVerify_OnTestProject(t *testing.T) {
dir := t.TempDir()
// Create a minimal passing Go project
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module testproj\n\ngo 1.22\n"), 0644)
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0644)
cfg := &config.Config{ProjectPath: dir}
// Set os.Args so cmdVerify doesn't pick up test flags
oldArgs := os.Args
os.Args = []string{"kaos-supervisor", "verify"}
defer func() { os.Args = oldArgs }()
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
exitCode := cmdVerify(cfg)
w.Close()
os.Stdout = old
var buf [4096]byte
n, _ := r.Read(buf[:])
output := string(buf[:n])
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d\noutput: %s", exitCode, output)
}
if !strings.Contains(output, "All checks passed") {
t.Errorf("expected 'All checks passed', got:\n%s", output)
}
}
func TestCmdHistory_ShowsReports(t *testing.T) {
_, cfg := setupTestTasks(t)
// Create a fake report
reportsDir := filepath.Join(cfg.TasksDir, "reports")
os.WriteFile(filepath.Join(reportsDir, "T01-report.md"), []byte("# T01 report\n"), 0644)
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
exitCode := cmdHistory(cfg)
w.Close()
os.Stdout = old
var buf [4096]byte
n, _ := r.Read(buf[:])
output := string(buf[:n])
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(output, "T01") {
t.Errorf("expected T01 in history, got:\n%s", output)
}
}
func TestUnknownCommand(t *testing.T) {
// Test printHelp doesn't panic
old := os.Stdout
_, w, _ := os.Pipe()
os.Stdout = w
printHelp()
w.Close()
os.Stdout = old
}
func TestCmdRun_NoReadyTasks(t *testing.T) {
dir := t.TempDir()
tasksDir := filepath.Join(dir, "TASKS")
for _, folder := range []string{"backlog", "ready", "active", "review", "done", "reports"} {
os.MkdirAll(filepath.Join(tasksDir, folder), 0755)
}
cfg := &config.Config{
TasksDir: tasksDir,
ProjectPath: dir,
}
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Save and restore os.Args
oldArgs := os.Args
os.Args = []string{"kaos-supervisor", "run"}
defer func() { os.Args = oldArgs }()
exitCode := cmdRun(cfg)
w.Close()
os.Stdout = old
var buf [4096]byte
n, _ := r.Read(buf[:])
output := string(buf[:n])
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(output, "Nema taskova") {
t.Errorf("expected 'Nema taskova', got:\n%s", output)
}
}
func TestStatusIcon(t *testing.T) {
tests := []struct {
status string
want string
}{
{"done", "✅"},
{"active", "🔄"},
{"ready", "📋"},
{"review", "👀"},
{"backlog", "📦"},
{"unknown", "❓"},
}
for _, tt := range tests {
got := statusIcon(tt.status)
if got != tt.want {
t.Errorf("statusIcon(%q) = %q, want %q", tt.status, got, tt.want)
}
}
}
// Verify that ScanTasks and NextTask integration works
func TestNextTask_Integration(t *testing.T) {
_, cfg := setupTestTasks(t)
tasks, err := supervisor.ScanTasks(cfg.TasksDir)
if err != nil {
t.Fatalf("ScanTasks error: %v", err)
}
next := supervisor.NextTask(tasks)
if next == nil {
t.Fatal("expected a next task")
}
if next.ID != "T02" {
t.Errorf("expected T02, got %s", next.ID)
}
}

View File

@ -16,6 +16,8 @@ type Config struct {
Timeout time.Duration Timeout time.Duration
// ProjectPath is the root path of the project being supervised. // ProjectPath is the root path of the project being supervised.
ProjectPath string ProjectPath string
// TasksDir is the path to the TASKS directory.
TasksDir string
} }
// Load reads configuration from environment variables. // Load reads configuration from environment variables.
@ -39,9 +41,15 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("KAOS_PROJECT_PATH environment variable is required") return nil, fmt.Errorf("KAOS_PROJECT_PATH environment variable is required")
} }
tasksDir := os.Getenv("KAOS_TASKS_DIR")
if tasksDir == "" {
return nil, fmt.Errorf("KAOS_TASKS_DIR environment variable is required")
}
return &Config{ return &Config{
Timeout: timeout, Timeout: timeout,
ProjectPath: projectPath, ProjectPath: projectPath,
TasksDir: tasksDir,
}, nil }, nil
} }

View File

@ -12,6 +12,7 @@ import (
func TestLoad_WithValidEnv(t *testing.T) { func TestLoad_WithValidEnv(t *testing.T) {
t.Setenv("KAOS_TIMEOUT", "30m") t.Setenv("KAOS_TIMEOUT", "30m")
t.Setenv("KAOS_PROJECT_PATH", "/tmp/test") t.Setenv("KAOS_PROJECT_PATH", "/tmp/test")
t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks")
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
@ -25,11 +26,16 @@ func TestLoad_WithValidEnv(t *testing.T) {
if cfg.ProjectPath != "/tmp/test" { if cfg.ProjectPath != "/tmp/test" {
t.Errorf("expected project path /tmp/test, got %s", cfg.ProjectPath) t.Errorf("expected project path /tmp/test, got %s", cfg.ProjectPath)
} }
if cfg.TasksDir != "/tmp/tasks" {
t.Errorf("expected tasks dir /tmp/tasks, got %s", cfg.TasksDir)
}
} }
func TestLoad_MissingTimeout(t *testing.T) { func TestLoad_MissingTimeout(t *testing.T) {
t.Setenv("KAOS_TIMEOUT", "") t.Setenv("KAOS_TIMEOUT", "")
t.Setenv("KAOS_PROJECT_PATH", "/tmp/test") t.Setenv("KAOS_PROJECT_PATH", "/tmp/test")
t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks")
_, err := Load() _, err := Load()
if err == nil { if err == nil {
@ -40,6 +46,7 @@ func TestLoad_MissingTimeout(t *testing.T) {
func TestLoad_InvalidTimeout(t *testing.T) { func TestLoad_InvalidTimeout(t *testing.T) {
t.Setenv("KAOS_TIMEOUT", "not-a-duration") t.Setenv("KAOS_TIMEOUT", "not-a-duration")
t.Setenv("KAOS_PROJECT_PATH", "/tmp/test") t.Setenv("KAOS_PROJECT_PATH", "/tmp/test")
t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks")
_, err := Load() _, err := Load()
if err == nil { if err == nil {
@ -50,6 +57,7 @@ func TestLoad_InvalidTimeout(t *testing.T) {
func TestLoad_MissingProjectPath(t *testing.T) { func TestLoad_MissingProjectPath(t *testing.T) {
t.Setenv("KAOS_TIMEOUT", "30m") t.Setenv("KAOS_TIMEOUT", "30m")
t.Setenv("KAOS_PROJECT_PATH", "") t.Setenv("KAOS_PROJECT_PATH", "")
t.Setenv("KAOS_TASKS_DIR", "/tmp/tasks")
_, err := Load() _, err := Load()
if err == nil { if err == nil {
@ -57,6 +65,17 @@ func TestLoad_MissingProjectPath(t *testing.T) {
} }
} }
func TestLoad_MissingTasksDir(t *testing.T) {
t.Setenv("KAOS_TIMEOUT", "30m")
t.Setenv("KAOS_PROJECT_PATH", "/tmp/test")
t.Setenv("KAOS_TASKS_DIR", "")
_, err := Load()
if err == nil {
t.Fatal("expected error for missing KAOS_TASKS_DIR")
}
}
func TestLoadEnvFile(t *testing.T) { func TestLoadEnvFile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
envPath := filepath.Join(dir, ".env") envPath := filepath.Join(dir, ".env")