返回 DeepSeek-Reasonix
tools.go
根目录 / internal / skill / tools.go
1 package skill
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "regexp"
8 "strconv"
9 "strings"
10
11 "gopkg.in/yaml.v3"
12
13 "reasonix/internal/event"
14 "reasonix/internal/tool"
15 )
16
17 // SubagentRunner runs a runAs=subagent skill: it spawns an isolated child loop
18 // with the skill body as system prompt and `task` as its only input, returning
19 // the final answer. boot wires this over the agent's sub-agent machinery; nil
20 // means subagent skills are unavailable in this session (they error rather than
21 // silently inlining, which would lose the isolation the author asked for).
22 type SubagentRunOptions struct {
23 ContinueFrom string
24 ForkFrom string
25 // HostInitiated marks an explicit controller entry point such as
26 // /<subagent-skill>. It may still carry a synthetic call context for nested
27 // UI events, but that ephemeral event ID must not be persisted as though it
28 // were a provider-visible parent tool call.
29 HostInitiated bool
30 }
31
32 type SubagentRunner func(ctx context.Context, sk Skill, task string, opts SubagentRunOptions) (string, error)
33
34 // ProfileResolver returns the model/effort profile a subagent skill will use.
35 // It is optional; without one, skill frontmatter still supplies display metadata.
36 type ProfileResolver func(sk Skill) *event.Profile
37
38 // InstalledHook fires after install_skill writes a new file, so a host can
39 // refresh UI (e.g. a skills sidebar) without a reload. nil is fine.
40 type InstalledHook func(name, path string, scope Scope)
41
42 // --- run_skill ---
43
44 type runSkillTool struct {
45 store *Store
46 runner SubagentRunner
47 profileResolver ProfileResolver
48 }
49
50 // NewRunSkillTool builds the general skill-invocation tool. runner may be nil
51 // (subagent skills then error).
52 func NewRunSkillTool(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) tool.Tool {
53 var pr ProfileResolver
54 if len(profileResolver) > 0 {
55 pr = profileResolver[0]
56 }
57 return &runSkillTool{store: store, runner: runner, profileResolver: pr}
58 }
59
60 func (*runSkillTool) Name() string { return "run_skill" }
61
62 // ReadOnly is false: an invoked subagent skill could call writer tools, so
63 // classify conservatively to keep the parallel-dispatch path from racing two
64 // skill runs' writes (mirrors the `task` tool).
65 func (*runSkillTool) ReadOnly() bool { return false }
66
67 func (*runSkillTool) Description() string {
68 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."
69 }
70
71 func (*runSkillTool) Schema() json.RawMessage {
72 return json.RawMessage(`{
73 "type":"object",
74 "properties":{
75 "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."},
76 "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."},
77 "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."}
78 },
79 "required":["name"]
80 }`)
81 }
82
83 func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
84 var p struct {
85 Name string `json:"name"`
86 Arguments string `json:"arguments"`
87 Continue string `json:"continue_from"`
88 Fork string `json:"fork_from"`
89 }
90 if err := json.Unmarshal(args, &p); err != nil {
91 return "", fmt.Errorf("invalid args: %w", err)
92 }
93 name := cleanSkillName(p.Name)
94 if name == "" {
95 return "", fmt.Errorf("run_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
96 }
97 sk, ok := t.store.Read(name)
98 if !ok {
99 return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
100 }
101 if err := t.store.ValidateInvocation(sk); err != nil {
102 return "", fmt.Errorf("run_skill: %w", err)
103 }
104 sk = t.store.Prepare(sk)
105 rawArgs := strings.TrimSpace(p.Arguments)
106 opts := SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)}
107 if opts.ContinueFrom != "" && opts.ForkFrom != "" {
108 return "", fmt.Errorf("run_skill: continue_from and fork_from are mutually exclusive; pass only continue_from")
109 }
110
111 if sk.RunAs == RunSubagent {
112 if t.runner == nil {
113 return "", fmt.Errorf("run_skill: skill %q is runAs=subagent but no subagent runner is configured in this session", name)
114 }
115 if rawArgs == "" {
116 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)
117 }
118 out, err := t.runner(ctx, sk, rawArgs, opts)
119 if err != nil {
120 return "", err
121 }
122 return tool.GuardSubagentHostDecisionText(out), nil
123 }
124 if opts.ContinueFrom != "" || opts.ForkFrom != "" {
125 return "", fmt.Errorf("run_skill: subagent continuation is only valid for runAs=subagent skills")
126 }
127 return renderInline(sk, rawArgs), nil
128 }
129
130 func (t *runSkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
131 var p struct {
132 Name string `json:"name"`
133 }
134 if err := json.Unmarshal(args, &p); err != nil {
135 return nil
136 }
137 name := cleanSkillName(p.Name)
138 if name == "" {
139 return nil
140 }
141 sk, ok := t.store.Read(name)
142 if !ok || sk.RunAs != RunSubagent {
143 return nil
144 }
145 return t.profileForSkill(sk)
146 }
147
148 func (t *runSkillTool) profileForSkill(sk Skill) *event.Profile {
149 return profileForSkill(sk, t.profileResolver)
150 }
151
152 // --- read_only_skill ---
153
154 type readOnlySkillTool struct {
155 store *Store
156 runner SubagentRunner
157 profileResolver ProfileResolver
158 }
159
160 // NewReadOnlySkillTool builds an explicitly read-only skill entry point. Inline
161 // skills are rendered like read_skill; subagent skills run through a host-provided
162 // read-only subagent runner with no continuation/fork controls.
163 func NewReadOnlySkillTool(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) tool.Tool {
164 var pr ProfileResolver
165 if len(profileResolver) > 0 {
166 pr = profileResolver[0]
167 }
168 return &readOnlySkillTool{store: store, runner: runner, profileResolver: pr}
169 }
170
171 func (*readOnlySkillTool) Name() string { return "read_only_skill" }
172
173 func (*readOnlySkillTool) ReadOnly() bool { return true }
174
175 // PlanModeSafe reports true because this explicit read-only capability is also
176 // semantically valid during the planning phase.
177 func (*readOnlySkillTool) PlanModeSafe() bool { return true }
178
179 func (*readOnlySkillTool) Description() string {
180 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."
181 }
182
183 func (*readOnlySkillTool) Schema() json.RawMessage {
184 return json.RawMessage(`{
185 "type":"object",
186 "properties":{
187 "name":{"type":"string","description":"Skill identifier as it appears in the pinned Skills index. Just the identifier, not the [🧬 subagent] tag."},
188 "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."}
189 },
190 "required":["name"]
191 }`)
192 }
193
194 func (t *readOnlySkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
195 var p struct {
196 Name string `json:"name"`
197 Arguments string `json:"arguments"`
198 }
199 if err := json.Unmarshal(args, &p); err != nil {
200 return "", fmt.Errorf("invalid args: %w", err)
201 }
202 name := cleanSkillName(p.Name)
203 if name == "" {
204 return "", fmt.Errorf("read_only_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
205 }
206 sk, ok := t.store.Read(name)
207 if !ok {
208 return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
209 }
210 if err := t.store.ValidateInvocation(sk); err != nil {
211 return "", fmt.Errorf("read_only_skill: %w", err)
212 }
213 sk = t.store.Prepare(sk)
214 rawArgs := strings.TrimSpace(p.Arguments)
215 if sk.RunAs == RunSubagent {
216 if t.runner == nil {
217 return "", fmt.Errorf("read_only_skill: skill %q is runAs=subagent but no read-only subagent runner is configured in this session", name)
218 }
219 if rawArgs == "" {
220 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)
221 }
222 out, err := t.runner(ctx, sk, rawArgs, SubagentRunOptions{})
223 if err != nil {
224 return "", err
225 }
226 return tool.GuardSubagentHostDecisionText(out), nil
227 }
228 return renderInline(sk, rawArgs), nil
229 }
230
231 func (t *readOnlySkillTool) ResolveProfile(args json.RawMessage) *event.Profile {
232 var p struct {
233 Name string `json:"name"`
234 }
235 if err := json.Unmarshal(args, &p); err != nil {
236 return nil
237 }
238 name := cleanSkillName(p.Name)
239 if name == "" {
240 return nil
241 }
242 sk, ok := t.store.Read(name)
243 if !ok || sk.RunAs != RunSubagent {
244 return nil
245 }
246 return profileForSkill(sk, t.profileResolver)
247 }
248
249 func profileForSkill(sk Skill, resolver ProfileResolver) *event.Profile {
250 if resolver != nil {
251 if pr := resolver(sk); pr != nil {
252 return pr
253 }
254 }
255 model, effort := strings.TrimSpace(sk.Model), strings.TrimSpace(sk.Effort)
256 if model == "" && effort == "" {
257 return nil
258 }
259 return &event.Profile{Model: model, Effort: effort}
260 }
261
262 // readSkillTool loads an inline skill body into context without running anything.
263 type readSkillTool struct {
264 store *Store
265 }
266
267 // NewReadSkillTool builds a read-only inline-skill loader so a plan can consult
268 // playbooks without starting a subagent.
269 func NewReadSkillTool(store *Store) tool.Tool { return &readSkillTool{store: store} }
270
271 func (*readSkillTool) Name() string { return "read_skill" }
272
273 // ReadOnly is true: read_skill only renders an inline skill body, with no
274 // subagent or side effects.
275 func (*readSkillTool) ReadOnly() bool { return true }
276
277 func (*readSkillTool) Description() string {
278 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. This is the read-only alternative when no subagent execution is needed. 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."
279 }
280
281 func (*readSkillTool) Schema() json.RawMessage {
282 return json.RawMessage(`{
283 "type":"object",
284 "properties":{
285 "name":{"type":"string","description":"Inline skill identifier as it appears in the pinned Skills index. Just the identifier, not the [🧬 subagent] tag."},
286 "arguments":{"type":"string","description":"Optional free-form arguments, appended as an 'Arguments:' line; the skill's own instructions decide how to use them."}
287 },
288 "required":["name"]
289 }`)
290 }
291
292 func (t *readSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
293 var p struct {
294 Name string `json:"name"`
295 Arguments string `json:"arguments"`
296 }
297 if err := json.Unmarshal(args, &p); err != nil {
298 return "", fmt.Errorf("invalid args: %w", err)
299 }
300 name := cleanSkillName(p.Name)
301 if name == "" {
302 return "", fmt.Errorf("read_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name)
303 }
304 sk, ok := t.store.Read(name)
305 if !ok {
306 return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store))
307 }
308 if err := t.store.ValidateInvocation(sk); err != nil {
309 return "", fmt.Errorf("read_skill: %w", err)
310 }
311 sk = t.store.Prepare(sk)
312 if sk.RunAs == RunSubagent {
313 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)
314 }
315 return renderInline(sk, strings.TrimSpace(p.Arguments)), nil
316 }
317
318 // --- dedicated subagent wrappers (explore / research / review / security_review) ---
319
320 type subagentSkillTool struct {
321 toolName string
322 skillName string
323 description string
324 taskDesc string
325 store *Store
326 runner SubagentRunner
327 profile ProfileResolver
328 }
329
330 func (t *subagentSkillTool) Name() string { return t.toolName }
331 func (*subagentSkillTool) ReadOnly() bool { return false }
332 func (t *subagentSkillTool) Description() string { return t.description }
333
334 func (t *subagentSkillTool) Schema() json.RawMessage {
335 return json.RawMessage(`{"type":"object","properties":{"task":{"type":"string","description":` +
336 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"]}`)
337 }
338
339 func (t *subagentSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
340 var p struct {
341 Task string `json:"task"`
342 Continue string `json:"continue_from"`
343 Fork string `json:"fork_from"`
344 }
345 if err := json.Unmarshal(args, &p); err != nil {
346 return "", fmt.Errorf("invalid args: %w", err)
347 }
348 task := strings.TrimSpace(p.Task)
349 if task == "" {
350 return "", fmt.Errorf("%s requires a non-empty 'task' argument — describe the concrete question", t.toolName)
351 }
352 sk, ok := t.store.Read(t.skillName)
353 if !ok {
354 return "", fmt.Errorf("%s: built-in skill %q is not registered", t.toolName, t.skillName)
355 }
356 if err := t.store.ValidateInvocation(sk); err != nil {
357 return "", fmt.Errorf("%s: %w", t.toolName, err)
358 }
359 sk = t.store.Prepare(sk)
360 // A user file overriding the built-in name with runAs:inline would lose
361 // isolation if dispatched here — bounce to run_skill where inline is defined.
362 if sk.RunAs != RunSubagent {
363 return "", fmt.Errorf("%s: skill %q is overridden as inline; invoke it via run_skill instead", t.toolName, t.skillName)
364 }
365 if t.runner == nil {
366 return "", fmt.Errorf("%s: no subagent runner is configured in this session", t.toolName)
367 }
368 opts := SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)}
369 if opts.ContinueFrom != "" && opts.ForkFrom != "" {
370 return "", fmt.Errorf("%s: continue_from and fork_from are mutually exclusive; pass only continue_from", t.toolName)
371 }
372 out, err := t.runner(ctx, sk, task, opts)
373 if err != nil {
374 return "", err
375 }
376 return tool.GuardSubagentHostDecisionText(out), nil
377 }
378
379 func (t *subagentSkillTool) ResolveProfile(json.RawMessage) *event.Profile {
380 sk, ok := t.store.Read(t.skillName)
381 if !ok || sk.RunAs != RunSubagent {
382 return nil
383 }
384 if t.profile != nil {
385 if pr := t.profile(sk); pr != nil {
386 return pr
387 }
388 }
389 model, effort := strings.TrimSpace(sk.Model), strings.TrimSpace(sk.Effort)
390 if model == "" && effort == "" {
391 return nil
392 }
393 return &event.Profile{Model: model, Effort: effort}
394 }
395
396 // BuiltinSubagentTools returns top-level wrapper tools for the built-in subagent
397 // skills, named after the verb so the model picks them naturally (affordance >
398 // prompt rules). Each is skipped when its underlying skill isn't present (e.g. a
399 // user disabled it), so the tool set never advertises a phantom skill.
400 func BuiltinSubagentTools(store *Store, runner SubagentRunner, profileResolver ...ProfileResolver) []tool.Tool {
401 var pr ProfileResolver
402 if len(profileResolver) > 0 {
403 pr = profileResolver[0]
404 }
405 specs := []struct {
406 toolName, skillName, description, taskDesc string
407 }{
408 {"explore", "explore",
409 "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.",
410 "Concrete investigation question. The subagent has none of your context — write a self-contained prompt naming the symbol / pattern / behavior to survey."},
411 {"research", "research",
412 "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).",
413 "Concrete research question. The subagent has none of your context — name the external thing to look up and the local code to compare against."},
414 {"review", "review",
415 "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.",
416 "What to focus the review on (e.g. 'focus on the auth changes' or 'general'). The subagent reads the diff itself."},
417 {"security_review", "security-review",
418 "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.",
419 "Optional scope hint (e.g. 'focus on token handling in internal/auth/') or 'full' for everything in the diff."},
420 }
421 var out []tool.Tool
422 for _, s := range specs {
423 sk, ok := store.Read(s.skillName)
424 if !ok || store.runtimeProfile != "" && !AllowedInProfile(sk, store.runtimeProfile) {
425 continue
426 }
427 out = append(out, &subagentSkillTool{
428 toolName: s.toolName,
429 skillName: s.skillName,
430 description: s.description,
431 taskDesc: s.taskDesc,
432 store: store,
433 runner: runner,
434 profile: pr,
435 })
436 }
437 return out
438 }
439
440 // --- install_skill ---
441
442 type installSkillTool struct {
443 store *Store
444 onInstalled InstalledHook
445 }
446
447 // NewInstallSkillTool builds the skill-authoring tool. onInstalled may be nil.
448 func NewInstallSkillTool(store *Store, onInstalled InstalledHook) tool.Tool {
449 return &installSkillTool{store: store, onInstalled: onInstalled}
450 }
451
452 func (*installSkillTool) Name() string { return "install_skill" }
453 func (*installSkillTool) ReadOnly() bool { return false }
454
455 func (t *installSkillTool) Description() string {
456 scope := "'global' (only option — no project workspace) writes to the Reasonix home skills directory."
457 if t.store.HasProjectScope() {
458 scope = "'project' (default) writes to <repo>/.reasonix/skills/ (this workspace only); 'global' writes to the Reasonix home skills directory (every project)."
459 }
460 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
461 }
462
463 func (*installSkillTool) Schema() json.RawMessage {
464 return json.RawMessage(`{
465 "type":"object",
466 "properties":{
467 "name":{"type":"string","description":"Identifier — letters/digits/_/-/., 1-64 chars, starts alphanumeric. Becomes the skill folder name under the selected skills directory."},
468 "description":{"type":"string","description":"≤120-char one-liner shown in the pinned Skills index — future agents read it to decide whether to invoke."},
469 "body":{"type":"string","description":"Markdown playbook. For subagent skills, write the subagent's persona/rules — it gets no context besides 'arguments' at runtime."},
470 "scope":{"type":"string","enum":["project","global"],"description":"Where to write. Defaults to project when a workspace exists, else global."},
471 "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)."},
472 "model":{"type":"string","description":"Optional model override for runAs=subagent (a configured provider/model name). Ignored otherwise."},
473 "effort":{"type":"string","description":"Optional effort for runAs=subagent (e.g. high, max). Ignored otherwise."},
474 "allowedTools":{"type":"array","items":{"type":"string"},"description":"Optional tool allowlist for runAs=subagent (e.g. ['read_file','grep'])."}
475 },
476 "required":["name","description","body"]
477 }`)
478 }
479
480 func (t *installSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
481 var p struct {
482 Name string `json:"name"`
483 Description string `json:"description"`
484 Body string `json:"body"`
485 Scope string `json:"scope"`
486 RunAs string `json:"runAs"`
487 Model string `json:"model"`
488 Effort string `json:"effort"`
489 AllowedTools []string `json:"allowedTools"`
490 }
491 if err := json.Unmarshal(args, &p); err != nil {
492 return "", fmt.Errorf("invalid args: %w", err)
493 }
494 name := strings.TrimSpace(p.Name)
495 desc := strings.TrimSpace(collapseSpaces(p.Description))
496 if name == "" {
497 return "", fmt.Errorf("install_skill requires a non-empty 'name'")
498 }
499 if desc == "" {
500 return "", fmt.Errorf("install_skill requires a non-empty 'description' — it is what appears in the Skills index")
501 }
502 if strings.TrimSpace(p.Body) == "" {
503 return "", fmt.Errorf("install_skill requires a non-empty 'body' — the playbook the skill executes")
504 }
505
506 scope := ScopeGlobal
507 switch strings.TrimSpace(p.Scope) {
508 case "global":
509 scope = ScopeGlobal
510 case "project":
511 scope = ScopeProject
512 default:
513 if t.store.HasProjectScope() {
514 scope = ScopeProject
515 }
516 }
517 if scope == ScopeProject && !t.store.HasProjectScope() {
518 return "", fmt.Errorf("install_skill: scope='project' requires a workspace — use scope='global'")
519 }
520
521 runAs := RunInline
522 if strings.TrimSpace(p.RunAs) == "subagent" {
523 runAs = RunSubagent
524 }
525
526 content := RenderSkillFile(SkillFileOptions{
527 Name: name,
528 Description: desc,
529 Body: p.Body,
530 RunAs: runAs,
531 Model: strings.TrimSpace(p.Model),
532 Effort: strings.TrimSpace(p.Effort),
533 AllowedTools: p.AllowedTools,
534 })
535 path, err := t.store.CreateWithContent(name, scope, content)
536 if err != nil {
537 return "", err
538 }
539 if t.onInstalled != nil {
540 t.onInstalled(name, path, scope)
541 }
542 res, _ := json.Marshal(map[string]any{
543 "ok": true,
544 "name": name,
545 "scope": string(scope),
546 "path": path,
547 "runAs": string(runAs),
548 "note": "Callable now via run_skill({name}) or /" + name + ". Appears in the pinned Skills index on the next launch.",
549 })
550 return string(res), nil
551 }
552
553 // SkillFileOptions configures a rendered skill markdown file's frontmatter.
554 // Shared by the model-facing install_skill tool and host-side authoring
555 // surfaces (e.g. a desktop subagent-profile settings page) so both produce
556 // identical, correctly-escaped frontmatter instead of hand-built YAML.
557 type SkillFileOptions struct {
558 Name string
559 Description string
560 Body string
561 RunAs RunAs
562 Model string // subagent-only; ignored when RunAs != RunSubagent
563 Effort string // subagent-only; ignored when RunAs != RunSubagent
564 AllowedTools []string
565 // ReadOnly, when true, emits frontmatter read-only: true so the profile
566 // runs against the read-only registry. Omitted/false keeps the legacy
567 // writable default for older profiles.
568 ReadOnly bool
569 Color string // optional display tag; emitted regardless of RunAs
570 // Invocation, when "manual", keeps the written skill out of the pinned
571 // Skills index (see index.go) — invocable by name only, never
572 // model-discovered. Anything else (including empty) is the default "auto".
573 Invocation string
574 }
575
576 // skillFileFrontmatter is the YAML shape RenderSkillFile emits. Field order is
577 // the emission order (yaml.v3 preserves struct order); values are marshaled by
578 // yaml.v3 so free-text fields with colons, '#', quotes, or newlines are
579 // escaped correctly instead of corrupting the block — an unparseable
580 // frontmatter would make the loader fall back to an EMPTY field map, silently
581 // resetting runAs to inline and invocation to auto (see frontmatter.Split).
582 type skillFileFrontmatter struct {
583 Name string `yaml:"name"`
584 Description string `yaml:"description"`
585 Color string `yaml:"color,omitempty"`
586 Invocation string `yaml:"invocation,omitempty"`
587 RunAs string `yaml:"runAs,omitempty"`
588 Model string `yaml:"model,omitempty"`
589 Effort string `yaml:"effort,omitempty"`
590 ReadOnly *bool `yaml:"read-only,omitempty"`
591 AllowedTools []string `yaml:"allowed-tools,omitempty,flow"`
592 }
593
594 // RenderSkillFile assembles a skill file's frontmatter + body. Subagent-only
595 // fields (model, effort, allowed-tools, read-only) are emitted only when
596 // RunAs=subagent; color and invocation are independent of RunAs.
597 func RenderSkillFile(opts SkillFileOptions) string {
598 fm := skillFileFrontmatter{
599 Name: opts.Name,
600 Description: opts.Description,
601 Color: strings.TrimSpace(opts.Color),
602 }
603 if strings.EqualFold(strings.TrimSpace(opts.Invocation), "manual") {
604 fm.Invocation = "manual"
605 }
606 if opts.RunAs == RunSubagent {
607 fm.RunAs = string(RunSubagent)
608 fm.Model = strings.TrimSpace(opts.Model)
609 fm.Effort = strings.TrimSpace(opts.Effort)
610 if opts.ReadOnly {
611 v := true
612 fm.ReadOnly = &v
613 }
614 for _, t := range opts.AllowedTools {
615 if t = strings.TrimSpace(t); t != "" {
616 fm.AllowedTools = append(fm.AllowedTools, t)
617 }
618 }
619 }
620 // Marshaling a flat struct of strings cannot fail.
621 raw, _ := yaml.Marshal(fm)
622 return "---\n" + string(raw) + "---\n\n" + strings.TrimRight(opts.Body, " \t\r\n") + "\n"
623 }
624
625 // --- shared helpers ---
626
627 // Render builds a skill's invocation text: a header (name, description, source)
628 // followed by the body and any arguments. Used directly when a user invokes a
629 // skill via "/<name>" (sent as a turn); the run_skill tool wraps the same text
630 // in a skill-pin sentinel (see renderInline).
631 func Render(sk Skill, args string) string {
632 var b strings.Builder
633 b.WriteString("# Skill: " + sk.Name)
634 if sk.Description != "" {
635 b.WriteString("\n> " + sk.Description)
636 }
637 b.WriteString("\n(scope: " + string(sk.Scope) + " · " + sk.Path + ")\n\n")
638 b.WriteString(sk.Body)
639 if args != "" {
640 b.WriteString("\n\nArguments: " + args)
641 }
642 return b.String()
643 }
644
645 // renderInline wraps Render's output in a skill-pin sentinel so context
646 // compaction preserves the body verbatim instead of paraphrasing it.
647 func renderInline(sk Skill, args string) string {
648 return "<skill-pin name=" + strconv.Quote(sk.Name) + ">\n" + Render(sk, args) + "\n</skill-pin>"
649 }
650
651 var bracketTagRe = regexp.MustCompile(`\[[^\]]*\]`)
652
653 // cleanSkillName extracts the bare identifier from a possibly-decorated name:
654 // models sometimes copy the index's "explore [🧬 subagent]" verbatim into the
655 // `name` arg. Drop any [..] tag, then take the first token starting alphanumeric.
656 func cleanSkillName(raw string) string {
657 raw = strings.TrimSpace(raw)
658 if raw == "" {
659 return ""
660 }
661 stripped := strings.TrimSpace(bracketTagRe.ReplaceAllString(raw, " "))
662 for _, tok := range strings.Fields(stripped) {
663 if c := tok[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
664 return tok
665 }
666 }
667 return ""
668 }
669
670 // collapseSpaces turns any run of whitespace (incl. newlines) into a single
671 // space, so a multi-line description stays a one-liner in the index.
672 func collapseSpaces(s string) string {
673 return strings.Join(strings.Fields(s), " ")
674 }
675
676 // availableNames lists the discoverable skill names for an error message.
677 func availableNames(store *Store) string {
678 skills := store.List()
679 if len(skills) == 0 {
680 return "(none — no skills defined)"
681 }
682 names := make([]string, len(skills))
683 for i, s := range skills {
684 names[i] = s.Name
685 }
686 return strings.Join(names, ", ")
687 }
688
688 lines GO