返回 DeepSeek-Reasonix
skill_hooks.go
根目录 / internal / cli / skill_hooks.go
1 package cli
2
3 import (
4 "fmt"
5 "log/slog"
6 "os"
7 "strings"
8
9 tea "charm.land/bubbletea/v2"
10
11 "reasonix/internal/config"
12 "reasonix/internal/control"
13 "reasonix/internal/skill"
14 )
15
16 func (m *chatTUI) runSkillSubcommand(input string) {
17 args := tokenizeArgs(input)
18 sub := ""
19 if len(args) > 1 {
20 sub = strings.ToLower(args[1])
21 }
22 switch sub {
23 case "":
24 m.openSkillPicker()
25 case "list", "ls":
26 m.skillList()
27 case "manage", "picker":
28 m.openSkillPicker()
29 case "show", "cat":
30 if len(args) < 3 {
31 m.notice("usage: /skills show <name>")
32 return
33 }
34 m.skillShow(args[2])
35 case "enable", "disable":
36 if len(args) < 3 {
37 m.notice("usage: /skills " + sub + " <name>")
38 return
39 }
40 m.skillSetEnabled(args[2], sub == "enable")
41 case "new", "init":
42 if len(args) < 3 {
43 m.notice("usage: /skills new <name> [--global]")
44 return
45 }
46 global := containsArg(args[3:], "--global")
47 m.skillNew(args[2], global)
48 case "paths":
49 m.skillPaths()
50 default:
51 hint := ""
52 if _, ok := m.ctrl.RunSkill("/" + args[1]); ok {
53 hint = " (to run it, type /" + args[1] + ")"
54 }
55 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")
56 }
57 }
58
59 func (m *chatTUI) skillList() {
60 skills := m.skills
61 if m.ctrl != nil {
62 skills = managementSlashSkills(m.ctrl)
63 }
64 if len(skills) == 0 {
65 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.")
66 return
67 }
68 m.commitLine(renderSkillList(m.width, sortedSkills(skills), m.disabledSkillNames()))
69 }
70
71 func (m *chatTUI) skillShow(name string) {
72 skills := m.skills
73 if m.ctrl != nil {
74 skills = managementSlashSkills(m.ctrl)
75 }
76 for _, s := range skills {
77 if s.Name == name || s.SlashName() == strings.TrimPrefix(name, "/") {
78 disabled := false
79 if m.ctrl != nil {
80 disabled = !m.ctrl.SkillEnabled(s.Name)
81 }
82 m.commitLine(renderSkillShow(m.width, s, disabled))
83 return
84 }
85 }
86 m.notice("unknown skill: " + name)
87 }
88
89 func managementSlashSkills(ctrl control.SessionAPI) []skill.Skill {
90 if ctrl == nil {
91 return nil
92 }
93 // AllSkills preserves disabled entries; SlashSkills adds every enabled
94 // package-qualified alias when multiple plugins export the same bare name.
95 all := append([]skill.Skill(nil), ctrl.AllSkills()...)
96 all = append(all, ctrl.SlashSkills()...)
97 return skill.VisibleSlashSkills(all)
98 }
99
100 func (m *chatTUI) disabledSkillNames() map[string]bool {
101 out := map[string]bool{}
102 if m.ctrl == nil {
103 return out
104 }
105 for _, s := range m.ctrl.DisabledSkills() {
106 out[s.Name] = true
107 }
108 return out
109 }
110
111 func (m *chatTUI) skillSetEnabled(name string, enabled bool) {
112 m.skillSaveEnabledChanges(map[string]bool{name: enabled})
113 }
114
115 func (m *chatTUI) skillSaveEnabledChanges(changes map[string]bool) {
116 if len(changes) == 0 {
117 return
118 }
119 if m.buildController == nil {
120 m.notice("skill toggle unavailable in this session")
121 return
122 }
123 if m.ctrl == nil {
124 m.notice("skill toggle unavailable in this session")
125 return
126 }
127 if m.runtimeSwitchBusy() {
128 m.notice("finish or cancel active work and stop background jobs before changing skills")
129 return
130 }
131 if m.modelSwitchPending {
132 m.notice("wait for the current runtime switch to finish")
133 return
134 }
135 known := map[string]string{}
136 for _, sk := range m.ctrl.AllSkills() {
137 known[config.SkillNameKey(sk.Name)] = sk.Name
138 }
139 for _, sk := range m.ctrl.SlashSkills() {
140 known[sk.SlashName()] = sk.Name
141 }
142 // Lock only the load-modify-save cycle; the session refresh below runs
143 // off-lock. The closure returns a non-empty notice on failure.
144 if failNotice := func() string {
145 unlock := config.LockUserConfigEdits()
146 defer unlock()
147 cfg := config.LoadForEdit(config.UserConfigPath())
148 for name, enabled := range changes {
149 key := config.SkillNameKey(name)
150 if key == "" {
151 key = strings.TrimPrefix(strings.TrimSpace(name), "/")
152 }
153 canonical, ok := known[key]
154 if !ok {
155 return "skill " + enableVerb(enabled) + ": unknown skill: " + name
156 }
157 if err := cfg.SetSkillEnabled(canonical, enabled); err != nil {
158 return "skill " + enableVerb(enabled) + ": " + err.Error()
159 }
160 }
161 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
162 return "skill toggle: " + err.Error()
163 }
164 return ""
165 }(); failNotice != "" {
166 m.notice(failNotice)
167 return
168 }
169 notice := ""
170 if len(changes) == 1 {
171 name := ""
172 enabled := false
173 for n, e := range changes {
174 name, enabled = n, e
175 }
176 if enabled {
177 notice = "enabled skill " + name + " — refreshing session"
178 } else {
179 notice = "disabled skill " + name + " — refreshing session"
180 }
181 } else {
182 notice = fmt.Sprintf("updated %d skills — refreshing session", len(changes))
183 }
184 m.scheduleSkillSessionRefresh("skill toggle", notice)
185 }
186
187 func (m *chatTUI) scheduleSkillSessionRefresh(reason, notice string) bool {
188 if m.buildController == nil {
189 m.notice("skill refresh unavailable in this session")
190 return false
191 }
192 if m.ctrl == nil {
193 return false
194 }
195 if m.runtimeSwitchBusy() {
196 m.notice("finish or cancel active work and stop background jobs before refreshing skills")
197 return false
198 }
199 if m.modelSwitchPending {
200 m.notice("wait for the current runtime switch to finish")
201 return false
202 }
203 if err := m.ctrl.Snapshot(); err != nil {
204 slog.Warn(reason+": snapshot failed", "err", err)
205 }
206 // Snapshot can retarget the controller to a recovery branch. Carry the
207 // post-snapshot path so the rebuild does not bind recovered history back to
208 // the stale original transcript.
209 carried := m.ctrl.History()
210 prevPath := m.ctrl.SessionPath()
211 // Move the lease before the rebuilt controller binds prevPath for writing
212 // (AdoptHistory resumes there): after a snapshot retarget the lease still
213 // guards the old path, and the async build must not open an unguarded
214 // writer on the recovery branch.
215 if err := m.rebindSessionLease(prevPath); err != nil {
216 m.notice(reason + ": " + sessionLeaseHeldNotice(err))
217 return false
218 }
219 if notice != "" {
220 m.notice(notice)
221 }
222 oldCtrl := m.ctrl
223 build := m.buildController
224 ref := m.modelRef
225 m.modelSwitchPending = true
226 m.pendingModelSwitch = func() tea.Msg {
227 c, err := build(controllerBuildSpec{
228 ModelRef: ref,
229 RuntimeProfile: m.runtimeProfile,
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 for _, a := range args {
317 if a == flag {
318 return true
319 }
320 }
321 return false
322 }
323
323 lines GO