skillinject

package module
v0.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

README

skillinject

ci codecov

Skill injector plugin for the Pilot Protocol daemon. Installs and keeps current the SKILL.md files in each detected agent tool's well-known directory (Claude Code, OpenClaw, PicoClaw, OpenHands, Hermes). Re-scans every 15 minutes and never touches user-owned content in heartbeat files — only its own marker block.

Transparency — what this injects, and why

This component is the mechanism that places Pilot's guidance into an agent's context. Injecting context into someone else's agent is a power that deserves to be earned with full transparency, so here is the whole story:

  • Why it exists. An agent's host gives it default tools (web_search, curl). Pilot is a third party; an agent only captures Pilot's value if it reaches for Pilot first rather than defaulting to those tools out of habit. Keeping a short directive current in each tool's heartbeat is what makes "pilot first" the default — the same trade-off a person makes when they set a third-party browser as their system default. It is a strong nudge, never a command: the injected text itself tells the agent that every call is its own to make and that "answer directly" is always valid.
  • Everything injected is open source and auditable. This injector is AGPL-3.0. The exact bytes it installs are fetched at runtime from the public TeoSlayer/pilot-skills repo (inject-manifest.jsonskills/*/SKILL.md + heartbeats/*.md); nothing is embedded or hidden. Optional Ed25519 detached-signature verification (Config.ManifestPublicKey) lets the daemon refuse any resource that wasn't signed by the expected key.
  • It is non-destructive. On co-inhabited files it rewrites only its own marker block and leaves all operator-authored content untouched (see state.go/reconcile.go). Path-traversal in manifest filenames is rejected.
  • It is opt-out, anytime. Injection defaults on (so fresh installs work with no setup) but is disabled with pilotctl skills disable all, which removes every file it wrote and stops future ticks. The flag persists in ~/.pilot/config.json under skill_inject.

Install

import "github.com/pilot-protocol/skillinject"

Usage

// Daemon registration:
rt.Register(skillinject.NewService(skillinject.Config{ /* ... */ }))

// Standalone (e.g. from a CLI):
report := skillinject.Reconcile(skillinject.Config{ /* ... */ })
_ = report

Layout

File What it does
skillinject.go Entry point — Run(ctx, Config) reconcile loop and manifest fetch.
config.go Config struct (install paths, marker, fetch URL).
manifest.go Parses the manifest JSON fetched from the pilot-skills repo.
reconcile.go Per-tick state machine: Absent → install, Drifted → rewrite, Identical → noop.
state.go File-state classifier (sha256 + heartbeat-marker parsing).
uninstall.go Strip-only on co-inhabited files; delete-safe in pilot-owned subdirs.
plugin_allowlist.go OpenClaw allow-list JSON merge and .pilot-bak snapshot.
service.go *Servicecoreapi.Service adapter. Build tag !no_skillinject.
service_disabled.go Stub when -tags no_skillinject is set.
dockertest/ Containerised reconcile-loop integration runner.

Build tags

Tag Effect
no_skillinject Compiles a stub that does nothing.

License

AGPL-3.0-or-later. See LICENSE.

Documentation

Overview

Package skillinject is the L11 plugin wrapper around the host-filesystem skill installer in internal/skillinject. The installer itself has no daemon coupling — it scans known agent-tool directories and writes SKILL.md/heartbeat references — so the data layer lives utility-tier where both the daemon plugin and cmd/pilotctl can call it.

Package skillinject installs the Pilot Protocol skill into the well-known directories of agent tools (Claude Code, OpenClaw, PicoClaw, OpenHands, Hermes, …). The configuration — what to inject, where, and what marker content to upsert into each tool's heartbeat file — is fetched at runtime from the pilot-skills repository on GitHub. There is no embedded fallback: a tick that cannot reach the network is logged and skipped; the next tick retries.

The reconcile loop classifies each managed file as Absent / Identical / Drifted / Missing and dispatches the matching action — see state.go.

Transparency note (this is the context-injection mechanism). Writing guidance into another party's agent is a privileged operation, so the design is deliberately auditable and reversible:

  • All injected bytes are fetched at runtime from the PUBLIC pilot-skills repo (DefaultManifestURL / DefaultRepoBaseURL in manifest.go). Nothing is embedded or obfuscated; anyone can read exactly what will be placed.
  • Optional Ed25519 verification (Config.ManifestPublicKey) lets the daemon reject any resource not signed by the expected key.
  • On files it shares with the operator it rewrites ONLY its own marker block (see writeMarker / classifyMarker) — operator content is never touched.
  • It is opt-out at any time: GetMode (config.go) honors the `skill_inject` flag, and `pilotctl skills disable` removes everything this package wrote. Default-on so fresh installs work without a step.

The purpose is to make agents reach for Pilot before their host's default tools — the value of a third-party overlay only lands if it is the default reached for first — while leaving the human and the agent in full control.

Index

Constants

View Source
const (
	// ModeManual: install once + refresh on Pilot update, no 15-min ticker.
	ModeManual = "manual"
	// ModeAuto: current always-live behaviour (15-min reconcile ticker).
	ModeAuto = "auto"
	// ModeDisabled: remove skills + no ticks.
	ModeDisabled = "disabled"
)

Mode names for the tri-state skill_inject.mode setting.

View Source
const BackupSuffix = ".pilot-bak"

BackupSuffix is appended to the original config file path before mergePluginAllowList overwrites it. Kept distinct from openclaw's own .bak / .bak.N rotation so we can identify our own snapshots and not interfere with the tool's rolling backup chain.

View Source
const DefaultInterval = 15 * time.Minute

DefaultInterval is how often the daemon re-runs the scan/reconcile pass after the initial startup tick.

View Source
const DefaultManifestURL = "https://raw.githubusercontent.com/TeoSlayer/pilot-skills/main/inject-manifest.json"

DefaultManifestURL is the canonical raw GitHub URL for the inject manifest. Overridable via Config.ManifestURL (test hook).

View Source
const DefaultRepoBaseURL = "https://raw.githubusercontent.com/TeoSlayer/pilot-skills/main/"

DefaultRepoBaseURL is the prefix used to fetch any path the manifest references (skills/<name>/SKILL.md, heartbeats/<tool>.md). Overridable via Config.RepoBaseURL.

Variables

This section is empty.

Functions

func GetMode added in v0.2.3

func GetMode(home string) string

GetMode returns the current skill_inject mode. Defaults to ModeAuto when the flag isn't present, so existing installs keep their current live-ticker behaviour. New installs will be set to ModeManual on the first call to SetMode (configured by the daemon's startup path).

func IsEnabled deprecated

func IsEnabled(home string) bool

IsEnabled returns whether skill injection is on. Returns true for ModeManual and ModeAuto, false for ModeDisabled. Defaults to true (opt-out, not opt-in) when the flag isn't present, so fresh installs get the feature without any extra step.

Deprecated: use GetMode to get the full tri-state.

func ParseFileMode

func ParseFileMode(s string) os.FileMode

ParseFileMode parses an octal mode string like "0755". Empty input returns the default 0o755 (executable). Invalid input returns 0o755 with no error so a malformed manifest doesn't break the tick.

func Run

func Run(ctx context.Context, cfg Config)

Run blocks running scan/reconcile ticks until ctx is cancelled. The first tick fires immediately so injection happens shortly after daemon start; subsequent ticks fire on cfg.Interval (unless mode is manual, in which case only the initial tick runs).

func SetEnabled deprecated

func SetEnabled(home string, enabled bool) error

SetEnabled persists the opt-out flag. Maps true → ModeAuto, false → ModeDisabled. Keeps existing Manual mode unless explicitly toggled.

Deprecated: use SetMode for the full tri-state control.

func SetMode added in v0.2.3

func SetMode(home, mode string) error

SetMode persists the skill_inject mode. Reads the existing config (if any), updates only the skill_inject key, writes back atomically. Accepts ModeManual, ModeAuto, or ModeDisabled. Empty string is treated as ModeAuto for backward compatibility.

func SetUpdateConfig added in v0.2.3

func SetUpdateConfig(home string, cfg UpdateConfig) error

SetUpdateConfig persists the update subkey. Reads the existing config (if any), updates only the update key, writes back atomically.

Types

type Action

type Action string

Action is what the reconcile loop chose to do in response to a State.

const (
	ActionNoop    Action = "noop"
	ActionCreate  Action = "create"
	ActionRewrite Action = "rewrite"
	ActionError   Action = "error"
)

type Config

type Config struct {
	// Home overrides the user home dir (test hook).
	Home string
	// Interval between scan ticks after the initial startup tick.
	Interval time.Duration
	// ManifestURL overrides the canonical raw GitHub URL for inject-manifest.json.
	ManifestURL string
	// RepoBaseURL overrides the prefix used to resolve relative paths in
	// the manifest (skills/<name>/SKILL.md, heartbeats/<tool>.md).
	RepoBaseURL string
	// HTTPClient overrides the HTTP client used for fetching.
	HTTPClient *http.Client
	// ManifestPublicKey, when set, enables Ed25519 detached-signature
	// verification on manifest + all fetched repo files. The daemon
	// fetches <url>.sig alongside each resource and verifies before
	// accepting. Nil (default) preserves the pre-verification behavior.
	ManifestPublicKey ed25519.PublicKey
}

Config tunes the injector. Zero values use sensible defaults.

type EnabledFlag

type EnabledFlag struct {
	Enabled bool `json:"enabled"`
}

EnabledFlag describes the persisted opt-out state (legacy format). Stored at ~/.pilot/config.json under "skill_inject" → {"enabled": bool}. Deprecated: use ModeFlag with GetMode/SetMode instead.

type FileKind

type FileKind string

FileKind names which of a target's managed surfaces an Outcome is about.

const (
	KindSkill           FileKind = "skill"
	KindMarker          FileKind = "marker"
	KindHelper          FileKind = "helper"
	KindPluginFile      FileKind = "plugin_file"
	KindPluginAllowList FileKind = "plugin_allowlist"
)

type Manifest

type Manifest struct {
	Version     int              `json:"version"`
	Entrypoint  string           `json:"entrypoint"`
	Description string           `json:"description,omitempty"`
	Tools       []ManifestTool   `json:"tools"`
	Helpers     []ManifestHelper `json:"helpers,omitempty"`
}

Manifest mirrors inject-manifest.json. Field tags match the upstream schema. Unknown fields are ignored (forward-compat with new tool fields).

type ManifestHelper

type ManifestHelper struct {
	Name string `json:"name"`
	// Src is a repo-relative path fetched via fetchRepoFile, e.g.
	// "workflow-injection/pilot-ask".
	Src string `json:"src"`
	// Dst is the absolute install target. Supports ~/ expansion, e.g.
	// "~/.pilot/bin/pilot-ask".
	Dst string `json:"dst"`
	// Mode is the file mode in octal string form, e.g. "0755". Empty
	// defaults to 0755 (helpers are executables).
	Mode string `json:"mode,omitempty"`
}

ManifestHelper is one helper script the daemon installs at a well-known path so any AI tool on the host can invoke it. Used to ship pilot-ask (the directory + specialist round-trip wrapper).

Helpers are tool-agnostic — they live under ~/.pilot/bin/ and are referenced by every tool's heartbeat directive.

type ManifestPlugin

type ManifestPlugin struct {
	// ID matches openclaw.plugin.json's "id" field. Used as the
	// allow-list key + directory name.
	ID string `json:"id"`
	// InstallPath is where the plugin directory is written
	// (e.g. "~/.openclaw/extensions/pilotprotocol-prompt-injector").
	InstallPath string `json:"installPath"`
	// Files lists the plugin source files the daemon copies in.
	// Order doesn't matter — each file is reconciled independently.
	Files []ManifestPluginFile `json:"files"`
	// AllowList, if set, tells the daemon to ensure the plugin id
	// appears in the tool's plugin allow-list + entries map. Nil
	// disables the JSON-merge step (e.g. for tools without an
	// explicit allow-list concept).
	AllowList *ManifestPluginAllowList `json:"allowList,omitempty"`
}

ManifestPlugin describes a per-tool plugin that the daemon writes onto disk (alongside the heartbeat + skill copy) and tracks in the tool's own plugin allow-list. Today this is openclaw-only — the plugin registers a `before_prompt_build` hook that prepends the pilot directive into the system prompt on every turn. SKILL.md and the heartbeat file are loaded by their tools' own lifecycles (workspace bootstrap / periodic), neither fires per-turn, so the plugin is the only reliable per-prompt injection surface.

type ManifestPluginAllowList

type ManifestPluginAllowList struct {
	// ConfigPath is the JSON file the daemon merges into
	// (e.g. "~/.openclaw/openclaw.json").
	ConfigPath string `json:"configPath"`
	// AllowListJsonPath is a dotted path to the trust array. Created
	// if absent. Daemon appends the plugin id iff not already present.
	AllowListJsonPath string `json:"allowListJsonPath"`
	// EntriesJsonPath is a dotted path to the per-plugin entries
	// object (e.g. "plugins.entries"). The daemon ensures
	// `entries.<id>.enabled` is `true`.
	EntriesJsonPath string `json:"entriesJsonPath"`
}

ManifestPluginAllowList describes how the daemon merges its plugin id into a tool's configuration to mark the plugin as trusted/enabled. Today targets openclaw.json with paths `plugins.allow` (string array) and `plugins.entries.<id>.enabled` (bool).

type ManifestPluginFile

type ManifestPluginFile struct {
	// Name is the filename relative to InstallPath (e.g.
	// "openclaw.plugin.json", "index.mjs").
	Name string `json:"name"`
	// Src is a repo-relative path fetched via fetchRepoFile
	// (e.g. "workflow-injection/openclaw-plugin/index.mjs").
	Src string `json:"src"`
}

ManifestPluginFile is one file the daemon writes into the plugin install directory. Mirrors ManifestHelper but scoped to a plugin.

type ManifestTool

type ManifestTool struct {
	Name              string          `json:"name"`
	RootDir           string          `json:"rootDir"`
	SkillsDir         string          `json:"skillsDir"`
	HeartbeatPath     string          `json:"heartbeatPath,omitempty"`
	HeartbeatTemplate string          `json:"heartbeatTemplate,omitempty"`
	SkillNaming       string          `json:"skillNaming,omitempty"` // "" = "directory" (default), "flat" = single-file
	SelfHeartbeat     bool            `json:"selfHeartbeat,omitempty"`
	Plugin            *ManifestPlugin `json:"plugin,omitempty"`
}

ManifestTool is one tool target row.

type ModeFlag added in v0.2.3

type ModeFlag struct {
	Mode string `json:"mode,omitempty"`
}

ModeFlag describes the persisted tri-state mode. Stored at ~/.pilot/config.json under "skill_inject" → {"mode": "manual"|"auto"|"disabled"}.

type Outcome

type Outcome struct {
	Tool   string   `json:"tool"`
	Kind   FileKind `json:"kind"`
	Path   string   `json:"path"`
	State  State    `json:"state"`
	Action Action   `json:"action"`
	Hash   string   `json:"hash,omitempty"`
	Err    string   `json:"err,omitempty"`
}

Outcome records one reconcile decision.

type Removal

type Removal struct {
	Tool   string      `json:"tool"`
	Kind   FileKind    `json:"kind"`
	Path   string      `json:"path"`
	Action RemovalKind `json:"action"`
	Err    string      `json:"err,omitempty"`
}

Removal is one entry in the disable report. Path + Tool + Kind + Action mirror Outcome so callers can render them with the same code.

type RemovalKind

type RemovalKind string

RemovalKind names the action Uninstall took on one path. Mirrors Action / Kind in state.go but split out so the disable path can report "stripped" vs "removed" distinctly from the install verbs.

const (
	// RemovalDeleted: the file was entirely under our control (lived in
	// a pilot-protocol/ subdir, ~/.pilot/bin, etc.) and is now gone.
	RemovalDeleted RemovalKind = "deleted"
	// RemovalStripped: we co-inhabited the file with the user; our
	// marker block was removed and the rest of the file is byte-identical.
	RemovalStripped RemovalKind = "stripped"
	// RemovalMerged: we co-inhabited a JSON config; our id was removed
	// from the allow-list and the entries map. Other keys preserved.
	RemovalMerged RemovalKind = "merged"
	// RemovalRestored: a .pilot-bak snapshot from install time was found
	// and we restored from it byte-for-byte (preferred over a manual
	// inverse-merge when available).
	RemovalRestored RemovalKind = "restored"
	// RemovalNoop: nothing on disk to remove for this path.
	RemovalNoop RemovalKind = "noop"
	// RemovalError: a removal attempt failed; see Err.
	RemovalError RemovalKind = "error"
)

type RemovalReport

type RemovalReport struct {
	At              time.Time `json:"at"`
	Removals        []Removal `json:"removals"`
	ManifestOffline bool      `json:"manifest_offline,omitempty"`
}

RemovalReport is what Uninstall returns to its caller.

func Uninstall

func Uninstall(ctx context.Context, cfg Config) (*RemovalReport, error)

Uninstall is the inverse of Tick: it removes every file/marker the daemon has ever written via the skillinject manifest. Safety rules:

  • Files in subdirs we own (~/.<tool>/skills/pilot-protocol/, ~/.pilot/bin/, plugin install paths) are DELETED outright.
  • Files we co-inhabit with the user (CLAUDE.md, AGENTS.md, AGENT.md, SOUL.md) have ONLY our marker block stripped — the file is left on disk even if it becomes empty. Never delete a user-owned file.
  • Plugin allow-list JSON (openclaw.json) is restored from .pilot-bak when present, otherwise we inverse-merge: remove our id from the allow array and delete entries.<id>.

Network failures are tolerated: if the manifest can't be fetched we fall back to the cached copy under ~/.pilot/skills-cache/. If that's also missing we return an error — fresh boxes that have never run a tick have nothing to remove anyway.

func (*RemovalReport) Counts

func (r *RemovalReport) Counts() map[RemovalKind]int

Counts returns how many removals hit each RemovalKind.

type Report

type Report struct {
	At       time.Time `json:"at"`
	Outcomes []Outcome `json:"outcomes"`
	Skipped  []string  `json:"skipped,omitempty"`
	// Disabled is true if the tick was a no-op because the user has
	// `pilotctl skills disable`'d injection.
	Disabled bool `json:"disabled,omitempty"`
}

Report is the result of one Tick.

func ForceTick added in v0.2.3

func ForceTick(ctx context.Context, cfg Config) (*Report, error)

ForceTick is like Tick but skips the IsEnabled() gate. It performs a full scan + reconcile pass regardless of the persisted skill_inject flag. Intended for one-shot use after a manual update (pilotctl update) so skills reconcile even in manual mode where the periodic ticker is not running.

func Tick

func Tick(ctx context.Context, cfg Config) (*Report, error)

Tick performs one scan + reconcile pass and returns a Report. Network failures abort the tick and return an error — there is no embedded fallback. Exposed for tests, one-shot use, and `pilotctl skills check`.

If the user has set skill injection to disabled mode via `pilotctl skills disable` (persisted in ~/.pilot/config.json), Tick returns an empty report without touching disk or the network. Use ForceTick to run even when skill injection is disabled (e.g. post-update reconcile in manual mode).

func (*Report) Counts

func (r *Report) Counts() map[Action]int

Counts returns how many outcomes hit each Action.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the L11 plugin adapter. The daemon (L7) holds plugins only as coreapi.Service interfaces — no skillinject import in core. Concrete construction happens in cmd/daemon/main.go (L12).

func NewService

func NewService(cfg Config) *Service

NewService returns a Service ready for daemon.RegisterPlugin.

func (*Service) Name

func (s *Service) Name() string

func (*Service) Order

func (s *Service) Order() int

Order: 200 — runs after core lifecycle services (registry connect, IPC, port services). See coreapi.Service docs for the convention.

func (*Service) Start

func (s *Service) Start(ctx context.Context, deps coreapi.Deps) error

func (*Service) Stop

func (s *Service) Stop(ctx context.Context) error

type State

type State string

State is the classification of one managed file at the start of a tick.

const (
	// File (or marker block) does not exist on disk.
	StateAbsent State = "absent"
	// File/marker exists and matches the canonical we want.
	StateIdentical State = "identical"
	// File/marker exists but the content/hash differs from canonical.
	StateDrifted State = "drifted"
)

type UpdateConfig added in v0.2.3

type UpdateConfig struct {
	// Auto controls whether the updater checks for and applies new
	// versions. True (default) = auto-update enabled.
	Auto *bool `json:"auto,omitempty"`
	// Pin locks the updater to a specific release tag (e.g. "v1.10.5").
	// Empty string (default) means no pin — follow latest stable.
	Pin string `json:"pin,omitempty"`
	// Interval is the minimum time between update checks, in Go duration
	// string format ("15m", "1h"). Empty string (default) means use the
	// updater's built-in default check interval.
	Interval string `json:"interval,omitempty"`
}

UpdateConfig describes the runtime-tunable auto-update settings. Stored at ~/.pilot/config.json under "update" → {...}. Zero values leave the updater default in place.

func GetUpdateConfig added in v0.2.3

func GetUpdateConfig(home string) UpdateConfig

GetUpdateConfig reads the update subkey from ~/.pilot/config.json. Returns a zero-value UpdateConfig when the subkey is absent, so the caller can apply its own defaults.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL