package cli import ( "fmt" "log/slog" "os" "strings" tea "charm.land/bubbletea/v2" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/hook" "reasonix/internal/skill" ) func (m *chatTUI) runSkillSubcommand(input string) { args := tokenizeArgs(input) sub := "" if len(args) > 1 { sub = strings.ToLower(args[1]) } switch sub { case "": m.openSkillPicker() case "list", "ls": m.skillList() case "manage", "picker": m.openSkillPicker() case "show", "cat": if len(args) < 3 { m.notice("usage: /skills show ") return } m.skillShow(args[2]) case "enable", "disable": if len(args) < 3 { m.notice("usage: /skills " + sub + " ") return } m.skillSetEnabled(args[2], sub == "enable") case "new", "init": if len(args) < 3 { m.notice("usage: /skills new [--global]") return } global := containsArg(args[3:], "--global") m.skillNew(args[2], global) case "paths": m.skillPaths() default: hint := "" if _, ok := m.ctrl.RunSkill("/" + args[1]); ok { hint = " (to run it, type /" + args[1] + ")" } m.notice("unknown /skills subcommand " + args[1] + hint + " — try: /skills, /skills manage, /skills show , /skills enable , /skills disable , /skills new , /skills paths") } } func (m *chatTUI) skillList() { skills := m.skills if m.ctrl != nil { skills = managementSlashSkills(m.ctrl) } if len(skills) == 0 { m.notice("no skills found. Add SKILL.md / .md under .reasonix/skills (project) or ~/.reasonix/skills (global); .agents/.agent/.claude skills dirs also work. Invoke with / or run_skill.") return } m.commitLine(renderSkillList(m.width, sortedSkills(skills), m.disabledSkillNames())) } func (m *chatTUI) skillShow(name string) { skills := m.skills if m.ctrl != nil { skills = managementSlashSkills(m.ctrl) } for _, s := range skills { if s.Name == name || s.SlashName() == strings.TrimPrefix(name, "/") { disabled := false if m.ctrl != nil { disabled = !m.ctrl.SkillEnabled(s.Name) } m.commitLine(renderSkillShow(m.width, s, disabled)) return } } m.notice("unknown skill: " + name) } func managementSlashSkills(ctrl control.SessionAPI) []skill.Skill { if ctrl == nil { return nil } // AllSkills preserves disabled entries; SlashSkills adds every enabled // package-qualified alias when multiple plugins export the same bare name. all := append([]skill.Skill(nil), ctrl.AllSkills()...) all = append(all, ctrl.SlashSkills()...) return skill.VisibleSlashSkills(all) } func (m *chatTUI) disabledSkillNames() map[string]bool { out := map[string]bool{} if m.ctrl == nil { return out } for _, s := range m.ctrl.DisabledSkills() { out[s.Name] = true } return out } func (m *chatTUI) skillSetEnabled(name string, enabled bool) { m.skillSaveEnabledChanges(map[string]bool{name: enabled}) } func (m *chatTUI) skillSaveEnabledChanges(changes map[string]bool) { if len(changes) == 0 { return } if m.buildController == nil { m.notice("skill toggle unavailable in this session") return } if m.ctrl == nil { m.notice("skill toggle unavailable in this session") return } if m.ctrl.Running() { m.notice("cannot change skills while a turn is running") return } if m.modelSwitchPending { m.notice("wait for the current runtime switch to finish") return } known := map[string]string{} for _, sk := range m.ctrl.AllSkills() { known[config.SkillNameKey(sk.Name)] = sk.Name } for _, sk := range m.ctrl.SlashSkills() { known[sk.SlashName()] = sk.Name } // Lock only the load-modify-save cycle; the session refresh below runs // off-lock. The closure returns a non-empty notice on failure. if failNotice := func() string { unlock := config.LockUserConfigEdits() defer unlock() cfg := config.LoadForEdit(config.UserConfigPath()) for name, enabled := range changes { key := config.SkillNameKey(name) if key == "" { key = strings.TrimPrefix(strings.TrimSpace(name), "/") } canonical, ok := known[key] if !ok { return "skill " + enableVerb(enabled) + ": unknown skill: " + name } if err := cfg.SetSkillEnabled(canonical, enabled); err != nil { return "skill " + enableVerb(enabled) + ": " + err.Error() } } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { return "skill toggle: " + err.Error() } return "" }(); failNotice != "" { m.notice(failNotice) return } notice := "" if len(changes) == 1 { name := "" enabled := false for n, e := range changes { name, enabled = n, e } if enabled { notice = "enabled skill " + name + " — refreshing session" } else { notice = "disabled skill " + name + " — refreshing session" } } else { notice = fmt.Sprintf("updated %d skills — refreshing session", len(changes)) } m.scheduleSkillSessionRefresh("skill toggle", notice) } func (m *chatTUI) scheduleSkillSessionRefresh(reason, notice string) bool { if m.buildController == nil { m.notice("skill refresh unavailable in this session") return false } if m.ctrl == nil { return false } if m.ctrl.Running() { m.notice("cannot refresh skills while a turn is running") return false } if m.modelSwitchPending { m.notice("wait for the current runtime switch to finish") return false } if err := m.ctrl.Snapshot(); err != nil { slog.Warn(reason+": snapshot failed", "err", err) } // Snapshot can retarget the controller to a recovery branch. Carry the // post-snapshot path so the rebuild does not bind recovered history back to // the stale original transcript. carried := m.ctrl.History() prevPath := m.ctrl.SessionPath() // Move the lease before the rebuilt controller binds prevPath for writing // (AdoptHistory resumes there): after a snapshot retarget the lease still // guards the old path, and the async build must not open an unguarded // writer on the recovery branch. if err := m.rebindSessionLease(prevPath); err != nil { m.notice(reason + ": " + sessionLeaseHeldNotice(err)) return false } if notice != "" { m.notice(notice) } oldCtrl := m.ctrl build := m.buildController ref := m.modelRef m.modelSwitchPending = true m.pendingModelSwitch = func() tea.Msg { c, err := build(controllerBuildSpec{ ModelRef: ref, RuntimeProfile: m.runtimeProfile, ToolApprovalMode: oldCtrl.ToolApprovalMode(), PlanMode: oldCtrl.PlanMode(), }, carried, prevPath) if err != nil { return modelSwitchMsg{ref: ref, err: err} } return modelSwitchMsg{ ref: ref, ctrl: c, oldCtrl: oldCtrl, label: c.Label(), commands: c.Commands(), skills: c.SlashSkills(), host: c.Host(), } } return true } func enableVerb(enabled bool) string { if enabled { return "enable" } return "disable" } func (m *chatTUI) skillNew(name string, global bool) { st := m.skillStore() scope := skill.ScopeProject if global || !st.HasProjectScope() { scope = skill.ScopeGlobal } path, err := st.Create(name, scope) if err != nil { m.notice("skill new: " + err.Error()) return } m.notice(fmt.Sprintf("created skill %q at %s — edit it, then /new (or restart) to pick it up", name, path)) } func (m *chatTUI) skillPaths() { st := m.skillStore() m.commitLine(renderSkillPaths(m.width, st.Roots())) } func (m *chatTUI) skillStore() *skill.Store { cwd, _ := os.Getwd() var custom []string var excluded []string var pluginPaths map[string][]string maxDepth := 3 if cfg, err := config.Load(); err == nil { custom = cfg.SkillCustomPaths() excluded = cfg.SkillExcludedPaths() pluginPaths = cfg.PluginPackageSkillOwners() maxDepth = cfg.SkillMaxDepth() } return skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, ExcludedPaths: excluded, MaxDepth: maxDepth}) } func (m *chatTUI) runHooksSubcommand(input string) { args := tokenizeArgs(input) sub := "" if len(args) > 1 { sub = strings.ToLower(args[1]) } cwd, _ := os.Getwd() switch sub { case "", "list", "ls": m.hooksList(cwd) case "trust": if err := hook.Trust(cwd, ""); err != nil { m.notice("hooks trust: " + err.Error()) return } m.notice("trusted this project's hooks — restart Reasonix to load them") default: m.notice("unknown /hooks subcommand " + args[1] + " — try: /hooks, /hooks trust") } } func (m *chatTUI) hooksList(cwd string) { active := m.ctrl.HookRunner().Hooks() trusted := hook.IsTrusted(cwd, "") m.commitLine(renderHooks(m.width, active, trusted, hook.ProjectDefinesHooks(cwd))) } func containsArg(args []string, flag string) bool { for _, a := range args { if a == flag { return true } } return false }