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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/agents/_skills.js +15 -0
- package/dist/agents/_toml.js +66 -0
- package/dist/agents/antigravity.js +68 -0
- package/dist/agents/claude/index.js +31 -0
- package/dist/agents/claude/mcp.js +164 -0
- package/dist/agents/claude/paths.js +32 -0
- package/dist/agents/claude/plugins.js +82 -0
- package/dist/agents/claude/settings.js +43 -0
- package/dist/agents/claude/skills.js +75 -0
- package/dist/agents/codex.js +53 -0
- package/dist/agents/copilot.js +72 -0
- package/dist/agents/cursor.js +32 -0
- package/dist/agents/devin.js +41 -0
- package/dist/agents/droid.js +48 -0
- package/dist/agents/gemini.js +51 -0
- package/dist/agents/grok.js +107 -0
- package/dist/agents/index.js +41 -0
- package/dist/agents/more.js +483 -0
- package/dist/agents/others.js +192 -0
- package/dist/agents/qwen.js +46 -0
- package/dist/core/generic.js +334 -0
- package/dist/core/groups.js +94 -0
- package/dist/core/i18n.js +832 -0
- package/dist/core/jsonio.js +136 -0
- package/dist/core/logo.js +128 -0
- package/dist/core/stash.js +21 -0
- package/dist/core/toml.js +138 -0
- package/dist/core/types.js +4 -0
- package/dist/core/yaml.js +31 -0
- package/dist/index.js +467 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevOhMyCode
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# agent-toggle
|
|
2
|
+
|
|
3
|
+
A TypeScript CLI to enable or disable the **MCP servers** (grouped by function),
|
|
4
|
+
**plugins**, **skills**, **hooks**, **commands** and **subagents** of every AI
|
|
5
|
+
agent CLI installed on the machine, at the **user** level, the **project**
|
|
6
|
+
level, or **both**.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install -g agent-toggle
|
|
10
|
+
agent-toggle # interactive menu, run it in the project folder you want
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
From source: `pnpm install && pnpm run build && npm link`. Requires Node.js 22 or later.
|
|
14
|
+
|
|
15
|
+
39 agent CLIs are supported. The menu and `agents` only show the ones detected on
|
|
16
|
+
the machine (a binary on the PATH or a characteristic config folder); `agents --all`
|
|
17
|
+
lists them all. The menu lists every detected agent, plus a **cross view**: the MCP servers of
|
|
18
|
+
all agents in a single list, grouped by function. Esc goes back to the previous
|
|
19
|
+
menu, Ctrl+C quits.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
agent-toggle agents # detected agents and what is managed
|
|
25
|
+
agent-toggle agents --all # every supported agent, installed or not
|
|
26
|
+
agent-toggle list all mcp # MCP servers of every agent
|
|
27
|
+
agent-toggle list codex skills
|
|
28
|
+
agent-toggle disable codex mcp node_repl
|
|
29
|
+
agent-toggle disable grok skills archify -s user
|
|
30
|
+
agent-toggle enable cursor mcp supabase -s project
|
|
31
|
+
agent-toggle claudeai off # claude.ai connectors (Claude Code)
|
|
32
|
+
agent-toggle groups # customizable MCP groups
|
|
33
|
+
agent-toggle lang # current language and the available ones
|
|
34
|
+
agent-toggle lang de # save the interface language
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Options: `-p <dir>` (project), `-s user|project|local|both|all`, `-n` (dry run),
|
|
38
|
+
`-l <en|fr|es|de>` (interface language for this run).
|
|
39
|
+
|
|
40
|
+
## Language
|
|
41
|
+
|
|
42
|
+
The interface is in **English** by default. Available: English (`en`),
|
|
43
|
+
French (`fr`), Spanish (`es`), German (`de`). The language is resolved in this
|
|
44
|
+
order:
|
|
45
|
+
|
|
46
|
+
1. the `--lang` / `-l` option;
|
|
47
|
+
2. the `AGENT_TOGGLE_LANG` environment variable;
|
|
48
|
+
3. the language saved by `agent-toggle lang <code>` or by the **Language** entry
|
|
49
|
+
of the interactive menu (`~/.agent-toggle/config.json`);
|
|
50
|
+
4. English.
|
|
51
|
+
|
|
52
|
+
Translations live in `src/core/i18n.ts`. English is the reference catalog, and
|
|
53
|
+
the compiler rejects any other catalog that misses a key.
|
|
54
|
+
|
|
55
|
+
## Mechanisms per agent
|
|
56
|
+
|
|
57
|
+
The agent's native mechanism is used whenever one exists. Otherwise the item is
|
|
58
|
+
**set aside**: a JSON entry goes to `~/.agent-toggle/stash.json`, a file or
|
|
59
|
+
directory is moved to `<dir>.disabled/`. In both cases it is restored exactly as
|
|
60
|
+
it was.
|
|
61
|
+
|
|
62
|
+
| Agent | MCP | Plugins | Skills | Hooks | Commands | Agents |
|
|
63
|
+
|---|---|---|---|---|---|---|
|
|
64
|
+
| **Claude Code** | `disabledMcpServers` (per project, `~/.claude.json`); stash for user servers | `enabledPlugins` | `skillOverrides: off` | stash + `disableAllHooks` | move | move |
|
|
65
|
+
| **Codex** | `[mcp_servers.x] enabled = false` | `[plugins."p@m"] enabled` | `[[skills.config]] enabled = false` | stash (`hooks.json`) | prompts: move | `.toml`: move |
|
|
66
|
+
| **Grok Build** | `disabled_mcp_servers` (every source); project: `enabled = false` | `grok plugin enable/disable` | `[skills] disabled` | `hooks/*.json` files: move | move | move |
|
|
67
|
+
| **Cursor Agent** | `disabled: true` | — (IDE) | link moved | stash (`hooks.json`) | move | move |
|
|
68
|
+
| **Copilot CLI** | `disabledMcpServers` | `enabledPlugins` | `disabledSkills` | stash + `disableAllHooks`; files: move | — | `subagents.disabledSubagents` |
|
|
69
|
+
| **Devin CLI** | `disabled: true` | — | move | stash | — | move |
|
|
70
|
+
| **Antigravity (agy)** | `disabled: true` | `plugins.<p>.enabled` | move | `<hook>.enabled = false` | — | — |
|
|
71
|
+
| **Gemini CLI** | `mcp.excluded` | — | `skills.disabled` | stash + `hooksConfig.enabled` | move | `agents.overrides.<n>.enabled` |
|
|
72
|
+
| **Qwen Code** | `mcp.excluded` | — | `skills.disabled` | stash + `disableAllHooks` | `slashCommands.disabled` | move |
|
|
73
|
+
| **Factory Droid** | `disabled: true` | `enabledPlugins` | `disabledSkills` | stash + `hooksDisabled` | move | droids: move |
|
|
74
|
+
| **OpenCode** | `mcp.<n>.enabled = false` | files: move | `permission.skill.<n> = deny` | — (plugins) | move | `agent.<n>.disable` |
|
|
75
|
+
| **Amp** | stash (no native flag) | files: move | move | — (plugins) | — | — |
|
|
76
|
+
| **Crush** | `disabled: true` | — | `options.disabled_skills` | stash | — | — |
|
|
77
|
+
| **Kiro CLI** | `disabled: true` | — | move | — | prompts: move | `.json`: move |
|
|
78
|
+
| **Kilo Code CLI** | `mcp.<n>.enabled = false` | files: move | move | — (plugins) | move | `agent.<n>.disable` |
|
|
79
|
+
| **Amazon Q CLI** (legacy Kiro) | `disabled: true` | — | — | — | prompts: move | `cli-agents/*.json`: move |
|
|
80
|
+
| **Goose** | `extensions.<n>.enabled: false` (YAML) | `disabledPlugins` | — (plugins) | — (plugins) | — | — |
|
|
81
|
+
| **Cline CLI** | `disabled: true` | — | `SKILL.md` frontmatter `disabled: true` | hook files: move | workflows: move | move |
|
|
82
|
+
| **Continue CLI** | stash (YAML array, no flag) | — | move | stash + `disableAllHooks` | — | — |
|
|
83
|
+
| **Augment Auggie** | stash | `enabledPlugins` | move | stash | move | move |
|
|
84
|
+
| **JetBrains Junie** | stash | — | move | stash | move | move |
|
|
85
|
+
| **OpenHands CLI** | `enabled: false` | — | move | — | — | move |
|
|
86
|
+
| **Kimi CLI** | stash | plugin folders: move | move | — | — | — |
|
|
87
|
+
| **Mistral Vibe** | `[[mcp_servers]] disabled = true` | — | `disabled_skills` | — | — | `disabled_agents` |
|
|
88
|
+
| **Rovo Dev CLI** | `mcp.disabledMcpServers` (YAML) | — | move | — | — | subagents: move |
|
|
89
|
+
| **Qoder CLI** | stash | — | move | — | move | move |
|
|
90
|
+
| **CodeBuddy Code** | stash | `enabledPlugins` | move | stash | move | move |
|
|
91
|
+
| **Letta Code** | — (per agent, use `/mcp`) | mods: move | move | stash + `hooks.disabled` | move | move |
|
|
92
|
+
| **Pi coding agent** | — (no MCP) | extensions: move | move | — | prompt templates: move | — |
|
|
93
|
+
| **Codebuff** | stash (strict schema) | — | — | — | — | — |
|
|
94
|
+
| **Hermes Agent** | `mcp_servers.<n>.enabled: false` (YAML) | — | move | — | — | — |
|
|
95
|
+
| **Command Code** | stash | — | — | stash | — | — |
|
|
96
|
+
| **Windsurf** | stash | — | — | — | — | — |
|
|
97
|
+
| **Trae Agent** | `allow_mcp_servers` allow-list (YAML) | — | — | — | — | — |
|
|
98
|
+
| **iFlow CLI** (discontinued) | `excludeMCPServers` | — | — | stash | move | move |
|
|
99
|
+
| **Qodo Command** (deprecated) | stash (project `mcp.json`) | — | — | — | — | — |
|
|
100
|
+
| **Aider**, **Plandex** | detected, nothing to toggle | | | | | |
|
|
101
|
+
| **Warp / Oz** | state kept inside the app: nothing is managed | | | | | |
|
|
102
|
+
|
|
103
|
+
### Good to know
|
|
104
|
+
|
|
105
|
+
- **Shared skills.** Cursor, Kiro, Amp and Antigravity have no per-skill
|
|
106
|
+
switch, so a skill is disabled by moving its folder (or link). If your skill
|
|
107
|
+
folders are links to a shared store such as `~/.agents/skills`, only the link
|
|
108
|
+
moves, and a script that syncs those links would recreate it. Codex, Grok,
|
|
109
|
+
Copilot, Qwen, Droid, OpenCode and Crush have a native setting that leaves the
|
|
110
|
+
files in place.
|
|
111
|
+
- **Project config and trust.** Codex and Grok only read a project's config
|
|
112
|
+
when the folder is "trusted".
|
|
113
|
+
- **Formats are preserved.** JSON/JSONC is edited path by path, comments
|
|
114
|
+
included (`jsonc-parser`); YAML through the `yaml` document API, comments
|
|
115
|
+
included. TOML is edited line by line and never rewritten as
|
|
116
|
+
a whole. A disable/enable round trip gives back an identical file.
|
|
117
|
+
- **Backups.** Each modified file is copied to `~/.agent-toggle/backups/`,
|
|
118
|
+
which keeps the last 20 copies.
|
|
119
|
+
|
|
120
|
+
## MCP server groups
|
|
121
|
+
|
|
122
|
+
Default groups use stable ids: `web`, `docs`, `browser`, `data`, `apps`,
|
|
123
|
+
`design`, `media`, `productivity`, `comms`, `pm`, `other`. Their labels follow
|
|
124
|
+
the interface language. Run `agent-toggle groups`, then edit
|
|
125
|
+
`~/.agent-toggle/groups.json`. A custom group name is displayed as-is, and
|
|
126
|
+
`overrides` can target a default id or a custom name:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"groups": { "My tools": ["jina", "/^deep/"] },
|
|
131
|
+
"overrides": { "claude.ai Splice": "media" }
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- **Undocumented switches.** When an agent has no documented native switch, the
|
|
136
|
+
safe fallback is used (stash, or `<dir>.disabled/`) rather than a guessed key:
|
|
137
|
+
some agents (Codebuff, Trae) reject unknown keys.
|
|
138
|
+
|
|
139
|
+
## Adding an agent
|
|
140
|
+
|
|
141
|
+
Write an adapter in `src/agents/`, an `Agent` object that describes how the
|
|
142
|
+
agent is detected and its sections, then add it to `AGENTS` in
|
|
143
|
+
`src/agents/index.ts`. The generic sections cover most cases: `mcpSection`,
|
|
144
|
+
`listSection`, `boolMapSection`, `hooksSection` and `dirSection` (in
|
|
145
|
+
`src/core/generic.ts`), plus `tomlFlagSection` and `tomlListSection`. User-facing
|
|
146
|
+
text goes through `t()` with a key added to all four catalogs.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { dirNames, shortPath } from "../core/generic.js";
|
|
3
|
+
/** Skills (directories containing SKILL.md) found in several roots, without duplicates. */
|
|
4
|
+
export function skillsIn(dirs, group) {
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const d of dirs)
|
|
8
|
+
for (const n of dirNames(d, "SKILL.md")) {
|
|
9
|
+
if (seen.has(n))
|
|
10
|
+
continue;
|
|
11
|
+
seen.add(n);
|
|
12
|
+
out.push({ id: n, hint: shortPath(d), group, file: path.join(d, n, "SKILL.md") });
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getPath, toggled, toggleInList } from "../core/jsonio.js";
|
|
2
|
+
import { readToml, setTomlKey } from "../core/toml.js";
|
|
3
|
+
import { describeServer, shortPath, sortByGroup } from "../core/generic.js";
|
|
4
|
+
import { mcpGroup } from "../core/groups.js";
|
|
5
|
+
import { t } from "../core/i18n.js";
|
|
6
|
+
const scopesOf = (f) => ["user", "project", "local"].filter((s) => f[s]);
|
|
7
|
+
/**
|
|
8
|
+
* TOML tables { id: { ... } } with a per-item flag
|
|
9
|
+
* (e.g. [mcp_servers.x] enabled = false, [plugins."p@m"] enabled = false).
|
|
10
|
+
*/
|
|
11
|
+
export function tomlFlagSection(o) {
|
|
12
|
+
const isMcp = o.kind === "mcp";
|
|
13
|
+
return {
|
|
14
|
+
kind: o.kind,
|
|
15
|
+
title: o.title,
|
|
16
|
+
scopes: scopesOf(o.files),
|
|
17
|
+
note: (scope, ctx) => `${shortPath(o.files[scope](ctx))} · [${o.table.join(".")}.<name>] ${o.key} = ${JSON.stringify(o.off)}`,
|
|
18
|
+
list(ctx, scope) {
|
|
19
|
+
const tbl = getPath(readToml(o.files[scope](ctx)), o.table) ?? {};
|
|
20
|
+
const items = Object.entries(tbl)
|
|
21
|
+
.filter(([, v]) => v && typeof v === "object" && !Array.isArray(v))
|
|
22
|
+
.map(([id, def]) => ({
|
|
23
|
+
id,
|
|
24
|
+
label: id,
|
|
25
|
+
hint: isMcp ? describeServer(def) : undefined,
|
|
26
|
+
group: isMcp ? mcpGroup(id) : t("group.installed"),
|
|
27
|
+
enabled: def[o.key] !== o.off,
|
|
28
|
+
}));
|
|
29
|
+
return isMcp ? sortByGroup(items) : items.sort((a, b) => a.label.localeCompare(b.label));
|
|
30
|
+
},
|
|
31
|
+
set(ctx, scope, item, enabled) {
|
|
32
|
+
const file = o.files[scope](ctx);
|
|
33
|
+
setTomlKey(file, [...o.table, item.id], o.key, enabled ? o.on : o.off, toggled(item.id, enabled, shortPath(file)));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** List of disabled names in a TOML file (e.g. [skills] disabled = [...]). */
|
|
38
|
+
export function tomlListSection(o) {
|
|
39
|
+
const path = [...o.table, o.key];
|
|
40
|
+
return {
|
|
41
|
+
kind: o.kind,
|
|
42
|
+
title: o.title,
|
|
43
|
+
scopes: scopesOf(o.files),
|
|
44
|
+
note: (scope, ctx) => `${shortPath(o.files[scope](ctx))} · ${path.join(".")}${o.note ? "\n" + t(o.note) : ""}`,
|
|
45
|
+
list(ctx, scope) {
|
|
46
|
+
const list = getPath(readToml(o.files[scope](ctx)), path) ?? [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const items = [];
|
|
49
|
+
for (const d of o.discover(ctx, scope)) {
|
|
50
|
+
if (seen.has(d.id))
|
|
51
|
+
continue;
|
|
52
|
+
seen.add(d.id);
|
|
53
|
+
items.push({ id: d.id, label: d.id, hint: d.hint, group: d.group ?? t("group.installed"), enabled: !list.includes(d.id) });
|
|
54
|
+
}
|
|
55
|
+
for (const n of list)
|
|
56
|
+
if (!seen.has(n))
|
|
57
|
+
items.push({ id: n, label: n, hint: t("hint.notFound"), group: t("group.others"), enabled: false });
|
|
58
|
+
return o.kind === "mcp" ? sortByGroup(items) : items.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
|
59
|
+
},
|
|
60
|
+
set(ctx, scope, item, enabled) {
|
|
61
|
+
const file = o.files[scope](ctx);
|
|
62
|
+
const list = toggleInList(getPath(readToml(file), path), item.id, !enabled);
|
|
63
|
+
setTomlKey(file, o.table, o.key, list.length ? list : undefined, toggled(item.id, enabled, shortPath(file)));
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { dirNames, dirSection, mcpSection, shortPath } from "../core/generic.js";
|
|
3
|
+
import { editJson, exists, home, readJson, toggled } from "../core/jsonio.js";
|
|
4
|
+
import { t } from "../core/i18n.js";
|
|
5
|
+
const CFG = home(".gemini", "config");
|
|
6
|
+
/** Plugins: plugins.<dir>.enabled in ~/.gemini/config/config.json (same as agy plugin enable|disable). */
|
|
7
|
+
const plugins = {
|
|
8
|
+
kind: "plugins",
|
|
9
|
+
scopes: ["user"],
|
|
10
|
+
note: () => `${shortPath(path.join(CFG, "config.json"))} · plugins.<dir>.enabled`,
|
|
11
|
+
list() {
|
|
12
|
+
const conf = readJson(path.join(CFG, "config.json")).plugins ?? {};
|
|
13
|
+
const ids = new Set([...dirNames(path.join(CFG, "plugins")), ...Object.keys(conf)]);
|
|
14
|
+
return [...ids].sort().map((id) => {
|
|
15
|
+
const own = readJson(path.join(CFG, "plugins", id, "plugin.json"));
|
|
16
|
+
const v = conf[id]?.enabled;
|
|
17
|
+
return { id, label: own.name ?? id, hint: own.version, group: t("group.installed"), enabled: typeof v === "boolean" ? v : own.disabled !== true };
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
set(_ctx, _scope, item, enabled) {
|
|
21
|
+
editJson(path.join(CFG, "config.json"), toggled(`Plugin ${item.id}`, enabled), [{ path: ["plugins", item.id, "enabled"], value: enabled }]);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Named hooks { name: { enabled?, <event>: [...] } }: native enabled = false.
|
|
26
|
+
* On the user side there are two files: ~/.gemini/config/hooks.json and the CLI's own.
|
|
27
|
+
*/
|
|
28
|
+
const hookFiles = (ctx, scope) => scope === "user" ? [path.join(CFG, "hooks.json"), home(".gemini", "antigravity-cli", "hooks.json")] : [path.join(ctx.root, ".agents", "hooks.json")];
|
|
29
|
+
const hooks = {
|
|
30
|
+
kind: "hooks",
|
|
31
|
+
scopes: ["user", "project"],
|
|
32
|
+
note: (s, c) => `${hookFiles(c, s).filter(exists).map(shortPath).join(", ") || t("note.noFile")} · <hook>.enabled = false`,
|
|
33
|
+
list(ctx, scope) {
|
|
34
|
+
return hookFiles(ctx, scope).flatMap((f) => Object.entries(readJson(f)).filter(([, v]) => v && typeof v === "object").map(([name, v]) => ({
|
|
35
|
+
id: `${f}|${name}`,
|
|
36
|
+
label: name,
|
|
37
|
+
hint: Object.keys(v).filter((k) => k !== "enabled").join(", "),
|
|
38
|
+
group: shortPath(f),
|
|
39
|
+
enabled: v.enabled !== false,
|
|
40
|
+
})));
|
|
41
|
+
},
|
|
42
|
+
set(_ctx, _scope, item, enabled) {
|
|
43
|
+
const [file, name] = [item.id.slice(0, item.id.lastIndexOf("|")), item.id.slice(item.id.lastIndexOf("|") + 1)];
|
|
44
|
+
editJson(file, toggled(`Hook ${name}`, enabled, shortPath(file)), [{ path: [name, "enabled"], value: enabled ? undefined : false }]);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
export const antigravity = {
|
|
48
|
+
id: "antigravity",
|
|
49
|
+
name: "Google Antigravity (agy)",
|
|
50
|
+
bins: ["agy"],
|
|
51
|
+
dirs: [".gemini/antigravity"],
|
|
52
|
+
sections: [
|
|
53
|
+
mcpSection({
|
|
54
|
+
agent: "antigravity",
|
|
55
|
+
files: { user: () => path.join(CFG, "mcp_config.json"), project: (c) => path.join(c.root, ".agents", "mcp_config.json") },
|
|
56
|
+
key: ["mcpServers"],
|
|
57
|
+
disable: { type: "flag", key: "disabled", off: true },
|
|
58
|
+
}),
|
|
59
|
+
plugins,
|
|
60
|
+
dirSection({
|
|
61
|
+
kind: "skills",
|
|
62
|
+
dirs: { user: () => path.join(CFG, "skills"), project: (c) => path.join(c.root, ".agents", "skills") },
|
|
63
|
+
entry: { dir: "SKILL.md" },
|
|
64
|
+
note: "note.sharedSkillsMoved",
|
|
65
|
+
}),
|
|
66
|
+
hooks,
|
|
67
|
+
],
|
|
68
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { dirSection, hooksSection } from "../../core/generic.js";
|
|
2
|
+
import { P, projectPaths } from "./paths.js";
|
|
3
|
+
import { settingsFile } from "./settings.js";
|
|
4
|
+
import { mcpSection } from "./mcp.js";
|
|
5
|
+
import { pluginsSection } from "./plugins.js";
|
|
6
|
+
import { skillsSection } from "./skills.js";
|
|
7
|
+
export { claudeAiConnectorsDisabled, setClaudeAiConnectors } from "./mcp.js";
|
|
8
|
+
export const claude = {
|
|
9
|
+
id: "claude",
|
|
10
|
+
name: "Claude Code",
|
|
11
|
+
bins: ["claude"],
|
|
12
|
+
dirs: [".claude"],
|
|
13
|
+
sections: [
|
|
14
|
+
mcpSection,
|
|
15
|
+
pluginsSection,
|
|
16
|
+
skillsSection,
|
|
17
|
+
hooksSection({
|
|
18
|
+
agent: "claude",
|
|
19
|
+
files: {
|
|
20
|
+
user: (ctx) => settingsFile(ctx, "user"),
|
|
21
|
+
project: (ctx) => settingsFile(ctx, "project"),
|
|
22
|
+
local: (ctx) => settingsFile(ctx, "local"),
|
|
23
|
+
},
|
|
24
|
+
key: ["hooks"],
|
|
25
|
+
disableAll: { path: ["disableAllHooks"], off: true },
|
|
26
|
+
}),
|
|
27
|
+
dirSection({ kind: "commands", dirs: { user: () => P.userCommands, project: (c) => projectPaths(c.root).commands }, entry: { ext: [".md"] }, sep: ":" }),
|
|
28
|
+
dirSection({ kind: "agents", dirs: { user: () => P.userAgents, project: (c) => projectPaths(c.root).agents }, entry: { ext: [".md"] } }),
|
|
29
|
+
],
|
|
30
|
+
notes: ["notes.claude.skills", "notes.claude.connectors"],
|
|
31
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { P, projectPaths } from "./paths.js";
|
|
4
|
+
import { readJson, toggled, toggleInList } from "../../core/jsonio.js";
|
|
5
|
+
import { t } from "../../core/i18n.js";
|
|
6
|
+
import { projectKeys, readClaudeJson, updateClaudeJson, updateSettings } from "./settings.js";
|
|
7
|
+
import { stashed } from "../../core/stash.js";
|
|
8
|
+
import { restoreKey, stashKey } from "../../core/generic.js";
|
|
9
|
+
import { groupOrder, mcpGroup } from "../../core/groups.js";
|
|
10
|
+
import { installedPlugins, pluginEnabledForUser } from "./plugins.js";
|
|
11
|
+
const userStash = () => new Map(stashed("claude", P.claudeJson).filter((e) => e.kind === "keyed").map((e) => [String(e.path.at(-1)), e.value]));
|
|
12
|
+
const sourceTag = (s) => s === "user" ? t("hint.user") : s === "local" ? t("hint.local") : s === "project" ? ".mcp.json" : s;
|
|
13
|
+
function describe(def) {
|
|
14
|
+
if (!def || typeof def !== "object")
|
|
15
|
+
return "";
|
|
16
|
+
if (def.url)
|
|
17
|
+
return String(def.url);
|
|
18
|
+
return [def.command, ...(def.args ?? [])].filter(Boolean).join(" ").slice(0, 70);
|
|
19
|
+
}
|
|
20
|
+
/** MCP servers declared by a plugin (.mcp.json or plugin.json#mcpServers). */
|
|
21
|
+
export function pluginMcpServers(installPath) {
|
|
22
|
+
const names = new Set();
|
|
23
|
+
const mcpJson = readJson(path.join(installPath, ".mcp.json"));
|
|
24
|
+
for (const k of Object.keys(mcpJson.mcpServers ?? mcpJson))
|
|
25
|
+
names.add(k);
|
|
26
|
+
const manifest = readJson(path.join(installPath, ".claude-plugin", "plugin.json"));
|
|
27
|
+
const m = manifest.mcpServers;
|
|
28
|
+
if (m && typeof m === "object")
|
|
29
|
+
for (const k of Object.keys(m))
|
|
30
|
+
names.add(k);
|
|
31
|
+
else if (typeof m === "string") {
|
|
32
|
+
const f = readJson(path.join(installPath, m));
|
|
33
|
+
for (const k of Object.keys(f.mcpServers ?? f))
|
|
34
|
+
names.add(k);
|
|
35
|
+
}
|
|
36
|
+
return [...names];
|
|
37
|
+
}
|
|
38
|
+
export function collectMcp(ctx) {
|
|
39
|
+
const cj = readClaudeJson();
|
|
40
|
+
const out = new Map();
|
|
41
|
+
const add = (s) => { if (!out.has(s.name))
|
|
42
|
+
out.set(s.name, s); };
|
|
43
|
+
for (const [n, d] of Object.entries(cj.mcpServers ?? {}))
|
|
44
|
+
add({ name: n, source: "user", detail: describe(d) });
|
|
45
|
+
for (const [n, d] of userStash())
|
|
46
|
+
add({ name: n, source: "user", detail: describe(d) });
|
|
47
|
+
for (const k of projectKeys(cj, ctx.root))
|
|
48
|
+
for (const [n, d] of Object.entries(cj.projects?.[k]?.mcpServers ?? {}))
|
|
49
|
+
add({ name: n, source: "local", detail: describe(d) });
|
|
50
|
+
const mj = readJson(projectPaths(ctx.root).mcpJson);
|
|
51
|
+
for (const [n, d] of Object.entries(mj.mcpServers ?? {}))
|
|
52
|
+
add({ name: n, source: "project", detail: describe(d) });
|
|
53
|
+
for (const p of installedPlugins(ctx)) {
|
|
54
|
+
if (!pluginEnabledForUser(ctx, p.id))
|
|
55
|
+
continue;
|
|
56
|
+
for (const s of pluginMcpServers(p.installPath))
|
|
57
|
+
add({ name: `plugin:${p.name}:${s}`, source: "plugin", detail: p.id });
|
|
58
|
+
}
|
|
59
|
+
for (const n of cj.claudeAiMcpEverConnected ?? [])
|
|
60
|
+
add({ name: n, source: "claude.ai", detail: t("hint.claudeaiConnector") });
|
|
61
|
+
// Names already disabled elsewhere (removed connector, uninstalled plugin…): keep them visible.
|
|
62
|
+
for (const k of projectKeys(cj, ctx.root))
|
|
63
|
+
for (const n of cj.projects?.[k]?.disabledMcpServers ?? [])
|
|
64
|
+
add({ name: n, source: n.startsWith("claude.ai ") ? "claude.ai" : n.startsWith("plugin:") ? "plugin" : "user", detail: t("hint.noLongerDetected") });
|
|
65
|
+
return [...out.values()];
|
|
66
|
+
}
|
|
67
|
+
function disabledIn(cj, root) {
|
|
68
|
+
const s = new Set();
|
|
69
|
+
for (const k of projectKeys(cj, root))
|
|
70
|
+
for (const n of cj.projects?.[k]?.disabledMcpServers ?? [])
|
|
71
|
+
s.add(n);
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
function setProjectDisabled(root, name, disabled) {
|
|
75
|
+
updateClaudeJson(toggled(`MCP ${name}`, !disabled, root), (cj) => {
|
|
76
|
+
cj.projects ??= {};
|
|
77
|
+
for (const k of projectKeys(cj, root)) {
|
|
78
|
+
const p = (cj.projects[k] ??= {});
|
|
79
|
+
p.disabledMcpServers = toggleInList(p.disabledMcpServers, name, disabled);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export const mcpSection = {
|
|
84
|
+
kind: "mcp",
|
|
85
|
+
scopes: ["user", "local"],
|
|
86
|
+
scopeLabel: { local: "scope.projectShort" },
|
|
87
|
+
note: (scope) => scope === "user"
|
|
88
|
+
? t("note.claudeMcpUser")
|
|
89
|
+
: t("note.claudeMcpProject"),
|
|
90
|
+
list(ctx, scope) {
|
|
91
|
+
const cj = readClaudeJson();
|
|
92
|
+
const stash = userStash();
|
|
93
|
+
const order = groupOrder();
|
|
94
|
+
const projects = Object.keys(cj.projects ?? {});
|
|
95
|
+
const items = collectMcp(ctx).map((s) => {
|
|
96
|
+
let enabled;
|
|
97
|
+
let extra = "";
|
|
98
|
+
if (scope === "user" && s.source === "user") {
|
|
99
|
+
enabled = !stash.has(s.name) && s.name in (cj.mcpServers ?? {});
|
|
100
|
+
}
|
|
101
|
+
else if (scope === "user") {
|
|
102
|
+
// Disabled at user level = disabled in every known project.
|
|
103
|
+
const n = projects.filter((k) => (cj.projects[k].disabledMcpServers ?? []).includes(s.name)).length;
|
|
104
|
+
enabled = !projects.length || n < projects.length;
|
|
105
|
+
if (n && enabled)
|
|
106
|
+
extra = ` · ${t("hint.disabledIn", { n, total: projects.length })}`;
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
enabled = !disabledIn(cj, ctx.root).has(s.name);
|
|
110
|
+
return {
|
|
111
|
+
id: s.name,
|
|
112
|
+
label: s.name,
|
|
113
|
+
hint: `${sourceTag(s.source)}${s.detail ? " · " + s.detail : ""}${extra}`,
|
|
114
|
+
group: mcpGroup(s.name),
|
|
115
|
+
enabled,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
return items.sort((a, b) => order.indexOf(a.group) - order.indexOf(b.group) || a.label.localeCompare(b.label));
|
|
119
|
+
},
|
|
120
|
+
set(ctx, scope, item, enabled) {
|
|
121
|
+
const src = collectMcp(ctx).find((s) => s.name === item.id)?.source;
|
|
122
|
+
if (scope === "user" && src === "user") {
|
|
123
|
+
if (enabled)
|
|
124
|
+
restoreKey("claude", P.claudeJson, ["mcpServers", item.id], `MCP ${item.id}`);
|
|
125
|
+
else
|
|
126
|
+
stashKey("claude", P.claudeJson, ["mcpServers", item.id], `MCP ${item.id}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (scope === "user") {
|
|
130
|
+
updateClaudeJson(toggled(`MCP ${item.id}`, enabled, t("hint.allProjects")), (cj) => {
|
|
131
|
+
cj.projects ??= {};
|
|
132
|
+
for (const p of Object.values(cj.projects))
|
|
133
|
+
p.disabledMcpServers = toggleInList(p.disabledMcpServers, item.id, !enabled);
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setProjectDisabled(ctx.root, item.id, !enabled);
|
|
138
|
+
// .mcp.json servers also have to be approved before they start.
|
|
139
|
+
if (src === "project")
|
|
140
|
+
updateSettings(ctx, "local", t(enabled ? "change.approved" : "change.rejected", { what: `.mcp.json ${item.id}` }), (s) => {
|
|
141
|
+
s.enabledMcpjsonServers = toggleInList(s.enabledMcpjsonServers, item.id, enabled);
|
|
142
|
+
s.disabledMcpjsonServers = toggleInList(s.disabledMcpjsonServers, item.id, !enabled);
|
|
143
|
+
for (const k of ["enabledMcpjsonServers", "disabledMcpjsonServers"])
|
|
144
|
+
if (!s[k].length)
|
|
145
|
+
delete s[k];
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
/** Interrupteur global des connecteurs claude.ai (setting officiel disableClaudeAiConnectors). */
|
|
150
|
+
export function claudeAiConnectorsDisabled(ctx, scope) {
|
|
151
|
+
const file = scope === "user" ? P.userSettings : scope === "project" ? projectPaths(ctx.root).settings : projectPaths(ctx.root).settingsLocal;
|
|
152
|
+
if (!fs.existsSync(file))
|
|
153
|
+
return undefined;
|
|
154
|
+
return readJson(file).disableClaudeAiConnectors;
|
|
155
|
+
}
|
|
156
|
+
export function setClaudeAiConnectors(ctx, scope, disabled) {
|
|
157
|
+
updateSettings(ctx, scope, toggled(t("label.claudeaiConnectors"), !disabled, scope), (s) => {
|
|
158
|
+
// A true value wins whatever its scope: to restore, the key is removed.
|
|
159
|
+
if (disabled)
|
|
160
|
+
s.disableClaudeAiConnectors = true;
|
|
161
|
+
else
|
|
162
|
+
delete s.disableClaudeAiConnectors;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const HOME = os.homedir();
|
|
4
|
+
export const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(HOME, ".claude");
|
|
5
|
+
/** Locations read and written by the tool. */
|
|
6
|
+
export const P = {
|
|
7
|
+
home: HOME,
|
|
8
|
+
claudeJson: path.join(HOME, ".claude.json"),
|
|
9
|
+
userSettings: path.join(CLAUDE_DIR, "settings.json"),
|
|
10
|
+
userSkills: path.join(CLAUDE_DIR, "skills"),
|
|
11
|
+
userCommands: path.join(CLAUDE_DIR, "commands"),
|
|
12
|
+
userAgents: path.join(CLAUDE_DIR, "agents"),
|
|
13
|
+
installedPlugins: path.join(CLAUDE_DIR, "plugins", "installed_plugins.json"),
|
|
14
|
+
syncedPlugins: path.join(CLAUDE_DIR, "plugins", "synced"),
|
|
15
|
+
/** Central skills store shared between agents (symlinks). */
|
|
16
|
+
centralSkills: path.join(HOME, ".agents", "skills"),
|
|
17
|
+
};
|
|
18
|
+
export function projectPaths(root) {
|
|
19
|
+
return {
|
|
20
|
+
root,
|
|
21
|
+
settings: path.join(root, ".claude", "settings.json"),
|
|
22
|
+
settingsLocal: path.join(root, ".claude", "settings.local.json"),
|
|
23
|
+
mcpJson: path.join(root, ".mcp.json"),
|
|
24
|
+
skills: path.join(root, ".claude", "skills"),
|
|
25
|
+
commands: path.join(root, ".claude", "commands"),
|
|
26
|
+
agents: path.join(root, ".claude", "agents"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Key in ~/.claude.json projects: absolute path with forward slashes, as Claude Code writes it. */
|
|
30
|
+
export function projectKey(root) {
|
|
31
|
+
return path.resolve(root).replace(/\\/g, "/");
|
|
32
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { P } from "./paths.js";
|
|
3
|
+
import { listDir, readJson, toggled } from "../../core/jsonio.js";
|
|
4
|
+
import { t } from "../../core/i18n.js";
|
|
5
|
+
import { chain, readSettings, updateSettings } from "./settings.js";
|
|
6
|
+
export function installedPlugins(_ctx) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const ip = readJson(P.installedPlugins).plugins ?? {};
|
|
9
|
+
for (const [id, entries] of Object.entries(ip)) {
|
|
10
|
+
const at = id.lastIndexOf("@");
|
|
11
|
+
out.push({
|
|
12
|
+
id,
|
|
13
|
+
name: id.slice(0, at),
|
|
14
|
+
marketplace: id.slice(at + 1),
|
|
15
|
+
installPath: entries[0]?.installPath ?? "",
|
|
16
|
+
installed: entries.map((e) => e.scope + (e.projectPath ? `@${e.projectPath}` : "")),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// Plugins synced from claude.ai: plugins/synced/<org>/<name>.
|
|
20
|
+
for (const org of listDir(P.syncedPlugins).filter((d) => d.isDirectory()))
|
|
21
|
+
for (const d of listDir(path.join(P.syncedPlugins, org.name)).filter((d) => d.isDirectory() && !d.name.startsWith(".")))
|
|
22
|
+
out.push({
|
|
23
|
+
id: `${d.name}@synced`,
|
|
24
|
+
name: d.name,
|
|
25
|
+
marketplace: "synced",
|
|
26
|
+
installPath: path.join(P.syncedPlugins, org.name, d.name),
|
|
27
|
+
installed: ["claude.ai"],
|
|
28
|
+
});
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** An installed plugin missing from enabledPlugins is active; false disables it. */
|
|
32
|
+
function defaultEnabled(p) {
|
|
33
|
+
return !!p && p.installed.some((s) => s === "user" || s === "claude.ai");
|
|
34
|
+
}
|
|
35
|
+
function effective(ctx, scope, id, p) {
|
|
36
|
+
for (const sc of chain(scope)) {
|
|
37
|
+
const v = readSettings(ctx, sc).enabledPlugins?.[id];
|
|
38
|
+
if (typeof v === "boolean")
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
return defaultEnabled(p);
|
|
42
|
+
}
|
|
43
|
+
export function pluginEnabledForUser(ctx, id) {
|
|
44
|
+
return effective(ctx, "user", id, installedPlugins(ctx).find((p) => p.id === id));
|
|
45
|
+
}
|
|
46
|
+
export const pluginsSection = {
|
|
47
|
+
kind: "plugins",
|
|
48
|
+
scopes: ["user", "project", "local"],
|
|
49
|
+
note: () => t("note.claudePlugins"),
|
|
50
|
+
list(ctx, scope) {
|
|
51
|
+
const all = installedPlugins(ctx);
|
|
52
|
+
const ids = new Set(all.map((p) => p.id));
|
|
53
|
+
for (const sc of chain(scope))
|
|
54
|
+
for (const id of Object.keys(readSettings(ctx, sc).enabledPlugins ?? {}))
|
|
55
|
+
ids.add(id);
|
|
56
|
+
return [...ids].sort().map((id) => {
|
|
57
|
+
const p = all.find((x) => x.id === id);
|
|
58
|
+
const mk = id.slice(id.lastIndexOf("@") + 1);
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
label: id.slice(0, id.lastIndexOf("@")),
|
|
62
|
+
hint: p ? p.installed.join(", ") : t("hint.notInstalled"),
|
|
63
|
+
group: mk === "synced" ? "claude.ai (synced)" : `Marketplace ${mk}`,
|
|
64
|
+
enabled: effective(ctx, scope, id, p),
|
|
65
|
+
};
|
|
66
|
+
}).sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
|
|
67
|
+
},
|
|
68
|
+
set(ctx, scope, item, enabled) {
|
|
69
|
+
const parent = scope === "user" ? defaultEnabled(installedPlugins(ctx).find((p) => p.id === item.id))
|
|
70
|
+
: effective(ctx, scope === "local" ? "project" : "user", item.id, installedPlugins(ctx).find((p) => p.id === item.id));
|
|
71
|
+
updateSettings(ctx, scope, toggled(`Plugin ${item.id}`, enabled, scope), (s) => {
|
|
72
|
+
s.enabledPlugins ??= {};
|
|
73
|
+
// Outside the user scope, a value equal to the inherited one is useless: remove it.
|
|
74
|
+
if (scope !== "user" && enabled === parent)
|
|
75
|
+
delete s.enabledPlugins[item.id];
|
|
76
|
+
else
|
|
77
|
+
s.enabledPlugins[item.id] = enabled;
|
|
78
|
+
if (!Object.keys(s.enabledPlugins).length)
|
|
79
|
+
delete s.enabledPlugins;
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
};
|