agent-toggle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ import path from "node:path";
2
+ import { dirSection, hooksSection, listSection, mcpSection, shortPath } from "../core/generic.js";
3
+ import { editJson, exists, getPath, home, readJson, toggled } from "../core/jsonio.js";
4
+ import { t } from "../core/i18n.js";
5
+ import { mcpGroup } from "../core/groups.js";
6
+ import { skillsIn } from "./_skills.js";
7
+ // ------------------------------------------------------------------ Kiro
8
+ const K = process.env.KIRO_HOME ?? home(".kiro");
9
+ export const kiro = {
10
+ id: "kiro",
11
+ name: "Kiro CLI",
12
+ bins: ["kiro-cli", "kiro"],
13
+ // Windows installer folder; elsewhere the kiro-cli binary is enough.
14
+ dirs: process.platform === "win32" ? [path.join(process.env.LOCALAPPDATA ?? home("AppData", "Local"), "Kiro-Cli")] : [],
15
+ sections: [
16
+ mcpSection({
17
+ agent: "kiro",
18
+ files: { user: () => path.join(K, "settings", "mcp.json"), project: (c) => path.join(c.root, ".kiro", "settings", "mcp.json") },
19
+ key: ["mcpServers"],
20
+ disable: { type: "flag", key: "disabled", off: true },
21
+ }),
22
+ dirSection({
23
+ kind: "skills",
24
+ dirs: { user: () => path.join(K, "skills"), project: (c) => path.join(c.root, ".kiro", "skills") },
25
+ entry: { dir: "SKILL.md" },
26
+ note: "note.linkMoved",
27
+ }),
28
+ dirSection({ kind: "commands", title: "title.kiroPrompts", dirs: { user: () => path.join(K, "prompts"), project: (c) => path.join(c.root, ".kiro", "prompts") }, entry: { ext: [".md"] } }),
29
+ dirSection({ kind: "agents", dirs: { user: () => path.join(K, "agents"), project: (c) => path.join(c.root, ".kiro", "agents") }, entry: { ext: [".json"] } }),
30
+ ],
31
+ };
32
+ // ------------------------------------------------------------------ Amp
33
+ /** Amp accepts settings.json or settings.jsonc: write to the one that exists. */
34
+ const ampFile = (dir) => (exists(path.join(dir, "settings.jsonc")) ? path.join(dir, "settings.jsonc") : path.join(dir, "settings.json"));
35
+ const AMP = home(".config", "amp");
36
+ export const amp = {
37
+ id: "amp",
38
+ name: "Amp",
39
+ bins: ["amp"],
40
+ dirs: [".amp", ".config/amp"],
41
+ sections: [
42
+ mcpSection({
43
+ agent: "amp",
44
+ files: { user: () => ampFile(AMP), project: (c) => ampFile(path.join(c.root, ".amp")) },
45
+ key: ["amp.mcpServers"],
46
+ disable: { type: "stash" },
47
+ }),
48
+ dirSection({ kind: "plugins", dirs: { user: () => path.join(AMP, "plugins"), project: (c) => path.join(c.root, ".amp", "plugins") }, entry: { ext: [".ts", ".js"] } }),
49
+ dirSection({
50
+ kind: "skills",
51
+ dirs: { user: () => path.join(AMP, "skills"), project: (c) => path.join(c.root, ".agents", "skills") },
52
+ entry: { dir: "SKILL.md" },
53
+ note: "note.linkMoved",
54
+ }),
55
+ ],
56
+ notes: ["notes.amp.plugins"],
57
+ };
58
+ /** Adapter shared by OpenCode and its forks: same config shape (mcp, agent, permission). */
59
+ function openCodeSections(o) {
60
+ const files = (dir) => o.names.map((f) => path.join(dir, f)).filter(exists);
61
+ const dirOf = (ctx, scope) => (scope === "user" ? o.dir : ctx.root);
62
+ /** File to write to: the one already defining the key, otherwise the last loaded. */
63
+ const target = (dir, keyPath) => {
64
+ const fs = files(dir);
65
+ return fs.slice().reverse().find((f) => getPath(readJson(f), keyPath) !== undefined) ?? fs.at(-1) ?? path.join(dir, o.names[1] ?? o.names[0]);
66
+ };
67
+ const merged = (dir) => files(dir).map((f) => readJson(f));
68
+ const mcp = {
69
+ kind: "mcp",
70
+ scopes: ["user", "project"],
71
+ note: (s, c) => `${files(dirOf(c, s)).map(shortPath).join(" + ") || t("note.noFile")} · mcp.<name>.enabled = false`,
72
+ list(ctx, scope) {
73
+ const m = new Map();
74
+ for (const data of merged(dirOf(ctx, scope)))
75
+ for (const [id, d] of Object.entries(data.mcp ?? {}))
76
+ m.set(id, { id, label: id, hint: d.url ?? [d.command].flat().join(" "), group: mcpGroup(id), enabled: d.enabled !== false });
77
+ return [...m.values()];
78
+ },
79
+ set(ctx, scope, item, enabled) {
80
+ const file = target(dirOf(ctx, scope), ["mcp", item.id]);
81
+ editJson(file, toggled(`MCP ${item.id}`, enabled, shortPath(file)), [{ path: ["mcp", item.id, "enabled"], value: enabled }]);
82
+ },
83
+ };
84
+ const agents = {
85
+ kind: "agents",
86
+ scopes: ["user", "project"],
87
+ note: () => t("note.opencodeAgents"),
88
+ list(ctx, scope) {
89
+ const conf = Object.assign({}, ...merged(dirOf(ctx, scope)).map((d) => d.agent ?? {}));
90
+ const base = scope === "user" ? o.dir : path.join(ctx.root, o.projectDir);
91
+ const found = [path.join(base, "agent"), path.join(base, "agents")].flatMap((d) => dirSection({ kind: "agents", dirs: { user: () => d }, entry: { ext: [".md"] } }).list(ctx, "user").map((i) => i.label));
92
+ const ids = new Set([...(scope === "user" ? o.builtinAgents : []), ...found, ...Object.keys(conf)]);
93
+ return [...ids].sort().map((id) => ({
94
+ id, label: id, group: t(o.builtinAgents.includes(id) ? "group.builtin" : "group.custom"), enabled: conf[id]?.disable !== true,
95
+ }));
96
+ },
97
+ set(ctx, scope, item, enabled) {
98
+ const file = target(dirOf(ctx, scope), ["agent", item.id]);
99
+ editJson(file, toggled(`Agent ${item.id}`, enabled, shortPath(file)), [{ path: ["agent", item.id, "disable"], value: enabled ? undefined : true }]);
100
+ },
101
+ };
102
+ const skills = o.skillPermission
103
+ ? {
104
+ kind: "skills",
105
+ scopes: ["user", "project"],
106
+ note: () => "permission.skill.<name> = \"deny\"",
107
+ list(ctx, scope) {
108
+ const perms = Object.assign({}, ...merged(dirOf(ctx, scope)).map((d) => d.permission?.skill ?? {}));
109
+ const found = scope === "user"
110
+ ? skillsIn(o.userSkillDirs, t("group.user"))
111
+ : skillsIn([`${o.projectDir}/skills`, ".claude/skills", ".agents/skills"].map((d) => path.join(ctx.root, d)), t("group.project"));
112
+ return found.map((s) => ({ id: s.id, label: s.id, hint: s.hint, group: s.group, enabled: perms[s.id] !== "deny" }))
113
+ .sort((a, b) => a.label.localeCompare(b.label));
114
+ },
115
+ set(ctx, scope, item, enabled) {
116
+ const file = target(dirOf(ctx, scope), ["permission", "skill"]);
117
+ editJson(file, toggled(`Skill ${item.id}`, enabled, shortPath(file)), [{ path: ["permission", "skill", item.id], value: enabled ? undefined : "deny" }]);
118
+ },
119
+ }
120
+ : dirSection({ kind: "skills", dirs: { user: () => o.userSkillDirs[0], project: (c) => path.join(c.root, o.projectDir, "skills") }, entry: { dir: "SKILL.md" } });
121
+ return [
122
+ mcp,
123
+ dirSection({ kind: "plugins", dirs: { user: () => path.join(o.dir, "plugins"), project: (c) => path.join(c.root, o.projectDir, "plugins") }, entry: { ext: [".ts", ".js"] } }),
124
+ skills,
125
+ dirSection({ kind: "commands", dirs: { user: () => path.join(o.dir, "command"), project: (c) => path.join(c.root, o.projectDir, "command") }, entry: { ext: [".md"] } }),
126
+ agents,
127
+ ];
128
+ }
129
+ const OC = process.env.OPENCODE_CONFIG_DIR ?? home(".config", "opencode");
130
+ export const opencode = {
131
+ id: "opencode",
132
+ name: "OpenCode",
133
+ bins: ["opencode"],
134
+ dirs: [".config/opencode", ".opencode"],
135
+ sections: openCodeSections({
136
+ dir: OC,
137
+ names: ["config.json", "opencode.json", "opencode.jsonc"],
138
+ projectDir: ".opencode",
139
+ builtinAgents: ["build", "plan", "general", "explore"],
140
+ skillPermission: true,
141
+ userSkillDirs: [path.join(OC, "skills"), home(".claude", "skills"), home(".agents", "skills")],
142
+ }),
143
+ notes: ["notes.opencode.hooks"],
144
+ };
145
+ const KILO = process.env.KILO_CONFIG_DIR ?? home(".config", "kilo");
146
+ export const kilo = {
147
+ id: "kilo",
148
+ name: "Kilo Code CLI",
149
+ bins: ["kilo", "kilocode"],
150
+ dirs: [".config/kilo", ".kilo"],
151
+ sections: openCodeSections({
152
+ dir: KILO,
153
+ names: ["config.json", "opencode.json", "opencode.jsonc", "kilo.json", "kilo.jsonc"],
154
+ projectDir: ".kilo",
155
+ builtinAgents: [],
156
+ skillPermission: false,
157
+ userSkillDirs: [home(".kilo", "skills")],
158
+ }),
159
+ notes: ["notes.opencode.hooks"],
160
+ };
161
+ // ------------------------------------------------------------------ Crush
162
+ const CR = process.env.CRUSH_GLOBAL_CONFIG ?? home(".config", "crush");
163
+ const crushFile = { user: () => path.join(CR, "crush.json"), project: (c) => (exists(path.join(c.root, ".crush.json")) ? path.join(c.root, ".crush.json") : path.join(c.root, "crush.json")) };
164
+ export const crush = {
165
+ id: "crush",
166
+ name: "Crush",
167
+ bins: ["crush"],
168
+ dirs: [".config/crush"],
169
+ sections: [
170
+ mcpSection({ agent: "crush", files: crushFile, key: ["mcp"], disable: { type: "flag", key: "disabled", off: true } }),
171
+ listSection({
172
+ kind: "skills",
173
+ files: crushFile,
174
+ path: ["options", "disabled_skills"],
175
+ discover: (ctx) => [
176
+ ...skillsIn([home(".config", "agents", "skills"), path.join(CR, "skills"), home(".agents", "skills"), home(".claude", "skills")], t("group.user")),
177
+ ...skillsIn([".agents/skills", ".crush/skills", ".claude/skills", ".cursor/skills"].map((d) => path.join(ctx.root, d)), t("group.project")),
178
+ ],
179
+ }),
180
+ hooksSection({ agent: "crush", files: crushFile, key: ["hooks"] }),
181
+ ],
182
+ notes: ["notes.crush.format"],
183
+ };
184
+ // ------------------------------------------------------------------ Warp / Oz
185
+ export const warp = {
186
+ id: "warp",
187
+ name: "Warp / Oz",
188
+ bins: ["oz", "warp"],
189
+ dirs: [],
190
+ sections: [],
191
+ notes: ["notes.warp.state", "notes.warp.none"],
192
+ };
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+ import { dirSection, hooksSection, listSection } from "../core/generic.js";
3
+ import { home, readJson } from "../core/jsonio.js";
4
+ import { mcpGroup } from "../core/groups.js";
5
+ import { t } from "../core/i18n.js";
6
+ import { skillsIn } from "./_skills.js";
7
+ const Q = home(".qwen");
8
+ const settings = { user: () => path.join(Q, "settings.json"), project: (c) => path.join(c.root, ".qwen", "settings.json") };
9
+ /** Command names (ns:cmd) of a commands directory. */
10
+ function commands(dir, group) {
11
+ const s = dirSection({ kind: "commands", dirs: { user: () => dir }, entry: { ext: [".md", ".toml"] }, sep: ":" });
12
+ return s.list({ root: "" }, "user").filter((i) => i.enabled).map((i) => ({ id: i.label, group }));
13
+ }
14
+ export const qwen = {
15
+ id: "qwen",
16
+ name: "Qwen Code",
17
+ bins: ["qwen"],
18
+ dirs: [".qwen"],
19
+ sections: [
20
+ listSection({
21
+ kind: "mcp",
22
+ files: settings,
23
+ path: ["mcp", "excluded"],
24
+ discover: (ctx) => [settings.user(), settings.project(ctx)].flatMap((f, i) => Object.keys(readJson(f).mcpServers ?? {}).map((id) => ({ id, hint: t(i ? "hint.project" : "hint.user"), group: mcpGroup(id) }))),
25
+ }),
26
+ listSection({
27
+ kind: "skills",
28
+ files: settings,
29
+ path: ["skills", "disabled"],
30
+ discover: (ctx) => [
31
+ ...skillsIn([path.join(Q, "skills")], t("group.user")),
32
+ ...skillsIn([path.join(ctx.root, ".qwen", "skills")], t("group.project")),
33
+ ],
34
+ note: "note.qwenUnion",
35
+ }),
36
+ hooksSection({ agent: "qwen", files: settings, key: ["hooks"], disableAll: { path: ["disableAllHooks"], off: true } }),
37
+ listSection({
38
+ kind: "commands",
39
+ files: settings,
40
+ path: ["slashCommands", "disabled"],
41
+ discover: (ctx) => [...commands(path.join(Q, "commands"), t("group.user")), ...commands(path.join(ctx.root, ".qwen", "commands"), t("group.project"))],
42
+ }),
43
+ dirSection({ kind: "agents", dirs: { user: () => path.join(Q, "agents"), project: (c) => path.join(c.root, ".qwen", "agents") }, entry: { ext: [".md"] } }),
44
+ ],
45
+ notes: ["notes.qwen.extensions"],
46
+ };
@@ -0,0 +1,334 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { editJson, exists, HOME, pathKey, state, getPath, isLink, listDir, moveEntry, readJson, toggled, toggleInList, writeText } from "./jsonio.js";
6
+ import { sameEntry, stashed, updateStash } from "./stash.js";
7
+ import { groupOrder, mcpGroup } from "./groups.js";
8
+ import { t } from "./i18n.js";
9
+ import { editYaml, readYaml } from "./yaml.js";
10
+ export const JSON_IO = { read: (f) => readJson(f), edit: editJson };
11
+ export const YAML_IO = { read: readYaml, edit: editYaml };
12
+ const scopesOf = (f) => ["user", "project", "local"].filter((s) => f[s]);
13
+ /** "User" or "Project" group label for a scope. */
14
+ export const scopeGroup = (scope) => t(scope === "user" ? "group.user" : "group.project");
15
+ /** Appends an optional translated note on a new line. */
16
+ const withNote = (text, note) => (note ? `${text}\n${t(note)}` : text);
17
+ export function shortPath(p) {
18
+ return pathKey(p).startsWith(pathKey(HOME)) ? "~" + p.slice(HOME.length).replace(/\\/g, "/") : p;
19
+ }
20
+ export function describeServer(def) {
21
+ if (!def || typeof def !== "object")
22
+ return "";
23
+ const url = def.url ?? def.httpUrl ?? def.serverUrl ?? def.uri;
24
+ if (url)
25
+ return String(url);
26
+ const cmd = Array.isArray(def.command) ? def.command : [def.command, ...(def.args ?? [])];
27
+ return cmd.filter(Boolean).join(" ").slice(0, 70);
28
+ }
29
+ export function sortByGroup(items, order = groupOrder()) {
30
+ return items.sort((a, b) => order.indexOf(a.group) - order.indexOf(b.group) || a.label.localeCompare(b.label));
31
+ }
32
+ // ------------------------------------------------------------------ JSON stash
33
+ /** Removes a JSON object key and keeps it in the stash. */
34
+ export function stashKey(agent, file, keyPath, label, io = JSON_IO) {
35
+ const value = getPath(io.read(file), keyPath);
36
+ if (value === undefined)
37
+ return;
38
+ updateStash(t("change.stashed", { what: label }), (s) => s.entries.push({ agent, file, kind: "keyed", path: keyPath, value }));
39
+ io.edit(file, t("change.removedFrom", { what: label, where: shortPath(file) }), [{ path: keyPath, value: undefined }]);
40
+ }
41
+ export function restoreKey(agent, file, keyPath, label, io = JSON_IO) {
42
+ const entry = stashed(agent, file).find((e) => e.kind === "keyed" && JSON.stringify(e.path) === JSON.stringify(keyPath));
43
+ if (!entry)
44
+ return;
45
+ io.edit(file, t("change.restoredIn", { what: label, where: shortPath(file) }), [{ path: keyPath, value: entry.value }]);
46
+ updateStash(t("change.unstashed", { what: label }), (s) => { s.entries = s.entries.filter((e) => !sameEntry(e, entry)); });
47
+ }
48
+ export function mcpSection(spec) {
49
+ const { agent, files, key, disable } = spec;
50
+ const io = spec.io ?? JSON_IO;
51
+ const isOff = (def, data, name) => disable.type === "flag" ? def?.[disable.key] === disable.off
52
+ : disable.type === "list" ? (getPath(data, disable.path) ?? []).includes(name)
53
+ : false;
54
+ return {
55
+ kind: "mcp",
56
+ scopes: scopesOf(files),
57
+ note: (scope, ctx) => `${shortPath(files[scope](ctx))} · ${spec.note ? t(spec.note) : describeDisable(disable)}`,
58
+ list(ctx, scope) {
59
+ const file = files[scope](ctx);
60
+ const data = io.read(file);
61
+ const items = Object.entries(getPath(data, key) ?? {}).map(([name, def]) => ({
62
+ id: name, label: name, hint: describeServer(def), group: mcpGroup(name), enabled: !isOff(def, data, name),
63
+ }));
64
+ for (const e of stashed(agent, file).filter((e) => e.kind === "keyed"))
65
+ items.push({ id: String(e.path.at(-1)), label: String(e.path.at(-1)), hint: `${describeServer(e.value)} · ${t("hint.stash")}`, group: mcpGroup(String(e.path.at(-1))), enabled: false });
66
+ return sortByGroup(items);
67
+ },
68
+ set(ctx, scope, item, enabled) {
69
+ const file = files[scope](ctx);
70
+ const label = `MCP ${item.id}`;
71
+ if (disable.type === "stash") {
72
+ if (enabled)
73
+ restoreKey(agent, file, [...key, item.id], label, io);
74
+ else
75
+ stashKey(agent, file, [...key, item.id], label, io);
76
+ }
77
+ else if (disable.type === "flag") {
78
+ io.edit(file, toggled(label, enabled, shortPath(file)), [
79
+ // An "enabled" flag is set back to true (some agents require it); other flags are removed.
80
+ { path: [...key, item.id, disable.key], value: enabled ? (disable.off === false ? true : undefined) : disable.off },
81
+ ]);
82
+ }
83
+ else {
84
+ const list = toggleInList(getPath(io.read(file), disable.path), item.id, !enabled);
85
+ io.edit(file, toggled(label, enabled, shortPath(file)), [{ path: disable.path, value: list.length ? list : undefined }]);
86
+ }
87
+ },
88
+ };
89
+ }
90
+ function describeDisable(d) {
91
+ return d.type === "flag" ? t("note.nativeFlag", { flag: `${d.key}: ${JSON.stringify(d.off)}` })
92
+ : d.type === "list" ? t("note.nativeList", { list: d.path.join(".") })
93
+ : t("note.noNativeStash");
94
+ }
95
+ const hookId = (event, group) => `${event}#${createHash("sha1").update(JSON.stringify(group)).digest("hex").slice(0, 10)}`;
96
+ function describeHook(group) {
97
+ const hs = Array.isArray(group.hooks) ? group.hooks : [group];
98
+ const txt = `${group.matcher ? `[${group.matcher}] ` : ""}${hs.map((h) => h.command ?? h.bash ?? h.powershell ?? h.prompt ?? h.url ?? h.type ?? "?").join(" ; ")}`;
99
+ // Absolute paths are shortened to their file name to keep the list readable.
100
+ return txt.replace(/(?:[A-Za-z]:)?(?:[\\/][^\s"'\\/]+)+[\\/]([^\s"'\\/]+)/g, "…/$1").replace(/\s+/g, " ").slice(0, 90);
101
+ }
102
+ /**
103
+ * Hooks in the { event: [entries] } format (Claude, Gemini, Qwen, Cursor…).
104
+ * They can rarely be disabled one by one: the entry is removed and kept in the stash.
105
+ */
106
+ export function hooksSection(spec) {
107
+ const { agent, files, key, disableAll } = spec;
108
+ const io = spec.io ?? JSON_IO;
109
+ const ALL = "__all__";
110
+ return {
111
+ kind: "hooks",
112
+ title: spec.title,
113
+ scopes: scopesOf(files),
114
+ note: (scope, ctx) => `${shortPath(files[scope](ctx))} · ${t("note.hookStash")}`,
115
+ list(ctx, scope) {
116
+ const file = files[scope](ctx);
117
+ const data = io.read(file);
118
+ const items = [];
119
+ if (disableAll)
120
+ items.push({ id: ALL, label: t("label.allHooks"), hint: disableAll.path.join("."), group: t("group.global"), enabled: getPath(data, disableAll.path) !== disableAll.off });
121
+ for (const [event, groups] of Object.entries(getPath(data, key) ?? {}))
122
+ if (Array.isArray(groups))
123
+ for (const g of groups)
124
+ items.push({ id: hookId(event, g), label: describeHook(g), group: event, enabled: true });
125
+ for (const e of stashed(agent, file).filter((e) => e.kind === "array")) {
126
+ const event = String(e.path.at(-1));
127
+ items.push({ id: hookId(event, e.value), label: describeHook(e.value), group: event, enabled: false });
128
+ }
129
+ return items;
130
+ },
131
+ set(ctx, scope, item, enabled) {
132
+ const file = files[scope](ctx);
133
+ if (item.id === ALL && disableAll) {
134
+ io.edit(file, toggled(t("label.allHooksShort"), enabled, shortPath(file)), [{ path: disableAll.path, value: enabled ? undefined : disableAll.off }]);
135
+ return;
136
+ }
137
+ const event = item.id.slice(0, item.id.lastIndexOf("#"));
138
+ const arrPath = [...key, event];
139
+ const current = getPath(io.read(file), arrPath) ?? [];
140
+ const label = `Hook ${event} ${item.label}`;
141
+ if (!enabled) {
142
+ const group = current.find((g) => hookId(event, g) === item.id);
143
+ if (!group)
144
+ return;
145
+ updateStash(t("change.stashed", { what: `Hook ${event}` }), (s) => s.entries.push({ agent, file, kind: "array", path: arrPath, value: group }));
146
+ const rest = current.filter((g) => hookId(event, g) !== item.id);
147
+ io.edit(file, toggled(label, false), [{ path: arrPath, value: rest.length ? rest : undefined }]);
148
+ }
149
+ else {
150
+ const entry = stashed(agent, file).find((e) => e.kind === "array" && hookId(event, e.value) === item.id);
151
+ if (!entry)
152
+ return;
153
+ io.edit(file, toggled(label, true), [{ path: arrPath, value: [...current, entry.value] }]);
154
+ updateStash(t("change.unstashed", { what: `Hook ${event}` }), (s) => { s.entries = s.entries.filter((e) => !sameEntry(e, entry)); });
155
+ }
156
+ },
157
+ };
158
+ }
159
+ function filesIn(dir, exts, rel = "") {
160
+ const out = [];
161
+ for (const d of listDir(path.join(dir, rel))) {
162
+ const r = rel ? `${rel}/${d.name}` : d.name;
163
+ if (d.isDirectory())
164
+ out.push(...filesIn(dir, exts, r));
165
+ else if (exts.some((e) => d.name.endsWith(e)))
166
+ out.push(r);
167
+ }
168
+ return out;
169
+ }
170
+ function entriesIn(dir, entry) {
171
+ if ("ext" in entry)
172
+ return filesIn(dir, entry.ext);
173
+ return listDir(dir)
174
+ .filter((d) => (d.isDirectory() || d.isSymbolicLink()) && !d.name.startsWith(".") && exists(path.join(dir, d.name, entry.dir)))
175
+ .map((d) => d.name);
176
+ }
177
+ /**
178
+ * Items stored as files/directories: a disabled item is moved from <dir>/
179
+ * to <dir>.disabled/, which the agent does not read.
180
+ */
181
+ export function dirSection(spec) {
182
+ const label = (rel) => rel.replace(/\.[^./]+$/, "").replace(/\//g, spec.sep ?? "/");
183
+ return {
184
+ kind: spec.kind,
185
+ title: spec.title,
186
+ scopes: scopesOf(spec.dirs),
187
+ note: (scope, ctx) => withNote(`${shortPath(spec.dirs[scope](ctx))} · ${t("note.movedTo", { dir: `${path.basename(spec.dirs[scope](ctx))}.disabled/` })}`, spec.note),
188
+ list(ctx, scope) {
189
+ const on = spec.dirs[scope](ctx);
190
+ const group = scopeGroup(scope);
191
+ const hint = (d, r) => (isLink(path.join(d, r)) ? t("hint.link") : undefined);
192
+ return [
193
+ ...entriesIn(on, spec.entry).map((r) => ({ id: r, label: label(r), hint: hint(on, r), group, enabled: true })),
194
+ ...entriesIn(`${on}.disabled`, spec.entry).map((r) => ({ id: r, label: label(r), hint: hint(`${on}.disabled`, r), group, enabled: false })),
195
+ ].sort((a, b) => a.label.localeCompare(b.label));
196
+ },
197
+ set(ctx, scope, item, enabled) {
198
+ const on = spec.dirs[scope](ctx);
199
+ const off = `${on}.disabled`;
200
+ const [from, to] = enabled ? [off, on] : [on, off];
201
+ moveEntry(path.join(from, item.id), path.join(to, item.id), toggled(item.label, enabled, shortPath(on)));
202
+ },
203
+ };
204
+ }
205
+ /** Native switch through a list of names (e.g. skills.disabled). */
206
+ export function listSection(spec) {
207
+ const allow = spec.mode === "allow";
208
+ const io = spec.io ?? JSON_IO;
209
+ return {
210
+ kind: spec.kind,
211
+ title: spec.title,
212
+ scopes: scopesOf(spec.files),
213
+ note: (scope, ctx) => withNote(`${shortPath(spec.files[scope](ctx))} · ${t("note.list", { list: spec.path.join(".") })}`, spec.note),
214
+ list(ctx, scope) {
215
+ const list = getPath(io.read(spec.files[scope](ctx)), spec.path) ?? [];
216
+ const seen = new Set();
217
+ const items = [];
218
+ for (const d of spec.discover(ctx, scope)) {
219
+ if (seen.has(d.id))
220
+ continue;
221
+ seen.add(d.id);
222
+ items.push({ id: d.id, label: d.id, hint: d.hint, group: d.group ?? scopeGroup(scope), enabled: allow ? list.includes(d.id) : !list.includes(d.id) });
223
+ }
224
+ for (const n of list)
225
+ if (!seen.has(n))
226
+ items.push({ id: n, label: n, hint: t("hint.notFound"), group: t("group.others"), enabled: allow });
227
+ return spec.kind === "mcp" ? sortByGroup(items) : items.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
228
+ },
229
+ set(ctx, scope, item, enabled) {
230
+ const file = spec.files[scope](ctx);
231
+ const list = toggleInList(getPath(io.read(file), spec.path), item.id, allow ? enabled : !enabled);
232
+ io.edit(file, toggled(item.id, enabled, shortPath(file)), [{ path: spec.path, value: list.length ? list : undefined }]);
233
+ },
234
+ };
235
+ }
236
+ export function dirNames(dir, marker) {
237
+ return listDir(dir)
238
+ .filter((d) => (d.isDirectory() || d.isSymbolicLink()) && !d.name.startsWith(".") && (!marker || fs.existsSync(path.join(dir, d.name, marker))))
239
+ .map((d) => d.name);
240
+ }
241
+ /** Scopes a scope inherits from, from the weakest to itself. */
242
+ const inherits = (scope) => (scope === "local" ? ["user", "project", "local"] : scope === "project" ? ["user", "project"] : ["user"]);
243
+ /** Native { id: boolean } switch with user → project → local inheritance. */
244
+ export function boolMapSection(spec) {
245
+ const io = spec.io ?? JSON_IO;
246
+ const effective = (ctx, scope, id, dflt) => {
247
+ let v = dflt;
248
+ for (const s of inherits(scope)) {
249
+ const f = spec.files[s];
250
+ const x = f ? getPath(io.read(f(ctx)), [...spec.path, id]) : undefined;
251
+ if (typeof x === "boolean")
252
+ v = x;
253
+ }
254
+ return v;
255
+ };
256
+ return {
257
+ kind: spec.kind,
258
+ scopes: scopesOf(spec.files),
259
+ note: (scope, ctx) => withNote(`${shortPath(spec.files[scope](ctx))} · ${spec.path.join(".")}`, spec.note),
260
+ list(ctx, scope) {
261
+ const known = new Map(spec.discover(ctx).map((d) => [d.id, d]));
262
+ for (const s of inherits(scope)) {
263
+ const f = spec.files[s];
264
+ for (const id of Object.keys((f && getPath(io.read(f(ctx)), spec.path)) ?? {}))
265
+ if (!known.has(id))
266
+ known.set(id, { id, hint: t("hint.notInstalled"), enabled: false });
267
+ }
268
+ return [...known.values()]
269
+ .map((d) => ({ id: d.id, label: d.id, hint: d.hint, group: d.group ?? t("group.installed"), enabled: effective(ctx, scope, d.id, d.enabled) }))
270
+ .sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
271
+ },
272
+ set(ctx, scope, item, enabled) {
273
+ const file = spec.files[scope](ctx);
274
+ io.edit(file, toggled(item.id, enabled, shortPath(file)), [{ path: [...spec.path, item.id], value: enabled }]);
275
+ },
276
+ };
277
+ }
278
+ // ------------------------------------------------------------------ agent CLI
279
+ /** Runs the agent's own CLI (honours dry-run). */
280
+ export function runAgentCli(bin, args, what, cwd) {
281
+ state.changes.push(what);
282
+ if (state.dryRun)
283
+ return;
284
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", shell: process.platform === "win32", timeout: 60_000 });
285
+ if (r.status !== 0)
286
+ throw new Error(`${bin} ${args.join(" ")}: ${(r.stderr || r.stdout || r.error?.message || t("err.failed")).trim().slice(0, 300)}`);
287
+ }
288
+ /** JSON output of an agent command, or undefined when unavailable. */
289
+ export function agentCliJson(bin, args, cwd) {
290
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", shell: process.platform === "win32", timeout: 60_000 });
291
+ if (r.status !== 0)
292
+ return undefined;
293
+ try {
294
+ return JSON.parse(r.stdout);
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ }
300
+ // ------------------------------------------------------------------ SKILL.md frontmatter flag
301
+ /** Reads/edits the "disabled: true" flag in the YAML frontmatter of a markdown file. */
302
+ function frontmatterDisabled(text) {
303
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
304
+ return /^disabled:\s*true\s*$/m.test(fm) || /^enabled:\s*false\s*$/m.test(fm);
305
+ }
306
+ function setFrontmatterDisabled(text, disabled) {
307
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
308
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
309
+ const body = (m?.[1] ?? "").split(/\r?\n/).filter((l) => !/^(disabled|enabled):/.test(l) && (m || l));
310
+ if (disabled)
311
+ body.push("disabled: true");
312
+ const fm = body.filter((l, i) => l || i < body.length - 1).join(eol);
313
+ const rest = m ? text.slice(m[0].length) : `${eol}${text}`;
314
+ return fm ? `---${eol}${fm}${eol}---${rest}` : rest.replace(/^\r?\n/, "");
315
+ }
316
+ /** Skills disabled through their own frontmatter (e.g. Cline writes disabled: true). */
317
+ export function frontmatterSkillsSection(spec) {
318
+ return {
319
+ kind: "skills",
320
+ scopes: scopesOf(spec.dirs),
321
+ note: (scope, ctx) => withNote(`${shortPath(spec.dirs[scope](ctx))} · SKILL.md → disabled: true`, spec.note),
322
+ list(ctx, scope) {
323
+ const dir = spec.dirs[scope](ctx);
324
+ return dirNames(dir, "SKILL.md").sort().map((n) => ({
325
+ id: n, label: n, group: scopeGroup(scope),
326
+ enabled: !frontmatterDisabled(fs.readFileSync(path.join(dir, n, "SKILL.md"), "utf8")),
327
+ }));
328
+ },
329
+ set(ctx, scope, item, enabled) {
330
+ const file = path.join(spec.dirs[scope](ctx), item.id, "SKILL.md");
331
+ writeText(file, setFrontmatterDisabled(fs.readFileSync(file, "utf8"), !enabled), toggled(`Skill ${item.id}`, enabled, shortPath(file)));
332
+ },
333
+ };
334
+ }
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readJson, STATE_DIR } from "./jsonio.js";
4
+ import { t } from "./i18n.js";
5
+ export const groupsFile = path.join(STATE_DIR, "groups.json");
6
+ /**
7
+ * Functional groups of MCP servers, keyed by a stable id (the display label
8
+ * comes from the translations). Each pattern is matched against the server's
9
+ * base name (without "claude.ai", "plugin:x:" or a "(2)" suffix), lowercased:
10
+ * substring, or a regular expression when wrapped in /.../.
11
+ * Order matters: the first matching group wins.
12
+ */
13
+ export const DEFAULT_GROUPS = {
14
+ web: [
15
+ "jina", "scrapegraph", "brightdata", "bright data", "crawlbase", "firecrawl",
16
+ "tavily", "exa", "perplexity", "brave", "serper", "serpapi", "apify", "fetch",
17
+ ],
18
+ docs: ["context7", "deepwiki", "github", "gitlab", "sentry", "sourcegraph", "ref-tools", "engineering"],
19
+ browser: ["chrome", "devtools", "playwright", "puppeteer", "browser"],
20
+ data: [
21
+ "supabase", "cloudflare", "posthog", "postgres", "neon", "vercel", "firebase",
22
+ "mongo", "redis", "aws", "azure", "gcp", "bigquery", "snowflake", "/^data$/",
23
+ ],
24
+ apps: ["v0", "lovable", "replit", "bolt", "base44"],
25
+ design: ["figma", "canva", "magic patterns", "mobbin", "excalidraw", "superdesign", "framer", "/^design$/"],
26
+ media: ["higgsfield", "runway", "elevenlabs", "cloudinary", "splice", "wispr", "midjourney", "replicate", "fal"],
27
+ productivity: ["google drive", "google calendar", "notion", "claude docs", "gamma", "pdf", "productivity", "dropbox", "box"],
28
+ comms: ["gmail", "slack", "resend", "intercom", "discord", "teams", "outlook", "twilio"],
29
+ pm: ["linear", "asana", "atlassian", "jira", "monday", "clickup", "trello"],
30
+ };
31
+ export const OTHER_GROUP = "other";
32
+ /** Display label of a group: translated for the default ids, as-is for custom groups. */
33
+ export function groupLabel(id) {
34
+ return id in DEFAULT_GROUPS || id === OTHER_GROUP ? t(`group.${id}`) : id;
35
+ }
36
+ let cached;
37
+ function config() {
38
+ if (cached)
39
+ return cached;
40
+ const user = readJson(groupsFile, {});
41
+ // User groups come first and replace default groups with the same id.
42
+ const groups = { ...(user.groups ?? {}) };
43
+ for (const [g, pats] of Object.entries(DEFAULT_GROUPS))
44
+ groups[g] ??= pats;
45
+ cached = { groups, overrides: user.overrides ?? {} };
46
+ return cached;
47
+ }
48
+ export function mcpBaseName(name) {
49
+ return name
50
+ .replace(/^claude\.ai /i, "")
51
+ .replace(/^plugin:[^:]+:/i, "")
52
+ .replace(/\s*\(\d+\)$/, "")
53
+ .replace(/^com\.|\/mcp$|-mcp$|-server$/gi, "")
54
+ .toLowerCase();
55
+ }
56
+ function matches(base, pattern) {
57
+ const p = pattern.toLowerCase();
58
+ if (p.length > 2 && p.startsWith("/") && p.endsWith("/"))
59
+ return new RegExp(p.slice(1, -1)).test(base);
60
+ // Short patterns (v0, exa, fal, box…): whole word only, to avoid false positives.
61
+ if (p.length <= 4)
62
+ return new RegExp(`(^|[^a-z0-9])${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9]|$)`).test(base);
63
+ return base.includes(p);
64
+ }
65
+ /** Group id of a server. */
66
+ function groupId(name) {
67
+ const cfg = config();
68
+ if (cfg.overrides[name])
69
+ return cfg.overrides[name];
70
+ const base = mcpBaseName(name);
71
+ // For plugin servers, the plugin name is the fallback (e.g. plugin:design:figma → figma).
72
+ const plugin = /^plugin:([^:]+):/i.exec(name)?.[1]?.toLowerCase();
73
+ for (const cand of [base, plugin].filter(Boolean))
74
+ for (const [group, pats] of Object.entries(cfg.groups))
75
+ if (pats.some((p) => matches(cand, p)))
76
+ return group;
77
+ return OTHER_GROUP;
78
+ }
79
+ /** Display label of the group of a server. */
80
+ export function mcpGroup(name) {
81
+ return groupLabel(groupId(name));
82
+ }
83
+ /** Group labels in display order. */
84
+ export function groupOrder() {
85
+ return [...Object.keys(config().groups), OTHER_GROUP].map(groupLabel);
86
+ }
87
+ /** Writes an editable groups.json, pre-filled with the default groups. */
88
+ export function initGroupsFile() {
89
+ if (!fs.existsSync(groupsFile)) {
90
+ fs.mkdirSync(path.dirname(groupsFile), { recursive: true });
91
+ fs.writeFileSync(groupsFile, JSON.stringify({ groups: DEFAULT_GROUPS, overrides: { "example-server": "other" } }, null, 2) + "\n");
92
+ }
93
+ return groupsFile;
94
+ }