Skip to content

Commit 46c7616

Browse files
committed
skillinject: install host-wide helpers (pilot-ask) per manifest
New top-level 'helpers' field in inject-manifest.json declares tool-agnostic scripts the daemon installs once per host, e.g. pilot-ask at ~/.pilot/bin/pilot-ask 0755. Heartbeat directives across all tools can then reference one stable command instead of inline 4-step shell flows. writeHelper does atomic write-via-rename, classifies state as absent/identical/drifted with mode-aware drift detection so a chmod doesn't get reverted but content drift does trigger rewrite. Tick records helper outcomes alongside skills/markers. Test covers create → noop → rewrite-on-drift transitions plus mode preservation.
1 parent cb9ce2d commit 46c7616

5 files changed

Lines changed: 185 additions & 4 deletions

File tree

pkg/skillinject/manifest.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,30 @@ const DefaultRepoBaseURL = "https://raw.githubusercontent.com/TeoSlayer/pilot-sk
2727
// Manifest mirrors inject-manifest.json. Field tags match the upstream
2828
// schema. Unknown fields are ignored (forward-compat with new tool fields).
2929
type Manifest struct {
30-
Version int `json:"version"`
31-
Entrypoint string `json:"entrypoint"`
32-
Description string `json:"description,omitempty"`
33-
Tools []ManifestTool `json:"tools"`
30+
Version int `json:"version"`
31+
Entrypoint string `json:"entrypoint"`
32+
Description string `json:"description,omitempty"`
33+
Tools []ManifestTool `json:"tools"`
34+
Helpers []ManifestHelper `json:"helpers,omitempty"`
35+
}
36+
37+
// ManifestHelper is one helper script the daemon installs at a
38+
// well-known path so any AI tool on the host can invoke it. Used to
39+
// ship pilot-ask (the directory + specialist round-trip wrapper).
40+
//
41+
// Helpers are tool-agnostic — they live under ~/.pilot/bin/ and are
42+
// referenced by every tool's heartbeat directive.
43+
type ManifestHelper struct {
44+
Name string `json:"name"`
45+
// Src is a repo-relative path fetched via fetchRepoFile, e.g.
46+
// "workflow-injection/pilot-ask".
47+
Src string `json:"src"`
48+
// Dst is the absolute install target. Supports ~/ expansion, e.g.
49+
// "~/.pilot/bin/pilot-ask".
50+
Dst string `json:"dst"`
51+
// Mode is the file mode in octal string form, e.g. "0755". Empty
52+
// defaults to 0755 (helpers are executables).
53+
Mode string `json:"mode,omitempty"`
3454
}
3555

3656
// ManifestTool is one tool target row.

pkg/skillinject/reconcile.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,59 @@ func writeFile(path string, content []byte) error {
2727
return nil
2828
}
2929

30+
// writeHelper installs a helper script at path with the given mode if
31+
// the file is missing or its content hash differs. Atomic write-via-rename.
32+
// Returns the resulting State for reporting (Absent → created, Drifted →
33+
// rewritten, Identical → noop).
34+
func writeHelper(path string, content []byte, mode os.FileMode) (State, error) {
35+
cur, err := os.ReadFile(path)
36+
state := StateAbsent
37+
switch {
38+
case err != nil && !os.IsNotExist(err):
39+
return state, err
40+
case err == nil:
41+
if sha256Hex(cur) == sha256Hex(content) {
42+
st, sErr := os.Stat(path)
43+
if sErr == nil && st.Mode().Perm() == mode.Perm() {
44+
return StateIdentical, nil
45+
}
46+
state = StateDrifted
47+
} else {
48+
state = StateDrifted
49+
}
50+
}
51+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
52+
return state, err
53+
}
54+
tmp := path + ".tmp"
55+
if err := os.WriteFile(tmp, content, mode); err != nil {
56+
return state, err
57+
}
58+
if err := os.Chmod(tmp, mode); err != nil {
59+
_ = os.Remove(tmp)
60+
return state, err
61+
}
62+
if err := os.Rename(tmp, path); err != nil {
63+
_ = os.Remove(tmp)
64+
return state, err
65+
}
66+
return state, nil
67+
}
68+
69+
// parseFileMode parses an octal mode string like "0755". Empty input
70+
// returns the default 0o755 (executable). Invalid input returns 0o755
71+
// with no error so a malformed manifest doesn't break the tick.
72+
func parseFileMode(s string) os.FileMode {
73+
if s == "" {
74+
return 0o755
75+
}
76+
var v uint64
77+
if _, err := fmt.Sscanf(s, "%o", &v); err != nil || v == 0 {
78+
return 0o755
79+
}
80+
return os.FileMode(v)
81+
}
82+
3083
// markerRE matches our heartbeat marker block. The hash field lets us
3184
// detect drift cheaply: if the canonical hash changes we re-render the
3285
// block.

pkg/skillinject/skillinject.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,37 @@ func Tick(ctx context.Context, cfg Config) (*Report, error) {
107107

108108
report := &Report{At: time.Now().UTC()}
109109

110+
// (0) install host-wide helpers (e.g. ~/.pilot/bin/pilot-ask). These
111+
// are tool-agnostic and referenced from every tool's heartbeat
112+
// directive. Failure is best-effort: we record an error outcome and
113+
// continue with skill/marker reconciliation.
114+
for _, h := range manifest.Helpers {
115+
dst := expandHome(h.Dst, home)
116+
o := Outcome{Tool: h.Name, Kind: KindHelper, Path: dst}
117+
body, err := f.fetchRepoFile(ctx, h.Src)
118+
if err != nil {
119+
o.Action = ActionError
120+
o.Err = fmt.Sprintf("fetch %s: %v", h.Src, err)
121+
report.Outcomes = append(report.Outcomes, o)
122+
continue
123+
}
124+
o.Hash = sha256Hex(body)
125+
state, err := writeHelper(dst, body, parseFileMode(h.Mode))
126+
o.State = state
127+
switch {
128+
case err != nil:
129+
o.Action = ActionError
130+
o.Err = err.Error()
131+
case state == StateAbsent:
132+
o.Action = ActionCreate
133+
case state == StateDrifted:
134+
o.Action = ActionRewrite
135+
default:
136+
o.Action = ActionNoop
137+
}
138+
report.Outcomes = append(report.Outcomes, o)
139+
}
140+
110141
for _, mt := range manifest.Tools {
111142
rootDir := expandHome(mt.RootDir, home)
112143
if !dirExists(rootDir) {

pkg/skillinject/skillinject_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,82 @@ func TestMarker_IsDirective(t *testing.T) {
334334
}
335335
}
336336

337+
// Helpers (~/.pilot/bin/pilot-ask) are installed once per tick with the
338+
// requested mode, then become noop on subsequent ticks while content
339+
// is unchanged, and rewrite when the upstream content drifts.
340+
func TestTick_InstallsHelpers(t *testing.T) {
341+
home := t.TempDir()
342+
mustMkdirAll(t, filepath.Join(home, ".claude"))
343+
344+
r := newFakeRepo(t)
345+
r.withTools(claudeOnly())
346+
r.manifest.Helpers = []ManifestHelper{{
347+
Name: "pilot-ask",
348+
Src: "workflow-injection/pilot-ask",
349+
Dst: "~/.pilot/bin/pilot-ask",
350+
Mode: "0755",
351+
}}
352+
r.files["workflow-injection/pilot-ask"] = []byte("#!/usr/bin/env bash\necho v1\n")
353+
354+
rep, err := Tick(context.Background(), r.cfg(home))
355+
if err != nil {
356+
t.Fatalf("Tick: %v", err)
357+
}
358+
359+
dst := filepath.Join(home, ".pilot", "bin", "pilot-ask")
360+
mustExist(t, dst)
361+
st, err := os.Stat(dst)
362+
if err != nil {
363+
t.Fatalf("stat: %v", err)
364+
}
365+
if got := st.Mode().Perm(); got != 0o755 {
366+
t.Errorf("mode = %o, want 0755", got)
367+
}
368+
got := mustRead(t, dst)
369+
if !strings.Contains(got, "echo v1") {
370+
t.Errorf("content not installed: %q", got)
371+
}
372+
373+
var helperOutcome *Outcome
374+
for i := range rep.Outcomes {
375+
if rep.Outcomes[i].Kind == KindHelper {
376+
helperOutcome = &rep.Outcomes[i]
377+
}
378+
}
379+
if helperOutcome == nil {
380+
t.Fatalf("no helper outcome in report: %+v", rep.Outcomes)
381+
}
382+
if helperOutcome.Action != ActionCreate {
383+
t.Errorf("first tick: action = %v, want create", helperOutcome.Action)
384+
}
385+
386+
// Second tick: identical → noop.
387+
rep2, err := Tick(context.Background(), r.cfg(home))
388+
if err != nil {
389+
t.Fatalf("Tick 2: %v", err)
390+
}
391+
for _, o := range rep2.Outcomes {
392+
if o.Kind == KindHelper && o.Action != ActionNoop {
393+
t.Errorf("second tick: action = %v, want noop", o.Action)
394+
}
395+
}
396+
397+
// Drift: upstream content changes → rewrite.
398+
r.files["workflow-injection/pilot-ask"] = []byte("#!/usr/bin/env bash\necho v2\n")
399+
rep3, err := Tick(context.Background(), r.cfg(home))
400+
if err != nil {
401+
t.Fatalf("Tick 3: %v", err)
402+
}
403+
for _, o := range rep3.Outcomes {
404+
if o.Kind == KindHelper && o.Action != ActionRewrite {
405+
t.Errorf("drift tick: action = %v, want rewrite", o.Action)
406+
}
407+
}
408+
if got := mustRead(t, dst); !strings.Contains(got, "echo v2") {
409+
t.Errorf("content not rewritten: %q", got)
410+
}
411+
}
412+
337413
// helpers
338414

339415
func mustMkdirAll(t *testing.T, p string) {

pkg/skillinject/state.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type FileKind string
3535
const (
3636
KindSkill FileKind = "skill"
3737
KindMarker FileKind = "marker"
38+
KindHelper FileKind = "helper"
3839
)
3940

4041
func actionFor(s State) Action {

0 commit comments

Comments
 (0)