From c6356e17ecdb8daecd76713326777883dfd902e2 Mon Sep 17 00:00:00 2001 From: stack Date: Sat, 11 Jul 2026 15:40:41 +0800 Subject: [PATCH 01/65] =?UTF-8?q?feat(codebuddy):=20=E6=B7=BB=E5=8A=A0=20e?= =?UTF-8?q?nabled=20=E9=85=8D=E7=BD=AE=E9=A1=B9=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- env/platforms/codebuddy.json | 2 + sync/platforms/codebuddy.py | 14 +++- sync/sync_config.py | 18 ++++- sync/validate_env_schema.py | 7 +- sync/validate_platform_keys.py | 4 +- tests/test_codebuddy_sync.py | 120 ++++++++++++++++++++++++++++++++- tests/test_env_validation.py | 10 +++ 7 files changed, 166 insertions(+), 9 deletions(-) diff --git a/env/platforms/codebuddy.json b/env/platforms/codebuddy.json index 12eb14a..1cd9170 100644 --- a/env/platforms/codebuddy.json +++ b/env/platforms/codebuddy.json @@ -1,4 +1,6 @@ { + "enabled": false, + "_comment": "Set enabled=true to sync CodeBuddy models again.", "models": [ { "id": "deepseek-v4-pro", diff --git a/sync/platforms/codebuddy.py b/sync/platforms/codebuddy.py index cb8b6df..785f671 100644 --- a/sync/platforms/codebuddy.py +++ b/sync/platforms/codebuddy.py @@ -91,12 +91,22 @@ def _merge_available_models( def _sync_models(cfg: dict[str, Any]) -> None: models = cfg.get("models") available_models = cfg.get("availableModels") + models_path = codebuddy_models_path() if models is None and available_models is None: - print("[codebuddy] No models config found — skipping model sync.") + existing = read_json_object(models_path) + removed = False + for key in ("models", "availableModels"): + if key in existing: + existing.pop(key, None) + removed = True + if removed: + write_json(models_path, existing) + print(f"[codebuddy] Removed managed models config from {models_path}.") + else: + print("[codebuddy] No models config found — skipping model sync.") return - models_path = codebuddy_models_path() existing = read_json_object(models_path) if models is not None: diff --git a/sync/sync_config.py b/sync/sync_config.py index ce3465a..f691b11 100644 --- a/sync/sync_config.py +++ b/sync/sync_config.py @@ -87,6 +87,20 @@ def _auto_export_env_to_zshrc(platform: str, platform_cfg: dict[str, Any]) -> No sync_env_to_zshrc(platform, env) +def _effective_platform_config(platform: str) -> dict[str, Any]: + """Load platform config and apply orchestration-only keys. + + ``enabled`` is owned by the sync orchestrator. Disabled platforms still run + their renderer with empty platform config so renderer-owned cleanup paths can + remove stale managed target state instead of leaving it behind. + """ + cfg = load_platform_config(platform) + if cfg.get("enabled") is False: + print(f"[sync] Platform '{platform}' disabled via enabled=false — using empty platform config.") + return {} + return {k: v for k, v in cfg.items() if k != "enabled"} + + def main() -> None: mcp_all = load_all_mcp() if not mcp_all: @@ -111,13 +125,13 @@ def main() -> None: for name in valid: fn = all_targets[name] mcp_servers = filter_mcp_for_platform(mcp_all, name) - platform_cfg = load_platform_config(name) + platform_cfg = _effective_platform_config(name) fn(mcp_servers, platform_cfg) _auto_export_env_to_zshrc(name, platform_cfg) elif args.target in all_targets: fn = all_targets[args.target] mcp_servers = filter_mcp_for_platform(mcp_all, args.target) - platform_cfg = load_platform_config(args.target) + platform_cfg = _effective_platform_config(args.target) fn(mcp_servers, platform_cfg) _auto_export_env_to_zshrc(args.target, platform_cfg) else: diff --git a/sync/validate_env_schema.py b/sync/validate_env_schema.py index 1cdcd07..756bd51 100644 --- a/sync/validate_env_schema.py +++ b/sync/validate_env_schema.py @@ -70,7 +70,7 @@ def validate_mcp_file(path: Path) -> list[str]: # ── Platform config schema ──────────────────────────────────────────────────── -COMMON_PLATFORM_FIELDS = {"_comment", "env", "export_env_to_zshrc", "mcp_target"} +COMMON_PLATFORM_FIELDS = {"_comment", "enabled", "env", "export_env_to_zshrc", "mcp_target"} PLATFORM_FIELDS = { # Claude-specific @@ -134,6 +134,11 @@ def validate_platform_file(path: Path) -> list[str]: if not isinstance(data, dict): return [f"{path.name}: root must be a JSON object"] + # Check enabled is a boolean if present + enabled = data.get("enabled") + if enabled is not None and not isinstance(enabled, bool): + errors.append(f"{path.name}: 'enabled' must be a boolean") + # Check env is an object if present env = data.get("env") if env is not None and not isinstance(env, dict): diff --git a/sync/validate_platform_keys.py b/sync/validate_platform_keys.py index 2a69b33..fb60608 100644 --- a/sync/validate_platform_keys.py +++ b/sync/validate_platform_keys.py @@ -24,7 +24,9 @@ from validate_env_schema import known_fields_for_platform # Keys that are handled by the sync engine itself (not synced to settings) -ENGINE_HANDLED_KEYS = {"env", "hooks", "export_env_to_zshrc", "_comment", "_hostSettings", "mcp_target"} +ENGINE_HANDLED_KEYS = { + "enabled", "env", "hooks", "export_env_to_zshrc", "_comment", "_hostSettings", "mcp_target" +} ENGINE_HANDLED_BY_PLATFORM = { "continue": {"path"}, } diff --git a/tests/test_codebuddy_sync.py b/tests/test_codebuddy_sync.py index d22fe91..e3de287 100644 --- a/tests/test_codebuddy_sync.py +++ b/tests/test_codebuddy_sync.py @@ -19,6 +19,42 @@ from platforms import common # noqa: E402 +DEFAULT_CODEBUDDY_CFG = { + "models": [ + { + "id": "deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "vendor": "dataeyes", + "url": "${codebuddy.url}", + "apiKey": "${codebuddy.key}", + "maxInputTokens": 128000, + "maxOutputTokens": 8192, + "supportsToolCall": True, + "supportsImages": False, + "relatedModels": { + "lite": "deepseek-v4-flash", + "reasoning": "deepseek-v4-pro", + }, + }, + { + "id": "deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "vendor": "dataeyes", + "url": "${codebuddy.url}", + "apiKey": "${codebuddy.key}", + "maxInputTokens": 128000, + "maxOutputTokens": 8192, + "supportsToolCall": True, + "supportsImages": False, + }, + ], + "availableModels": [ + "deepseek-v4-pro", + "deepseek-v4-flash", + ], +} + + @contextlib.contextmanager def patched_sync_environment(root: Path): """Redirect HOME and common module paths for isolated CodeBuddy sync tests.""" @@ -46,9 +82,7 @@ class CodeBuddySyncTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) - self.platform_cfg = json.loads( - (REPO_ROOT / "env" / "platforms" / "codebuddy.json").read_text() - ) + self.platform_cfg = DEFAULT_CODEBUDDY_CFG self._write_json( self.root / "env" / "mcp" / "sample.json", { @@ -360,6 +394,86 @@ def test_no_models_config_skips_model_sync(self) -> None: models_path = self.root / "home" / ".codebuddy" / "models.json" self.assertFalse(models_path.exists(), "models.json should not be created without model config") + def test_no_models_config_removes_existing_managed_model_keys(self) -> None: + """When model config is absent, previously synced model keys are removed.""" + models_path = self.root / "home" / ".codebuddy" / "models.json" + self._write_json( + models_path, + { + "meta": {"version": 2}, + "models": [ + { + "id": "deepseek-v4-pro", + "name": "Stale DeepSeek V4 Pro", + "vendor": "old", + } + ], + "availableModels": ["deepseek-v4-pro"], + }, + ) + + result = self._run_codebuddy_sync({}) + + self.assertEqual(result["models"], {"meta": {"version": 2}}) + + def test_disabled_platform_config_removes_existing_managed_model_keys(self) -> None: + """enabled=false keeps config JSON valid while disabling managed model sync.""" + models_path = self.root / "home" / ".codebuddy" / "models.json" + self._write_json( + models_path, + { + "meta": {"version": 2}, + "models": [ + { + "id": "deepseek-v4-pro", + "name": "Stale DeepSeek V4 Pro", + "vendor": "old", + } + ], + "availableModels": ["deepseek-v4-pro"], + }, + ) + cfg = {"enabled": False, **self.platform_cfg} + + result = self._run_codebuddy_sync(cfg) + + self.assertEqual(result["models"], {"meta": {"version": 2}}) + + def test_commented_platform_config_removes_existing_managed_model_keys(self) -> None: + """A fully commented codebuddy.json parses as absent config and clears managed model keys.""" + models_path = self.root / "home" / ".codebuddy" / "models.json" + self._write_json( + models_path, + { + "models": [ + { + "id": "deepseek-v4-flash", + "name": "Stale DeepSeek V4 Flash", + "vendor": "old", + } + ], + "availableModels": ["deepseek-v4-flash"], + }, + ) + config_path = self.root / "env" / "platforms" / "codebuddy.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + "// {\n" + "// \"models\": [\n" + "// {\"id\": \"deepseek-v4-flash\"}\n" + "// ],\n" + "// \"availableModels\": [\"deepseek-v4-flash\"]\n" + "// }\n", + encoding="utf-8", + ) + + with patched_sync_environment(self.root): + sys.argv = ["sync_config.py", "--target", "codebuddy"] + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + sync_config.main() + + self.assertEqual(self._read_json(models_path), {}) + def test_models_only_sync(self) -> None: """When config has only 'models' but no 'availableModels', only models are synced.""" cfg: dict = {"models": self.platform_cfg["models"]} diff --git a/tests/test_env_validation.py b/tests/test_env_validation.py index 3ecf485..a5c5fa4 100644 --- a/tests/test_env_validation.py +++ b/tests/test_env_validation.py @@ -17,6 +17,7 @@ class PlatformSchemaValidationTests(unittest.TestCase): def test_known_fields_are_platform_scoped(self) -> None: self.assertIn("theme", known_fields_for_platform("claude")) self.assertNotIn("theme", known_fields_for_platform("codebuddy")) + self.assertIn("enabled", known_fields_for_platform("codebuddy")) def test_cross_platform_field_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -36,6 +37,15 @@ def test_cross_platform_field_is_rejected(self) -> None: self.assertEqual(["codebuddy.json: unknown fields: theme"], errors) + def test_enabled_must_be_boolean(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "codebuddy.json" + path.write_text(json.dumps({"enabled": "false"}), encoding="utf-8") + + errors = validate_platform_file(path) + + self.assertEqual(["codebuddy.json: 'enabled' must be a boolean"], errors) + if __name__ == "__main__": unittest.main() From e5ec2f3fb5e1880be6157032ee88d502133c7235 Mon Sep 17 00:00:00 2001 From: stack Date: Mon, 13 Jul 2026 14:30:45 +0800 Subject: [PATCH 02/65] feat: add Cline platform support with global state and secrets sync - Introduced Cline platform configuration in `env/platforms/cline.json`. - Implemented syncing of global state and secrets for Cline in `sync/platforms/cline.py`. - Updated README and documentation to reflect Cline integration. - Enhanced sync process to skip platforms not installed, including Cline, Codex, Claude, CodeBuddy, Gemini, and Continue. - Added tests to ensure proper handling of missing platform directories and configurations. - Refactored sync logic to improve clarity and maintainability across platforms. --- README.md | 3 +- docs/index.md | 2 +- env/README.md | 1 + env/platforms/cline.json | 14 +++ env/secrets.json.example | 4 + sync/README.md | 18 ++- sync/platforms/claude.py | 20 +++- sync/platforms/cline.py | 101 +++++++++++++++- sync/platforms/codebuddy.py | 13 ++- sync/platforms/codex.py | 21 ++-- sync/platforms/common.py | 3 +- sync/platforms/continue.py | 19 +-- sync/platforms/gemini.py | 16 ++- sync/platforms/paths.py | 71 +++++++++++- sync/sync_all.sh | 9 +- sync/sync_config.py | 27 +++-- sync/validate_env_schema.py | 2 + tests/test_claude_sync.py | 46 +++++++- tests/test_codebuddy_sync.py | 11 ++ tests/test_codex_sync.py | 48 ++++++++ tests/test_gemini_sync.py | 77 +++++++++--- tests/test_platform_install_root_skip.py | 142 +++++++++++++++++++++++ 22 files changed, 600 insertions(+), 68 deletions(-) create mode 100644 env/platforms/cline.json create mode 100644 tests/test_platform_install_root_skip.py diff --git a/README.md b/README.md index 74936ca..6ab4fc1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ bash sync.sh | **Cursor** | `.cursor/mcp.json` | | **CodeBuddy** | `.codebuddy/mcp.json`, `models.json`, `skills/` | | **Claude Code** | `.claude.json`, `settings.json`, `skills/` | -| **Codex CLI** | `.codex/config.toml`, `mcp.generated.toml` | +| **Codex CLI** | `.codex/config.toml` | | **Gemini CLI** | Environment variables | | **Continue** | `.continue/config.yaml` | | **Cline** (VSCode) | MCP settings JSON, `skills/` | @@ -81,4 +81,3 @@ bash install-hooks.sh ## License [MIT](LICENSE) - diff --git a/docs/index.md b/docs/index.md index 3a61dfc..ba2e5f2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,7 @@ npm install -g @i-stack/ai-coding-kit | **Cursor** | `.cursor/mcp.json` | | **CodeBuddy** | `.codebuddy/mcp.json`, `models.json`, `skills/` | | **Claude Code** | `.claude.json`, `settings.json`, `skills/` | -| **Codex CLI** | `.codex/config.toml`, `mcp.generated.toml` | +| **Codex CLI** | `.codex/config.toml` | | **Gemini CLI** | Environment variables | | **Continue** | `.continue/config.yaml` | | **Cline** (VSCode) | MCP settings JSON, `skills/` | diff --git a/env/README.md b/env/README.md index e9bbbab..0c3d236 100644 --- a/env/README.md +++ b/env/README.md @@ -23,6 +23,7 @@ env/ │ ├── platforms/ ← 平台专属配置 │ ├── claude.json +│ ├── cline.json │ ├── codex.json │ ├── codebuddy.json │ ├── continue.json diff --git a/env/platforms/cline.json b/env/platforms/cline.json new file mode 100644 index 0000000..6a96324 --- /dev/null +++ b/env/platforms/cline.json @@ -0,0 +1,14 @@ +{ + "_comment": "Cline (VSCode extension) global state + secrets sync. Set 'enabled' to true to sync the 5 globalState keys + geminiApiKey; set to false to clear geminiBaseUrl (disable the third-party API) while leaving the other keys and the secret intact. The 5 keys under 'globalState' are merged into ~/.cline/data/globalState.json (all other keys are preserved). 'geminiBaseUrl' uses ${cline.url}; leave cline.url empty in secrets.json to disable the third-party API (geminiBaseUrl becomes \"\"). The 'secrets' object is merged into ~/.cline/data/secrets.json — Cline stores the key under the ApiKey key, e.g. geminiApiKey for provider 'gemini'.", + "enabled": true, + "globalState": { + "planModeApiProvider": "gemini", + "actModeApiProvider": "gemini", + "geminiBaseUrl": "${cline.url}", + "planModeApiModelId": "gemini-3.5-flash", + "actModeApiModelId": "gemini-3.5-flash" + }, + "secrets": { + "geminiApiKey": "${cline.key}" + } +} \ No newline at end of file diff --git a/env/secrets.json.example b/env/secrets.json.example index ffcc27a..b1a62bb 100644 --- a/env/secrets.json.example +++ b/env/secrets.json.example @@ -26,6 +26,10 @@ "url": "https://your-gemini-proxy.example.com", "key": "sk-your-gemini-api-key" }, + "cline": { + "url": "", + "key": "sk-your-cline-gemini-api-key" + }, "postgres": { "connection_string": "postgresql://user:password@localhost:5432/your_database" }, diff --git a/sync/README.md b/sync/README.md index 0409897..27f38ed 100644 --- a/sync/README.md +++ b/sync/README.md @@ -97,23 +97,33 @@ Each `env/platforms/.json` follows that platform's **official configuratio | Gemini | `gemini.json` | Gemini CLI env vars | | Continue | `continue.json` | Continue `config.yaml` models | | Cursor | `cursor.json` | (no platform config needed) | -| Cline | `cline.json` | (no platform config needed) | +| Cline | `cline.json` | Merge `globalState` + `secrets` into `~/.cline/data/` | The JSON keys map directly to the platform's native format — no field name translation needed. ## Targets +For Cline, Codex, Claude, CodeBuddy, Gemini, and Continue, sync first checks +the tool's home directory (`~/.cline`, `~/.codex`, `~/.claude`, +`~/.codebuddy`, `~/.gemini`, `~/.continue`). If that root does not exist, the +target is skipped so sync does not create config for tools the user has not +installed. + +Xcode CodingAssistant targets are checked separately. If +`~/Library/Developer/Xcode/CodingAssistant` does not exist, native CLI targets +still sync, but the Xcode-specific Codex / Claude / Gemini outputs are skipped. + | Target | Output | |--------|--------| | Cursor | Replace `mcpServers` in `~/.cursor/mcp.json` | | CodeBuddy | Replace `mcpServers` in `~/.codebuddy/mcp.json`, sync `models.json`, skills | -| Codex CLI | `~/.codex/mcp.generated.toml` + managed blocks in `config.toml` | +| Codex CLI | Managed MCP + shared blocks in `~/.codex/config.toml` | | Xcode Codex | `~/Library/.../CodingAssistant/codex/` | | Claude Code | Replace `mcpServers` in `~/.claude.json` + Xcode Claude | | Claude settings | Merge `env` + `hooks` into `~/.claude/settings.json`, set `~/.claude/config.json` `primaryApiKey` to `self` | -| Cline | Replace `mcpServers` in VSCode extension settings + skills sync | +| Cline | Replace `mcpServers` in VSCode extension settings + skills sync + merge `globalState`/`secrets` into `~/.cline/data/` | | Gemini CLI | Replace `mcpServers` in `~/.gemini/settings.json` + `~/.zshrc` env | -| Continue | Update `mcpServers` + `models` in `~/.continue/config.yaml` | +| Continue | Update `mcpServers` + `models` in `~/.continue/config.yaml`, creating it when `~/.continue` exists | ## Adding a Platform diff --git a/sync/platforms/claude.py b/sync/platforms/claude.py index 40eaf79..ec7007c 100644 --- a/sync/platforms/claude.py +++ b/sync/platforms/claude.py @@ -7,7 +7,9 @@ claude_config_json_path, claude_hooks_dir_path, claude_json_path, + claude_root_dir, claude_settings_json_path, + xcode_coding_assistant_exists, xcode_claude_dir, xcode_claude_json_path, ) @@ -217,6 +219,11 @@ def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: 5. Sync team-shared settings, env, and hooks to Xcode Claude Agent. 6. Install hook shell scripts. """ + root = claude_root_dir() + if not root.exists(): + print(f"[claude] Claude root not found: {root} — skipping (tool not installed).") + return + # ── 1. ~/.claude.json — MCP servers ── cj_path = claude_json_path() claude = read_json_object(cj_path) @@ -224,8 +231,12 @@ def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: write_json(cj_path, claude) print(f"Replaced MCP servers in {cj_path} (other top-level config preserved).") - # ── 2. Xcode Claude Agent ── - _sync_xcode_claude_json(mcp_servers) + xcode_available = xcode_coding_assistant_exists() + if xcode_available: + # ── 2. Xcode Claude Agent ── + _sync_xcode_claude_json(mcp_servers) + else: + print("[claude] Xcode CodingAssistant path not found — skipping Xcode Claude sync.") # ── 3. config.json — avoid Claude Code login prompt with third-party API ── _sync_claude_config() @@ -265,5 +276,6 @@ def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: write_json(settings_path, settings) - # ── 5. Xcode Claude Agent — settings ── - _sync_xcode_claude_settings(managed, env, config_hooks) + if xcode_available: + # ── 5. Xcode Claude Agent — settings ── + _sync_xcode_claude_settings(managed, env, config_hooks) diff --git a/sync/platforms/cline.py b/sync/platforms/cline.py index 9137bf4..34e0316 100644 --- a/sync/platforms/cline.py +++ b/sync/platforms/cline.py @@ -1,8 +1,19 @@ +import re import shutil from typing import Any from .common import read_json_object, write_json -from .paths import claude_skills_base, cline_mcp_candidate_paths, cline_skills_base +from .paths import ( + claude_skills_base, + cline_root_dir, + cline_global_state_path, + cline_mcp_candidate_paths, + cline_secrets_path, + cline_skills_base, +) + +# Matches a value that is still a single unresolved ${VAR} placeholder. +_UNRESOLVED_PLACEHOLDER_RE = re.compile(r"\A\$\{[^}]+\}\Z") def _sync_mcp(servers: dict[str, Any]) -> None: @@ -40,7 +51,93 @@ def _sync_skills() -> None: print(f"Synced {len(synced)} skills to {cline_skills_dir}: {', '.join(synced) or '(none)'}.") +def _clear_global_state_base_url() -> None: + """Disabled-platform cleanup: reset geminiBaseUrl to empty. + + Called when the platform is disabled (enabled=false). The other global + state keys and the secret are left untouched — only the third-party API + base URL is cleared. No-op if it is already empty. + """ + path = cline_global_state_path() + existing = read_json_object(path) + if not existing.get("geminiBaseUrl"): + print(f"[cline] geminiBaseUrl already empty in {path} — nothing to clear.") + return + existing["geminiBaseUrl"] = "" + write_json(path, existing) + print(f"Cleared geminiBaseUrl in {path} (platform disabled).") + + +def _sync_global_state(managed: dict[str, Any]) -> None: + """Merge the 5 managed keys into ~/.cline/data/globalState.json. + + Preserves every other key in the file (welcome state, auto-approval + settings, workspace roots, etc.). Unresolved ${VAR} placeholders are + skipped so a missing cline.url never writes literal "${cline.url}" into + the user's global state. + """ + if not managed: + return + path = cline_global_state_path() + existing = read_json_object(path) + merged = dict(existing) + applied = 0 + for key, value in managed.items(): + if isinstance(value, str) and _UNRESOLVED_PLACEHOLDER_RE.match(value): + print(f"[cline] Skipping globalState.{key}: unresolved placeholder {value} — set cline.url in secrets.json.") + continue + merged[key] = value + applied += 1 + if applied: + write_json(path, merged) + print(f"Synced {applied} global state key(s) to {path}.") + else: + print("[cline] No resolvable global state keys to sync — skipping.") + + +def _sync_secrets(secrets: dict[str, Any]) -> None: + """Merge API secrets into ~/.cline/data/secrets.json. + + Cline stores each provider's key under the ApiKey key + (e.g. geminiApiKey). Existing keys for other providers are preserved. + Unresolved ${VAR} placeholders are skipped to avoid writing garbage. + """ + if not secrets: + return + path = cline_secrets_path() + existing = read_json_object(path) + merged = dict(existing) + applied = 0 + for key, value in secrets.items(): + if isinstance(value, str) and _UNRESOLVED_PLACEHOLDER_RE.match(value): + print(f"[cline] Skipping secret '{key}': unresolved placeholder {value} — set cline.key in secrets.json.") + continue + merged[key] = value + applied += 1 + if applied: + write_json(path, merged) + print(f"Synced {applied} secret key(s) to {path}.") + else: + print("[cline] No resolvable secrets to sync — skipping.") + + def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: - """Sync MCP servers and skills to Cline (VSCode extension).""" + """Sync MCP servers, skills, global state, and secrets to Cline (VSCode extension). + + When the platform is disabled (enabled=false), the orchestrator passes an + empty cfg. In that case MCP servers and skills are still synced, but the + managed global state is cleaned up by clearing geminiBaseUrl (disabling the + third-party API) while leaving the other keys and the secret intact. + """ + root = cline_root_dir() + if not root.exists(): + print(f"[cline] Cline root not found: {root} — skipping (tool not installed).") + return + _sync_mcp(mcp_servers) _sync_skills() + if not cfg: + _clear_global_state_base_url() + return + _sync_global_state(cfg.get("globalState", {})) + _sync_secrets(cfg.get("secrets", {})) diff --git a/sync/platforms/codebuddy.py b/sync/platforms/codebuddy.py index 785f671..94f07c8 100644 --- a/sync/platforms/codebuddy.py +++ b/sync/platforms/codebuddy.py @@ -2,7 +2,13 @@ from typing import Any from .common import read_json_object, sync_json_mcp, write_json -from .paths import codebuddy_mcp_path, codebuddy_models_path, codebuddy_skills_base, claude_skills_base +from .paths import ( + claude_skills_base, + codebuddy_mcp_path, + codebuddy_models_path, + codebuddy_root_dir, + codebuddy_skills_base, +) def _validate_model_entries(value: Any) -> list[dict[str, Any]]: @@ -173,6 +179,11 @@ def _sync_skills() -> None: def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: """Sync MCP servers, models, and skills to CodeBuddy.""" + root = codebuddy_root_dir() + if not root.exists(): + print(f"[codebuddy] CodeBuddy root not found: {root} — skipping (tool not installed).") + return + sync_json_mcp(codebuddy_mcp_path(), mcp_servers) _sync_models(cfg) _sync_skills() diff --git a/sync/platforms/codex.py b/sync/platforms/codex.py index 2d7ba1e..a2ac054 100644 --- a/sync/platforms/codex.py +++ b/sync/platforms/codex.py @@ -4,7 +4,7 @@ from .common import ( codex_config_path, - codex_generated_toml_path, + codex_root_dir, load_platform_config, toml_array, toml_header_key_segment, @@ -13,6 +13,7 @@ toml_section, toml_value, xcode_codex_dir, + xcode_coding_assistant_exists, ) MCP_BEGIN = "# BEGIN MCP SYNC (from env/mcp/)" @@ -165,22 +166,22 @@ def merge_managed_blocks(cfg_path: Path, shared_body: str, mcp_body: str) -> Non def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: """Sync MCP servers and Codex platform config to native TOML format.""" + root = codex_root_dir() + if not root.exists(): + print(f"[codex] Codex root not found: {root} — skipping (tool not installed).") + return + generated = generate_mcp_toml(mcp_servers) shared = generate_shared_toml(cfg) - # Write standalone mcp.generated.toml - out = codex_generated_toml_path() - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(generated, encoding="utf-8") - print(f"Wrote: {out}") - # Merge into config.toml merge_managed_blocks(codex_config_path(), shared, generated) # Xcode Codex target + if not xcode_coding_assistant_exists(): + print("[codex] Xcode CodingAssistant path not found — skipping Xcode Codex sync.") + return + xc = xcode_codex_dir() xc.mkdir(parents=True, exist_ok=True) - xc_gen = xc / "mcp.generated.toml" - xc_gen.write_text(generated, encoding="utf-8") - print(f"Wrote: {xc_gen}") merge_managed_blocks(xc / "config.toml", shared, generated) diff --git a/sync/platforms/common.py b/sync/platforms/common.py index bad081e..ccbb64c 100644 --- a/sync/platforms/common.py +++ b/sync/platforms/common.py @@ -311,8 +311,9 @@ def merge_object(existing: Any, updates: dict[str, Any]) -> dict[str, Any]: from .paths import ( # noqa: F401 codex_config_path, - codex_generated_toml_path, + codex_root_dir, xcode_codex_dir, + xcode_coding_assistant_exists, xcode_gemini_dir, gemini_settings_path, ) diff --git a/sync/platforms/continue.py b/sync/platforms/continue.py index c6cffd6..8d5194d 100644 --- a/sync/platforms/continue.py +++ b/sync/platforms/continue.py @@ -1,8 +1,7 @@ -import os from pathlib import Path from typing import Any -from .common import load_platform_config +from .paths import continue_root_dir def dump_yaml_scalar(v: Any) -> str: @@ -113,14 +112,20 @@ def update_yaml_root_key(yaml_text: str, key_name: str, new_key_yaml: str) -> st def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: """Sync MCP servers and models to Continue (YAML format).""" + root = continue_root_dir() + if not root.exists(): + print(f"[continue] Continue root not found: {root} — skipping (tool not installed).") + return + path_str = cfg.get("path", "~/.continue/config.yaml") target_path = Path(path_str).expanduser() - if not target_path.exists(): - print(f"[warn] Continue configuration file does not exist at {target_path}. Skipping.") - return - - yaml_text = target_path.read_text(encoding="utf-8") + if target_path.exists(): + yaml_text = target_path.read_text(encoding="utf-8") + else: + target_path.parent.mkdir(parents=True, exist_ok=True) + yaml_text = "" + print(f"[continue] Continue configuration file not found at {target_path} — creating it.") # 1. Sync mcpServers continue_servers = [] diff --git a/sync/platforms/gemini.py b/sync/platforms/gemini.py index 9b32fa4..e1099f4 100644 --- a/sync/platforms/gemini.py +++ b/sync/platforms/gemini.py @@ -2,7 +2,12 @@ from typing import Any from .common import read_json_object, write_json -from .paths import gemini_settings_path, xcode_gemini_dir +from .paths import ( + gemini_root_dir, + gemini_settings_path, + xcode_coding_assistant_exists, + xcode_gemini_dir, +) # Internal/platform keys that should NOT appear in the managed settings.json. # These are consumed by the sync engine/orchestrator, not by Gemini CLI itself. @@ -55,12 +60,21 @@ def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: to ~/.zshrc by the orchestrator via the export_env_to_zshrc mechanism defined in env/platforms/gemini.json. """ + root = gemini_root_dir() + if not root.exists(): + print(f"[gemini] Gemini root not found: {root} — skipping (tool not installed).") + return + managed = _extract_settings(cfg) # ── Native Gemini CLI target ── _sync_settings(gemini_settings_path(), managed, mcp_servers) # ── Xcode CodingAssistant target ── + if not xcode_coding_assistant_exists(): + print("[gemini] Xcode CodingAssistant path not found — skipping Xcode Gemini sync.") + return + xc = xcode_gemini_dir() xc.mkdir(parents=True, exist_ok=True) _sync_settings(xc / "settings.json", managed, mcp_servers) diff --git a/sync/platforms/paths.py b/sync/platforms/paths.py index 8772648..8263dec 100644 --- a/sync/platforms/paths.py +++ b/sync/platforms/paths.py @@ -23,6 +23,10 @@ def xcode_coding_assistant_dir() -> Path: return _home() / "Library/Developer/Xcode/CodingAssistant" +def xcode_coding_assistant_exists() -> bool: + return xcode_coding_assistant_dir().exists() + + def xcode_codex_dir() -> Path: """Xcode Codex agent config directory.""" return xcode_coding_assistant_dir() / "codex" @@ -54,17 +58,23 @@ def codex_config_path() -> Path: return _home() / ".codex/config.toml" -def codex_generated_toml_path() -> Path: +def codex_root_dir() -> Path: import os if home := os.environ.get("CODEX_HOME"): - return Path(home).expanduser() / "mcp.generated.toml" - return _home() / ".codex/mcp.generated.toml" + return Path(home).expanduser() + if p := os.environ.get("CODEX_CONFIG"): + return Path(p).expanduser().parent + return _home() / ".codex" def claude_json_path() -> Path: return _home() / ".claude.json" +def claude_root_dir() -> Path: + return _home() / ".claude" + + def claude_settings_json_path() -> Path: return _home() / ".claude" / "settings.json" @@ -81,6 +91,10 @@ def gemini_settings_path() -> Path: return _home() / ".gemini/settings.json" +def gemini_root_dir() -> Path: + return _home() / ".gemini" + + def cursor_mcp_path() -> Path: return _home() / ".cursor/mcp.json" @@ -89,6 +103,10 @@ def codebuddy_mcp_path() -> Path: return _home() / ".codebuddy/mcp.json" +def codebuddy_root_dir() -> Path: + return _home() / ".codebuddy" + + def codebuddy_models_path() -> Path: return _home() / ".codebuddy/models.json" @@ -101,6 +119,31 @@ def cline_mcp_candidate_paths() -> list[Path]: ] +def cline_root_dir() -> Path: + return _home() / ".cline" + + +def continue_root_dir() -> Path: + return _home() / ".continue" + + +# ── Cline data paths ────────────────────────────────────────────────────────── + +def cline_data_dir() -> Path: + """~/.cline/data — Cline's global state + secrets storage.""" + return _home() / ".cline" / "data" + + +def cline_global_state_path() -> Path: + """~/.cline/data/globalState.json — Cline's global VS Code state.""" + return cline_data_dir() / "globalState.json" + + +def cline_secrets_path() -> Path: + """~/.cline/data/secrets.json — Cline's encrypted-at-rest API secrets.""" + return cline_data_dir() / "secrets.json" + + # ── Skill cache base directories ───────────────────────────────────────────── def codex_skills_base() -> Path: @@ -133,3 +176,25 @@ def cline_skills_base() -> Path: def codebuddy_skills_base() -> Path: return _home() / ".codebuddy/skills" + + +_INSTALL_ROOTS = { + "cline": cline_root_dir, + "codex": codex_root_dir, + "claude": claude_root_dir, + "codebuddy": codebuddy_root_dir, + "gemini": gemini_root_dir, + "continue": continue_root_dir, +} + + +def platform_install_root(platform: str) -> Path | None: + getter = _INSTALL_ROOTS.get(platform) + if getter is None: + return None + return getter() + + +def platform_is_installed(platform: str) -> bool: + root = platform_install_root(platform) + return root is None or root.exists() diff --git a/sync/sync_all.sh b/sync/sync_all.sh index c0d4c3f..277920b 100755 --- a/sync/sync_all.sh +++ b/sync/sync_all.sh @@ -7,15 +7,18 @@ # # Targets: # 1) Cursor: generate ~/.cursor/mcp.json with mcpServers. -# 2) Codex CLI + Xcode Coding Assistant: regenerate ~/.codex/mcp.generated.toml and -# ~/Library/Developer/Xcode/CodingAssistant/codex/mcp.generated.toml, then merge the -# MCP and CODEX SHARED marker blocks into each config.toml. +# 2) Codex CLI + Xcode Coding Assistant: merge the MCP and CODEX SHARED +# marker blocks into each config.toml. # 3) Claude Code: replace mcpServers in ~/.claude.json and in Xcode's # ~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json # (per-project mcpServers), plus env into ~/.claude/settings.json and # primaryApiKey=self into ~/.claude/config.json. # 4) Cline: replace mcpServers in the VSCode extension MCP settings JSON, and copy # skills from ~/.claude/skills/ into ~/.cline/skills/. +# Cline, Codex, Claude, CodeBuddy, Gemini, and Continue are skipped when their +# tool home directory does not exist. +# Xcode targets are skipped when ~/Library/Developer/Xcode/CodingAssistant +# does not exist. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/sync/sync_config.py b/sync/sync_config.py index f691b11..99f313c 100644 --- a/sync/sync_config.py +++ b/sync/sync_config.py @@ -16,6 +16,7 @@ from platforms import claude, cline, codebuddy, codex, cursor, gemini from platforms.common import discover_platforms, filter_mcp_for_platform, load_all_mcp, load_platform_config, sync_env_to_zshrc +from platforms.paths import platform_install_root, platform_is_installed # continue.py contains 'continue' keyword which can't be a Python import name. import importlib as _importlib @@ -101,6 +102,20 @@ def _effective_platform_config(platform: str) -> dict[str, Any]: return {k: v for k, v in cfg.items() if k != "enabled"} +def _sync_one_platform( + name: str, fn: SyncFn, mcp_all: dict[str, Any] +) -> None: + root = platform_install_root(name) + if root is not None and not platform_is_installed(name): + print(f"[sync] Platform '{name}' root not found: {root} — skipping (tool not installed).") + return + + mcp_servers = filter_mcp_for_platform(mcp_all, name) + platform_cfg = _effective_platform_config(name) + fn(mcp_servers, platform_cfg) + _auto_export_env_to_zshrc(name, platform_cfg) + + def main() -> None: mcp_all = load_all_mcp() if not mcp_all: @@ -123,17 +138,9 @@ def main() -> None: if args.target == "all": for name in valid: - fn = all_targets[name] - mcp_servers = filter_mcp_for_platform(mcp_all, name) - platform_cfg = _effective_platform_config(name) - fn(mcp_servers, platform_cfg) - _auto_export_env_to_zshrc(name, platform_cfg) + _sync_one_platform(name, all_targets[name], mcp_all) elif args.target in all_targets: - fn = all_targets[args.target] - mcp_servers = filter_mcp_for_platform(mcp_all, args.target) - platform_cfg = _effective_platform_config(args.target) - fn(mcp_servers, platform_cfg) - _auto_export_env_to_zshrc(args.target, platform_cfg) + _sync_one_platform(args.target, all_targets[args.target], mcp_all) else: print(f"[error] Unknown target '{args.target}'. Valid: all, {', '.join(valid)}", file=sys.stderr) raise SystemExit(1) diff --git a/sync/validate_env_schema.py b/sync/validate_env_schema.py index 756bd51..c42e8e3 100644 --- a/sync/validate_env_schema.py +++ b/sync/validate_env_schema.py @@ -114,6 +114,8 @@ def validate_mcp_file(path: Path) -> list[str]: "primary_model", "fallback_model", "model", "context", "tools", "skills", "hooksConfig", "security", "experimental", "contextManagement", }, + # Cline-specific + "cline": {"globalState", "secrets"}, } diff --git a/tests/test_claude_sync.py b/tests/test_claude_sync.py index e8de3ca..8ebc9b0 100644 --- a/tests/test_claude_sync.py +++ b/tests/test_claude_sync.py @@ -61,6 +61,7 @@ def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) self.home = self.root / "home" + (self.home / ".claude").mkdir(parents=True, exist_ok=True) self.platform_cfg = json.loads( (REPO_ROOT / "env" / "platforms" / "claude.json").read_text() ) @@ -440,9 +441,12 @@ def test_xcode_claude_settings_env_merged(self) -> None: def test_xcode_claude_settings_hooks_merged(self) -> None: """hooks must be merged into the Xcode Claude Agent settings.json.""" + xc_settings_dir = self.home / "Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude" + xc_settings_dir.mkdir(parents=True, exist_ok=True) + _run_claude_sync(self.root, self.platform_cfg) - xc_settings = self.home / "Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude/settings.json" + xc_settings = xc_settings_dir / "settings.json" self.assertTrue(xc_settings.exists(), f"Missing {xc_settings}") settings = self._read_json(xc_settings) @@ -503,6 +507,24 @@ def test_claude_json_properties_are_mapped_or_excluded_as_expected(self) -> None self.assertIn("env", settings) self.assertIn("hooks", settings) + def test_missing_xcode_path_skips_xcode_claude_target_only(self) -> None: + """When Xcode CodingAssistant is absent, native Claude sync still runs.""" + _run_claude_sync(self.root, self.platform_cfg) + + data = self._read_json(self.home / ".claude.json") + settings = self._read_json(self.home / ".claude" / "settings.json") + self.assertIn("sample", data["mcpServers"]) + self.assertEqual(settings["model"], "claude-sonnet-4-6") + self.assertFalse( + ( + self.home + / "Library" + / "Developer" + / "Xcode" + / "CodingAssistant" + ).exists() + ) + # ── generate_managed_settings unit test ───────────────────────────────────── def test_generate_managed_settings_filters_correctly(self) -> None: @@ -567,6 +589,28 @@ def test_claude_json_not_found_graceful(self) -> None: data = self._read_json(self.home / ".claude.json") self.assertIn("mcpServers", data) + def test_missing_claude_root_skips_sync(self) -> None: + """When ~/.claude does not exist, sync should treat Claude as not installed.""" + self._write_json( + self.home / "Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json", + {"existing": True}, + ) + for path in sorted((self.home / ".claude").glob("**/*"), reverse=True): + if path.is_file(): + path.unlink() + elif path.is_dir(): + path.rmdir() + (self.home / ".claude").rmdir() + + _run_claude_sync(self.root, self.platform_cfg) + + self.assertFalse((self.home / ".claude").exists()) + self.assertFalse((self.home / ".claude.json").exists()) + xcode_data = self._read_json( + self.home / "Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json" + ) + self.assertEqual(xcode_data, {"existing": True}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_codebuddy_sync.py b/tests/test_codebuddy_sync.py index e3de287..8f1e02a 100644 --- a/tests/test_codebuddy_sync.py +++ b/tests/test_codebuddy_sync.py @@ -82,6 +82,7 @@ class CodeBuddySyncTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) + (self.root / "home" / ".codebuddy").mkdir(parents=True, exist_ok=True) self.platform_cfg = DEFAULT_CODEBUDDY_CFG self._write_json( self.root / "env" / "mcp" / "sample.json", @@ -523,6 +524,16 @@ def test_mcp_json_not_found_creates_new_file(self) -> None: self.assertIn("mcpServers", result["mcp"]) self.assertIn("sample", result["mcp"]["mcpServers"]) + def test_missing_codebuddy_root_skips_sync(self) -> None: + """When ~/.codebuddy does not exist, sync should not create CodeBuddy files.""" + (self.root / "home" / ".codebuddy").rmdir() + + result = self._run_codebuddy_sync() + + self.assertFalse((self.root / "home" / ".codebuddy").exists()) + self.assertEqual(result["mcp"], {}) + self.assertEqual(result["models"], {}) + def test_models_json_not_found_creates_new_file(self) -> None: """When ~/.codebuddy/models.json doesn't exist, sync creates it.""" result = self._run_codebuddy_sync() diff --git a/tests/test_codex_sync.py b/tests/test_codex_sync.py index fe5829b..c0c4bcc 100644 --- a/tests/test_codex_sync.py +++ b/tests/test_codex_sync.py @@ -71,6 +71,7 @@ def _write_json(self, path: Path, data: dict) -> None: def _run_codex_sync(self, cfg: dict) -> tuple[str, dict]: self._write_json(self.root / "env" / "platforms" / "codex.json", cfg) + (self.root / "home" / ".codex").mkdir(parents=True, exist_ok=True) with patched_sync_environment(self.root): sys.argv = ["sync_config.py", "--target", "codex"] with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): @@ -159,6 +160,53 @@ def test_export_env_to_zshrc_replaces_existing_managed_dataeyes_key(self) -> Non self.assertTrue(zshrc_text.startswith("before\n")) self.assertTrue(zshrc_text.endswith("after\n")) + def test_missing_codex_root_skips_sync_and_env_export(self) -> None: + cfg = dict(self.platform_cfg) + zshrc = self.root / "home" / ".zshrc" + zshrc.parent.mkdir(parents=True, exist_ok=True) + zshrc.write_text("export OTHER=value\n", encoding="utf-8") + self._write_json(self.root / "env" / "platforms" / "codex.json", cfg) + + with patched_sync_environment(self.root): + sys.argv = ["sync_config.py", "--target", "codex"] + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + sync_config.main() + + self.assertFalse((self.root / "home" / ".codex").exists()) + self.assertEqual(zshrc.read_text(encoding="utf-8"), "export OTHER=value\n") + + def test_missing_xcode_path_skips_xcode_codex_target_only(self) -> None: + config_text, parsed = self._run_codex_sync(self.platform_cfg) + + self.assertIn("mcp_servers", parsed) + self.assertIn("sample", parsed["mcp_servers"]) + self.assertIn("# BEGIN MCP SYNC", config_text) + self.assertFalse((self.root / "home" / ".codex" / "mcp.generated.toml").exists()) + self.assertFalse( + ( + self.root + / "home" + / "Library" + / "Developer" + / "Xcode" + / "CodingAssistant" + / "codex" + ).exists() + ) + + def test_xcode_codex_sync_uses_config_toml_without_generated_toml(self) -> None: + xcode_root = self.root / "home" / "Library" / "Developer" / "Xcode" / "CodingAssistant" + xcode_root.mkdir(parents=True, exist_ok=True) + + self._run_codex_sync(self.platform_cfg) + + xcode_codex = xcode_root / "codex" + config_path = xcode_codex / "config.toml" + self.assertTrue(config_path.exists()) + self.assertFalse((xcode_codex / "mcp.generated.toml").exists()) + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + self.assertIn("sample", parsed["mcp_servers"]) + def test_codex_json_properties_are_mapped_or_excluded_as_expected(self) -> None: config_text, parsed = self._run_codex_sync(self.platform_cfg) diff --git a/tests/test_gemini_sync.py b/tests/test_gemini_sync.py index 4773403..0a926ae 100644 --- a/tests/test_gemini_sync.py +++ b/tests/test_gemini_sync.py @@ -44,6 +44,7 @@ class GeminiSyncTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name) + (self.root / "home" / ".gemini").mkdir(parents=True, exist_ok=True) self.platform_cfg = json.loads( (REPO_ROOT / "env" / "platforms" / "gemini.json").read_text() ) @@ -211,18 +212,14 @@ def test_settings_json_deep_merges_nested_user_keys(self) -> None: # ── Xcode target ───────────────────────────────────────────────────────── def test_xcode_target_receives_same_settings(self) -> None: - self._run_gemini_sync() - + xcode_root = self.root / "home" / "Library" / "Developer" / "Xcode" / "CodingAssistant" + xcode_root.mkdir(parents=True, exist_ok=True) xc_settings_path = ( - self.root - / "home" - / "Library" - / "Developer" - / "Xcode" - / "CodingAssistant" - / "gemini" - / "settings.json" + xcode_root / "gemini" / "settings.json" ) + + self._run_gemini_sync() + self.assertTrue(xc_settings_path.exists(), "Xcode Gemini settings.json was not created") native = self._read_json(self.root / "home" / ".gemini" / "settings.json") @@ -230,15 +227,10 @@ def test_xcode_target_receives_same_settings(self) -> None: self.assertEqual(native, xcode) def test_xcode_target_preserves_existing_user_keys(self) -> None: + xcode_root = self.root / "home" / "Library" / "Developer" / "Xcode" / "CodingAssistant" + xcode_root.mkdir(parents=True, exist_ok=True) xc_settings_path = ( - self.root - / "home" - / "Library" - / "Developer" - / "Xcode" - / "CodingAssistant" - / "gemini" - / "settings.json" + xcode_root / "gemini" / "settings.json" ) self._write_json( xc_settings_path, @@ -256,6 +248,22 @@ def test_xcode_target_preserves_existing_user_keys(self) -> None: self.assertTrue(xcode["context"]["fileFiltering"]["respectGitIgnore"]) self.assertIn("mcpServers", xcode) + def test_missing_xcode_path_skips_xcode_gemini_target_only(self) -> None: + native = self._run_gemini_sync() + + self.assertEqual(native["model"]["name"], "gemini-3.5-flash") + self.assertIn("mcpServers", native) + self.assertFalse( + ( + self.root + / "home" + / "Library" + / "Developer" + / "Xcode" + / "CodingAssistant" + ).exists() + ) + # ── zshrc env export ───────────────────────────────────────────────────── def test_export_env_to_zshrc_creates_managed_block(self) -> None: @@ -310,6 +318,39 @@ def test_export_env_to_zshrc_replaces_existing_block(self) -> None: self.assertEqual(zshrc_text.count("# BEGIN GEMINI ENV SYNC"), 1) self.assertEqual(zshrc_text.count("# END GEMINI ENV SYNC"), 1) + def test_missing_gemini_root_skips_sync_and_env_export(self) -> None: + (self.root / "home" / ".gemini").rmdir() + zshrc = self.root / "home" / ".zshrc" + zshrc.parent.mkdir(parents=True, exist_ok=True) + zshrc.write_text("export OTHER=value\n", encoding="utf-8") + + cfg = dict(self.platform_cfg) + cfg["export_env_to_zshrc"] = { + "GEMINI_API_KEY": "sk-test-gemini", + "GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com", + "GEMINI_MODEL": "gemini-3.5-flash", + } + self._write_json(self.root / "env" / "platforms" / "gemini.json", cfg) + + with patched_sync_environment(self.root): + sys.argv = ["sync_config.py", "--target", "gemini"] + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + sync_config.main() + + self.assertFalse((self.root / "home" / ".gemini").exists()) + self.assertFalse( + ( + self.root + / "home" + / "Library" + / "Developer" + / "Xcode" + / "CodingAssistant" + / "gemini" + ).exists() + ) + self.assertEqual(zshrc.read_text(encoding="utf-8"), "export OTHER=value\n") + # ── Property coverage ──────────────────────────────────────────────────── def test_gemini_json_properties_are_mapped_or_excluded_as_expected(self) -> None: diff --git a/tests/test_platform_install_root_skip.py b/tests/test_platform_install_root_skip.py new file mode 100644 index 0000000..3c92b12 --- /dev/null +++ b/tests/test_platform_install_root_skip.py @@ -0,0 +1,142 @@ +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SYNC_DIR = REPO_ROOT / "sync" +if str(SYNC_DIR) not in sys.path: + sys.path.insert(0, str(SYNC_DIR)) + +import sync_config # noqa: E402 +from platforms import common # noqa: E402 + + +@contextlib.contextmanager +def patched_sync_environment(root: Path): + old_env = {k: os.environ.get(k) for k in ("HOME",)} + old_paths = (common.MCP_DIR, common.PLATFORMS_DIR, common.SECRETS_PATH) + old_argv = sys.argv[:] + try: + os.environ["HOME"] = str(root / "home") + common.MCP_DIR = root / "env" / "mcp" + common.PLATFORMS_DIR = root / "env" / "platforms" + common.SECRETS_PATH = root / "env" / "secrets.json" + yield + finally: + for key, value in old_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + common.MCP_DIR, common.PLATFORMS_DIR, common.SECRETS_PATH = old_paths + sys.argv = old_argv + + +class PlatformInstallRootSkipTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.home = self.root / "home" + self._write_json( + self.root / "env" / "mcp" / "sample.json", + { + "name": "sample", + "type": "stdio", + "command": "echo", + "args": ["hello"], + }, + ) + self._write_json(self.root / "env" / "secrets.json", {}) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _write_json(self, path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8") + + def _run_target(self, target: str) -> str: + with patched_sync_environment(self.root): + sys.argv = ["sync_config.py", "--target", target] + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(io.StringIO()): + sync_config.main() + return stdout.getvalue() + + def test_missing_cline_root_skips_all_cline_outputs(self) -> None: + self._write_json( + self.root / "env" / "platforms" / "cline.json", + { + "globalState": {"apiProvider": "gemini", "geminiBaseUrl": "https://example.test"}, + "secrets": {"geminiApiKey": "sk-test"}, + }, + ) + vscode_mcp = ( + self.home + / "Library/Application Support/Code/User/globalStorage/" + / "saoudrizwan.claude-dev/settings/cline_mcp_settings.json" + ) + self._write_json(vscode_mcp, {"mcpServers": {"keep": {"command": "keep"}}}) + + output = self._run_target("cline") + + self.assertIn("Platform 'cline' root not found", output) + self.assertFalse((self.home / ".cline").exists()) + self.assertEqual( + json.loads(vscode_mcp.read_text(encoding="utf-8")), + {"mcpServers": {"keep": {"command": "keep"}}}, + ) + + def test_missing_continue_root_skips_continue_config(self) -> None: + self._write_json( + self.root / "env" / "platforms" / "continue.json", + { + "path": str(self.root / "external-continue.yaml"), + "models": [{"name": "managed"}], + }, + ) + target = self.root / "external-continue.yaml" + target.write_text("name: existing\n", encoding="utf-8") + + output = self._run_target("continue") + + self.assertIn("Platform 'continue' root not found", output) + self.assertFalse((self.home / ".continue").exists()) + self.assertEqual(target.read_text(encoding="utf-8"), "name: existing\n") + + def test_existing_continue_root_creates_missing_config_yaml(self) -> None: + (self.home / ".continue").mkdir(parents=True, exist_ok=True) + self._write_json( + self.root / "env" / "platforms" / "continue.json", + { + "models": [ + { + "name": "managed-model", + "provider": "openai", + "model": "managed-model", + } + ], + }, + ) + target = self.home / ".continue" / "config.yaml" + + output = self._run_target("continue") + + self.assertIn("creating it", output) + self.assertTrue(target.exists()) + text = target.read_text(encoding="utf-8") + self.assertIn("mcpServers:", text) + self.assertIn("- name: sample", text) + self.assertIn("command: echo", text) + self.assertIn("models:", text) + self.assertIn('- name: "managed-model"', text) + + +if __name__ == "__main__": + unittest.main() From 29bdcb58596cb9bba5b78f478012c2b0f5c2166e Mon Sep 17 00:00:00 2001 From: stack Date: Mon, 13 Jul 2026 15:18:56 +0800 Subject: [PATCH 03/65] =?UTF-8?q?feat(cline):=20=E6=9B=B4=E6=96=B0=20Cline?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E4=BB=A5=E6=94=AF=E6=8C=81=20OpenAI=20?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E4=B8=BB=E8=A6=81=E6=8F=90=E4=BE=9B=E8=80=85?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=20openAiApiKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- env/platforms/cline.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/env/platforms/cline.json b/env/platforms/cline.json index 6a96324..0738af6 100644 --- a/env/platforms/cline.json +++ b/env/platforms/cline.json @@ -1,14 +1,18 @@ { - "_comment": "Cline (VSCode extension) global state + secrets sync. Set 'enabled' to true to sync the 5 globalState keys + geminiApiKey; set to false to clear geminiBaseUrl (disable the third-party API) while leaving the other keys and the secret intact. The 5 keys under 'globalState' are merged into ~/.cline/data/globalState.json (all other keys are preserved). 'geminiBaseUrl' uses ${cline.url}; leave cline.url empty in secrets.json to disable the third-party API (geminiBaseUrl becomes \"\"). The 'secrets' object is merged into ~/.cline/data/secrets.json — Cline stores the key under the ApiKey key, e.g. geminiApiKey for provider 'gemini'.", + "_comment": "Cline (VSCode extension) global state + secrets sync. Set 'enabled' to true to sync the managed globalState keys + secrets; set to false to clear geminiBaseUrl (disable the third-party API) while leaving the other keys and secrets intact. The keys under 'globalState' are merged into ~/.cline/data/globalState.json (all other keys are preserved). OpenAI-compatible is the highest-priority provider (planModeApiProvider/actModeApiProvider = 'openai'); its base URL and model IDs are set literally, and its key uses ${cline.openaiKey}. The gemini keys are kept as a fallback config. 'geminiBaseUrl' uses ${cline.url}; leave cline.url empty in secrets.json to disable the third-party API (geminiBaseUrl becomes \"\"). The 'secrets' object is merged into ~/.cline/data/secrets.json — Cline stores the key under the ApiKey key, e.g. geminiApiKey for provider 'gemini' and openAiApiKey for provider 'openai'.", "enabled": true, "globalState": { - "planModeApiProvider": "gemini", - "actModeApiProvider": "gemini", + "planModeApiProvider": "openai", + "actModeApiProvider": "openai", + "openAiBaseUrl": "https://integrate.api.nvidia.com/v1", + "planModeOpenAiModelId": "deepseek-ai/deepseek-v4-pro", + "actModeOpenAiModelId": "deepseek-ai/deepseek-v4-pro", "geminiBaseUrl": "${cline.url}", "planModeApiModelId": "gemini-3.5-flash", "actModeApiModelId": "gemini-3.5-flash" }, "secrets": { - "geminiApiKey": "${cline.key}" + "geminiApiKey": "${cline.key}", + "openAiApiKey": "${cline.openaiKey}" } } \ No newline at end of file From 48e5d51d4fe7853c268c768a3bd6291f74fb42f0 Mon Sep 17 00:00:00 2001 From: stack Date: Wed, 15 Jul 2026 10:20:18 +0800 Subject: [PATCH 04/65] feat: enhance Qwen Code integration and update documentation - Updated README to reflect the addition of Qwen Code with 9+ AI coding tools. - Added Qwen Code configuration in `env/secrets.json.example` for API key management. - Enhanced sync process to include Qwen Code, updating paths and syncing skills. - Modified TypeScript configuration to support Qwen Code in the CLI. - Improved CLI commands for better usability and error handling. --- .qwen/settings.json | 9 + README.md | 7 +- env/platforms/qwen.json | 6 + env/secrets.json.example | 3 + skills-engineering/plan-reviews/src/cli.ts | 214 ++++++++++-------- skills-engineering/plan-reviews/tsconfig.json | 3 +- sync/README.md | 10 +- sync/platforms/paths.py | 13 ++ sync/platforms/qwen.py | 75 ++++++ sync/sync_config.py | 3 +- 10 files changed, 236 insertions(+), 107 deletions(-) create mode 100644 .qwen/settings.json create mode 100644 env/platforms/qwen.json create mode 100644 sync/platforms/qwen.py diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 0000000..1bf5506 --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Agent(Explore)", + "Bash(python3 *)" + ] + }, + "$version": 4 +} \ No newline at end of file diff --git a/README.md b/README.md index 6ab4fc1..85d0057 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # ai-coding-kit -[![Agent Skills](https://img.shields.io/badge/Agent%20Skills-8%2B%20AI%20Coding%20Tools-5856D6)](skills-engineering/README.md) +[![Agent Skills](https://img.shields.io/badge/Agent%20Skills-9%2B%20AI%20Coding%20Tools-5856D6)](skills-engineering/README.md) [![iOS Engineer Skill](https://img.shields.io/badge/iOS%20Engineer-Swift%20%7C%20SwiftUI%20%7C%20UIKit-0A84FF)](skills-engineering/ios-engineer/SKILL.md) -[![MCP Config Sync](https://img.shields.io/badge/MCP%20Config-8%20Platforms-663399)](sync/README.md) +[![MCP Config Sync](https://img.shields.io/badge/MCP%20Config-9%20Platforms-663399)](sync/README.md) [![Validate Skills](https://github.com/i-stack/ai-coding-kit/actions/workflows/validate.yml/badge.svg?branch=feature_3.0.0)](https://github.com/i-stack/ai-coding-kit/actions/workflows/validate.yml) [![Check Hardcoded Paths](https://github.com/i-stack/ai-coding-kit/actions/workflows/hardcoded-paths.yml/badge.svg)](https://github.com/i-stack/ai-coding-kit/actions/workflows/hardcoded-paths.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -> **One kit. All your AI coding tools.** Agent Skills management, MCP configuration sync, and iOS engineering rules — unified for Cursor, CodeBuddy, Codex, Claude Code, Gemini CLI, Continue, Cline, and Xcode Coding Assistant. +> **One kit. All your AI coding tools.** Agent Skills management, MCP configuration sync, and iOS engineering rules — unified for Cursor, CodeBuddy, Codex, Claude Code, Gemini CLI, Continue, Cline, Qwen Code, and Xcode Coding Assistant. **ai-coding-kit** is a local-first AI coding workflow toolkit. Define your MCP servers, API keys, Agent Skills, and platform settings once — auto-sync to every AI coding host you use. @@ -57,6 +57,7 @@ bash sync.sh | **Gemini CLI** | Environment variables | | **Continue** | `.continue/config.yaml` | | **Cline** (VSCode) | MCP settings JSON, `skills/` | +| **Qwen Code** | `settings.json` env, `skills/` | | **Xcode Coding Assistant** | Codex + Claude Agent config paths | ## 安装 Git 钩子 diff --git a/env/platforms/qwen.json b/env/platforms/qwen.json new file mode 100644 index 0000000..8716215 --- /dev/null +++ b/env/platforms/qwen.json @@ -0,0 +1,6 @@ +{ + "_comment": "Qwen Code platform configuration. Syncs DASHSCOPE_API_KEY to ~/.qwen/settings.json env and skills to ~/.qwen/skills/.", + "env": { + "DASHSCOPE_API_KEY": "${qwen.dashscopeApiKey}" + } +} \ No newline at end of file diff --git a/env/secrets.json.example b/env/secrets.json.example index b1a62bb..6462c19 100644 --- a/env/secrets.json.example +++ b/env/secrets.json.example @@ -35,5 +35,8 @@ }, "sqlite": { "db_path": "./data/your_database.sqlite" + }, + "qwen": { + "dashscopeApiKey": "sk-your-qwen-api-key" } } diff --git a/skills-engineering/plan-reviews/src/cli.ts b/skills-engineering/plan-reviews/src/cli.ts index f5bc0a7..74abd03 100644 --- a/skills-engineering/plan-reviews/src/cli.ts +++ b/skills-engineering/plan-reviews/src/cli.ts @@ -9,12 +9,13 @@ * npx tsx src/cli.ts reset # Full reset and re-sync */ +import process from "node:process"; import { PlanReviewsKB } from "./index.js"; import { generateKnowledgeGraph } from "./visualize.js"; const args = process.argv.slice(2); const command = args[0] ?? "help"; -const query = args.slice(1).join(" ") || args[0] || ""; +const query = args.slice(1).join(" "); // Parse --output flag function parseOutputFlag(): string | undefined { @@ -22,121 +23,138 @@ function parseOutputFlag(): string | undefined { return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined; } - +// Commands that require opening the knowledge base. +const KB_COMMANDS = new Set([ + "sync", + "search", + "recall", + "merge", + "stats", + "reset", + "visualize", +]); + +function printHelp() { + console.log("plan-reviews knowledge base CLI"); + console.log(""); + console.log("Commands:"); + console.log(" sync Sync .plan-reviews/ to knowledge base"); + console.log(" search Search the knowledge base"); + console.log(" recall Search and print injection-ready context block"); + console.log(" merge De-dup / consolidate cross-plan knowledge (metabolism)"); + console.log(" stats Show KB statistics"); + console.log(" reset Full reset and re-sync"); + console.log(" visualize Generate interactive knowledge graph HTML"); + console.log(" --output Custom output path (default: .plan-reviews/knowledge-graph.html)"); + + console.log(""); + console.log("Environment:"); + console.log(" EMBEDDING_API_KEY Embedding API key (optional)"); + console.log(" EMBEDDING_BASE_URL Embedding API base URL"); + console.log(" EMBEDDING_MODEL Embedding model name"); +} async function main() { - const kb = await PlanReviewsKB.init(); - - switch (command) { - case "sync": { - console.log("Syncing .plan-reviews/ to knowledge base..."); - const stats = await kb.sync(); - console.log( - `Done: ${stats.added} added, ${stats.modified} modified, ` + - `${stats.removed} removed, ${stats.skipped} skipped`, - ); - if (stats.errors.length > 0) { - console.log(`\nErrors (${stats.errors.length}):`); - for (const err of stats.errors) console.log(` - ${err}`); - } - break; - } + // Help and unknown commands don't need to open (and pay the cost of) the KB. + if (!KB_COMMANDS.has(command)) { + printHelp(); + return; + } - case "search": { - if (!query || query === "search") { - console.log("Usage: cli.ts search "); + const kb = await PlanReviewsKB.init(); + try { + switch (command) { + case "sync": { + console.log("Syncing .plan-reviews/ to knowledge base..."); + const stats = await kb.sync(); + console.log( + `Done: ${stats.added} added, ${stats.modified} modified, ` + + `${stats.removed} removed, ${stats.skipped} skipped`, + ); + if (stats.errors.length > 0) { + console.log(`\nErrors (${stats.errors.length}):`); + for (const err of stats.errors) console.log(` - ${err}`); + } break; } - console.log(`Searching: "${query}"\n`); - const results = await kb.search({ query }); - console.log(kb.formatResults(results)); - break; - } - case "recall": { - if (!query || query === "recall") { - console.log("Usage: cli.ts recall "); + case "search": { + if (!query) { + console.log("Usage: cli.ts search "); + break; + } + console.log(`Searching: "${query}"\n`); + const results = await kb.search({ query }); + console.log(kb.formatResults(results)); break; } - console.log(`Recalling context for: "${query}"\n`); - const block = await kb.recall(query); - if (!block) { - console.log("(no relevant prior knowledge found)"); - } else { - console.log(block); - } - break; - } - case "merge": { - console.log("Running memory-metabolism merge (de-dup cross-plan knowledge)..."); - if (!kb.stats.chunks) { - console.log("No chunks indexed yet. Run `sync` first."); + case "recall": { + if (!query) { + console.log("Usage: cli.ts recall "); + break; + } + console.log(`Recalling context for: "${query}"\n`); + const block = await kb.recall(query); + if (!block) { + console.log("(no relevant prior knowledge found)"); + } else { + console.log(block); + } break; } - const points = await kb.merge(); - if (points.length === 0) { - console.log("No duplicate knowledge points found (or embedding API not configured)."); - } else { - console.log(`Merged ${points.length} knowledge point(s):`); - for (const p of points) { - console.log(` - ${p.title} [minSim=${p.minSimilarity.toFixed(2)}]`); + + case "merge": { + console.log("Running memory-metabolism merge (de-dup cross-plan knowledge)..."); + if (!kb.stats.chunks) { + console.log("No chunks indexed yet. Run `sync` first."); + break; + } + const points = await kb.merge(); + if (points.length === 0) { + console.log("No duplicate knowledge points found (or embedding API not configured)."); + } else { + console.log(`Merged ${points.length} knowledge point(s):`); + for (const p of points) { + console.log(` - ${p.title} [minSim=${Number(p.minSimilarity ?? 0).toFixed(2)}]`); + } + console.log("Written to .plan-reviews/.kb-merged.json and .plan-reviews/MERGED-KNOWLEDGE.md"); } - console.log("Written to .plan-reviews/.kb-merged.json and .plan-reviews/MERGED-KNOWLEDGE.md"); + break; } - break; - } - case "stats": { - const s = kb.stats; - console.log("Knowledge Base Statistics:"); - console.log(` Plans: ${s.plans}`); - console.log(` Entities: ${s.entities}`); - console.log(` Relations: ${s.relations}`); - console.log(` Chunks: ${s.chunks}`); - break; - } + case "stats": { + const s = kb.stats; + console.log("Knowledge Base Statistics:"); + console.log(` Plans: ${s.plans}`); + console.log(` Entities: ${s.entities}`); + console.log(` Relations: ${s.relations}`); + console.log(` Chunks: ${s.chunks}`); + break; + } - case "reset": { - console.log("Resetting knowledge base..."); - const stats = await kb.reset(); - console.log( - `Done: ${stats.added} added, ${stats.modified} modified, ` + - `${stats.removed} removed, ${stats.skipped} skipped`, - ); - break; - } + case "reset": { + console.log("Resetting knowledge base..."); + const stats = await kb.reset(); + console.log( + `Done: ${stats.added} added, ${stats.modified} modified, ` + + `${stats.removed} removed, ${stats.skipped} skipped`, + ); + break; + } - case "visualize": { - const output = parseOutputFlag(); - const outputPath = generateKnowledgeGraph({ output }); - console.log(`Knowledge graph generated: ${outputPath}`); - console.log("Open it in your browser to explore."); - break; + case "visualize": { + const output = parseOutputFlag(); + const outputPath = generateKnowledgeGraph({ output }); + console.log(`Knowledge graph generated: ${outputPath}`); + console.log("Open it in your browser to explore."); + break; + } } - - default: - console.log("plan-reviews knowledge base CLI"); - console.log(""); - console.log("Commands:"); - console.log(" sync Sync .plan-reviews/ to knowledge base"); - console.log(" search Search the knowledge base"); - console.log(" recall Search and print injection-ready context block"); - console.log(" merge De-dup / consolidate cross-plan knowledge (metabolism)"); - console.log(" stats Show KB statistics"); - console.log(" reset Full reset and re-sync"); - console.log(" visualize Generate interactive knowledge graph HTML"); - console.log(" --output Custom output path (default: .plan-reviews/knowledge-graph.html)"); - - console.log(""); - console.log("Environment:"); - console.log(" EMBEDDING_API_KEY Embedding API key (optional)"); - console.log(" EMBEDDING_BASE_URL Embedding API base URL"); - console.log(" EMBEDDING_MODEL Embedding model name"); - break; + } finally { + // Always release KB resources, even when a command throws. + kb.close(); } - - kb.close(); } main().catch((err) => { diff --git a/skills-engineering/plan-reviews/tsconfig.json b/skills-engineering/plan-reviews/tsconfig.json index 155848c..bbf3b13 100644 --- a/skills-engineering/plan-reviews/tsconfig.json +++ b/skills-engineering/plan-reviews/tsconfig.json @@ -13,7 +13,8 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "types": ["node"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "tests"] diff --git a/sync/README.md b/sync/README.md index 27f38ed..b064900 100644 --- a/sync/README.md +++ b/sync/README.md @@ -98,16 +98,17 @@ Each `env/platforms/.json` follows that platform's **official configuratio | Continue | `continue.json` | Continue `config.yaml` models | | Cursor | `cursor.json` | (no platform config needed) | | Cline | `cline.json` | Merge `globalState` + `secrets` into `~/.cline/data/` | +| Qwen Code | `qwen.json` | Merge `env` into `~/.qwen/settings.json`, sync skills | The JSON keys map directly to the platform's native format — no field name translation needed. ## Targets -For Cline, Codex, Claude, CodeBuddy, Gemini, and Continue, sync first checks +For Cline, Codex, Claude, CodeBuddy, Gemini, Continue, and Qwen Code, sync first checks the tool's home directory (`~/.cline`, `~/.codex`, `~/.claude`, -`~/.codebuddy`, `~/.gemini`, `~/.continue`). If that root does not exist, the -target is skipped so sync does not create config for tools the user has not -installed. +`~/.codebuddy`, `~/.gemini`, `~/.continue`, `~/.qwen`). If that root does not +exist, the target is skipped so sync does not create config for tools the user +has not installed. Xcode CodingAssistant targets are checked separately. If `~/Library/Developer/Xcode/CodingAssistant` does not exist, native CLI targets @@ -124,6 +125,7 @@ still sync, but the Xcode-specific Codex / Claude / Gemini outputs are skipped. | Cline | Replace `mcpServers` in VSCode extension settings + skills sync + merge `globalState`/`secrets` into `~/.cline/data/` | | Gemini CLI | Replace `mcpServers` in `~/.gemini/settings.json` + `~/.zshrc` env | | Continue | Update `mcpServers` + `models` in `~/.continue/config.yaml`, creating it when `~/.continue` exists | +| Qwen Code | Merge `env` into `~/.qwen/settings.json`, sync skills to `~/.qwen/skills/` | ## Adding a Platform diff --git a/sync/platforms/paths.py b/sync/platforms/paths.py index 8263dec..16f8356 100644 --- a/sync/platforms/paths.py +++ b/sync/platforms/paths.py @@ -178,6 +178,18 @@ def codebuddy_skills_base() -> Path: return _home() / ".codebuddy/skills" +def qwen_settings_json_path() -> Path: + return _home() / ".qwen/settings.json" + + +def qwen_root_dir() -> Path: + return _home() / ".qwen" + + +def qwen_skills_base() -> Path: + return _home() / ".qwen/skills" + + _INSTALL_ROOTS = { "cline": cline_root_dir, "codex": codex_root_dir, @@ -185,6 +197,7 @@ def codebuddy_skills_base() -> Path: "codebuddy": codebuddy_root_dir, "gemini": gemini_root_dir, "continue": continue_root_dir, + "qwen": qwen_root_dir, } diff --git a/sync/platforms/qwen.py b/sync/platforms/qwen.py new file mode 100644 index 0000000..4846ad9 --- /dev/null +++ b/sync/platforms/qwen.py @@ -0,0 +1,75 @@ +"""Sync engine for Qwen Code platform. + +Writes DASHSCOPE_API_KEY into ~/.qwen/settings.json env and syncs skills +from ~/.claude/skills/ to ~/.qwen/skills/. +""" +import shutil +from typing import Any + +from .common import read_json_object, write_json +from .paths import ( + claude_skills_base, + qwen_root_dir, + qwen_settings_json_path, + qwen_skills_base, +) + + +def _sync_env(env: dict[str, Any]) -> None: + """Merge managed env vars into ~/.qwen/settings.json. + + Preserves all other keys in the settings file. Only the env + keys declared in the platform config are overwritten; any + pre-existing env keys not in the config are left untouched. + """ + if not env: + return + path = qwen_settings_json_path() + existing = read_json_object(path) + existing_env = existing.get("env") + if not isinstance(existing_env, dict): + existing_env = {} + merged_env = dict(existing_env) + merged_env.update(env) + existing["env"] = merged_env + write_json(path, existing) + keys = ", ".join(env.keys()) + print(f"[qwen] Synced env keys to {path}: {keys}.") + + +def _sync_skills() -> None: + claude_skills_dir = claude_skills_base() + qwen_skills_dir = qwen_skills_base() + if not claude_skills_dir.exists(): + print(f"[qwen] Claude skills directory not found: {claude_skills_dir} — skipping skill sync.") + return + + qwen_skills_dir.mkdir(parents=True, exist_ok=True) + synced: list[str] = [] + + for skill_dir in sorted(claude_skills_dir.iterdir()): + if not skill_dir.is_dir() or not (skill_dir / "SKILL.md").exists(): + continue + + dest = qwen_skills_dir / skill_dir.name + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(skill_dir, dest) + synced.append(skill_dir.name) + + print(f"[qwen] Synced {len(synced)} skills to {qwen_skills_dir}: {', '.join(synced) or '(none)'}.") + + +def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: + """Sync env vars and skills to Qwen Code. + + Qwen Code does not use MCP servers in the same way as other + platforms — the mcp_servers parameter is accepted but ignored. + """ + root = qwen_root_dir() + if not root.exists(): + print(f"[qwen] Qwen root not found: {root} — skipping (tool not installed).") + return + + _sync_env(cfg.get("env", {})) + _sync_skills() \ No newline at end of file diff --git a/sync/sync_config.py b/sync/sync_config.py index 99f313c..d98e865 100644 --- a/sync/sync_config.py +++ b/sync/sync_config.py @@ -14,7 +14,7 @@ from collections.abc import Callable from typing import Any -from platforms import claude, cline, codebuddy, codex, cursor, gemini +from platforms import claude, cline, codebuddy, codex, cursor, gemini, qwen from platforms.common import discover_platforms, filter_mcp_for_platform, load_all_mcp, load_platform_config, sync_env_to_zshrc from platforms.paths import platform_install_root, platform_is_installed @@ -35,6 +35,7 @@ "gemini": gemini.sync, "cline": cline.sync, "continue": _continue.sync, + "qwen": qwen.sync, } From 27a1b87d85b5dca7736c4f6aa30d928c74adadd9 Mon Sep 17 00:00:00 2001 From: stack Date: Wed, 15 Jul 2026 10:24:58 +0800 Subject: [PATCH 05/65] feat(sync): update sync.sh to enhance Cline and Gemini CLI documentation - Revised Cline configuration paths to include specific files for better clarity. - Updated Gemini CLI documentation to specify relevant configuration files. - Added Qwen Code configuration details to the sync output for improved user guidance. --- sync.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sync.sh b/sync.sh index 42bd63d..069b477 100755 --- a/sync.sh +++ b/sync.sh @@ -85,9 +85,10 @@ run_sync() { echo -e " • Xcode Codex (~/Library/Developer/Xcode/CodingAssistant/codex/)" echo -e " • Claude Code (~/.claude.json, settings.json)" echo -e " • Xcode Claude (~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/)" - echo -e " • Cline (VSCode MCP settings)" - echo -e " • Gemini CLI (环境变量)" + echo -e " • Cline (~/.cline/data/globalState.json, secrets.json, skills)" + echo -e " • Gemini CLI (~/.gemini/settings.json, ~/.zshrc env)" echo -e " • Continue (~/.continue/config.yaml)" + echo -e " • Qwen Code (~/.qwen/settings.json, skills)" } # --- 主流程 --- From a9437f02a10a60bad592b0abd7a4b2c78d53af58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Mon, 20 Jul 2026 16:15:14 +0800 Subject: [PATCH 06/65] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20enabled=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9=E4=BB=A5=E6=94=AF=E6=8C=81=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E5=92=8C=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- env/platforms/codex.json | 1 + sync/platforms/claude.py | 2 ++ sync/platforms/cline.py | 11 ++++++----- sync/platforms/codebuddy.py | 7 +++++-- sync/platforms/codex.py | 15 +++++++++++---- sync/platforms/gemini.py | 2 +- sync/sync_config.py | 20 ++++++++++++-------- tests/test_codex_sync.py | 1 + 8 files changed, 39 insertions(+), 20 deletions(-) diff --git a/env/platforms/codex.json b/env/platforms/codex.json index 64ece48..e9f7fdc 100644 --- a/env/platforms/codex.json +++ b/env/platforms/codex.json @@ -1,4 +1,5 @@ { + "enabled": true, "model": "gpt-5.5", "personality": "pragmatic", "model_provider": "", diff --git a/sync/platforms/claude.py b/sync/platforms/claude.py index ec7007c..675b7dc 100644 --- a/sync/platforms/claude.py +++ b/sync/platforms/claude.py @@ -28,6 +28,8 @@ def _repo_hooks_dir() -> Path: # These keys are kept in env/platforms/claude.json as reference but excluded # from managed (team-shared) settings — each developer sets them individually. _HOST_SKIP = { + # Orchestration-only keys + "enabled", # Personal UI/UX preferences "apiKeyHelper", "theme", diff --git a/sync/platforms/cline.py b/sync/platforms/cline.py index 34e0316..cef72b4 100644 --- a/sync/platforms/cline.py +++ b/sync/platforms/cline.py @@ -124,10 +124,11 @@ def _sync_secrets(secrets: dict[str, Any]) -> None: def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: """Sync MCP servers, skills, global state, and secrets to Cline (VSCode extension). - When the platform is disabled (enabled=false), the orchestrator passes an - empty cfg. In that case MCP servers and skills are still synced, but the - managed global state is cleaned up by clearing geminiBaseUrl (disabling the - third-party API) while leaving the other keys and the secret intact. + When the platform is disabled (enabled=false), the orchestrator still passes + the full cfg but the renderer applies its disabled-state handling: MCP + servers and skills are still synced, but the managed global state is cleaned + up by clearing geminiBaseUrl (disabling the third-party API) while leaving + the other keys and the secret intact. """ root = cline_root_dir() if not root.exists(): @@ -136,7 +137,7 @@ def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: _sync_mcp(mcp_servers) _sync_skills() - if not cfg: + if cfg.get("enabled") is False: _clear_global_state_base_url() return _sync_global_state(cfg.get("globalState", {})) diff --git a/sync/platforms/codebuddy.py b/sync/platforms/codebuddy.py index 94f07c8..cd9164c 100644 --- a/sync/platforms/codebuddy.py +++ b/sync/platforms/codebuddy.py @@ -99,7 +99,10 @@ def _sync_models(cfg: dict[str, Any]) -> None: available_models = cfg.get("availableModels") models_path = codebuddy_models_path() - if models is None and available_models is None: + # A disabled platform (enabled=false) clears the managed model keys while + # preserving any developer-added siblings (e.g. "meta"). The orchestrator + # forwards `enabled` to the renderer instead of passing an empty config. + if cfg.get("enabled") is False or (models is None and available_models is None): existing = read_json_object(models_path) removed = False for key in ("models", "availableModels"): @@ -108,7 +111,7 @@ def _sync_models(cfg: dict[str, Any]) -> None: removed = True if removed: write_json(models_path, existing) - print(f"[codebuddy] Removed managed models config from {models_path}.") + print(f"[codebuddy] Removed managed models config from {models_path} (platform disabled).") else: print("[codebuddy] No models config found — skipping model sync.") return diff --git a/sync/platforms/codex.py b/sync/platforms/codex.py index a2ac054..cb64930 100644 --- a/sync/platforms/codex.py +++ b/sync/platforms/codex.py @@ -91,13 +91,20 @@ def generate_shared_toml(cfg: dict[str, Any]) -> str: """ lines: list[str] = ["# AUTOGENERATED from env/platforms/codex.json"] - # ── model_provider: commented when empty ── + # ── model_provider: commented when disabled or empty ── + # `enabled` controls whether the managed model_provider is active. When the + # platform is disabled (enabled=false), the provider is emitted as a comment + # so it can be re-enabled later by simply flipping `enabled` back to true + # (no other config is touched). An absent `enabled` is treated as enabled. model_provider = cfg.get("model_provider") - if model_provider: + if cfg.get("enabled", True) is False: + lines.append("# model_provider (disabled via enabled=false)") + lines.append('# preferred_auth_method = "apikey"') + elif model_provider: lines.append(f"model_provider = {toml_quote(str(model_provider))}") lines.append('preferred_auth_method = "apikey"') else: - lines.append("# model_provider") + lines.append(f"model_provider = {toml_quote(str(model_provider))}") lines.append('# preferred_auth_method = "apikey"') # ── everything else via toml_section ── @@ -108,7 +115,7 @@ def generate_shared_toml(cfg: dict[str, Any]) -> str: section = toml_section( cfg_for_section, ignore=_HOST_SKIP - | {"model_provider", "model_providers", "env", "export_env_to_zshrc", "projects", "_comment"}, + | {"model_provider", "model_providers", "env", "export_env_to_zshrc", "projects", "_comment", "enabled"}, ) if section.strip(): lines.append(section) diff --git a/sync/platforms/gemini.py b/sync/platforms/gemini.py index e1099f4..cf7dfe7 100644 --- a/sync/platforms/gemini.py +++ b/sync/platforms/gemini.py @@ -11,7 +11,7 @@ # Internal/platform keys that should NOT appear in the managed settings.json. # These are consumed by the sync engine/orchestrator, not by Gemini CLI itself. -_INTERNAL_SKIP = {"export_env_to_zshrc", "_comment"} +_INTERNAL_SKIP = {"export_env_to_zshrc", "_comment", "enabled"} def _extract_settings(cfg: dict[str, Any]) -> dict[str, Any]: diff --git a/sync/sync_config.py b/sync/sync_config.py index d98e865..78f8d8a 100644 --- a/sync/sync_config.py +++ b/sync/sync_config.py @@ -90,17 +90,21 @@ def _auto_export_env_to_zshrc(platform: str, platform_cfg: dict[str, Any]) -> No def _effective_platform_config(platform: str) -> dict[str, Any]: - """Load platform config and apply orchestration-only keys. - - ``enabled`` is owned by the sync orchestrator. Disabled platforms still run - their renderer with empty platform config so renderer-owned cleanup paths can - remove stale managed target state instead of leaving it behind. + """Load platform config and pass orchestration-only keys to the renderer. + + ``enabled`` is owned by the sync orchestrator and is forwarded to the + renderer (not stripped) so each platform can decide what a disabled state + means via its renderer-owned cleanup path: + - Cline clears its third-party API base URL (geminiBaseUrl). + - Codex comments out its managed ``model_provider`` while keeping the rest + of the config intact. + Other platforms ignore ``enabled`` and sync normally. An absent ``enabled`` + key is treated as enabled. """ cfg = load_platform_config(platform) if cfg.get("enabled") is False: - print(f"[sync] Platform '{platform}' disabled via enabled=false — using empty platform config.") - return {} - return {k: v for k, v in cfg.items() if k != "enabled"} + print(f"[sync] Platform '{platform}' disabled via enabled=false — renderer applies its disabled-state handling.") + return dict(cfg) def _sync_one_platform( diff --git a/tests/test_codex_sync.py b/tests/test_codex_sync.py index c0c4bcc..2c87628 100644 --- a/tests/test_codex_sync.py +++ b/tests/test_codex_sync.py @@ -213,6 +213,7 @@ def test_codex_json_properties_are_mapped_or_excluded_as_expected(self) -> None: covered_keys = { "model", "personality", + "enabled", "model_provider", "model_reasoning_effort", "model_verbosity", From d02a579e0b96dcb121b94b1ee5f3d28533d1e5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Mon, 20 Jul 2026 16:25:18 +0800 Subject: [PATCH 07/65] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20codex.json?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=EF=BC=8C=E7=A6=81=E7=94=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=B9=B6=E8=AE=BE=E7=BD=AE=E6=95=B0=E6=8D=AE=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E8=80=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- env/platforms/codex.json | 4 ++-- sync/platforms/codex.py | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/env/platforms/codex.json b/env/platforms/codex.json index e9f7fdc..e2faa12 100644 --- a/env/platforms/codex.json +++ b/env/platforms/codex.json @@ -1,8 +1,8 @@ { - "enabled": true, + "enabled": false, "model": "gpt-5.5", "personality": "pragmatic", - "model_provider": "", + "model_provider": "dataeyes", "model_reasoning_effort": "medium", "model_verbosity": "medium", "model_reasoning_summary": "auto", diff --git a/sync/platforms/codex.py b/sync/platforms/codex.py index cb64930..79f8ed0 100644 --- a/sync/platforms/codex.py +++ b/sync/platforms/codex.py @@ -100,12 +100,9 @@ def generate_shared_toml(cfg: dict[str, Any]) -> str: if cfg.get("enabled", True) is False: lines.append("# model_provider (disabled via enabled=false)") lines.append('# preferred_auth_method = "apikey"') - elif model_provider: - lines.append(f"model_provider = {toml_quote(str(model_provider))}") - lines.append('preferred_auth_method = "apikey"') else: lines.append(f"model_provider = {toml_quote(str(model_provider))}") - lines.append('# preferred_auth_method = "apikey"') + lines.append('preferred_auth_method = "apikey"') # ── everything else via toml_section ── # Strip model_providers from cfg so toml_section's special handler won't From ad9ddc2ef2ab65a5e7f3d2508fc51cb5f7ee2647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 10:17:24 +0800 Subject: [PATCH 08/65] feat(cline): implement managed keys sidecar for improved key management during sync - Added a sidecar to track keys managed by Cline, allowing for automatic pruning of stale keys from globalState.json and secrets.json when the platform is disabled. - Updated sync logic to remove unmanaged keys, enhancing the cleanup process for disabled platforms. - Revised documentation to reflect changes in key management and sync behavior. --- env/platforms/cline.json | 8 +-- sync/platforms/cline.py | 142 +++++++++++++++++++++++++++++++-------- sync/sync_config.py | 2 +- 3 files changed, 116 insertions(+), 36 deletions(-) diff --git a/env/platforms/cline.json b/env/platforms/cline.json index 0738af6..f4459b3 100644 --- a/env/platforms/cline.json +++ b/env/platforms/cline.json @@ -2,17 +2,11 @@ "_comment": "Cline (VSCode extension) global state + secrets sync. Set 'enabled' to true to sync the managed globalState keys + secrets; set to false to clear geminiBaseUrl (disable the third-party API) while leaving the other keys and secrets intact. The keys under 'globalState' are merged into ~/.cline/data/globalState.json (all other keys are preserved). OpenAI-compatible is the highest-priority provider (planModeApiProvider/actModeApiProvider = 'openai'); its base URL and model IDs are set literally, and its key uses ${cline.openaiKey}. The gemini keys are kept as a fallback config. 'geminiBaseUrl' uses ${cline.url}; leave cline.url empty in secrets.json to disable the third-party API (geminiBaseUrl becomes \"\"). The 'secrets' object is merged into ~/.cline/data/secrets.json — Cline stores the key under the ApiKey key, e.g. geminiApiKey for provider 'gemini' and openAiApiKey for provider 'openai'.", "enabled": true, "globalState": { - "planModeApiProvider": "openai", - "actModeApiProvider": "openai", "openAiBaseUrl": "https://integrate.api.nvidia.com/v1", "planModeOpenAiModelId": "deepseek-ai/deepseek-v4-pro", - "actModeOpenAiModelId": "deepseek-ai/deepseek-v4-pro", - "geminiBaseUrl": "${cline.url}", - "planModeApiModelId": "gemini-3.5-flash", - "actModeApiModelId": "gemini-3.5-flash" + "actModeOpenAiModelId": "deepseek-ai/deepseek-v4-pro" }, "secrets": { - "geminiApiKey": "${cline.key}", "openAiApiKey": "${cline.openaiKey}" } } \ No newline at end of file diff --git a/sync/platforms/cline.py b/sync/platforms/cline.py index cef72b4..abdc318 100644 --- a/sync/platforms/cline.py +++ b/sync/platforms/cline.py @@ -5,6 +5,7 @@ from .common import read_json_object, write_json from .paths import ( claude_skills_base, + cline_data_dir, cline_root_dir, cline_global_state_path, cline_mcp_candidate_paths, @@ -15,6 +16,73 @@ # Matches a value that is still a single unresolved ${VAR} placeholder. _UNRESOLVED_PLACEHOLDER_RE = re.compile(r"\A\$\{[^}]+\}\Z") +# Sidecar that records which keys this tool currently manages, so a key +# removed from the platform config can be pruned from the user's files on the +# next sync. Without this record the managed set would have to be hardcoded +# (and kept in sync by hand every time the config gains or drops a key). Lives +# next to globalState.json / secrets.json; Cline ignores dot-files. +_MANAGED_KEYS_SIDECAR = cline_data_dir() / ".managed_keys.json" + + +def _load_managed_keys() -> dict[str, set[str]]: + """Return the set of keys this tool currently manages, per section. + + The sidecar is the single source of truth and is updated on every sync, + so the managed set is derived from actual sync history rather than + hardcoded. On a fresh install (no sidecar yet) there is nothing to prune, + and the first sync writes the current config's keys into the sidecar. + """ + data = read_json_object(_MANAGED_KEYS_SIDECAR) + if data: + return { + "globalState": set(data.get("globalState", [])), + "secrets": set(data.get("secrets", [])), + } + return {"globalState": set(), "secrets": set()} + + +def _save_managed_keys(global_state_keys: set[str], secret_keys: set[str]) -> None: + """Persist the currently-managed key sets to the sidecar.""" + write_json(_MANAGED_KEYS_SIDECAR, { + "globalState": sorted(global_state_keys), + "secrets": sorted(secret_keys), + }) + + +def _prune_all_managed_keys() -> None: + """Disabled-platform cleanup: remove every key this tool manages. + + Deletes all keys recorded in the sidecar from globalState.json and + secrets.json, leaving Cline's own keys (welcome state, auto-approval, + etc.) untouched. The sidecar itself is left intact so re-enabling still + prunes keys that have since been dropped from the config. + + No-op (beyond a log line) when nothing managed is present — e.g. on a + fresh install that never synced, or after a previous cleanup. + """ + record = _load_managed_keys() + gs_path = cline_global_state_path() + gs = read_json_object(gs_path) + removed_gs = [k for k in record["globalState"] if k in gs] + for k in removed_gs: + del gs[k] + + sec_path = cline_secrets_path() + sec = read_json_object(sec_path) + removed_sec = [k for k in record["secrets"] if k in sec] + for k in removed_sec: + del sec[k] + + if removed_gs: + write_json(gs_path, gs) + if removed_sec: + write_json(sec_path, sec) + + if removed_gs or removed_sec: + print(f"[cline] Platform disabled — removed {len(removed_gs)} global state key(s) and {len(removed_sec)} secret key(s).") + else: + print("[cline] Platform disabled — no managed keys to remove.") + def _sync_mcp(servers: dict[str, Any]) -> None: targets = [p for p in cline_mcp_candidate_paths() if p.parent.exists()] @@ -51,30 +119,18 @@ def _sync_skills() -> None: print(f"Synced {len(synced)} skills to {cline_skills_dir}: {', '.join(synced) or '(none)'}.") -def _clear_global_state_base_url() -> None: - """Disabled-platform cleanup: reset geminiBaseUrl to empty. - - Called when the platform is disabled (enabled=false). The other global - state keys and the secret are left untouched — only the third-party API - base URL is cleared. No-op if it is already empty. - """ - path = cline_global_state_path() - existing = read_json_object(path) - if not existing.get("geminiBaseUrl"): - print(f"[cline] geminiBaseUrl already empty in {path} — nothing to clear.") - return - existing["geminiBaseUrl"] = "" - write_json(path, existing) - print(f"Cleared geminiBaseUrl in {path} (platform disabled).") - - def _sync_global_state(managed: dict[str, Any]) -> None: - """Merge the 5 managed keys into ~/.cline/data/globalState.json. + """Merge the managed keys into ~/.cline/data/globalState.json. Preserves every other key in the file (welcome state, auto-approval settings, workspace roots, etc.). Unresolved ${VAR} placeholders are skipped so a missing cline.url never writes literal "${cline.url}" into the user's global state. + + Any key we previously managed (tracked in the sidecar) that is no longer + present in the config is deleted from the file, so removing a key from the + platform config (e.g. planModeApiProvider) also removes its stale value + instead of leaving it behind on the next sync. """ if not managed: return @@ -88,11 +144,24 @@ def _sync_global_state(managed: dict[str, Any]) -> None: continue merged[key] = value applied += 1 - if applied: + record = _load_managed_keys() + removed = 0 + for key in record["globalState"]: + if key not in managed and key in merged: + del merged[key] + removed += 1 + if applied or removed: write_json(path, merged) - print(f"Synced {applied} global state key(s) to {path}.") + record["globalState"] = set(managed.keys()) + _save_managed_keys(record["globalState"], record["secrets"]) + parts = [] + if applied: + parts.append(f"set {applied} global state key(s)") + if removed: + parts.append(f"removed {removed} stale global state key(s)") + print(f"Synced global state to {path}: {'; '.join(parts)}.") else: - print("[cline] No resolvable global state keys to sync — skipping.") + print("[cline] No global state changes to sync — skipping.") def _sync_secrets(secrets: dict[str, Any]) -> None: @@ -101,6 +170,10 @@ def _sync_secrets(secrets: dict[str, Any]) -> None: Cline stores each provider's key under the ApiKey key (e.g. geminiApiKey). Existing keys for other providers are preserved. Unresolved ${VAR} placeholders are skipped to avoid writing garbage. + + Any key we previously managed (tracked in the sidecar) that is no longer + present in the config is deleted from the file, so removing a provider + (e.g. geminiApiKey) also removes its stale secret on the next sync. """ if not secrets: return @@ -114,11 +187,24 @@ def _sync_secrets(secrets: dict[str, Any]) -> None: continue merged[key] = value applied += 1 - if applied: + record = _load_managed_keys() + removed = 0 + for key in record["secrets"]: + if key not in secrets and key in merged: + del merged[key] + removed += 1 + if applied or removed: write_json(path, merged) - print(f"Synced {applied} secret key(s) to {path}.") + record["secrets"] = set(secrets.keys()) + _save_managed_keys(record["globalState"], record["secrets"]) + parts = [] + if applied: + parts.append(f"set {applied} secret key(s)") + if removed: + parts.append(f"removed {removed} stale secret key(s)") + print(f"Synced secrets to {path}: {'; '.join(parts)}.") else: - print("[cline] No resolvable secrets to sync — skipping.") + print("[cline] No secret changes to sync — skipping.") def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: @@ -126,9 +212,9 @@ def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: When the platform is disabled (enabled=false), the orchestrator still passes the full cfg but the renderer applies its disabled-state handling: MCP - servers and skills are still synced, but the managed global state is cleaned - up by clearing geminiBaseUrl (disabling the third-party API) while leaving - the other keys and the secret intact. + servers and skills are still synced, and every key this tool manages is + removed from globalState.json and secrets.json (a thorough cleanup, so no + stale values linger). Re-enabling re-applies the managed keys. """ root = cline_root_dir() if not root.exists(): @@ -138,7 +224,7 @@ def sync(mcp_servers: dict[str, Any], cfg: dict[str, Any]) -> None: _sync_mcp(mcp_servers) _sync_skills() if cfg.get("enabled") is False: - _clear_global_state_base_url() + _prune_all_managed_keys() return _sync_global_state(cfg.get("globalState", {})) _sync_secrets(cfg.get("secrets", {})) diff --git a/sync/sync_config.py b/sync/sync_config.py index 78f8d8a..061c377 100644 --- a/sync/sync_config.py +++ b/sync/sync_config.py @@ -95,7 +95,7 @@ def _effective_platform_config(platform: str) -> dict[str, Any]: ``enabled`` is owned by the sync orchestrator and is forwarded to the renderer (not stripped) so each platform can decide what a disabled state means via its renderer-owned cleanup path: - - Cline clears its third-party API base URL (geminiBaseUrl). + - Cline removes every key it manages from globalState.json and secrets.json. - Codex comments out its managed ``model_provider`` while keeping the rest of the config intact. Other platforms ignore ``enabled`` and sync normally. An absent ``enabled`` From 30a5f91b9ace216b94fa7c0511bfb5c45d3dd5b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 10:36:51 +0800 Subject: [PATCH 09/65] fix(cline): update actModeOpenAiModelId to use deepseek-v4-flash - Changed actModeOpenAiModelId in cline.json from deepseek-v4-pro to deepseek-v4-flash for improved model performance. --- env/platforms/cline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/env/platforms/cline.json b/env/platforms/cline.json index f4459b3..bac1e0d 100644 --- a/env/platforms/cline.json +++ b/env/platforms/cline.json @@ -4,7 +4,7 @@ "globalState": { "openAiBaseUrl": "https://integrate.api.nvidia.com/v1", "planModeOpenAiModelId": "deepseek-ai/deepseek-v4-pro", - "actModeOpenAiModelId": "deepseek-ai/deepseek-v4-pro" + "actModeOpenAiModelId": "deepseek-ai/deepseek-v4-flash" }, "secrets": { "openAiApiKey": "${cline.openaiKey}" From 2aa89311c0539249b9ee39d1837282028f8b2950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 10:44:57 +0800 Subject: [PATCH 10/65] chore(cline): simplify comment in cline.json for clarity - Updated the comment in cline.json to provide a clearer explanation of the global state and secrets sync process, emphasizing the merging of keys and the handling of placeholder values. --- env/platforms/cline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/env/platforms/cline.json b/env/platforms/cline.json index bac1e0d..c67692f 100644 --- a/env/platforms/cline.json +++ b/env/platforms/cline.json @@ -1,5 +1,5 @@ { - "_comment": "Cline (VSCode extension) global state + secrets sync. Set 'enabled' to true to sync the managed globalState keys + secrets; set to false to clear geminiBaseUrl (disable the third-party API) while leaving the other keys and secrets intact. The keys under 'globalState' are merged into ~/.cline/data/globalState.json (all other keys are preserved). OpenAI-compatible is the highest-priority provider (planModeApiProvider/actModeApiProvider = 'openai'); its base URL and model IDs are set literally, and its key uses ${cline.openaiKey}. The gemini keys are kept as a fallback config. 'geminiBaseUrl' uses ${cline.url}; leave cline.url empty in secrets.json to disable the third-party API (geminiBaseUrl becomes \"\"). The 'secrets' object is merged into ~/.cline/data/secrets.json — Cline stores the key under the ApiKey key, e.g. geminiApiKey for provider 'gemini' and openAiApiKey for provider 'openai'.", + "_comment": "Cline global state + secrets sync. Keys in 'globalState' and 'secrets' are merged into ~/.cline/data/globalState.json and ~/.cline/data/secrets.json respectively. Placeholder values (${cline.*}) are skipped by the sync script when unresolved.", "enabled": true, "globalState": { "openAiBaseUrl": "https://integrate.api.nvidia.com/v1", From 38ba02ebef37581fe705e06b550c613ce63fb02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 11:30:45 +0800 Subject: [PATCH 11/65] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8F=97?= =?UTF-8?q?=E6=8E=A7=E6=BC=94=E8=BF=9B=E9=97=AD=E7=8E=AF=E3=80=81=E6=89=93?= =?UTF-8?q?=E5=8C=85=E4=B8=8E=E5=AE=9A=E6=97=B6=E5=90=8C=E6=AD=A5=E7=AD=89?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + USER.md.example | 32 +++ cron/README.md | 35 +++ cron/install.sh | 102 +++++++ cron/run-sync.sh | 51 ++++ cron/uninstall.sh | 28 ++ env/optional-mcps/README.md | 40 +++ env/optional-mcps/enabled.json | 1 + env/optional-mcps/filesystem-extra.json | 9 + env/optional-mcps/puppeteer.json | 9 + env/optional-mcps/wechat-bridge.json | 11 + skills-engineering/README.md | 13 + .../scripts/suggest_skill_proposals.sh | 230 +++++++++++++++ skills-engineering/scripts/skill_bundles.sh | 271 ++++++++++++++++++ skills-engineering/scripts/sync-skills.sh | 31 +- .../scripts/sync-user-profile.sh | 137 +++++++++ .../scripts/validate-skill-integrity.sh | 151 ++++++++++ sync/README.md | 12 + sync/list_models.sh | 89 ++++++ sync/model_routing.md | 41 +++ sync/optional_mcps.sh | 108 +++++++ 21 files changed, 1408 insertions(+), 1 deletion(-) create mode 100644 USER.md.example create mode 100644 cron/README.md create mode 100755 cron/install.sh create mode 100755 cron/run-sync.sh create mode 100755 cron/uninstall.sh create mode 100644 env/optional-mcps/README.md create mode 100644 env/optional-mcps/enabled.json create mode 100644 env/optional-mcps/filesystem-extra.json create mode 100644 env/optional-mcps/puppeteer.json create mode 100644 env/optional-mcps/wechat-bridge.json create mode 100755 skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh create mode 100755 skills-engineering/scripts/skill_bundles.sh create mode 100755 skills-engineering/scripts/sync-user-profile.sh create mode 100755 skills-engineering/scripts/validate-skill-integrity.sh create mode 100755 sync/list_models.sh create mode 100644 sync/model_routing.md create mode 100755 sync/optional_mcps.sh diff --git a/.gitignore b/.gitignore index 61e34a9..23f1790 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,13 @@ docs/.vitepress/.temp/ skills-engineering/ios-engineer/evolution/usage/* !skills-engineering/ios-engineer/evolution/usage/usage.jsonl +# 自动生成的提案去重注册表(由 suggest_skill_proposals.sh 维护) +skills-engineering/ios-engineer/evolution/.auto_proposal_registry.json +# skill_bundles.sh 导出的 agentskills.io bundle 产物 +skills-engineering/.bundles/ +# 用户个人画像(从 USER.md.example 复制,不提交) +USER.md +# 技能完整性校验基线(由 validate-skill-integrity.sh 生成) +skills-engineering/.integrity/ templates/portability-ecosystem.md PRD/ diff --git a/USER.md.example b/USER.md.example new file mode 100644 index 0000000..8307932 --- /dev/null +++ b/USER.md.example @@ -0,0 +1,32 @@ +# USER.md — 跨会话用户画像(模板) + +> 复制为 `USER.md`(同目录,已被 .gitignore 排除,不提交),填写你的真实信息。 +> `skills-engineering/scripts/sync-user-profile.sh` 会把它同步到 `~/.ai-coding-kit/USER.md` +> 并注入各端 Agent preamble 的 `user-profile` 托管块,使所有 AI 工具共享同一份偏好。 + +## 身份与角色 +- 姓名 / 称呼: +- 主要角色:______(如 iOS 工程师 / 全栈 / 技术负责人 / 学生) +- 常用语言:中文 / English(回答默认语言:______) + +## 技术偏好 +- 主力语言 / 框架: +- 偏好的代码风格: +- 偏好的测试策略: +- 是否喜欢最小改动 / 显式确认再执行: + +## 沟通偏好 +- 回答风格:简洁直接 / 详细带解释 / 先给结论 +- 是否接受主动建议(超出请求范围):是 / 否 +- 不确定时:明确说「不确定」/ 给最佳猜测 + +## 约束与红线 +- 不可做的事(合规 / 安全 / 隐私): +- 敏感项目 / 不可外传的信息: + +## 设备与环境 +- OS:macOS / Linux / Windows +- 常用编辑器 / IDE: +- 已安装的 AI 工具:Codex / Claude Code / Cursor / Gemini / Cline / 其他 + + diff --git a/cron/README.md b/cron/README.md new file mode 100644 index 0000000..83b8363 --- /dev/null +++ b/cron/README.md @@ -0,0 +1,35 @@ +# cron — 定时同步自动化 + +对齐 Hermes Agent 的 `cron/` 思路:把 `sync.sh` 注册为系统定时任务,实现「set-and-forget」配置同步与校验。 + +## 包含的脚本 + +| 文件 | 作用 | +|------|------| +| `run-sync.sh` | 定时执行体:运行 MCP 同步 + 技能同步 + preamble + 校验,日志写入 `~/.ai-coding-kit-cron/logs/`。仅当 `env/secrets.json` 存在时才真正同步。 | +| `install.sh` | 注册定时任务(macOS 默认 launchd,可用 `--cron` 改用 crontab)。 | +| `uninstall.sh` | 移除定时任务(launchd + crontab 一并清理)。 | + +## 用法 + +```bash +# 默认每天 09:00(launchd,仅 macOS) +bash cron/install.sh + +# 自定义时间:每天 03:30 +bash cron/install.sh --hour 3 --minute 30 + +# 改用 crontab(非 macOS 或偏好 cron) +bash cron/install.sh --cron +bash cron/install.sh --cron --schedule "0 3 * * *" + +# 卸载 +bash cron/uninstall.sh +``` + +## 设计要点 + +- **不破坏既有守卫**:`run-sync.sh` 复用 `sync.sh` 与 `skills-engineering/scripts/*`,与 `pre-push` 钩子走同一套同步逻辑。 +- **安全跳过**:缺少 `env/secrets.json` 时只记录 SKIP 日志,不报错、不写未解析的 `${...}` 占位符。 +- **日志滚动**:保留最近 30 份执行日志,便于排查同步失败。 +- **幂等注册**:`install.sh` 重复运行会先卸载旧代理再注册,避免重复条目。 diff --git a/cron/install.sh b/cron/install.sh new file mode 100755 index 0000000..7078024 --- /dev/null +++ b/cron/install.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# ============================================================================= +# install.sh — 把 ai-coding-kit 同步注册为系统定时任务 +# +# 默认在 macOS 上注册 launchd 代理(推荐),也可通过 --cron 改用 crontab。 +# +# 用法: +# bash cron/install.sh # 默认每天 09:00 运行(launchd) +# bash cron/install.sh --hour 3 --minute 30 # 每天 03:30 +# bash cron/install.sh --cron # 改用 crontab(每天 09:00) +# bash cron/install.sh --cron --schedule "0 3 * * *" # 自定义 cron 表达式 +# +# 卸载: bash cron/uninstall.sh +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +RUN_SYNC="${SCRIPT_DIR}/run-sync.sh" +LABEL="com.aicodingkit.sync" +PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist" + +HOUR=9 +MINUTE=0 +USE_CRON=0 +SCHEDULE_SET=0 +CRON_SCHEDULE="0 9 * * *" + +while [ $# -gt 0 ]; do + case "$1" in + --hour) [[ "$2" =~ ^[0-9]+$ ]] && HOUR="$2" || { echo "--hour must be an integer" >&2; exit 1; }; shift 2 ;; + --minute) [[ "$2" =~ ^[0-9]+$ ]] && MINUTE="$2" || { echo "--minute must be an integer" >&2; exit 1; }; shift 2 ;; + --cron) USE_CRON=1; shift ;; + --schedule) CRON_SCHEDULE="$2"; SCHEDULE_SET=1; shift 2 ;; + -h|--help) + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +# launchd 模式下 --schedule 无意义,给出警告避免静默忽略 +if [[ "$USE_CRON" -eq 0 && "${SCHEDULE_SET:-0}" -eq 1 ]]; then + echo "Warning: --schedule is only used with --cron; ignoring (use --hour/--minute for launchd)." >&2 +fi + +chmod +x "$RUN_SYNC" + +if [ "$USE_CRON" -eq 1 ]; then + # ---- crontab 方式 ---- + if ! command -v crontab >/dev/null 2>&1; then + echo "crontab not available on this system." >&2 + exit 1 + fi + # 去重:移除旧的同标签任务再添加 + ( crontab -l 2>/dev/null | grep -v "$LABEL" ) | crontab - + ( crontab -l 2>/dev/null; echo "${CRON_SCHEDULE} ${RUN_SYNC} # ${LABEL}" ) | crontab - + echo "Registered cron job: '${CRON_SCHEDULE} ${RUN_SYNC}'" + echo "View with: crontab -l" +else + # ---- launchd 方式(macOS 推荐) ---- + if [ "$(uname)" != "Darwin" ]; then + echo "launchd is macOS-only. Use --cron on this platform." >&2 + exit 1 + fi + mkdir -p "$(dirname "$PLIST")" + cat > "$PLIST" < + + + + Label + ${LABEL} + ProgramArguments + + /bin/bash + ${RUN_SYNC} + + StartCalendarInterval + + Hour + ${HOUR} + Minute + ${MINUTE} + + RunAtLoad + + StandardOutPath + ${HOME}/.ai-coding-kit-cron/launchd.out.log + StandardErrorPath + ${HOME}/.ai-coding-kit-cron/launchd.err.log + + +EOF + launchctl unload "$PLIST" 2>/dev/null || true + launchctl load "$PLIST" + echo "Registered launchd agent: ${PLIST}" + echo "Schedule: daily at ${HOUR}:$(printf '%02d' ${MINUTE})" + echo "Logs: ~/.ai-coding-kit-cron/logs/" + echo "Unload with: bash cron/uninstall.sh" +fi diff --git a/cron/run-sync.sh b/cron/run-sync.sh new file mode 100755 index 0000000..cc70d1b --- /dev/null +++ b/cron/run-sync.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# ============================================================================= +# run-sync.sh — 定时同步执行体(被 launchd / cron 调用) +# +# 做「set-and-forget」式配置同步: +# 1. 仅当 env/secrets.json 存在时才真正同步(否则跳过,不报错) +# 2. 运行 sync.sh(MCP + 平台配置) +# 3. 运行 skills-engineering 的技能同步 + preamble 同步 + 校验 +# 4. 全程日志写入 ~/.ai-coding-kit-cron/logs/ +# +# 该脚本本身不依赖任何调度器;调度由 cron/install.sh 注册。 +# ============================================================================= +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOG_DIR="${HOME}/.ai-coding-kit-cron/logs" +mkdir -p "$LOG_DIR" +TS="$(date '+%Y%m%d-%H%M%S')" +LOG_FILE="${LOG_DIR}/sync-${TS}.log" +# 保留最近 30 个日志(BSD head 不支持 -n 负数,改用 ls -t + tail -n +31 实现 macOS 兼容) +ls -t "$LOG_DIR"/sync-*.log 2>/dev/null | tail -n +31 | xargs rm -f 2>/dev/null || true + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } + +log "=== ai-coding-kit scheduled sync start (pid $$) ===" + +if [ ! -f "${REPO_ROOT}/env/secrets.json" ]; then + log "SKIP: env/secrets.json not found — run 'cp env/secrets.json.example env/secrets.json' first." + log "=== sync skipped ===" + exit 0 +fi + +# 1) MCP + 平台配置同步 +log "[1/2] sync.sh (MCP + platforms)" +if bash "${REPO_ROOT}/sync.sh" >>"$LOG_FILE" 2>&1; then + log " sync.sh OK" +else + log " sync.sh FAILED (see log) — continuing to skill sync" +fi + +# 2) 技能同步 + preamble + 校验 +log "[2/2] skills-engineering sync + verify" +SE="${REPO_ROOT}/skills-engineering" +if [ -d "$SE" ]; then + bash "${SE}/scripts/sync-skills.sh" >>"$LOG_FILE" 2>&1 && log " sync-skills.sh OK" || log " sync-skills.sh FAILED" + bash "${SE}/scripts/sync-agent-preamble.sh" >>"$LOG_FILE" 2>&1 && log " sync-agent-preamble.sh OK" || log " sync-agent-preamble.sh FAILED" + bash "${SE}/scripts/sync-user-profile.sh" >>"$LOG_FILE" 2>&1 && log " sync-user-profile.sh OK" || log " sync-user-profile.sh FAILED" + bash "${SE}/scripts/verify-sync.sh" >>"$LOG_FILE" 2>&1 && log " verify-sync.sh OK" || log " verify-sync.sh FAILED" +fi + +log "=== ai-coding-kit scheduled sync done ===" diff --git a/cron/uninstall.sh b/cron/uninstall.sh new file mode 100755 index 0000000..53a15a0 --- /dev/null +++ b/cron/uninstall.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# ============================================================================= +# uninstall.sh — 移除 ai-coding-kit 定时同步任务 +# 同时清理 launchd 代理与 crontab 中的同标签任务。 +# ============================================================================= +set -uo pipefail + +LABEL="com.aicodingkit.sync" +PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist" + +# launchd +if [ -f "$PLIST" ]; then + launchctl unload "$PLIST" 2>/dev/null || true + rm -f "$PLIST" + echo "Removed launchd agent: $PLIST" +else + echo "No launchd agent found." +fi + +# crontab +if command -v crontab >/dev/null 2>&1; then + if crontab -l 2>/dev/null | grep -q "$LABEL"; then + ( crontab -l 2>/dev/null | grep -v "$LABEL" ) | crontab - + echo "Removed cron job for ${LABEL}." + fi +fi + +echo "Done. Logs in ~/.ai-coding-kit-cron/ are left intact." diff --git a/env/optional-mcps/README.md b/env/optional-mcps/README.md new file mode 100644 index 0000000..33e85b5 --- /dev/null +++ b/env/optional-mcps/README.md @@ -0,0 +1,40 @@ +# optional-mcps — 可选 MCP 服务器目录 + +对齐 Hermes Agent 的 `optional-mcps/` 思路:把**非默认、社区/高级**的 MCP 服务器与开箱即用的 `env/mcp/` 集合分开,避免污染默认配置,同时保留「一键启用」能力。 + +## 工作机制 + +- `env/optional-mcps/*.json`:可选的 MCP 服务器定义(**不**自动同步)。 +- `sync/optional_mcps.sh enable `:把定义复制到 `env/mcp/.json`,由于 `env/mcp/*.json` 会被 `sync.sh` 自动发现,下一次 `sync.sh` 即生效。 +- `sync/optional_mcps.sh disable `:从 `env/mcp/` 移除并停止同步。 +- 启用状态记录在 `env/optional-mcps/enabled.json`(git 提交,便于团队共享「已启用集合」)。 + +## 用法 + +```bash +# 列出所有可选服务器及其启用状态 +bash sync/optional_mcps.sh list + +# 启用一个 +bash sync/optional_mcps.sh enable playwright + +# 禁用一个 +bash sync/optional_mcps.sh disable playwright + +# 启用后照常同步 +bash sync.sh +``` + +## 新增一个可选服务器 + +1. 在 `env/optional-mcps/` 放 `.json`(格式同 `env/mcp/*.json`,敏感值用 `${...}` 占位)。 +2. 若需要 secret,在 `env/secrets.json.example` 增加对应字段说明,并提醒用户填写 `env/secrets.json`。 +3. 运行 `bash sync/optional_mcps.sh enable `。 + +## 示例 + +| 服务器 | 说明 | 需要 secret | +|--------|------|-------------| +| `puppeteer` | 浏览器自动化(与默认 `playwright` 互补,择一启用) | 否 | +| `filesystem-extra` | 扩展文件系统访问 | 是(`filesystem_extra.root`) | +| `wechat-bridge` | 微信桥接(演示) | 是(`wechat.token`) | diff --git a/env/optional-mcps/enabled.json b/env/optional-mcps/enabled.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/env/optional-mcps/enabled.json @@ -0,0 +1 @@ +{} diff --git a/env/optional-mcps/filesystem-extra.json b/env/optional-mcps/filesystem-extra.json new file mode 100644 index 0000000..b41922e --- /dev/null +++ b/env/optional-mcps/filesystem-extra.json @@ -0,0 +1,9 @@ +{ + "name": "filesystem-extra", + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "${filesystem_extra.root}"], + "env": {}, + "platforms": ["claude", "codex", "codebuddy", "cursor", "cline", "gemini"], + "_comment": "可选 MCP:扩展文件系统访问。需在 env/secrets.json 增加 \"filesystem_extra\": { \"root\": \"/abs/path/allowed\" } 占位解析。enable 后同步。" +} diff --git a/env/optional-mcps/puppeteer.json b/env/optional-mcps/puppeteer.json new file mode 100644 index 0000000..3546c23 --- /dev/null +++ b/env/optional-mcps/puppeteer.json @@ -0,0 +1,9 @@ +{ + "name": "puppeteer", + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "env": {}, + "platforms": ["claude", "codex", "codebuddy", "cursor", "cline", "gemini"], + "_comment": "可选 MCP:基于 Puppeteer 的浏览器自动化(与默认 playwright 互补,择一启用)。enable 后由 sync.sh 自动发现。" +} diff --git a/env/optional-mcps/wechat-bridge.json b/env/optional-mcps/wechat-bridge.json new file mode 100644 index 0000000..22327b8 --- /dev/null +++ b/env/optional-mcps/wechat-bridge.json @@ -0,0 +1,11 @@ +{ + "name": "wechat-bridge", + "type": "stdio", + "command": "npx", + "args": ["-y", "@example/wechat-mcp"], + "env": { + "WECHAT_TOKEN": "${wechat.token}" + }, + "platforms": ["claude", "codex", "codebuddy"], + "_comment": "可选 MCP:企业微信 / 个人微信桥接示例。需在 env/secrets.json 增加 \"wechat\": { \"token\": \"...\" }。enable 后同步。" +} diff --git a/skills-engineering/README.md b/skills-engineering/README.md index b4dced9..11956a8 100644 --- a/skills-engineering/README.md +++ b/skills-engineering/README.md @@ -458,3 +458,16 @@ git push --no-verify # 跳过整个 pre-push(含 sync/sync_all - `.agents/invocation.md`:触发矩阵补齐缺失的 `plan-grill` 与 `cross-model-review`,并指向 `composition.md`。 - `cognitive-expansion` / `logical-reasoning` 及 `cognitive_expansion.md`:对 ios-engineer 的跨技能链接加"条件性"说明,消除非 iOS 环境死链风险。 - `ios-engineer/SKILL.md`:en-US 镜像声明改为诚实的部分镜像说明(符合 GR-011)。 + +### 3.0.2 — 2026-07-21(对标 NousResearch/hermes-agent 补充) + +> 分析开源库 `NousResearch/hermes-agent` 后,按优先级补入与其「受控演进」定位契合、且不与其运行时能力冲突的能力: + +- **P0-1 Skill 自我改进闭环**:新增 `ios-engineer/scripts/suggest_skill_proposals.sh`,读取 `summarize_usage_ledger.sh --json` 的提案候选信号,**自动生成 draft proposal**(仅 draft,不自动晋升),并用 `evolution/.auto_proposal_registry.json` 去重。对齐 Hermes 学习循环,但落在既有受控演进闸门内(观测 → 建议 → 人工审批)。 +- **P0-2 agentskills.io 兼容打包/导入/校验**:新增 `scripts/skill_bundles.sh`(`export` / `validate` / `import` / `list`),把任一 skill 打包成 agentskills.io 兼容产物(`SKILL.md` + `references/` + `bundle.json` 含 sha256),支持从社区 Skills Hub / Hermes 兼容 bundle 导入。导出产物落在 `skills-engineering/.bundles/`(已 gitignore)。 +- **P1-3 定时同步自动化**:新增 `cron/`(launchd 默认、`--cron` 可选 crontab),`run-sync.sh` 复用 `sync.sh` + 技能同步 + preamble + 校验,日志滚动保留 30 份。 +- **P1-4 可选 MCP 服务器目录**:新增 `env/optional-mcps/`(playwright 改名 `puppeteer` 避免与默认 `env/mcp/playwright.json` 冲突;另含 `filesystem-extra`、`wechat-bridge` 示例)与 `sync/optional_mcps.sh`(`enable` / `disable` / `list` / `sync`)。`disable` 带护栏:只移除由本工具启用的服务器,绝不删除仓库默认 `env/mcp/*.json`。 +- **P1-5 跨会话用户画像**:新增仓库根 `USER.md.example` 与 `scripts/sync-user-profile.sh`,把用户画像同步到 `~/.ai-coding-kit/USER.md` 并注入各端 preamble 的 `user-profile` 托管块(与 ios-engineer 块标记独立、互不干扰);个人 `USER.md` 已 gitignore。 +- **P2-6 多平台模型路由抽象**:新增 `sync/list_models.sh`(跨平台 model/provider 配置总览,密钥打码)与 `sync/model_routing.md`(统一 Provider 层设计说明)。 +- **P2-7 子代理并行同步**:`scripts/sync-skills.sh` 支持 `PARALLEL=1`(默认 `MAX_PARALLEL=4`),把 (skill × target) 同步以子代理式后台并行执行。 +- **P2-8 技能校验加固**:新增 `scripts/validate-skill-integrity.sh`(sha256 基线比对,发现 ADDED/MODIFIED/REMOVED;`--verify-bundle` 校验 `skill_bundles` 产物 checksum),基线落在 `skills-engineering/.integrity/`(已 gitignore)。 diff --git a/skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh b/skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh new file mode 100755 index 0000000..465925a --- /dev/null +++ b/skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# ============================================================================= +# suggest_skill_proposals.sh — Skill 自我改进闭环 (观测 → 建议 → 人工审批) +# +# 对齐 Hermes Agent 的「学习循环」思路,但落地为本仓库既有的「受控演进」闸门: +# 1. 读取 summarize_usage_ledger.sh --json 产出的 proposal_signals +# 2. 对每个超过阈值的信号,自动生成一份 DRAFT proposal(仅 draft,不自动晋升) +# 3. 用注册表去重,避免对同一信号反复建草稿 +# 4. 打印一份「建议清单」供人工 review / 审批 +# +# 设计原则: +# - 只产出 draft,绝不自动 approve / promote(受控演进不被绕过) +# - 幂等:同一信号重复运行不会新建重复草稿 +# - 只读 ledger,不改写任何演进数据 +# +# 用法: +# bash scripts/suggest_skill_proposals.sh # 生成草稿并打印建议 +# bash scripts/suggest_skill_proposals.sh --dry-run # 只打印会生成什么,不落盘 +# bash scripts/suggest_skill_proposals.sh --json # 机器可读输出 +# ============================================================================= +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +DRY_RUN=0 +EMIT_JSON=0 +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --json) EMIT_JSON=1 ;; + -h|--help) + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac + shift +done + +SUMMARIZE="scripts/summarize_usage_ledger.sh" +PROPOSALS_DIR="evolution/proposals" +REGISTRY="evolution/.auto_proposal_registry.json" +mkdir -p "$PROPOSALS_DIR" + +# 读取信号 +if [ ! -f "$SUMMARIZE" ]; then + echo "summarizer not found: $SUMMARIZE" >&2 + exit 1 +fi + +SIGNALS_JSON="$(bash "$SUMMARIZE" --json 2>/dev/null || true)" +if [ -z "$SIGNALS_JSON" ]; then + echo "No ledger summary produced (ledger empty or missing)." + exit 0 +fi + +# 用 python3 解析信号并生成草稿(python3 与 sync subtree 一致,JSON 处理更稳) +python3 - "$SIGNALS_JSON" "$PROPOSALS_DIR" "$REGISTRY" "$DRY_RUN" "$EMIT_JSON" <<'PY' +import json, sys, os, re, datetime + +signals_raw, proposals_dir, registry_path, dry_run_s, emit_json_s = sys.argv[1:6] +dry_run = dry_run_s == "1" +emit_json = emit_json_s == "1" + +try: + payload = json.loads(signals_raw) +except Exception: + # summarizer may emit a plain-text "no entries" message when the ledger is + # empty (not valid JSON) — treat that as "no signals" rather than failing. + msg = "No proposal signals (ledger empty or summarizer produced no JSON)." + print(msg if not emit_json else json.dumps({"generated": [], "skipped": [], "message": msg}, indent=2, ensure_ascii=False)) + sys.exit(0) + +signals = payload.get("proposal_signals", []) or [] +if not signals: + msg = "No proposal signals above threshold — skill is currently healthy." + print(msg if not emit_json else json.dumps({"generated": [], "skipped": [], "message": msg}, indent=2, ensure_ascii=False)) + sys.exit(0) + +# 加载注册表(signal_key -> {proposal_id, status}) +registry = {} +if os.path.isfile(registry_path): + try: + with open(registry_path, encoding="utf-8") as f: + registry = json.load(f) + except Exception: + registry = {} + +def signal_key(s): + kind = s.get("kind", "unknown") + if kind == "missed_rule": + return f"missed_rule:{s.get('rule_id','')}" + if kind == "task_type_other": + return "task_type_other" + if kind == "deviation": + return f"deviation:{s.get('text','')[:60]}" + if kind == "tool_divergence": + return f"tool_divergence:{s.get('rule_id','')}" + return f"{kind}:{s.get('rule_id', s.get('text', ''))}" + +def slugify(s): + kind = s.get("kind", "x") + key = signal_key(s) + tail = re.sub(r'[^A-Za-z0-9]+', '-', key.split(':',1)[-1])[:40].strip('-') + return f"auto-{kind}-{tail}" + +def change_type_for(s): + kind = s.get("kind") + if kind == "task_type_other": + return "新增能力" + if kind == "tool_divergence": + return "修正表达 / 一致性" + return "修正表达" + +generated = [] +skipped = [] + +for s in signals: + key = signal_key(s) + # 去重:已注册且目标草稿仍存在且未 rejected + if key in registry: + pid = registry[key].get("proposal_id") + pstat = registry[key].get("status") + if pid and os.path.isfile(os.path.join(proposals_dir, pid + ".md")) and pstat != "rejected": + skipped.append({"signal_key": key, "reason": f"already drafted as {pid} (status={pstat})"}) + continue + if dry_run: + generated.append({"signal_key": key, "slug": slugify(s), "note": s.get("note",""), "dry_run": True}) + continue + + # 生成草稿 + now = datetime.datetime.now(datetime.timezone.utc) + ts = now.strftime("%Y%m%d-%H%M%S") + slug = slugify(s) + pid = f"{ts}-{slug}" + note = s.get("note", "").strip() + ctype = change_type_for(s) + + # 针对 missed_rule / tool_divergence 给出更具体的变更内容 + if s.get("kind") == "missed_rule": + rid = s.get("rule_id", "") + change = (f"1. 复查 `{rid}` 在 `references/rule_index.md` 的 active 定义与对应 ref 文件。\n" + f"2. 提升该规则的表达清晰度或路由触发条件,使 Agent 在相关任务更易命中。\n" + f"3. 若规则已过时,考虑按「退役规则」流程处理。") + benefit = f"降低 `{rid}` 的 missed 次数(当前累计 {s.get('miss_count','?')} 次),提升规则命中率。" + elif s.get("kind") == "tool_divergence": + d = s.get("data", {}) or {} + rid = s.get("rule_id", "") + change = (f"1. 对比 `{rid}` 在 {d.get('high_tool','?')}({d.get('high_rate','?')}%)与 " + f"{d.get('low_tool','?')}({d.get('low_rate','?')}%)两端的命中差异。\n" + f"2. 检查两端 preamble / 注入语境是否一致,统一触发表述。") + benefit = f"收敛 `{rid}` 的工具间命中率差异(当前差 {d.get('diff_pct','?')}%)。" + elif s.get("kind") == "task_type_other": + change = ("1. 在 `evolution/scenarios/` 与 validation_scenarios 中增补 task_type=other 的高频模式。\n" + "2. 若形成稳定类别,考虑在 SKILL.md 症状导航中新增入口。") + benefit = "覆盖当前 12 选 1 之外的高频任务类型,减少 audit 落入 other。" + else: # deviation + change = (f"1. 针对稳定失败模式「{s.get('text','')}」在相关 ref 增加更明确的检查项。\n" + "2. 必要时补充回归场景固化该检查。") + benefit = f"消除「{s.get('text','')}」这类稳定失败模式(累计 {s.get('count','?')} 次)。" + + proposal = f"""# 自动生成的演进提案(观测驱动) + +## Metadata +- **Proposal ID**: {pid} +- **Title**: 观测驱动 — {s.get('kind','')} 信号 +- **Author**: skill-self-improvement-loop (auto) +- **Date**: {now.strftime('%Y-%m-%d %H:%M:%S %z')} +- **Active Version At Creation**: (待填充 — 运行 create_skill_proposal.sh 风格元数据) +- **Status**: draft +- **Auto-generated**: true +- **Signal key**: {key} + +## 问题信号 +- 来源:usage ledger 汇总信号(summarize_usage_ledger.sh)。 +- {note} + +## 变更类型 +- {ctype} + +## 变更内容 +{change} + +## 预期收益 +- {benefit} + +## 验证 +- 结构校验:`bash ios-engineer/scripts/validate_skill_evolution.sh` +- 场景回放:必要时 `bash ios-engineer/scripts/validate_skill_proposal.sh evolution/proposals/{pid}.md` +- 残留风险:本提案为自动草稿,需人工 review 后走 approve → promote 流程,未审批前不生效。 + +## 状态 +- draft +""" + ppath = os.path.join(proposals_dir, pid + ".md") + with open(ppath, "w", encoding="utf-8") as f: + f.write(proposal) + registry[key] = {"proposal_id": pid, "status": "draft", "created_at": now.isoformat()} + generated.append({"signal_key": key, "proposal_id": pid, "note": note}) + +# 写回注册表 +if not dry_run and generated: + with open(registry_path, "w", encoding="utf-8") as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + f.write("\n") + +if emit_json: + print(json.dumps({"generated": generated, "skipped": skipped}, indent=2, ensure_ascii=False)) + sys.exit(0) + +if dry_run: + print("== [dry-run] 以下信号将生成草稿(未落盘) ==") +else: + print("== 已生成草稿提案(仅 draft,需人工审批) ==") +for g in generated: + if dry_run: + print(f" • {g['signal_key']} -> slug={g['slug']}") + print(f" {g['note']}") + else: + print(f" • {g['proposal_id']}") + print(f" {g['note']}") +if skipped: + print("") + print("== 已跳过(去重:已有未驳回草稿) ==") + for k in skipped: + print(f" • {k['signal_key']} ({k['reason']})") +print("") +print(f"共生成 {len(generated)} 份草稿,跳过 {len(skipped)} 份。运行 create_skill_proposal.sh / approve 流程前请先 review。") +PY diff --git a/skills-engineering/scripts/skill_bundles.sh b/skills-engineering/scripts/skill_bundles.sh new file mode 100755 index 0000000..7aceeee --- /dev/null +++ b/skills-engineering/scripts/skill_bundles.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# ============================================================================= +# skill_bundles.sh — agentskills.io 兼容的技能打包 / 导入 / 校验 +# +# 对齐 Hermes Agent 的 skill_bundles.py:让本仓库技能可以「走出去」—— +# 1) export 把任一 skill 打包成 agentskills.io 兼容产物(SKILL.md + references/) +# 2) validate 校验某 skill 是否满足 agentskills.io frontmatter 契约 +# 3) import 从社区 Skills Hub / Hermes 兼容 bundle 导入技能到本仓库 +# +# agentskills.io 契约(Anthropic 开放标准): +# - 目录含 SKILL.md +# - SKILL.md 顶部 YAML frontmatter 须含 `name`(kebab-case,与目录名一致) +# 与 `description`(非空字符串) +# - 允许附带 references/ 等额外文件 +# +# 用法: +# bash scripts/skill_bundles.sh export [--out DIR] [--tar] +# bash scripts/skill_bundles.sh validate +# bash scripts/skill_bundles.sh import [--target DIR] [--name NAME] [--force] +# bash scripts/skill_bundles.sh list +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +BUNDLES_DIR="${SE_DIR}/.bundles" + +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +# ---- 通用 frontmatter 解析(python3,与 sync subtree 一致) ---- +parse_frontmatter() { + # $1 = skill dir; prints JSON {name,description,locale,supported_locales,raw_lines} + python3 - "$1" <<'PY' +import os, re, sys, json +skill_dir = sys.argv[1] +skill_md = os.path.join(skill_dir, "SKILL.md") +out = {"ok": False, "name": "", "description": "", "locale": "", "supported_locales": "", "lines": 0} +if not os.path.isfile(skill_md): + print(json.dumps(out)); sys.exit(0) +with open(skill_md, encoding="utf-8") as f: + raw = f.read() +lines = raw.splitlines() +out["lines"] = len(lines) +if not lines or lines[0].strip() != "---": + print(json.dumps(out)); sys.exit(0) +end = None +for i in range(1, len(lines)): + if lines[i].strip() == "---": + end = i; break +if end is None: + print(json.dumps(out)); sys.exit(0) +fm = {} +block_key = None +for ln in lines[1:end]: + m = re.match(r'^([A-Za-z_][\w-]*):\s?(.*)$', ln) + if m and not ln.startswith(" "): + k, v = m.group(1), m.group(2) + block_key = k if v.strip() in (">", ">-", "|", "|-") else None + fm[k] = v.strip() + elif block_key and (ln.startswith(" ") or ln.strip() == ""): + if ln.strip(): + fm[block_key] = (fm.get(block_key, "") + " " + ln.strip()).strip() + else: + block_key = None +out["ok"] = True +out["name"] = fm.get("name", "") +out["description"] = fm.get("description", "") +out["locale"] = fm.get("locale", "") +out["supported_locales"] = fm.get("supported_locales", "") +print(json.dumps(out)) +PY +} + +cmd="${1:-}"; shift || true +case "$cmd" in + export) + [ $# -ge 1 ] || usage 1 + SKILL="$1"; shift + OUT_DIR="$BUNDLES_DIR" + MAKE_TAR=0 + while [ $# -gt 0 ]; do + case "$1" in + --out) OUT_DIR="$2"; shift 2 ;; + --tar) MAKE_TAR=1; shift ;; + *) echo "Unknown arg: $1" >&2; usage 1 ;; + esac + done + SRC="${SE_DIR}/${SKILL}" + [ -d "$SRC" ] || { echo "Skill not found: $SRC"; exit 1; } + FM="$(parse_frontmatter "$SRC")" + if ! python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d['ok'] else 1)" <<<"$FM"; then + echo "SKILL.md missing or no frontmatter in $SRC"; exit 1 + fi + NAME="$(python3 -c "import sys,json;print(json.loads(sys.argv[1])['name'])" "$FM")" + DESC="$(python3 -c "import sys,json;print(json.loads(sys.argv[1])['description'])" "$FM")" + # 版本:ios-engineer 取 active_version,否则 local + VERSION="local" + if [ -f "${SRC}/evolution/active_version.json" ]; then + VERSION="$(python3 -c "import json;print(json.load(open('${SRC}/evolution/active_version.json')).get('active_version','local'))" 2>/dev/null || echo local)" + fi + DEST="${OUT_DIR}/${SKILL}" + rm -rf "$DEST"; mkdir -p "$DEST" + # agentskills.io 布局:SKILL.md + references/(+ 允许的 companion 文件) + cp "${SRC}/SKILL.md" "$DEST/SKILL.md" + [ -d "${SRC}/references" ] && cp -R "${SRC}/references" "$DEST/references" + for extra in AGENT-BRIEF.md OUT-OF-SCOPE.md; do + [ -f "${SRC}/${extra}" ] && cp "${SRC}/${extra}" "$DEST/${extra}" + done + # 生成 manifest + 校验和 + python3 - "$DEST" "$NAME" "$DESC" "$VERSION" "$SKILL" <<'PY' +import os, sys, json, hashlib, datetime +dest, name, desc, version, skill = sys.argv[1:6] +files = [] +checksums = {} +for root, _, fnames in os.walk(dest): + for fn in sorted(fnames): + if fn == "bundle.json": + continue + fp = os.path.join(root, fn) + rel = os.path.relpath(fp, dest) + files.append(rel) + h = hashlib.sha256() + with open(fp, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + checksums[rel] = h.hexdigest() +manifest = { + "format": "agentskills.io", + "agentskills_compatible": True, + "name": name, + "description": desc, + "version": version, + "source_skill": skill, + "source_repo": "ai-coding-kit/skills-engineering", + "exported_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S%z"), + "files": files, + "checksums_sha256": checksums, +} +with open(os.path.join(dest, "bundle.json"), "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + f.write("\n") +print(f"Exported '{skill}' -> {dest}") +print(f" name={name} version={version} files={len(files)}") +PY + if [ "$MAKE_TAR" -eq 1 ]; then + TAR="${OUT_DIR}/${SKILL}-${VERSION}.tar.gz" + tar -czf "$TAR" -C "$OUT_DIR" "$SKILL" + echo "Bundled tarball: $TAR" + fi + ;; + + validate) + [ $# -ge 1 ] || usage 1 + SKILL="$1" + SRC="${SE_DIR}/${SKILL}" + [ -d "$SRC" ] || { echo "Skill not found: $SRC"; exit 1; } + FM="$(parse_frontmatter "$SRC")" + python3 - "$FM" "$SRC" <<'PY' +import os, sys, json, re +fm_raw, src = sys.argv[1:3] +fm = json.loads(fm_raw) +fails = 0 +def fail(m): + global fails; fails += 1; print(f" FAIL: {m}") +def ok(m): + print(f" [ok] {m}") +if not fm["ok"]: + fail("SKILL.md missing or no YAML frontmatter"); print(f"--- {os.path.basename(src)}: FAIL ---"); sys.exit(1) +name = fm["name"]; desc = fm["description"] +if not name: + fail("frontmatter.name missing/empty (agentskills.io requires 'name')") +else: + ok(f"name = {name}") + if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name): + fail(f"name '{name}' is not kebab-case (agentskills.io recommends kebab-case)") +if not desc: + fail("frontmatter.description missing/empty (agentskills.io requires 'description')") +else: + ok(f"description present ({len(desc)} chars)") +if fm["lines"] > 500: + fail(f"SKILL.md too long: {fm['lines']} lines (>500)") +else: + ok(f"SKILL.md size = {fm['lines']} lines") +# 本地引用解析 +refs_dir = os.path.join(src, "references") +link_re = re.compile(r'\[([^\]]*)\]\(([^)]+)\)') +missing = 0 +if os.path.isdir(refs_dir): + for rf in sorted(os.listdir(refs_dir)): + if not rf.endswith(".md"): continue + with open(os.path.join(refs_dir, rf), encoding="utf-8") as f: + for line in f: + for _, link in link_re.findall(line): + if re.match(r'^(https?|mailto):', link): continue + path = link.split('#',1)[0].strip() + if not path: continue + full = os.path.normpath(os.path.join(refs_dir, path)) + if full.startswith(refs_dir + os.sep) and full.endswith(".md") and not os.path.isfile(full): + missing += 1; print(f" FAIL: missing local reference in references/{rf}: {link}") +if missing == 0: + ok("local references resolve") +print(f"--- {os.path.basename(src)}: {'PASS' if fails==0 else 'FAIL ('+str(fails)+')'} (agentskills.io compatible: {fails==0}) ---") +sys.exit(1 if fails else 0) +PY + ;; + + import) + [ $# -ge 1 ] || usage 1 + BUNDLE="$1"; shift + TARGET_DIR="$SE_DIR" + FORCE=0 + NAME_OVERRIDE="" + while [ $# -gt 0 ]; do + case "$1" in + --target) TARGET_DIR="$2"; shift 2 ;; + --name) NAME_OVERRIDE="$2"; shift 2 ;; + --force) FORCE=1; shift ;; + *) echo "Unknown arg: $1" >&2; usage 1 ;; + esac + done + [ -e "$BUNDLE" ] || { echo "Bundle not found: $BUNDLE"; exit 1; } + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + if [ -f "$BUNDLE" ]; then + tar -xzf "$BUNDLE" -C "$TMP" 2>/dev/null || { echo "Failed to extract $BUNDLE (expected .tar.gz)"; exit 1; } + else + cp -R "$BUNDLE/." "$TMP/" + fi + # 定位含 SKILL.md 的目录(tar 可能多包一层) + SKILL_SRC="" + for d in "$TMP" "$TMP"/*; do + [ -f "$d/SKILL.md" ] && { SKILL_SRC="$d"; break; } + done + [ -n "$SKILL_SRC" ] || { echo "No SKILL.md found in bundle"; exit 1; } + FM="$(parse_frontmatter "$SKILL_SRC")" + BNAME="$(python3 -c "import sys,json,os;print(json.load(sys.stdin)['name'] or os.path.basename(sys.argv[1]))" "$SKILL_SRC" <<<"$FM")" + NAME="${NAME_OVERRIDE:-$BNAME}" + DEST="${TARGET_DIR}/${NAME}" + if [ -e "$DEST" ] && [ "$FORCE" -ne 1 ]; then + echo "Target already exists: $DEST (use --force to overwrite)"; exit 1 + fi + # 若 bundle 自带 bundle.json,先校验 checksums_sha256,防止导入被篡改/损坏的产物 + if [ -f "$SKILL_SRC/bundle.json" ]; then + echo "Verifying bundle integrity..." + if ! bash "${SCRIPT_DIR}/validate-skill-integrity.sh" --verify-bundle "$SKILL_SRC/bundle.json"; then + echo "Bundle checksum verification FAILED. Aborting import." >&2 + exit 1 + fi + fi + mkdir -p "$(dirname "$DEST")" + rm -rf "$DEST"; cp -R "$SKILL_SRC" "$DEST" + echo "Imported bundle -> $DEST" + # 用现有结构校验兜底 + if [ -x "${SCRIPT_DIR}/validate-skill-structure.sh" ]; then + echo "Running structure validation..." + bash "${SCRIPT_DIR}/validate-skill-structure.sh" "$NAME" || echo " [warn] structure validation reported issues — please review." + fi + ;; + + list) + for d in "$SE_DIR"/*/; do + [ -f "${d}SKILL.md" ] && echo "$(basename "${d}")" + done + ;; + + ""|-h|--help) usage 0 ;; + *) echo "Unknown command: $cmd" >&2; usage 1 ;; +esac diff --git a/skills-engineering/scripts/sync-skills.sh b/skills-engineering/scripts/sync-skills.sh index b7cb472..f8eca82 100755 --- a/skills-engineering/scripts/sync-skills.sh +++ b/skills-engineering/scripts/sync-skills.sh @@ -45,6 +45,9 @@ Environment variables: SKILL_NAME Sync only this skill (e.g. ios-engineer) SKILL_NAMES Colon-separated list (e.g. ios-engineer:cognitive-expansion) SOURCE_DIR Override source when SKILL_NAME is set (default: /) + PARALLEL Set to 1 to sync (skill × target) pairs concurrently in the + background (子代理式并行); capped by MAX_PARALLEL (default 4). + MAX_PARALLEL Max concurrent sync jobs when PARALLEL=1 (default 4). CODEX_DEST_BASE Default: ~/.codex/skills CLAUDE_DEST_BASE Default: ~/.claude/skills CURSOR_DEST_BASE Default: ~/.cursor/skills @@ -188,6 +191,10 @@ sync_one_skill_to_target() { sync_all_skills() { local skill source_dir base + local jobs=0 + local pids=() + local sync_failed=0 + local max_parallel="${MAX_PARALLEL:-4}" for skill in "${SKILL_LIST[@]}"; do if [[ -n "${SKILL_NAME:-}" && -n "${SOURCE_DIR:-}" ]]; then source_dir="${SOURCE_DIR}" @@ -199,9 +206,31 @@ sync_all_skills() { exit 1 fi for base in "${DEST_BASES[@]}"; do - sync_one_skill_to_target "${source_dir}" "${base}/${skill}" + if [[ "${PARALLEL:-0}" == "1" ]]; then + # 子代理式并行:每个 (skill, target) 同步在后台运行,受 max_parallel 限制 + sync_one_skill_to_target "${source_dir}" "${base}/${skill}" & + pids+=($!) + jobs=$((jobs + 1)) + if [[ $jobs -ge $max_parallel ]]; then + for pid in "${pids[@]}"; do + wait "$pid" || sync_failed=1 + done + pids=() + jobs=0 + fi + else + sync_one_skill_to_target "${source_dir}" "${base}/${skill}" || sync_failed=1 + fi done done + # 等待所有剩余后台同步完成,并逐个检查退出码(无参 wait 会吞掉失败) + for pid in "${pids[@]}"; do + wait "$pid" || sync_failed=1 + done + if [[ $sync_failed -ne 0 ]]; then + echo "Error: one or more skill sync jobs failed." >&2 + exit 1 + fi } resolve_skill_list diff --git a/skills-engineering/scripts/sync-user-profile.sh b/skills-engineering/scripts/sync-user-profile.sh new file mode 100755 index 0000000..0970629 --- /dev/null +++ b/skills-engineering/scripts/sync-user-profile.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# ============================================================================= +# sync-user-profile.sh — 跨会话用户画像注入 +# +# 对齐 Hermes Agent 的 USER.md + 辩证式用户建模:把一份用户画像同步到各端 +# Agent preamble,让所有 AI 工具共享同一份偏好与约束。 +# +# 机制: +# 1. 用户从仓库根 USER.md.example 复制出 USER.md(gitignored,不提交)并填写 +# 2. 本脚本把 USER.md 复制到 ~/.ai-coding-kit/USER.md(跨端共享位置) +# 3. 在各端 preamble 文件中 upsert 一个独立的 +# `` 托管块, +# 指示 Agent 读取该画像并按其调整输出 +# 4. 若 USER.md 不存在,则移除所有已注入的托管块(清理) +# +# 该托管块与 sync-agent-preamble.sh 的 ios-engineer 块标记不同,互不干扰。 +# +# 用法: +# bash scripts/sync-user-profile.sh # 同步 / 清理 +# bash scripts/sync-user-profile.sh --dry-run # 仅预览 +# bash scripts/sync-user-profile.sh --remove # 强制移除托管块 +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +KIT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +USER_SRC="${KIT_ROOT}/USER.md" +PROFILE_DEST="${HOME}/.ai-coding-kit/USER.md" +mkdir -p "$(dirname "$PROFILE_DEST")" + +DRY_RUN=0 +REMOVE=0 +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --remove) REMOVE=1 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac + shift +done + +BLOCK_BEGIN='' +BLOCK_END='' + +# 目标文件(与各端 preamble 一致;不存在则跳过并提示) +TARGETS=( + "${HOME}/.claude/CLAUDE.md" + "${HOME}/.codex/AGENTS.md" + "${HOME}/Library/Developer/Xcode/CodingAssistant/codex/AGENTS.md" + "${HOME}/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/CLAUDE.md" + "${HOME}/.gemini/GEMINI.md" +) + +# 用 python3 在文件中 upsert / 移除托管块 +upsert_block() { + # $1 = file, $2 = block content (with begin/end markers) + local file="$1" content="$2" + [ -f "$file" ] || { echo " skip (not found): $file"; return 0; } + if [ "$DRY_RUN" -eq 1 ]; then + echo " [dry-run] upsert block in: $file" + return 0 + fi + python3 - "$file" "$BLOCK_BEGIN" "$BLOCK_END" "$content" <<'PY' +import sys, re +path, begin, end, content = sys.argv[1:5] +with open(path, encoding="utf-8") as f: + text = f.read() +pat = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S) +if pat.search(text): + text = pat.sub(content + "\n", text) +else: + text = text.rstrip("\n") + "\n\n" + content + "\n" +with open(path, "w", encoding="utf-8") as f: + f.write(text) +PY + echo " upserted: $file" +} + +remove_block() { + local file="$1" + [ -f "$file" ] || return 0 + if [ "$DRY_RUN" -eq 1 ]; then + echo " [dry-run] remove block from: $file" + return 0 + fi + python3 - "$file" "$BLOCK_BEGIN" "$BLOCK_END" <<'PY' +import sys, re +path, begin, end = sys.argv[1:4] +with open(path, encoding="utf-8") as f: + text = f.read() +pat = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S) +if pat.search(text): + with open(path, "w", encoding="utf-8") as f: + f.write(pat.sub("", text)) +PY + echo " removed: $file" +} + +build_block() { + cat < USER.md and fill it in to enable the profile." + echo "Cleaning any stale managed blocks..." + fi + for t in "${TARGETS[@]}"; do remove_block "$t"; done + [ -f "$PROFILE_DEST" ] && rm -f "$PROFILE_DEST" && echo "Removed $PROFILE_DEST" + echo "Done." + exit 0 +fi + +# 有 USER.md:复制并注入 +if [ "$DRY_RUN" -ne 1 ]; then + cp "$USER_SRC" "$PROFILE_DEST" + echo "Synced profile -> $PROFILE_DEST" +else + echo "[dry-run] would sync $USER_SRC -> $PROFILE_DEST" +fi +BLOCK="$(build_block)" +echo "Injecting user-profile managed blocks..." +for t in "${TARGETS[@]}"; do upsert_block "$t" "$BLOCK"; done +echo "Done. Agents will now read your profile from ~/.ai-coding-kit/USER.md." diff --git a/skills-engineering/scripts/validate-skill-integrity.sh b/skills-engineering/scripts/validate-skill-integrity.sh new file mode 100755 index 0000000..7b10812 --- /dev/null +++ b/skills-engineering/scripts/validate-skill-integrity.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# ============================================================================= +# validate-skill-integrity.sh — 技能分发前完整性校验(checksum) +# +# 对齐 Hermes Agent 的 bundle 校验 / checksum 思路:在同步/分发前对技能文件 +# 计算 sha256,与上次基线比对,发现非预期改动(ADDED / MODIFIED / REMOVED)。 +# +# 这层校验独立于结构校验(validate-skill-structure.sh),关注的是「内容是否被 +# 篡改 / 意外改动」,可作为 pre-push 或 CI 的额外闸门。 +# +# 用法: +# bash scripts/validate-skill-integrity.sh # 全部技能:比对并更新基线 +# bash scripts/validate-skill-integrity.sh # 单技能 +# bash scripts/validate-skill-integrity.sh --check-only # 只比对,不更新基线 +# bash scripts/validate-skill-integrity.sh --verify-bundle +# # 校验 skill_bundles 产物的 checksum +# +# 基线存储:skills-engineering/.integrity/.sha256(gitignored) +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +INTEGRITY_DIR="${SE_DIR}/.integrity" +mkdir -p "$INTEGRITY_DIR" + +CHECK_ONLY=0 +VERIFY_BUNDLE="" +SKILL_ARG="" + +while [ $# -gt 0 ]; do + case "$1" in + --check-only) CHECK_ONLY=1; shift ;; + --verify-bundle) VERIFY_BUNDLE="$2"; shift 2 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) SKILL_ARG="$1"; shift ;; + esac +done + +if [ -n "$VERIFY_BUNDLE" ]; then + [ -f "$VERIFY_BUNDLE" ] || { echo "Bundle manifest not found: $VERIFY_BUNDLE" >&2; exit 1; } + python3 - "$VERIFY_BUNDLE" <<'PY' +import sys, os, json, hashlib +manifest = sys.argv[1] +base = os.path.dirname(manifest) +data = json.load(open(manifest, encoding="utf-8")) +checksums = data.get("checksums_sha256", {}) +fails = 0 +for rel, expected in checksums.items(): + fp = os.path.join(base, rel) + if not os.path.isfile(fp): + print(f" FAIL: missing file in bundle: {rel}"); fails += 1; continue + h = hashlib.sha256() + with open(fp, "rb") as f: + for c in iter(lambda: f.read(8192), b""): + h.update(c) + if h.hexdigest() != expected: + print(f" FAIL: checksum mismatch: {rel}"); fails += 1 + else: + print(f" [ok] {rel}") +print(f"--- bundle {data.get('name','?')}: {'PASS' if fails==0 else 'FAIL ('+str(fails)+')'} ---") +sys.exit(1 if fails else 0) +PY + exit $? +fi + +collect_hashes() { + # $1 = skill dir; prints "relpath sha256" lines (sorted) + python3 - "$1" <<'PY' +import os, sys, hashlib +skill_dir = sys.argv[1] +files = [] +for root, _, fnames in os.walk(skill_dir): + for fn in fnames: + if any(fn.endswith(ext) for ext in (".md", ".json", ".yaml", ".yml")): + files.append(os.path.join(root, fn)) +out = [] +for fp in files: + h = hashlib.sha256() + with open(fp, "rb") as f: + for c in iter(lambda: f.read(8192), b""): + h.update(c) + rel = os.path.relpath(fp, skill_dir) + out.append(f"{rel} {h.hexdigest()}") +for line in sorted(out): + print(line) +PY +} + +SKILLS=() +if [[ -n "$SKILL_ARG" ]]; then + SKILLS=("$SE_DIR/$SKILL_ARG") +else + for d in "$SE_DIR"/*/; do + [[ -f "$d/SKILL.md" ]] && SKILLS+=("$d") + done +fi + +TOTAL_FAIL=0 +for skill_dir in "${SKILLS[@]}"; do + name="$(basename "${skill_dir%/}")" + baseline="${INTEGRITY_DIR}/${name}.sha256" + cur="$(collect_hashes "$skill_dir")" + + if [[ ! -f "$baseline" ]]; then + echo "=== $name ===" + echo " [baseline] created ($(echo "$cur" | grep -c .) files hashed)" + if [[ "$CHECK_ONLY" -ne 1 ]]; then + echo "$cur" > "$baseline" + fi + continue + fi + + base="$(cat "$baseline")" + # 比对 + diff_out="$(diff <(echo "$base") <(echo "$cur") || true)" + if [[ -z "$diff_out" ]]; then + echo "=== $name ===" + echo " [ok] integrity unchanged ($(echo "$cur" | grep -c .) files)" + else + echo "=== $name ===" + while IFS= read -r line; do + if [[ "$line" == "< "* ]]; then + rel="${line#< }"; rel="${rel%% *}" + echo " REMOVED: $rel" + elif [[ "$line" == "> "* ]]; then + rel="${line#> }"; rel="${rel%% *}" + # 判断是新增还是修改:看对面是否同 rel 不同 hash + if grep -q "^${rel} " <<<"$base"; then + echo " MODIFIED: $rel" + else + echo " ADDED: $rel" + fi + fi + done <<<"$diff_out" + TOTAL_FAIL=$((TOTAL_FAIL + 1)) + if [[ "$CHECK_ONLY" -ne 1 ]]; then + echo "$cur" > "$baseline" + echo " [baseline] updated" + fi + fi +done + +echo "=========================================" +if [[ $TOTAL_FAIL -eq 0 ]]; then + echo "All checked skills: integrity OK." + exit 0 +else + echo "Integrity drift detected in $TOTAL_FAIL skill(s)." + exit 1 +fi diff --git a/sync/README.md b/sync/README.md index b064900..4b0306d 100644 --- a/sync/README.md +++ b/sync/README.md @@ -150,6 +150,18 @@ python3 sync/sync_config.py --target all # sync all (Python direct) python3 sync/sync_config.py --target codex # single platform ``` +## 可选 MCP 服务器 + +开箱即用的服务器在 `env/mcp/`。**非默认、社区/高级**服务器放在 `env/optional-mcps/`,用 `sync/optional_mcps.sh` 按需启用: + +```bash +bash sync/optional_mcps.sh list # 查看可选服务器与启用状态 +bash sync/optional_mcps.sh enable puppeteer # 启用 -> 下次 sync.sh 生效 +bash sync/optional_mcps.sh disable puppeteer # 停用 +``` + +`disable` 带护栏:只移除由本工具启用的服务器,绝不删除仓库默认的 `env/mcp/*.json`。详见 `env/optional-mcps/README.md`。 + ## Design Principles 1. **One file to configure**: user only edits `env/secrets.json` — each platform has its own `{url, key/token}` object diff --git a/sync/list_models.sh b/sync/list_models.sh new file mode 100755 index 0000000..19e1102 --- /dev/null +++ b/sync/list_models.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ============================================================================= +# list_models.sh — 跨平台模型 / provider 配置总览 +# +# 对齐 Hermes Agent 的 model_metadata.py(provider 无关)思路:把分散在 +# env/platforms/*.json 里的模型 / provider 配置抽出来统一查看,便于新增 +# 模型或厂商时一眼看清「哪里配了什么」。 +# +# 它不修改任何配置,只读 env/platforms/*.json,抽取含 model / provider / +# base_url / api_key / model_providers 等关键词的字段并打印成表。 +# +# 用法: +# bash sync/list_models.sh +# bash sync/list_models.sh --json +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +PLATFORMS_DIR="${REPO_ROOT}/env/platforms" + +EMIT_JSON=0 +[ "${1:-}" = "--json" ] && EMIT_JSON=1 + +if [ ! -d "$PLATFORMS_DIR" ]; then + echo "No platforms dir: $PLATFORMS_DIR" >&2 + exit 1 +fi + +python3 - "$PLATFORMS_DIR" "$EMIT_JSON" <<'PY' +import os, sys, json + +plat_dir, emit_json = sys.argv[1], sys.argv[2] == "1" +# 关注的键(大小写不敏感包含匹配) +KEYWORDS = ("model", "provider", "base_url", "api_key", "apikey", "endpoint") + +def walk(obj, prefix=""): + """Yield (path, value) for leaf nodes whose key matches KEYWORDS.""" + out = [] + if isinstance(obj, dict): + for k, v in obj.items(): + if any(kw in k.lower() for kw in KEYWORDS): + if isinstance(v, (str, int, float, bool)): + out.append((prefix + k, v)) + elif isinstance(v, dict) and v: + # 折叠一层,避免刷屏 + out.append((prefix + k, "{...}")) + out.extend(walk(v, prefix + k + ".")) + elif isinstance(obj, list): + for i, v in enumerate(obj): + out.extend(walk(v, f"{prefix}[{i}].")) + return out + +rows = [] +for fn in sorted(os.listdir(plat_dir)): + if not fn.endswith(".json"): + continue + path = os.path.join(plat_dir, fn) + try: + data = json.load(open(path, encoding="utf-8")) + except Exception as e: + rows.append((fn, "(parse error)", str(e))) + continue + found = walk(data) + if not found: + rows.append((fn, "(no model/provider fields)", "")) + else: + for k, v in found: + # 脱敏:值含 key/token 时打码 + sval = str(v) + if any(t in k.lower() for t in ("key", "token", "secret")) and len(sval) > 6: + sval = sval[:3] + "***" + sval[-2:] + rows.append((fn, k, sval)) + +if emit_json: + print(json.dumps( + [{"platform": r[0], "field": r[1], "value": r[2] if len(r) > 2 else ""} for r in rows], + indent=2, ensure_ascii=False)) +else: + print("=== 跨平台模型 / provider 配置总览 ===\n") + print(f"{'platform':<22} {'field':<28} value") + print("-" * 80) + for r in rows: + plat, field = r[0], r[1] + val = r[2] if len(r) > 2 else "" + print(f"{plat:<22} {field:<28} {val}") + print("\n提示:新增模型/厂商时,复制 env/templates/platform.template.json," + "在各平台 JSON 中按官方 spec 填 model/provider 字段即可。") +PY diff --git a/sync/model_routing.md b/sync/model_routing.md new file mode 100644 index 0000000..851c013 --- /dev/null +++ b/sync/model_routing.md @@ -0,0 +1,41 @@ +# 多平台模型路由抽象(设计说明) + +对齐 Hermes Agent 的 `model_metadata.py`:把各平台散落的模型 / provider 配置抽象为**统一 provider 层**,降低新增模型或厂商的成本。 + +## 现状 + +`env/platforms/*.json` 每个文件遵循该平台的官方 spec,模型相关字段命名不统一: + +- Codex:`model_providers..base_url` + `env.` +- Claude:`env` / `hooks` +- Gemini:`env` 变量 +- 其他平台各异 + +新增一个模型往往要在多个平台 JSON 里分别改字段,容易遗漏或写错。 + +## 目标抽象(建议形态) + +``` +统一 Provider 层(env/model_providers.json,规划中) +┌──────────────────────────────────────────┐ +│ provider: nous-portal / openrouter / ... │ +│ models: [ {id, context, cost}, ... ] │ +│ auth: ${provider.key} ← 统一密钥 │ +└──────────────────────────────────────────┘ + │ 渲染 + ▼ +env/platforms/*.json (各平台官方格式,自动生成) +``` + +要点: + +1. **统一密钥(tool gateway 式)**:所有 provider key 集中到 `env/secrets.json` 的 `providers..key`,同步时按平台 spec 注入,避免每个平台各写一份。 +2. **模型目录即真理源**:`model_providers.json` 描述「有哪些模型、上下文、成本、默认路由」,平台 JSON 只描述「怎么接」。 +3. **新增模型 = 改一处**:加一个 model 条目,所有平台同步受益。 + +## 当前可用工具 + +- `sync/list_models.sh`:只读抽取各平台 JSON 中的 model / provider / base_url / api_key 字段,统一成表,便于核对「哪里配了什么」。 +- `env/templates/platform.template.json`:新增平台时的起点。 + +> 本文件为设计说明;统一 Provider 层的实际渲染器尚未实现,当前仍由各平台 JSON 直接描述。引入前需评估与现有「零字段映射」原则的兼容性。 diff --git a/sync/optional_mcps.sh b/sync/optional_mcps.sh new file mode 100755 index 0000000..6471ae9 --- /dev/null +++ b/sync/optional_mcps.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# ============================================================================= +# optional_mcps.sh — 可选 MCP 服务器的启用 / 禁用 / 列出 +# +# 对齐 Hermes Agent 的 optional-mcps/:把非默认、社区/高级 MCP 服务器与开箱 +# 即用的 env/mcp/ 分开,避免污染默认配置。 +# +# enable 把 env/optional-mcps/.json 复制到 env/mcp/.json +# 下一次 sync.sh 会自动发现并同步 +# disable 从 env/mcp/ 移除并停止同步 +# list 列出所有可选服务器及启用状态 +# sync 重新复制所有已启用服务器(刷新) +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +OPT_DIR="${REPO_ROOT}/env/optional-mcps" +MCP_DIR="${REPO_ROOT}/env/mcp" +REGISTRY="${OPT_DIR}/enabled.json" + +mkdir -p "$MCP_DIR" +[ -f "$REGISTRY" ] || echo '{}' > "$REGISTRY" + +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +read_registry() { + python3 -c "import json,sys; print(json.dumps(json.load(open(sys.argv[1]))))" "$REGISTRY" +} + +# 注:registry 写入统一走下方 enable/disable 的内联「tmp 写 → 原子 mv」模式, +# 不存在独立的 write_registry 入口(已移除死代码)。 +cmd="${1:-}"; shift || true +case "$cmd" in + list) + echo "=== optional MCP servers ===" + enabled_names="$(python3 -c "import json; print(' '.join(json.load(open('$REGISTRY')).keys()))")" + found=0 + for f in "${OPT_DIR}"/*.json; do + [ -e "$f" ] || break + name="$(basename "$f" .json)" + [ "$name" = "enabled" ] && continue + found=1 + case " $enabled_names " in + *" $name "*) printf " [on ] %s\n" "$name" ;; + *) printf " [off] %s\n" "$name" ;; + esac + done + if [ "$found" -eq 0 ]; then echo "(none)"; fi + ;; + enable) + [ $# -ge 1 ] || usage 1 + name="$1" + src="${OPT_DIR}/${name}.json" + dst="${MCP_DIR}/${name}.json" + [ -f "$src" ] || { echo "Optional MCP not found: $src"; exit 1; } + if [ -f "$dst" ]; then + echo "Already enabled (or present in env/mcp): $name" + else + cp "$src" "$dst" + echo "Enabled $name -> $dst (will sync on next 'bash sync.sh')" + fi + reg="$(read_registry)" + echo "$reg" | python3 -c "import json,sys; d=json.load(sys.stdin); d[sys.argv[1]]={'enabled_at':'$(date -u +%Y-%m-%dT%H:%M:%SZ)'}; print(json.dumps(d))" "$name" > "$REGISTRY.tmp" + mv "$REGISTRY.tmp" "$REGISTRY" + ;; + disable) + [ $# -ge 1 ] || usage 1 + name="$1" + dst="${MCP_DIR}/${name}.json" + # 安全护栏:只移除「由本工具启用」的服务器,绝不删除仓库默认 env/mcp/*.json + if ! python3 -c "import json,sys; sys.exit(0 if sys.argv[1] in json.load(open('$REGISTRY')) else 1)" "$name"; then + echo "$name is not managed by optional_mcps (not in enabled registry)." + echo "If it is a default server in env/mcp/, edit/remove it directly — refusing to delete tracked defaults." + exit 1 + fi + if [ -f "$dst" ]; then + rm -f "$dst" + echo "Disabled $name (removed $dst)" + else + echo "$name was recorded but its file is already gone; cleaning registry only." + fi + reg="$(read_registry)" + echo "$reg" | python3 -c "import json,sys; d=json.load(sys.stdin); d.pop(sys.argv[1], None); print(json.dumps(d))" "$name" > "$REGISTRY.tmp" + mv "$REGISTRY.tmp" "$REGISTRY" + ;; + sync) + reg="$(read_registry)" + echo "$reg" | python3 -c " +import json, sys, os, shutil +d = json.load(sys.stdin) +opt='${OPT_DIR}'; mcp='${MCP_DIR}' +for name in d: + src = os.path.join(opt, name + '.json') + dst = os.path.join(mcp, name + '.json') + if os.path.isfile(src): + shutil.copy(src, dst) + print('refreshed', name) + else: + print('missing source for', name, '- skipped') +" + ;; + ""|-h|--help) usage 0 ;; + *) echo "Unknown command: $cmd" >&2; usage 1 ;; +esac From b31765a72352a909616113e7f84022d5d817a6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 11:34:07 +0800 Subject: [PATCH 12/65] =?UTF-8?q?fix(sync):=20=E7=A7=BB=E9=99=A4=E9=9D=9E?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=9A=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8A=91=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills-engineering/scripts/sync-skills.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skills-engineering/scripts/sync-skills.sh b/skills-engineering/scripts/sync-skills.sh index f8eca82..7425b84 100755 --- a/skills-engineering/scripts/sync-skills.sh +++ b/skills-engineering/scripts/sync-skills.sh @@ -219,7 +219,8 @@ sync_all_skills() { jobs=0 fi else - sync_one_skill_to_target "${source_dir}" "${base}/${skill}" || sync_failed=1 + # 非并行模式:保留 set -e 的「首次失败即中止」语义 + sync_one_skill_to_target "${source_dir}" "${base}/${skill}" fi done done From be2264348920f8c06c124412db5dd176f27b283f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 14:28:55 +0800 Subject: [PATCH 13/65] feat(validate): enhance link validation in markdown files and improve skill bundle integrity checks - Updated `validate.sh` to skip links pointing outside the skill root directory, ensuring only valid internal links are checked. - Enhanced `skill_bundles.sh` to copy necessary directories and files for skill distribution, including a whitelist for allowed directories. - Improved `validate-skill-integrity.sh` to assert the existence of `SKILL.md` in single skill mode, preventing silent passes on integrity checks. - Added validation for optional MCP definitions in `optional_mcps.sh` to ensure only valid configurations are enabled and synced. --- .../ios-engineer/scripts/validate.sh | 10 ++ skills-engineering/scripts/skill_bundles.sh | 71 +++++++--- .../scripts/validate-skill-integrity.sh | 14 +- sync/optional_mcps.sh | 124 ++++++++++++++++-- sync/validate_env_schema.py | 33 +++-- 5 files changed, 217 insertions(+), 35 deletions(-) diff --git a/skills-engineering/ios-engineer/scripts/validate.sh b/skills-engineering/ios-engineer/scripts/validate.sh index fe326d6..093b97e 100755 --- a/skills-engineering/ios-engineer/scripts/validate.sh +++ b/skills-engineering/ios-engineer/scripts/validate.sh @@ -95,6 +95,7 @@ if $SCENARIOS; then run_step "S1" "Validate scenario specs" bash scripts/validate_scenario_specs.sh run_step "S2" "Validate internal markdown links" bash -c \ 'ruby <<'"'"'RUBY'"'"' +root = File.expand_path(".") broken = 0 Dir.glob("references/*.md").sort.each do |file| File.foreach(file).with_index(1) do |line, lineno| @@ -103,6 +104,10 @@ Dir.glob("references/*.md").sort.each do |file| path = link.split("#", 2).first.to_s next if path.empty? full = File.expand_path(path, File.dirname(file)) + # 跳过指向 skill 根目录之外的链接(如 ../../sibling-skill/、../../../env/)。 + # 这些只在完整仓库布局中有效,独立 bundle 无法包含对应文件; + # 仅校验 bundle 内承诺存在的链接,使导入后的技能可自校验通过。 + next unless full == root || full.start_with?(root + File::SEPARATOR) unless File.exist?(full) puts "Broken link in #{file}:#{lineno} -> #{link} (resolved: #{full})" broken += 1 @@ -119,6 +124,7 @@ if $LINKS; then echo "=== Link Validation ===" run_step "L1" "Validate internal markdown links" bash -c \ 'ruby <<'"'"'RUBY'"'"' +root = File.expand_path(".") broken = 0 Dir.glob("references/*.md").sort.each do |file| File.foreach(file).with_index(1) do |line, lineno| @@ -127,6 +133,10 @@ Dir.glob("references/*.md").sort.each do |file| path = link.split("#", 2).first.to_s next if path.empty? full = File.expand_path(path, File.dirname(file)) + # 跳过指向 skill 根目录之外的链接(如 ../../sibling-skill/、../../../env/)。 + # 这些只在完整仓库布局中有效,独立 bundle 无法包含对应文件; + # 仅校验 bundle 内承诺存在的链接,使导入后的技能可自校验通过。 + next unless full == root || full.start_with?(root + File::SEPARATOR) unless File.exist?(full) puts "Broken link in #{file}:#{lineno} -> #{link} (resolved: #{full})" broken += 1 diff --git a/skills-engineering/scripts/skill_bundles.sh b/skills-engineering/scripts/skill_bundles.sh index 7aceeee..2463240 100755 --- a/skills-engineering/scripts/skill_bundles.sh +++ b/skills-engineering/scripts/skill_bundles.sh @@ -103,9 +103,32 @@ case "$cmd" in fi DEST="${OUT_DIR}/${SKILL}" rm -rf "$DEST"; mkdir -p "$DEST" - # agentskills.io 布局:SKILL.md + references/(+ 允许的 companion 文件) + # agentskills.io 布局:SKILL.md + 随技能分发的支持目录(references/ scripts/ + # templates/ examples/ assets/)+ 允许的 companion 文件。scripts/ 等目录被 + # references/*.md 以 ../scripts/... 形式引用,必须随包分发,否则导出的技能不完整。 cp "${SRC}/SKILL.md" "$DEST/SKILL.md" - [ -d "${SRC}/references" ] && cp -R "${SRC}/references" "$DEST/references" + # 随技能分发的支持目录(被 references/ 与 scripts/ 引用,必须随包分发)。 + # 注意:这是「允许随包分发」的白名单——新增需要在 bundle 中可用的目录时, + # 需在此处登记;若某 skill 不含该目录则自动跳过(不影响通用性)。 + for subdir in references scripts templates examples assets agents; do + [ -d "${SRC}/${subdir}" ] && cp -R "${SRC}/${subdir}" "$DEST/${subdir}" + done + # 自校验(scripts/validate.sh --quick / --scenarios)依赖的演进工具链最小必要子集。 + # 不复制整个 evolution/(含 490+ history 文件,会使 bundle 臃肿且含仓库内部演进历史), + # 仅复制运行校验所需的:active_version.json、scenarios/,并保留空的演进工作目录结构。 + if [ -d "${SRC}/evolution" ]; then + mkdir -p "${DEST}/evolution" + [ -f "${SRC}/evolution/active_version.json" ] && cp "${SRC}/evolution/active_version.json" "${DEST}/evolution/active_version.json" + [ -d "${SRC}/evolution/scenarios" ] && cp -R "${SRC}/evolution/scenarios" "${DEST}/evolution/scenarios" + # 演进工作目录:保留空结构,避免校验/工具因目录缺失而失败。 + # usage.jsonl 是运行时生成的用量账本,bundle 内创建空文件以满足 + # references 中的 ../evolution/usage/usage.jsonl 链接(避免自校验断链)。 + for wd in proposals validations approvals; do + mkdir -p "${DEST}/evolution/${wd}" + done + mkdir -p "${DEST}/evolution/usage" + : > "${DEST}/evolution/usage/usage.jsonl" + fi for extra in AGENT-BRIEF.md OUT-OF-SCOPE.md; do [ -f "${SRC}/${extra}" ] && cp "${SRC}/${extra}" "$DEST/${extra}" done @@ -184,22 +207,40 @@ if fm["lines"] > 500: fail(f"SKILL.md too long: {fm['lines']} lines (>500)") else: ok(f"SKILL.md size = {fm['lines']} lines") -# 本地引用解析 -refs_dir = os.path.join(src, "references") +# 本地引用解析:扫描 SKILL.md + 所有 references/*.md,校验全部本地相对链接 +# (含 ../scripts/... 等非 .md 目标),链接相对「所在文件目录」解析,限定在 skill 根内。 link_re = re.compile(r'\[([^\]]*)\]\(([^)]+)\)') -missing = 0 +src_root = os.path.normpath(src) + +md_files = [] +skill_md = os.path.join(src, "SKILL.md") +if os.path.isfile(skill_md): + md_files.append(skill_md) +refs_dir = os.path.join(src, "references") if os.path.isdir(refs_dir): for rf in sorted(os.listdir(refs_dir)): - if not rf.endswith(".md"): continue - with open(os.path.join(refs_dir, rf), encoding="utf-8") as f: - for line in f: - for _, link in link_re.findall(line): - if re.match(r'^(https?|mailto):', link): continue - path = link.split('#',1)[0].strip() - if not path: continue - full = os.path.normpath(os.path.join(refs_dir, path)) - if full.startswith(refs_dir + os.sep) and full.endswith(".md") and not os.path.isfile(full): - missing += 1; print(f" FAIL: missing local reference in references/{rf}: {link}") + if rf.endswith(".md"): + md_files.append(os.path.join(refs_dir, rf)) + +missing = 0 +for md in md_files: + base_dir = os.path.dirname(md) + rel_label = os.path.relpath(md, src) + with open(md, encoding="utf-8") as f: + for line in f: + for _, link in link_re.findall(line): + if re.match(r'^(https?|mailto|tel):', link): continue + path = link.split('#', 1)[0].strip() + if not path: continue # 纯锚点(#section)跳过 + if path.startswith("//"): continue + full = os.path.normpath(os.path.join(base_dir, path)) + # 只校验解析后仍落在 skill 根目录内的本地链接 + if full != src_root and not full.startswith(src_root + os.sep): + continue + exists = os.path.isdir(full) if link.rstrip().endswith("/") else (os.path.isfile(full) or os.path.isdir(full)) + if not exists: + missing += 1 + fail(f"missing local reference in {rel_label}: {link}") if missing == 0: ok("local references resolve") print(f"--- {os.path.basename(src)}: {'PASS' if fails==0 else 'FAIL ('+str(fails)+')'} (agentskills.io compatible: {fails==0}) ---") diff --git a/skills-engineering/scripts/validate-skill-integrity.sh b/skills-engineering/scripts/validate-skill-integrity.sh index 7b10812..dbac818 100755 --- a/skills-engineering/scripts/validate-skill-integrity.sh +++ b/skills-engineering/scripts/validate-skill-integrity.sh @@ -89,6 +89,12 @@ PY SKILLS=() if [[ -n "$SKILL_ARG" ]]; then + # 单 skill 模式:先断言目录存在且含 SKILL.md,避免拼写错误的技能名 + # 被当成「0 文件」空集合而静默通过完整性门禁。 + if [[ ! -d "$SE_DIR/$SKILL_ARG" || ! -f "$SE_DIR/$SKILL_ARG/SKILL.md" ]]; then + echo "Skill not found or missing SKILL.md: $SKILL_ARG" >&2 + exit 1 + fi SKILLS=("$SE_DIR/$SKILL_ARG") else for d in "$SE_DIR"/*/; do @@ -104,8 +110,12 @@ for skill_dir in "${SKILLS[@]}"; do if [[ ! -f "$baseline" ]]; then echo "=== $name ===" - echo " [baseline] created ($(echo "$cur" | grep -c .) files hashed)" - if [[ "$CHECK_ONLY" -ne 1 ]]; then + if [[ "$CHECK_ONLY" -eq 1 ]]; then + # --check-only 只比对不写基线:无基线即「无可比对」,应判失败而非静默通过。 + echo " FAIL: no integrity baseline for '$name' (run without --check-only first to create it)" + TOTAL_FAIL=$((TOTAL_FAIL + 1)) + else + echo " [baseline] created ($(echo "$cur" | grep -c .) files hashed)" echo "$cur" > "$baseline" fi continue diff --git a/sync/optional_mcps.sh b/sync/optional_mcps.sh index 6471ae9..a07bbbd 100755 --- a/sync/optional_mcps.sh +++ b/sync/optional_mcps.sh @@ -31,6 +31,53 @@ read_registry() { python3 -c "import json,sys; print(json.dumps(json.load(open(sys.argv[1]))))" "$REGISTRY" } +# sha256(file) -> 裸 hash(macOS/Linux 通用) +file_sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +# 读取 registry 中某 name 的 sha256(不存在则空串) +registry_sha256() { + python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get(sys.argv[2],{}).get('sha256','') if isinstance(d.get(sys.argv[2]),dict) else '')" "$REGISTRY" "$1" +} + +# 校验 optional 源是否为合法的 MCP 定义,防止把无效定义 enable/sync 进同步链路 +# 导致平台渲染 cfg['command'] 时 KeyError 崩溃。规则与 validate_env_schema.py 对齐。 +validate_optional_source() { + python3 - "$1" <<'PY' +import json, sys +src = sys.argv[1] +try: + data = json.load(open(src, encoding="utf-8")) +except Exception as e: + print(f"invalid JSON in {src}: {e}", file=sys.stderr); sys.exit(1) +if not isinstance(data, dict): + print(f"{src}: root must be a JSON object", file=sys.stderr); sys.exit(1) +t = data.get("type") +KNOWN = {"name","type","command","args","env","url","headers","platforms","_comment"} +if t is not None and t not in ("stdio", "sse"): + print(f"{src}: invalid type '{t}' (must be stdio or sse)", file=sys.stderr); sys.exit(1) +# 渲染兜底:必须有可渲染的入口——stdio 需要 command,sse 需要 url。 +# 任意其一存在即可(type 可省略,由字段推断),但都不能缺失,否则 +# load_all_mcp() 后平台渲染器在 cfg['command']/cfg['url'] 处 KeyError 崩溃。 +has_cmd = "command" in data +has_url = "url" in data +if not has_cmd and not has_url: + print(f"{src}: MCP definition must include 'command' (stdio) or 'url' (sse) (invalid MCP definition)", file=sys.stderr); sys.exit(1) +if t == "stdio" and "command" not in data: + print(f"{src}: type=stdio requires 'command' (invalid MCP definition)", file=sys.stderr); sys.exit(1) +if t == "sse" and "url" not in data: + print(f"{src}: type=sse requires 'url' (invalid MCP definition)", file=sys.stderr); sys.exit(1) +unknown = set(data.keys()) - KNOWN +if unknown: + print(f"{src}: unknown fields: {', '.join(sorted(unknown))} (invalid MCP definition)", file=sys.stderr); sys.exit(1) +PY +} + # 注:registry 写入统一走下方 enable/disable 的内联「tmp 写 → 原子 mv」模式, # 不存在独立的 write_registry 入口(已移除死代码)。 cmd="${1:-}"; shift || true @@ -57,14 +104,30 @@ case "$cmd" in src="${OPT_DIR}/${name}.json" dst="${MCP_DIR}/${name}.json" [ -f "$src" ] || { echo "Optional MCP not found: $src"; exit 1; } + # 校验 optional 源合法性,防止把无效定义 enable 进同步链路导致平台崩溃 + if ! validate_optional_source "$src"; then + echo "Refusing to enable '$name': invalid MCP definition in $src" >&2 + exit 1 + fi + src_sum="$(file_sha256 "$src")" if [ -f "$dst" ]; then - echo "Already enabled (or present in env/mcp): $name" + dst_sum="$(file_sha256 "$dst")" + if [ "$src_sum" != "$dst_sum" ]; then + # 目标已存在且内容与 optional 源不同:极可能是仓库默认或手动编辑的文件。 + # 若此时登记,disable 会误删非本工具创建的文件(违反安全护栏)。故拒绝。 + echo "Refusing to enable '$name': ${dst} already exists and differs from the optional source." >&2 + echo "It looks like a repo default or a manually edited file — not registering, not overwriting." >&2 + echo "If you really want the optional version, remove/rename ${dst} first, then re-run enable." >&2 + exit 1 + fi + echo "Already enabled ($name content matches optional source)." else cp "$src" "$dst" echo "Enabled $name -> $dst (will sync on next 'bash sync.sh')" fi + # 记录启用时的内容 checksum,供 disable 前的归属校验使用 reg="$(read_registry)" - echo "$reg" | python3 -c "import json,sys; d=json.load(sys.stdin); d[sys.argv[1]]={'enabled_at':'$(date -u +%Y-%m-%dT%H:%M:%SZ)'}; print(json.dumps(d))" "$name" > "$REGISTRY.tmp" + echo "$reg" | python3 -c "import json,sys; d=json.load(sys.stdin); d[sys.argv[1]]={'enabled_at':'$(date -u +%Y-%m-%dT%H:%M:%SZ)','sha256':sys.argv[2]}; print(json.dumps(d))" "$name" "$src_sum" > "$REGISTRY.tmp" mv "$REGISTRY.tmp" "$REGISTRY" ;; disable) @@ -78,8 +141,27 @@ case "$cmd" in exit 1 fi if [ -f "$dst" ]; then - rm -f "$dst" - echo "Disabled $name (removed $dst)" + # 删除前二次确认归属:当前文件内容必须与「启用时记录的 checksum」一致, + # 否则说明文件已被手动改动或本就是默认文件,拒绝删除以免误删仓库默认配置。 + recorded_sum="$(registry_sha256 "$name")" + current_sum="$(file_sha256 "$dst")" + # 若本工具之外的刷新路径(如 sync.sh)已更新 dst,registry 中的 checksum 可能过期。 + # 此时只要当前文件与 optional 源完全一致,仍可安全删除(它仍是本工具复制的产物)。 + src_sum="" + [ -f "${OPT_DIR}/${name}.json" ] && src_sum="$(file_sha256 "${OPT_DIR}/${name}.json")" + if [ -n "$recorded_sum" ] && [ "$recorded_sum" != "$current_sum" ]; then + if [ -n "$src_sum" ] && [ "$src_sum" = "$current_sum" ]; then + rm -f "$dst" + echo "Disabled $name (removed $dst; current content matches optional source after sync)" + else + echo "Refusing to remove ${dst}: current content differs from what optional_mcps enabled (checksum mismatch)." >&2 + echo "It may be a repo default or was edited after enabling — leaving the file in place." >&2 + echo "Cleaning registry entry only." >&2 + fi + else + rm -f "$dst" + echo "Disabled $name (removed $dst)" + fi else echo "$name was recorded but its file is already gone; cleaning registry only." fi @@ -89,19 +171,41 @@ case "$cmd" in ;; sync) reg="$(read_registry)" + # 刷新前校验每个已启用 optional 源的合法性,跳过无效定义以免破坏同步链路 + valid_names=() + for name in $(echo "$reg" | python3 -c "import json,sys; print(' '.join(json.load(sys.stdin).keys()))"); do + src="${OPT_DIR}/${name}.json" + if [ ! -f "$src" ]; then + echo "missing source for $name - skipped" + continue + fi + if validate_optional_source "$src"; then + valid_names+=("$name") + else + echo "Skipping sync of '$name': invalid MCP definition in $src" >&2 + fi + done + # 仅刷新通过校验的源,并同步更新 registry 中的 sha256(避免后续 disable 因 + # 校验和过期而拒绝删除,导致 list 显示 off 但 sync.sh 仍同步)。 echo "$reg" | python3 -c " -import json, sys, os, shutil +import json, sys, os, shutil, hashlib d = json.load(sys.stdin) -opt='${OPT_DIR}'; mcp='${MCP_DIR}' -for name in d: +opt='${OPT_DIR}'; mcp='${MCP_DIR}'; reg='${REGISTRY}' +valid = set(sys.argv[1:]) +for name in valid: src = os.path.join(opt, name + '.json') dst = os.path.join(mcp, name + '.json') if os.path.isfile(src): shutil.copy(src, dst) + new_sum = hashlib.sha256(open(src, 'rb').read()).hexdigest() + if isinstance(d.get(name), dict): + d[name]['sha256'] = new_sum + else: + d[name] = {'sha256': new_sum} print('refreshed', name) - else: - print('missing source for', name, '- skipped') -" +json.dump(d, open(reg + '.tmp', 'w'), indent=2, ensure_ascii=False) +open(reg + '.tmp', 'a').write('\n') +" "${valid_names[@]}" && mv "${REGISTRY}.tmp" "${REGISTRY}" ;; ""|-h|--help) usage 0 ;; *) echo "Unknown command: $cmd" >&2; usage 1 ;; diff --git a/sync/validate_env_schema.py b/sync/validate_env_schema.py index c42e8e3..5504b33 100644 --- a/sync/validate_env_schema.py +++ b/sync/validate_env_schema.py @@ -17,6 +17,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent ENV_DIR = REPO_ROOT / "env" MCP_DIR = ENV_DIR / "mcp" +OPTIONAL_MCP_DIR = ENV_DIR / "optional-mcps" PLATFORMS_DIR = ENV_DIR / "platforms" # ── MCP server schema ──────────────────────────────────────────────────────── @@ -47,6 +48,14 @@ def validate_mcp_file(path: Path) -> list[str]: if srv_type is not None and srv_type not in MCP_VALID_TYPES: errors.append(f"{path.name}: invalid type '{srv_type}' (must be one of {MCP_VALID_TYPES})") + # 渲染兜底:必须有可渲染的入口——stdio 需要 command,sse 需要 url。 + # 任意其一存在即可(type 可省略,由字段推断),但都不能缺失,否则 + # load_all_mcp() 后平台渲染器在 cfg['command']/cfg['url'] 处 KeyError 崩溃。 + has_command = "command" in data + has_url = "url" in data + if not has_command and not has_url: + errors.append(f"{path.name}: MCP definition must include 'command' (stdio) or 'url' (sse)") + # Check stdio requires command if srv_type == "stdio" and "command" not in data: errors.append(f"{path.name}: type=stdio requires 'command' field") @@ -170,14 +179,22 @@ def main() -> None: all_errors: list[str] = [] - # Validate MCP files - if not args.platforms_only and MCP_DIR.is_dir(): - mcp_files = sorted(MCP_DIR.glob("*.json")) - if not mcp_files: - all_errors.append("env/mcp/: no JSON files found") - for f in mcp_files: - all_errors.extend(validate_mcp_file(f)) - print(f"Checked {len(mcp_files)} MCP file(s).") + # Validate MCP files (env/mcp + env/optional-mcps) + if not args.platforms_only: + mcp_dirs = [MCP_DIR] + if OPTIONAL_MCP_DIR.is_dir(): + mcp_dirs.append(OPTIONAL_MCP_DIR) + total_mcp = 0 + for d in mcp_dirs: + if not d.is_dir(): + all_errors.append(f"{d.relative_to(REPO_ROOT)}/: no JSON files found") + continue + for f in sorted(d.glob("*.json")): + if d is OPTIONAL_MCP_DIR and f.name == "enabled.json": + continue # registry 文件,不是 MCP 定义 + all_errors.extend(validate_mcp_file(f)) + total_mcp += 1 + print(f"Checked {total_mcp} MCP file(s) (incl. optional-mcps).") # Validate platform files if not args.mcp_only and PLATFORMS_DIR.is_dir(): From 8b5e90ca9c6e98532edfa0ca9a02c9d742a8db64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 14:43:42 +0800 Subject: [PATCH 14/65] fix(suggest_skill_proposals): clarify comment on ledger behavior in skill proposal script - Updated comment to specify that the active version of the ledger remains unchanged while only draft proposals are created and registry deduplication occurs. --- .../ios-engineer/scripts/suggest_skill_proposals.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh b/skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh index 465925a..f8f771b 100755 --- a/skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh +++ b/skills-engineering/ios-engineer/scripts/suggest_skill_proposals.sh @@ -11,7 +11,7 @@ # 设计原则: # - 只产出 draft,绝不自动 approve / promote(受控演进不被绕过) # - 幂等:同一信号重复运行不会新建重复草稿 -# - 只读 ledger,不改写任何演进数据 +# - 只读 ledger,不改变 active 版本;仅落盘 draft proposal 与去重 registry # # 用法: # bash scripts/suggest_skill_proposals.sh # 生成草稿并打印建议 From e271f5657117ea0b911d83b00ab23ecc00379b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 14:50:04 +0800 Subject: [PATCH 15/65] fix(sync): ensure proper handling of background processes in sync_all_skills function - Added a check to ensure that the wait command for background process IDs only executes if there are active processes, preventing unnecessary errors when no processes are running. --- skills-engineering/scripts/sync-skills.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/skills-engineering/scripts/sync-skills.sh b/skills-engineering/scripts/sync-skills.sh index 7425b84..12791b0 100755 --- a/skills-engineering/scripts/sync-skills.sh +++ b/skills-engineering/scripts/sync-skills.sh @@ -225,9 +225,11 @@ sync_all_skills() { done done # 等待所有剩余后台同步完成,并逐个检查退出码(无参 wait 会吞掉失败) - for pid in "${pids[@]}"; do - wait "$pid" || sync_failed=1 - done + if [[ ${#pids[@]} -gt 0 ]]; then + for pid in "${pids[@]}"; do + wait "$pid" || sync_failed=1 + done + fi if [[ $sync_failed -ne 0 ]]; then echo "Error: one or more skill sync jobs failed." >&2 exit 1 From 152c8293a01d08e7f97f508197c390a2ed0c14a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E6=B1=9F=E5=AD=A4=E5=BD=B1?= Date: Tue, 21 Jul 2026 15:24:02 +0800 Subject: [PATCH 16/65] feat(sync-memory): add script for cross-session event-level memory management - Introduced `sync-memory.sh` to facilitate the accumulation and retrieval of user memory across sessions. - The script allows users to inject managed blocks into various target files, ensuring persistent memory storage in `~/.ai-coding-kit/MEMORY.md`. - Added functionality for remembering and recalling memories with optional tagging, enhancing user interaction and memory management. --- skills-engineering/scripts/sync-memory.sh | 239 ++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100755 skills-engineering/scripts/sync-memory.sh diff --git a/skills-engineering/scripts/sync-memory.sh b/skills-engineering/scripts/sync-memory.sh new file mode 100755 index 0000000..5d2f142 --- /dev/null +++ b/skills-engineering/scripts/sync-memory.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# ============================================================================= +# sync-memory.sh — 跨会话事件级记忆(对齐 Hermes 持久记忆的「自动累积」层) +# +# 与 sync-user-profile.sh(用户手维护的静态画像 USER.md)互补: +# - user-profile:用户自维护的稳定偏好 / 角色 / 约束(静态) +# - user-memory :交互中累积的事件级记忆(被纠正的偏好、项目约定、决策理由) +# +# 机制: +# 1. 记忆落在 ~/.ai-coding-kit/MEMORY.md(跨端共享、跨会话持久;在仓库外,无需 gitignore) +# 2. 默认(或 `sync` 子命令)向各端 Agent preamble upsert 一个独立的 +# `` 托管块,指示 Agent +# 读取该记忆;并把本脚本自复制到 ~/.ai-coding-kit/sync-memory.sh, +# 使 Agent 在任意会话都能用稳定路径调用 remember / recall。 +# 3. `remember "..."`:追加一条带时间戳的记忆(可选 --tag 分类) +# 4. `recall [关键词]`:打印全部记忆,或按关键词过滤 +# +# 该托管块与 user-profile / ios-engineer 块标记互相独立,互不干扰。 +# +# 用法: +# bash scripts/sync-memory.sh # 注入托管块 + 自复制(幂等) +# bash scripts/sync-memory.sh sync # 同上 +# bash scripts/sync-memory.sh --dry-run # 仅预览托管块变更 +# bash scripts/sync-memory.sh --remove # 移除托管块(保留 MEMORY.md) +# bash scripts/sync-memory.sh remember "用户偏好用中文回答" [--tag 沟通] +# bash scripts/sync-memory.sh recall [关键词] +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KIT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +MEMORY_DIR="${HOME}/.ai-coding-kit" +MEMORY_FILE="${MEMORY_DIR}/MEMORY.md" +SELF_COPY="${MEMORY_DIR}/sync-memory.sh" + +DRY_RUN=0 +REMOVE=0 +ACTION="sync" +REMEMBER_TEXT="" +REMEMBER_TAG="" + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --remove) REMOVE=1 ;; + sync) ACTION="sync" ;; + remember) + ACTION="remember" + shift + # 收集 remember 后的文本,直到 --tag + _buf="" + while [ $# -gt 0 ]; do + case "$1" in + --tag) + REMEMBER_TAG="${2:-}" + shift 2 || shift $# + ;; + *) + _buf="${_buf:+$_buf }$1" + shift + ;; + esac + done + REMEMBER_TEXT="${_buf}" + break + ;; + recall) + ACTION="recall" + shift + REMEMBER_TEXT="${*:-}" # 剩余参数作为查询关键词 + break + ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac + shift +done + +BLOCK_BEGIN='' +BLOCK_END='' + +# 目标文件(与 user-profile / preamble 一致;不存在则跳过并提示) +TARGETS=( + "${HOME}/.claude/CLAUDE.md" + "${HOME}/.codex/AGENTS.md" + "${HOME}/Library/Developer/Xcode/CodingAssistant/codex/AGENTS.md" + "${HOME}/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/CLAUDE.md" + "${HOME}/.gemini/GEMINI.md" +) + +upsert_block() { + local file="$1" content="$2" + [ -f "$file" ] || { echo " skip (not found): $file"; return 0; } + if [ "$DRY_RUN" -eq 1 ]; then + echo " [dry-run] upsert block in: $file" + return 0 + fi + python3 - "$file" "$BLOCK_BEGIN" "$BLOCK_END" "$content" <<'PY' || { echo " ERROR: failed to upsert block in $file" >&2; return 1; } +import sys, re +path, begin, end, content = sys.argv[1:5] +with open(path, encoding="utf-8") as f: + text = f.read() +pat = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S) +if pat.search(text): + text = pat.sub(content + "\n", text) +else: + text = text.rstrip("\n") + "\n\n" + content + "\n" +with open(path, "w", encoding="utf-8") as f: + f.write(text) +PY + echo " upserted: $file" +} + +remove_block() { + local file="$1" + [ -f "$file" ] || return 0 + if [ "$DRY_RUN" -eq 1 ]; then + echo " [dry-run] remove block from: $file" + return 0 + fi + python3 - "$file" "$BLOCK_BEGIN" "$BLOCK_END" <<'PY' || { echo " ERROR: failed to remove block from $file" >&2; return 1; } +import sys, re +path, begin, end = sys.argv[1:4] +with open(path, encoding="utf-8") as f: + text = f.read() +pat = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S) +if pat.search(text): + with open(path, "w", encoding="utf-8") as f: + f.write(pat.sub("", text)) +PY + echo " removed: $file" +} + +build_block() { + cat <&2; return 1; } + if [ ! -f "$MEMORY_FILE" ]; then + if [ "$DRY_RUN" -eq 1 ]; then + echo " [dry-run] would create: $MEMORY_FILE" + return 0 + fi + cat > "$MEMORY_FILE" <<'EOF' || { echo " ERROR: cannot write $MEMORY_FILE" >&2; return 1; } +# Cross-session Memory (auto-accumulated) + +> Append via: ~/.ai-coding-kit/sync-memory.sh remember "..." [--tag label] +> This file is local-only and shared across all AI coding tools you use. +> Keep entries concise and factual; older entries may be consolidated by hand. + +EOF + echo "Created: $MEMORY_FILE" + fi +} + +self_copy() { + if [ "$DRY_RUN" -eq 1 ]; then + echo " [dry-run] would copy self -> $SELF_COPY" + return 0 + fi + mkdir -p "$MEMORY_DIR" || { echo " ERROR: cannot create $MEMORY_DIR" >&2; return 1; } + cp "$0" "$SELF_COPY" || { echo " ERROR: cannot copy self to $SELF_COPY" >&2; return 1; } + chmod +x "$SELF_COPY" 2>/dev/null || true + echo "Copied self -> $SELF_COPY (stable invocation path for agents)" +} + +do_sync() { + if [ "$REMOVE" -eq 1 ]; then + echo "Removing user-memory managed blocks from all targets..." + for t in "${TARGETS[@]}"; do remove_block "$t"; done + echo "Done. (MEMORY.md at $MEMORY_FILE is preserved — remove it manually if desired.)" + exit 0 + fi + ensure_memory_file + BLOCK="$(build_block)" + echo "Injecting user-memory managed blocks..." + for t in "${TARGETS[@]}"; do upsert_block "$t" "$BLOCK"; done + self_copy + echo "Done. Agents will read your memory from $MEMORY_FILE." +} + +do_remember() { + if [ -z "$REMEMBER_TEXT" ]; then + echo "remember: no text provided." >&2 + echo "Usage: sync-memory.sh remember \"\" [--tag