Merge pull request #7981 from SivanCola/fix/skills-settings-consistency

Align Skills settings sources and invocation policy / 统一技能来源与调用策略设置
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-09 04:09:46 +08:00
commit 6122d3a0c6
27 files changed
+1442 -282

No files matched your search

+257 -41
View File
@@ -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 {
+1
View File
@@ -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();
+19 -4
View File
@@ -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 },
+4
View File
@@ -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;
+9 -5
View File
@@ -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",
+9 -5
View File
@@ -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 檔案",
+9 -5
View File
@@ -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 档案",
+53 -39
View File
@@ -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);
+55
View File
@@ -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
View File
@@ -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() {
+6 -5
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+6 -3
View File
@@ -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
+83
View File
@@ -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).
+41 -6
View File
@@ -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
View File
@@ -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)
+210
View File
@@ -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
View File
@@ -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)
+18 -6
View File
@@ -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))
}
}
+4
View File
@@ -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())
}
+22 -7
View File
@@ -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 {
+1
View File
@@ -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 /
+43 -45
View File
@@ -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": {