diff --git a/cmd/core-agent/main.go b/cmd/core-agent/main.go index 181ab4c..b831a3c 100644 --- a/cmd/core-agent/main.go +++ b/cmd/core-agent/main.go @@ -124,8 +124,9 @@ func main() { }, }) - // --- Forge CLI commands --- + // --- Forge + Workspace CLI commands --- registerForgeCommands(c) + registerWorkspaceCommands(c) // --- CLI commands for feature testing --- diff --git a/cmd/core-agent/workspace.go b/cmd/core-agent/workspace.go new file mode 100644 index 0000000..38410ed --- /dev/null +++ b/cmd/core-agent/workspace.go @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package main + +import ( + "os" + + "dappco.re/go/core" + + "dappco.re/go/agent/pkg/agentic" +) + +func registerWorkspaceCommands(c *core.Core) { + + // workspace/list — show all workspaces with status + c.Command("workspace/list", core.Command{ + Description: "List all agent workspaces with status", + Action: func(opts core.Options) core.Result { + wsRoot := agentic.WorkspaceRoot() + fsys := c.Fs() + + r := fsys.List(wsRoot) + if !r.OK { + core.Print(nil, "no workspaces at %s", wsRoot) + return core.Result{OK: true} + } + + entries := r.Value.([]os.DirEntry) + count := 0 + for _, e := range entries { + if !e.IsDir() { + continue + } + statusFile := core.JoinPath(wsRoot, e.Name(), "status.json") + if sr := fsys.Read(statusFile); sr.OK { + // Quick parse for status field + content := sr.Value.(string) + status := extractField(content, "status") + repo := extractField(content, "repo") + agent := extractField(content, "agent") + core.Print(nil, " %-8s %-8s %-10s %s", status, agent, repo, e.Name()) + count++ + } + } + if count == 0 { + core.Print(nil, " no workspaces") + } + return core.Result{OK: true} + }, + }) + + // workspace/clean — remove stale workspaces + c.Command("workspace/clean", core.Command{ + Description: "Remove completed/failed/blocked workspaces", + Action: func(opts core.Options) core.Result { + wsRoot := agentic.WorkspaceRoot() + fsys := c.Fs() + filter := opts.String("_arg") + if filter == "" { + filter = "all" + } + + r := fsys.List(wsRoot) + if !r.OK { + core.Print(nil, "no workspaces") + return core.Result{OK: true} + } + + entries := r.Value.([]os.DirEntry) + var toRemove []string + + for _, e := range entries { + if !e.IsDir() { + continue + } + statusFile := core.JoinPath(wsRoot, e.Name(), "status.json") + sr := fsys.Read(statusFile) + if !sr.OK { + continue + } + status := extractField(sr.Value.(string), "status") + + switch filter { + case "all": + if status == "completed" || status == "failed" || status == "blocked" || status == "merged" || status == "ready-for-review" { + toRemove = append(toRemove, e.Name()) + } + case "completed": + if status == "completed" || status == "merged" || status == "ready-for-review" { + toRemove = append(toRemove, e.Name()) + } + case "failed": + if status == "failed" { + toRemove = append(toRemove, e.Name()) + } + case "blocked": + if status == "blocked" { + toRemove = append(toRemove, e.Name()) + } + } + } + + if len(toRemove) == 0 { + core.Print(nil, "nothing to clean") + return core.Result{OK: true} + } + + for _, name := range toRemove { + path := core.JoinPath(wsRoot, name) + fsys.DeleteAll(path) + core.Print(nil, " removed %s", name) + } + core.Print(nil, "\n %d workspaces removed", len(toRemove)) + return core.Result{OK: true} + }, + }) + + // workspace/dispatch — dispatch an agent (CLI wrapper for MCP tool) + c.Command("workspace/dispatch", core.Command{ + Description: "Dispatch an agent to work on a repo task", + Action: func(opts core.Options) core.Result { + repo := opts.String("_arg") + if repo == "" { + core.Print(nil, "usage: core-agent workspace/dispatch --task=\"...\" --issue=N|--pr=N|--branch=X [--agent=codex]") + return core.Result{OK: false} + } + + core.Print(nil, "dispatch via CLI not yet wired — use MCP agentic_dispatch tool") + core.Print(nil, "repo: %s, task: %s", repo, opts.String("task")) + return core.Result{OK: true} + }, + }) +} + +// extractField does a quick JSON field extraction without full unmarshal. +// Looks for "field":"value" pattern. Good enough for status.json. +func extractField(jsonStr, field string) string { + // Match both "field":"value" and "field": "value" + needle := core.Concat("\"", field, "\"") + idx := -1 + for i := 0; i <= len(jsonStr)-len(needle); i++ { + if jsonStr[i:i+len(needle)] == needle { + idx = i + len(needle) + break + } + } + if idx < 0 { + return "" + } + // Skip : and whitespace to find opening quote + for idx < len(jsonStr) && (jsonStr[idx] == ':' || jsonStr[idx] == ' ' || jsonStr[idx] == '\t') { + idx++ + } + if idx >= len(jsonStr) || jsonStr[idx] != '"' { + return "" + } + idx++ // skip opening quote + end := idx + for end < len(jsonStr) && jsonStr[end] != '"' { + end++ + } + return jsonStr[idx:end] +}