mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #7981 from SivanCola/fix/skills-settings-consistency
Align Skills settings sources and invocation policy / 统一技能来源与调用策略设置
This commit is contained in:
27 files changed
+1442
-282
No files matched your search
+257
-41
@@ -7501,8 +7501,9 @@ type CapabilitiesView struct {
|
||||
// SkillsSettingsView is the skills management page's data, split from MCP
|
||||
// status so opening MCP settings does not scan skill roots.
|
||||
type SkillsSettingsView struct {
|
||||
Skills []SkillView `json:"skills"`
|
||||
SkillRoots []SkillRootView `json:"skillRoots"`
|
||||
Skills []SkillView `json:"skills"`
|
||||
SkillRoots []SkillRootView `json:"skillRoots"`
|
||||
AllowImplicitInvocation bool `json:"allowImplicitInvocation"`
|
||||
}
|
||||
|
||||
// ServerView is one MCP server for the drawer. Status is "connected" (with
|
||||
@@ -7565,6 +7566,7 @@ type SkillView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Scope string `json:"scope"`
|
||||
SourceDir string `json:"sourceDir,omitempty"`
|
||||
RunAs string `json:"runAs"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
@@ -7610,6 +7612,7 @@ type SkillRootView struct {
|
||||
Scope string `json:"scope"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
Removable bool `json:"removable"`
|
||||
Skills int `json:"skills"`
|
||||
@@ -8014,12 +8017,16 @@ func (a *App) disconnectMCPServerAllRuntimes(serverName string) bool {
|
||||
|
||||
// SkillsSettings returns the skills management snapshot without MCP status.
|
||||
func (a *App) SkillsSettings() SkillsSettingsView {
|
||||
out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}
|
||||
out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}, AllowImplicitInvocation: true}
|
||||
a.mu.RLock()
|
||||
tab := a.activeTabLocked()
|
||||
var ctrl control.SessionAPI
|
||||
workspaceRoot := "."
|
||||
if tab != nil {
|
||||
ctrl = tab.Ctrl
|
||||
if strings.TrimSpace(tab.WorkspaceRoot) != "" {
|
||||
workspaceRoot = tab.WorkspaceRoot
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if ctrl == nil {
|
||||
@@ -8028,7 +8035,8 @@ func (a *App) SkillsSettings() SkillsSettingsView {
|
||||
|
||||
disabled := map[string]bool{}
|
||||
var configuredModels, configuredEfforts map[string]string
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
if cfg, err := config.LoadForRootReadOnly(workspaceRoot); err == nil {
|
||||
out.AllowImplicitInvocation = cfg.ImplicitSkillInvocationEnabled()
|
||||
for _, name := range cfg.Skills.DisabledSkills {
|
||||
if key := config.SkillNameKey(name); key != "" {
|
||||
disabled[key] = true
|
||||
@@ -8037,10 +8045,11 @@ func (a *App) SkillsSettings() SkillsSettingsView {
|
||||
configuredModels = cfg.Agent.SubagentModels
|
||||
configuredEfforts = cfg.Agent.SubagentEfforts
|
||||
}
|
||||
out.SkillRoots = a.cachedSkillRootsView(workspaceRoot)
|
||||
for _, s := range ctrl.AllSkills() {
|
||||
view := SkillView{
|
||||
Name: s.Name, Description: s.Description,
|
||||
Scope: string(s.Scope), RunAs: string(s.RunAs),
|
||||
Scope: string(s.Scope), SourceDir: skillSourceDir(s, out.SkillRoots), RunAs: string(s.RunAs),
|
||||
Enabled: !disabled[config.SkillNameKey(s.Name)],
|
||||
Plugin: s.Plugin,
|
||||
Model: s.Model,
|
||||
@@ -8062,10 +8071,23 @@ func (a *App) SkillsSettings() SkillsSettingsView {
|
||||
}
|
||||
out.Skills = append(out.Skills, view)
|
||||
}
|
||||
out.SkillRoots = a.cachedSkillRootsView()
|
||||
return out
|
||||
}
|
||||
|
||||
// SetSkillImplicitInvocation persists whether the model may discover and
|
||||
// invoke skills automatically, then rebuilds the active runtime. Explicit
|
||||
// /skill invocation and skill management remain available in either mode.
|
||||
func (a *App) SetSkillImplicitInvocation(enabled bool) error {
|
||||
err := a.applySkillConfigChange("disable_implicit_invocation", "skills policy", func(c *config.Config) error {
|
||||
c.SetSkillImplicitInvocation(enabled)
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
a.invalidateSkillRootsCache()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// subagentOverrideFor resolves a per-name subagent override with the same
|
||||
// underscore/hyphen alias fallback the runtime dispatch uses
|
||||
// (boot.SubagentModelKeys) — an exact-key read would show a legacy
|
||||
@@ -8421,11 +8443,15 @@ func mcpServerSource(source config.MCPConfigSource) (kind, configSource string)
|
||||
|
||||
const skillRootsCacheTTL = 10 * time.Second
|
||||
|
||||
func (a *App) cachedSkillRootsView() []SkillRootView {
|
||||
cwd, _ := os.Getwd()
|
||||
cfg, _ := config.Load()
|
||||
func (a *App) cachedSkillRootsView(workspaceRoots ...string) []SkillRootView {
|
||||
workspaceRoot := "."
|
||||
if len(workspaceRoots) > 0 {
|
||||
workspaceRoot = workspaceRoots[0]
|
||||
}
|
||||
workspaceRoot = normalizeWorkspaceRoot(workspaceRoot)
|
||||
cfg, _ := config.LoadForRootReadOnly(workspaceRoot)
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
key := skillRootsCacheKey(cwd, cfg, userCfg)
|
||||
key := skillRootsCacheKey(workspaceRoot, cfg, userCfg)
|
||||
|
||||
now := time.Now()
|
||||
a.skillRootsMu.Lock()
|
||||
@@ -8436,7 +8462,7 @@ func (a *App) cachedSkillRootsView() []SkillRootView {
|
||||
}
|
||||
a.skillRootsMu.Unlock()
|
||||
|
||||
roots := skillRootsViewFrom(cwd, cfg, userCfg)
|
||||
roots := skillRootsViewFrom(workspaceRoot, cfg, userCfg)
|
||||
|
||||
a.skillRootsMu.Lock()
|
||||
a.skillRootsCache = skillRootsCache{
|
||||
@@ -8461,7 +8487,8 @@ func skillRootsView() []SkillRootView {
|
||||
return skillRootsViewFrom(cwd, cfg, userCfg)
|
||||
}
|
||||
|
||||
func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {
|
||||
func skillRootsViewFrom(workspaceRoot string, cfg, userCfg *config.Config) []SkillRootView {
|
||||
workspaceRoot = normalizeWorkspaceRoot(workspaceRoot)
|
||||
var custom []string
|
||||
var excluded []string
|
||||
maxDepth := 3
|
||||
@@ -8476,7 +8503,7 @@ func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView
|
||||
pluginPaths = cfg.PluginPackageSkillOwners()
|
||||
pluginAgentPaths = cfg.PluginPackageAgentOwners()
|
||||
}
|
||||
st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, PluginAgentPaths: pluginAgentPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
|
||||
st := skill.New(skill.Options{ProjectRoot: workspaceRoot, CustomPaths: custom, PluginPaths: pluginPaths, PluginAgentPaths: pluginAgentPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
|
||||
counts := map[string]int{}
|
||||
skillItems := map[string][]SkillRootSkillView{}
|
||||
roots := st.Roots()
|
||||
@@ -8504,19 +8531,30 @@ func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView
|
||||
userConfigured := map[string]bool{}
|
||||
if userCfg != nil {
|
||||
for _, p := range userCfg.Skills.Paths {
|
||||
userConfigured[config.CanonicalSkillPath(p)] = true
|
||||
userConfigured[canonicalSkillPathForRoot(p, workspaceRoot)] = true
|
||||
}
|
||||
}
|
||||
effectiveConfigured := map[string]bool{}
|
||||
effectiveExcluded := map[string]bool{}
|
||||
if cfg != nil {
|
||||
for _, p := range cfg.Skills.Paths {
|
||||
effectiveConfigured[canonicalSkillPathForRoot(p, workspaceRoot)] = true
|
||||
}
|
||||
for _, p := range cfg.Skills.ExcludedPaths {
|
||||
effectiveExcluded[canonicalSkillPathForRoot(p, workspaceRoot)] = true
|
||||
}
|
||||
}
|
||||
out := []SkillRootView{}
|
||||
seenRoots := map[string]int{}
|
||||
for _, r := range roots {
|
||||
dir := config.CanonicalSkillPath(r.Dir)
|
||||
dir := canonicalSkillPathForRoot(r.Dir, workspaceRoot)
|
||||
view := SkillRootView{
|
||||
Dir: r.Dir,
|
||||
Scope: string(r.Scope),
|
||||
Priority: r.Priority + 1,
|
||||
Status: string(r.Status),
|
||||
Configured: r.Scope == skill.ScopeCustom && userConfigured[dir],
|
||||
Enabled: true,
|
||||
Configured: r.Scope == skill.ScopeCustom && (userConfigured[dir] || effectiveConfigured[dir]),
|
||||
Removable: true,
|
||||
Skills: counts[dir],
|
||||
SkillItems: skillItems[dir],
|
||||
@@ -8528,29 +8566,92 @@ func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView
|
||||
seenRoots[dir] = len(out)
|
||||
out = append(out, view)
|
||||
}
|
||||
if userCfg != nil {
|
||||
for _, p := range userCfg.Skills.Paths {
|
||||
if rootActive(out, p) {
|
||||
if cfg != nil {
|
||||
for _, p := range cfg.Skills.Paths {
|
||||
if rootActive(out, p, workspaceRoot) {
|
||||
continue
|
||||
}
|
||||
out = append(out, SkillRootView{
|
||||
Dir: p,
|
||||
dir := canonicalSkillPathForRoot(p, workspaceRoot)
|
||||
enabled := !effectiveExcluded[dir]
|
||||
status := "inactive"
|
||||
warning := "configured in project/user config but not active in this workspace"
|
||||
if !enabled {
|
||||
status = "disabled"
|
||||
warning = ""
|
||||
}
|
||||
appendSkillRootView(&out, &seenRoots, SkillRootView{
|
||||
Dir: dir, Scope: string(skill.ScopeCustom), Status: status, Enabled: enabled,
|
||||
Configured: true, Removable: true, Warning: warning,
|
||||
}, workspaceRoot)
|
||||
}
|
||||
for _, p := range cfg.Skills.ExcludedPaths {
|
||||
if rootActive(out, p, workspaceRoot) {
|
||||
continue
|
||||
}
|
||||
dir := canonicalSkillPathForRoot(p, workspaceRoot)
|
||||
scope := skillRootScopeForPath(p, workspaceRoot)
|
||||
appendSkillRootView(&out, &seenRoots, SkillRootView{
|
||||
Dir: dir, Scope: string(scope), Status: "disabled", Enabled: false,
|
||||
Configured: scope == skill.ScopeCustom || effectiveConfigured[dir], Removable: true,
|
||||
}, workspaceRoot)
|
||||
}
|
||||
}
|
||||
if userCfg != nil {
|
||||
userExcluded := map[string]bool{}
|
||||
for _, p := range userCfg.Skills.ExcludedPaths {
|
||||
userExcluded[canonicalSkillPathForRoot(p, workspaceRoot)] = true
|
||||
}
|
||||
for _, p := range userCfg.Skills.Paths {
|
||||
if rootActive(out, p, workspaceRoot) {
|
||||
continue
|
||||
}
|
||||
enabled := !userExcluded[canonicalSkillPathForRoot(p, workspaceRoot)]
|
||||
status := "inactive"
|
||||
warning := "configured in user config but not active in this workspace; project [skills].paths may override it"
|
||||
if !enabled {
|
||||
status = "disabled"
|
||||
warning = ""
|
||||
}
|
||||
appendSkillRootView(&out, &seenRoots, SkillRootView{
|
||||
Dir: canonicalSkillPathForRoot(p, workspaceRoot),
|
||||
Scope: string(skill.ScopeCustom),
|
||||
Status: "inactive",
|
||||
Status: status,
|
||||
Enabled: enabled,
|
||||
Configured: true,
|
||||
Removable: true,
|
||||
Warning: "configured in user config but not active in this workspace; project [skills].paths may override it",
|
||||
})
|
||||
Warning: warning,
|
||||
}, workspaceRoot)
|
||||
}
|
||||
for _, p := range userCfg.Skills.ExcludedPaths {
|
||||
if rootActive(out, p, workspaceRoot) || userConfigured[canonicalSkillPathForRoot(p, workspaceRoot)] {
|
||||
continue
|
||||
}
|
||||
scope := skillRootScopeForPath(p, workspaceRoot)
|
||||
appendSkillRootView(&out, &seenRoots, SkillRootView{
|
||||
Dir: canonicalSkillPathForRoot(p, workspaceRoot), Scope: string(scope), Status: "disabled", Enabled: false,
|
||||
Configured: scope == skill.ScopeCustom, Removable: true,
|
||||
}, workspaceRoot)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendSkillRootView(out *[]SkillRootView, seen *map[string]int, view SkillRootView, workspaceRoot string) {
|
||||
dir := canonicalSkillPathForRoot(view.Dir, workspaceRoot)
|
||||
if idx, ok := (*seen)[dir]; ok {
|
||||
(*out)[idx] = mergeDuplicateSkillRootView((*out)[idx], view)
|
||||
return
|
||||
}
|
||||
(*seen)[dir] = len(*out)
|
||||
*out = append(*out, view)
|
||||
}
|
||||
|
||||
func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {
|
||||
existing.Configured = existing.Configured || duplicate.Configured
|
||||
existing.Removable = existing.Removable || duplicate.Removable
|
||||
if existing.Status != "ok" && duplicate.Status == "ok" {
|
||||
existing.Status = duplicate.Status
|
||||
existing.Enabled = duplicate.Enabled
|
||||
}
|
||||
if existing.Skills == 0 && duplicate.Skills > 0 {
|
||||
existing.Skills = duplicate.Skills
|
||||
@@ -8562,7 +8663,7 @@ func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootVie
|
||||
return existing
|
||||
}
|
||||
|
||||
func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {
|
||||
func skillRootsCacheKey(workspaceRoot string, cfg, userCfg *config.Config) string {
|
||||
type cacheKey struct {
|
||||
CWD string `json:"cwd"`
|
||||
Custom []string `json:"custom"`
|
||||
@@ -8571,20 +8672,21 @@ func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {
|
||||
MaxDepth int `json:"maxDepth"`
|
||||
UserPaths []string `json:"userPaths"`
|
||||
}
|
||||
key := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}
|
||||
workspaceRoot = normalizeWorkspaceRoot(workspaceRoot)
|
||||
key := cacheKey{CWD: canonicalSkillPathForRoot(workspaceRoot, workspaceRoot), MaxDepth: 3}
|
||||
if cfg != nil {
|
||||
key.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())
|
||||
key.Custom = canonicalSkillPathsForRoot(cfg.SkillCustomPaths(), workspaceRoot)
|
||||
for path, owners := range cfg.PluginPackageSkillOwners() {
|
||||
for _, owner := range owners {
|
||||
key.Plugins = append(key.Plugins, config.CanonicalSkillPath(path)+"\x00"+owner)
|
||||
key.Plugins = append(key.Plugins, canonicalSkillPathForRoot(path, workspaceRoot)+"\x00"+owner)
|
||||
}
|
||||
}
|
||||
sort.Strings(key.Plugins)
|
||||
key.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())
|
||||
key.Excluded = canonicalSkillPathsForRoot(cfg.SkillExcludedPaths(), workspaceRoot)
|
||||
key.MaxDepth = cfg.SkillMaxDepth()
|
||||
}
|
||||
if userCfg != nil {
|
||||
key.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)
|
||||
key.UserPaths = canonicalSkillPathsForRoot(userCfg.Skills.Paths, workspaceRoot)
|
||||
}
|
||||
b, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
@@ -8593,15 +8695,52 @@ func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func canonicalSkillPaths(paths []string) []string {
|
||||
func canonicalSkillPathsForRoot(paths []string, workspaceRoot string) []string {
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
out = append(out, config.CanonicalSkillPath(p))
|
||||
out = append(out, canonicalSkillPathForRoot(p, workspaceRoot))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeWorkspaceRoot(root string) string {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" || root == "." {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
return filepath.Clean(cwd)
|
||||
}
|
||||
return "."
|
||||
}
|
||||
if abs, err := filepath.Abs(root); err == nil {
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
return filepath.Clean(root)
|
||||
}
|
||||
|
||||
// canonicalSkillPathForRoot mirrors skill.Store's path resolution while keeping
|
||||
// comparisons independent of the desktop process CWD. Config may intentionally
|
||||
// contain relative paths; those are relative to the active workspace.
|
||||
func canonicalSkillPathForRoot(path, workspaceRoot string) string {
|
||||
path = config.ExpandVars(strings.TrimSpace(path))
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if path == "~" {
|
||||
path = home
|
||||
} else {
|
||||
path = filepath.Join(home, path[2:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(normalizeWorkspaceRoot(workspaceRoot), path)
|
||||
}
|
||||
return config.CanonicalSkillPath(path)
|
||||
}
|
||||
|
||||
func cloneSkillRootViews(in []SkillRootView) []SkillRootView {
|
||||
out := make([]SkillRootView, len(in))
|
||||
for i, r := range in {
|
||||
@@ -8611,10 +8750,14 @@ func cloneSkillRootViews(in []SkillRootView) []SkillRootView {
|
||||
return out
|
||||
}
|
||||
|
||||
func rootActive(roots []SkillRootView, path string) bool {
|
||||
want := config.CanonicalSkillPath(path)
|
||||
func rootActive(roots []SkillRootView, path string, workspaceRoots ...string) bool {
|
||||
workspaceRoot := "."
|
||||
if len(workspaceRoots) > 0 {
|
||||
workspaceRoot = workspaceRoots[0]
|
||||
}
|
||||
want := canonicalSkillPathForRoot(path, workspaceRoot)
|
||||
for _, r := range roots {
|
||||
if config.CanonicalSkillPath(r.Dir) == want {
|
||||
if canonicalSkillPathForRoot(r.Dir, workspaceRoot) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -8664,7 +8807,11 @@ func (a *App) PickPluginFolder() (string, error) {
|
||||
func (a *App) AddSkillPath(path string) error {
|
||||
path = normalizeSkillPath(path)
|
||||
workspaceRoot := a.activeWorkspaceRoot()
|
||||
err := a.applyConfigChange(func(c *config.Config) error {
|
||||
field := "paths"
|
||||
if isConventionSkillRoot(path, workspaceRoot) {
|
||||
field = "excluded_paths"
|
||||
}
|
||||
err := a.applySkillConfigChange(field, "skills source", func(c *config.Config) error {
|
||||
if isConventionSkillRoot(path, workspaceRoot) {
|
||||
return c.RestoreSkillPath(path)
|
||||
}
|
||||
@@ -8680,7 +8827,12 @@ func (a *App) AddSkillPath(path string) error {
|
||||
// convention roots, it records a pseudo-delete in excluded_paths.
|
||||
func (a *App) RemoveSkillPath(path string) error {
|
||||
path = normalizeSkillPath(path)
|
||||
err := a.applyConfigChange(func(c *config.Config) error {
|
||||
workspaceRoot := a.activeWorkspaceRoot()
|
||||
field := "paths"
|
||||
if isConventionSkillRoot(path, workspaceRoot) {
|
||||
field = "excluded_paths"
|
||||
}
|
||||
err := a.applySkillConfigChange(field, "skills source", func(c *config.Config) error {
|
||||
removed, err := c.RemoveSkillPath(path)
|
||||
if err != nil || removed {
|
||||
return err
|
||||
@@ -8693,6 +8845,24 @@ func (a *App) RemoveSkillPath(path string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetSkillPathEnabled persists a reversible source toggle and rebuilds the
|
||||
// controller so the source is immediately included or excluded from discovery.
|
||||
func (a *App) SetSkillPathEnabled(path string, enabled bool) error {
|
||||
path = normalizeSkillPath(path)
|
||||
workspaceRoot := a.activeWorkspaceRoot()
|
||||
field := "paths"
|
||||
if isConventionSkillRoot(path, workspaceRoot) {
|
||||
field = "excluded_paths"
|
||||
}
|
||||
err := a.applySkillConfigChange(field, "skills source", func(c *config.Config) error {
|
||||
return c.SetSkillPathEnabled(path, enabled)
|
||||
})
|
||||
if err == nil {
|
||||
a.invalidateSkillRootsCache()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshSkills rebuilds the controller without changing config, reloading skill
|
||||
// discovery, the system prompt index, and slash completions.
|
||||
func (a *App) RefreshSkills() error {
|
||||
@@ -8727,7 +8897,7 @@ func (a *App) ReloadCommands() error {
|
||||
// SetSkillEnabled persists a skill toggle and rebuilds the controller so the
|
||||
// prompt index, slash menu, and skill tools reflect it immediately.
|
||||
func (a *App) SetSkillEnabled(name string, enabled bool) error {
|
||||
err := a.applyConfigChange(func(c *config.Config) error {
|
||||
err := a.applySkillConfigChange("disabled_skills", "skill", func(c *config.Config) error {
|
||||
return c.SetSkillEnabled(name, enabled)
|
||||
})
|
||||
if err == nil {
|
||||
@@ -8772,11 +8942,11 @@ func normalizeSkillPath(path string) string {
|
||||
}
|
||||
|
||||
func isConventionSkillRoot(path, workspaceRoot string) bool {
|
||||
want := config.CanonicalSkillPath(path)
|
||||
want := canonicalSkillPathForRoot(path, workspaceRoot)
|
||||
if want == "" {
|
||||
return false
|
||||
}
|
||||
bases := []string{workspaceRoot}
|
||||
bases := []string{normalizeWorkspaceRoot(workspaceRoot)}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
bases = append(bases, home)
|
||||
}
|
||||
@@ -8786,7 +8956,7 @@ func isConventionSkillRoot(path, workspaceRoot string) bool {
|
||||
continue
|
||||
}
|
||||
for _, dir := range config.ConventionDirs {
|
||||
if want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {
|
||||
if want == canonicalSkillPathForRoot(filepath.Join(base, dir, skill.SkillsDirname), workspaceRoot) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -8794,6 +8964,21 @@ func isConventionSkillRoot(path, workspaceRoot string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func skillRootScopeForPath(path, workspaceRoot string) skill.Scope {
|
||||
want := canonicalSkillPathForRoot(path, workspaceRoot)
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
for _, dir := range config.ConventionDirs {
|
||||
if want == canonicalSkillPathForRoot(filepath.Join(home, dir, skill.SkillsDirname), workspaceRoot) {
|
||||
return skill.ScopeGlobal
|
||||
}
|
||||
}
|
||||
}
|
||||
if isConventionSkillRoot(path, workspaceRoot) {
|
||||
return skill.ScopeProject
|
||||
}
|
||||
return skill.ScopeCustom
|
||||
}
|
||||
|
||||
func skillRootPath(path string) string {
|
||||
if filepath.Base(path) == skill.SkillFile {
|
||||
return filepath.Dir(path)
|
||||
@@ -8816,6 +9001,37 @@ func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
|
||||
return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
|
||||
}
|
||||
|
||||
func skillSourceDir(sk skill.Skill, roots []SkillRootView) string {
|
||||
path := strings.TrimSpace(sk.Path)
|
||||
if path == "" || strings.HasPrefix(path, "(builtin") {
|
||||
return ""
|
||||
}
|
||||
cleanPath := config.CanonicalSkillPath(path)
|
||||
bestDir := ""
|
||||
bestLen := -1
|
||||
for _, root := range roots {
|
||||
if root.Scope != "" && root.Scope != string(sk.Scope) {
|
||||
continue
|
||||
}
|
||||
cleanRoot := config.CanonicalSkillPath(root.Dir)
|
||||
if cleanRoot == "" {
|
||||
continue
|
||||
}
|
||||
prefix := cleanRoot + string(filepath.Separator)
|
||||
if cleanPath != cleanRoot && !strings.HasPrefix(cleanPath, prefix) {
|
||||
continue
|
||||
}
|
||||
if len(cleanRoot) > bestLen {
|
||||
bestDir = root.Dir
|
||||
bestLen = len(cleanRoot)
|
||||
}
|
||||
}
|
||||
if bestDir != "" {
|
||||
return bestDir
|
||||
}
|
||||
return config.CanonicalSkillPath(filepath.Dir(skillRootPath(path)))
|
||||
}
|
||||
|
||||
// MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
|
||||
// + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
|
||||
type MCPServerInput struct {
|
||||
|
||||
@@ -5481,6 +5481,7 @@ export default function App() {
|
||||
initialFocus={settingsFocus ?? undefined}
|
||||
agentRunning={state.running}
|
||||
desktopPlatform={desktopPlatform}
|
||||
activeWorkspaceKey={`${activeTab?.id ?? activeTabId ?? ""}\u0000${activeTab?.workspaceRoot ?? activeTab?.cwd ?? state.meta?.cwd ?? ""}`}
|
||||
onUseSubagent={prefillSubagentCommand}
|
||||
onClose={() => {
|
||||
setSettingsFocus(null);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SkillsSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
type SkillsView = {
|
||||
skills: Array<{ name: string; description: string; scope: string; runAs: string; enabled: boolean }>;
|
||||
skillRoots: [];
|
||||
allowImplicitInvocation: boolean;
|
||||
};
|
||||
|
||||
const pending: Record<string, Array<(view: SkillsView) => void>> = { a: [], b: [] };
|
||||
let activeWorkspace = "a";
|
||||
|
||||
function view(name: string): SkillsView {
|
||||
return {
|
||||
skills: [{ name, description: name, scope: "project", runAs: "direct", enabled: true }],
|
||||
skillRoots: [],
|
||||
allowImplicitInvocation: true,
|
||||
};
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
const appBindings = {
|
||||
Meta: async () => ({ eventChannel: "agent:event", cwd: `/workspace-${activeWorkspace}` }),
|
||||
ListTabs: async () => [{ id: `tab-${activeWorkspace}`, scope: "project", workspaceRoot: `/workspace-${activeWorkspace}`, active: true }],
|
||||
SkillsSettings: async () => new Promise<SkillsView>((resolve) => {
|
||||
pending[activeWorkspace].push(resolve);
|
||||
}),
|
||||
} as unknown as AppBindings;
|
||||
|
||||
window.go = { main: { App: appBindings } };
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<SkillsSettingsPage activeWorkspaceKey="tab-a\u0000/workspace-a" />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace A request", () => pending.a.length === 1);
|
||||
|
||||
activeWorkspace = "b";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<SkillsSettingsPage activeWorkspaceKey="tab-b\u0000/workspace-b" />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace B request", () => pending.b.length === 1);
|
||||
|
||||
await act(async () => {
|
||||
pending.b.shift()?.(view("skill-b"));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace B render", () => rootEl.textContent?.includes("skill-b") === true);
|
||||
|
||||
await act(async () => {
|
||||
pending.a.shift()?.(view("skill-a"));
|
||||
await flushPromises();
|
||||
});
|
||||
if (rootEl.textContent?.includes("skill-a")) {
|
||||
throw new Error("stale workspace A response overwrote workspace B skills");
|
||||
}
|
||||
if (!rootEl.textContent?.includes("skill-b")) {
|
||||
throw new Error("workspace B skills disappeared after stale response");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushPromises();
|
||||
});
|
||||
console.log("skills settings refresh race: passed");
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, CircleAlert, Plus, RefreshCw, Search, Server as ServerIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, CircleAlert, Folder, Plus, RefreshCw, Search, Server as ServerIcon } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -107,7 +107,7 @@ export function CapabilitiesPanel({
|
||||
const q = skillQuery.trim().toLowerCase();
|
||||
if (!q) return view.skills;
|
||||
return view.skills.filter((sk) => {
|
||||
const text = [sk.name, `/${sk.name}`, sk.invocation, sk.plugin, sk.description, sk.scope, sk.runAs].join(" ").toLowerCase();
|
||||
const text = [sk.name, `/${sk.name}`, sk.invocation, sk.plugin, sk.description, sk.scope, sk.sourceDir, sk.runAs].join(" ").toLowerCase();
|
||||
return text.includes(q);
|
||||
});
|
||||
}, [view, skillQuery]);
|
||||
@@ -292,7 +292,7 @@ export function CapabilitiesPanel({
|
||||
if (path) await app.AddSkillPath(path);
|
||||
})}
|
||||
onRefresh={() => mutate(() => app.RefreshSkills())}
|
||||
onRemove={(path) => mutate(() => app.RemoveSkillPath(path))}
|
||||
onToggle={(path, enabled) => mutate(() => app.SetSkillPathEnabled(path, enabled))}
|
||||
/>
|
||||
<div className="cap-skills-head">
|
||||
<div className="cap-skills-head__copy">
|
||||
@@ -351,9 +351,11 @@ function normalizeSkillsSettingsView(view: SkillsSettingsView | CapabilitiesView
|
||||
skills: asArray(view?.skills),
|
||||
skillRoots: asArray(view?.skillRoots).map((root) => ({
|
||||
...root,
|
||||
enabled: root.enabled !== false,
|
||||
removable: Boolean(root.removable),
|
||||
skillItems: asArray(root.skillItems),
|
||||
})),
|
||||
allowImplicitInvocation: view?.allowImplicitInvocation !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,11 +411,12 @@ function skillScopeSummary(scope: string, count: number, t: ReturnType<typeof us
|
||||
}
|
||||
}
|
||||
|
||||
function skillSourceSummary(active: number, missing: number, empty: number, t: ReturnType<typeof useT>): string {
|
||||
function skillSourceSummary(active: number, missing: number, empty: number, disabled: number, t: ReturnType<typeof useT>): string {
|
||||
const parts: string[] = [];
|
||||
if (active > 0) parts.push(t("caps.sourcesSummaryActive", { active }));
|
||||
if (missing > 0) parts.push(t("caps.sourcesSummaryMissing", { missing }));
|
||||
if (empty > 0) parts.push(t("caps.sourcesSummaryEmpty", { empty }));
|
||||
if (disabled > 0) parts.push(t("caps.sourcesSummaryDisabled", { disabled }));
|
||||
return parts.length > 0 ? parts.join(" · ") : t("caps.sourcesSummaryNone");
|
||||
}
|
||||
|
||||
@@ -422,27 +425,32 @@ function SkillSources({
|
||||
busy,
|
||||
onAdd,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: {
|
||||
roots: SkillRootView[];
|
||||
busy: boolean;
|
||||
onAdd: () => void;
|
||||
onRefresh: () => void;
|
||||
onRemove: (path: string) => void;
|
||||
onToggle: (path: string, enabled: boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
// Sources are a core part of the Skills page, so expose them on first visit.
|
||||
// Users can still collapse the section when they need more room for the list.
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [expandedRootSkills, setExpandedRootSkills] = useState<Set<string>>(() => new Set());
|
||||
const [fullRootSkills, setFullRootSkills] = useState<Set<string>>(() => new Set());
|
||||
const primaryRoots = roots.filter(isPrimarySkillRoot);
|
||||
const diagnosticRoots = roots.filter((root) => !isPrimarySkillRoot(root));
|
||||
const diagnosticsVisible = expanded && showDiagnostics;
|
||||
const shownRoots = diagnosticsVisible ? [...primaryRoots, ...diagnosticRoots] : primaryRoots;
|
||||
const summaryRoots = diagnosticsVisible ? roots : primaryRoots;
|
||||
const enabledRoots = primaryRoots.filter((root) => root.enabled !== false && root.status !== "disabled");
|
||||
const disabledRoots = primaryRoots.filter((root) => root.enabled === false || root.status === "disabled");
|
||||
const shownRoots = [
|
||||
...enabledRoots,
|
||||
...disabledRoots,
|
||||
];
|
||||
const summaryRoots = roots;
|
||||
const active = summaryRoots.filter((root) => root.skills > 0).length;
|
||||
const missing = summaryRoots.filter((root) => root.status === "missing").length;
|
||||
const empty = summaryRoots.filter((root) => root.status === "ok" && root.skills === 0).length;
|
||||
const disabled = summaryRoots.filter((root) => root.enabled === false || root.status === "disabled").length;
|
||||
const toggleRootSkills = (key: string) => {
|
||||
setExpandedRootSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -461,27 +469,28 @@ function SkillSources({
|
||||
};
|
||||
return (
|
||||
<div className={`cap-sources${expanded ? " cap-sources--expanded" : ""}`}>
|
||||
<div className="cap-sources__head">
|
||||
<div className="cap-sources__copy">
|
||||
<div className="cap-sources__title">{t("caps.sources")}</div>
|
||||
<div className="cap-sources__summary">{skillSourceSummary(active, missing, empty, t)}</div>
|
||||
</div>
|
||||
{!expanded && (
|
||||
<div className="cap-sources__actions">
|
||||
<button className="btn btn--small" type="button" onClick={() => setExpanded(true)} aria-expanded={expanded}>
|
||||
{t("caps.manageSkillSources")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="cap-sources__head"
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<span className="cap-sources__copy">
|
||||
<span className="cap-sources__title">{t("caps.sources")}</span>
|
||||
<span className="cap-sources__summary">{skillSourceSummary(active, missing, empty, disabled, t)}</span>
|
||||
</span>
|
||||
<ChevronDown className={`cap-sources__chevron${expanded ? " cap-sources__chevron--expanded" : ""}`} aria-hidden size={16} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="cap-sources__manage">
|
||||
<div className="cap-sources__manage-actions">
|
||||
<button className="btn btn--small" disabled={busy} onClick={onRefresh}>
|
||||
<RefreshCw aria-hidden size={13} />
|
||||
{t("caps.refreshSkills")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} onClick={onAdd}>
|
||||
<Plus aria-hidden size={13} />
|
||||
{t("caps.addSkillFolder")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -489,7 +498,6 @@ function SkillSources({
|
||||
className="btn btn--small"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowDiagnostics(false);
|
||||
setExpanded(false);
|
||||
}}
|
||||
aria-expanded={expanded}
|
||||
@@ -497,9 +505,9 @@ function SkillSources({
|
||||
{t("common.collapse")}
|
||||
</button>
|
||||
</div>
|
||||
{shownRoots.length === 0 ? (
|
||||
{roots.length === 0 ? (
|
||||
<div className="mem-empty">{t("caps.noSkillRoots")}</div>
|
||||
) : (
|
||||
) : shownRoots.length > 0 ? (
|
||||
<div className="cap-source-list">
|
||||
{shownRoots.map((root) => {
|
||||
const key = skillRootKey(root);
|
||||
@@ -507,7 +515,6 @@ function SkillSources({
|
||||
const rootSkillsExpanded = expandedRootSkills.has(key);
|
||||
const rootSkillsFull = fullRootSkills.has(key);
|
||||
const canShowRootSkills = rootSkills.length > 0;
|
||||
const canRemoveRoot = root.removable;
|
||||
return (
|
||||
<div className={`cap-source cap-source--${skillRootTone(root)}`} key={key}>
|
||||
<span className={`cap-dot cap-dot--${skillRootDot(root)}`} />
|
||||
@@ -516,37 +523,30 @@ function SkillSources({
|
||||
<div className="cap-source__label" title={root.dir}>
|
||||
{skillRootLabel(root)}
|
||||
</div>
|
||||
<div className="cap-source__badges">
|
||||
{skillRootBadges(root, t).map((badge) => (
|
||||
<span className={`cap-source-badge cap-source-badge--${badge.tone}`} key={badge.label}>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cap-source__meta">
|
||||
<span>{skillRootStatus(root, t)}</span>
|
||||
<span>{t("caps.skillRootCount", { skills: root.skills })}</span>
|
||||
{root.configured && <span>{t("caps.skillRootConfigured")}</span>}
|
||||
</div>
|
||||
{(canShowRootSkills || canRemoveRoot) && (
|
||||
{canShowRootSkills && (
|
||||
<div className="cap-source-actions">
|
||||
<>
|
||||
{canShowRootSkills && (
|
||||
<button
|
||||
className="btn btn--small"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
aria-expanded={rootSkillsExpanded}
|
||||
onClick={() => toggleRootSkills(key)}
|
||||
>
|
||||
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
|
||||
</button>
|
||||
)}
|
||||
{canRemoveRoot && (
|
||||
<InlineConfirmButton
|
||||
label={t("caps.skillRootRemove")}
|
||||
confirmLabel={t("caps.skillRootConfirmRemove")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={() => onRemove(root.dir)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<button
|
||||
className="btn btn--small"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
aria-expanded={rootSkillsExpanded}
|
||||
onClick={() => toggleRootSkills(key)}
|
||||
>
|
||||
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rootSkillsExpanded && rootSkills.length > 0 && (
|
||||
@@ -558,23 +558,24 @@ function SkillSources({
|
||||
)}
|
||||
{root.warning && <div className="cap-source__warning">{root.warning}</div>}
|
||||
</div>
|
||||
<div className="cap-source__badges">
|
||||
{skillRootBadges(root, t).map((badge) => (
|
||||
<span className={`cap-source-badge cap-source-badge--${badge.tone}`} key={badge.label}>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
<div className="cap-source__side">
|
||||
{root.removable && (
|
||||
<input
|
||||
className="provider-capability-row__switch cap-source__switch"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={root.enabled !== false}
|
||||
disabled={busy}
|
||||
aria-label={`${root.enabled === false ? t("caps.skillRootEnable") : t("caps.skillRootDisable")} ${root.dir}`}
|
||||
onChange={(event) => onToggle(root.dir, event.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{diagnosticRoots.length > 0 && (
|
||||
<button className="cap-diagnostics" type="button" onClick={() => setShowDiagnostics((v) => !v)}>
|
||||
{diagnosticsVisible ? t("caps.hideDiagnostics") : t("caps.showDiagnostics", { count: diagnosticRoots.length })}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -623,11 +624,11 @@ function skillRootKey(root: SkillRootView): string {
|
||||
}
|
||||
|
||||
function isPrimarySkillRoot(root: SkillRootView): boolean {
|
||||
return root.skills > 0 || root.configured || Boolean(root.warning);
|
||||
return root.skills > 0 || root.configured || root.status === "disabled" || Boolean(root.warning);
|
||||
}
|
||||
|
||||
function skillRootTone(root: SkillRootView): "active" | "empty" | "problem" {
|
||||
if (root.warning || root.status === "inactive" || root.status === "unreadable") return "problem";
|
||||
if (root.warning || root.status === "inactive" || root.status === "missing" || root.status === "unreadable") return "problem";
|
||||
if (root.skills > 0) return "active";
|
||||
return "empty";
|
||||
}
|
||||
@@ -640,8 +641,10 @@ function skillRootDot(root: SkillRootView): "connected" | "disabled" | "failed"
|
||||
}
|
||||
|
||||
function skillRootStatus(root: SkillRootView, t: ReturnType<typeof useT>): string {
|
||||
if (root.status === "disabled") return t("caps.skillRootDisabled");
|
||||
if (root.status === "ok" && root.skills > 0) return t("caps.skillRootActive");
|
||||
if (root.status === "ok") return t("caps.skillRootEmpty");
|
||||
if (root.status === "missing") return t("caps.skillRootMissing");
|
||||
return root.status;
|
||||
}
|
||||
|
||||
@@ -1482,7 +1485,15 @@ function SkillRow({
|
||||
<span className="cap-skill-card__head">
|
||||
<span className="cap-skill-card__icon">/</span>
|
||||
<span className="cap-skill-card__main">
|
||||
<span className="cap-skill-card__command">{(skill.invocation || `/${skill.name}`).replace(/^\//, "")}</span>
|
||||
<span className="cap-skill-card__identity">
|
||||
<span className="cap-skill-card__command">{(skill.invocation || `/${skill.name}`).replace(/^\//, "")}</span>
|
||||
{skill.sourceDir && (
|
||||
<span className="cap-skill-card__source" title={skill.sourceDir}>
|
||||
<Folder aria-hidden size={11} />
|
||||
<span>{skill.sourceDir}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="cap-skill-card__badges">
|
||||
<span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
|
||||
{skill.plugin && <span className="cap-skill-badge">{t("slash.plugin", { name: skill.plugin })}</span>}
|
||||
@@ -3309,7 +3320,7 @@ export function MCPServersSettingsPage() {
|
||||
|
||||
// SkillsSettingsPage is a self-contained skills management page embedded inside
|
||||
// the settings centre.
|
||||
export function SkillsSettingsPage() {
|
||||
export function SkillsSettingsPage({ activeWorkspaceKey = "" }: { activeWorkspaceKey?: string }) {
|
||||
const t = useT();
|
||||
const [snapshotKey, setSnapshotKey] = useState("");
|
||||
const [view, setView] = useState<SkillsSettingsView | null>(null);
|
||||
@@ -3317,12 +3328,15 @@ export function SkillsSettingsPage() {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [skillQuery, setSkillQuery] = useState("");
|
||||
const [expandedSkills, setExpandedSkills] = useState<Set<string>>(() => new Set());
|
||||
const reloadSequence = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const sequence = ++reloadSequence.current;
|
||||
const [meta, tabs] = await Promise.all([
|
||||
app.Meta().catch(() => null),
|
||||
app.ListTabs().catch(() => []),
|
||||
]);
|
||||
if (sequence !== reloadSequence.current) return;
|
||||
const key = settingsSnapshotKey(meta, tabs);
|
||||
setSnapshotKey(key);
|
||||
const cached = key ? skillsSettingsSnapshot : null;
|
||||
@@ -3332,10 +3346,14 @@ export function SkillsSettingsPage() {
|
||||
setView(null);
|
||||
}
|
||||
const next = normalizeSkillsSettingsView(await app.SkillsSettings().catch(() => ({ skills: [], skillRoots: [] })));
|
||||
if (sequence !== reloadSequence.current) return;
|
||||
skillsSettingsSnapshot = { key, value: next };
|
||||
setView(next);
|
||||
}, []);
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
}, [activeWorkspaceKey]);
|
||||
useEffect(() => {
|
||||
setView(null);
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const mutate = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
@@ -3358,7 +3376,7 @@ export function SkillsSettingsPage() {
|
||||
const q = skillQuery.trim().toLowerCase();
|
||||
if (!q) return view.skills;
|
||||
return view.skills.filter((sk) => {
|
||||
const text = [sk.name, "/" + sk.name, sk.invocation, sk.plugin, sk.description, sk.scope, sk.runAs].join(" ").toLowerCase();
|
||||
const text = [sk.name, "/" + sk.name, sk.invocation, sk.plugin, sk.description, sk.scope, sk.sourceDir, sk.runAs].join(" ").toLowerCase();
|
||||
return text.includes(q);
|
||||
});
|
||||
}, [view, skillQuery]);
|
||||
@@ -3387,6 +3405,20 @@ export function SkillsSettingsPage() {
|
||||
onChange={(e) => setSkillQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="provider-capability-row cap-skill-policy">
|
||||
<span className="provider-capability-row__copy">
|
||||
<span className="provider-capability-row__title">{t("caps.skillImplicitInvocation")}</span>
|
||||
<span className="cap-skill-policy__hint">{t("caps.skillImplicitInvocationHint")}</span>
|
||||
</span>
|
||||
<input
|
||||
className="provider-capability-row__switch"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={view.allowImplicitInvocation}
|
||||
disabled={actionBusy}
|
||||
onChange={(e) => void mutate(() => app.SetSkillImplicitInvocation(e.target.checked))}
|
||||
/>
|
||||
</label>
|
||||
<SkillSources
|
||||
roots={view.skillRoots ?? []}
|
||||
busy={actionBusy}
|
||||
@@ -3395,7 +3427,7 @@ export function SkillsSettingsPage() {
|
||||
if (path) await app.AddSkillPath(path);
|
||||
})}
|
||||
onRefresh={() => mutate(() => app.RefreshSkills())}
|
||||
onRemove={(path) => mutate(() => app.RemoveSkillPath(path))}
|
||||
onToggle={(path, enabled) => mutate(() => app.SetSkillPathEnabled(path, enabled))}
|
||||
/>
|
||||
<div className="cap-skills-head">
|
||||
<div className="cap-skills-head__copy">
|
||||
|
||||
@@ -100,6 +100,7 @@ export function SettingsPanel({
|
||||
agentRunning = false,
|
||||
desktopPlatform,
|
||||
onUseSubagent,
|
||||
activeWorkspaceKey = "",
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onChanged: (settings?: SettingsView | null) => void;
|
||||
@@ -108,6 +109,7 @@ export function SettingsPanel({
|
||||
agentRunning?: boolean;
|
||||
desktopPlatform: DesktopPlatform;
|
||||
onUseSubagent: (command: string) => void;
|
||||
activeWorkspaceKey?: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [s, setS] = useState<SettingsView | null>(null);
|
||||
@@ -323,7 +325,7 @@ export function SettingsPanel({
|
||||
{tab === "bots" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><BotsSection s={s} busy={busy} apply={apply} initialFocus={initialFocus} /></SettingsPageShell>}
|
||||
{tab === "mcp" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><MCPServersSettingsPage /></Suspense></SettingsPageShell>}
|
||||
{tab === "remote" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><RemoteHostsPage /></Suspense></SettingsPageShell>}
|
||||
{tab === "skills" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><SkillsSettingsPage /></Suspense></SettingsPageShell>}
|
||||
{tab === "skills" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><SkillsSettingsPage activeWorkspaceKey={activeWorkspaceKey} /></Suspense></SettingsPageShell>}
|
||||
{tab === "subagents" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><Suspense fallback={lazySettingsPageFallback}><SubagentsSettingsPage s={s} onUseInChat={(command) => {
|
||||
pendingSubagentCommandRef.current = command;
|
||||
requestClose();
|
||||
|
||||
@@ -347,9 +347,11 @@ export interface AppBindings {
|
||||
PickPluginFolder(): Promise<string>;
|
||||
AddSkillPath(path: string): Promise<void>;
|
||||
RemoveSkillPath(path: string): Promise<void>;
|
||||
SetSkillPathEnabled(path: string, enabled: boolean): Promise<void>;
|
||||
RefreshSkills(): Promise<void>;
|
||||
ReloadCommands(): Promise<void>;
|
||||
SetSkillEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
SetSkillImplicitInvocation(enabled: boolean): Promise<void>;
|
||||
AvailableSubagentTools(): Promise<MCPToolView[]>;
|
||||
CreateSubagentProfile(input: SubagentProfileInput): Promise<string>;
|
||||
UpdateSubagentProfile(name: string, scope: string, input: SubagentProfileInput): Promise<void>;
|
||||
@@ -933,7 +935,7 @@ function bridgeBreadcrumb(method: string): string {
|
||||
if (/^(CheckUpdate|ApplyUpdateRequest|OpenDownloadPage|OpenUserConfigPath|ReloadUserConfig)/.test(method)) return `update ${method}`;
|
||||
if (/^(AddMCPServer|InstallMCPServer|UpdateMCPServer|RemoveMCPServer|AuthorizeAndConnectMCPServer|AuthenticateMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
|
||||
return `mcp ${method}`;
|
||||
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion|AvailableSubagentTools|CreateSubagentProfile|UpdateSubagentProfile|DeleteSubagentProfile|SetSubagentProfileModel|SetSubagentProfileEffort|TrySubagentProfile|CancelTrySubagentProfile)/.test(method))
|
||||
if (/^(AddSkillPath|RemoveSkillPath|SetSkillPathEnabled|RefreshSkills|SetSkillEnabled|SetSkillImplicitInvocation|AcceptSkillSuggestion|AvailableSubagentTools|CreateSubagentProfile|UpdateSubagentProfile|DeleteSubagentProfile|SetSubagentProfileModel|SetSubagentProfileEffort|TrySubagentProfile|CancelTrySubagentProfile)/.test(method))
|
||||
return `skill ${method}`;
|
||||
if (/^(MinimiseMainWindow|ToggleMaximiseMainWindow|IsMainWindowMaximised|CloseMainWindow)$/.test(method)) return `window ${method}`;
|
||||
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace|DeliveryWorktreeAvailability|CreateDeliveryWorktree)/.test(method))
|
||||
@@ -1382,21 +1384,22 @@ function makeMockApp(): AppBindings {
|
||||
configuredModel: "deepseek/deepseek-v4-pro", configuredEffort: "high",
|
||||
},
|
||||
{ name: "research", description: "Combine web_fetch + code reading in an isolated subagent", scope: "builtin", runAs: "subagent", enabled: true, allowedTools: ["read_file", "ls", "glob", "grep", "code_index", "web_fetch"], invocation: "/research", invocationMode: "auto" },
|
||||
{ name: "review", description: "Review the staged diff", scope: "project", runAs: "inline", enabled: false, invocation: "/review" },
|
||||
{ name: "review", description: "Review the staged diff", scope: "project", sourceDir: "~/projects/reasonix/.reasonix/skills", runAs: "inline", enabled: false, invocation: "/review" },
|
||||
{ name: "init", description: "Scaffold a REASONIX.md for this repo", scope: "builtin", runAs: "inline", enabled: true, invocation: "/init" },
|
||||
{
|
||||
name: "my-formatter", description: "Formats code the way I like it", scope: "global", runAs: "subagent", enabled: true,
|
||||
name: "my-formatter", description: "Formats code the way I like it", scope: "global", sourceDir: "~/.reasonix/skills", runAs: "subagent", enabled: true,
|
||||
model: "deepseek-pro", effort: "high", allowedTools: ["read_file", "edit_file"], color: "amber", invocation: "/my-formatter", invocationMode: "manual",
|
||||
body: "You are a code formatting assistant. Reformat the given file to match project style without changing behavior.",
|
||||
},
|
||||
];
|
||||
let capSkillRoots: SkillRootView[] = [
|
||||
{ dir: "~/projects/reasonix/.reasonix/skills", scope: "project", priority: 1, status: "missing", configured: false, removable: true, skills: 0 },
|
||||
{ dir: "~/projects/reasonix/.reasonix/skills", scope: "project", priority: 1, status: "missing", enabled: true, configured: false, removable: true, skills: 0 },
|
||||
{
|
||||
dir: "~/my-skills",
|
||||
scope: "custom",
|
||||
priority: 5,
|
||||
status: "ok",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
removable: true,
|
||||
skills: 1,
|
||||
@@ -1407,6 +1410,7 @@ function makeMockApp(): AppBindings {
|
||||
scope: "global",
|
||||
priority: 6,
|
||||
status: "ok",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
removable: true,
|
||||
skills: 2,
|
||||
@@ -3334,6 +3338,7 @@ function makeMockApp(): AppBindings {
|
||||
return {
|
||||
skills: capSkills.map((s) => ({ ...s })),
|
||||
skillRoots: capSkillRoots.map((s) => ({ ...s })),
|
||||
allowImplicitInvocation: true,
|
||||
};
|
||||
},
|
||||
async RuntimeDoctor() {
|
||||
@@ -3585,6 +3590,7 @@ function makeMockApp(): AppBindings {
|
||||
scope: "custom",
|
||||
priority: capSkillRoots.length + 1,
|
||||
status: "ok",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
removable: true,
|
||||
skills: 1,
|
||||
@@ -3602,12 +3608,21 @@ function makeMockApp(): AppBindings {
|
||||
if (idx >= 0) capSkills.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
async SetSkillPathEnabled(path: string, enabled: boolean) {
|
||||
const root = capSkillRoots.find((r) => r.dir === path);
|
||||
if (root) {
|
||||
root.enabled = enabled;
|
||||
root.status = enabled ? "ok" : "disabled";
|
||||
root.skills = enabled ? (root.skillItems?.length ?? 0) : 0;
|
||||
}
|
||||
},
|
||||
async RefreshSkills() {},
|
||||
async ReloadCommands() {},
|
||||
async SetSkillEnabled(name: string, enabled: boolean) {
|
||||
const skill = capSkills.find((s) => s.name === name);
|
||||
if (skill) skill.enabled = enabled;
|
||||
},
|
||||
async SetSkillImplicitInvocation(_enabled: boolean) {},
|
||||
async AvailableSubagentTools() {
|
||||
return [
|
||||
{ name: "read_file", description: "Read a file's contents", readOnlyHint: true },
|
||||
|
||||
@@ -988,6 +988,7 @@ export interface SkillView {
|
||||
name: string;
|
||||
description: string;
|
||||
scope: string;
|
||||
sourceDir?: string;
|
||||
runAs: string;
|
||||
enabled: boolean;
|
||||
plugin?: string;
|
||||
@@ -1019,6 +1020,7 @@ export interface SkillRootView {
|
||||
scope: string;
|
||||
priority: number;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
removable: boolean;
|
||||
skills: number;
|
||||
@@ -1030,10 +1032,12 @@ export interface CapabilitiesView {
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
plugins: PluginView[];
|
||||
allowImplicitInvocation?: boolean;
|
||||
}
|
||||
export interface SkillsSettingsView {
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
allowImplicitInvocation?: boolean;
|
||||
}
|
||||
export interface SubagentProfileInput {
|
||||
name: string;
|
||||
|
||||
@@ -336,8 +336,10 @@ export const en = {
|
||||
"caps.connected": "connected",
|
||||
"caps.failed": "failed to connect",
|
||||
"caps.tools": "Tools",
|
||||
"caps.skills": "Skills",
|
||||
"caps.noSkills": "No skills found.",
|
||||
"caps.skills": "Skills",
|
||||
"caps.skillImplicitInvocation": "Allow automatic skill invocation",
|
||||
"caps.skillImplicitInvocationHint": "When off, skills remain available through /skill but are hidden from the model's automatic index and tools.",
|
||||
"caps.noSkills": "No skills found.",
|
||||
"caps.skillsSummaryAvailable": "{skills} available",
|
||||
"caps.skillsSummaryMatches": "{matched} matches · {total} total",
|
||||
"caps.skillsSummaryBuiltin": "{count} builtin",
|
||||
@@ -544,9 +546,10 @@ export const en = {
|
||||
"caps.sourcesSummaryActive": "{active} active",
|
||||
"caps.sourcesSummaryMissing": "{missing} missing",
|
||||
"caps.sourcesSummaryEmpty": "{empty} empty",
|
||||
"caps.sourcesSummaryDisabled": "{disabled} disabled",
|
||||
"caps.sourcesSummaryNone": "No sources",
|
||||
"caps.manageSkillSources": "Manage",
|
||||
"caps.addSkillFolder": "+ Add folder",
|
||||
"caps.addSkillFolder": "Add folder",
|
||||
"caps.refreshSkills": "Rescan",
|
||||
"caps.noSkillRoots": "No skill sources found.",
|
||||
"caps.skillRootCount": "{skills} skills",
|
||||
@@ -554,6 +557,9 @@ export const en = {
|
||||
"caps.skillRootRemove": "Remove source",
|
||||
"caps.skillRootConfirmRemove": "Confirm remove",
|
||||
"caps.skillRootRemoveHint": "Removes it from config only; the folder is not deleted.",
|
||||
"caps.skillRootDisable": "Disable",
|
||||
"caps.skillRootEnable": "Enable",
|
||||
"caps.skillRootDisabled": "disabled",
|
||||
"caps.skillRootActive": "active",
|
||||
"caps.skillRootEmpty": "empty",
|
||||
"caps.skillRootBuiltinPath": "Built-in path",
|
||||
@@ -561,8 +567,6 @@ export const en = {
|
||||
"caps.skillRootConfiguredPath": "Configured path",
|
||||
"caps.skillRootMissing": "Directory missing",
|
||||
"caps.skillRootShowAllSkills": "View all {count}",
|
||||
"caps.showDiagnostics": "View {count} hidden paths",
|
||||
"caps.hideDiagnostics": "Hide diagnostic paths",
|
||||
|
||||
// subagent profiles (settings > Subagents)
|
||||
"subagents.tabHint": "Built-in + your own agent profiles",
|
||||
|
||||
@@ -230,8 +230,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.connected": "已連線",
|
||||
"caps.failed": "連線失敗",
|
||||
"caps.tools": "工具",
|
||||
"caps.skills": "技能",
|
||||
"caps.noSkills": "未發現技能。",
|
||||
"caps.skills": "技能",
|
||||
"caps.skillImplicitInvocation": "允許 Agent 自動呼叫技能",
|
||||
"caps.skillImplicitInvocationHint": "關閉後仍可透過 /skill 使用技能,但 Agent 看不到技能索引和專用工具。",
|
||||
"caps.noSkills": "未發現技能。",
|
||||
"caps.skillsSummaryAvailable": "{skills} 個可用",
|
||||
"caps.skillsSummaryMatches": "{matched} 個匹配 · 共 {total} 個",
|
||||
"caps.skillsSummaryBuiltin": "{count} 個內建",
|
||||
@@ -438,9 +440,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.sourcesSummaryActive": "{active} 個有效",
|
||||
"caps.sourcesSummaryMissing": "{missing} 個缺失",
|
||||
"caps.sourcesSummaryEmpty": "{empty} 個空目錄",
|
||||
"caps.sourcesSummaryDisabled": "{disabled} 個已停用",
|
||||
"caps.sourcesSummaryNone": "暫無來源",
|
||||
"caps.manageSkillSources": "管理",
|
||||
"caps.addSkillFolder": "+ 新增目錄",
|
||||
"caps.addSkillFolder": "新增目錄",
|
||||
"caps.refreshSkills": "重新掃描",
|
||||
"caps.noSkillRoots": "未發現技能來源。",
|
||||
"caps.skillRootCount": "{skills} 個技能",
|
||||
@@ -448,6 +451,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.skillRootRemove": "移除來源",
|
||||
"caps.skillRootConfirmRemove": "確認移除",
|
||||
"caps.skillRootRemoveHint": "只會從設定中移除,不刪除目錄。",
|
||||
"caps.skillRootDisable": "停用",
|
||||
"caps.skillRootEnable": "啟用",
|
||||
"caps.skillRootDisabled": "已停用",
|
||||
"caps.skillRootActive": "有效",
|
||||
"caps.skillRootEmpty": "空目錄",
|
||||
"caps.skillRootBuiltinPath": "內建路徑",
|
||||
@@ -455,8 +461,6 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.skillRootConfiguredPath": "設定路徑",
|
||||
"caps.skillRootMissing": "目錄不存在",
|
||||
"caps.skillRootShowAllSkills": "檢視全部 {count} 個",
|
||||
"caps.showDiagnostics": "檢視隱藏路徑 {count}",
|
||||
"caps.hideDiagnostics": "隱藏診斷路徑",
|
||||
|
||||
// 子智慧體檔案(設定 > 子智慧體)
|
||||
"subagents.tabHint": "內建 + 你自己的 agent 檔案",
|
||||
|
||||
@@ -337,8 +337,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.connected": "已连接",
|
||||
"caps.failed": "连接失败",
|
||||
"caps.tools": "工具",
|
||||
"caps.skills": "技能",
|
||||
"caps.noSkills": "未发现技能。",
|
||||
"caps.skills": "技能",
|
||||
"caps.skillImplicitInvocation": "允许 Agent 自动调用技能",
|
||||
"caps.skillImplicitInvocationHint": "关闭后仍可通过 /skill 使用技能,但 Agent 看不到技能索引和专用工具。",
|
||||
"caps.noSkills": "未发现技能。",
|
||||
"caps.skillsSummaryAvailable": "{skills} 个可用",
|
||||
"caps.skillsSummaryMatches": "{matched} 个匹配 · 共 {total} 个",
|
||||
"caps.skillsSummaryBuiltin": "{count} 个内置",
|
||||
@@ -545,9 +547,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.sourcesSummaryActive": "{active} 个有效",
|
||||
"caps.sourcesSummaryMissing": "{missing} 个缺失",
|
||||
"caps.sourcesSummaryEmpty": "{empty} 个空目录",
|
||||
"caps.sourcesSummaryDisabled": "{disabled} 个已停用",
|
||||
"caps.sourcesSummaryNone": "暂无来源",
|
||||
"caps.manageSkillSources": "管理",
|
||||
"caps.addSkillFolder": "+ 添加目录",
|
||||
"caps.addSkillFolder": "添加目录",
|
||||
"caps.refreshSkills": "重新扫描",
|
||||
"caps.noSkillRoots": "未发现技能来源。",
|
||||
"caps.skillRootCount": "{skills} 个技能",
|
||||
@@ -555,6 +558,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.skillRootRemove": "移除来源",
|
||||
"caps.skillRootConfirmRemove": "确认移除",
|
||||
"caps.skillRootRemoveHint": "只会从配置中移除,不删除目录。",
|
||||
"caps.skillRootDisable": "停用",
|
||||
"caps.skillRootEnable": "启用",
|
||||
"caps.skillRootDisabled": "已停用",
|
||||
"caps.skillRootActive": "有效",
|
||||
"caps.skillRootEmpty": "空目录",
|
||||
"caps.skillRootBuiltinPath": "内置路径",
|
||||
@@ -562,8 +568,6 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.skillRootConfiguredPath": "配置路径",
|
||||
"caps.skillRootMissing": "目录不存在",
|
||||
"caps.skillRootShowAllSkills": "查看全部 {count} 个",
|
||||
"caps.showDiagnostics": "查看隐藏路径 {count}",
|
||||
"caps.hideDiagnostics": "隐藏诊断路径",
|
||||
|
||||
// 子智能体档案(设置 > 子智能体)
|
||||
"subagents.tabHint": "内置 + 你自己的 agent 档案",
|
||||
|
||||
@@ -13577,7 +13577,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cap-search .mem-input {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.cap-sources {
|
||||
display: flex;
|
||||
@@ -13587,14 +13587,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
padding: 2px 0 8px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.cap-sources--expanded {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.cap-sources__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cap-sources__head:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.cap-sources__copy {
|
||||
min-width: 0;
|
||||
@@ -13618,15 +13626,16 @@ body > .mermaid-diagram--fullscreen {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cap-sources__actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
.cap-sources__chevron {
|
||||
color: var(--fg-dim);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
.cap-sources__actions .btn {
|
||||
min-height: 24px;
|
||||
padding-inline: 9px;
|
||||
.cap-sources__head:hover .cap-sources__chevron,
|
||||
.cap-sources__head:focus-visible .cap-sources__chevron {
|
||||
color: var(--fg);
|
||||
}
|
||||
.cap-sources__chevron--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.cap-sources__manage {
|
||||
display: flex;
|
||||
@@ -13653,8 +13662,8 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 8px 9px;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--bg-soft) 42%, transparent);
|
||||
@@ -13671,10 +13680,12 @@ body > .mermaid-diagram--fullscreen {
|
||||
.cap-source__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cap-source__label {
|
||||
flex: 1 1 220px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--fg);
|
||||
@@ -13688,10 +13699,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-self: end;
|
||||
gap: 4px;
|
||||
max-width: 142px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cap-source-badge {
|
||||
@@ -13717,9 +13725,9 @@ body > .mermaid-diagram--fullscreen {
|
||||
color: var(--ok);
|
||||
}
|
||||
.cap-source-badge--missing {
|
||||
border-color: color-mix(in srgb, var(--err) 28%, var(--border));
|
||||
background: color-mix(in srgb, var(--err) 7%, var(--bg-soft));
|
||||
color: var(--err);
|
||||
border-color: color-mix(in srgb, var(--warn) 34%, var(--border));
|
||||
background: color-mix(in srgb, var(--warn) 8%, var(--bg-soft));
|
||||
color: var(--warn);
|
||||
}
|
||||
.cap-source-badge--builtin {
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, var(--border));
|
||||
@@ -13730,7 +13738,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 8px;
|
||||
margin-top: 4px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -13738,7 +13745,7 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cap-source-skills {
|
||||
display: flex;
|
||||
@@ -13803,21 +13810,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.cap-diagnostics {
|
||||
--wails-draggable: no-drag;
|
||||
align-self: flex-start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.cap-diagnostics:hover,
|
||||
.cap-diagnostics:focus-visible {
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
.cap-failures {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
@@ -14141,6 +14133,12 @@ body > .mermaid-diagram--fullscreen {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cap-skill-card__identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.cap-skill-card__command {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -14151,6 +14149,17 @@ body > .mermaid-diagram--fullscreen {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cap-skill-card__source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--fg-faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
.cap-skill-card__source span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cap-skill-card__badges {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
@@ -17170,8 +17179,8 @@ body > .mermaid-diagram--fullscreen {
|
||||
transition: transform 140ms ease, background 140ms ease;
|
||||
}
|
||||
.provider-capability-row__switch:checked {
|
||||
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
background: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--ok) 72%, var(--border));
|
||||
background: var(--ok);
|
||||
}
|
||||
.provider-capability-row__switch:checked::before {
|
||||
background: var(--bg);
|
||||
@@ -17188,6 +17197,11 @@ body > .mermaid-diagram--fullscreen {
|
||||
.provider-capability-row:has(.provider-capability-row__switch:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cap-skill-policy__hint {
|
||||
color: var(--fg-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.provider-capability-row__cost {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--text-2xs);
|
||||
|
||||
@@ -1311,6 +1311,60 @@ func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// applySkillConfigChange edits the config file that owns the selected [skills]
|
||||
// field. Project skill settings shadow the global setting at runtime, so
|
||||
// writing only the user config would make the UI appear to save while the
|
||||
// active project continued using its old value.
|
||||
func (a *App) applySkillConfigChange(field, setting string, mutate func(*config.Config) error) error {
|
||||
return a.applySkillConfigChangeForFields([]string{field}, setting, mutate)
|
||||
}
|
||||
|
||||
func (a *App) applySkillConfigChangeForFields(fields []string, setting string, mutate func(*config.Config) error) error {
|
||||
workspaceRoot := a.activeWorkspaceRoot()
|
||||
projectPath := config.SourcePathForRoot(workspaceRoot)
|
||||
projectOwned := strings.TrimSpace(projectPath) != "" && !config.IsUserConfigPath(projectPath)
|
||||
if projectOwned {
|
||||
projectOwned = slices.ContainsFunc(fields, func(field string) bool {
|
||||
return config.ConfigFileDefinesSkillKey(projectPath, field)
|
||||
})
|
||||
}
|
||||
if !projectOwned {
|
||||
return a.applyConfigChange(mutate)
|
||||
}
|
||||
if err := a.ensureActiveTabRebuildAllowed(setting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := func() error {
|
||||
unlock, err := config.LockConfigFileEdits(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
cfg, err := config.LoadForEditWithoutCredentialsReadOnlyStrict(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mutate(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, field := range fields {
|
||||
if err := cfg.KeepProjectSkillKey(field); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return cfg.SaveTo(projectPath)
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.rebuildSetting(setting); err != nil {
|
||||
if _, ok := a.deferredRebuildWarning(setting, err); ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyConfigChangeWithWarning(setting string, mutate func(*config.Config) error) (string, error) {
|
||||
if err := a.ensureActiveTabRebuildAllowed(setting); err != nil {
|
||||
return "", err
|
||||
@@ -1903,6 +1957,7 @@ func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admiss
|
||||
func (a *App) buildSettingReplacementController(tab *WorkspaceTab, snap tabRuntimeSnapshot, runtime normalizedTabRuntime, model, prevPath, setting string, oldCtrl control.SessionAPI, carried []provider.Message, reload bool) (control.SessionAPI, normalizedTabRuntime, string, error) {
|
||||
opts := boot.Options{
|
||||
Model: model, RequireKey: false,
|
||||
RuntimeReload: boot.RuntimeReload{ForceFullRebuild: reload},
|
||||
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
|
||||
StatsSource: "desktop",
|
||||
Sink: snap.sink,
|
||||
|
||||
+196
-5
@@ -51,7 +51,7 @@ func TestSkillRootsViewCountsProjectSkills(t *testing.T) {
|
||||
roots := skillRootsView()
|
||||
want := realTestPath(root)
|
||||
for _, r := range roots {
|
||||
if realTestPath(r.Dir) == want {
|
||||
if config.CanonicalSkillPath(r.Dir) == want {
|
||||
if r.Status != "ok" || r.Skills != 1 || r.Scope != "project" {
|
||||
t.Fatalf("project root view = %+v", r)
|
||||
}
|
||||
@@ -64,6 +64,140 @@ func TestSkillRootsViewCountsProjectSkills(t *testing.T) {
|
||||
t.Fatalf("project skill root %q not found in %+v", root, roots)
|
||||
}
|
||||
|
||||
func TestSkillRootsViewUsesActiveWorkspaceForRelativeProjectPaths(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
project := t.TempDir()
|
||||
root := filepath.Join(project, "relative-skills")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "relative.md"), []byte("---\ndescription: relative\n---\nbody"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[skills]\npaths = [\"relative-skills\"]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadForRootReadOnly(project)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := skillRootsViewFrom(project, cfg, config.LoadForEdit(config.UserConfigPath()))
|
||||
if got := skillRootCount(roots, root); got != 1 {
|
||||
t.Fatalf("relative project skill root count = %d, want 1; roots=%+v", got, roots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillRootsViewShowsProjectExcludedPathAsDisabled(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
project := t.TempDir()
|
||||
root := filepath.Join(project, "project-skills")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "disabled.md"), []byte("---\ndescription: disabled\n---\nbody"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[skills]\nexcluded_paths = [\"project-skills\"]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadForRootReadOnly(project)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := skillRootsViewFrom(project, cfg, config.LoadForEdit(config.UserConfigPath()))
|
||||
for _, view := range roots {
|
||||
if realTestPath(view.Dir) != realTestPath(root) {
|
||||
continue
|
||||
}
|
||||
if view.Enabled || view.Status != "disabled" || !view.Configured || view.Skills != 0 {
|
||||
t.Fatalf("project excluded root view = %+v", view)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("project excluded skill root %q not found in %+v", root, roots)
|
||||
}
|
||||
|
||||
func TestSkillSettingsEditProjectOwnedFields(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
project := t.TempDir()
|
||||
root := filepath.Join(project, "project-skills")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectConfig := filepath.Join(project, "reasonix.toml")
|
||||
if err := os.WriteFile(projectConfig, []byte("[skills]\npaths = [\"project-skills\"]\ndisable_implicit_invocation = true\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(wd)
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
if err := app.SetSkillPathEnabled(root, false); err != nil {
|
||||
t.Fatalf("SetSkillPathEnabled: %v", err)
|
||||
}
|
||||
projectCfg, err := config.LoadForEditReadOnlyStrict(projectConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(projectCfg.Skills.ExcludedPaths) != 1 || realTestPath(projectCfg.Skills.ExcludedPaths[0]) != realTestPath(root) {
|
||||
t.Fatalf("project excluded paths = %v, want %q", projectCfg.Skills.ExcludedPaths, root)
|
||||
}
|
||||
if err := app.SetSkillPathEnabled(root, true); err != nil {
|
||||
t.Fatalf("SetSkillPathEnabled restore: %v", err)
|
||||
}
|
||||
projectCfg, err = config.LoadForEditReadOnlyStrict(projectConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(projectCfg.Skills.ExcludedPaths) != 0 {
|
||||
t.Fatalf("project excluded paths after restore = %v, want empty", projectCfg.Skills.ExcludedPaths)
|
||||
}
|
||||
if err := app.SetSkillImplicitInvocation(false); err != nil {
|
||||
t.Fatalf("SetSkillImplicitInvocation: %v", err)
|
||||
}
|
||||
projectCfg, err = config.LoadForEditReadOnlyStrict(projectConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(projectCfg.Skills.ExcludedPaths) != 0 {
|
||||
t.Fatalf("project excluded paths changed during policy edit: %v", projectCfg.Skills.ExcludedPaths)
|
||||
}
|
||||
if !projectCfg.Skills.DisableImplicitInvocation {
|
||||
t.Fatal("project implicit invocation policy was overwritten")
|
||||
}
|
||||
if err := app.SetSkillImplicitInvocation(true); err != nil {
|
||||
t.Fatalf("SetSkillImplicitInvocation restore: %v", err)
|
||||
}
|
||||
projectCfg, err = config.LoadForEditReadOnlyStrict(projectConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projectCfg.Skills.DisableImplicitInvocation {
|
||||
t.Fatal("project implicit invocation policy did not restore")
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if len(userCfg.Skills.ExcludedPaths) != 0 || userCfg.Skills.DisableImplicitInvocation {
|
||||
t.Fatalf("project-owned skill edits leaked into user config: %+v", userCfg.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
@@ -211,7 +345,7 @@ func TestSkillRootsViewDedupesConfiguredProjectConventionRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillRootsViewOmitsExcludedConventionRoot(t *testing.T) {
|
||||
func TestSkillRootsViewShowsExcludedConventionRootAsDisabled(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
@@ -242,12 +376,16 @@ func TestSkillRootsViewOmitsExcludedConventionRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
roots := skillRootsView()
|
||||
want := realTestPath(root)
|
||||
want := config.CanonicalSkillPath("~/.agents/skills")
|
||||
for _, r := range roots {
|
||||
if realTestPath(r.Dir) == want {
|
||||
t.Fatalf("excluded convention root should be hidden, got %+v in %+v", r, roots)
|
||||
if config.CanonicalSkillPath(r.Dir) == want {
|
||||
if r.Enabled || r.Status != "disabled" || r.Skills != 0 {
|
||||
t.Fatalf("excluded convention root should remain visible as disabled, got %+v", r)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("excluded convention root should remain visible as disabled, roots=%+v", roots)
|
||||
}
|
||||
|
||||
func TestRemoveSkillPathPseudoDeletesConventionRoot(t *testing.T) {
|
||||
@@ -366,6 +504,59 @@ func TestSkillsSettingsCarriesSubagentProfileFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsSettingsCarriesSkillSourceDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
project := t.TempDir()
|
||||
root := filepath.Join(project, ".reasonix", "skills")
|
||||
skillDir := filepath.Join(root, "owned")
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(skillPath, []byte("---\ndescription: owned\n---\nbody"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(wd)
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := NewApp()
|
||||
a.setTestCtrl(control.New(control.Options{
|
||||
AllSkills: []skill.Skill{{Name: "owned", Description: "owned", Scope: skill.ScopeProject, Path: skillPath}},
|
||||
}), "")
|
||||
defer a.activeCtrl().Close()
|
||||
|
||||
views := a.SkillsSettings().Skills
|
||||
if len(views) != 1 {
|
||||
t.Fatalf("Skills = %+v, want exactly one entry", views)
|
||||
}
|
||||
if got, want := realTestPath(views[0].SourceDir), realTestPath(root); got != want {
|
||||
t.Fatalf("SourceDir = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSourceDirPrefersLongestMatchingRoot(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
nested := filepath.Join(parent, "nested")
|
||||
skillPath := filepath.Join(nested, "example", "SKILL.md")
|
||||
got := skillSourceDir(skill.Skill{Scope: skill.ScopeCustom, Path: skillPath}, []SkillRootView{
|
||||
{Dir: parent, Scope: string(skill.ScopeCustom)},
|
||||
{Dir: nested, Scope: string(skill.ScopeCustom)},
|
||||
})
|
||||
if realTestPath(got) != realTestPath(nested) {
|
||||
t.Fatalf("skillSourceDir = %q, want longest matching root %q", got, nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubagentToolsExcludesAlwaysHiddenTools(t *testing.T) {
|
||||
a := NewApp()
|
||||
for _, view := range a.AvailableSubagentTools() {
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
// Populated on successful BuildRuntime so the next RebuildFrom can skip
|
||||
// skill/command/hook rediscovery and snapshot re-freeze when the plan allows.
|
||||
type ReusedAssembly struct {
|
||||
SystemPrompt string
|
||||
Skills []skill.Skill
|
||||
Commands []command.Command
|
||||
Hooks []hook.ResolvedHook
|
||||
Registry *tool.Registry
|
||||
SystemPrompt string
|
||||
Skills []skill.Skill
|
||||
Commands []command.Command
|
||||
Hooks []hook.ResolvedHook
|
||||
Registry *tool.Registry
|
||||
ImplicitSkillInvocation bool
|
||||
}
|
||||
|
||||
// shouldReuseDiscovery reports whether rediscovery of skills/commands/hooks
|
||||
|
||||
+47
-26
@@ -591,13 +591,16 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
projectChecks := instruction.ExtractHostChecks(mem.Docs)
|
||||
sysPrompt = memory.Compose(sysPrompt, mem)
|
||||
|
||||
implicitSkillInvocation := cfg.ImplicitSkillInvocationEnabled()
|
||||
// Skills: rediscovery skipped on no-op/interceptor/UI rebuilds when
|
||||
// ReuseAssembly is retained from the previous BuildResult.
|
||||
var skillStore *skill.Store
|
||||
var skills []skill.Skill
|
||||
var allSkillStore *skill.Store
|
||||
var allSkills []skill.Skill
|
||||
if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) {
|
||||
canReuseSkills := opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) &&
|
||||
opts.ReuseAssembly.ImplicitSkillInvocation == implicitSkillInvocation
|
||||
if canReuseSkills {
|
||||
skills = opts.ReuseAssembly.Skills
|
||||
allSkills = skills
|
||||
skillStore = skill.New(skill.Options{ProjectRoot: root, Stderr: io.Discard})
|
||||
@@ -615,7 +618,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
skills = skillStore.List()
|
||||
allSkillStore = skill.New(skill.Options{ProjectRoot: root, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard})
|
||||
allSkills = allSkillStore.List()
|
||||
if !tokenEconomy {
|
||||
if !tokenEconomy && implicitSkillInvocation {
|
||||
sysPrompt = skill.ApplyIndex(sysPrompt, skills)
|
||||
}
|
||||
}
|
||||
@@ -1413,7 +1416,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
// Expose loaded slash commands to the model via slash_command. In economy
|
||||
// mode skills join this list only after the skills source is enabled.
|
||||
var slashEntries []command.SlashEntry
|
||||
if includeSkills {
|
||||
if includeSkills && implicitSkillInvocation {
|
||||
for _, sk := range skillStore.SlashList() {
|
||||
slashEntries = append(slashEntries, command.SlashEntry{
|
||||
Name: sk.SlashName(),
|
||||
@@ -1505,6 +1508,9 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
}
|
||||
readOnlySkillToolsAdded := false
|
||||
addReadOnlySkillTools := func() string {
|
||||
if !implicitSkillInvocation {
|
||||
return "automatic skill invocation is disabled; use an explicit /skill command instead."
|
||||
}
|
||||
if readOnlySkillToolsAdded {
|
||||
return "read_only_skill tool is already enabled.\n\n" + skill.ReadOnlyIndexBlock(skills)
|
||||
}
|
||||
@@ -1514,6 +1520,9 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
}
|
||||
skillToolsAdded := false
|
||||
addSkillTools := func() string {
|
||||
if !implicitSkillInvocation {
|
||||
return "automatic skill invocation is disabled; use an explicit /skill command instead."
|
||||
}
|
||||
if skillToolsAdded {
|
||||
return "skills are already enabled.\n\n" + skill.IndexBlock(skills)
|
||||
}
|
||||
@@ -1525,12 +1534,16 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
for _, t := range skill.BuiltinSubagentTools(skillStore, skillRunner, skillProfile) {
|
||||
reg.Add(t)
|
||||
}
|
||||
addSlashCommandTool(true)
|
||||
addSlashCommandTool(implicitSkillInvocation)
|
||||
return "enabled skills. Use run_skill/read_skill/read_only_skill or the dedicated skill tools on the next model request.\n\n" + skill.IndexBlock(skills)
|
||||
}
|
||||
if !tokenEconomy {
|
||||
addInstallSourceTool()
|
||||
addSkillTools()
|
||||
if implicitSkillInvocation {
|
||||
addSkillTools()
|
||||
} else {
|
||||
addSlashCommandTool(false)
|
||||
}
|
||||
}
|
||||
if tokenEconomy {
|
||||
addBuiltinSourceTools := func(source string, names ...string) string {
|
||||
@@ -1866,26 +1879,27 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
}
|
||||
|
||||
ctrlOpts := control.Options{
|
||||
Runner: runner,
|
||||
Executor: executor,
|
||||
Sink: sink,
|
||||
Policy: policy,
|
||||
SubagentGate: headlessGate,
|
||||
Label: label,
|
||||
ModelRef: modelRef,
|
||||
SystemPrompt: sysPrompt,
|
||||
SessionDir: sessionDir,
|
||||
Host: pluginHost,
|
||||
Commands: cmds,
|
||||
Skills: skills,
|
||||
AllSkills: allSkills,
|
||||
SkillStore: skillStore,
|
||||
AllSkillStore: allSkillStore,
|
||||
SkillRunner: skillRunner,
|
||||
ReadOnlySkillRunner: readOnlySkillRunner,
|
||||
SkillProfile: skillProfile,
|
||||
Hooks: hookRunner,
|
||||
Memory: mem,
|
||||
Runner: runner,
|
||||
Executor: executor,
|
||||
Sink: sink,
|
||||
Policy: policy,
|
||||
SubagentGate: headlessGate,
|
||||
Label: label,
|
||||
ModelRef: modelRef,
|
||||
SystemPrompt: sysPrompt,
|
||||
SessionDir: sessionDir,
|
||||
Host: pluginHost,
|
||||
Commands: cmds,
|
||||
Skills: skills,
|
||||
AllSkills: allSkills,
|
||||
SkillStore: skillStore,
|
||||
AllSkillStore: allSkillStore,
|
||||
DisableImplicitSkillInvocation: !implicitSkillInvocation,
|
||||
SkillRunner: skillRunner,
|
||||
ReadOnlySkillRunner: readOnlySkillRunner,
|
||||
SkillProfile: skillProfile,
|
||||
Hooks: hookRunner,
|
||||
Memory: mem,
|
||||
// Indirection: the cleanup variable gains the extension runtime set at
|
||||
// the end of build (snapshot assembly runs after control.New), and the
|
||||
// controller must observe the final chain at Close time.
|
||||
@@ -2144,7 +2158,14 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
ctrl.ApplyExtensionSystemPrompt(final)
|
||||
}
|
||||
}
|
||||
assembly := &ReusedAssembly{SystemPrompt: sysPrompt, Skills: skills, Commands: cmds, Hooks: resolvedHooks, Registry: reg}
|
||||
assembly := &ReusedAssembly{
|
||||
SystemPrompt: sysPrompt,
|
||||
Skills: skills,
|
||||
Commands: cmds,
|
||||
Hooks: resolvedHooks,
|
||||
Registry: reg,
|
||||
ImplicitSkillInvocation: implicitSkillInvocation,
|
||||
}
|
||||
return finalizeBuildResult(&BuildResult{Controller: ctrl, Snapshot: snap, Runtime: runtimeSet, Owner: owner, Extensions: extensionMgr, Dispatcher: extensionDispatcher, ExtensionUI: extUIHub, ProviderResolver: providerResolver, BaseProviderResolver: baseResolver, Assembly: assembly}, !opts.deferPublish), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
|
||||
opts.Graph = fromGraph
|
||||
|
||||
// Prefer subgraph-classified rebuild when previous assembly is available.
|
||||
if previous != nil {
|
||||
if previous != nil && !opts.ForceFullRebuild {
|
||||
if res, handled, err := tryRebuildSubgraph(ctx, old, previous, opts, m); handled {
|
||||
return res, err
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ import (
|
||||
// RuntimeReload is previous-generation state for incremental sidecar adoption
|
||||
// and subgraph-classified rebuild.
|
||||
type RuntimeReload struct {
|
||||
Extensions *sidecar.Manager
|
||||
Graph *extension.DependencyGraph
|
||||
Generation uint64
|
||||
// ForceFullRebuild bypasses extension subgraph reuse when config-owned
|
||||
// skills/providers may have changed without changing the native graph.
|
||||
ForceFullRebuild bool
|
||||
Extensions *sidecar.Manager
|
||||
Graph *extension.DependencyGraph
|
||||
Generation uint64
|
||||
// Owner is reused across one controller/session rebuild lineage. Cold builds
|
||||
// leave it nil and receive a fresh isolated owner.
|
||||
Owner *extension.RuntimeOwner
|
||||
|
||||
@@ -1,15 +1,98 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestBuildRuntimeDisablesImplicitSkillInvocation(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
writeRuntimeFixture(t, dir)
|
||||
configPath := filepath.Join(dir, "reasonix.toml")
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture config: %v", err)
|
||||
}
|
||||
content = append(content, []byte("\n[skills]\ndisable_implicit_invocation = true\n")...)
|
||||
if err := os.WriteFile(configPath, content, 0o644); err != nil {
|
||||
t.Fatalf("write skills config: %v", err)
|
||||
}
|
||||
res := buildRuntimeFixture(t)
|
||||
if res.Controller.ImplicitSkillInvocationEnabled() {
|
||||
t.Fatal("controller should disable implicit skill invocation")
|
||||
}
|
||||
if res.Assembly == nil || res.Assembly.ImplicitSkillInvocation {
|
||||
t.Fatal("reused assembly should record implicit skill invocation as disabled")
|
||||
}
|
||||
if strings.Contains(res.Snapshot.SystemPrompt(), "One-liner index") {
|
||||
t.Fatal("skill index should not be provider-visible when implicit invocation is disabled")
|
||||
}
|
||||
for _, entry := range res.Controller.ToolContractEntries() {
|
||||
switch entry.Name {
|
||||
case "run_skill", "read_skill", "read_only_skill", "install_skill":
|
||||
t.Fatalf("skill tool %q should not be exposed to the model", entry.Name)
|
||||
}
|
||||
}
|
||||
if _, ok := res.Controller.RunSkill("/explore inspect"); !ok {
|
||||
t.Fatal("explicit /skill invocation should remain available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildFromForceFullRebuildRefreshesSkillPolicy(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
writeRuntimeFixture(t, dir)
|
||||
configPath := filepath.Join(dir, "reasonix.toml")
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture config: %v", err)
|
||||
}
|
||||
content = append(content, []byte("\n[skills]\ndisable_implicit_invocation = true\n")...)
|
||||
if err := os.WriteFile(configPath, content, 0o644); err != nil {
|
||||
t.Fatalf("write disabled skill policy: %v", err)
|
||||
}
|
||||
previous, err := BuildRuntime(context.Background(), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("initial BuildRuntime: %v", err)
|
||||
}
|
||||
if previous.Controller.ImplicitSkillInvocationEnabled() {
|
||||
t.Fatal("initial controller should disable implicit skill invocation")
|
||||
}
|
||||
content = bytes.Replace(content, []byte("disable_implicit_invocation = true"), []byte("disable_implicit_invocation = false"), 1)
|
||||
if err := os.WriteFile(configPath, content, 0o644); err != nil {
|
||||
t.Fatalf("write enabled skill policy: %v", err)
|
||||
}
|
||||
res, err := RebuildFrom(context.Background(), previous, Options{
|
||||
RuntimeReload: RuntimeReload{ForceFullRebuild: true},
|
||||
})
|
||||
if err != nil {
|
||||
previous.Controller.Close()
|
||||
t.Fatalf("forced RebuildFrom: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
res.Controller.Close()
|
||||
})
|
||||
if res.Controller == previous.Controller {
|
||||
t.Fatal("forced rebuild reused the previous controller")
|
||||
}
|
||||
if !res.Controller.ImplicitSkillInvocationEnabled() {
|
||||
t.Fatal("forced rebuild did not refresh the enabled skill policy")
|
||||
}
|
||||
previous.Controller.Close()
|
||||
}
|
||||
|
||||
// writeRuntimeFixture writes the minimal deterministic config the runtime
|
||||
// tests share: a resolvable model, a fixed base system prompt, and the
|
||||
// environment probe section disabled (it embeds machine-specific data).
|
||||
|
||||
@@ -76,13 +76,38 @@ type Config struct {
|
||||
pluginPackageOwners map[string]string
|
||||
pluginPackageSkillOwners map[string][]string
|
||||
pluginPackageAgentOwners map[string][]string
|
||||
editLoadErr error
|
||||
// explicitProjectSkillKeys records project-level skill fields that the
|
||||
// settings UI intentionally owns even when their value equals the built-in
|
||||
// default. It is transient edit metadata and is never serialized directly.
|
||||
explicitProjectSkillKeys map[string]bool
|
||||
editLoadErr error
|
||||
// loadWarnings are non-fatal issues observed while loading config (corrupt
|
||||
// user/project files recovered via last-known-good or defaults). They never
|
||||
// rewrite the original file; the UI may surface them for doctor repair.
|
||||
loadWarnings []string
|
||||
}
|
||||
|
||||
// KeepProjectSkillKey marks a skill field as an intentional project override.
|
||||
// An explicit empty/false project value must still be written so it can
|
||||
// override a non-default user setting in the layered configuration.
|
||||
func (c *Config) KeepProjectSkillKey(key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
switch key {
|
||||
case "paths", "excluded_paths", "disabled_skills", "disable_implicit_invocation", "max_depth":
|
||||
default:
|
||||
return fmt.Errorf("unknown project skill key %q", key)
|
||||
}
|
||||
if c.explicitProjectSkillKeys == nil {
|
||||
c.explicitProjectSkillKeys = make(map[string]bool)
|
||||
}
|
||||
c.explicitProjectSkillKeys[key] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) keepsProjectSkillKey(key string) bool {
|
||||
return c != nil && c.explicitProjectSkillKeys[key]
|
||||
}
|
||||
|
||||
type promptFileSource uint8
|
||||
|
||||
const (
|
||||
@@ -1006,12 +1031,22 @@ func (c *Config) NetworkProxyMode() string {
|
||||
// the global roots. ExcludedPaths hides matching discovery roots without deleting
|
||||
// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills
|
||||
// hides named skills from the agent prompt, slash invocation, and skill tools
|
||||
// while keeping them manageable.
|
||||
// while keeping them manageable. DisableImplicitInvocation keeps skills
|
||||
// discoverable to the host for explicit /skill use and management, but hides
|
||||
// their index and model-facing invocation tools.
|
||||
type SkillsConfig struct {
|
||||
Paths []string `toml:"paths"`
|
||||
ExcludedPaths []string `toml:"excluded_paths"`
|
||||
DisabledSkills []string `toml:"disabled_skills"`
|
||||
MaxDepth int `toml:"max_depth"`
|
||||
Paths []string `toml:"paths"`
|
||||
ExcludedPaths []string `toml:"excluded_paths"`
|
||||
DisabledSkills []string `toml:"disabled_skills"`
|
||||
DisableImplicitInvocation bool `toml:"disable_implicit_invocation"`
|
||||
MaxDepth int `toml:"max_depth"`
|
||||
}
|
||||
|
||||
// ImplicitSkillInvocationEnabled reports whether the model may discover and
|
||||
// invoke skills without an explicit user slash command. The zero value keeps
|
||||
// the historical default enabled for old configs.
|
||||
func (c *Config) ImplicitSkillInvocationEnabled() bool {
|
||||
return c == nil || !c.Skills.DisableImplicitInvocation
|
||||
}
|
||||
|
||||
// SkillCustomPaths returns the configured custom skill roots with ${VAR}
|
||||
|
||||
+92
-3
@@ -784,6 +784,25 @@ func (c *Config) ExcludeSkillPath(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSkillPathEnabled enables or disables a skill discovery root without
|
||||
// deleting its configured path. Disabled roots are recorded in excluded_paths
|
||||
// and can be restored without asking the user to browse for the folder again.
|
||||
func (c *Config) SetSkillPathEnabled(path string, enabled bool) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("skill path: empty path")
|
||||
}
|
||||
want := CanonicalSkillPath(path)
|
||||
if want == "" {
|
||||
return fmt.Errorf("skill path: empty path")
|
||||
}
|
||||
if enabled {
|
||||
c.removeExcludedSkillPath(want)
|
||||
return nil
|
||||
}
|
||||
return c.ExcludeSkillPath(path)
|
||||
}
|
||||
|
||||
func (c *Config) removeExcludedSkillPath(want string) {
|
||||
next := c.Skills.ExcludedPaths[:0]
|
||||
for _, existing := range c.Skills.ExcludedPaths {
|
||||
@@ -824,6 +843,13 @@ func (c *Config) SetSkillEnabled(name string, enabled bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSkillImplicitInvocation controls whether skills are exposed to the model
|
||||
// for automatic discovery and invocation. Explicit /skill commands remain
|
||||
// available regardless of this setting.
|
||||
func (c *Config) SetSkillImplicitInvocation(enabled bool) {
|
||||
c.Skills.DisableImplicitInvocation = !enabled
|
||||
}
|
||||
|
||||
// CanonicalSkillPath expands env vars, ~ and relative segments to an absolute
|
||||
// cleaned path for comparing skill roots. On Windows it folds case so paths that
|
||||
// differ only in casing dedupe. Use only for comparison, never as stored config.
|
||||
@@ -841,6 +867,12 @@ func CanonicalSkillPath(path string) string {
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
// Resolve existing paths before cleaning so Windows 8.3 short names and
|
||||
// long names compare identically. A missing configured path still falls
|
||||
// back to the absolute lexical form used for persistence and diagnostics.
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = resolved
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(path)
|
||||
@@ -1605,11 +1637,12 @@ func (c *Config) saveProjectIncrementalResolved(logicalPath, resolvedPath string
|
||||
}
|
||||
removePlugins := len(tomlPluginsForScope(c.Plugins, RenderScopeProject)) == 0 && tomlBodyHasSection(body, "plugins")
|
||||
removeSandboxBash := shouldRemoveIneffectiveProjectSandboxBash(body, c)
|
||||
removeSkills := projectSkillsKeysToRemove(body, c)
|
||||
_, hasLegacyDesktopAutoGuard := tomlSectionKeyValue(body, "desktop", "default_auto_recovery_checkpoint")
|
||||
_, hasRetiredAgentAutoGuard := tomlSectionKeyValue(body, "agent", "auto_recovery_checkpoint")
|
||||
removeRetiredAutoGuard := hasLegacyDesktopAutoGuard || hasRetiredAgentAutoGuard
|
||||
writeProviderAccess := c.Desktop.ProviderAccess != nil
|
||||
if strings.TrimSpace(delta) == "" && !removePlugins && !removeSandboxBash && !removeRetiredAutoGuard && !writeProviderAccess {
|
||||
if strings.TrimSpace(delta) == "" && !removePlugins && !removeSandboxBash && !removeSkills && !removeRetiredAutoGuard && !writeProviderAccess {
|
||||
return nil // no changes to write
|
||||
}
|
||||
|
||||
@@ -1623,6 +1656,9 @@ func (c *Config) saveProjectIncrementalResolved(logicalPath, resolvedPath string
|
||||
if removeSandboxBash {
|
||||
body = removeTOMLSectionKey(body, "sandbox", "bash")
|
||||
}
|
||||
if removeSkills {
|
||||
body = cleanupProjectSkillsKeys(body, c)
|
||||
}
|
||||
if removeRetiredAutoGuard {
|
||||
body = removeTOMLSectionKey(body, "desktop", "default_auto_recovery_checkpoint")
|
||||
body = removeTOMLSectionKey(body, "agent", "auto_recovery_checkpoint")
|
||||
@@ -1633,6 +1669,58 @@ func (c *Config) saveProjectIncrementalResolved(logicalPath, resolvedPath string
|
||||
return writeConfigFileResolved(resolvedPath, body, configFilePerm(logicalPath))
|
||||
}
|
||||
|
||||
// projectSkillsKeysToRemove reports whether an existing project [skills]
|
||||
// section contains a field whose current edit value is the built-in default.
|
||||
// Project saves are incremental, so an empty RenderTOMLProjectDelta cannot
|
||||
// remove a stale override without this explicit cleanup pass.
|
||||
func projectSkillsKeysToRemove(body string, c *Config) bool {
|
||||
if c == nil || !tomlBodyHasSection(body, "skills") {
|
||||
return false
|
||||
}
|
||||
for _, key := range projectSkillKeys {
|
||||
if projectSkillKeyIsDefault(c, key) {
|
||||
if _, ok := tomlSectionKeyValue(body, "skills", key); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var projectSkillKeys = [...]string{"paths", "excluded_paths", "disabled_skills", "disable_implicit_invocation", "max_depth"}
|
||||
|
||||
func projectSkillKeyIsDefault(c *Config, key string) bool {
|
||||
if c != nil && c.keepsProjectSkillKey(key) {
|
||||
return false
|
||||
}
|
||||
switch key {
|
||||
case "paths":
|
||||
return len(c.Skills.Paths) == 0
|
||||
case "excluded_paths":
|
||||
return len(c.Skills.ExcludedPaths) == 0
|
||||
case "disabled_skills":
|
||||
return len(c.Skills.DisabledSkills) == 0
|
||||
case "disable_implicit_invocation":
|
||||
return !c.Skills.DisableImplicitInvocation
|
||||
case "max_depth":
|
||||
return c.Skills.MaxDepth == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupProjectSkillsKeys(body string, c *Config) string {
|
||||
if c == nil {
|
||||
return body
|
||||
}
|
||||
for _, key := range projectSkillKeys {
|
||||
if projectSkillKeyIsDefault(c, key) {
|
||||
body = removeTOMLSectionKey(body, "skills", key)
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func shouldRemoveIneffectiveProjectSandboxBash(body string, c *Config) bool {
|
||||
if c == nil || runtimeGOOS != "windows" {
|
||||
return false
|
||||
@@ -2171,15 +2259,16 @@ func removeTOMLSectionKey(body, sectionName, key string) string {
|
||||
if sectionIdx < 0 || keyIdx < 0 {
|
||||
return body
|
||||
}
|
||||
keyEndIdx := tomlValueEndSpan(spans, keyIdx)
|
||||
for i := sectionIdx + 1; i < endIdx; i++ {
|
||||
if i == keyIdx {
|
||||
if i >= keyIdx && i <= keyEndIdx {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(spans[i].text)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
return body[:spans[keyIdx].start] + body[spans[keyIdx].end:]
|
||||
return body[:spans[keyIdx].start] + body[spans[keyEndIdx].end:]
|
||||
}
|
||||
sectionStart := spans[sectionIdx].start
|
||||
sectionEnd := len(body)
|
||||
|
||||
@@ -1039,6 +1039,29 @@ func TestSkillPathMutators(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillPathEnabledMutatorPreservesConfiguredPath(t *testing.T) {
|
||||
c := Default()
|
||||
root := t.TempDir()
|
||||
if err := c.AddSkillPath(root); err != nil {
|
||||
t.Fatalf("add skill path: %v", err)
|
||||
}
|
||||
if err := c.SetSkillPathEnabled(root, false); err != nil {
|
||||
t.Fatalf("disable skill path: %v", err)
|
||||
}
|
||||
if len(c.Skills.Paths) != 1 || filepath.Clean(c.Skills.Paths[0]) != filepath.Clean(root) {
|
||||
t.Fatalf("paths after disable = %v, want %q preserved", c.Skills.Paths, root)
|
||||
}
|
||||
if len(c.Skills.ExcludedPaths) != 1 || CanonicalSkillPath(c.Skills.ExcludedPaths[0]) != CanonicalSkillPath(root) {
|
||||
t.Fatalf("excluded paths after disable = %v, want %q", c.Skills.ExcludedPaths, root)
|
||||
}
|
||||
if err := c.SetSkillPathEnabled(root, true); err != nil {
|
||||
t.Fatalf("enable skill path: %v", err)
|
||||
}
|
||||
if len(c.Skills.Paths) != 1 || len(c.Skills.ExcludedPaths) != 0 {
|
||||
t.Fatalf("state after enable = paths %v excluded %v", c.Skills.Paths, c.Skills.ExcludedPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillEnabledMutator(t *testing.T) {
|
||||
c := Default()
|
||||
if err := c.SetSkillEnabled("review", false); err != nil {
|
||||
@@ -1064,6 +1087,21 @@ func TestSkillEnabledMutator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillImplicitInvocationMutator(t *testing.T) {
|
||||
c := Default()
|
||||
if !c.ImplicitSkillInvocationEnabled() {
|
||||
t.Fatal("implicit skill invocation should be enabled by default")
|
||||
}
|
||||
c.SetSkillImplicitInvocation(false)
|
||||
if c.ImplicitSkillInvocationEnabled() || !c.Skills.DisableImplicitInvocation {
|
||||
t.Fatal("implicit skill invocation should be disabled")
|
||||
}
|
||||
c.SetSkillImplicitInvocation(true)
|
||||
if !c.ImplicitSkillInvocationEnabled() || c.Skills.DisableImplicitInvocation {
|
||||
t.Fatal("implicit skill invocation should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginMutators(t *testing.T) {
|
||||
c := Default()
|
||||
|
||||
@@ -2007,6 +2045,178 @@ func TestSaveToExistingProjectPersistsTopLevelDelta(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveToExistingProjectRemovesResetSkillOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
set func(*Config)
|
||||
reset func(*Config)
|
||||
}{
|
||||
{name: "paths", key: "paths", set: func(c *Config) { c.Skills.Paths = []string{"project-skills"} }, reset: func(c *Config) { c.Skills.Paths = nil }},
|
||||
{name: "excluded paths", key: "excluded_paths", set: func(c *Config) { c.Skills.ExcludedPaths = []string{"project-skills"} }, reset: func(c *Config) { c.Skills.ExcludedPaths = nil }},
|
||||
{name: "disabled skills", key: "disabled_skills", set: func(c *Config) { c.Skills.DisabledSkills = []string{"review"} }, reset: func(c *Config) { c.Skills.DisabledSkills = nil }},
|
||||
{name: "implicit invocation", key: "disable_implicit_invocation", set: func(c *Config) { c.Skills.DisableImplicitInvocation = true }, reset: func(c *Config) { c.Skills.DisableImplicitInvocation = false }},
|
||||
{name: "max depth", key: "max_depth", set: func(c *Config) { c.Skills.MaxDepth = 2 }, reset: func(c *Config) { c.Skills.MaxDepth = 0 }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
|
||||
cfg := Default()
|
||||
tt.set(cfg)
|
||||
if err := cfg.SaveTo(projectPath); err != nil {
|
||||
t.Fatalf("initial SaveTo: %v", err)
|
||||
}
|
||||
loaded, err := LoadForEditReadOnlyStrict(projectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load project config: %v", err)
|
||||
}
|
||||
tt.reset(loaded)
|
||||
if err := loaded.SaveTo(projectPath); err != nil {
|
||||
t.Fatalf("reset SaveTo: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(projectPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), tt.key+" =") {
|
||||
t.Fatalf("reset left stale %s override:\n%s", tt.key, body)
|
||||
}
|
||||
fresh, err := LoadForEditReadOnlyStrict(projectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reload reset project config: %v", err)
|
||||
}
|
||||
if fresh.Skills.Paths != nil || fresh.Skills.ExcludedPaths != nil || fresh.Skills.DisabledSkills != nil || fresh.Skills.DisableImplicitInvocation || fresh.Skills.MaxDepth != 0 {
|
||||
t.Fatalf("reloaded skills retained reset override: %+v", fresh.Skills)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveToExistingProjectPreservesExplicitSkillDefaults(t *testing.T) {
|
||||
projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
|
||||
if err := os.WriteFile(projectPath, []byte("[skills]\npaths = [\"project-skills\"]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadForEditReadOnlyStrict(projectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load project config: %v", err)
|
||||
}
|
||||
cfg.Skills.Paths = nil
|
||||
cfg.Skills.ExcludedPaths = nil
|
||||
cfg.Skills.DisabledSkills = nil
|
||||
cfg.Skills.DisableImplicitInvocation = false
|
||||
cfg.Skills.MaxDepth = 0
|
||||
for _, key := range projectSkillKeys {
|
||||
if err := cfg.KeepProjectSkillKey(key); err != nil {
|
||||
t.Fatalf("keep %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
if err := cfg.SaveTo(projectPath); err != nil {
|
||||
t.Fatalf("save explicit project defaults: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(projectPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
for _, want := range []string{
|
||||
"paths = []",
|
||||
"excluded_paths = []",
|
||||
"disabled_skills = []",
|
||||
"disable_implicit_invocation = false",
|
||||
"max_depth = 0",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("explicit project default %q missing from:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnrelatedProjectSavePreservesExplicitDefaultSkillOverride(t *testing.T) {
|
||||
projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
|
||||
if err := os.WriteFile(projectPath, []byte("[skills]\ndisable_implicit_invocation = false\n\n[permissions]\nmode = \"ask\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadForEditReadOnlyStrict(projectPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.SetDefaultModel("deepseek-pro"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.SaveTo(projectPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(projectPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "disable_implicit_invocation = false") {
|
||||
t.Fatalf("explicit default override was removed:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitProjectSkillDefaultOverridesUserConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
project := t.TempDir()
|
||||
user := Default()
|
||||
user.Skills.DisableImplicitInvocation = true
|
||||
if err := user.SaveTo(UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
projectPath := filepath.Join(project, "reasonix.toml")
|
||||
if err := os.WriteFile(projectPath, []byte("[skills]\ndisable_implicit_invocation = true\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadForEditReadOnlyStrict(projectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load project config: %v", err)
|
||||
}
|
||||
cfg.SetSkillImplicitInvocation(true)
|
||||
if err := cfg.KeepProjectSkillKey("disable_implicit_invocation"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.SaveTo(projectPath); err != nil {
|
||||
t.Fatalf("save project override: %v", err)
|
||||
}
|
||||
effective, err := LoadForRootReadOnly(project)
|
||||
if err != nil {
|
||||
t.Fatalf("load effective config: %v", err)
|
||||
}
|
||||
if !effective.ImplicitSkillInvocationEnabled() {
|
||||
t.Fatalf("project explicit false did not override user config: %+v", effective.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveToExistingProjectRemovesMultilineSkillArray(t *testing.T) {
|
||||
projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
|
||||
original := "[skills]\npaths = [\n \"project-skills\",\n \"shared-skills\",\n]\n\n[permissions]\nmode = \"ask\"\n"
|
||||
if err := os.WriteFile(projectPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadForEditReadOnlyStrict(projectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load project config: %v", err)
|
||||
}
|
||||
cfg.Skills.Paths = nil
|
||||
if err := cfg.SaveTo(projectPath); err != nil {
|
||||
t.Fatalf("reset multiline paths: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(projectPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), "project-skills") || strings.Contains(string(body), "shared-skills") {
|
||||
t.Fatalf("multiline skill array was only partially removed:\n%s", body)
|
||||
}
|
||||
if err := ValidateFile(projectPath); err != nil {
|
||||
t.Fatalf("reset project config is invalid TOML: %v\n%s", err, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveToExistingProjectPersistsProviderAccessWithoutReplacingDesktopSection(t *testing.T) {
|
||||
projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
|
||||
if err := os.WriteFile(projectPath, []byte("[desktop]\nlegacy_preference = \"keep\"\n\n[permissions]\nallow = [\"Bash(go test:*)\"]\n"), 0o644); err != nil {
|
||||
|
||||
+35
-1
@@ -334,6 +334,19 @@ func ConfigFileDefinesCompactRatio(path string) bool {
|
||||
return tomlFileDefinesKey(path, "agent", "compact_ratio")
|
||||
}
|
||||
|
||||
// ConfigFileDefinesSkillKey reports whether a project or user TOML file
|
||||
// explicitly owns one of the supported [skills] settings. Desktop settings use
|
||||
// this narrow provenance check to edit the file that wins at runtime instead
|
||||
// of persisting a shadowed value to the global config.
|
||||
func ConfigFileDefinesSkillKey(path, key string) bool {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "paths", "excluded_paths", "disabled_skills", "disable_implicit_invocation", "max_depth":
|
||||
return tomlFileDefinesKey(path, "skills", key)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard
|
||||
// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro
|
||||
// SKU, leaving users unable to switch to it. In-memory only — the user's file is
|
||||
@@ -735,9 +748,11 @@ func loadForEditStrict(path string, loadCredentials, persistMigrations bool) (*C
|
||||
loadDotEnvForEditPath(path)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := mergeFile(cfg, path); err != nil {
|
||||
meta, err := mergeFileSnapshot(cfg, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
markExplicitDefaultProjectSkillKeys(cfg, path, meta)
|
||||
changed := normalizeConfigForEdit(cfg)
|
||||
if persistMigrations && changed && strings.TrimSpace(path) != "" {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
@@ -749,6 +764,25 @@ func loadForEditStrict(path string, loadCredentials, persistMigrations bool) (*C
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// markExplicitDefaultProjectSkillKeys preserves project skill fields that are
|
||||
// explicitly present in a file but equal the built-in default. Without this
|
||||
// transient provenance, saving an unrelated project setting would mistake an
|
||||
// intentional `false`/empty override for a stale delta and remove it.
|
||||
func markExplicitDefaultProjectSkillKeys(c *Config, path string, meta toml.MetaData) {
|
||||
if c == nil || isUserConfigPath(path) {
|
||||
return
|
||||
}
|
||||
for _, key := range projectSkillKeys {
|
||||
if !meta.IsDefined("skills", key) || !projectSkillKeyIsDefault(c, key) {
|
||||
continue
|
||||
}
|
||||
if c.explicitProjectSkillKeys == nil {
|
||||
c.explicitProjectSkillKeys = make(map[string]bool)
|
||||
}
|
||||
c.explicitProjectSkillKeys[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeConfigForEdit(cfg *Config) bool {
|
||||
normalizePluginCommandLines(cfg)
|
||||
normalizeLegacyEffort(cfg)
|
||||
|
||||
@@ -433,6 +433,11 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
} else {
|
||||
b.WriteString("# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n")
|
||||
}
|
||||
if c.Skills.DisableImplicitInvocation {
|
||||
b.WriteString("disable_implicit_invocation = true # keep /skill explicit; hide skill discovery and tools from the model\n")
|
||||
} else {
|
||||
b.WriteString("# disable_implicit_invocation = false # keep skills available for automatic model invocation\n")
|
||||
}
|
||||
if c.Skills.MaxDepth != 0 {
|
||||
fmt.Fprintf(&b, "max_depth = %d # nested scan depth; default 3, set 1 for legacy root-only discovery\n", c.SkillMaxDepth())
|
||||
} else {
|
||||
@@ -1133,18 +1138,25 @@ func RenderTOMLProjectDelta(c *Config) string {
|
||||
}
|
||||
|
||||
// [skills]
|
||||
if !reflect.DeepEqual(c.Skills, d.Skills) {
|
||||
if !reflect.DeepEqual(c.Skills, d.Skills) || len(c.explicitProjectSkillKeys) > 0 {
|
||||
b.WriteString("[skills]\n")
|
||||
if len(c.Skills.Paths) > 0 {
|
||||
if len(c.Skills.Paths) > 0 || c.keepsProjectSkillKey("paths") {
|
||||
fmt.Fprintf(&b, "paths = %s\n", renderStringArray(c.Skills.Paths))
|
||||
}
|
||||
if len(c.Skills.ExcludedPaths) > 0 {
|
||||
if len(c.Skills.ExcludedPaths) > 0 || c.keepsProjectSkillKey("excluded_paths") {
|
||||
fmt.Fprintf(&b, "excluded_paths = %s\n", renderStringArray(c.Skills.ExcludedPaths))
|
||||
}
|
||||
if c.Skills.MaxDepth != 0 {
|
||||
fmt.Fprintf(&b, "max_depth = %d\n", c.SkillMaxDepth())
|
||||
if c.Skills.DisableImplicitInvocation || c.keepsProjectSkillKey("disable_implicit_invocation") {
|
||||
fmt.Fprintf(&b, "disable_implicit_invocation = %t\n", c.Skills.DisableImplicitInvocation)
|
||||
}
|
||||
if disabled := c.DisabledSkillNames(); len(disabled) > 0 {
|
||||
if c.Skills.MaxDepth != 0 || c.keepsProjectSkillKey("max_depth") {
|
||||
depth := c.Skills.MaxDepth
|
||||
if depth != 0 {
|
||||
depth = c.SkillMaxDepth()
|
||||
}
|
||||
fmt.Fprintf(&b, "max_depth = %d\n", depth)
|
||||
}
|
||||
if disabled := c.DisabledSkillNames(); len(disabled) > 0 || c.keepsProjectSkillKey("disabled_skills") {
|
||||
fmt.Fprintf(&b, "disabled_skills = %s\n\n", renderStringArray(disabled))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
orig.Skills.Paths = []string{"~/my-skills", "../shared/skills"}
|
||||
orig.Skills.ExcludedPaths = []string{"~/.agents/skills"}
|
||||
orig.Skills.DisabledSkills = []string{"review", "explore"}
|
||||
orig.Skills.DisableImplicitInvocation = true
|
||||
orig.Skills.MaxDepth = 2
|
||||
orig.Bot.ToolApprovalMode = "auto"
|
||||
orig.Bot.Control = BotControlConfig{Enabled: true, Addr: "127.0.0.1:39001", TokenEnv: "BOT_CONTROL_TOKEN"}
|
||||
@@ -517,6 +518,9 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
if len(got.Skills.DisabledSkills) != 2 || got.Skills.DisabledSkills[0] != "review" || got.Skills.DisabledSkills[1] != "explore" {
|
||||
t.Errorf("skills.disabled_skills = %v", got.Skills.DisabledSkills)
|
||||
}
|
||||
if !got.Skills.DisableImplicitInvocation || got.ImplicitSkillInvocationEnabled() {
|
||||
t.Error("skills.disable_implicit_invocation was not preserved")
|
||||
}
|
||||
if got.SkillMaxDepth() != 2 {
|
||||
t.Errorf("skills.max_depth = %d, want 2", got.SkillMaxDepth())
|
||||
}
|
||||
|
||||
@@ -115,12 +115,13 @@ type Controller struct {
|
||||
// skills owns the session's discovered skills (enabled subset, full set, and
|
||||
// the reloadable stores) — the skills slice of the Capabilities concern. See
|
||||
// skill.go.
|
||||
skills skillSet
|
||||
skillRunner skill.SubagentRunner
|
||||
readOnlySkillRunner skill.SubagentRunner
|
||||
skillProfile skill.ProfileResolver
|
||||
slashSkillSeq atomic.Uint64
|
||||
hooks *hook.Runner // session hook runner; nil-safe (no hooks configured)
|
||||
skills skillSet
|
||||
skillRunner skill.SubagentRunner
|
||||
readOnlySkillRunner skill.SubagentRunner
|
||||
skillProfile skill.ProfileResolver
|
||||
disableImplicitSkillInvocation bool
|
||||
slashSkillSeq atomic.Uint64
|
||||
hooks *hook.Runner // session hook runner; nil-safe (no hooks configured)
|
||||
// hookContexts carries one-shot lifecycle hook context into the next real
|
||||
// user turn without changing the cache-stable system prompt.
|
||||
hookContexts []string
|
||||
@@ -442,6 +443,9 @@ type Options struct {
|
||||
AllSkills []skill.Skill
|
||||
SkillStore *skill.Store
|
||||
AllSkillStore *skill.Store
|
||||
// DisableImplicitSkillInvocation controls model-facing discovery only;
|
||||
// explicit /skill commands and management remain host-side capabilities.
|
||||
DisableImplicitSkillInvocation bool
|
||||
// SkillRunner executes a runAs=subagent skill in an isolated child loop.
|
||||
// ReadOnlySkillRunner is reserved for explicitly read-only entry points;
|
||||
// Plan itself is a workflow instruction and uses SkillRunner with the shared
|
||||
@@ -585,6 +589,7 @@ func New(opts Options) *Controller {
|
||||
sessionPath: opts.SessionPath,
|
||||
commands: atomic.Pointer[[]command.Command]{},
|
||||
skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore),
|
||||
disableImplicitSkillInvocation: opts.DisableImplicitSkillInvocation,
|
||||
skillRunner: opts.SkillRunner,
|
||||
readOnlySkillRunner: opts.ReadOnlySkillRunner,
|
||||
skillProfile: opts.SkillProfile,
|
||||
@@ -4794,7 +4799,10 @@ func (c *Controller) ReloadCommands(ctx context.Context) error {
|
||||
default:
|
||||
}
|
||||
cmds, loadErr := command.LoadRoots(config.CommandRootsForRoot(c.workspaceRoot)...)
|
||||
cmdSkills := c.SlashSkills()
|
||||
var cmdSkills []skill.Skill
|
||||
if !c.disableImplicitSkillInvocation {
|
||||
cmdSkills = c.SlashSkills()
|
||||
}
|
||||
|
||||
entries := make([]command.SlashEntry, 0, len(cmdSkills)+len(cmds))
|
||||
for _, sk := range cmdSkills {
|
||||
@@ -4838,6 +4846,13 @@ func (c *Controller) Skills() []skill.Skill {
|
||||
return c.skills.list()
|
||||
}
|
||||
|
||||
// ImplicitSkillInvocationEnabled reports whether skills are exposed to the
|
||||
// model for automatic discovery and invocation. Explicit /skill handling is
|
||||
// independent of this model-facing capability.
|
||||
func (c *Controller) ImplicitSkillInvocationEnabled() bool {
|
||||
return c != nil && !c.disableImplicitSkillInvocation
|
||||
}
|
||||
|
||||
// SlashSkills returns the user-visible skill directory. Plugin skills use
|
||||
// package-qualified names while Skills keeps bare model/run_skill identifiers.
|
||||
func (c *Controller) SlashSkills() []skill.Skill {
|
||||
|
||||
@@ -148,6 +148,7 @@ stalled_warning_seconds = 900 # warn once per background job after this many q
|
||||
# [skills]
|
||||
# paths = ["~/my-skills", "../shared/skills"] # extra "custom"-scope skill roots
|
||||
# excluded_paths = ["~/.agents/skills"] # hide convention roots without deleting folders
|
||||
# disable_implicit_invocation = true # keep /skill explicit; hide skill discovery and tools from the model
|
||||
# disabled_skills = ["review"] # hide from prompt, slash invocation, and skill tools
|
||||
|
||||
# Hooks run shell commands around the loop (PreToolUse / PostToolUse /
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
"limits": {
|
||||
"banner": 0,
|
||||
"commented-code": 0,
|
||||
"complexity": 2056,
|
||||
"essay": 4028,
|
||||
"file-size": 108472,
|
||||
"function-size": 9127,
|
||||
"complexity": 2063,
|
||||
"essay": 4022,
|
||||
"file-size": 108885,
|
||||
"function-size": 9163,
|
||||
"layering": 1,
|
||||
"marker": 0,
|
||||
"narrative": 61,
|
||||
"test-file-size": 68117
|
||||
"test-file-size": 68330
|
||||
},
|
||||
"files": {
|
||||
"cmd/e2ebench/main.go": {
|
||||
"essay": 1,
|
||||
"function-size": 5
|
||||
"function-size": 3
|
||||
},
|
||||
"cmd/e2ebench/mutation.go": {
|
||||
"essay": 1
|
||||
@@ -28,8 +28,8 @@
|
||||
"desktop/app.go": {
|
||||
"complexity": 64,
|
||||
"essay": 90,
|
||||
"file-size": 11538,
|
||||
"function-size": 361
|
||||
"file-size": 11685,
|
||||
"function-size": 388
|
||||
},
|
||||
"desktop/app_autosave_test.go": {
|
||||
"essay": 1
|
||||
@@ -84,13 +84,13 @@
|
||||
"essay": 2
|
||||
},
|
||||
"desktop/frontend/src/App.tsx": {
|
||||
"file-size": 4764
|
||||
"file-size": 4765
|
||||
},
|
||||
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
|
||||
"test-file-size": 19
|
||||
},
|
||||
"desktop/frontend/src/__tests__/capabilities-panel-actions.test.ts": {
|
||||
"test-file-size": 308
|
||||
"test-file-size": 307
|
||||
},
|
||||
"desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx": {
|
||||
"test-file-size": 1701
|
||||
@@ -114,7 +114,7 @@
|
||||
"file-size": 255
|
||||
},
|
||||
"desktop/frontend/src/components/CapabilitiesPanel.tsx": {
|
||||
"file-size": 2633
|
||||
"file-size": 2658
|
||||
},
|
||||
"desktop/frontend/src/components/Composer.tsx": {
|
||||
"file-size": 3963
|
||||
@@ -132,7 +132,7 @@
|
||||
"file-size": 141
|
||||
},
|
||||
"desktop/frontend/src/components/SettingsPanel.tsx": {
|
||||
"file-size": 6664
|
||||
"file-size": 6666
|
||||
},
|
||||
"desktop/frontend/src/components/StatusBar.tsx": {
|
||||
"file-size": 23
|
||||
@@ -156,13 +156,13 @@
|
||||
"file-size": 413
|
||||
},
|
||||
"desktop/frontend/src/lib/bridge.ts": {
|
||||
"file-size": 4453
|
||||
"file-size": 4465
|
||||
},
|
||||
"desktop/frontend/src/lib/crash.ts": {
|
||||
"file-size": 179
|
||||
},
|
||||
"desktop/frontend/src/lib/types.ts": {
|
||||
"file-size": 1369
|
||||
"file-size": 1373
|
||||
},
|
||||
"desktop/frontend/src/lib/useController.ts": {
|
||||
"file-size": 3503
|
||||
@@ -260,7 +260,7 @@
|
||||
"desktop/settings_app.go": {
|
||||
"complexity": 25,
|
||||
"essay": 28,
|
||||
"file-size": 3012,
|
||||
"file-size": 3067,
|
||||
"function-size": 50
|
||||
},
|
||||
"desktop/settings_app_test.go": {
|
||||
@@ -405,8 +405,8 @@
|
||||
},
|
||||
"internal/agent/agent.go": {
|
||||
"complexity": 61,
|
||||
"essay": 113,
|
||||
"file-size": 2719,
|
||||
"essay": 109,
|
||||
"file-size": 2718,
|
||||
"function-size": 124
|
||||
},
|
||||
"internal/agent/ask.go": {
|
||||
@@ -640,10 +640,10 @@
|
||||
"file-size": 216
|
||||
},
|
||||
"internal/boot/boot.go": {
|
||||
"complexity": 287,
|
||||
"complexity": 293,
|
||||
"essay": 102,
|
||||
"file-size": 2204,
|
||||
"function-size": 1818,
|
||||
"file-size": 2212,
|
||||
"function-size": 1839,
|
||||
"narrative": 3
|
||||
},
|
||||
"internal/boot/boot_test.go": {
|
||||
@@ -678,8 +678,7 @@
|
||||
"essay": 4
|
||||
},
|
||||
"internal/boot/rebuild_subgraph.go": {
|
||||
"complexity": 2,
|
||||
"function-size": 17
|
||||
"function-size": 7
|
||||
},
|
||||
"internal/boot/reload.go": {
|
||||
"essay": 32
|
||||
@@ -777,10 +776,10 @@
|
||||
"essay": 15
|
||||
},
|
||||
"internal/cli/chat_tui.go": {
|
||||
"complexity": 302,
|
||||
"complexity": 300,
|
||||
"essay": 110,
|
||||
"file-size": 4555,
|
||||
"function-size": 1200
|
||||
"file-size": 4551,
|
||||
"function-size": 1196
|
||||
},
|
||||
"internal/cli/chat_tui_paste.go": {
|
||||
"essay": 9
|
||||
@@ -830,7 +829,7 @@
|
||||
"internal/cli/mcp.go": {
|
||||
"complexity": 8,
|
||||
"essay": 5,
|
||||
"file-size": 85,
|
||||
"file-size": 66,
|
||||
"function-size": 7
|
||||
},
|
||||
"internal/cli/mcp_manager.go": {
|
||||
@@ -944,8 +943,8 @@
|
||||
"essay": 2
|
||||
},
|
||||
"internal/config/config.go": {
|
||||
"essay": 56,
|
||||
"file-size": 1484,
|
||||
"essay": 58,
|
||||
"file-size": 1519,
|
||||
"narrative": 2
|
||||
},
|
||||
"internal/config/credentials.go": {
|
||||
@@ -966,22 +965,21 @@
|
||||
},
|
||||
"internal/config/edit.go": {
|
||||
"essay": 15,
|
||||
"file-size": 1592
|
||||
"file-size": 1681
|
||||
},
|
||||
"internal/config/edit_test.go": {
|
||||
"test-file-size": 2194
|
||||
"test-file-size": 2404
|
||||
},
|
||||
"internal/config/effort.go": {
|
||||
"complexity": 10,
|
||||
"essay": 11,
|
||||
"function-size": 5
|
||||
"complexity": 9,
|
||||
"essay": 11
|
||||
},
|
||||
"internal/config/effort_test.go": {
|
||||
"essay": 2
|
||||
},
|
||||
"internal/config/load.go": {
|
||||
"essay": 24,
|
||||
"file-size": 1196,
|
||||
"file-size": 1230,
|
||||
"function-size": 53
|
||||
},
|
||||
"internal/config/main_test.go": {
|
||||
@@ -1018,13 +1016,13 @@
|
||||
"file-size": 272
|
||||
},
|
||||
"internal/config/render.go": {
|
||||
"complexity": 240,
|
||||
"complexity": 249,
|
||||
"essay": 2,
|
||||
"file-size": 996,
|
||||
"function-size": 948
|
||||
"file-size": 1008,
|
||||
"function-size": 960
|
||||
},
|
||||
"internal/config/render_test.go": {
|
||||
"test-file-size": 757
|
||||
"test-file-size": 761
|
||||
},
|
||||
"internal/config/vision.go": {
|
||||
"essay": 2
|
||||
@@ -1041,7 +1039,7 @@
|
||||
"internal/control/controller.go": {
|
||||
"complexity": 11,
|
||||
"essay": 170,
|
||||
"file-size": 5334,
|
||||
"file-size": 5349,
|
||||
"function-size": 77,
|
||||
"narrative": 4
|
||||
},
|
||||
@@ -1438,7 +1436,7 @@
|
||||
"test-file-size": 275
|
||||
},
|
||||
"internal/plugin/plugin.go": {
|
||||
"essay": 30,
|
||||
"essay": 29,
|
||||
"file-size": 1315,
|
||||
"function-size": 14
|
||||
},
|
||||
@@ -1511,10 +1509,10 @@
|
||||
"essay": 8
|
||||
},
|
||||
"internal/provider/openai/openai.go": {
|
||||
"complexity": 64,
|
||||
"essay": 42,
|
||||
"file-size": 554,
|
||||
"function-size": 255
|
||||
"complexity": 61,
|
||||
"essay": 40,
|
||||
"file-size": 531,
|
||||
"function-size": 252
|
||||
},
|
||||
"internal/provider/openai/openai_test.go": {
|
||||
"essay": 5,
|
||||
@@ -1524,7 +1522,7 @@
|
||||
"essay": 7
|
||||
},
|
||||
"internal/provider/provider.go": {
|
||||
"essay": 51,
|
||||
"essay": 50,
|
||||
"file-size": 353
|
||||
},
|
||||
"internal/provider/responses/responses.go": {
|
||||
|
||||
Reference in new issue
Block a user