@softspark/ai-toolkit 2.0.2 → 2.1.1
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/CHANGELOG.md +49 -0
- package/README.md +143 -774
- package/app/ARCHITECTURE.md +1 -1
- package/app/plugins/README.md +6 -2
- package/app/skills/plugin-creator/SKILL.md +3 -4
- package/bin/ai-toolkit.js +34 -10
- package/kb/procedures/maintenance-sop.md +64 -16
- package/kb/procedures/release-preparation-sop.md +4 -2
- package/kb/procedures/release-verification-sop.md +15 -13
- package/kb/reference/architecture-overview.md +44 -5
- package/kb/reference/claude-ecosystem-expansion-foundations.md +4 -4
- package/kb/reference/cli-reference.md +135 -0
- package/kb/reference/codex-cli-compatibility.md +136 -0
- package/kb/reference/comparison.md +29 -0
- package/kb/reference/extension-api.md +23 -6
- package/kb/reference/global-install-model.md +62 -5
- package/kb/reference/mcp-editor-compatibility.md +62 -0
- package/kb/reference/mcp-templates.md +32 -6
- package/kb/reference/plugin-pack-conventions.md +22 -21
- package/kb/reference/skills-catalog.md +27 -5
- package/kb/reference/unique-features.md +213 -0
- package/llms-full.txt +903 -84
- package/llms.txt +5 -0
- package/package.json +6 -5
- package/scripts/codex_skill_adapter.py +295 -0
- package/scripts/dir_rules_shared.py +46 -7
- package/scripts/generate_agents_md.py +13 -0
- package/scripts/generate_antigravity.py +2 -1
- package/scripts/generate_augment_rules.py +2 -1
- package/scripts/generate_cline_rules.py +13 -3
- package/scripts/generate_codex.py +105 -0
- package/scripts/generate_codex_hooks.py +78 -0
- package/scripts/generate_codex_rules.py +52 -0
- package/scripts/generate_cursor_mdc.py +2 -1
- package/scripts/generate_roo_rules.py +2 -1
- package/scripts/generate_windsurf_rules.py +2 -1
- package/scripts/generator_base.py +15 -0
- package/scripts/install_steps/ai_tools.py +83 -4
- package/scripts/mcp_editors.py +340 -0
- package/scripts/mcp_manager.py +125 -13
- package/scripts/plugin.py +745 -301
- package/scripts/plugin_schema.py +16 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate .codex/hooks.json for OpenAI Codex CLI.
|
|
3
|
+
|
|
4
|
+
Maps compatible ai-toolkit hooks to Codex lifecycle events.
|
|
5
|
+
Hook scripts are shared with Claude Code (stored in ~/.softspark/ai-toolkit/hooks/).
|
|
6
|
+
|
|
7
|
+
Codex supports 5 events: SessionStart, PreToolUse, PostToolUse,
|
|
8
|
+
UserPromptSubmit, Stop. PreToolUse/PostToolUse only support Bash matcher.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 scripts/generate_codex_hooks.py [target-dir]
|
|
12
|
+
|
|
13
|
+
Writes .codex/hooks.json to target-dir.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
HOOKS_PREFIX = '"$HOME/.softspark/ai-toolkit/hooks/'
|
|
23
|
+
|
|
24
|
+
# Hooks compatible with Codex, grouped by event.
|
|
25
|
+
# Format: (matcher, script_name)
|
|
26
|
+
CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
27
|
+
"SessionStart": [
|
|
28
|
+
("startup|resume", "session-start.sh"),
|
|
29
|
+
("startup|resume", "mcp-health.sh"),
|
|
30
|
+
("startup|resume", "session-context.sh"),
|
|
31
|
+
],
|
|
32
|
+
"PreToolUse": [
|
|
33
|
+
("Bash", "guard-destructive.sh"),
|
|
34
|
+
("Bash", "commit-quality.sh"),
|
|
35
|
+
],
|
|
36
|
+
"UserPromptSubmit": [
|
|
37
|
+
("", "user-prompt-submit.sh"),
|
|
38
|
+
("", "track-usage.sh"),
|
|
39
|
+
],
|
|
40
|
+
"Stop": [
|
|
41
|
+
("", "quality-check.sh"),
|
|
42
|
+
("", "save-session.sh"),
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_hooks_json() -> dict:
|
|
48
|
+
"""Build the hooks.json structure for Codex."""
|
|
49
|
+
hooks: dict[str, list] = {}
|
|
50
|
+
for event, entries in CODEX_HOOKS.items():
|
|
51
|
+
hooks[event] = []
|
|
52
|
+
for matcher, script in entries:
|
|
53
|
+
entry: dict = {"hooks": [{"type": "command", "command": f"{HOOKS_PREFIX}{script}\""}]}
|
|
54
|
+
if matcher:
|
|
55
|
+
entry["matcher"] = matcher
|
|
56
|
+
hooks[event].append(entry)
|
|
57
|
+
return {"hooks": hooks}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate(target_dir: Path) -> None:
|
|
61
|
+
"""Write .codex/hooks.json to target_dir."""
|
|
62
|
+
codex_dir = target_dir / ".codex"
|
|
63
|
+
codex_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
hooks_path = codex_dir / "hooks.json"
|
|
65
|
+
data = build_hooks_json()
|
|
66
|
+
with open(hooks_path, "w", encoding="utf-8") as f:
|
|
67
|
+
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
68
|
+
f.write("\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
73
|
+
generate(target)
|
|
74
|
+
print(f"Generated: .codex/hooks.json ({sum(len(v) for v in CODEX_HOOKS.values())} hooks)")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate Codex CLI .agents/rules/ files.
|
|
3
|
+
|
|
4
|
+
Codex discovers rules in .agents/rules/ at the project root.
|
|
5
|
+
This generator follows the same pattern as generate_antigravity.py.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 scripts/generate_codex_rules.py [target-dir]
|
|
9
|
+
|
|
10
|
+
Writes files directly to target-dir/.agents/rules/.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
from dir_rules_shared import (
|
|
19
|
+
STANDARD_RULES,
|
|
20
|
+
STANDARD_SCOPE,
|
|
21
|
+
build_language_rules,
|
|
22
|
+
build_registered_rules,
|
|
23
|
+
write_rules,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate(target_dir: Path, *,
|
|
28
|
+
language_modules: list[str] | None = None,
|
|
29
|
+
rules_dir: Path | None = None,
|
|
30
|
+
cleanup: bool = True,
|
|
31
|
+
managed_scopes: tuple[str, ...] = (STANDARD_SCOPE,)) -> None:
|
|
32
|
+
"""Write .agents/rules/ files to target_dir."""
|
|
33
|
+
rules = dict(STANDARD_RULES)
|
|
34
|
+
rules.update(build_language_rules(language_modules))
|
|
35
|
+
rules.update(build_registered_rules(rules_dir))
|
|
36
|
+
write_rules(
|
|
37
|
+
target_dir,
|
|
38
|
+
rules,
|
|
39
|
+
".agents/rules",
|
|
40
|
+
cleanup=cleanup,
|
|
41
|
+
managed_scopes=managed_scopes,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
47
|
+
from paths import RULES_DIR
|
|
48
|
+
generate(target, rules_dir=RULES_DIR)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
main()
|
|
@@ -149,7 +149,8 @@ def generate(target_dir: Path, *,
|
|
|
149
149
|
|
|
150
150
|
def main() -> None:
|
|
151
151
|
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
152
|
-
|
|
152
|
+
from paths import RULES_DIR
|
|
153
|
+
generate(target, rules_dir=RULES_DIR)
|
|
153
154
|
|
|
154
155
|
|
|
155
156
|
if __name__ == "__main__":
|
|
@@ -27,6 +27,9 @@ Usage::
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
29
|
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from paths import RULES_DIR
|
|
30
33
|
|
|
31
34
|
from emission import (
|
|
32
35
|
count_agents_and_skills,
|
|
@@ -138,3 +141,15 @@ def render_generator(config: dict) -> None:
|
|
|
138
141
|
if config.get("use_markers", True):
|
|
139
142
|
print()
|
|
140
143
|
print_toolkit_end()
|
|
144
|
+
|
|
145
|
+
# Registered custom rules from ~/.softspark/ai-toolkit/rules/
|
|
146
|
+
if RULES_DIR.is_dir():
|
|
147
|
+
for rule_file in sorted(RULES_DIR.glob("*.md")):
|
|
148
|
+
rule_name = rule_file.stem
|
|
149
|
+
print()
|
|
150
|
+
print(f"<!-- TOOLKIT:{rule_name} START -->")
|
|
151
|
+
print("<!-- Auto-injected by ai-toolkit. Re-run to update. -->")
|
|
152
|
+
print()
|
|
153
|
+
print(rule_file.read_text(encoding="utf-8").rstrip())
|
|
154
|
+
print()
|
|
155
|
+
print(f"<!-- TOOLKIT:{rule_name} END -->")
|
|
@@ -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(
|
|
538
|
-
|
|
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")
|