@softspark/ai-toolkit 2.0.1 → 2.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.
@@ -1,4 +1,4 @@
1
- """Install AI tool configs (Cursor, Windsurf, Gemini, Augment) and local project setup."""
1
+ """Install AI tool configs (Cursor, Windsurf, Gemini, Augment, Codex) and local project setup."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import shutil
@@ -6,6 +6,11 @@ import subprocess
6
6
  from pathlib import Path
7
7
 
8
8
  from _common import app_dir, inject_section, should_install, toolkit_dir
9
+ from codex_skill_adapter import (
10
+ cleanup_codex_skills,
11
+ sync_codex_skill,
12
+ )
13
+ from mcp_editors import sync_project_mcp_to_editors
9
14
  from injection import (
10
15
  collapse_blank_runs as _collapse_blank_runs,
11
16
  strip_section as _strip_section,
@@ -125,7 +130,7 @@ def run_script(script_name: str, *args: str, capture: bool = False) -> str:
125
130
  # All known editor identifiers for --editors flag
126
131
  ALL_EDITORS = [
127
132
  "copilot", "cursor", "windsurf", "cline", "roo",
128
- "aider", "augment", "antigravity",
133
+ "aider", "augment", "antigravity", "codex",
129
134
  ]
130
135
 
131
136
  # Map of project files/dirs → editor names for auto-detection
@@ -142,6 +147,9 @@ _EDITOR_MARKERS: dict[str, str] = {
142
147
  "CONVENTIONS.md": "aider",
143
148
  ".augment/rules": "augment",
144
149
  ".agent/rules": "antigravity",
150
+ ".agents/skills": "codex",
151
+ ".codex": "codex",
152
+ "AGENTS.md": "codex",
145
153
  }
146
154
 
147
155
 
@@ -495,6 +503,46 @@ def _create_local_settings(cwd: Path, reset: bool) -> None:
495
503
  print(" Kept: .claude/settings.local.json (already exists)")
496
504
 
497
505
 
506
+ def _install_codex_skills(cwd: Path) -> None:
507
+ """Install all skills to `.agents/skills/` for Codex.
508
+
509
+ Native Codex-compatible skills are symlinked directly. Skills that rely on
510
+ Claude-only orchestration primitives are rendered into generated wrappers
511
+ with Codex-native delegation guidance.
512
+ """
513
+ skills_src = app_dir / "skills"
514
+ if not skills_src.is_dir():
515
+ return
516
+
517
+ skills_dst = cwd / ".agents" / "skills"
518
+ skills_dst.mkdir(parents=True, exist_ok=True)
519
+
520
+ linked = 0
521
+ adapted = 0
522
+ skipped = 0
523
+ for skill_dir in sorted(skills_src.iterdir()):
524
+ if not skill_dir.is_dir() or skill_dir.name.startswith("_"):
525
+ continue
526
+ skill_md = skill_dir / "SKILL.md"
527
+ if not skill_md.is_file():
528
+ continue
529
+
530
+ mode = sync_codex_skill(skill_dir, skills_dst)
531
+ if mode == "linked":
532
+ linked += 1
533
+ elif mode == "adapted":
534
+ adapted += 1
535
+ else:
536
+ skipped += 1
537
+
538
+ cleanup_codex_skills(skills_dst, skills_src)
539
+
540
+ print(
541
+ f" Installed: {linked + adapted} skills to .agents/skills/"
542
+ f" ({linked} linked, {adapted} adapted, {skipped} skipped)"
543
+ )
544
+
545
+
498
546
  def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
499
547
  editors: list[str],
500
548
  language_modules: list[str] | None = None) -> None:
@@ -534,8 +582,12 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
534
582
  legacy_clinerules.unlink()
535
583
  print(" Migrated: .clinerules file → .clinerules/ directory")
536
584
  from generate_cline_rules import generate as gen_cline_rules
537
- gen_cline_rules(cwd, language_modules=language_modules,
538
- rules_dir=rules_dir)
585
+ gen_cline_rules(
586
+ cwd,
587
+ language_modules=language_modules,
588
+ rules_dir=rules_dir,
589
+ managed_scopes=("standard", "lang", "custom"),
590
+ )
539
591
 
540
592
  if "roo" in eds:
541
593
  roo_output = run_script("generate-roo-modes.sh", capture=True)
@@ -561,4 +613,31 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
561
613
  gen_antigravity(cwd, language_modules=language_modules,
562
614
  rules_dir=rules_dir)
563
615
 
616
+ if "codex" in eds:
617
+ # AGENTS.md — marker injection (like CLAUDE.md)
618
+ inject_with_rules(
619
+ "generate_codex.py",
620
+ cwd / "AGENTS.md",
621
+ rules_dir,
622
+ )
623
+ # .agents/rules/ — directory-based rules
624
+ from generate_codex_rules import generate as gen_codex_rules
625
+ gen_codex_rules(
626
+ cwd,
627
+ language_modules=language_modules,
628
+ rules_dir=rules_dir,
629
+ managed_scopes=("standard", "lang", "custom"),
630
+ )
631
+ # .codex/hooks.json — Codex lifecycle hooks
632
+ from generate_codex_hooks import generate as gen_codex_hooks
633
+ gen_codex_hooks(cwd)
634
+ print(" Created: .codex/hooks.json")
635
+ # .agents/skills/ — filtered symlinks (Codex-compatible skills only)
636
+ _install_codex_skills(cwd)
637
+
638
+ synced_paths = sync_project_mcp_to_editors(cwd, sorted(eds))
639
+ for path in synced_paths:
640
+ rel = path.relative_to(cwd)
641
+ print(f" Synced: {rel} (from .mcp.json)")
642
+
564
643
  run_script("install-git-hooks.sh", str(cwd))
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """Editor-specific MCP config adapters for ai-toolkit."""
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ from pathlib import Path
8
+
9
+ try:
10
+ import tomllib
11
+ except ModuleNotFoundError: # pragma: no cover - Python 3.11+ should have tomllib
12
+ tomllib = None
13
+
14
+
15
+ EDITOR_SPECS: dict[str, dict[str, str | None]] = {
16
+ "claude": {
17
+ "label": "Claude Code",
18
+ "project_path": ".claude/settings.local.json",
19
+ "global_path": ".claude/settings.json",
20
+ "format": "json",
21
+ "doc_scope": "project + global",
22
+ },
23
+ "cursor": {
24
+ "label": "Cursor",
25
+ "project_path": ".cursor/mcp.json",
26
+ "global_path": ".cursor/mcp.json",
27
+ "format": "json",
28
+ "doc_scope": "project + global",
29
+ },
30
+ "copilot": {
31
+ "label": "GitHub Copilot",
32
+ "project_path": ".github/mcp.json",
33
+ "global_path": ".copilot/mcp-config.json",
34
+ "format": "json",
35
+ "doc_scope": "project + global",
36
+ },
37
+ "gemini": {
38
+ "label": "Gemini CLI",
39
+ "project_path": ".gemini/settings.json",
40
+ "global_path": ".gemini/settings.json",
41
+ "format": "json",
42
+ "doc_scope": "project + global",
43
+ },
44
+ "windsurf": {
45
+ "label": "Windsurf",
46
+ "project_path": None,
47
+ "global_path": ".codeium/windsurf/mcp_config.json",
48
+ "format": "json",
49
+ "doc_scope": "global",
50
+ },
51
+ "cline": {
52
+ "label": "Cline",
53
+ "project_path": None,
54
+ "global_path": ".cline/data/settings/cline_mcp_settings.json",
55
+ "format": "json",
56
+ "doc_scope": "global",
57
+ },
58
+ "augment": {
59
+ "label": "Augment",
60
+ "project_path": None,
61
+ "global_path": ".augment/settings.json",
62
+ "format": "json",
63
+ "doc_scope": "global",
64
+ },
65
+ "codex": {
66
+ "label": "Codex CLI",
67
+ "project_path": None,
68
+ "global_path": ".codex/config.toml",
69
+ "format": "toml",
70
+ "doc_scope": "global",
71
+ },
72
+ }
73
+
74
+
75
+ PROJECT_SCOPED_EDITORS = {
76
+ name for name, spec in EDITOR_SPECS.items() if spec.get("project_path")
77
+ }
78
+
79
+
80
+ def supported_editors() -> list[str]:
81
+ """Return all editor ids with native MCP adapters."""
82
+ return sorted(EDITOR_SPECS)
83
+
84
+
85
+ def editor_rows() -> list[dict[str, str]]:
86
+ """Return display metadata for `ai-toolkit mcp editors`."""
87
+ rows: list[dict[str, str]] = []
88
+ for name in supported_editors():
89
+ spec = EDITOR_SPECS[name]
90
+ rows.append({
91
+ "name": name,
92
+ "label": str(spec["label"]),
93
+ "scope": str(spec["doc_scope"]),
94
+ "project_path": str(spec.get("project_path") or "—"),
95
+ "global_path": str(spec.get("global_path") or "—"),
96
+ "format": str(spec["format"]),
97
+ })
98
+ return rows
99
+
100
+
101
+ def resolve_editor_path(
102
+ editor: str,
103
+ scope: str,
104
+ *,
105
+ project_dir: Path | None = None,
106
+ home: Path | None = None,
107
+ ) -> Path:
108
+ """Resolve the native config path for an editor + scope."""
109
+ if editor not in EDITOR_SPECS:
110
+ raise ValueError(f"Unsupported editor: {editor}")
111
+ spec = EDITOR_SPECS[editor]
112
+ if scope == "project":
113
+ rel = spec.get("project_path")
114
+ if not rel:
115
+ raise ValueError(f"Editor '{editor}' does not support project-scoped MCP config")
116
+ base = project_dir or Path.cwd()
117
+ return base / str(rel)
118
+ if scope == "global":
119
+ rel = spec.get("global_path")
120
+ if not rel:
121
+ raise ValueError(f"Editor '{editor}' does not support global MCP config")
122
+ return (home or Path.home()) / str(rel)
123
+ raise ValueError(f"Unsupported scope: {scope}")
124
+
125
+
126
+ def load_project_mcp_servers(project_dir: Path) -> dict:
127
+ """Load `.mcp.json` servers from a project directory."""
128
+ config_path = project_dir / ".mcp.json"
129
+ if not config_path.is_file():
130
+ raise FileNotFoundError(f"{config_path} not found")
131
+ with open(config_path, encoding="utf-8") as f:
132
+ data = json.load(f)
133
+ servers = data.get("mcpServers", {})
134
+ if not isinstance(servers, dict):
135
+ raise ValueError(f"{config_path} has invalid mcpServers data")
136
+ return servers
137
+
138
+
139
+ def install_servers(
140
+ editors: list[str],
141
+ servers: dict,
142
+ *,
143
+ scope: str,
144
+ project_dir: Path | None = None,
145
+ home: Path | None = None,
146
+ ) -> list[Path]:
147
+ """Merge servers into native editor config files."""
148
+ updated: list[Path] = []
149
+ for editor in editors:
150
+ path = resolve_editor_path(
151
+ editor,
152
+ scope,
153
+ project_dir=project_dir,
154
+ home=home,
155
+ )
156
+ if EDITOR_SPECS[editor]["format"] == "toml":
157
+ _merge_toml_servers(path, servers)
158
+ else:
159
+ _merge_json_servers(path, editor, servers)
160
+ updated.append(path)
161
+ return updated
162
+
163
+
164
+ def remove_servers(
165
+ editors: list[str],
166
+ server_names: list[str],
167
+ *,
168
+ scope: str,
169
+ project_dir: Path | None = None,
170
+ home: Path | None = None,
171
+ ) -> list[Path]:
172
+ """Remove servers from native editor config files."""
173
+ updated: list[Path] = []
174
+ for editor in editors:
175
+ path = resolve_editor_path(
176
+ editor,
177
+ scope,
178
+ project_dir=project_dir,
179
+ home=home,
180
+ )
181
+ if EDITOR_SPECS[editor]["format"] == "toml":
182
+ _remove_toml_servers(path, server_names)
183
+ else:
184
+ _remove_json_servers(path, server_names)
185
+ updated.append(path)
186
+ return updated
187
+
188
+
189
+ def sync_project_mcp_to_editors(project_dir: Path, editors: list[str]) -> list[Path]:
190
+ """Mirror `.mcp.json` into project-scoped editor configs.
191
+
192
+ Claude project settings are always synced when `.mcp.json` exists.
193
+ """
194
+ config_path = project_dir / ".mcp.json"
195
+ if not config_path.is_file():
196
+ return []
197
+
198
+ servers = load_project_mcp_servers(project_dir)
199
+ selected = {"claude"}
200
+ selected.update(e for e in editors if e in PROJECT_SCOPED_EDITORS)
201
+ return install_servers(
202
+ sorted(selected),
203
+ servers,
204
+ scope="project",
205
+ project_dir=project_dir,
206
+ )
207
+
208
+
209
+ def _load_json_file(path: Path) -> dict:
210
+ if not path.is_file():
211
+ return {}
212
+ with open(path, encoding="utf-8") as f:
213
+ data = json.load(f)
214
+ if not isinstance(data, dict):
215
+ raise ValueError(f"{path} must contain a JSON object")
216
+ return data
217
+
218
+
219
+ def _write_json_file(path: Path, data: dict) -> None:
220
+ path.parent.mkdir(parents=True, exist_ok=True)
221
+ with open(path, "w", encoding="utf-8") as f:
222
+ json.dump(data, f, indent=2)
223
+ f.write("\n")
224
+
225
+
226
+ def _normalize_server(editor: str, server: dict) -> dict:
227
+ data = copy.deepcopy(server)
228
+ if editor == "copilot":
229
+ if "url" in data:
230
+ data.setdefault("type", "http")
231
+ elif "command" in data:
232
+ data.setdefault("type", "local")
233
+ data.setdefault("tools", ["*"])
234
+ return data
235
+
236
+
237
+ def _merge_json_servers(path: Path, editor: str, servers: dict) -> None:
238
+ data = _load_json_file(path)
239
+ bucket = data.setdefault("mcpServers", {})
240
+ if not isinstance(bucket, dict):
241
+ raise ValueError(f"{path} has invalid mcpServers data")
242
+ for key, value in servers.items():
243
+ bucket[key] = _normalize_server(editor, value)
244
+ _write_json_file(path, data)
245
+
246
+
247
+ def _remove_json_servers(path: Path, server_names: list[str]) -> None:
248
+ if not path.is_file():
249
+ return
250
+ data = _load_json_file(path)
251
+ bucket = data.get("mcpServers", {})
252
+ if not isinstance(bucket, dict):
253
+ raise ValueError(f"{path} has invalid mcpServers data")
254
+ for name in server_names:
255
+ bucket.pop(name, None)
256
+ data["mcpServers"] = bucket
257
+ _write_json_file(path, data)
258
+
259
+
260
+ def _load_toml_file(path: Path) -> dict:
261
+ if not path.is_file():
262
+ return {}
263
+ if tomllib is None: # pragma: no cover
264
+ raise RuntimeError("tomllib is unavailable")
265
+ return tomllib.loads(path.read_text(encoding="utf-8"))
266
+
267
+
268
+ def _merge_toml_servers(path: Path, servers: dict) -> None:
269
+ data = _load_toml_file(path)
270
+ bucket = data.setdefault("mcp_servers", {})
271
+ if not isinstance(bucket, dict):
272
+ raise ValueError(f"{path} has invalid mcp_servers data")
273
+ for key, value in servers.items():
274
+ bucket[key] = _normalize_toml_server(value)
275
+ _write_toml_file(path, data)
276
+
277
+
278
+ def _remove_toml_servers(path: Path, server_names: list[str]) -> None:
279
+ if not path.is_file():
280
+ return
281
+ data = _load_toml_file(path)
282
+ bucket = data.get("mcp_servers", {})
283
+ if not isinstance(bucket, dict):
284
+ raise ValueError(f"{path} has invalid mcp_servers data")
285
+ for name in server_names:
286
+ bucket.pop(name, None)
287
+ data["mcp_servers"] = bucket
288
+ _write_toml_file(path, data)
289
+
290
+
291
+ def _normalize_toml_server(server: dict) -> dict:
292
+ data: dict = {}
293
+ for key, value in copy.deepcopy(server).items():
294
+ if value in (None, {}, []):
295
+ continue
296
+ data[key] = value
297
+ return data
298
+
299
+
300
+ def _format_toml_value(value) -> str:
301
+ if isinstance(value, bool):
302
+ return "true" if value else "false"
303
+ if isinstance(value, (int, float)):
304
+ return str(value)
305
+ if isinstance(value, str):
306
+ return json.dumps(value)
307
+ if isinstance(value, list):
308
+ return "[" + ", ".join(_format_toml_value(v) for v in value) + "]"
309
+ raise TypeError(f"Unsupported TOML value: {value!r}")
310
+
311
+
312
+ def _format_toml_key(key: str) -> str:
313
+ if key.replace("-", "").replace("_", "").isalnum():
314
+ return key
315
+ return json.dumps(key)
316
+
317
+
318
+ def _render_toml_table(prefix: str, data: dict, lines: list[str]) -> None:
319
+ scalars = [(k, v) for k, v in data.items() if not isinstance(v, dict)]
320
+ tables = [(k, v) for k, v in data.items() if isinstance(v, dict) and v]
321
+
322
+ if prefix:
323
+ lines.append(f"[{prefix}]")
324
+ for key, value in scalars:
325
+ lines.append(f"{_format_toml_key(key)} = {_format_toml_value(value)}")
326
+
327
+ for key, value in tables:
328
+ if lines and lines[-1] != "":
329
+ lines.append("")
330
+ child_key = _format_toml_key(key)
331
+ child_prefix = f"{prefix}.{child_key}" if prefix else child_key
332
+ _render_toml_table(child_prefix, value, lines)
333
+
334
+
335
+ def _write_toml_file(path: Path, data: dict) -> None:
336
+ path.parent.mkdir(parents=True, exist_ok=True)
337
+ lines: list[str] = []
338
+ _render_toml_table("", data, lines)
339
+ text = "\n".join(lines).rstrip() + "\n"
340
+ path.write_text(text, encoding="utf-8")
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- """MCP template manager -- add, remove, list, and inspect MCP server configs.
2
+ """MCP template manager -- add, remove, inspect, and install MCP configs.
3
3
 
4
4
  Usage:
5
5
  mcp_manager.py list List available templates
6
+ mcp_manager.py editors List native editor MCP adapters
6
7
  mcp_manager.py show <name> Show template details
7
8
  mcp_manager.py add <name> [names..] [--target <path>] Add to .mcp.json
9
+ mcp_manager.py install --editor <name[,..]> [--scope project|global] [--target <path>] [name..]
8
10
  mcp_manager.py remove <name> Remove from .mcp.json
9
11
  """
10
12
  from __future__ import annotations
@@ -13,6 +15,14 @@ import json
13
15
  import sys
14
16
  from pathlib import Path
15
17
 
18
+ from mcp_editors import (
19
+ editor_rows,
20
+ install_servers,
21
+ load_project_mcp_servers,
22
+ remove_servers,
23
+ supported_editors,
24
+ )
25
+
16
26
  TOOLKIT_DIR = Path(__file__).resolve().parent.parent
17
27
  TEMPLATES_DIR = TOOLKIT_DIR / "app" / "mcp-templates"
18
28
  MCP_CONFIG_NAME = ".mcp.json"
@@ -85,6 +95,20 @@ def cmd_list() -> None:
85
95
  print(f"Add with: ai-toolkit mcp add <name>")
86
96
 
87
97
 
98
+ def cmd_editors() -> None:
99
+ """List editors with native MCP config adapters."""
100
+ rows = editor_rows()
101
+ print(f"{'Editor':<12} {'Scope':<18} {'Project Path':<28} {'Global Path'}")
102
+ print("-" * 110)
103
+ for row in rows:
104
+ print(
105
+ f"{row['name']:<12} {row['scope']:<18} "
106
+ f"{row['project_path']:<28} {row['global_path']}"
107
+ )
108
+ print()
109
+ print(f"{len(rows)} editors supported")
110
+
111
+
88
112
  def cmd_show(name: str) -> None:
89
113
  """Show details of a specific template."""
90
114
  data = load_template(name)
@@ -132,14 +156,84 @@ def cmd_add(names: list[str], target_dir: Path) -> None:
132
156
  print(f"Added: {', '.join(added)}")
133
157
 
134
158
 
135
- def cmd_remove(name: str, target_dir: Path) -> None:
159
+ def cmd_install(
160
+ names: list[str],
161
+ editors: list[str],
162
+ *,
163
+ target_dir: Path | None,
164
+ scope: str | None,
165
+ ) -> None:
166
+ """Install MCP templates into native editor config files."""
167
+ if not editors:
168
+ print("Error: install requires --editor <name[,..]>", file=sys.stderr)
169
+ print(
170
+ f"Supported editors: {', '.join(supported_editors())}",
171
+ file=sys.stderr,
172
+ )
173
+ sys.exit(1)
174
+
175
+ eff_scope = scope or ("project" if target_dir is not None or not names else "global")
176
+ if eff_scope == "project":
177
+ project_dir = target_dir or Path.cwd()
178
+ if names:
179
+ cmd_add(names, project_dir)
180
+ servers = {}
181
+ for name in names:
182
+ servers.update(load_template(name).get("mcpServers", {}))
183
+ else:
184
+ servers = load_project_mcp_servers(project_dir)
185
+ updated = install_servers(
186
+ editors,
187
+ servers,
188
+ scope="project",
189
+ project_dir=project_dir,
190
+ )
191
+ else:
192
+ if not names:
193
+ print(
194
+ "Error: global install requires at least one template name.",
195
+ file=sys.stderr,
196
+ )
197
+ sys.exit(1)
198
+ servers = {}
199
+ for name in names:
200
+ servers.update(load_template(name).get("mcpServers", {}))
201
+ updated = install_servers(editors, servers, scope="global")
202
+
203
+ for path in updated:
204
+ print(f"Updated: {path}")
205
+
206
+
207
+ def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope: str | None) -> None:
136
208
  """Remove an MCP server from .mcp.json."""
137
- config_path = target_dir / MCP_CONFIG_NAME
209
+ if editors:
210
+ eff_scope = scope or ("project" if target_dir else "global")
211
+ if eff_scope == "project":
212
+ project_dir = target_dir or Path.cwd()
213
+ config_path = project_dir / MCP_CONFIG_NAME
214
+ if config_path.is_file():
215
+ config = load_mcp_config(project_dir)
216
+ config.get("mcpServers", {}).pop(name, None)
217
+ write_mcp_config(project_dir, config)
218
+ updated = remove_servers(
219
+ editors,
220
+ [name],
221
+ scope="project",
222
+ project_dir=project_dir,
223
+ )
224
+ else:
225
+ updated = remove_servers(editors, [name], scope="global")
226
+ for path in updated:
227
+ print(f"Updated: {path}")
228
+ print(f"Removed: {name}")
229
+ return
230
+
231
+ config_path = (target_dir or Path.cwd()) / MCP_CONFIG_NAME
138
232
  if not config_path.is_file():
139
233
  print(f"Error: {config_path} not found.", file=sys.stderr)
140
234
  sys.exit(1)
141
235
 
142
- config = load_mcp_config(target_dir)
236
+ config = load_mcp_config(target_dir or Path.cwd())
143
237
  servers = config.get("mcpServers", {})
144
238
 
145
239
  if name not in servers:
@@ -148,7 +242,7 @@ def cmd_remove(name: str, target_dir: Path) -> None:
148
242
  sys.exit(1)
149
243
 
150
244
  del servers[name]
151
- write_mcp_config(target_dir, config)
245
+ write_mcp_config(target_dir or Path.cwd(), config)
152
246
  print(f"Removed: {name}")
153
247
 
154
248
 
@@ -156,19 +250,27 @@ def cmd_remove(name: str, target_dir: Path) -> None:
156
250
  # Argument parsing
157
251
  # ---------------------------------------------------------------------------
158
252
 
159
- def parse_target(args: list[str]) -> tuple[list[str], Path]:
160
- """Extract --target <path> from args, return (remaining_args, target_dir)."""
161
- target_dir = Path.cwd()
253
+ def parse_options(args: list[str]) -> tuple[list[str], Path | None, list[str], str | None]:
254
+ """Extract common MCP CLI options."""
255
+ target_dir: Path | None = None
256
+ editors: list[str] = []
257
+ scope: str | None = None
162
258
  remaining = []
163
259
  i = 0
164
260
  while i < len(args):
165
261
  if args[i] == "--target" and i + 1 < len(args):
166
262
  target_dir = Path(args[i + 1]).resolve()
167
263
  i += 2
264
+ elif args[i] == "--editor" and i + 1 < len(args):
265
+ editors = [e.strip() for e in args[i + 1].split(",") if e.strip()]
266
+ i += 2
267
+ elif args[i] == "--scope" and i + 1 < len(args):
268
+ scope = args[i + 1]
269
+ i += 2
168
270
  else:
169
271
  remaining.append(args[i])
170
272
  i += 1
171
- return remaining, target_dir
273
+ return remaining, target_dir, editors, scope
172
274
 
173
275
 
174
276
  def main() -> None:
@@ -182,20 +284,30 @@ def main() -> None:
182
284
 
183
285
  if subcmd == "list":
184
286
  cmd_list()
287
+ elif subcmd == "editors":
288
+ cmd_editors()
185
289
  elif subcmd == "show":
186
290
  if not rest:
187
291
  print("Usage: ai-toolkit mcp show <name>", file=sys.stderr)
188
292
  sys.exit(1)
189
293
  cmd_show(rest[0])
190
294
  elif subcmd == "add":
191
- names, target_dir = parse_target(rest)
192
- cmd_add(names, target_dir)
295
+ names, target_dir, _editors, _scope = parse_options(rest)
296
+ cmd_add(names, target_dir or Path.cwd())
297
+ elif subcmd == "install":
298
+ names, target_dir, editors, scope = parse_options(rest)
299
+ cmd_install(names, editors, target_dir=target_dir, scope=scope)
193
300
  elif subcmd == "remove":
194
- names, target_dir = parse_target(rest)
301
+ names, target_dir, editors, scope = parse_options(rest)
195
302
  if not names:
196
303
  print("Usage: ai-toolkit mcp remove <name>", file=sys.stderr)
197
304
  sys.exit(1)
198
- cmd_remove(names[0], target_dir)
305
+ cmd_remove(
306
+ names[0],
307
+ target_dir if editors else (target_dir or Path.cwd()),
308
+ editors=editors,
309
+ scope=scope,
310
+ )
199
311
  else:
200
312
  print(f"Unknown subcommand: {subcmd}", file=sys.stderr)
201
313
  print(__doc__)