676 lines
28 KiB
Go
676 lines
28 KiB
Go
package skill
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// SubagentRunner runs a runAs=subagent skill: it spawns an isolated child loop
|
|
// with the skill body as system prompt and `task` as its only input, returning
|
|
// the final answer. boot wires this over the agent's sub-agent machinery; nil
|
|
// means subagent skills are unavailable in this session (they error rather than
|
|
// silently inlining, which would lose the isolation the author asked for).
|
|
type SubagentRunOptions struct {
|
|
ContinueFrom string
|
|
ForkFrom string
|
|
// HostInitiated marks an explicit controller entry point such as
|
|
// /<subagent-skill>. It may still carry a synthetic call context for nested
|
|
// UI events, but that ephemeral event ID must not be persisted as though it
|
|
// were a provider-visible parent tool call.
|
|
HostInitiated bool
|
|
}
|
|
|
|
type SubagentRunner func(ctx context.Context, sk Skill, task string, opts SubagentRunOptions) (string, error)
|
|
|
|
// ProfileResolver returns the model/effort profile a subagent skill will use.
|
|
// It is optional; without one, skill frontmatter still supplies display metadata.
|
|
type ProfileResolver func(sk Skill) *event.Profile
|
|
|
|
// InstalledHook fires after install_skill writes a new file, so a host can
|
|
// refresh UI (e.g. a skills sidebar) without a reload. nil is fine.
|
|
type InstalledHook func(name, path string, scope Scope)
|
|
|
|
// --- run_skill ---
|
|
|
|
type runSkillTool struct {
|
|
store *Store
|
|
runner SubagentRunner
|
|
profileResolver ProfileResolver
|
|
}
|
|
|
|
// NewRunSkillTool builds the general skill-invocation tool. runner may be nil
|
|
// (subagent skills then error).
|
|
func NewRunSkillTool(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) tool.Tool {
|
|
var pr ProfileResolver
|
|
if len(profileResolver) > 0 {
|
|
pr = profileResolver[0]
|
|
}
|
|
return &runSkillTool{store: store, runner: runner, profileResolver: pr}
|
|
}
|
|
|
|
func (*runSkillTool) Name() string { return "run_skill" }
|
|
|
|
// ReadOnly is false: an invoked subagent skill could call writer tools, so
|
|
// classify conservatively to keep the parallel-dispatch path from racing two
|
|
// skill runs' writes (mirrors the `task` tool).
|
|
func (*runSkillTool) ReadOnly() bool { return false }
|
|
|
|
func (*runSkillTool) Description() string {
|
|
return "Invoke a playbook from the Skills index pinned in the system prompt. For the built-in subagent skills (explore / research / review / security_review), prefer the dedicated top-level tools of the same name — they're easier to pick and do the same thing. Pass `name` as the BARE identifier (e.g. 'explore'), NOT the `[🧬 subagent]` tag that follows it in the index. `[🧬 subagent]` skills spawn an isolated subagent — only the final distilled answer returns; supply `arguments` describing the concrete task since the subagent has no other context. Untagged skills are inlined: the body becomes a tool result you read and follow."
|
|
}
|
|
|
|
func (*runSkillTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"name":{"type":"string","description":"Skill identifier as it appears in the pinned Skills index (e.g. 'explore', 'review'). Case-sensitive. Just the identifier, not the [🧬 subagent] tag."},
|
|
"arguments":{"type":"string","description":"Free-form arguments. For inline skills: appended as an 'Arguments:' line; the skill's own instructions decide how to use them. For subagent skills: REQUIRED — becomes the entire task the subagent receives."},
|
|
"continue_from":{"type":"string","description":"Continue a prior compatible subagent transcript in the current conversation context. Only valid for runAs=subagent skills. Pass only the 'sa_...' value from the prior result's 'Subagent reference: ...' line."}
|
|
},
|
|
"required":["name"]
|
|
}`)
|
|
}
|
|
|
|
func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
Continue string `json:"continue_from"`
|
|
Fork string `json:"fork_from"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
name := cleanSkillName(p.Name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("run_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
|
|
}
|
|
sk, ok := t.store.Read(name)
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
|
|
}
|
|
if err := t.store.ValidateInvocation(sk); err != nil {
|
|
return "", fmt.Errorf("run_skill: %w", err)
|
|
}
|
|
rawArgs := strings.TrimSpace(p.Arguments)
|
|
opts := SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)}
|
|
if opts.ContinueFrom != "" && opts.ForkFrom != "" {
|
|
return "", fmt.Errorf("run_skill: continue_from and fork_from are mutually exclusive; pass only continue_from")
|
|
}
|
|
|
|
if sk.RunAs == RunSubagent {
|
|
if t.runner == nil {
|
|
return "", fmt.Errorf("run_skill: skill %q is runAs=subagent but no subagent runner is configured in this session", name)
|
|
}
|
|
if rawArgs == "" {
|
|
return "", fmt.Errorf("run_skill: skill %q is a subagent and requires 'arguments' — the subagent has no other context, so describe the concrete task", name)
|
|
}
|
|
out, err := t.runner(ctx, sk, rawArgs, opts)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return tool.GuardSubagentHostDecisionText(out), nil
|
|
}
|
|
if opts.ContinueFrom != "" || opts.ForkFrom != "" {
|
|
return "", fmt.Errorf("run_skill: subagent continuation is only valid for runAs=subagent skills")
|
|
}
|
|
return renderInline(sk, rawArgs), nil
|
|
}
|
|
|
|
func (t *runSkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return nil
|
|
}
|
|
name := cleanSkillName(p.Name)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
sk, ok := t.store.Read(name)
|
|
if !ok || sk.RunAs != RunSubagent {
|
|
return nil
|
|
}
|
|
return t.profileForSkill(sk)
|
|
}
|
|
|
|
func (t *runSkillTool) profileForSkill(sk Skill) *event.Profile {
|
|
return profileForSkill(sk, t.profileResolver)
|
|
}
|
|
|
|
// --- read_only_skill ---
|
|
|
|
type readOnlySkillTool struct {
|
|
store *Store
|
|
runner SubagentRunner
|
|
profileResolver ProfileResolver
|
|
}
|
|
|
|
// NewReadOnlySkillTool builds a plan-mode-safe skill entry point. Inline skills
|
|
// are rendered like read_skill; subagent skills run through a host-provided
|
|
// read-only subagent runner with no continuation/fork controls.
|
|
func NewReadOnlySkillTool(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) tool.Tool {
|
|
var pr ProfileResolver
|
|
if len(profileResolver) > 0 {
|
|
pr = profileResolver[0]
|
|
}
|
|
return &readOnlySkillTool{store: store, runner: runner, profileResolver: pr}
|
|
}
|
|
|
|
func (*readOnlySkillTool) Name() string { return "read_only_skill" }
|
|
|
|
func (*readOnlySkillTool) ReadOnly() bool { return true }
|
|
|
|
// PlanModeSafe reports true: read_only_skill delegates to a skill sub-agent
|
|
// restricted to read-only research tools and plan-mode-safe foreground bash, so
|
|
// it is safe to run while planning.
|
|
func (*readOnlySkillTool) PlanModeSafe() bool { return true }
|
|
|
|
func (*readOnlySkillTool) Description() string {
|
|
return "Invoke a skill in read-only mode. Inline skills are loaded into context like read_skill. `[🧬 subagent]` skills run in an isolated ephemeral read-only subagent with only read-only research tools and safe foreground bash; no writes, installers, memory mutation, continuation/fork, background jobs, or writer-capable delegation are available. Read-only nested delegation may be available until max_subagent_depth is reached. Pass `name` as the bare skill identifier and `arguments` as the concrete task."
|
|
}
|
|
|
|
func (*readOnlySkillTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"name":{"type":"string","description":"Skill identifier as it appears in the pinned Skills index. Just the identifier, not the [🧬 subagent] tag."},
|
|
"arguments":{"type":"string","description":"Free-form arguments. For inline skills: appended as an 'Arguments:' line. For subagent skills: REQUIRED — becomes the read-only subagent's entire task."}
|
|
},
|
|
"required":["name"]
|
|
}`)
|
|
}
|
|
|
|
func (t *readOnlySkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
name := cleanSkillName(p.Name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("read_only_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
|
|
}
|
|
sk, ok := t.store.Read(name)
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
|
|
}
|
|
if err := t.store.ValidateInvocation(sk); err != nil {
|
|
return "", fmt.Errorf("read_only_skill: %w", err)
|
|
}
|
|
rawArgs := strings.TrimSpace(p.Arguments)
|
|
if sk.RunAs == RunSubagent {
|
|
if t.runner == nil {
|
|
return "", fmt.Errorf("read_only_skill: skill %q is runAs=subagent but no read-only subagent runner is configured in this session", name)
|
|
}
|
|
if rawArgs == "" {
|
|
return "", fmt.Errorf("read_only_skill: skill %q is a subagent and requires 'arguments' — the subagent has no other context, so describe the concrete read-only task", name)
|
|
}
|
|
out, err := t.runner(ctx, sk, rawArgs, SubagentRunOptions{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return tool.GuardSubagentHostDecisionText(out), nil
|
|
}
|
|
return renderInline(sk, rawArgs), nil
|
|
}
|
|
|
|
func (t *readOnlySkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return nil
|
|
}
|
|
name := cleanSkillName(p.Name)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
sk, ok := t.store.Read(name)
|
|
if !ok || sk.RunAs != RunSubagent {
|
|
return nil
|
|
}
|
|
return profileForSkill(sk, t.profileResolver)
|
|
}
|
|
|
|
func profileForSkill(sk Skill, resolver ProfileResolver) *event.Profile {
|
|
if resolver != nil {
|
|
if pr := resolver(sk); pr != nil {
|
|
return pr
|
|
}
|
|
}
|
|
model, effort := strings.TrimSpace(sk.Model), strings.TrimSpace(sk.Effort)
|
|
if model == "" && effort == "" {
|
|
return nil
|
|
}
|
|
return &event.Profile{Model: model, Effort: effort}
|
|
}
|
|
|
|
// readSkillTool loads an inline skill body into context without running anything.
|
|
type readSkillTool struct {
|
|
store *Store
|
|
}
|
|
|
|
// NewReadSkillTool builds a read-only inline-skill loader. Unlike run_skill it
|
|
// stays available in plan mode, so a plan can consult inline playbooks.
|
|
func NewReadSkillTool(store *Store) tool.Tool { return &readSkillTool{store: store} }
|
|
|
|
func (*readSkillTool) Name() string { return "read_skill" }
|
|
|
|
// ReadOnly is true: read_skill only renders an inline skill body (no subagent,
|
|
// no side effects), so it is allowed in plan mode where run_skill is not.
|
|
func (*readSkillTool) ReadOnly() bool { return true }
|
|
|
|
func (*readSkillTool) Description() string {
|
|
return "Load an inline playbook from the Skills index into your context WITHOUT running anything — the skill body returns as a tool result you read and follow. Read-only, so it works in plan mode (unlike run_skill). Pass `name` as the BARE identifier (e.g. 'commit'), NOT the `[🧬 subagent]` tag. Subagent-tagged skills are rejected: use run_skill (or the dedicated tool) for those, since they execute work."
|
|
}
|
|
|
|
func (*readSkillTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"name":{"type":"string","description":"Inline skill identifier as it appears in the pinned Skills index. Just the identifier, not the [🧬 subagent] tag."},
|
|
"arguments":{"type":"string","description":"Optional free-form arguments, appended as an 'Arguments:' line; the skill's own instructions decide how to use them."}
|
|
},
|
|
"required":["name"]
|
|
}`)
|
|
}
|
|
|
|
func (t *readSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
name := cleanSkillName(p.Name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("read_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
|
|
}
|
|
sk, ok := t.store.Read(name)
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
|
|
}
|
|
if err := t.store.ValidateInvocation(sk); err != nil {
|
|
return "", fmt.Errorf("read_skill: %w", err)
|
|
}
|
|
if sk.RunAs == RunSubagent {
|
|
return "", fmt.Errorf("read_skill: skill %q is a subagent and must be executed, not read — use run_skill (or the dedicated %s tool)", name, name)
|
|
}
|
|
return renderInline(sk, strings.TrimSpace(p.Arguments)), nil
|
|
}
|
|
|
|
// --- dedicated subagent wrappers (explore / research / review / security_review) ---
|
|
|
|
type subagentSkillTool struct {
|
|
toolName string
|
|
skillName string
|
|
description string
|
|
taskDesc string
|
|
store *Store
|
|
runner SubagentRunner
|
|
profile ProfileResolver
|
|
}
|
|
|
|
func (t *subagentSkillTool) Name() string { return t.toolName }
|
|
func (*subagentSkillTool) ReadOnly() bool { return false }
|
|
func (t *subagentSkillTool) Description() string { return t.description }
|
|
|
|
func (t *subagentSkillTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{"type":"object","properties":{"task":{"type":"string","description":` +
|
|
strconv.Quote(t.taskDesc) + `},"continue_from":{"type":"string","description":"Continue a prior compatible subagent transcript in the current conversation context. Pass only the 'sa_...' value from the prior result's 'Subagent reference: ...' line."}},"required":["task"]}`)
|
|
}
|
|
|
|
func (t *subagentSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
Task string `json:"task"`
|
|
Continue string `json:"continue_from"`
|
|
Fork string `json:"fork_from"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
task := strings.TrimSpace(p.Task)
|
|
if task == "" {
|
|
return "", fmt.Errorf("%s requires a non-empty 'task' argument — describe the concrete question", t.toolName)
|
|
}
|
|
sk, ok := t.store.Read(t.skillName)
|
|
if !ok {
|
|
return "", fmt.Errorf("%s: built-in skill %q is not registered", t.toolName, t.skillName)
|
|
}
|
|
if err := t.store.ValidateInvocation(sk); err != nil {
|
|
return "", fmt.Errorf("%s: %w", t.toolName, err)
|
|
}
|
|
// A user file overriding the built-in name with runAs:inline would lose
|
|
// isolation if dispatched here — bounce to run_skill where inline is defined.
|
|
if sk.RunAs != RunSubagent {
|
|
return "", fmt.Errorf("%s: skill %q is overridden as inline; invoke it via run_skill instead", t.toolName, t.skillName)
|
|
}
|
|
if t.runner == nil {
|
|
return "", fmt.Errorf("%s: no subagent runner is configured in this session", t.toolName)
|
|
}
|
|
opts := SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)}
|
|
if opts.ContinueFrom != "" && opts.ForkFrom != "" {
|
|
return "", fmt.Errorf("%s: continue_from and fork_from are mutually exclusive; pass only continue_from", t.toolName)
|
|
}
|
|
out, err := t.runner(ctx, sk, task, opts)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return tool.GuardSubagentHostDecisionText(out), nil
|
|
}
|
|
|
|
func (t *subagentSkillTool) ResolveProfile(json.RawMessage) *event.Profile {
|
|
sk, ok := t.store.Read(t.skillName)
|
|
if !ok || sk.RunAs != RunSubagent {
|
|
return nil
|
|
}
|
|
if t.profile != nil {
|
|
if pr := t.profile(sk); pr != nil {
|
|
return pr
|
|
}
|
|
}
|
|
model, effort := strings.TrimSpace(sk.Model), strings.TrimSpace(sk.Effort)
|
|
if model == "" && effort == "" {
|
|
return nil
|
|
}
|
|
return &event.Profile{Model: model, Effort: effort}
|
|
}
|
|
|
|
// BuiltinSubagentTools returns top-level wrapper tools for the built-in subagent
|
|
// skills, named after the verb so the model picks them naturally (affordance >
|
|
// prompt rules). Each is skipped when its underlying skill isn't present (e.g. a
|
|
// user disabled it), so the tool set never advertises a phantom skill.
|
|
func BuiltinSubagentTools(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) []tool.Tool {
|
|
var pr ProfileResolver
|
|
if len(profileResolver) > 0 {
|
|
pr = profileResolver[0]
|
|
}
|
|
specs := []struct {
|
|
toolName, skillName, description, taskDesc string
|
|
}{
|
|
{"explore", "explore",
|
|
"Run a focused read-only codebase investigation in an isolated subagent. Use for broad survey questions across many files — 'find all places that X', 'how does Y work across the project', 'audit Z'. Returns one distilled answer with file:line citations. Its reads + reasoning never enter your context, unlike chained read_file.",
|
|
"Concrete investigation question. The subagent has none of your context — write a self-contained prompt naming the symbol / pattern / behavior to survey."},
|
|
{"research", "research",
|
|
"Combine web_fetch + code reading in an isolated subagent. Use when the answer needs both an external reference and local verification — 'is X supported by lib Y', 'compare our impl against the spec'. Returns one synthesis citing code (file:line) and web (URL).",
|
|
"Concrete research question. The subagent has none of your context — name the external thing to look up and the local code to compare against."},
|
|
{"review", "review",
|
|
"Review the pending changes (current branch diff) in an isolated subagent — flags correctness / security / missing-tests / hidden behavior per file:line. Read-only; you decide what to act on. Use before suggesting a PR-shaped change or after finishing a multi-step edit.",
|
|
"What to focus the review on (e.g. 'focus on the auth changes' or 'general'). The subagent reads the diff itself."},
|
|
{"security_review", "security-review",
|
|
"Security-focused review of the current branch diff in an isolated subagent — injection / authz / secrets / deserialization / path-traversal / crypto, severity-tagged. Read-only. Use when shipping changes that touch auth, input parsing, file IO, or external requests.",
|
|
"Optional scope hint (e.g. 'focus on token handling in internal/auth/') or 'full' for everything in the diff."},
|
|
}
|
|
var out []tool.Tool
|
|
for _, s := range specs {
|
|
sk, ok := store.Read(s.skillName)
|
|
if !ok || store.runtimeProfile != "" && !AllowedInProfile(sk, store.runtimeProfile) {
|
|
continue
|
|
}
|
|
out = append(out, &subagentSkillTool{
|
|
toolName: s.toolName,
|
|
skillName: s.skillName,
|
|
description: s.description,
|
|
taskDesc: s.taskDesc,
|
|
store: store,
|
|
runner: runner,
|
|
profile: pr,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- install_skill ---
|
|
|
|
type installSkillTool struct {
|
|
store *Store
|
|
onInstalled InstalledHook
|
|
}
|
|
|
|
// NewInstallSkillTool builds the skill-authoring tool. onInstalled may be nil.
|
|
func NewInstallSkillTool(store *Store, onInstalled InstalledHook) tool.Tool {
|
|
return &installSkillTool{store: store, onInstalled: onInstalled}
|
|
}
|
|
|
|
func (*installSkillTool) Name() string { return "install_skill" }
|
|
func (*installSkillTool) ReadOnly() bool { return false }
|
|
|
|
func (t *installSkillTool) Description() string {
|
|
scope := "'global' (only option — no project workspace) writes to the Reasonix home skills directory."
|
|
if t.store.HasProjectScope() {
|
|
scope = "'project' (default) writes to <repo>/.reasonix/skills/ (this workspace only); 'global' writes to the Reasonix home skills directory (every project)."
|
|
}
|
|
return "Author and save a new skill — a reusable playbook future turns invoke via run_skill (or /<name>). Runnable immediately this turn; appears in the pinned Skills index on the next launch. " + scope
|
|
}
|
|
|
|
func (*installSkillTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"name":{"type":"string","description":"Identifier — letters/digits/_/-/., 1-64 chars, starts alphanumeric. Becomes the skill folder name under the selected skills directory."},
|
|
"description":{"type":"string","description":"≤120-char one-liner shown in the pinned Skills index — future agents read it to decide whether to invoke."},
|
|
"body":{"type":"string","description":"Markdown playbook. For subagent skills, write the subagent's persona/rules — it gets no context besides 'arguments' at runtime."},
|
|
"scope":{"type":"string","enum":["project","global"],"description":"Where to write. Defaults to project when a workspace exists, else global."},
|
|
"runAs":{"type":"string","enum":["inline","subagent"],"description":"inline (default) folds the body into the parent turn; subagent spawns an isolated child loop returning only its final answer (use for context-heavy work)."},
|
|
"model":{"type":"string","description":"Optional model override for runAs=subagent (a configured provider/model name). Ignored otherwise."},
|
|
"effort":{"type":"string","description":"Optional effort for runAs=subagent (e.g. high, max). Ignored otherwise."},
|
|
"allowedTools":{"type":"array","items":{"type":"string"},"description":"Optional tool allowlist for runAs=subagent (e.g. ['read_file','grep'])."}
|
|
},
|
|
"required":["name","description","body"]
|
|
}`)
|
|
}
|
|
|
|
func (t *installSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Body string `json:"body"`
|
|
Scope string `json:"scope"`
|
|
RunAs string `json:"runAs"`
|
|
Model string `json:"model"`
|
|
Effort string `json:"effort"`
|
|
AllowedTools []string `json:"allowedTools"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
name := strings.TrimSpace(p.Name)
|
|
desc := strings.TrimSpace(collapseSpaces(p.Description))
|
|
if name == "" {
|
|
return "", fmt.Errorf("install_skill requires a non-empty 'name'")
|
|
}
|
|
if desc == "" {
|
|
return "", fmt.Errorf("install_skill requires a non-empty 'description' — it is what appears in the Skills index")
|
|
}
|
|
if strings.TrimSpace(p.Body) == "" {
|
|
return "", fmt.Errorf("install_skill requires a non-empty 'body' — the playbook the skill executes")
|
|
}
|
|
|
|
scope := ScopeGlobal
|
|
switch strings.TrimSpace(p.Scope) {
|
|
case "global":
|
|
scope = ScopeGlobal
|
|
case "project":
|
|
scope = ScopeProject
|
|
default:
|
|
if t.store.HasProjectScope() {
|
|
scope = ScopeProject
|
|
}
|
|
}
|
|
if scope == ScopeProject && !t.store.HasProjectScope() {
|
|
return "", fmt.Errorf("install_skill: scope='project' requires a workspace — use scope='global'")
|
|
}
|
|
|
|
runAs := RunInline
|
|
if strings.TrimSpace(p.RunAs) == "subagent" {
|
|
runAs = RunSubagent
|
|
}
|
|
|
|
content := RenderSkillFile(SkillFileOptions{
|
|
Name: name,
|
|
Description: desc,
|
|
Body: p.Body,
|
|
RunAs: runAs,
|
|
Model: strings.TrimSpace(p.Model),
|
|
Effort: strings.TrimSpace(p.Effort),
|
|
AllowedTools: p.AllowedTools,
|
|
})
|
|
path, err := t.store.CreateWithContent(name, scope, content)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if t.onInstalled != nil {
|
|
t.onInstalled(name, path, scope)
|
|
}
|
|
res, _ := json.Marshal(map[string]any{
|
|
"ok": true,
|
|
"name": name,
|
|
"scope": string(scope),
|
|
"path": path,
|
|
"runAs": string(runAs),
|
|
"note": "Callable now via run_skill({name}) or /" + name + ". Appears in the pinned Skills index on the next launch.",
|
|
})
|
|
return string(res), nil
|
|
}
|
|
|
|
// SkillFileOptions configures a rendered skill markdown file's frontmatter.
|
|
// Shared by the model-facing install_skill tool and host-side authoring
|
|
// surfaces (e.g. a desktop subagent-profile settings page) so both produce
|
|
// identical, correctly-escaped frontmatter instead of hand-built YAML.
|
|
type SkillFileOptions struct {
|
|
Name string
|
|
Description string
|
|
Body string
|
|
RunAs RunAs
|
|
Model string // subagent-only; ignored when RunAs != RunSubagent
|
|
Effort string // subagent-only; ignored when RunAs != RunSubagent
|
|
AllowedTools []string
|
|
Color string // optional display tag; emitted regardless of RunAs
|
|
// Invocation, when "manual", keeps the written skill out of the pinned
|
|
// Skills index (see index.go) — invocable by name only, never
|
|
// model-discovered. Anything else (including empty) is the default "auto".
|
|
Invocation string
|
|
}
|
|
|
|
// skillFileFrontmatter is the YAML shape RenderSkillFile emits. Field order is
|
|
// the emission order (yaml.v3 preserves struct order); values are marshaled by
|
|
// yaml.v3 so free-text fields with colons, '#', quotes, or newlines are
|
|
// escaped correctly instead of corrupting the block — an unparseable
|
|
// frontmatter would make the loader fall back to an EMPTY field map, silently
|
|
// resetting runAs to inline and invocation to auto (see frontmatter.Split).
|
|
type skillFileFrontmatter struct {
|
|
Name string `yaml:"name"`
|
|
Description string `yaml:"description"`
|
|
Color string `yaml:"color,omitempty"`
|
|
Invocation string `yaml:"invocation,omitempty"`
|
|
RunAs string `yaml:"runAs,omitempty"`
|
|
Model string `yaml:"model,omitempty"`
|
|
Effort string `yaml:"effort,omitempty"`
|
|
AllowedTools []string `yaml:"allowed-tools,omitempty,flow"`
|
|
}
|
|
|
|
// RenderSkillFile assembles a skill file's frontmatter + body. Subagent-only
|
|
// fields (model, effort, allowed-tools) are emitted only when RunAs=subagent;
|
|
// color and invocation are independent of RunAs.
|
|
func RenderSkillFile(opts SkillFileOptions) string {
|
|
fm := skillFileFrontmatter{
|
|
Name: opts.Name,
|
|
Description: opts.Description,
|
|
Color: strings.TrimSpace(opts.Color),
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(opts.Invocation), "manual") {
|
|
fm.Invocation = "manual"
|
|
}
|
|
if opts.RunAs == RunSubagent {
|
|
fm.RunAs = string(RunSubagent)
|
|
fm.Model = strings.TrimSpace(opts.Model)
|
|
fm.Effort = strings.TrimSpace(opts.Effort)
|
|
for _, t := range opts.AllowedTools {
|
|
if t = strings.TrimSpace(t); t != "" {
|
|
fm.AllowedTools = append(fm.AllowedTools, t)
|
|
}
|
|
}
|
|
}
|
|
// Marshaling a flat struct of strings cannot fail.
|
|
raw, _ := yaml.Marshal(fm)
|
|
return "---\n" + string(raw) + "---\n\n" + strings.TrimRight(opts.Body, " \t\r\n") + "\n"
|
|
}
|
|
|
|
// --- shared helpers ---
|
|
|
|
// Render builds a skill's invocation text: a header (name, description, source)
|
|
// followed by the body and any arguments. Used directly when a user invokes a
|
|
// skill via "/<name>" (sent as a turn); the run_skill tool wraps the same text
|
|
// in a skill-pin sentinel (see renderInline).
|
|
func Render(sk Skill, args string) string {
|
|
var b strings.Builder
|
|
b.WriteString("# Skill: " + sk.Name)
|
|
if sk.Description != "" {
|
|
b.WriteString("\n> " + sk.Description)
|
|
}
|
|
b.WriteString("\n(scope: " + string(sk.Scope) + " · " + sk.Path + ")\n\n")
|
|
b.WriteString(sk.Body)
|
|
if args != "" {
|
|
b.WriteString("\n\nArguments: " + args)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// renderInline wraps Render's output in a skill-pin sentinel so context
|
|
// compaction preserves the body verbatim instead of paraphrasing it.
|
|
func renderInline(sk Skill, args string) string {
|
|
return "<skill-pin name=" + strconv.Quote(sk.Name) + ">\n" + Render(sk, args) + "\n</skill-pin>"
|
|
}
|
|
|
|
var bracketTagRe = regexp.MustCompile(`\[[^\]]*\]`)
|
|
|
|
// cleanSkillName extracts the bare identifier from a possibly-decorated name:
|
|
// models sometimes copy the index's "explore [🧬 subagent]" verbatim into the
|
|
// `name` arg. Drop any [..] tag, then take the first token starting alphanumeric.
|
|
func cleanSkillName(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
stripped := strings.TrimSpace(bracketTagRe.ReplaceAllString(raw, " "))
|
|
for _, tok := range strings.Fields(stripped) {
|
|
if c := tok[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
|
return tok
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// collapseSpaces turns any run of whitespace (incl. newlines) into a single
|
|
// space, so a multi-line description stays a one-liner in the index.
|
|
func collapseSpaces(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
// availableNames lists the discoverable skill names for an error message.
|
|
func availableNames(store *Store) string {
|
|
skills := store.List()
|
|
if len(skills) == 0 {
|
|
return "(none — no skills defined)"
|
|
}
|
|
names := make([]string, len(skills))
|
|
for i, s := range skills {
|
|
names[i] = s.Name
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|