Skip to content

Commit e8f1035

Browse files
committed
feat(skills): auto-inject Pilot Protocol skill into agent tools
pkg/skillinject: reconcile loop fetches inject-manifest.json from pilot-skills repo and writes SKILL.md + heartbeat refs into Claude Code, OpenClaw, PicoClaw, OpenHands, and Hermes tool directories. Runs every 15 minutes from the daemon; first tick fires on startup. internal/nodesapi: thin client for the nodes API used by the injector. cmd/pilotctl/skills: user-facing surface — status, paths, check subcommands.
1 parent 1ee4db7 commit e8f1035

12 files changed

Lines changed: 2900 additions & 0 deletions

File tree

cmd/pilotctl/skills.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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

Comments
 (0)