|
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +package main |
| 4 | + |
| 5 | +import ( |
| 6 | + "context" |
| 7 | + "fmt" |
| 8 | + "os" |
| 9 | + "path/filepath" |
| 10 | + "sort" |
| 11 | + "strings" |
| 12 | + "time" |
| 13 | + |
| 14 | + "github.com/TeoSlayer/pilotprotocol/pkg/skillinject" |
| 15 | +) |
| 16 | + |
| 17 | +// cmdSkills is the user-facing surface for the daemon's auto-installed |
| 18 | +// agent skill. It tells the user where the daemon writes the SKILL.md for |
| 19 | +// each detected tool, the live state of each path, and (with --paths) just |
| 20 | +// the bare paths for shell-friendly use. |
| 21 | +// |
| 22 | +// Subcommands: |
| 23 | +// |
| 24 | +// pilotctl skills — alias for `status` |
| 25 | +// pilotctl skills status — show per-tool install paths + state |
| 26 | +// pilotctl skills paths — print just the install paths |
| 27 | +// pilotctl skills check — run one reconcile pass right now |
| 28 | +func cmdSkills(args []string) { |
| 29 | + sub := "status" |
| 30 | + if len(args) > 0 && !strings.HasPrefix(args[0], "--") { |
| 31 | + sub = args[0] |
| 32 | + args = args[1:] |
| 33 | + } |
| 34 | + switch sub { |
| 35 | + case "status": |
| 36 | + cmdSkillsStatus(args) |
| 37 | + case "paths": |
| 38 | + cmdSkillsPaths(args) |
| 39 | + case "check": |
| 40 | + cmdSkillsCheck(args) |
| 41 | + default: |
| 42 | + fatalHint("invalid_argument", |
| 43 | + "available: status, paths, check", |
| 44 | + "unknown skills subcommand: %s", sub) |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +func runTick() (*skillinject.Report, error) { |
| 49 | + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| 50 | + defer cancel() |
| 51 | + return skillinject.Tick(ctx, skillinject.Config{}) |
| 52 | +} |
| 53 | + |
| 54 | +// cmdSkillsStatus runs one tick (fetching the manifest + entrypoint over |
| 55 | +// HTTPS) and prints what state each managed file is in and what action |
| 56 | +// the daemon's next live tick would take. |
| 57 | +func cmdSkillsStatus(_ []string) { |
| 58 | + report, err := runTick() |
| 59 | + if err != nil { |
| 60 | + fatalCode("internal", "skills tick: %v", err) |
| 61 | + } |
| 62 | + |
| 63 | + if jsonOutput { |
| 64 | + out := []map[string]interface{}{} |
| 65 | + for _, o := range report.Outcomes { |
| 66 | + out = append(out, map[string]interface{}{ |
| 67 | + "tool": o.Tool, |
| 68 | + "kind": string(o.Kind), |
| 69 | + "path": o.Path, |
| 70 | + "state": string(o.State), |
| 71 | + "action": string(o.Action), |
| 72 | + "hash": o.Hash, |
| 73 | + "err": o.Err, |
| 74 | + }) |
| 75 | + } |
| 76 | + output(map[string]interface{}{ |
| 77 | + "at": report.At, |
| 78 | + "outcomes": out, |
| 79 | + "skipped": report.Skipped, |
| 80 | + }) |
| 81 | + return |
| 82 | + } |
| 83 | + |
| 84 | + fmt.Println("Pilot Protocol skill — install status") |
| 85 | + fmt.Println("=====================================") |
| 86 | + fmt.Printf("Reconcile cadence: every %s (default), plus once on daemon startup.\n", skillinject.DefaultInterval) |
| 87 | + fmt.Println("All paths below are auto-managed by the daemon — edits are reverted on next tick.") |
| 88 | + fmt.Println() |
| 89 | + |
| 90 | + if len(report.Outcomes) == 0 { |
| 91 | + fmt.Println("No supported agent tools detected on this host.") |
| 92 | + fmt.Println("Supported (auto-detected by directory presence):") |
| 93 | + fmt.Println(" - Claude Code (~/.claude)") |
| 94 | + fmt.Println(" - OpenClaw (~/.openclaw)") |
| 95 | + fmt.Println(" - Cursor (~/.cursor)") |
| 96 | + fmt.Println(" - OpenHands (~/.openhands)") |
| 97 | + fmt.Println(" - Hermes (~/.hermes)") |
| 98 | + return |
| 99 | + } |
| 100 | + |
| 101 | + // Group outcomes by tool for readable output. |
| 102 | + byTool := map[string][]skillinject.Outcome{} |
| 103 | + tools := []string{} |
| 104 | + for _, o := range report.Outcomes { |
| 105 | + if _, seen := byTool[o.Tool]; !seen { |
| 106 | + tools = append(tools, o.Tool) |
| 107 | + } |
| 108 | + byTool[o.Tool] = append(byTool[o.Tool], o) |
| 109 | + } |
| 110 | + sort.Strings(tools) |
| 111 | + |
| 112 | + for _, tool := range tools { |
| 113 | + fmt.Printf("[%s]\n", tool) |
| 114 | + for _, o := range byTool[tool] { |
| 115 | + label := "skill copy: " |
| 116 | + if o.Kind == skillinject.KindMarker { |
| 117 | + label = "heartbeat ref: " |
| 118 | + } |
| 119 | + fmt.Printf(" %s%s\n", label, o.Path) |
| 120 | + fmt.Printf(" state=%s next_action=%s\n", o.State, o.Action) |
| 121 | + if o.Err != "" { |
| 122 | + fmt.Printf(" ERROR: %s\n", o.Err) |
| 123 | + } |
| 124 | + } |
| 125 | + fmt.Println() |
| 126 | + } |
| 127 | + |
| 128 | + if len(report.Skipped) > 0 { |
| 129 | + fmt.Printf("Not installed (skipped): %s\n", strings.Join(report.Skipped, ", ")) |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +// cmdSkillsPaths prints just the install paths — one per line, no decoration — |
| 134 | +// suitable for shell pipelines (`pilotctl skills paths | xargs ls -la`). |
| 135 | +func cmdSkillsPaths(_ []string) { |
| 136 | + report, err := runTick() |
| 137 | + if err != nil { |
| 138 | + fatalCode("internal", "skills tick: %v", err) |
| 139 | + } |
| 140 | + if jsonOutput { |
| 141 | + paths := []string{} |
| 142 | + for _, o := range report.Outcomes { |
| 143 | + paths = append(paths, o.Path) |
| 144 | + } |
| 145 | + output(map[string]interface{}{"paths": paths}) |
| 146 | + return |
| 147 | + } |
| 148 | + for _, o := range report.Outcomes { |
| 149 | + fmt.Println(o.Path) |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +// cmdSkillsCheck triggers one reconcile pass right now (instead of waiting |
| 154 | +// for the daemon's next tick) and reports what changed. Useful right after |
| 155 | +// installing a new agent tool — no need to wait 15 minutes. |
| 156 | +func cmdSkillsCheck(_ []string) { |
| 157 | + report, err := runTick() |
| 158 | + if err != nil { |
| 159 | + fatalCode("internal", "skills tick: %v", err) |
| 160 | + } |
| 161 | + c := report.Counts() |
| 162 | + |
| 163 | + if jsonOutput { |
| 164 | + outputOK(map[string]interface{}{ |
| 165 | + "checked": len(report.Outcomes), |
| 166 | + "noops": c[skillinject.ActionNoop], |
| 167 | + "creates": c[skillinject.ActionCreate], |
| 168 | + "rewrites": c[skillinject.ActionRewrite], |
| 169 | + "errors": c[skillinject.ActionError], |
| 170 | + "skipped": report.Skipped, |
| 171 | + }) |
| 172 | + return |
| 173 | + } |
| 174 | + |
| 175 | + fmt.Printf("Reconcile complete — %d files checked.\n", len(report.Outcomes)) |
| 176 | + fmt.Printf(" noop: %d\n", c[skillinject.ActionNoop]) |
| 177 | + fmt.Printf(" create: %d\n", c[skillinject.ActionCreate]) |
| 178 | + fmt.Printf(" rewrite: %d\n", c[skillinject.ActionRewrite]) |
| 179 | + if c[skillinject.ActionError] > 0 { |
| 180 | + fmt.Printf(" errors: %d (run `pilotctl skills status` for detail)\n", c[skillinject.ActionError]) |
| 181 | + } |
| 182 | + if len(report.Skipped) > 0 { |
| 183 | + fmt.Printf("Not installed (skipped): %s\n", strings.Join(report.Skipped, ", ")) |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +// skillsHomeRel returns a $HOME-relative pretty path for display (purely |
| 188 | +// cosmetic). Falls back to the absolute path if HOME isn't resolvable. |
| 189 | +func skillsHomeRel(p string) string { |
| 190 | + h, err := os.UserHomeDir() |
| 191 | + if err != nil { |
| 192 | + return p |
| 193 | + } |
| 194 | + rel, err := filepath.Rel(h, p) |
| 195 | + if err != nil || strings.HasPrefix(rel, "..") { |
| 196 | + return p |
| 197 | + } |
| 198 | + return "~/" + rel |
| 199 | +} |
| 200 | + |
| 201 | +var _ = skillsHomeRel // reserved for future use; keeps gofmt happy |
| 202 | + |
| 203 | +// printSkillInstallSummary is called from cmdInfo to surface the agent |
| 204 | +// skill install paths in the standard daemon diagnostic. Quiet (no header) |
| 205 | +// when no agent tools are detected on the host. |
| 206 | +func printSkillInstallSummary() { |
| 207 | + report, err := runTick() |
| 208 | + if err != nil || report == nil || len(report.Outcomes) == 0 { |
| 209 | + return |
| 210 | + } |
| 211 | + // Collapse to one path per tool: prefer the skill copy over the marker. |
| 212 | + type entry struct { |
| 213 | + Tool, Path string |
| 214 | + } |
| 215 | + seen := map[string]string{} |
| 216 | + order := []string{} |
| 217 | + for _, o := range report.Outcomes { |
| 218 | + if o.Kind != skillinject.KindSkill { |
| 219 | + continue |
| 220 | + } |
| 221 | + if _, ok := seen[o.Tool]; !ok { |
| 222 | + order = append(order, o.Tool) |
| 223 | + } |
| 224 | + seen[o.Tool] = o.Path |
| 225 | + } |
| 226 | + if len(order) == 0 { |
| 227 | + return |
| 228 | + } |
| 229 | + fmt.Printf("\nAgent skill installed at:\n") |
| 230 | + for _, tool := range order { |
| 231 | + fmt.Printf(" %-13s %s\n", tool+":", seen[tool]) |
| 232 | + } |
| 233 | + fmt.Printf(" (auto-managed by daemon — run `pilotctl skills` for full state)\n") |
| 234 | +} |
0 commit comments