Files
2026-07-13 13:00:08 +08:00

326 lines
8.5 KiB
Go

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 <name>")
return
}
m.skillShow(args[2])
case "enable", "disable":
if len(args) < 3 {
m.notice("usage: /skills " + sub + " <name>")
return
}
m.skillSetEnabled(args[2], sub == "enable")
case "new", "init":
if len(args) < 3 {
m.notice("usage: /skills new <name> [--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 <name>, /skills enable <name>, /skills disable <name>, /skills new <name>, /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 / <name>.md under .reasonix/skills (project) or ~/.reasonix/skills (global); .agents/.agent/.claude skills dirs also work. Invoke with /<name> 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
}