返回 DeepSeek-Reasonix
skill_hooks.go
根目录 / internal / cli / skill_hooks.go
1 package cli
2
3 import (
4 "fmt"
5 "log/slog"
6 "os"
7 "slices"
8 "strings"
9
10 tea "charm.land/bubbletea/v2"
11
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/skill"
15 )
16
17 func (m *chatTUI) runSkillSubcommand(input string) {
18 args := tokenizeArgs(input)
19 sub := ""
20 if len(args) > 1 {
21 sub = strings.ToLower(args[1])
22 }
23 switch sub {
24 case "":
25 m.openSkillPicker()
26 case "list", "ls":
27 m.skillList()
28 case "manage", "picker":
29 m.openSkillPicker()
30 case "show", "cat":
31 if len(args) < 3 {
32 m.notice("usage: /skills show <name>")
33 return
34 }
35 m.skillShow(args[2])
36 case "enable", "disable":
37 if len(args) < 3 {
38 m.notice("usage: /skills " + sub + " <name>")
39 return
40 }
41 m.skillSetEnabled(args[2], sub == "enable")
42 case "new", "init":
43 if len(args) < 3 {
44 m.notice("usage: /skills new <name> [--global]")
45 return
46 }
47 global := containsArg(args[3:], "--global")
48 m.skillNew(args[2], global)
49 case "paths":
50 m.skillPaths()
51 default:
52 hint := ""
53 if _, ok := m.ctrl.RunSkill("/" + args[1]); ok {
54 hint = " (to run it, type /" + args[1] + ")"
55 }
56 m.notice("unknown /skills subcommand " + args[1] + hint + " — try: /skills, /skills manage, /skills show <name>, /skills enable <name>, /skills disable <name>, /skills new <name>, /skills paths")
57 }
58 }
59
60 func (m *chatTUI) skillList() {
61 skills := m.skills
62 if m.ctrl != nil {
63 skills = managementSlashSkills(m.ctrl)
64 }
65 if len(skills) == 0 {
66 m.notice("no skills found. Add SKILL.md / <name>.md under .reasonix/skills (project) or ~/.reasonix/skills (global); .agents/.agent/.claude skills dirs also work. Invoke with /<name> or run_skill.")
67 return
68 }
69 m.commitLine(renderSkillList(m.width, sortedSkills(skills), m.disabledSkillNames()))
70 }
71
72 func (m *chatTUI) skillShow(name string) {
73 skills := m.skills
74 if m.ctrl != nil {
75 skills = managementSlashSkills(m.ctrl)
76 }
77 for _, s := range skills {
78 if s.Name == name || s.SlashName() == strings.TrimPrefix(name, "/") {
79 disabled := false
80 if m.ctrl != nil {
81 disabled = !m.ctrl.SkillEnabled(s.Name)
82 }
83 m.commitLine(renderSkillShow(m.width, s, disabled))
84 return
85 }
86 }
87 m.notice("unknown skill: " + name)
88 }
89
90 func managementSlashSkills(ctrl control.SessionAPI) []skill.Skill {
91 if ctrl == nil {
92 return nil
93 }
94 // AllSkills preserves disabled entries; SlashSkills adds every enabled
95 // package-qualified alias when multiple plugins export the same bare name.
96 all := append([]skill.Skill(nil), ctrl.AllSkills()...)
97 all = append(all, ctrl.SlashSkills()...)
98 return skill.VisibleSlashSkills(all)
99 }
100
101 func (m *chatTUI) disabledSkillNames() map[string]bool {
102 out := map[string]bool{}
103 if m.ctrl == nil {
104 return out
105 }
106 for _, s := range m.ctrl.DisabledSkills() {
107 out[s.Name] = true
108 }
109 return out
110 }
111
112 func (m *chatTUI) skillSetEnabled(name string, enabled bool) {
113 m.skillSaveEnabledChanges(map[string]bool{name: enabled})
114 }
115
116 func (m *chatTUI) skillSaveEnabledChanges(changes map[string]bool) {
117 if len(changes) == 0 {
118 return
119 }
120 if m.buildController == nil {
121 m.notice("skill toggle unavailable in this session")
122 return
123 }
124 if m.ctrl == nil {
125 m.notice("skill toggle unavailable in this session")
126 return
127 }
128 if m.runtimeSwitchBusy() {
129 m.notice("finish or cancel active work and stop background jobs before changing skills")
130 return
131 }
132 if m.modelSwitchPending {
133 m.notice("wait for the current runtime switch to finish")
134 return
135 }
136 known := map[string]string{}
137 for _, sk := range m.ctrl.AllSkills() {
138 known[config.SkillNameKey(sk.Name)] = sk.Name
139 }
140 for _, sk := range m.ctrl.SlashSkills() {
141 known[sk.SlashName()] = sk.Name
142 }
143 // Lock only the load-modify-save cycle; the session refresh below runs
144 // off-lock. The closure returns a non-empty notice on failure.
145 if failNotice := func() string {
146 unlock := config.LockUserConfigEdits()
147 defer unlock()
148 cfg := config.LoadForEdit(config.UserConfigPath())
149 for name, enabled := range changes {
150 key := config.SkillNameKey(name)
151 if key == "" {
152 key = strings.TrimPrefix(strings.TrimSpace(name), "/")
153 }
154 canonical, ok := known[key]
155 if !ok {
156 return "skill " + enableVerb(enabled) + ": unknown skill: " + name
157 }
158 if err := cfg.SetSkillEnabled(canonical, enabled); err != nil {
159 return "skill " + enableVerb(enabled) + ": " + err.Error()
160 }
161 }
162 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
163 return "skill toggle: " + err.Error()
164 }
165 return ""
166 }(); failNotice != "" {
167 m.notice(failNotice)
168 return
169 }
170 notice := ""
171 if len(changes) == 1 {
172 name := ""
173 enabled := false
174 for n, e := range changes {
175 name, enabled = n, e
176 }
177 if enabled {
178 notice = "enabled skill " + name + " — refreshing session"
179 } else {
180 notice = "disabled skill " + name + " — refreshing session"
181 }
182 } else {
183 notice = fmt.Sprintf("updated %d skills — refreshing session", len(changes))
184 }
185 m.scheduleSkillSessionRefresh("skill toggle", notice)
186 }
187
188 func (m *chatTUI) scheduleSkillSessionRefresh(reason, notice string) bool {
189 if m.buildController == nil {
190 m.notice("skill refresh unavailable in this session")
191 return false
192 }
193 if m.ctrl == nil {
194 return false
195 }
196 if m.runtimeSwitchBusy() {
197 m.notice("finish or cancel active work and stop background jobs before refreshing skills")
198 return false
199 }
200 if m.modelSwitchPending {
201 m.notice("wait for the current runtime switch to finish")
202 return false
203 }
204 if err := m.ctrl.Snapshot(); err != nil {
205 slog.Warn(reason+": snapshot failed", "err", err)
206 }
207 // Snapshot can retarget the controller to a recovery branch. Carry the
208 // post-snapshot path so the rebuild does not bind recovered history back to
209 // the stale original transcript.
210 carried := m.ctrl.History()
211 prevPath := m.ctrl.SessionPath()
212 // Move the lease before the rebuilt controller binds prevPath for writing
213 // (AdoptHistory resumes there): after a snapshot retarget the lease still
214 // guards the old path, and the async build must not open an unguarded
215 // writer on the recovery branch.
216 if err := m.rebindSessionLease(prevPath); err != nil {
217 m.notice(reason + ": " + sessionLeaseHeldNotice(err))
218 return false
219 }
220 if notice != "" {
221 m.notice(notice)
222 }
223 oldCtrl := m.ctrl
224 build := m.buildController
225 ref := m.modelRef
226 m.modelSwitchPending = true
227 m.pendingModelSwitch = func() tea.Msg {
228 c, err := build(controllerBuildSpec{
229 ModelRef: ref,
230 ToolApprovalMode: oldCtrl.ToolApprovalMode(),
231 PlanMode: oldCtrl.PlanMode(),
232 }, carried, prevPath, oldCtrl)
233 if err != nil {
234 return modelSwitchMsg{ref: ref, err: err}
235 }
236 return modelSwitchMsg{
237 ref: ref,
238 ctrl: c,
239 oldCtrl: oldCtrl,
240 label: c.Label(),
241 commands: c.Commands(),
242 skills: c.SlashSkills(),
243 host: c.Host(),
244 }
245 }
246 return true
247 }
248
249 func enableVerb(enabled bool) string {
250 if enabled {
251 return "enable"
252 }
253 return "disable"
254 }
255
256 func (m *chatTUI) skillNew(name string, global bool) {
257 st := m.skillStore()
258 scope := skill.ScopeProject
259 if global || !st.HasProjectScope() {
260 scope = skill.ScopeGlobal
261 }
262 path, err := st.Create(name, scope)
263 if err != nil {
264 m.notice("skill new: " + err.Error())
265 return
266 }
267 m.notice(fmt.Sprintf("created skill %q at %s — edit it, then /new (or restart) to pick it up", name, path))
268 }
269
270 func (m *chatTUI) skillPaths() {
271 st := m.skillStore()
272 m.commitLine(renderSkillPaths(m.width, st.Roots()))
273 }
274
275 func (m *chatTUI) skillStore() *skill.Store {
276 cwd, _ := os.Getwd()
277 var custom []string
278 var excluded []string
279 var pluginPaths map[string][]string
280 var pluginAgentPaths map[string][]string
281 maxDepth := 3
282 if cfg, err := config.Load(); err == nil {
283 custom = cfg.SkillCustomPaths()
284 excluded = cfg.SkillExcludedPaths()
285 pluginPaths = cfg.PluginPackageSkillOwners()
286 pluginAgentPaths = cfg.PluginPackageAgentOwners()
287 maxDepth = cfg.SkillMaxDepth()
288 }
289 return skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, PluginAgentPaths: pluginAgentPaths, ExcludedPaths: excluded, MaxDepth: maxDepth})
290 }
291
292 func (m *chatTUI) runHooksSubcommand(input string) {
293 args := tokenizeArgs(input)
294 sub := ""
295 if len(args) > 1 {
296 sub = strings.ToLower(args[1])
297 }
298 cwd, _ := os.Getwd()
299 switch sub {
300 case "", "list", "ls":
301 m.hooksList(cwd)
302 case "trust":
303 // Backward-compatible response for old clients and saved commands.
304 m.notice("project hooks are enabled automatically; no trust action is required")
305 default:
306 m.notice("unknown /hooks subcommand " + args[1] + " — try: /hooks or /hooks list")
307 }
308 }
309
310 func (m *chatTUI) hooksList(cwd string) {
311 active := m.ctrl.HookRunner().Hooks()
312 m.commitLine(renderHooks(m.width, active))
313 }
314
315 func containsArg(args []string, flag string) bool {
316 return slices.Contains(args, flag)
317 }
318
318 lines GO