| 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 |