diff --git a/docs/README.skills.md b/docs/README.skills.md index 41ee218b8..24a009c60 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -37,6 +37,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [agent-governance](../skills/agent-governance/SKILL.md)
`gh skills install github/awesome-copilot agent-governance` | Patterns and techniques for adding governance, safety, and trust controls to AI agent systems. Use this skill when:
- Building AI agents that call external tools (APIs, databases, file systems)
- Implementing policy-based access controls for agent tool usage
- Adding semantic intent classification to detect dangerous prompts
- Creating trust scoring systems for multi-agent workflows
- Building audit trails for agent actions and decisions
- Enforcing rate limits, content filters, or tool restrictions on agents
- Working with any agent framework (PydanticAI, CrewAI, OpenAI Agents, LangChain, AutoGen) | None | | [agent-owasp-compliance](../skills/agent-owasp-compliance/SKILL.md)
`gh skills install github/awesome-copilot agent-owasp-compliance` | Check any AI agent codebase against the OWASP Agentic Security Initiative (ASI) Top 10 risks.
Use this skill when:
- Evaluating an agent system's security posture before production deployment
- Running a compliance check against OWASP ASI 2026 standards
- Mapping existing security controls to the 10 agentic risks
- Generating a compliance report for security review or audit
- Comparing agent framework security features against the standard
- Any request like "is my agent OWASP compliant?", "check ASI compliance", or "agentic security audit" | None | | [agent-skill-stack](../skills/agent-skill-stack/SKILL.md)
`gh skills install github/awesome-copilot agent-skill-stack` | Find, evaluate, and assemble the smallest compatible set of AI Agent Skills for an end-to-end natural-language goal. Use when a user wants Skills for a multi-step workflow, asks which Skills fit a project, needs an installed-Skill audit or conflict check, has low Skill recall, wants indirect helpers such as humanizers or compliance checks, or wants a project-specific Skill Stack with controlled installation. Search local Skills, registries, GitHub, and OpenCLI; compare adoption, verified fit, safety, and overlap. Do not use for locating one known or common Skill; use the generic find-skills workflow. | `agents/openai.yaml`
`references/discovery-ranking.md`
`references/local-index-and-profiles.md`
`references/security-installation.md`
`references/workflow-model.md`
`scripts/inventory_skills.py`
`scripts/project_profile.py`
`scripts/render_stack_card.py`
`scripts/skill_index.py`
`scripts/stage_install.py` | +| [agent-skills-setup](../skills/agent-skills-setup/SKILL.md)
`gh skills install github/awesome-copilot agent-skills-setup` | Use when a user wants to migrate, back up, restore, compare, or move AI-coding-agent context across Cursor, Claude Code, Codex, Cline, Copilot, Windsurf, Gemini CLI, or another supported profile, including switching computers. Handles reviewed Skills, instructions/rules, and MCP with secret redaction, plan preview, verification, and rollback. | `assets/LICENSE.clawhub`
`assets/demo.gif`
`evals/evals.json`
`evals/files/cursor-project-mcp.json`
`evals/files/instruction-cline-source.md`
`evals/files/instruction-ir-cursor.golden.mdc`
`evals/files/instruction-ir-source.mdc`
`evals/profile-contracts.json`
`evals/trigger-evals.json`
`references/doc-freshness-checks.json`
`references/ide-paths.json`
`references/ide-registry.md`
`references/ides/aider.md`
`references/ides/amazon-q.md`
`references/ides/android-studio.md`
`references/ides/antigravity.md`
`references/ides/augment-code.md`
`references/ides/baidu-comate-ide.md`
`references/ides/baidu-comate.md`
`references/ides/blackbox.md`
`references/ides/bolt-new.md`
`references/ides/claude-desktop.md`
`references/ides/claude.md`
`references/ides/cline.md`
`references/ides/codecompanion-nvim.md`
`references/ides/codeium.md`
`references/ides/codely.md`
`references/ides/codex.md`
`references/ides/cody.md`
`references/ides/continue.md`
`references/ides/copilot.md`
`references/ides/crush.md`
`references/ides/cursor.md`
`references/ides/devin.md`
`references/ides/emacs.md`
`references/ides/factory-droid.md`
`references/ides/firebase-studio.md`
`references/ides/forge.md`
`references/ides/gemini-cli.md`
`references/ides/gemini-code-assist.md`
`references/ides/gitlab-duo.md`
`references/ides/goose-cli.md`
`references/ides/gptel-mcp-el.md`
`references/ides/helix.md`
`references/ides/hermes.md`
`references/ides/ibm-bob.md`
`references/ides/jetbrains-ai.md`
`references/ides/jetbrains.md`
`references/ides/jules.md`
`references/ides/kilocode.md`
`references/ides/kimiai.md`
`references/ides/kiro.md`
`references/ides/letta-code.md`
`references/ides/letta.md`
`references/ides/lovable.md`
`references/ides/mcphub-nvim.md`
`references/ides/minimax-code.md`
`references/ides/mistral-vibe.md`
`references/ides/mmx-cli.md`
`references/ides/monkeycode.md`
`references/ides/neovim.md`
`references/ides/openclaw.md`
`references/ides/opencode.md`
`references/ides/openhands.md`
`references/ides/pearai.md`
`references/ides/pi.md`
`references/ides/pieces.md`
`references/ides/qoder-cn.md`
`references/ides/qoder.md`
`references/ides/qodo.md`
`references/ides/qwen-code.md`
`references/ides/replit.md`
`references/ides/roo-code.md`
`references/ides/rovodev.md`
`references/ides/sourcegraph-amp.md`
`references/ides/supermaven.md`
`references/ides/tabnine.md`
`references/ides/tencent-codebuddy-ide.md`
`references/ides/tencent-codebuddy.md`
`references/ides/tongyi-lingma.md`
`references/ides/trae-cn.md`
`references/ides/trae-work.md`
`references/ides/trae.md`
`references/ides/ui-only-mcp.md`
`references/ides/v0.md`
`references/ides/vecli.md`
`references/ides/visual-studio.md`
`references/ides/void-editor.md`
`references/ides/vscode.md`
`references/ides/warp-oz.md`
`references/ides/warp.md`
`references/ides/windsurf.md`
`references/ides/workbuddy.md`
`references/ides/xcode.md`
`references/ides/zcode.md`
`references/ides/zed.md`
`references/ides/zencoder.md`
`references/ides/zenflow.md`
`references/mcp-migration.md`
`references/mcp-transport.md`
`references/migration-safety.md`
`references/object-migration.md`
`references/registry-v2.json`
`references/registry-v2.schema.json`
`references/verification.md`
`scripts/README.md`
`scripts/acb/__init__.py`
`scripts/acb/bundle.py`
`scripts/check-doc-freshness.py`
`scripts/common.sh`
`scripts/context-migrator.py`
`scripts/detect/__init__.py`
`scripts/detect/probes.py`
`scripts/doc_freshness.py`
`scripts/ide-paths.tsv`
`scripts/legacy-smart-ide-migration.sh`
`scripts/migration_core.py`
`scripts/registry/__init__.py`
`scripts/registry/alias_resolver.py`
`scripts/registry/exceptions.py`
`scripts/scan-skill-secrets.py`
`scripts/skill_secret_scanner.py`
`scripts/smart-ide-migration.sh`
`scripts/sync-ide-reference-summaries.py`
`scripts/test-acb-all-installed.sh`
`scripts/test-acb-bundle-backed-plan.sh`
`scripts/test-acb-multiscope-restore.sh`
`scripts/test-acb-p0-audit-regressions.sh`
`scripts/test-acb-restore-noop.sh`
`scripts/test-acb-secret-scan-unified.sh`
`scripts/test-acb-security-boundary.sh`
`scripts/test-acb-snapshot-restore.sh`
`scripts/test-acb-true-restore.sh`
`scripts/test-alias-resolution.sh`
`scripts/test-antigravity-migration.sh`
`scripts/test-audit-e2e-matrix.sh`
`scripts/test-claude-code-mapping.sh`
`scripts/test-claude-desktop-mapping.sh`
`scripts/test-codex-migration.sh`
`scripts/test-conflict-strategies.sh`
`scripts/test-copilot-mapping.sh`
`scripts/test-cursor-mapping.sh`
`scripts/test-detection-probes.sh`
`scripts/test-doc-freshness.sh`
`scripts/test-emacs-mapping.sh`
`scripts/test-eval-coverage.sh`
`scripts/test-freshness-expanded.sh`
`scripts/test-ide-paths.sh`
`scripts/test-ide-reference-generation.sh`
`scripts/test-ir-schemas.sh`
`scripts/test-jetbrains-junie-mapping.sh`
`scripts/test-legacy-registry-gate.sh`
`scripts/test-mcp-adapters.sh`
`scripts/test-mcp-secret-redaction.sh`
`scripts/test-migrate-command.sh`
`scripts/test-migration-core.sh`
`scripts/test-migration-default-scope.sh`
`scripts/test-migration-evidence.sh`
`scripts/test-migration-handoff-branch.sh`
`scripts/test-migration.sh`
`scripts/test-modern-ide-mappings.sh`
`scripts/test-opencode-v2-mapping.sh`
`scripts/test-partial-safe-apply.sh`
`scripts/test-profile-contracts.sh`
`scripts/test-progressive-disclosure.sh`
`scripts/test-reference-composition.sh`
`scripts/test-reference-layout.sh`
`scripts/test-registry-v2.sh`
`scripts/test-remaining-ide-mappings.sh`
`scripts/test-root-mirror-links.sh`
`scripts/test-skill-metadata.sh`
`scripts/test-skill-secret-preflight.sh`
`scripts/test-smart-ide-migration.sh`
`scripts/test-stable-object-ids.sh`
`scripts/test-trae-boundary.sh`
`scripts/test-vscode-mapping.sh`
`scripts/test-windsurf-mapping.sh`
`scripts/test-zed-mapping.sh`
`scripts/validate-registry-v2.py` | | [agent-supply-chain](../skills/agent-supply-chain/SKILL.md)
`gh skills install github/awesome-copilot agent-supply-chain` | Verify supply chain integrity for AI agent plugins, tools, and dependencies. Use this skill when:
- Generating SHA-256 integrity manifests for agent plugins or tool packages
- Verifying that installed plugins match their published manifests
- Detecting tampered, modified, or untracked files in agent tool directories
- Auditing dependency pinning and version policies for agent components
- Building provenance chains for agent plugin promotion (dev → staging → production)
- Any request like "verify plugin integrity", "generate manifest", "check supply chain", or "sign this plugin" | None | | [agentic-eval](../skills/agentic-eval/SKILL.md)
`gh skills install github/awesome-copilot agentic-eval` | Patterns and techniques for evaluating and improving AI agent outputs. Use this skill when:
- Implementing self-critique and reflection loops
- Building evaluator-optimizer pipelines for quality-critical generation
- Creating test-driven code refinement workflows
- Designing rubric-based or LLM-as-judge evaluation systems
- Adding iterative improvement to agent outputs (code, reports, analysis)
- Measuring and improving agent response quality | None | | [ai-prompt-engineering-safety-review](../skills/ai-prompt-engineering-safety-review/SKILL.md)
`gh skills install github/awesome-copilot ai-prompt-engineering-safety-review` | Comprehensive AI prompt engineering safety review and improvement prompt. Analyzes prompts for safety, bias, security vulnerabilities, and effectiveness while providing detailed improvement recommendations with extensive frameworks, testing methodologies, and educational content. | None | diff --git a/skills/agent-skills-setup/agent-skills-setup/SKILL.md b/skills/agent-skills-setup/agent-skills-setup/SKILL.md new file mode 100644 index 000000000..b179a23a8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/SKILL.md @@ -0,0 +1,59 @@ +--- +name: agent-skills-setup +license: MIT +compatibility: Requires local Bash, Python 3, environment lookup, and filesystem reads. Writes only approved migration targets; no network access. +metadata: + version: "0.9.2" + permissions.shell: "bundled offline Bash/Python scripts plus local read-only detection commands" + permissions.env: "read environment variables to resolve product paths" + permissions.file_read: "named source products, workspace tree, bundled references" + permissions.file_write: "reviewed plan targets after explicit --yes consent" + permissions.network: "denied" +description: >- + Use when a user wants to migrate, back up, restore, compare, or move + AI-coding-agent context across Cursor, Claude Code, Codex, Cline, + Copilot, Windsurf, Gemini CLI, or another supported profile, including + switching computers. Handles reviewed Skills, instructions/rules, and + MCP with secret redaction, preview, verification, and rollback. +--- + +# AI IDE Context Migration + +## Permissions + +- `shell`: bundled offline scripts plus local read-only detection commands (git, version, mdfind). No downloads or binary installations. +- `env`: path resolution only; credential-looking values are redacted, never copied or printed. +- `file_read`: named source products, workspace tree, bundled `references/`; no probing of unlisted products. +- `file_write`: reviewed plan targets after `--yes` consent; state under `/.agent-context-migration/`. +- `network`: denied. Every subcommand is offline; no downloads, telemetry, or remote calls. + +## Capabilities and authorization + +- `detect`, `doctor`, `inventory`, `plan`, `snapshot`, and `bundle-verify` read only named products and workspace; network access is forbidden. +- A generic migration request authorizes planning only; separate explicit user approval (`--yes`) or explicit action verbs (apply, restore, 迁到) under `--apply-safe` authorize write. +- Save the plan, review its diff/rebuild manifest, and apply that exact file. ACB `restore` constructs a dual-side plan binding bundle sources to destination targets, supporting replayable plans (`--plan-in`) with strict TOCTOU state guards. + +## Route + +1. Resolve both product profiles through [ide-registry.md](references/ide-registry.md) / [registry-v2.json](references/registry-v2.json). +2. Read only [references/ides/.md](references/ides/) and [references/ides/.md](references/ides/). +3. Load reference by need: + - Before preview or apply: [references/migration-safety.md](references/migration-safety.md) + - MCP objects: [references/mcp-migration.md](references/mcp-migration.md) + - Other file objects: [references/object-migration.md](references/object-migration.md) + - Approved apply / proof: [references/verification.md](references/verification.md) + +## Execution & Scope + +- High-level: `bash scripts/smart-ide-migration.sh migrate --source --target --workspace . --objects all-portable --yes` +- Step-by-step: `plan --output ` -> `apply --manifest --yes` -> `verify --manifest ` -> `rollback --manifest --yes`. +- Device handoff (ACB): `snapshot` captures portable skills/instructions/MCP with atomic staging and 1:1 manifest bindings; `bundle-verify` re-checks checksums, bindings, secrets, and signatures; `restore [--plan-only | --plan-in --yes]` reviews then executes the dual-side plan. `--all-installed` bulk mode requires review and `--yes`. +- Cross-platform: `%APPDATA%` / `%USERPROFILE%` / `$APPDATA` resolution, platform detection, per-surface path isolation (remote hosts experimental). `detect` / `doctor` inspect installation state offline. +- The explicit `legacy` subcommand is read-only lookup compatibility (`--print-path`, `--dry-run`); legacy writes are disabled and enforced by the Python wrapper. +- Object-type scope (exhaustive — apply writes nothing outside it): + - Auto-migratable (`ready`): `skills`, `instructions`, `mcp`; opaque plugin package copy where both profiles declare it. + - Draft-only, never auto-written: `prompts`, `commands`, `agents`, `hooks`, `workflows`. Executable surfaces have no staging writer; replayed plans marking them eligible fail closed. + - Opt-in session transfer: `handoff` needs `--objects handoff` AND `--include-session`; only reviewed summary, git branch, relative selected files, and an explicit patch travel. Raw conversation, tokens, session state, machine paths, logs discarded. + - Never migrated: trust state, generated memory, cloud knowledge, approvals, chat history. +- Sensitive shared settings files are read only for the named migration's authorized MCP subobject; trust sections (`never-migrate`) and sibling settings never enter plans or bundles; strict secret redaction before output. See [references/mcp-migration.md](references/mcp-migration.md). +- Claude Desktop app MCP in **Settings → Extensions** and **Settings → Connectors** is UI-managed; do not infer or rewrite it from legacy JSON. diff --git a/skills/agent-skills-setup/agent-skills-setup/assets/LICENSE.clawhub b/skills/agent-skills-setup/agent-skills-setup/assets/LICENSE.clawhub new file mode 100644 index 000000000..e1580aab1 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/assets/LICENSE.clawhub @@ -0,0 +1,17 @@ +MIT No Attribution + +Copyright 2026 agent-skills-setup contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/agent-skills-setup/agent-skills-setup/assets/demo.gif b/skills/agent-skills-setup/agent-skills-setup/assets/demo.gif new file mode 100644 index 000000000..2e733ddb1 Binary files /dev/null and b/skills/agent-skills-setup/agent-skills-setup/assets/demo.gif differ diff --git a/skills/agent-skills-setup/agent-skills-setup/evals/evals.json b/skills/agent-skills-setup/agent-skills-setup/evals/evals.json new file mode 100644 index 000000000..e2a8784df --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/evals/evals.json @@ -0,0 +1,110 @@ +{ + "skill_name": "agent-skills-setup", + "evals": [ + { + "id": 1, + "prompt": "Preview moving Cursor skills and project rules to Claude Code for /tmp/acme-app. No writes or unrelated discovery. Give the exact first command.", + "expected_output": "A scoped Cursor-to-Claude dry run for the named workspace.", + "files": [], + "assertions": [ + "Starts with a read-only dry run and does not claim migration.", + "Names cursor, claude, /tmp/acme-app, and --dry-run.", + "Inspects only the named IDEs and workspace.", + "Defaults to skills/rules/prompts, excluding MCP, config, agents, hooks, and memory." + ] + }, + { + "id": 2, + "prompt": "Preview Cursor project MCP from evals/files/cursor-project-mcp.json to OpenCode for /tmp/acme-mcp. Show target shape and separate preview/apply commands. Preserve behavior; never copy literal credentials.", + "expected_output": "An explicit-source, zero-write Cursor-to-OpenCode MCP conversion plan.", + "files": ["evals/files/cursor-project-mcp.json"], + "assertions": [ + "Uses OpenCode mcp, not mcpServers.", + "Uses local type, command array, and environment rather than env.", + "Preserves local command configurations and environment mappings without guessing transport.", + "Converts exact ${env:NAME} to {env:NAME} and blanks literal credentials.", + "Uses --dry-run first and --yes only in a separate apply command.", + "Both commands select project MCP, /tmp/acme-mcp, and the supplied --source-mcp-file.", + "Explains that --source cursor selects schema while the file only selects input." + ] + }, + { + "id": 3, + "prompt": "Preview moving user Continue YAML mcpServers from ~/.continue/config.yaml into trusted Codex project config at /tmp/atlas. Do not read or write real files. Give only safe diagnostics and the next step.", + "expected_output": "A preview that rejects generic YAML-to-Codex-TOML conversion and recommends reviewed reconstruction.", + "files": [], + "assertions": [ + "Identifies Continue user YAML and /tmp/atlas/.codex/config.toml mcp_servers.", + "States automatic conversion is unsafe and fabricates no target config.", + "Uses only --dry-run or --print-path diagnostics.", + "Uses no --yes, remote installer, or completion claim.", + "Recommends manual TOML reconstruction and validation." + ] + }, + { + "id": 4, + "prompt": "In an isolated workspace, run Cursor-to-OpenCode project MCP migration using evals/files/cursor-project-mcp.json. Prove dry-run reads it without a target, then apply with --yes. Keep source bytes unchanged and save opencode.json.", + "expected_output": "Execution evidence for zero-write preview, valid converted target, redaction, and source immutability.", + "files": ["evals/files/cursor-project-mcp.json"], + "assertions": [ + "Shows dry-run exit 0, supplied fixture path, and validated source summary.", + "Proves opencode.json is absent after dry-run and appears only after apply.", + "Produces mcp.local-search with local type, command array, and environment.", + "Converts ${env:NAME}, blanks LITERAL_TOKEN, and preserves command configuration without guessing transport.", + "Uses --json and reports evidence.mcp paths, hashes, validation, and status.", + "Proves unchanged source and recorded target digest." + ] + }, + { + "id": 5, + "prompt": "Explain Cursor mcp.json and its root key only. Do not inspect files, migrate, or give changing commands.", + "expected_output": "A concise informational answer outside the migration workflow.", + "files": [], + "assertions": [ + "Treats this as explanation, not migration authority.", + "Identifies mcpServers as the root key.", + "Claims no local read or script execution.", + "Provides no --yes/apply command or unrelated discovery." + ] + }, + { + "id": 6, + "prompt": "Explain --strategy skip, backup, and overwrite for Cursor project MCP into existing OpenCode config with unrelated theme, same-name, and other servers. Do not execute.", + "expected_output": "Exact non-executing conflict semantics for a shared OpenCode JSON file.", + "files": [], + "assertions": [ + "Says skip preserves the existing target without renamed entries.", + "Says backup snapshots the complete file, merges non-conflicts, and activates same-name source.", + "Says overwrite replaces only selected MCP map and retains theme.", + "Does not claim _migrated or whole-config deletion.", + "Does not execute or claim migration." + ] + }, + { + "id": 7, + "prompt": "Preview Claude Code user MCP to VS Code with a named Profile whose directory I did not provide. Do not guess a path or write. Give the safe next step and project alternative.", + "expected_output": "A fail-closed VS Code profile plan with a documented workspace alternative.", + "files": [], + "assertions": [ + "Does not guess a user VS Code MCP path.", + "Uses MCP: Open User Configuration or asks for resolved target path.", + "Identifies .vscode/mcp.json as project alternative.", + "Uses --dry-run for any project preview.", + "Does not claim completion." + ] + }, + { + "id": 8, + "prompt": "Preview Cursor project MCP from evals/files/cursor-project-mcp.json into native OpenCode V2 for /tmp/opencode-v2. No writes or mixed V1/V2 layout. Show schema and command.", + "expected_output": "An explicit-source OpenCode V2 dry run using native mcp.servers only.", + "files": ["evals/files/cursor-project-mcp.json"], + "assertions": [ + "Command includes --opencode-version v2, --dry-run, project-mcp, workspace, and source file.", + "Uses mcp.servers without a V1 sibling map.", + "Explains enabled/disabled, timeout, and OAuth field transforms when present.", + "Preserves server behavior and redaction policy without unsupported fields.", + "Uses no --yes or completion claim." + ] + } + ] +} diff --git a/skills/agent-skills-setup/agent-skills-setup/evals/files/cursor-project-mcp.json b/skills/agent-skills-setup/agent-skills-setup/evals/files/cursor-project-mcp.json new file mode 100644 index 000000000..c38e257e5 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/evals/files/cursor-project-mcp.json @@ -0,0 +1,26 @@ +{ + "mcpServers": { + "local-search": { + "command": "npx", + "args": [ + "-y", + "@acme/search-mcp" + ], + "env": { + "ACME_API_KEY": "${env:ACME_API_KEY}", + "LITERAL_TOKEN": "__test_placeholder_value__", + "LOG_LEVEL": "info" + } + }, + "local-docs": { + "command": "node", + "args": [ + "./scripts/docs-server.js" + ], + "env": { + "DOCS_AUTH_TOKEN": "${env:ACME_BEARER_TOKEN}", + "WORKSPACE_ID": "acme" + } + } + } +} diff --git a/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-cline-source.md b/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-cline-source.md new file mode 100644 index 000000000..406bb4c00 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-cline-source.md @@ -0,0 +1,3 @@ +# TypeScript guardrails + +Validate external input before it reaches business logic. diff --git a/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-ir-cursor.golden.mdc b/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-ir-cursor.golden.mdc new file mode 100644 index 000000000..9bbe8db75 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-ir-cursor.golden.mdc @@ -0,0 +1,8 @@ +--- +description: "TypeScript guardrails" +globs: "src/**/*.ts,tests/**/*.ts" +alwaysApply: false +--- +# TypeScript guardrails + +Validate external input before it reaches business logic. diff --git a/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-ir-source.mdc b/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-ir-source.mdc new file mode 100644 index 000000000..9bbe8db75 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/evals/files/instruction-ir-source.mdc @@ -0,0 +1,8 @@ +--- +description: "TypeScript guardrails" +globs: "src/**/*.ts,tests/**/*.ts" +alwaysApply: false +--- +# TypeScript guardrails + +Validate external input before it reaches business logic. diff --git a/skills/agent-skills-setup/agent-skills-setup/evals/profile-contracts.json b/skills/agent-skills-setup/agent-skills-setup/evals/profile-contracts.json new file mode 100644 index 000000000..3084b50d5 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/evals/profile-contracts.json @@ -0,0 +1,476 @@ +{ + "fixture_classes": [ + "minimal-valid", + "complete-valid", + "legacy-version", + "invalid", + "literal-secret", + "alias-conflict" + ], + "profiles": { + "amazon-q/ide": { + "instructions": [ + "amazon-q-rule" + ], + "mcp": [ + "json:mcpServers" + ] + }, + "augment-code/cli-ide": { + "instructions": [ + "augment-rule", + "plain-markdown" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "claude/code-cli": { + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "claude/desktop-code": { + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "cline/cli": { + "instructions": [ + "cline-rule" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "cline/ide": { + "instructions": [ + "cline-rule" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "cline/sdk": { + "instructions": [ + "cline-rule" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "codex/cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "continue/cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "copilot/cli": { + "instructions": [ + "copilot-instructions" + ], + "skills": [ + "agent-skill" + ] + }, + "copilot/visual-studio": { + "instructions": [ + "copilot-instructions" + ], + "skills": [ + "agent-skill" + ] + }, + "copilot/vscode": { + "instructions": [ + "copilot-instructions" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "crush/cli": { + "skills": [ + "agent-skill" + ] + }, + "cursor/ide": { + "instructions": [ + "cursor-mdc" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "devin/terminal": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "factory-droid/cli": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "forge/cli": { + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "gemini-cli/cli": { + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "gemini-code-assist/jetbrains": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "gemini-code-assist/vscode": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "gitlab-duo/cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "gitlab-duo/flows": {}, + "gitlab-duo/jetbrains": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "gitlab-duo/vscode": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "hermes/cli": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "ibm-bob/ide": { + "instructions": [ + "agents-md", + "plain-markdown" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "ibm-bob/shell": { + "instructions": [ + "agents-md", + "plain-markdown" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "jetbrains/ai-assistant": { + "skills": [ + "agent-skill" + ] + }, + "jetbrains/junie": { + "skills": [ + "agent-skill" + ] + }, + "jules/cli": { + "instructions": [ + "agents-md" + ] + }, + "kiro/ide": { + "instructions": [ + "kiro-steering" + ], + "skills": [ + "agent-skill" + ] + }, + "letta-code/agent-file": {}, + "letta-code/desktop-cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "letta/cli": { + "skills": [ + "agent-skill" + ] + }, + "mistral-vibe/cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "opencode/cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "openhands/cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "pi/cli": { + "skills": [ + "agent-skill" + ] + }, + "qoder/cli": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "qoder/cn-cli": { + "skills": [ + "agent-skill" + ] + }, + "qoder/cn-ide": { + "instructions": [ + "qoder-rule" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "qoder/ide": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "qwen-code/cli": { + "agents": [ + "qwen-agent" + ], + "commands": [ + "qwen-command" + ], + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "mcp-plugins": [ + "qwen-mcp-plugin" + ], + "plugins": [ + "qwen-plugin" + ], + "review_context": [ + "qwen-review-context" + ], + "skills": [ + "agent-skill" + ] + }, + "rovodev/cli": { + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "sourcegraph-amp/cli": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "trae/cn-ide": { + "instructions": [ + "trae-rule" + ], + "skills": [ + "agent-skill" + ] + }, + "trae/ide": { + "instructions": [ + "trae-rule" + ], + "skills": [ + "agent-skill" + ] + }, + "warp/desktop": { + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "warp/oz-cli": { + "instructions": [ + "agents-md" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "windsurf/ide": { + "instructions": [ + "agents-md", + "plain-markdown", + "windsurf-rule" + ], + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "zed/ide": { + "instructions": [ + "agents-md" + ], + "skills": [ + "agent-skill" + ] + }, + "zencoder/ide": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + }, + "zenflow/ide": { + "mcp": [ + "json:mcpServers" + ], + "skills": [ + "agent-skill" + ] + } + }, + "schema_version": 1 +} diff --git a/skills/agent-skills-setup/agent-skills-setup/evals/trigger-evals.json b/skills/agent-skills-setup/agent-skills-setup/evals/trigger-evals.json new file mode 100644 index 000000000..666e304de --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/evals/trigger-evals.json @@ -0,0 +1,22 @@ +[ + {"query": "We are moving this monorepo from Cursor to Claude Code. Preview only the project skills and coding rules under /work/acme; don't scan my home directory or touch files yet.", "should_trigger": true}, + {"query": "i switched from windsurf to codex yesterday—can you carry over my user MCP servers but strip any literal tokens and show me the no-write command first?", "should_trigger": true}, + {"query": "Convert ./fixtures/cursor-mcp.json into native OpenCode V2 for /tmp/atlas. I need the dry run and the separate approved apply command, with mcp.servers only.", "should_trigger": true}, + {"query": "My Android team is standardizing on Gemini CLI. Move the reusable agent skills in this Android Studio Quail workspace into Gemini's native project location without merging duplicate aliases.", "should_trigger": true}, + {"query": "We're opening an old VS Code solution in Visual Studio 2026. Transfer the repository's Copilot skills and .vscode/mcp.json safely, preserving unrelated target servers.", "should_trigger": true}, + {"query": "firebase studio is shutting down and this existing workspace has .idx/airules.md; help me migrate those instructions to Antigravity, but leave .idx/dev.nix and API settings alone", "should_trigger": true}, + {"query": "Can you move Claude Code project MCP from /srv/payments into Zed? First prove the preview writes nothing, then tell me exactly what approval is needed.", "should_trigger": true}, + {"query": "I have the same deploy skill under .cursor/skills and .agents/skills after changing editors. Consolidate it into Codex for this repo with backups and explain which copy wins.", "should_trigger": true}, + {"query": "Port the old Roo Code rules in ./legacy-app to Continue. Roo is archived, so call out anything that must be reconstructed instead of pretending the formats match.", "should_trigger": true}, + {"query": "把 /tmp/demo 里 OpenCode 的 agent 配置迁到 Cursor,先 dry-run;skills、rules 和 prompts 可以迁,hooks 和 memory 不要碰。", "should_trigger": true}, + {"query": "I'm writing a migration guide. What root key does Cursor use in mcp.json, and how does it differ from Visual Studio's file? No local changes.", "should_trigger": false}, + {"query": "After moving from Cursor to VS Code, the same MCP server now times out after 30 seconds. Diagnose the connection and logs; don't migrate anything.", "should_trigger": false}, + {"query": "Install the Playwright MCP server in Claude Code and authenticate it for this project.", "should_trigger": false}, + {"query": "We're choosing between Cursor, Windsurf, and Visual Studio 2026. Compare current pricing, privacy controls, and C# support.", "should_trigger": false}, + {"query": "Create a new Codex skill under .agents/skills that reviews SQL migrations and add three eval prompts for it.", "should_trigger": false}, + {"query": "Where does VS Code store the active Profile's user MCP configuration on macOS? I only need the location and UI command.", "should_trigger": false}, + {"query": "CI says opencode.json is invalid JSONC near line 18. Validate and fix that one file in place.", "should_trigger": false}, + {"query": "Update README.md to say we completed the Cursor-to-Claude migration last quarter.", "should_trigger": false}, + {"query": "Explain when my team should use prompts versus rules versus Agent Skills; include examples for Copilot and Claude.", "should_trigger": false}, + {"query": "Copy my Cursor user rules from my work laptop profile to the same Cursor profile on a new laptop, byte-for-byte, as a backup.", "should_trigger": false} +] diff --git a/skills/agent-skills-setup/agent-skills-setup/references/doc-freshness-checks.json b/skills/agent-skills-setup/agent-skills-setup/references/doc-freshness-checks.json new file mode 100644 index 000000000..1480ca4d2 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/doc-freshness-checks.json @@ -0,0 +1,146 @@ +{ + "schema_version": 1, + "verified_at": "2026-08-15", + "checks": [ + { + "id": "agent-skills-spec", + "url": "https://raw.githubusercontent.com/agentskills/agentskills/main/docs/specification.mdx", + "required_terms": [ + "name", + "description", + "metadata" + ] + }, + { + "id": "cline-config", + "url": "https://docs.cline.bot/getting-started/config", + "required_terms": [ + "cline", + "configuration" + ] + }, + { + "id": "forgecode-docs", + "url": "https://forgecode.dev/docs/", + "required_terms": [ + "forge", + "agent" + ] + }, + { + "id": "qoder-docs", + "url": "https://docs.qoder.com/", + "required_terms": [ + "qoder" + ] + }, + { + "id": "codex-docs", + "url": "https://learn.chatgpt.com/docs/llms.txt", + "required_terms": [ + "codex" + ] + }, + { + "id": "openhands-docs", + "url": "https://docs.openhands.dev/overview/introduction", + "required_terms": [ + "openhands" + ] + }, + { + "id": "amp-manual", + "url": "https://ampcode.com/manual", + "required_terms": [ + "agent skills", + "mcp" + ] + }, + { + "id": "monkeycode-readme", + "url": "https://raw.githubusercontent.com/chaitin/MonkeyCode/main/README.md", + "required_terms": [ + "monkeycode", + "development platform" + ] + }, + { + "id": "cursor-rules", + "url": "https://docs.cursor.com/context/rules", + "required_terms": [ + "cursor", + "rule" + ] + }, + { + "id": "claude-code-skills", + "url": "https://code.claude.com/docs/en/skills", + "required_terms": [ + "skill" + ] + }, + { + "id": "github-copilot-cli", + "url": "https://docs.github.com/en/copilot/how-tos/copilot-cli", + "required_terms": [ + "copilot", + "cli" + ] + }, + { + "id": "vscode-agent-skills", + "url": "https://code.visualstudio.com/docs/agent-customization/agent-skills", + "required_terms": [ + "skill" + ] + }, + { + "id": "gemini-cli-skills", + "url": "https://github.com/google-gemini/gemini-cli/blob/main/docs/skills.md", + "required_terms": [ + "gemini", + "skill" + ] + }, + { + "id": "kiro-steering", + "url": "https://kiro.dev/docs/steering", + "required_terms": [ + "kiro", + "steering" + ] + }, + { + "id": "rovodev-cli-skills", + "url": "https://support.atlassian.com/rovo/docs/use-agent-skills-in-rovo-dev-cli/", + "required_terms": [ + "rovo", + "skill" + ] + }, + { + "id": "ibm-bob-docs", + "url": "https://www.ibm.com/docs/en/bob", + "required_terms": [ + "bob", + "agent" + ] + }, + { + "id": "warp-drive-prompts", + "url": "https://docs.warp.dev/agents/prompts", + "required_terms": [ + "warp", + "prompt" + ] + }, + { + "id": "windsurf-rules", + "url": "https://docs.codeium.com/windsurf/rules", + "required_terms": [ + "windsurf", + "rule" + ] + } + ] +} \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ide-paths.json b/skills/agent-skills-setup/agent-skills-setup/references/ide-paths.json new file mode 100644 index 000000000..60f110de3 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ide-paths.json @@ -0,0 +1,370 @@ +{ + "antigravity": { + "global_skills": "~/.gemini/config/skills", + "project_skills": ".agents/skills", + "rules": ".agents/rules", + "mcp": "~/.gemini/config/mcp_config.json", + "project_mcp": ".agents/mcp_config.json", + "config": "" + }, + "claude": { + "global_skills": "~/.claude/skills", + "project_skills": ".claude/skills", + "rules": "CLAUDE.md", + "mcp": "~/.claude.json", + "project_mcp": ".mcp.json", + "project_config": ".claude/settings.json", + "config": "~/.claude/settings.json" + }, + "codely": { + "global_skills": "~/.codely-cli/skills", + "project_skills": ".codely-cli/skills", + "rules": "CODELY.md", + "mcp": "~/.codely-cli/settings.json", + "project_mcp": ".codely-cli/settings.json", + "project_config": ".codely-cli/settings.json", + "config": "~/.codely-cli/settings.json" + }, + "codex": { + "global_skills": "~/.agents/skills", + "project_skills": ".agents/skills", + "rules": "AGENTS.md", + "mcp": "~/.codex/config.toml", + "project_mcp": ".codex/config.toml", + "project_config": ".codex/config.toml", + "config": "~/.codex/config.toml" + }, + "copilot": { + "global_skills": "~/.copilot/skills", + "project_skills": ".github/skills", + "rules": ".github/copilot-instructions.md", + "mcp": "~/.copilot/mcp-config.json", + "project_mcp": ".mcp.json", + "config": "" + }, + "cursor": { + "global_skills": "~/.cursor/skills", + "project_skills": ".cursor/skills", + "rules": ".cursor/rules", + "mcp": "~/.cursor/mcp.json", + "project_mcp": ".cursor/mcp.json", + "config": "" + }, + "windsurf": { + "global_skills": "~/.codeium/windsurf/skills", + "project_skills": ".windsurf/skills", + "rules": ".windsurf/rules", + "mcp": "~/.codeium/windsurf/mcp_config.json", + "config": "" + }, + "jetbrains": { + "global_skills": "~/.junie/skills", + "project_skills": ".junie/skills", + "rules": ".junie/AGENTS.md", + "mcp": "~/.junie/mcp/mcp.json", + "project_mcp": ".junie/mcp/mcp.json", + "config": "" + }, + "openclaw": { + "global_skills": "~/.openclaw/skills", + "project_skills": "skills", + "rules": "AGENTS.md", + "mcp": "~/.openclaw/openclaw.json", + "config": "~/.openclaw/openclaw.json" + }, + "trae": { + "global_skills": "~/.trae/skills", + "project_skills": ".trae/skills", + "rules": ".trae/rules", + "mcp": "", + "project_mcp": ".trae/mcp.json", + "config": "" + }, + "trae-cn": { + "global_skills": "~/.trae-cn/skills", + "project_skills": ".trae/skills", + "rules": ".trae/rules", + "mcp": "", + "project_mcp": ".trae/mcp.json", + "config": "" + }, + "vscode": { + "global_skills": "~/.copilot/skills", + "project_skills": ".github/skills", + "rules": ".github/copilot-instructions.md", + "mcp": "", + "project_mcp": ".vscode/mcp.json", + "config": "" + }, + "visual-studio": { + "global_skills": "~/.copilot/skills", + "project_skills": ".github/skills", + "rules": ".github/copilot-instructions.md", + "mcp": { + "darwin": "", + "linux": "", + "windows": "%USERPROFILE%\\.mcp.json" + }, + "project_mcp": ".mcp.json", + "config": "" + }, + "jetbrains-ai": { + "global_skills": "", + "project_skills": ".agents/skills", + "rules": "", + "mcp": "", + "project_mcp": "", + "config": "" + }, + "firebase-studio": { + "global_skills": "", + "project_skills": "", + "rules": ".idx/airules.md", + "mcp": "", + "project_mcp": ".idx/mcp.json", + "config": "" + }, + "android-studio": { + "global_skills": "~/.agents/skills", + "project_skills": ".agents/skills", + "rules": "AGENTS.md", + "mcp": "", + "project_mcp": "", + "config": "" + }, + "zed": { + "global_skills": "~/.agents/skills", + "project_skills": ".agents/skills", + "rules": "AGENTS.md", + "mcp": "~/.config/zed/settings.json", + "project_mcp": ".zed/settings.json", + "config": "" + }, + "neovim": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "", + "config": "~/.config/nvim/init.lua" + }, + "emacs": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "", + "config": "" + }, + "continue": { + "global_skills": "", + "project_skills": "", + "rules": ".continue/rules", + "mcp": "~/.continue/config.yaml", + "project_mcp": ".continue/mcpServers", + "config": "~/.continue/config.yaml" + }, + "aider": { + "global_skills": "", + "project_skills": "", + "rules": "CONVENTIONS.md", + "mcp": "", + "config": "~/.aider.conf.yml" + }, + "roo-code": { + "global_skills": "~/.roo/skills", + "project_skills": ".roo/skills", + "rules": ".roorules", + "project_mcp": ".roo/mcp.json", + "mcp": "", + "config": "" + }, + "cline": { + "project": "", + "global_skills": "~/.cline/skills", + "project_skills": ".cline/skills", + "rules": ".cline/rules", + "mcp": "~/.cline/data/settings/cline_mcp_settings.json", + "project_mcp": ".cline/mcp.json", + "config": "" + }, + "amazon-q": { + "global_skills": "", + "project_skills": "", + "rules": ".amazonq/rules", + "mcp": "~/.aws/amazonq/default.json", + "project_mcp": ".amazonq/default.json", + "config": "" + }, + "cody": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "", + "config": "" + }, + "codeium": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "", + "config": "" + }, + "tabnine": { + "global_skills": "", + "project_skills": "", + "rules": ".tabnine/guidelines", + "mcp": "~/.tabnine/mcp_servers.json", + "project_mcp": ".tabnine/mcp_servers.json", + "config": "" + }, + "replit": { + "global_skills": "", + "project_skills": ".agents/skills", + "rules": "replit.md", + "mcp": "", + "project_config": ".replit", + "config": "" + }, + "pearai": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "", + "config": "" + }, + "supermaven": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "", + "config": "" + }, + "pieces": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "", + "project_mcp": "", + "project_config": "", + "config": "" + }, + "blackbox": { + "global_skills": "", + "project_skills": ".blackbox/skills", + "rules": "", + "mcp": "", + "config": "" + }, + "gemini-cli": { + "global_skills": "~/.gemini/skills", + "project_skills": ".gemini/skills", + "rules": "GEMINI.md", + "mcp": "~/.gemini/settings.json", + "project_mcp": ".gemini/settings.json", + "project_config": ".gemini/settings.json", + "config": "~/.gemini/settings.json" + }, + "goose-cli": { + "global_skills": "~/.agents/skills", + "project_skills": ".agents/skills", + "rules": ".goosehints", + "mcp": "~/.config/goose/config.yaml", + "config": "~/.config/goose/config.yaml" + }, + "opencode": { + "global_skills": "~/.config/opencode/skills", + "project_skills": ".opencode/skills", + "rules": "AGENTS.md", + "mcp": "~/.config/opencode/opencode.json", + "project_mcp": "opencode.json", + "project_config": "opencode.json", + "config": "~/.config/opencode/opencode.json" + }, + "kilocode": { + "project": ".kilo", + "global_skills": "~/.kilo/skills", + "project_skills": ".kilo/skills", + "rules": "AGENTS.md", + "mcp": "~/.config/kilo/kilo.jsonc", + "project_mcp": ".kilo/kilo.jsonc", + "project_config": ".kilo/kilo.jsonc", + "config": "~/.config/kilo/kilo.jsonc" + }, + "kimiai": { + "global_skills": "~/.kimi-code/skills", + "project_skills": ".kimi-code/skills", + "rules": "AGENTS.md", + "mcp": "~/.kimi-code/mcp.json", + "project_mcp": ".kimi-code/mcp.json", + "config": "~/.kimi-code/config.toml" + }, + "workbuddy": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": "~/.workbuddy/mcp.json", + "project_mcp": ".workbuddy/mcp.json", + "config": "" + }, + "claude-desktop": { + "global_skills": "", + "project_skills": "", + "rules": "", + "mcp": { + "darwin": "~/Library/Application Support/Claude/claude_desktop_config.json", + "linux": "", + "windows": "%APPDATA%\\Claude\\claude_desktop_config.json" + }, + "config": "" + }, + "kiro": { + "global_skills": "~/.kiro/skills", + "project_skills": ".kiro/skills", + "rules": ".kiro/steering", + "mcp": "~/.kiro/settings/mcp.json", + "project_mcp": ".kiro/settings/mcp.json", + "config": "" + }, + "augment-code": { + "global_skills": "~/.augment/skills", + "project_skills": ".augment/skills", + "rules": ".augment/rules", + "mcp": "~/.augment/settings.json", + "project_mcp": ".augment/settings.json", + "project_config": ".augment/settings.json", + "config": "~/.augment/settings.json" + }, + "void-editor": { + "global_skills": "", + "project_skills": "", + "rules": ".voidrules", + "mcp": "~/.void-editor/mcp.json", + "project_mcp": ".vscode/mcp.json", + "config": "" + }, + "baidu-comate": { + "global_skills": "~/.comate/skills", + "project_skills": ".comate/skills", + "rules": "", + "mcp": "~/.comate/mcp.json", + "project_mcp": ".comate/mcp.json", + "config": "" + }, + "tencent-codebuddy": { + "global_skills": "~/.codebuddy/skills", + "project_skills": ".codebuddy/skills", + "rules": "CODEBUDDY.md", + "mcp": "~/.codebuddy/.mcp.json", + "project_mcp": ".mcp.json", + "project_config": ".codebuddy/settings.json", + "config": "~/.codebuddy/settings.json" + }, + "zcode": { + "global_skills": "~/.zcode/skills", + "project_skills": "", + "rules": "AGENTS.md", + "mcp": "~/.zcode/cli/config.json", + "project_mcp": ".zcode/config.json", + "project_config": ".zcode/config.json", + "config": "~/.zcode/cli/config.json" + } +} diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ide-registry.md b/skills/agent-skills-setup/agent-skills-setup/references/ide-registry.md new file mode 100644 index 000000000..84b83e9fb --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ide-registry.md @@ -0,0 +1,116 @@ +# IDE Reference Index + +Read only the selected source and target entries. Each link records documented +paths, supported migration surfaces, and manual boundaries; a few UI-only +clients intentionally share one reference. `ide-paths.json` remains the +legacy compatibility mapping. [Registry v2](registry-v2.json) is authoritative +for product profiles, lifecycle, storage type, scope, migration policy, source, +version range, and freshness. + +Registry support levels are evidence contracts, not marketing labels. No +profile is currently `full`: reviewed automatic subsets are `partial`; disputed +or undocumented products remain `manual`, `source-only`, `provider`, `legacy`, +or `unverified`. Product templates cannot authorize a legacy write. Promotion +requires current official sources, a versioned surface contract, an adapter, +fixtures, secret/rollback coverage, and a fresh documentation check. + +## Lifecycle and target eligibility + +| Classification | Products | Default behavior | +| --- | --- | --- | +| Legacy/source-only | Roo Code, Void, Supermaven, Firebase Studio | Inventory and export only; never target. | +| Brand alias | Codeium, Tongyi Lingma | Resolve to the named current profile; do not invent paths. | +| Provider | Pieces | Configure the consuming MCP client. | +| Editor host | Emacs, Neovim, Helix | Select a concrete plugin profile first. | +| Cloud/UI | Devin, v0, Lovable, Bolt, TRAE Work, Cody | Use official API/UI or a rebuild checklist. | +| Profiled local client | Cline, Amazon Q, Codex, ForgeCode, Augment, Windsurf, Qoder and the new CLI entries | Enforce per-surface policy and loss reporting. | + +## IDE references + +- [`claude-desktop`](ides/claude-desktop.md) — claude-desktop (Claude Desktop app) +- [`codely`](ides/codely.md) — codely (Tuanjie Codely / Tuanjie Cowork; Unity 中国 AI Agent) +- [`claude`](ides/claude.md) — claude (Claude Code) +- [`cursor`](ides/cursor.md) — cursor +- [`cline`](ides/cline.md) — cline +- [`roo-code`](ides/roo-code.md) — roo-code (archived 2026-05) +- [`vscode`](ides/vscode.md) — vscode (VS Code + GitHub Copilot IDE; not cloud agent or the `copilot` script target) +- [`visual-studio`](ides/visual-studio.md) — visual-studio (Visual Studio 2026/2022 + GitHub Copilot; Windows only) +- [`firebase-studio`](ides/firebase-studio.md) — firebase-studio (sunsetting 2027-03-22; existing-workspace rules source) +- [`android-studio`](ides/android-studio.md) — android-studio (Gemini in Android Studio, Quail 1+) +- [`copilot`](ides/copilot.md) — copilot-cli +- [`windsurf`](ides/windsurf.md) — windsurf +- [`codeium`](ides/codeium.md) — codeium (Codeium → Windsurf) +- [`continue`](ides/continue.md) — continue +- [`emacs`](ides/emacs.md) — emacs (GNU Emacs) +- [`augment-code`](ides/augment-code.md) — augment-code +- [`kilocode`](ides/kilocode.md) — kilocode +- [`zed`](ides/zed.md) — zed +- [`trae`](ides/trae.md) — trae +- [`trae-work`](ides/trae-work.md) — trae-work (separate product; not a supported mapper target) +- [`trae-cn`](ides/trae-cn.md) — trae-cn +- [`jetbrains`](ides/jetbrains.md) — jetbrains (Junie in JetBrains IDEs; not JetBrains AI Assistant) +- [`jetbrains-ai`](ides/jetbrains-ai.md) — jetbrains-ai (JetBrains AI Assistant; distinct from Junie) +- [`kiro`](ides/kiro.md) — kiro +- [`codex`](ides/codex.md) — codex +- [`gemini-cli`](ides/gemini-cli.md) — gemini-cli +- [`antigravity`](ides/antigravity.md) — antigravity (Antigravity IDE / shared 2.0 surface) +- [`amazon-q`](ides/amazon-q.md) — amazon-q +- [`opencode`](ides/opencode.md) — opencode +- [`goose-cli`](ides/goose-cli.md) — goose-cli (Goose CLI) +- [`openclaw`](ides/openclaw.md) — openclaw (OpenClaw) +- [`aider`](ides/aider.md) — aider +- [`openhands`](ides/openhands.md) — openhands +- [`replit`](ides/replit.md) — replit (Replit AI) +- [`sourcegraph-amp`](ides/sourcegraph-amp.md) — sourcegraph-amp +- [`cody`](ides/cody.md) — sourcegraph-cody +- [`forge`](ides/forge.md) — forge +- [`pearai`](ides/pearai.md) — pearai +- [`void-editor`](ides/void-editor.md) — void-editor +- [`tabnine`](ides/tabnine.md) — tabnine +- [`supermaven`](ides/supermaven.md) — supermaven +- [`blackbox`](ides/blackbox.md) — blackbox (Blackbox AI) +- [`pieces`](ides/pieces.md) — pieces (Pieces for Developers) +- [`helix`](ides/helix.md) — helix +- [`neovim`](ides/neovim.md) — neovim +- [`mcphub-nvim`](ides/mcphub-nvim.md) — mcphub-nvim +- [`codecompanion-nvim`](ides/codecompanion-nvim.md) — codecompanion-nvim +- [`tongyi-lingma`](ides/tongyi-lingma.md) — tongyi-lingma (DEPRECATED — renamed to Qoder CN on 2026-05-20, see `qoder-cn`) +- [`baidu-comate`](ides/baidu-comate.md) — baidu-comate +- [`tencent-codebuddy`](ides/tencent-codebuddy.md) — tencent-codebuddy +- [`kimiai`](ides/kimiai.md) — kimi-code (Moonshot AI) +- [`workbuddy`](ides/workbuddy.md) — workbuddy (WorkBuddy) +- [`zcode`](ides/zcode.md) — zcode (Zhipu AI) +- [`minimax-code`](ides/minimax-code.md) — minimax-code (MiniMax) +- [`mmx-cli`](ides/mmx-cli.md) — mmx-cli (MiniMax CLI) +- [`qoder-cn`](ides/qoder-cn.md) — qoder-cn (Alibaba — formerly Tongyi Lingma, renamed 2026-05-20) +- [`baidu-comate-ide`](ides/baidu-comate-ide.md) — baidu-comate-ide (Baidu — standalone IDE, distinct from plugin) +- [`tencent-codebuddy-ide`](ides/tencent-codebuddy-ide.md) — tencent-codebuddy-ide (Tencent — standalone IDE, distinct from plugin) +- [`iflycode`](ides/ui-only-mcp.md) — iflycode (iFlytek; shared UI-only reference) +- [`raccoon-ai`](ides/ui-only-mcp.md) — raccoon-ai (SenseTime; shared UI-only reference) +- [`monkeycode`](ides/monkeycode.md) — monkeycode (Chaitin Tech) +- [`vecli`](ides/vecli.md) — vecli (Volcano Engine) +- [`bolt-new`](ides/bolt-new.md) — bolt-new (StackBlitz) +- [`qodo`](ides/qodo.md) — qodo (formerly CodiumAI) +- [`devin`](ides/devin.md) — devin (Cognition) +- [`v0`](ides/v0.md) — v0 (Vercel) +- [`lovable`](ides/lovable.md) — lovable +- [`xcode`](ides/xcode.md) — xcode (Xcode 26.3+/27 coding agents; manual configuration boundary) +- [`gptel-mcp-el`](ides/gptel-mcp-el.md) — gptel-mcp-el (third-party Emacs packages) +- [`qoder`](ides/qoder.md) — Qoder International CLI / IDE profiles +- [`qwen-code`](ides/qwen-code.md) — Qwen Code +- [`mistral-vibe`](ides/mistral-vibe.md) — Mistral Vibe Code +- [`factory-droid`](ides/factory-droid.md) — Factory Droid +- [`warp-oz`](ides/warp-oz.md) — Warp Oz local/cloud agent CLI +- [`pi`](ides/pi.md) — Pi coding agent +- [`crush`](ides/crush.md) — Crush terminal coding agent +- [`gemini-code-assist`](ides/gemini-code-assist.md) — Gemini Code Assist +- [`gitlab-duo`](ides/gitlab-duo.md) — GitLab Duo Agent Platform +- [`hermes`](ides/hermes.md) — Hermes Agent +- [`ibm-bob`](ides/ibm-bob.md) — IBM Bob +- [`jules`](ides/jules.md) — Google Jules +- [`letta`](ides/letta.md) — Letta +- [`letta-code`](ides/letta-code.md) — Letta Code +- [`rovodev`](ides/rovodev.md) — Atlassian Rovo Dev +- [`warp`](ides/warp.md) — Warp +- [`zencoder`](ides/zencoder.md) — Zencoder +- [`zenflow`](ides/zenflow.md) — Zenflow diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/aider.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/aider.md new file mode 100644 index 000000000..842db1595 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/aider.md @@ -0,0 +1,20 @@ +# aider + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | `CONVENTIONS.md` | +| MCP | Not mapped | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | `~/.aider.conf.yml` | + + +- Aider config is YAML and layered; an explicit `--config` can select another file. It is diagnostic/manual, never a conversion target. +- `CONVENTIONS.md` is read-only context (`--read` or YAML `read:`). +- `.env`, `AIDER_*`, CLI flags, `/load`, prompts, Skills, and MCP lack a portable generic migration contract. Never copy credentials or rewrite another IDE's config into Aider YAML. + +Sources: [configuration](https://aider.chat/docs/config.html), [YAML config](https://aider.chat/docs/config/aider_conf.html), [conventions](https://aider.chat/docs/usage/conventions.html). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/amazon-q.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/amazon-q.md new file mode 100644 index 000000000..2f6304f62 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/amazon-q.md @@ -0,0 +1,21 @@ +# amazon-q + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | `.amazonq/rules` | +| MCP | `~/.aws/amazonq/default.json` | +| Project MCP | `.amazonq/default.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Resolve a profile before acting. Q in the IDE documents user `~/.aws/amazonq/default.json`, project `.amazonq/default.json`, and legacy `mcp.json` compatibility. `~/.aws/amazonq/agents/` belongs to custom-agent definitions and is not an alternative IDE MCP destination. +- Q CLI and custom agents have their own agent/profile directories. Their prompts, tools, permissions, hooks, and MCP state are not interchangeable with the narrow IDE `mcpServers` subobject. +- `.amazonq/` and `~/.aws/amazonq/` are mixed namespaces. Only the selected profile's explicit surface may be read or written; never flatten the whole tree. +- The v2 profiles are `amazon-q/ide`, `amazon-q/cli`, and `amazon-q/custom-agent`. + +Sources: [IDE MCP](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html), [project rules](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/context-project-rules.html), [MCP scopes](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/qdev-mcp.html). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/android-studio.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/android-studio.md new file mode 100644 index 000000000..69a6c99c8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/android-studio.md @@ -0,0 +1,20 @@ +# android-studio (Gemini in Android Studio) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.agents/skills` | +| Project skills | `.agents/skills` | +| Rules | `AGENTS.md` | +| MCP | Not mapped | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | Not mapped | + + +- Agent Skills require Android Studio Quail 1+. Project and personal skills support `.agents/skills/` and `.android-studio/skills/`; the mapper chooses portable `.agents/skills/`. Legacy `.skills/` and `agent/skills/` are deprecated migration sources and remain manual. +- Agent files use hierarchical `AGENTS.md`; `GEMINI.md` takes precedence in the same directory. IDE-native rules in `.idea/project.prompts.xml` are not portable and remain manual. +- MCP is configured through **Settings → Tools → AI → MCP Servers** and stored in a product/version-managed `mcp.json` with `mcpServers`. Do not guess its path or copy UI trust state. Current support excludes stdio, MCP resources, and MCP prompt templates. + +Sources: [Skills](https://developer.android.com/studio/gemini/skills), [agent files](https://developer.android.com/studio/gemini/agent-files), [MCP](https://developer.android.com/studio/gemini/add-mcp-server). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/antigravity.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/antigravity.md new file mode 100644 index 000000000..3e76bb74f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/antigravity.md @@ -0,0 +1,26 @@ +# antigravity (Antigravity IDE / shared 2.0 surface) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.gemini/config/skills` | +| Project skills | `.agents/skills` | +| Rules | `.agents/rules` | +| MCP | `~/.gemini/config/mcp_config.json` | +| Project MCP | `.agents/mcp_config.json` | +| Project config | Not mapped | +| Config | Not mapped | + + + +**Registry status:** `unverified/manual-reference`. These paths are discovery +evidence only; Antigravity is not an automatic source or target until its +IDE/CLI and version-specific schemas have independent adapters and fixtures. + +- MCP uses JSON `mcpServers`; remote endpoints use `serverUrl`, not `url`. The global file is shared by Antigravity surfaces; workspace MCP remains manual. +- Global Skills default to the generated path. `ANTIGRAVITY_SKILLS_DIR` overrides it; otherwise preserve a legacy-only tree and never merge legacy/current trees implicitly. `.agent/` remains legacy compatibility. +- Workspace rules use `.agents/rules/`; do not invent `.agents/AGENTS.md`. No official installation-detection or stable workflow path exists. +- Hooks use `.agents/hooks.json` and `~/.gemini/config/hooks.json`; plugins use `.agents/plugins/` and `~/.gemini/config/plugins/`. They are supported but schema/trust-sensitive, so reconstruct manually. Workflows remain UI-managed. Antigravity CLI is separate. + +Sources: [IDE Skills](https://antigravity.google/docs/ide/skills), [shared Skills](https://antigravity.google/docs/skills?app=antigravity-ide), [MCP](https://antigravity.google/docs/mcp), [plugins](https://antigravity.google/docs/ide/plugins), [hooks](https://antigravity.google/docs/hooks). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/augment-code.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/augment-code.md new file mode 100644 index 000000000..d56109bb4 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/augment-code.md @@ -0,0 +1,20 @@ +# augment-code + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.augment/skills` | +| Project skills | `.augment/skills` | +| Rules | `.augment/rules` | +| MCP | `~/.augment/settings.json` | +| Project MCP | `.augment/settings.json` | +| Project config | `.augment/settings.json` | +| Config | `~/.augment/settings.json` | + + +- MCP JSON uses `mcpServers`; retain explicit `http`/legacy `sse`, never infer transport from URL. +- Rules are `~/.augment/rules/` and `.augment/rules/`; user guidelines also use `~/.augment/user-guidelines.md`. Preserve activation frontmatter through the instruction IR. +- Commands and plugins are separate state. Project `.augment/settings.json` and local `.augment/settings.local.json` are independent surfaces; `--scope project` never selects the local override, and `--scope local` never rewrites the shareable project file. Merge only the selected MCP subobject. + +Sources: [Skills](https://docs.augmentcode.com/jetbrains/using-augment/skills), [Rules](https://docs.augmentcode.com/cli/rules), [MCP/settings](https://docs.augmentcode.com/cli/config). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/baidu-comate-ide.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/baidu-comate-ide.md new file mode 100644 index 000000000..88a48fd7b --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/baidu-comate-ide.md @@ -0,0 +1,6 @@ +# baidu-comate-ide (standalone IDE) + +**Registry status:** `unverified/manual-reference`; it is not an automatic +source or target. MCP is UI-managed; rules, custom agents, commands, memory, +Spec/Mission, and user state require a versioned official profile. This +standalone IDE is distinct from the Comate plugin; do not reuse plugin paths. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/baidu-comate.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/baidu-comate.md new file mode 100644 index 000000000..04c369ad8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/baidu-comate.md @@ -0,0 +1,24 @@ +# baidu-comate + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.comate/skills` | +| Project skills | `.comate/skills` | +| Rules | Not mapped | +| MCP | `~/.comate/mcp.json` | +| Project MCP | `.comate/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +**Registry status:** `unverified/manual-reference`; the generated paths are +read-only discovery hints until plugin/IDE versions, all context surfaces, and +adapters have profile fixtures. + +- MCP JSON uses `mcpServers` with explicit `stdio`, legacy `sse`, or `streamableHttp`; never infer transport from a URL. +- Rules use Comate-specific `.mdr`; Skills may use `.comate/skills/` or `.agents/skills/`. +- Agents and local three-tier configuration require manual review. + +Sources: [Skills](https://cloud.baidu.com/doc/COMATE/s/Nmma28iqe), [MCP](https://cloud.baidu.com/doc/COMATE/s/Ymir0x2ye). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/blackbox.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/blackbox.md new file mode 100644 index 000000000..4676e051f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/blackbox.md @@ -0,0 +1,20 @@ +# blackbox (Blackbox AI) + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | `.blackbox/skills` | +| Rules | Not mapped | +| MCP | Not mapped | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | Not mapped | + + +- This mapper covers Blackbox CLI project Skills only. The documented project path is diagnostic/manual because generic Skills migration has no project-scope selector. +- No portable global Skills, rules, prompt, MCP, or config path/schema is published. Do not infer `~/.blackbox`, `.blackbox/mcp.json`, or a whole `.blackbox` transfer. +- `/skill` and `blackbox mcp` are commands, not portable file contracts; `configure` is interactive. + +Sources: [Skills](https://docs.blackbox.ai/features/blackbox-cli/skills), [commands](https://docs.blackbox.ai/features/blackbox-cli/commands-reference), [CLI](https://docs.blackbox.ai/features/blackbox-cli/getting-started). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/bolt-new.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/bolt-new.md new file mode 100644 index 000000000..a0531d3d4 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/bolt-new.md @@ -0,0 +1,3 @@ +# bolt-new + +Bolt uses JSON `servers` (not `mcpServers`) and UI/plugin-managed MCP state. Remote auth and imports need manual review; do not copy foreign server maps unchanged. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/claude-desktop.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/claude-desktop.md new file mode 100644 index 000000000..3c5088654 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/claude-desktop.md @@ -0,0 +1,21 @@ +# claude-desktop (Claude Desktop app) + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | Not mapped | +| MCP | darwin: `~/Library/Application Support/Claude/claude_desktop_config.json`
windows: `%APPDATA%\Claude\claude_desktop_config.json` | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | Not mapped | + + +- The mapper supports legacy local `claude_desktop_config.json` on macOS and native Windows with JSON `mcpServers`; it never guesses Linux or virtualized Windows paths. Legacy `sse` stays legacy, not Streamable HTTP. +- Modern local MCP uses **Settings → Extensions → Advanced settings → Install Extension** for `.mcpb`; do not unpack it into a guessed directory. [Local MCP servers](https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop) and [MCPB](https://claude.com/docs/connectors/building/mcpb) define that flow. +- Remote MCP uses **Customize → Connectors → Add custom connector**; re-authorize and use any server-owner replacement endpoint. See [remote connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). +- Chat/Desktop Skills and Plugins are installed through **Customize** and have no portable mapper path. The Code tab uses Claude Code's own skills, settings, and MCP paths; keep those separate from Chat MCP. Claude Code import is interactive: `claude mcp add-from-claude-desktop`. + +Sources: [Skills](https://support.claude.com/en/articles/12512180-use-skills-in-claude), [Customize directory](https://support.claude.com/en/articles/14328846-browse-skills-connectors-and-plugins-in-one-directory), [Code tab](https://code.claude.com/docs/en/desktop), [MCP protocol](https://modelcontextprotocol.io/docs/develop/connect-local-servers). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/claude.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/claude.md new file mode 100644 index 000000000..8114acf46 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/claude.md @@ -0,0 +1,21 @@ +# claude (Claude Code) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.claude/skills` | +| Project skills | `.claude/skills` | +| Rules | `CLAUDE.md` | +| MCP | `~/.claude.json` | +| Project MCP | `.mcp.json` | +| Project config | `.claude/settings.json` | +| Config | `~/.claude/settings.json` | + + +- Settings include project `.claude/settings.json` and local `.claude/settings.local.json`; local scopes are manual. +- User/local MCP is `~/.claude.json`; shared project MCP is `.mcp.json`, both with `mcpServers`. The mapper handles user MCP and reports project scope for review. +- Rules include `CLAUDE.md`, `.claude/CLAUDE.md`, `.claude/rules/`, and local `CLAUDE.local.md`. Project/user Skills are standard directories. +- `.claude/commands/*.md` is **legacy compatibility**; prefer Skills. Agents and hooks are schema-bound and manual. Do not auto-migrate auto memory from `~/.claude/projects/.../memory/`. + +Sources: [settings](https://code.claude.com/docs/en/settings), [MCP](https://code.claude.com/docs/en/mcp), [memory](https://code.claude.com/docs/en/memory), [Skills](https://code.claude.com/docs/en/slash-commands), [subagents](https://code.claude.com/docs/en/sub-agents). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/cline.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/cline.md new file mode 100644 index 000000000..1d6e5cb91 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/cline.md @@ -0,0 +1,21 @@ +# cline + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.cline/skills` | +| Project skills | `.cline/skills` | +| Rules | `.cline/rules` | +| MCP | `~/.cline/data/settings/cline_mcp_settings.json` | +| Project MCP | `.cline/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Cline now uses one application data root for IDE, CLI, and SDK profiles. User MCP is `~/.cline/data/settings/cline_mcp_settings.json`; `CLINE_DATA_DIR` replaces `~/.cline/data`, so resolve `settings/cline_mcp_settings.json` beneath it. Do not infer a VS Code `globalStorage` path. +- User rules, hooks, Skills, agents, plugins, and cron live directly under `~/.cline/`; user workflows live under `~/.cline/data/workflows/`. Project counterparts live under `.cline/`. Treat each as a separate semantic surface. +- Skills use `.cline/skills/` and `~/.cline/skills/`. Project instructions use `.cline/rules/`, with legacy `.clinerules` as an alternative; user rules use `~/.cline/rules/`, with `~/Documents/Cline/Rules/` as a compatibility location. If both alternatives exist, require explicit source selection instead of merging them. +- Provider settings, teams, sessions, databases, and generated runtime state are private. Never copy the whole data root or credentials. + +Sources: [unified configuration and `CLINE_DATA_DIR`](https://docs.cline.bot/getting-started/config), [rules](https://docs.cline.bot/customization/cline-rules). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/codecompanion-nvim.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/codecompanion-nvim.md new file mode 100644 index 000000000..1bdcdb53e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/codecompanion-nvim.md @@ -0,0 +1,3 @@ +# codecompanion-nvim + +CodeCompanion uses Lua MCP config, configurable instruction files, prompt-library tables, and Lua hooks. Treat it as a plugin-specific manual migration surface. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/codeium.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/codeium.md new file mode 100644 index 000000000..5fe6ef52b --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/codeium.md @@ -0,0 +1,10 @@ +# codeium (Codeium → Windsurf) + + + +All automatic paths are unsupported. + + +Codeium is a legacy token retained to fail closed. No standalone Skills, MCP, or portable config contract is documented; do not treat shared `~/.codeium/` state as a Codeium install or copy it wholesale. Use the [Windsurf](windsurf.md) reference for the current product. + +Sources: [Windsurf plugins](https://docs.windsurf.com/plugins/getting-started), [Skills](https://docs.windsurf.com/windsurf/cascade/skills). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/codely.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/codely.md new file mode 100644 index 000000000..76e97f8be --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/codely.md @@ -0,0 +1,41 @@ +# codely + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.codely-cli/skills` | +| Project skills | `.codely-cli/skills` | +| Rules | `CODELY.md` | +| MCP | `~/.codely-cli/settings.json` | +| Project MCP | `.codely-cli/settings.json` | +| Project config | `.codely-cli/settings.json` | +| Config | `~/.codely-cli/settings.json` | + + + +## Status + +Codely / Tuanjie Cowork is an active Unity China product family, but the public +material and observed local layouts do not establish one versioned contract +shared by the CLI, Cowork app, engine integration, and IDE extensions. In +particular, the repository has no reviewed Codely target adapter that validates +the complete MCP transport and settings schema. + +The paths above are retained only for read-only discovery of an existing +installation. Registry v2 classifies this entry as `unverified` and +`manual-reference`: + +- do not use Codely as an automatic source or target; +- do not pass MCP objects through verbatim; +- do not copy `settings.json`, `CODELY.md`, generated memories, Context/RAG + indexes, Unity Insight state, agents, extensions, hooks, or LSP state; +- review the active product/version and reconstruct approved content manually; +- bind credentials after reconstruction through the product's supported secret + or environment mechanism. + +Moving Codely to `partial` requires an official, versioned schema; separate +profiles for each product surface; source and target adapters; native parser or +CLI verification; and golden fixtures for every automated surface. + +Sources: [Tuanjie Codely overview](https://developer.unity.cn/projects/6a7189bcedbc2a120fe6d4af), [Tuanjie Cowork](https://codely.tuanjie.cn/download), [AI environment setup](https://codely-docs.tuanjie.cn/learn/ai-programming-environment-setup-guide). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/codex.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/codex.md new file mode 100644 index 000000000..da27c140e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/codex.md @@ -0,0 +1,22 @@ +# codex + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.agents/skills` | +| Project skills | `.agents/skills` | +| Rules | `AGENTS.md` | +| MCP | `~/.codex/config.toml` | +| Project MCP | `.codex/config.toml` | +| Project config | `.codex/config.toml` | +| Config | `~/.codex/config.toml` | + + +- MCP/config is TOML `mcp_servers`; project config requires trust. JSON `mcpServers` is never converted automatically—rebuild and validate `[mcp_servers.]` manually. +- Codex supports stdio and Streamable HTTP; do not map legacy SSE or add protocol/session headers. Recheck authorization with `codex mcp list`. +- Instructions are a hierarchy, not one `rules` file. User scope chooses `~/.codex/AGENTS.override.md` before `~/.codex/AGENTS.md`; project discovery walks from repository root toward the working directory and chooses `AGENTS.override.md` before `AGENTS.md` at each level. Automatic targets use the ordinary `AGENTS.md` location. If both files already exist at one selected scope, planning stops because composing precedence content requires review. +- `.codex/rules/*.rules`, `.codex/agents/*.toml`, hooks, generated memories, layered config, and trust/managed policy are different objects. Rules/agents need native templates, hooks may only be emitted disabled, generated memory must be regenerated, and trust is never migrated. +- `AGENTS.md` and documented `.agents/skills` paths are portable after validation; admin skills may also live under `/etc/codex/skills`. `[[skills.config]]`, plugin bundles, and `agents/openai.yaml` remain separate policy/UI surfaces. + +Sources: [config](https://learn.chatgpt.com/docs/config-file/config-basic.md), [AGENTS hierarchy](https://learn.chatgpt.com/docs/agent-configuration/agents-md.md), [Skills](https://developers.openai.com/codex/skills). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/cody.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/cody.md new file mode 100644 index 000000000..ba35bf1c5 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/cody.md @@ -0,0 +1,10 @@ +# sourcegraph-cody + + + +All automatic paths are unsupported. + + +Cody is currently an Enterprise extension/UI surface; all automatic paths are unsupported. MCP is editor settings/UI and Enterprise-feature-gated, prompts belong to the Prompt Library, and no portable Skills, rules, config, or project file is established. Do not infer `.cody`, `.codyrules`, `~/.config/cody/`, or `.vscode/cody.json`. + +Sourcegraph MCP Server is a separate server configured by its client, not Cody local state. Sources: [Cody](https://sourcegraph.com/docs/cody), [MCP](https://sourcegraph.com/docs/cody/capabilities/agentic-context-fetching), [prompts](https://sourcegraph.com/docs/cody/capabilities/prompts). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/continue.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/continue.md new file mode 100644 index 000000000..f09468797 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/continue.md @@ -0,0 +1,20 @@ +# continue + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | `.continue/rules` | +| MCP | `~/.continue/config.yaml` | +| Project MCP | `.continue/mcpServers` | +| Project config | Not mapped | +| Config | `~/.continue/config.yaml` | + + +- Continue uses YAML: `config.yaml` and project block directories are not generic JSON config. `mcpServers` is an array, not a server object map. +- Project rules and prompts use Markdown/YAML frontmatter; no `CONTINUE.md` or Agent Skills directory is documented. +- Config, MCP, rules, and the mixed `.continue/` namespace are diagnostic/manual. Do not convert JSON into YAML or copy the tree. + +Sources: [configuration](https://docs.continue.dev/customize/deep-dives/configuration), [MCP](https://docs.continue.dev/customize/deep-dives/mcp), [rules](https://docs.continue.dev/customize/deep-dives/rules), [prompts](https://docs.continue.dev/customize/prompts). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/copilot.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/copilot.md new file mode 100644 index 000000000..396c96481 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/copilot.md @@ -0,0 +1,20 @@ +# copilot-cli + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.copilot/skills` | +| Project skills | `.github/skills` | +| Rules | `.github/copilot-instructions.md` | +| MCP | `~/.copilot/mcp-config.json` | +| Project MCP | `.mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- `copilot` means GitHub Copilot CLI, not VS Code. Global MCP and project `.mcp.json`/`.github/mcp.json` use `mcpServers`; project files remain manual. +- Keep explicit local/stdio/http/legacy-SSE transport and required tools; never copy VS Code `.vscode/mcp.json` (`servers`) unchanged or guess between CLI project files. +- Rules and Skills use documented GitHub/agent files. IDE prompt files are unsupported; agents, hooks, plugins, and managed state need manual review. + +Sources: [Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [MCP](https://docs.github.com/en/copilot/customizing-copilot/extending-copilot-chat-with-mcp), [custom instructions](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/crush.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/crush.md new file mode 100644 index 000000000..2c1fb5164 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/crush.md @@ -0,0 +1,7 @@ +# crush + +Crush loads project config in precedence order from `.crush.json` and `crush.json`, then user `~/.config/crush/crush.json`, with environment overrides for global config/data. These project and user MCP subobjects are inventoried separately. It discovers Skills in canonical Crush, common Agent Skills, Claude, and Cursor locations. + +Use `~/.config/crush/skills` and `.crush/skills` as canonical write targets. Treat the MCP subobject as manual: Crush requires its own typed server schema and performs shell expansion, so a generic JSON emitter cannot preserve its runtime semantics safely. `$HOME/.local/share/crush` and `CRUSH_GLOBAL_DATA` identify generated application state and are never migration sources. + +Source: [Crush repository and configuration](https://github.com/charmbracelet/crush). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/cursor.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/cursor.md new file mode 100644 index 000000000..73b65c835 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/cursor.md @@ -0,0 +1,21 @@ +# cursor + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.cursor/skills` | +| Project skills | `.cursor/skills` | +| Rules | `.cursor/rules` | +| MCP | `~/.cursor/mcp.json` | +| Project MCP | `.cursor/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- MCP JSON uses `mcpServers`; retain an explicit documented stdio, legacy-SSE, or Streamable HTTP transport. A bare URL is not enough, and `${env:NAME}` is Cursor-specific. +- `.cursor/rules/*.mdc` uses frontmatter; root `.cursorrules` is legacy and `AGENTS.md` is also recognized. `.agents/skills/` is a supported compatibility path, not the mapper's canonical target. +- `.cursor/commands/*.md`, `.cursor/hooks.json`, user hooks, and plugins are supported product surfaces but remain manual because their schemas and trust state are not generic migration objects. Memories and `.cursorignore` are also manual. +- No stable installation-detection path is used by this mapper. + +Sources: [Skills](https://cursor.com/docs/context/skills), [rules](https://cursor.com/docs/rules), [hooks](https://cursor.com/docs/hooks), [plugins](https://cursor.com/docs/reference/plugins), [MCP](https://cursor.com/docs/context/mcp). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/devin.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/devin.md new file mode 100644 index 000000000..60b05bad8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/devin.md @@ -0,0 +1,3 @@ +# devin (Cognition) + +Devin is cloud-configured. Its MCP marketplace/server and wiki are UI/service state, not a local migration target; guide manual dashboard setup. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/emacs.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/emacs.md new file mode 100644 index 000000000..01c47c3af --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/emacs.md @@ -0,0 +1,10 @@ +# emacs (GNU Emacs) + + + +All automatic paths are unsupported. + + +Native Emacs exposes no AI Skills, rules, MCP, or project-config mapping. Init files and `.dir-locals.el` are user-selected Emacs Lisp, not automatic migration targets. Package-specific AI/MCP integrations are separate products. + +Sources: [init files](https://www.gnu.org/software/emacs/manual/html_node/emacs/Init-File.html), [directory locals](https://www.gnu.org/software/emacs/manual/html_node/emacs/Directory-Variables.html). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/factory-droid.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/factory-droid.md new file mode 100644 index 000000000..fcf681046 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/factory-droid.md @@ -0,0 +1,7 @@ +# factory-droid + +Factory Droid uses `.factory/skills/` and `~/.factory/skills/`, project `AGENTS.md` and user `~/.factory/AGENTS.md`, `.factory/mcp.json` and `~/.factory/mcp.json`, and custom droids under `.factory/droids/` or `~/.factory/droids/`. Hooks are separate user, project, and local subobjects in `~/.factory/settings.json`, `.factory/settings.json`, and `.factory/settings.local.json`. + +Skills and reviewed MCP server maps are portable. Custom droids require a manual agent template; hooks can only be generated disabled and must never be activated by migration. + +Sources: [MCP](https://docs.factory.ai/cli/configuration/mcp), [hooks](https://docs.factory.ai/reference/hooks-reference), [custom droids](https://docs.factory.ai/cli/configuration/custom-droids). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/firebase-studio.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/firebase-studio.md new file mode 100644 index 000000000..774e0d13c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/firebase-studio.md @@ -0,0 +1,22 @@ +# firebase-studio + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | `.idx/airules.md` | +| MCP | Not mapped | +| Project MCP | `.idx/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Firebase Studio stopped accepting new users and workspaces on 2026-06-22 and is scheduled to shut down on 2027-03-22. Use this ID only to migrate rules out of an existing workspace; prefer Google AI Studio or Antigravity for new work. +- Gemini prioritizes `.idx/airules.md`, then `GEMINI.md`, `.gemini/styleguide.md`, `AGENTS.md`, and `cursorrules`. The mapper uses only the canonical `.idx/airules.md` file and does not flatten the fallbacks. +- Existing workspaces have project MCP at `.idx/mcp.json` under the `mcpServers` root. It supports stdio and remote HTTP transports, headers, env, and environment-variable references. It is a source-only surface because Firebase Studio is sunsetting. +- No stable Firebase Studio Agent Skills, prompts, or commands surface is documented. Do not infer Gemini CLI or VS Code paths. +- Workspace provisioning in `.idx/dev.nix`, Gemini API keys, models, chat state, and user settings are configuration/runtime state and remain manual. + +Sources: [lifecycle](https://firebase.google.com/docs/studio/get-started), [MCP](https://firebase.google.com/docs/studio/mcp-servers), [Gemini workspace configuration](https://firebase.google.com/docs/studio/set-up-gemini). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/forge.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/forge.md new file mode 100644 index 000000000..98f539a1a --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/forge.md @@ -0,0 +1,10 @@ +# forge (ForgeCode) + +This ID is ForgeCode, not an unrelated product named Forge. + +- Project config is `.forge.toml`; user config is `~/.forge/.forge.toml`. +- MCP uses `.mcp.json` with `mcpServers` and a native `forge mcp import` flow. +- Skills resolve in precedence order from `.forge/skills/`, `~/.agents/skills/`, `~/forge/skills/`, then built-ins. +- `AGENTS.md`, commands, agents, and permissions are distinct surfaces. Skills can be copied after validation; instructions use IR; agents and permissions require manual templates. + +Sources: [Skills](https://forgecode.dev/docs/skills/), [MCP](https://forgecode.dev/docs/mcp-integration/), [configuration](https://forgecode.dev/docs/forgecode-config/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/gemini-cli.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/gemini-cli.md new file mode 100644 index 000000000..942d9fe80 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/gemini-cli.md @@ -0,0 +1,21 @@ +# gemini-cli + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.gemini/skills` | +| Project skills | `.gemini/skills` | +| Rules | `GEMINI.md` | +| MCP | `~/.gemini/settings.json` | +| Project MCP | `.gemini/settings.json` | +| Project config | `.gemini/settings.json` | +| Config | `~/.gemini/settings.json` | + + +- User settings are the only automatic MCP/config source. Project settings and project MCP are diagnostic/manual; never copy the whole `.gemini` namespace. +- MCP JSON uses `mcpServers`; servers need `command`, legacy SSE `url`, or Streamable HTTP `httpUrl`. Never relabel `url` and `httpUrl`; aliases containing `_` require manual renaming and policy review. +- `GEMINI.md` supports project/ancestor context and configurable filenames. Commands use user/project `.gemini/commands/*.toml`; subagents use `.gemini/agents/*.md`. Both remain manual because their formats are not generic prompts or rules. +- `.agents/skills/` is a supported higher-precedence alias within the same scope; the mapper uses canonical Gemini paths and never merges aliases implicitly. + +Sources: [configuration](https://geminicli.com/docs/reference/configuration), [MCP](https://geminicli.com/docs/tools/mcp-server/), [Skills](https://geminicli.com/docs/cli/using-agent-skills/), [commands](https://geminicli.com/docs/cli/custom-commands/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/gemini-code-assist.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/gemini-code-assist.md new file mode 100644 index 000000000..eb94cc6fe --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/gemini-code-assist.md @@ -0,0 +1,5 @@ +# gemini-code-assist + +Gemini Code Assist supports VS Code and JetBrains IDE extensions with user and project settings, custom prompts, and MCP integrations. User skills and project context use standard paths. UI-managed Prompt Library is distinct from Gemini CLI commands. + +Sources: [docs](https://cloud.google.com/gemini-code-assist/docs). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/gitlab-duo.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/gitlab-duo.md new file mode 100644 index 000000000..efa2a930d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/gitlab-duo.md @@ -0,0 +1,5 @@ +# gitlab-duo + +GitLab Duo Agent Platform provides VS Code, JetBrains, CLI, Web, and Flow surfaces. User-level AGENTS are located at `~/.gitlab/duo/AGENTS.md` (Linux/macOS) or `%APPDATA%\GitLab\duo\AGENTS.md` (Windows). Project and subdirectory AGENTS hierarchy is respected. + +Sources: [docs](https://docs.gitlab.com/ee/user/duo_agent_platform/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/goose-cli.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/goose-cli.md new file mode 100644 index 000000000..3740c15c4 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/goose-cli.md @@ -0,0 +1,21 @@ +# goose-cli (Goose CLI) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.agents/skills` | +| Project skills | `.agents/skills` | +| Rules | `.goosehints` | +| MCP | `~/.config/goose/config.yaml` | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | `~/.config/goose/config.yaml` | + + +- `config.yaml` is YAML `extensions`, not JSON MCP; `sse` is legacy and must not be relabeled as `streamable_http`. MCP/config/project/prompt migration is manual. +- Dedicated Skills and local `.goosehints` are low-risk. Other context names can be selected with `CONTEXT_FILE_NAMES`. +- Recipes, prompt templates, Memory, slash commands, permissions, and `secrets.yaml` are separate state. Never copy secrets, mixed config, or runtime memory automatically. +- Windows uses a distinct app-data path; the generated POSIX mapping is macOS/Linux only. + +Sources: [Skills](https://goose-docs.ai/docs/guides/context-engineering/using-skills/), [configuration](https://goose-docs.ai/docs/guides/config-files/), [extensions](https://goose-docs.ai/docs/getting-started/using-extensions/), [goosehints](https://goose-docs.ai/docs/guides/context-engineering/using-goosehints/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/gptel-mcp-el.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/gptel-mcp-el.md new file mode 100644 index 000000000..57897bd19 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/gptel-mcp-el.md @@ -0,0 +1,3 @@ +# gptel-mcp-el + +`mcp.el`/gptel is package-specific Emacs Lisp, not native Emacs MCP. Install, init snippets, server registration, and package state require manual review; do not treat `~/.emacs.d/` as a Skills directory. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/helix.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/helix.md new file mode 100644 index 000000000..d6733377e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/helix.md @@ -0,0 +1,3 @@ +# helix + +Helix AI integration is plugin-based TOML `mcp_servers` and project `HELIX.md`; it supports a limited MCP surface. Treat plugin and editor config as manual. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/hermes.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/hermes.md new file mode 100644 index 000000000..9a7d12ce8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/hermes.md @@ -0,0 +1,5 @@ +# hermes + +Hermes Agent provides skills, persistent memory, subagents, cron, ACP, and MCP capabilities. User skills reside in `~/.hermes/skills/` and project skills in `.hermes/skills/`. Supports migration from OpenClaw via built-in migration tooling. + +Sources: [docs](https://github.com/hermes-agent/hermes). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/ibm-bob.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/ibm-bob.md new file mode 100644 index 000000000..ba6a0f74e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/ibm-bob.md @@ -0,0 +1,5 @@ +# ibm-bob + +IBM Bob provides IDE and Bob Shell profiles. Skills are supported in `.bob/skills` and `~/.bob/skills`. Rules include `.bob/rules`, mode-specific rules, and `AGENTS.md`. IDE global MCP is `~/.bob/mcp.json`, Bob Shell MCP is `~/.bob/mcp_settings.json`, and project MCP is `.bob/mcp.json`. + +Sources: [docs](https://www.ibm.com/products/bob). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/jetbrains-ai.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/jetbrains-ai.md new file mode 100644 index 000000000..c706d5bdc --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/jetbrains-ai.md @@ -0,0 +1,21 @@ +# jetbrains-ai (JetBrains AI Assistant; not Junie) + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | `.agents/skills` | +| Rules | Not mapped | +| MCP | Not mapped | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | Not mapped | + + +- Agent Skills require JetBrains AI Assistant 2026.2+. Project skills use `.agents/skills/`; IDE-level agent storage is version/product-managed and remains manual. +- Project rules use `.aiassistant/rules/*.md`; the directory contains scoped rule files and is not flattened by the single-file rules mapper. User rules are configured in the UI. +- MCP is managed through **Settings → Tools → AI Assistant → Model Context Protocol (MCP)**. Do not infer Junie `.junie/mcp/mcp.json` or copy UI trust/approval state. +- Claude Agent and Codex integration can discover their own native skills/configuration. Preserve the selected agent's ownership instead of merging caches or settings across agents. + +Sources: [Skills](https://www.jetbrains.com/help/ai-assistant/agent-skills.html), [project rules](https://www.jetbrains.com/help/ai-assistant/configure-project-rules.html), [MCP](https://www.jetbrains.com/help/ai-assistant/mcp.html). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/jetbrains.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/jetbrains.md new file mode 100644 index 000000000..9eee1d382 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/jetbrains.md @@ -0,0 +1,20 @@ +# jetbrains (Junie in JetBrains IDEs; not JetBrains AI Assistant) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.junie/skills` | +| Project skills | `.junie/skills` | +| Rules | `.junie/AGENTS.md` | +| MCP | `~/.junie/mcp/mcp.json` | +| Project MCP | `.junie/mcp/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Junie project Skills override same-named user Skills. `.junie/AGENTS.md` is preferred; root `AGENTS.md` is a fallback. Legacy guidelines and custom project settings are manual inputs. +- MCP JSON uses `mcpServers`. Only local command/args/env conversion is automatic; remote, header, transport, and unknown fields are manual. Do not copy the mixed `.junie/` namespace. +- Junie CLI `/import`, extensions, config, and trust files are separate surfaces. Extension bundles may include skills, MCP, subagents, commands, and guidelines; keep the package intact and manual. IDE-level Project Settings, MCP Settings, and Action Allowlist are UI-managed. Use the separate `jetbrains-ai` ID for AI Assistant. + +Sources: [Junie Skills](https://junie.jetbrains.com/docs/agent-skills.html), [IDE plugin](https://junie.jetbrains.com/docs/junie-ide-plugin.html), [MCP settings](https://junie.jetbrains.com/docs/junie-plugin-mcp-settings.html), [CLI config](https://junie.jetbrains.com/docs/junie-cli-configuration.html). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/jules.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/jules.md new file mode 100644 index 000000000..89a53bb6d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/jules.md @@ -0,0 +1,5 @@ +# jules + +Google Jules is an asynchronous coding agent providing cloud, CLI, and API profiles. It reads root `AGENTS.md`, repository setup scripts, and environment snapshots. Automated migrations treat Jules as a cloud-rebuild profile. + +Sources: [docs](https://jules.google.com/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/kilocode.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/kilocode.md new file mode 100644 index 000000000..4a92b5819 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/kilocode.md @@ -0,0 +1,21 @@ +# kilocode + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.kilo/skills` | +| Project skills | `.kilo/skills` | +| Rules | `AGENTS.md` | +| MCP | `~/.config/kilo/kilo.jsonc` | +| Project MCP | `.kilo/kilo.jsonc` | +| Project config | `.kilo/kilo.jsonc` | +| Config | `~/.config/kilo/kilo.jsonc` | + + +- MCP is JSONC `mcp` with distinct local/remote shapes; do not flatten it into another client schema. +- Skills use documented locations, with compatibility directories kept separate. +- Portable `AGENTS.md` remains the mapper default. Native custom instructions are configured as arrays that can reference `.kilo/rules/*.md`; preserve those scopes manually. +- Workflows/commands use `.kilo/commands/` and `~/.config/kilo/commands/`. Remote skill paths, agents, and config fields remain manual. + +Sources: [Skills](https://kilo.ai/docs/customize/skills), [MCP](https://kilo.ai/docs/automate/mcp/using-in-kilo-code), [rules](https://kilo.ai/docs/customize/custom-rules), [instructions](https://kilo.ai/docs/customize/custom-instructions), [workflows](https://kilo.ai/docs/customize/workflows). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/kimiai.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/kimiai.md new file mode 100644 index 000000000..7741df004 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/kimiai.md @@ -0,0 +1,19 @@ +# kimi-code (Moonshot AI) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.kimi-code/skills` | +| Project skills | `.kimi-code/skills` | +| Rules | `AGENTS.md` | +| MCP | `~/.kimi-code/mcp.json` | +| Project MCP | `.kimi-code/mcp.json` | +| Project config | Not mapped | +| Config | `~/.kimi-code/config.toml` | + + +- `KIMI_CODE_HOME` can override the generated home. `~/.kimi-code/` is current; legacy `~/.kimi/` is not. +- MCP JSON uses `mcpServers` across user/project scope. Retain documented remote transport, never equate legacy SSE with Streamable HTTP, and do not copy `mcp-oauth` state. +- `AGENTS.md` and Skills have documented aliases; commands are built-in/plugin based, not a standalone directory. +- Agents, hooks, sessions, plans, credentials, and TOML config are manual. Never copy credentials or runtime state. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/kiro.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/kiro.md new file mode 100644 index 000000000..c4960d2f1 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/kiro.md @@ -0,0 +1,20 @@ +# kiro + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.kiro/skills` | +| Project skills | `.kiro/skills` | +| Rules | `.kiro/steering` | +| MCP | `~/.kiro/settings/mcp.json` | +| Project MCP | `.kiro/settings/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- MCP JSON uses `mcpServers`; migrate only reviewed endpoint metadata and re-authorize OAuth in Kiro. +- Project steering uses `.kiro/steering/`; global steering uses `~/.kiro/steering/`, and root `AGENTS.md` is also recognized. Preserve inclusion frontmatter instead of flattening files. Skills use documented directories. +- IDE custom agents use project/user `.kiro/agents/*.md` and may embed MCP. CLI agents use a different JSON schema; reconstruct manually and never convert between them automatically. + +Sources: [Skills](https://kiro.dev/docs/skills/), [MCP](https://kiro.dev/docs/mcp/configuration/), [steering](https://kiro.dev/docs/steering/), [custom agents](https://kiro.dev/docs/custom-agents/), [hooks](https://kiro.dev/docs/hooks/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/letta-code.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/letta-code.md new file mode 100644 index 000000000..1f74bb51b --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/letta-code.md @@ -0,0 +1,5 @@ +# letta-code + +Letta Code provides desktop CLI, cloud, and `.af` AgentFile profiles. Supports skills, subagents, and memory policies. Desktop CLI reads standard `AGENTS.md` and user skills. + +Sources: [docs](https://docs.letta.com/letta-code/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/letta.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/letta.md new file mode 100644 index 000000000..40bf4475c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/letta.md @@ -0,0 +1,5 @@ +# letta + +Letta provides stateful agent memory, subagents, and tools. Profile `letta/cli` manages local agent state and tool configurations. + +Sources: [docs](https://docs.letta.com/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/lovable.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/lovable.md new file mode 100644 index 000000000..575e7aa06 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/lovable.md @@ -0,0 +1,3 @@ +# lovable + +Lovable is a web platform with UI-managed Chat Connectors and its own MCP server. OAuth, connector availability, and enterprise policy are cloud state; guide a manual UI setup. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/mcphub-nvim.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/mcphub-nvim.md new file mode 100644 index 000000000..fdbcf8444 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/mcphub-nvim.md @@ -0,0 +1,3 @@ +# mcphub-nvim + +McpHub uses JSON5 `mcpServers`/VS Code-compatible `servers` and environment interpolation. Verify with `:McpHub`; convert manually rather than assuming another client's schema. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/minimax-code.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/minimax-code.md new file mode 100644 index 000000000..f739883cd --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/minimax-code.md @@ -0,0 +1,3 @@ +# minimax-code (MiniMax) + +MiniMax Code desktop has built-in Skills, Agent Team, Memory, and scheduling, but no documented portable MCP/config path. Treat its desktop state as manual; do not infer a file contract. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/mistral-vibe.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/mistral-vibe.md new file mode 100644 index 000000000..e2a13efe7 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/mistral-vibe.md @@ -0,0 +1,7 @@ +# mistral-vibe + +Mistral Vibe loads Skills from custom paths, `.agents/skills/`, `.vibe/skills/`, `~/.vibe/skills/`, and `~/.agents/skills/` in documented precedence. Project `.vibe/config.toml` and user `~/.vibe/config.toml` are separate surfaces; MCP uses the `mcp_servers` TOML subobject. + +`AGENTS.md`, custom prompts, Skills, agents, and MCP are separate surfaces. TOML MCP, agents, and executable behavior require native adapters/templates; `.env`, logs, sessions, and credentials are forbidden migration inputs. + +Source: [Mistral Vibe](https://github.com/mistralai/mistral-vibe). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/mmx-cli.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/mmx-cli.md new file mode 100644 index 000000000..4161ba35f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/mmx-cli.md @@ -0,0 +1,3 @@ +# mmx-cli (MiniMax CLI) + +`mmx` is a tool, not an MCP client. Its config is JSON and its skills installer links into other products' paths; do not treat it as an independent migration target. Keep API region/host/key configuration manual. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/monkeycode.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/monkeycode.md new file mode 100644 index 000000000..5f966ef4d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/monkeycode.md @@ -0,0 +1,12 @@ +# monkeycode + +MonkeyCode is treated as a cloud or self-hosted software-development platform, +not as security middleware and not as a verified local IDE MCP client. The +repository has no current official evidence for a portable JSON `mcpServers` +file, Agent Skills directory, or SDD-file contract. + +Registry v2 therefore classifies it as a cloud/UI reconstruction target. Do not +invent a local path or copy workspace, task, model, Git/PR, credential, or agent +state. Inventory durable project instructions without secrets, then recreate +them through the product's reviewed UI/API only after binding the exact product +deployment and version to official documentation. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/neovim.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/neovim.md new file mode 100644 index 000000000..fde30001a --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/neovim.md @@ -0,0 +1,18 @@ +# neovim + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | Not mapped | +| MCP | Not mapped | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | `~/.config/nvim/init.lua` | + + +`init.lua` is editor configuration, not portable AI context; its effective location can vary with XDG/NVIM_APPNAME. Native Neovim has no Skills, rules, prompts, MCP, or project-config mapping. Plugins are separate products, and any migration to Lua must be manual. + +Sources: [startup](https://neovim.io/doc/user/starting/), [Lua](https://neovim.io/doc/user/lua-guide/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/openclaw.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/openclaw.md new file mode 100644 index 000000000..786acfe29 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/openclaw.md @@ -0,0 +1,25 @@ +# openclaw (OpenClaw) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.openclaw/skills` | +| Project skills | `skills` | +| Rules | `AGENTS.md` | +| MCP | `~/.openclaw/openclaw.json` | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | `~/.openclaw/openclaw.json` | + + + +**Registry status:** `unverified/manual-reference`. The workspace/bootstrap and +MCP contract is not approved for automatic conversion; the paths below are +read-only discovery guidance, not write authorization. + +- MCP JSON uses `mcp.servers`; local entries use command/args, remote entries require `transport: "streamable-http"`. Do not relabel legacy SSE or add protocol/session headers. +- `AGENTS.md` belongs to the active workspace, selected by `agents.defaults.workspace`; there is no fixed repository project-config root. +- Copy only named Skills and preview rule merges. MCP/config are opt-in; never copy `.openclaw`, runtime/UI state, or `.env*` wholesale. + +Sources: [Skills](https://docs.openclaw.ai/tools/skills), [workspace](https://docs.openclaw.ai/concepts/agent-workspace), [MCP](https://docs.openclaw.ai/cli/mcp), [configuration](https://docs.openclaw.ai/gateway/configuration). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/opencode.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/opencode.md new file mode 100644 index 000000000..ece3ca218 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/opencode.md @@ -0,0 +1,22 @@ +# opencode + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.config/opencode/skills` | +| Project skills | `.opencode/skills` | +| Rules | `AGENTS.md` | +| MCP | `~/.config/opencode/opencode.json` | +| Project MCP | `opencode.json` | +| Project config | `opencode.json` | +| Config | `~/.config/opencode/opencode.json` | + + +- MCP config is JSON/JSONC. V1 stores servers under `mcp`; V2 under `mcp.servers` and requires `--opencode-version v2`. Do not mix layouts. +- Local servers require target-native command arrays and environment syntax. Preserve only documented remote connection fields, re-authorize OAuth, and never add protocol state. +- Version changes replace only the selected MCP container; unrelated settings and requested backup strategy remain intact. +- `AGENTS.md` is the rules surface. OpenCode also discovers `.agents/skills/` and `.claude/skills/` aliases; keep duplicate-name precedence manual. Skill access can be limited by `permission.skill`. +- Commands use `.opencode/commands/*.md`; agents, hooks, and memory use their own formats/plugins and require manual review. + +Sources: [Skills](https://opencode.ai/docs/skills/), [V1 MCP](https://opencode.ai/docs/mcp/), [V2 MCP](https://opencode.ai/v2/docs/mcp-servers), [V1→V2](https://opencode.ai/v2/docs/migrate-v1). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/openhands.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/openhands.md new file mode 100644 index 000000000..c30625485 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/openhands.md @@ -0,0 +1,9 @@ +# openhands + +**Registry status:** `unverified/manual-reference`; it is not an automatic +source or target. OpenHands CLI and app/config surfaces must be bound to a +specific version before use. `AGENTS.md`, project Skills, MCP, agents, setup +scripts, hooks, and condenser memory have different scopes, so inventory and +reconstruct them individually without copying whole configuration or state. + +Source: [OpenHands documentation](https://docs.openhands.dev/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/pearai.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/pearai.md new file mode 100644 index 000000000..be6c8bbb7 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/pearai.md @@ -0,0 +1,10 @@ +# pearai + + + +All automatic paths are unsupported. + + +PearAI is a VS Code/Continue fork, not a documented filesystem contract. No PearAI-owned Skills, rules, MCP, or config path/schema is published; do not infer `~/.pearai`, `.pearai`, `.pearairules`, or borrow VS Code/Continue paths. All migration is manual/UI-managed. + +Sources: [app](https://github.com/trypear/pearai-app), [extension](https://github.com/trypear/pearai-submodule). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/pi.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/pi.md new file mode 100644 index 000000000..307d42054 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/pi.md @@ -0,0 +1,9 @@ +# pi + +Pi reads Skills from `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, `.agents/skills/`, packages, settings, and explicit CLI paths. Use the canonical Pi paths as targets and report compatibility paths instead of merging them. + +Pi deliberately tolerates some Skill-name/directory mismatches that the common Agent Skills specification rejects. Validate against the common specification before cross-client migration and report Pi-only metadata as loss. + +User settings are `~/.pi/agent/settings.json`; project settings are `.pi/settings.json`. They are separate scopes rather than aliases, and only reviewed subobjects may be reconstructed. + +Sources: [Skills](https://pi.dev/docs/latest/skills), [settings](https://pi.dev/docs/latest/settings). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/pieces.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/pieces.md new file mode 100644 index 000000000..7c682672d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/pieces.md @@ -0,0 +1,12 @@ +# pieces (Pieces for Developers) + + + +All automatic paths are unsupported. + + +PiecesOS/Desktop/CLI is an MCP server/provider, not a file-backed MCP client or Skill host. Every automatic object is unsupported: do not infer `~/.pieces`, `.pieces`, Skills, rules, or config. + +Configure the consuming client from **Settings → MCP** or `pieces mcp setup`; endpoints and transport are server-provided and may vary. Never rewrite its date/SSE path or migrate Pieces databases, logs, or extension state. + +Sources: [MCP](https://docs.pieces.app/products/mcp), [Cursor setup](https://docs.pieces.app/products/mcp/cursor), [CLI](https://docs.pieces.app/products/cli/get-started). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/qoder-cn.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/qoder-cn.md new file mode 100644 index 000000000..02e2e725e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/qoder-cn.md @@ -0,0 +1,3 @@ +# qoder-cn (formerly Tongyi Lingma) + +Qoder CN is the China product profile formerly branded Tongyi Lingma. It is distinct from the international `qoder.com` product, whose CLI uses `~/.qoder/skills`, `.qoder/skills`, layered settings, hooks, agents, and file-backed MCP. Keep Qoder CN in the conservative manual profile until its own official file contracts are verified; never reuse international paths by brand similarity. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/qoder.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/qoder.md new file mode 100644 index 000000000..a705834e9 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/qoder.md @@ -0,0 +1,7 @@ +# qoder (International) + +Qoder International exposes distinct CLI and IDE profiles. The CLI uses user Skills at `~/.qoder/skills/`, project Skills at `.qoder/skills/`, user settings at `~/.qoder/settings.json`, local project settings at `.qoder/settings.local.json`, and shareable MCP at `.mcp.json`. + +Hooks live in separate user (`~/.qoder/settings.json`), project (`.qoder/settings.json`), and local (`.qoder/settings.local.json`) settings layers and must only be emitted as disabled drafts. MCP conversion merges only `mcpServers`; OAuth tokens and permissions are not portable. Qoder CN, Work, and Cloud remain separate profiles. + +Sources: [Skills](https://docs.qoder.com/en/cli/Skills), [MCP](https://docs.qoder.com/en/cli/mcp-servers), [hooks](https://docs.qoder.com/cli/hooks). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/qodo.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/qodo.md new file mode 100644 index 000000000..6a39454a7 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/qodo.md @@ -0,0 +1,7 @@ +# qodo (formerly CodiumAI) + +**Registry status:** `unverified/manual-reference`; the Qodo IDE, CLI, and Merge +product surfaces are not interchangeable and none is an automatic source or +target. MCP UI, TOML agents/modes, custom instructions, and enterprise +allowlists require product- and version-specific manual review. It is not +Codeium/Windsurf. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/qwen-code.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/qwen-code.md new file mode 100644 index 000000000..efffb838a --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/qwen-code.md @@ -0,0 +1,7 @@ +# qwen-code + +Qwen Code uses `~/.qwen/skills/` and `.qwen/skills/` for user and project Skills. MCP is the `mcpServers` subobject in `~/.qwen/settings.json` or `.qwen/settings.json`; HTTP uses `httpUrl`, while legacy SSE uses `url`. + +Preserve Qwen-only Skill activation metadata through the loss report. Never migrate OAuth token stores or automatically generated/archived Skills as hand-authored content. + +Sources: [Skills](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/skills.md), [MCP](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/mcp.md). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/replit.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/replit.md new file mode 100644 index 000000000..edcb2b94c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/replit.md @@ -0,0 +1,21 @@ +# replit (Replit AI) + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | `.agents/skills` | +| Rules | `replit.md` | +| MCP | Not mapped | +| Project MCP | Not mapped | +| Project config | `.replit` | +| Config | Not mapped | + + +- Agent Skills are project-scoped. `.local/secondary_skills/` is compatibility/discovery state, not a merge target. +- `replit.md` is a living Agent-maintained project document; never overwrite it automatically. +- `.replit` and `replit.nix` are application/runtime configuration, not portable AI context. User/enterprise config, prompts, and MCP Integrations are cloud/UI managed and manual. +- Do not infer `~/.replit`, a global Skills directory, or a local MCP file. + +Sources: [replit.md](https://docs.replit.com/features/project-setup/replit-dot-md), [Skills](https://docs.replit.com/features/agent/skills), [configuration](https://docs.replit.com/features/project-setup/configuration), [MCP](https://docs.replit.com/build/connect-via-mcp). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/roo-code.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/roo-code.md new file mode 100644 index 000000000..f0acae189 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/roo-code.md @@ -0,0 +1,21 @@ +# roo-code (archived 2026-05) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.roo/skills` | +| Project skills | `.roo/skills` | +| Rules | `.roorules` | +| MCP | Not mapped | +| Project MCP | `.roo/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Project MCP is `.roo/mcp.json` with JSON `mcpServers`; use explicit project scope. Global MCP is extension-managed and manual—never infer a VS Code/Cline `globalStorage` path. +- The mapper uses Roo-specific Skills paths. `.roorules` is its one-file rules target; scoped/global rule collections, commands, modes, and memory require manual review. +- `.roo/` mixes those surfaces, so whole-project migration is unsupported. +- Roo Code was archived on 2026-05-15. Treat Cline as the verified replacement; do not infer a ZooCode or Kilo Code migration path. + +Sources: [announcement](https://roocodeinc.github.io/Roo-Code/), [Skills](https://roocodeinc.github.io/Roo-Code/features/skills/), [MCP](https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo/), [archive](https://github.com/RooCodeInc/Roo-Code). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/rovodev.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/rovodev.md new file mode 100644 index 000000000..9aeed1ff0 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/rovodev.md @@ -0,0 +1,5 @@ +# rovodev + +Atlassian Rovo Dev provides CLI, IDE, and Cloud surfaces. Supports Agent Skills in `~/.rovodev/skills`, `~/.agents/skills`, `.rovodev/skills`, and `.agents/skills`. Instructions include `~/.rovodev/AGENTS.md` and project `AGENTS.md`/`AGENTS.local.md`. MCP configuration is resolved via `config.yml:mcpConfigPath` probing both `~/.rovodev/mcp.json` and `~/.rovodev/mcp_config.json`. + +Sources: [docs](https://support.atlassian.com/rovo/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/sourcegraph-amp.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/sourcegraph-amp.md new file mode 100644 index 000000000..7945f6464 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/sourcegraph-amp.md @@ -0,0 +1,9 @@ +# sourcegraph-amp + +**Registry status:** `unverified/manual-reference`; it is not an automatic +source or target. Amp has documented Skills, `AGENTS.md`, settings, plugins, +checks, and MCP precedence, but no profile adapter or complete golden-fixture +set exists here yet. Preserve native trust, HTTP/OAuth, plugin, and approval +semantics and reconstruct manually. + +Source: [Amp Owner's Manual](https://ampcode.com/manual). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/supermaven.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/supermaven.md new file mode 100644 index 000000000..3ef84e1b9 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/supermaven.md @@ -0,0 +1,10 @@ +# supermaven + + + +All automatic paths are unsupported. + + +Supermaven is a host-editor completion plugin. `~/.supermaven` is runtime/binary storage and `.supermavenignore` controls indexing, not skills or rules. No portable Supermaven Skills, MCP, prompt, or config contract is documented, so every automatic object is unsupported. + +Sources: [download](https://supermaven.com/download), [Neovim plugin](https://github.com/supermaven-inc/supermaven-nvim#readme), [maintainer issue](https://github.com/supermaven-inc/supermaven-nvim/issues/85). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/tabnine.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/tabnine.md new file mode 100644 index 000000000..b71d0e455 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/tabnine.md @@ -0,0 +1,19 @@ +# tabnine + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | `.tabnine/guidelines` | +| MCP | `~/.tabnine/mcp_servers.json` | +| Project MCP | `.tabnine/mcp_servers.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +**Registry status:** `unverified/manual-reference`; the generated paths are +read-only discovery hints and do not authorize writes. + +MCP JSON uses `mcpServers`; rules are scoped guidelines. No Agent Skills or whole-config contract is documented. Enterprise governance and other Tabnine state remain manual. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/tencent-codebuddy-ide.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/tencent-codebuddy-ide.md new file mode 100644 index 000000000..5531f2cb7 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/tencent-codebuddy-ide.md @@ -0,0 +1,8 @@ +# tencent-codebuddy-ide (standalone IDE) + +**Registry status:** `unverified/manual-reference`; the paths below are +read-only documentation and do not authorize automatic writes. + +MCP is Settings/UI-managed with `mcpServers`, but no portable file is documented. Project rules use `.codebuddy/rules//RULE.mdc`; project Skills use `.codebuddy/skills/`; `CODEBUDDY.md` is context with `AGENTS.md` fallback. Commands, agents, hooks, memory, and user Skills are manual. Do not reuse CodeBuddy CLI paths. + +Sources: [IDE Skills](https://www.codebuddy.cn/docs/ide/Features/Skills), [rules](https://www.codebuddy.cn/docs/ide/User-guide/Rules), [MCP](https://www.codebuddy.cn/docs/ide/User-guide/MCP). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/tencent-codebuddy.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/tencent-codebuddy.md new file mode 100644 index 000000000..dbfae69e8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/tencent-codebuddy.md @@ -0,0 +1,20 @@ +# tencent-codebuddy + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.codebuddy/skills` | +| Project skills | `.codebuddy/skills` | +| Rules | `CODEBUDDY.md` | +| MCP | `~/.codebuddy/.mcp.json` | +| Project MCP | `.mcp.json` | +| Project config | `.codebuddy/settings.json` | +| Config | `~/.codebuddy/settings.json` | + + +- This key is CodeBuddy Code CLI, not the standalone CodeBuddy IDE. Do not reuse CLI paths for IDE UI. +- Recommended user MCP is JSON `mcpServers`; project/legacy precedence is manual. Skills and `CODEBUDDY.md` are documented; settings, local settings, hooks, agents, and memory are separate/manual. +- Never copy generated memory or execute hooks. The mixed `.codebuddy/` namespace is not a whole-project target. + +Sources: [Skills](https://www.codebuddy.cn/docs/cli/skills), [MCP](https://www.codebuddy.cn/docs/cli/mcp), [memory](https://www.codebuddy.cn/docs/cli/memory), [hooks](https://www.codebuddy.cn/docs/cli/hooks). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/tongyi-lingma.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/tongyi-lingma.md new file mode 100644 index 000000000..ad5b7a723 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/tongyi-lingma.md @@ -0,0 +1,3 @@ +# tongyi-lingma (deprecated) + +Legacy Tongyi Lingma is an alias of the Qoder CN profile, not Qoder International. Old `.lingma` state remains source-only/manual; use [qoder-cn](qoder-cn.md) for the current China profile and [qoder](qoder.md) for the international CLI/IDE profiles. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/trae-cn.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/trae-cn.md new file mode 100644 index 000000000..96836ea04 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/trae-cn.md @@ -0,0 +1,22 @@ +# trae-cn + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.trae-cn/skills` | +| Project skills | `.trae/skills` | +| Rules | `.trae/rules` | +| MCP | Not mapped | +| Project MCP | `.trae/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Trae CN is separate from the international build. `.agents/skills/` is optional; `.trae/skills/` wins duplicates, and `.trae/skill-config.json` remains manual. +- Project rules use documented Markdown frontmatter. Project Commands, agents, hooks, and memory have product-specific schemas or scope and remain manual; never copy/run hooks. +- Project MCP is `.trae/mcp.json` with `mcpServers` when enabled. Global MCP is **Settings → MCP Servers**/raw JSON, not a documented portable path. +- **config/argv**: empty/unsupported. Do not infer `~/.trae-cn/argv.json` or settings. `bytedance/trae-agent` is a separate repo-local CLI, not a Trae CN target. +- Do not merge TRAE CLI, Plugin, or Work/Desktop paths into this entry. + +Sources: [Skills](https://docs.trae.cn/ide/skills), [Rules](https://docs.trae.cn/ide/rules), [MCP](https://docs.trae.cn/ide/add-mcp-servers), [Commands](https://docs.trae.cn/ide/slash-commands), [Hooks](https://docs.trae.cn/ide_hook-configuration-reference), [Subagents](https://docs.trae.cn/ide_subagents). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/trae-work.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/trae-work.md new file mode 100644 index 000000000..93923769a --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/trae-work.md @@ -0,0 +1,3 @@ +# trae-work (separate product) + +TRAE Work has no mapper key. Do not reuse Trae IDE/CN `.trae` paths, schemas, cloud state, or whole namespace. Add a mapping only with official file-backed evidence. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/trae.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/trae.md new file mode 100644 index 000000000..b3270748c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/trae.md @@ -0,0 +1,21 @@ +# trae + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.trae/skills` | +| Project skills | `.trae/skills` | +| Rules | `.trae/rules` | +| MCP | Not mapped | +| Project MCP | `.trae/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Project MCP is `.trae/mcp.json` with `mcpServers`; global MCP is managed through **Settings → MCP Servers** and its raw-JSON editor, not a portable file. +- `.agents/skills/` is optional and `.trae/skills/` takes precedence. Project rules use documented frontmatter; Commands, Subagents, Hooks, and Memory need manual scope/schema review. Never copy or run hooks automatically. +- No global CLI, argv, or settings file is documented. Do not infer `~/.trae/argv.json` or `~/.trae/settings.json`. +- **`bytedance/trae-agent`** is a separate CLI with repo-local `trae_config.yaml`/`trae_config.json`; its `mcp_servers` schema is not an IDE target. Do not merge TRAE Work/Desktop/Web, Plugin, or Agent paths here. + +Sources: [MCP](https://docs.trae.ai/ide/model-context-protocol?_lang=en), [Skills](https://docs.trae.ai/ide/skills?_lang=en), [Rules](https://docs.trae.ai/ide/rules?_lang=en), [Hooks](https://docs.trae.ai/ide/automate-actions-with-hooks?_lang=en), [Trae Agent](https://github.com/bytedance/trae-agent). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/ui-only-mcp.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/ui-only-mcp.md new file mode 100644 index 000000000..09b9dd828 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/ui-only-mcp.md @@ -0,0 +1,7 @@ +# UI-managed MCP clients + +Use for iFlyCode (`iflycode`) and the ambiguous Raccoon AI (`raccoon-ai`) name. +No current official, versioned file or UI MCP contract is recorded for either +entry. They are unverified references, not supported migration targets. Bind an +exact vendor product URL and version before adding any profile; until then, do +not invent a path, JSON root key, or portable context surface. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/v0.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/v0.md new file mode 100644 index 000000000..a51f5b64c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/v0.md @@ -0,0 +1,3 @@ +# v0 (Vercel) + +v0 uses web UI MCP connections and exposes an MCP server. Connector auth, availability, and client allowlists are cloud state; guide manual UI configuration. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/vecli.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/vecli.md new file mode 100644 index 000000000..fdcbd517b --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/vecli.md @@ -0,0 +1,6 @@ +# vecli (Volcano Engine) + +**Registry status:** `unverified/manual-reference`. The abbreviated identity is +not bound to a stable official repository, product version, or local context +contract. Vecli is distinct from Trae CLI and the cloud-resource `ve` CLI; do +not use it as a source or target until that identity is resolved. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/visual-studio.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/visual-studio.md new file mode 100644 index 000000000..11dcb190f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/visual-studio.md @@ -0,0 +1,21 @@ +# visual-studio (Visual Studio + GitHub Copilot; Windows only) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.copilot/skills` | +| Project skills | `.github/skills` | +| Rules | `.github/copilot-instructions.md` | +| MCP | windows: `%USERPROFILE%\.mcp.json` | +| Project MCP | `.mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Agent Skills require Visual Studio 2026 18.5+. The documented project locations are `.github/skills/`, `.claude/skills/`, and `.agents/skills/`; the mapper uses `.github/skills/`. Personal equivalents are also discovered, with `~/.copilot/skills/` used here. +- MCP uses `servers`. User MCP is `%USERPROFILE%\.mcp.json`; repository MCP is `.mcp.json`. Visual Studio also discovers `.vs/mcp.json`, `.vscode/mcp.json`, and `.cursor/mcp.json`, but these compatibility inputs are manual to avoid duplicate or user-local state. +- Rules use `.github/copilot-instructions.md`; custom agents use `.github/agents/*.agent.md`. Agents, alternate skill paths, scoped instructions, tool approvals, and IDE-managed trust remain manual. +- Visual Studio is Windows-only. Do not confuse this ID with `vscode` or the `copilot` CLI target. + +Sources: [Skills](https://learn.microsoft.com/en-us/visualstudio/ide/copilot-agent-skills?view=visualstudio), [MCP](https://learn.microsoft.com/en-us/visualstudio/ide/mcp-servers?view=visualstudio), [agents](https://learn.microsoft.com/en-us/visualstudio/ide/copilot-specialized-agents?view=visualstudio), [instructions](https://learn.microsoft.com/en-us/visualstudio/ide/copilot-chat-context?view=visualstudio). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/void-editor.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/void-editor.md new file mode 100644 index 000000000..9ecec707a --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/void-editor.md @@ -0,0 +1,21 @@ +# void-editor + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | `.voidrules` | +| MCP | `~/.void-editor/mcp.json` | +| Project MCP | `.vscode/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Void's repository was archived on 2026-06-02; treat its store as legacy and never copy the whole directory. +- Void MCP JSON uses `mcpServers`; authenticated/header-bearing remote entries are manual because the archived runtime does not reliably pass headers. +- Inherited VS Code project MCP uses `.vscode/mcp.json` with `servers` and is a separate manual surface. `.voidrules` is root plain-text context; global instructions are UI-managed. +- No portable Skills, whole-config, command, agent, hook, or memory path is documented. + +Sources: [repository](https://github.com/voideditor/void), [releases](https://github.com/voideditor/void/releases), [MCP service](https://github.com/voideditor/void/blob/main/src/vs/workbench/contrib/void/common/mcpService.ts). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/vscode.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/vscode.md new file mode 100644 index 000000000..c404fcbd8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/vscode.md @@ -0,0 +1,22 @@ +# vscode (VS Code + GitHub Copilot IDE; not the `copilot` CLI target) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.copilot/skills` | +| Project skills | `.github/skills` | +| Rules | `.github/copilot-instructions.md` | +| MCP | Not mapped | +| Project MCP | `.vscode/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- Workspace MCP is `.vscode/mcp.json` with `servers`. User MCP is active-Profile/UI managed: use **MCP: Open User Configuration**, never guess a default, Insiders, VSCodium, or relocated path. +- Keep explicit `http`/legacy `sse` types, re-authorize OAuth, and fail closed on foreign schemas. This differs from CLI `mcpServers`. +- Project Skills may also live in `.agents/skills/` or `.claude/skills/`; personal aliases are `~/.agents/skills/` and `~/.claude/skills/`. The mapper uses GitHub Copilot's primary locations to avoid duplicate-name precedence surprises. +- Rules include `.github/copilot-instructions.md`, scoped instruction files, `AGENTS.md`, and `CLAUDE.md`; project prompts are `.github/prompts/*.prompt.md`. Agents, hooks, and preview plugins are supported but schema-sensitive and remain manual. +- The `copilot` mapper key is GitHub Copilot CLI, not this VS Code surface. + +Sources: [MCP](https://code.visualstudio.com/docs/agent-customization/mcp-servers), [instructions](https://code.visualstudio.com/docs/agent-customization/custom-instructions), [Skills](https://code.visualstudio.com/docs/agent-customization/agent-skills), [prompts](https://code.visualstudio.com/docs/agent-customization/prompt-files), [plugins](https://code.visualstudio.com/docs/agent-customization/agent-plugins), [profiles](https://code.visualstudio.com/docs/configure/profiles). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/warp-oz.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/warp-oz.md new file mode 100644 index 000000000..d0e18e6fb --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/warp-oz.md @@ -0,0 +1,7 @@ +# warp-oz + +Oz is Warp's current local/cloud agent CLI; `warp-cli` is deprecated. Skills, MCP, and agent profiles are run-level or cloud-managed inputs exposed by `--skill`, `--mcp`, and `--profile` rather than one portable local directory contract. + +Generate a reconstruction plan and exact CLI arguments. Do not copy Warp Drive rules, saved prompts, notebooks, workflows, API keys, or cloud profile state. + +Source: [Oz CLI](https://docs.warp.dev/reference/cli). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/warp.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/warp.md new file mode 100644 index 000000000..33e5cf506 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/warp.md @@ -0,0 +1,5 @@ +# warp + +Warp provides desktop, oz-cli, and cloud profiles. Warp Desktop supports Agent Skills in standard compatibility roots, root and subtree `AGENTS.md` (and legacy `WARP.md`), Global Rules, and MCP (`~/.warp/mcp.json` / `.warp/mcp.json`). Warp Oz CLI provides the Oz command runtime. + +Sources: [docs](https://docs.warp.dev/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/windsurf.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/windsurf.md new file mode 100644 index 000000000..2dfb4a6f8 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/windsurf.md @@ -0,0 +1,21 @@ +# windsurf + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.codeium/windsurf/skills` | +| Project skills | `.windsurf/skills` | +| Rules | `.windsurf/rules` | +| MCP | `~/.codeium/windsurf/mcp_config.json` | +| Project MCP | Not mapped | +| Project config | Not mapped | +| Config | Not mapped | + + +- MCP JSON uses `mcpServers`; remote entries use exactly one documented `serverUrl` or `url`, never a guessed VS Code `type`/transport. +- Current project Rules use `.windsurf/rules/*.md`; `AGENTS.md` is hierarchy-aware plain Markdown. `.devin/rules/` belongs to a different Devin surface and must not be used as Windsurf's canonical target. +- Global Rules use `~/.codeium/windsurf/memories/global_rules.md`. Generated memories share the surrounding runtime namespace but are not portable; never copy them as rules without review. +- Workflows and mixed `.windsurf/` state remain separate. No project MCP or whole-project mapping is claimed. + +Sources: [Skills](https://docs.windsurf.com/windsurf/cascade/skills), [MCP](https://docs.windsurf.com/windsurf/cascade/mcp), [Rules and memories](https://docs.windsurf.com/windsurf/cascade/memories), [AGENTS.md](https://docs.windsurf.com/windsurf/cascade/agents-md). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/workbuddy.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/workbuddy.md new file mode 100644 index 000000000..c8bf4d82f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/workbuddy.md @@ -0,0 +1,20 @@ +# workbuddy (WorkBuddy) + + + +| Object | Documented path | +| --- | --- | +| Global skills | Not mapped | +| Project skills | Not mapped | +| Rules | Not mapped | +| MCP | `~/.workbuddy/mcp.json` | +| Project MCP | `.workbuddy/mcp.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- MCP JSON uses `mcpServers`; documented desktop entries are local command/args/env. Remote headers/transport are unestablished and manual. +- Skills are imported through the UI and use `skill.yml`, implementation files, and a README; no portable global/project Skills directory is published. +- Memory and settings are UI-managed private state. Do not copy generated memory or infer a whole-settings file. + +Sources: [MCP](https://www.workbuddy.ai/docs/zh/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide), [Skills](https://www.workbuddy.ai/docs/zh/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/Skills-Market), [memory](https://www.workbuddy.ai/docs/zh/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/Memory). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/xcode.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/xcode.md new file mode 100644 index 000000000..8edc6e017 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/xcode.md @@ -0,0 +1,10 @@ +# xcode (Xcode coding agents) + +**Registry status:** `unverified/manual-reference`; Xcode is a host and runtime +bridge here, not an automatic context-migration target. + +Xcode 26.3+/27 can host external coding agents and extensions, but its agent-specific data under `~/Library/Developer/Xcode/CodingAssistant/` is isolated implementation state rather than a portable migration target. Do not copy `ClaudeAgentConfig`, `codex`, `gemini`, credentials, sessions, approvals, or caches. + +Use each agent's documented configuration UI or CLI and verify inside Xcode. Xcode exposes IDE capabilities to external agents through `xcrun mcpbridge`; this is a runtime bridge, not an MCP configuration file to migrate. Extension-bundled skills, subagents, and MCP declarations remain package-owned and manual. + +Sources: [customizing agents](https://developer.apple.com/documentation/Xcode/extending-and-customizing-agents), [external agent access](https://developer.apple.com/documentation/Xcode/giving-external-agents-access-to-xcode). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/zcode.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/zcode.md new file mode 100644 index 000000000..feb839eb4 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/zcode.md @@ -0,0 +1,23 @@ +# zcode (Zhipu AI) + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.zcode/skills` | +| Project skills | Not mapped | +| Rules | `AGENTS.md` | +| MCP | `~/.zcode/cli/config.json` | +| Project MCP | `.zcode/config.json` | +| Project config | `.zcode/config.json` | +| Config | `~/.zcode/cli/config.json` | + + +**Registry status:** `unverified/manual-reference`; the generated paths are +read-only discovery hints and ZCode is not an automatic source or target. + +- MCP JSON uses `mcp.servers` (also accepts `mcpServers`). Retain explicit transport; do not silently relabel legacy SSE or copy authorization state. +- Rules use `AGENTS.md`, not `CLAUDE.md`; onboarding import does not change that contract. +- Only global Skills are documented. Commands, agents, hooks, memory, and GUI API-key state are manual; ZCode is not CodeGeeX. + +Sources: [Skills](https://zcode.z.ai/en/docs/skill), [MCP](https://zcode.z.ai/cn/docs/mcp-services), [agents](https://zcode.z.ai/en/docs/agents), [plugins](https://zcode.z.ai/en/docs/plugin). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/zed.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/zed.md new file mode 100644 index 000000000..cd2d8a8ec --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/zed.md @@ -0,0 +1,21 @@ +# zed + + + +| Object | Documented path | +| --- | --- | +| Global skills | `~/.agents/skills` | +| Project skills | `.agents/skills` | +| Rules | `AGENTS.md` | +| MCP | `~/.config/zed/settings.json` | +| Project MCP | `.zed/settings.json` | +| Project config | Not mapped | +| Config | Not mapped | + + +- MCP JSON uses `context_servers`; project settings are manual. Local and remote entries have distinct fields; no generic whole-settings conversion exists. +- Project instructions use first-match compatibility discovery across files including `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, and tool-specific rules; personal instructions use `~/.config/zed/AGENTS.md`. The mapper chooses portable `AGENTS.md` and does not merge competing files. +- Agent Skills use documented paths. Legacy Rules were replaced by Skills plus Instructions; prompts are server-provided, not a prompt-template directory. +- Agent servers, config, and GUI PATH are product-specific; use absolute executable paths when necessary. + +Sources: [instructions](https://zed.dev/docs/ai/instructions), [Skills](https://zed.dev/docs/ai/skills), [MCP](https://zed.dev/docs/ai/mcp). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/zencoder.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/zencoder.md new file mode 100644 index 000000000..9613a380c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/zencoder.md @@ -0,0 +1,5 @@ +# zencoder + +Zencoder IDE provides settings, custom agents, saved prompts, skills, and MCP integrations under `~/.zencoder/settings.json` and `.zencoder/settings.json`. + +Sources: [docs](https://docs.zencoder.ai/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/ides/zenflow.md b/skills/agent-skills-setup/agent-skills-setup/references/ides/zenflow.md new file mode 100644 index 000000000..976b6f37b --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/ides/zenflow.md @@ -0,0 +1,5 @@ +# zenflow + +Zenflow provides workflow automation and scheduled task management with settings, skills, and `.zenflow/workflows`. + +Sources: [docs](https://docs.zenflow.ai/). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/mcp-migration.md b/skills/agent-skills-setup/agent-skills-setup/references/mcp-migration.md new file mode 100644 index 000000000..affbf7b3d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/mcp-migration.md @@ -0,0 +1,40 @@ +# MCP migration + +Use for the profile-aware `mcp` object. The automatic core accepts the reviewed stdio subset of JSON and JSONC server maps. Remote HTTP/SSE requires a dedicated target-profile transport adapter and currently produces reconstruction actions; read [mcp-transport.md](mcp-transport.md) for remote URLs, OAuth, or headers. + +| Target | Automatic shape | Boundary | +| --- | --- | --- | +| Common clients | `mcpServers` | Automatic for reviewed stdio `command`/`args`/`env`; permission and lifecycle fields enter the loss report. | +| Registered JSON/JSONC profiles | Profile-specific root key | Validate command/URL and preserve unrelated keys. | +| TOML/YAML/JSON5/XML/Lua | Dedicated manual adapter | Generate a reviewed reconstruction; never use JSON fallback. | +| Cloud/UI profiles | Rebuild manifest | Use the official API/UI; never invent a local file. | + +Validate command/args/env or URL/headers, apply [migration-safety.md](migration-safety.md), convert only target-supported fields, preserve unrelated settings, parse the target, and emit a credential-free diff. Ambiguous transport, OAuth/session state, unknown schema, and non-automatic adapters remain manual. + +## Sensitive configuration handling + +User-level agent config files are treated as sensitive inputs: + +- **Subobject extraction only.** For `config-subobject` storage — and for + every MCP object at ACB collection time, including shared host settings + files registered with plain `file` storage — the adapter parses and + re-emits only the authorized MCP servers section. Sibling settings — + model choice, approval policy, sandbox, profile state, telemetry — are + never read into plans, bundles, or reports. +- **Trust sections are hard-denied.** Surfaces registered with the + `never-migrate` policy (e.g. the trust block of a host config file) are + excluded before disk collection; no code path can inventory or copy them. +- **Least-privilege reads.** A config file is opened only when its product is + an explicitly named migration source or target. There is no startup scan of + installed products. +- **Secret preflight and redaction.** Every collected object passes the strict + secret scanner before output; credential-looking values in `env`, `args`, + and URLs are dropped to the loss report instead of being migrated. +- **No network.** MCP migration never contacts servers, registries, or update + endpoints; remote-transport entries always produce a manual rebuild. + +~~~bash +bash scripts/smart-ide-migration.sh plan \ + --source cline/ide --target forge/cli --workspace /path/to/project \ + --objects mcp --scope project --output /path/to/plan.json --json +~~~ diff --git a/skills/agent-skills-setup/agent-skills-setup/references/mcp-transport.md b/skills/agent-skills-setup/agent-skills-setup/references/mcp-transport.md new file mode 100644 index 000000000..65e01ab66 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/mcp-transport.md @@ -0,0 +1,11 @@ +# MCP transport and authorization boundaries + +Read for remote MCP, explicit transport, OAuth, headers, or a bare URL. Migrate reviewed client configuration, never runtime protocol or authorization state. + +- Require a target-documented transport; a URL alone is ambiguous. +- Preserve `sse` only as a supported legacy label; never relabel it as HTTP or Streamable HTTP. +- Preserve explicit Streamable HTTP only when the target supports its field. +- Drop protocol headers, session/resumption IDs, handshakes, subscriptions, OAuth tokens, registration, and `autoApprove`/`enabledTools`/`disabledTools`. +- Re-authorize and approve tools in the target client. + +Use [migration-safety.md](migration-safety.md) when conversion or redaction is unclear. Sources: [MCP changes](https://modelcontextprotocol.io/specification/2026-07-28/changelog), [Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http), and [authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization). diff --git a/skills/agent-skills-setup/agent-skills-setup/references/migration-safety.md b/skills/agent-skills-setup/agent-skills-setup/references/migration-safety.md new file mode 100644 index 000000000..9aeeed400 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/migration-safety.md @@ -0,0 +1,36 @@ +# Migration safety and conflicts + +Use before a migration can write. A generic request to migrate or transfer authorizes planning only. Inspect only the explicitly named source, target, objects, scope, and workspace; if any is missing, stop before filesystem inspection. Save the profile-aware plan and show its exact file list/diff or cloud rebuild actions and target paths. Obtain separate explicit user approval before `apply` or `rollback`; `--yes` records that approval but does not replace it. Apply only the reviewed plan: its checksum binds the Registry digest, adapter versions, resolved surfaces, source/target hashes, and Git provenance. Any drift requires a new review. Inventory canonical and compatibility paths; if more than one alternative exists, stop for explicit selection, and if multiple precedence files exist, do not pretend they are one document. Before copying a Skill directory or converting instructions, scan the source and reject likely literal credentials. Reject links outside a Skill root, exclude `.env` and `.env.*`, and preserve the source. + +Apply stages and validates every output before the first target mutation, snapshots every destination, then commits the saved plan as one operation. A failure in any later write or in manifest creation restores every earlier target in reverse order; no partial success is reported. Plan and manifest artifact paths must not overlap the Registry or any selected source/target surface. The manifest is written only after all target hashes are recorded. + +## Device handoff and Agent Context Bundle (ACB) safety + +- **Strict Allowlist Snapshotting (P0-2)**: `snapshot` captures only requested scopes and valid portable objects (skills, instructions, mcp). Policies like `forbidden-regenerate`, `never-migrate`, `source-only`, and objects like `generated_memory`, `session`, `chat`, `runtime`, `database`, `trust`, `approval`, `oauth_state` are strictly blocked before disk read. +- **Sub-Object Field-Level Whitelist**: For `config-subobject` surfaces (e.g. `settings.json` storing `mcpServers` in Augment, Gemini, VS Code, Qoder), snapshot extracts, validates, and serializes ONLY the targeted sub-object slice. Host configuration sibling keys (API keys, provider tokens, telemetry, UI preferences, proxy configs, organizational policies) are never copied into the bundle. +- **Dual-Side Plan Architecture (P0-1)**: `restore` builds a dual-side plan with the verified bundle as `source_registry` and the local host as `target_registry`. Real destination paths, pre-apply states (`exists` -> `replace` vs `create`), semantic diffs, and workspace are evaluated on the destination device and locked into `plan_sha256` before apply. +- **Replayable Restore Plans & TOCTOU State Locks**: Plans saved with `restore --plan-out ` can be replayed and applied via `restore --plan-in --yes` (or `apply --bundle --yes`). Replay verifies bundle integrity, plan checksum, registry checksum, and enforces strict state locks (`expected_source_state` and `expected_target_state`) against destination surfaces. +- **Authoritative Bundle Precedence (P0-3)**: The bundle is always the single source of truth during `restore`. The presence of a local source IDE on the destination device cannot override or bypass bundle content. +- **Strict Handoff Whitelist (P0-4)**: Handoff data serializes only explicitly whitelisted fields (`reviewed_summary`, `git_branch`, `selected_files`, `patch`). Raw logs, conversation histories, tokens, and machine paths are dropped. +- **Closed-World Integrity Verification**: Bundles must pass `bundle-verify` against `checksums.json` and deep secret/binary scans before restore. +- **Plan-Only Review & Execution Safety**: `restore ` (or `--plan-only`) builds and reviews the plan with zero disk writes. Applying requires explicit `--yes`. Extraction into a review tree (`--restore-root `) is opt-in. + +## Surface and runtime boundaries + +- **Plugins & Extensions**: Binary packages and executable plugins are not auto-installed or executed; they are recorded as `draft-disabled` or `manual-rebuild`. +- **Sessions & Runtime State**: Interactive chat logs, runtime tokens, OAuth tokens, and approval grants are strictly non-migratable and excluded. +- **Probes & Diagnostics**: `detect` and `doctor` run local filesystem and binary checks only; network access is forbidden. + +Use [mcp-transport.md](mcp-transport.md) for remote transport, OAuth, or protocol state. The script blanks literal credentials and may translate an exact documented environment reference; mixed or complex expressions need manual reconstruction. MCP target symlinks fail before conversion. Redaction cleanup accepts only the exact target artifacts, while copied-skill cleanup is contained within the canonical target copy root. + +| Strategy | Existing selected object | +| --- | --- | +| `skip` | Leave unchanged. | +| `backup` (default) | Save `.bak.`, then merge. | +| `overwrite` | Replace only the selected object, without backup. | + +For shared MCP configuration, preserve unrelated settings; `overwrite` replaces only the selected server map. Do not invent renamed fallback entries. + +The explicit `legacy` subcommand supports lookup and zero-write dry-runs only. Calls beginning with an implicit legacy flag are rejected. Any `legacy --yes` write fails before the compatibility engine runs; create and apply a saved profile-aware plan instead. + +Restate source, target, objects, scope, workspace, and boundaries. After review, use `apply --yes --json`; report checksums, paths, parse result, source integrity, target evidence, backup, and manual follow-ups. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/object-migration.md b/skills/agent-skills-setup/agent-skills-setup/references/object-migration.md new file mode 100644 index 000000000..c75041e00 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/object-migration.md @@ -0,0 +1,21 @@ +# File-backed object migration + +Read with [migration-safety.md](migration-safety.md) for non-MCP objects. + +| Object | Handling | +| --- | --- | +| Skills | Preflight all source text, then copy credential-free named directories as units. | +| Rules / Instructions | Parse and emit the selected product's native frontmatter; never flatten conditional activation into an unconditional file. | +| Prompts / Commands | Use the target's documented format; review Gemini TOML and UI/enterprise libraries. | +| Workflows | Translate deterministic pipeline steps; review complex orchestrations for target engine support. | +| Config / project | Manual-only; never copy whole config or opaque trees. | +| Agents / Droids | Recreate reviewed content against target permission, event, and command schemas (generated with `draft-disabled` for safety). | +| Hooks | Map lifecycle event triggers; shell execution scripts are generated in `draft-disabled` state. | +| Plugins / Extensions | Non-executable; binaries and package installations are flagged `manual-rebuild` or `draft-disabled`. | +| Sessions / Chat logs | Strictly non-transferable; runtime conversation logs and state are excluded from migration (handoff uses strict whitelist serialization). | +| Memory / Context | Do not copy private/generated state (e.g. `~/.cline/data` generated memory); rewrite selected context as rules. | +| Bundles (ACB) | Package reviewed objects into self-contained, secret-redacted, offline portable archives (`.acb`) under strict allowlists, sub-object field isolation, and replayable dual-side plan binding (`--plan-in`). | + +Treat living or generated files, including Replit `replit.md`, as manual conversation state rather than overwrite targets. When no compatible target format is documented, describe reconstruction instead of an unvalidated copy. There is no generic embedded-config exception: a sub-object is automatic only when Registry v2 names a reviewed source and target adapter for the exact profile/version. + +The reviewed instruction adapters use native fields for Augment (`type`), Cline/Claude (`paths`), Cursor and Continue (`alwaysApply`/`globs`), Kiro (`inclusion`/`fileMatchPattern`), Copilot (`applyTo`), Trae/Qoder (`alwaysApply`), and Windsurf (`trigger`). Unknown frontmatter is reported as loss. A conversion becomes manual when the target cannot preserve `always`, glob, model-decided, or manual activation semantics. diff --git a/skills/agent-skills-setup/agent-skills-setup/references/registry-v2.json b/skills/agent-skills-setup/agent-skills-setup/references/registry-v2.json new file mode 100644 index 000000000..9119d284f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/registry-v2.json @@ -0,0 +1,5365 @@ +{ + "$schema": "./registry-v2.schema.json", + "aliases": { + "claude-desktop": "claude/desktop-chat", + "codeium": "windsurf/ide", + "jetbrains-ai": "jetbrains/ai-assistant", + "trae-cn": "trae/cn-ide", + "visual-studio": "copilot/visual-studio", + "vscode": "copilot/vscode" + }, + "candidate_discovery": { + "auto_promote_threshold": { + "min_detection_probes": 1, + "min_official_sources": 2, + "min_surfaces": 2 + }, + "enabled": false, + "report_format": { + "include_archived_products": true, + "include_new_paths": true, + "include_new_products": true, + "include_renamed_products": true, + "include_schema_changes": true, + "suggest_registry_diff": true + }, + "schedule": "disabled" + }, + "detection_config": { + "installation_states": [ + "installed", + "configured-only", + "compatibility-only", + "cloud-connected", + "legacy", + "ambiguous", + "not-detected" + ], + "platforms": [ + "darwin", + "linux", + "windows", + "wsl", + "remote-ssh", + "dev-container", + "codespaces", + "vscode-profile", + "extension-host" + ], + "probes": { + "app-bundle": { + "description": "macOS app bundle identifier lookup" + }, + "binary": { + "description": "CLI binary lookup with version command" + }, + "cloud-account": { + "description": "Cloud/API account connection check" + }, + "environment": { + "description": "Environment variable override for path" + }, + "file-signature": { + "description": "File path existence with content fingerprint" + }, + "schema-probe": { + "description": "JSON schema validation for config files" + }, + "vscode-extension": { + "description": "VS Code extension marketplace lookup" + } + } + }, + "freshness": { + "candidate_discovery_schedule": "disabled", + "candidate_discovery_sources": [], + "demote_stale_on_detect": true, + "max_age_days": 365, + "online_checks_enabled": false, + "stale_levels": [ + "stale-partial", + "stale-manual", + "stale-source-only" + ] + }, + "object_policies": { + "agent": "manual-template", + "automation": "disabled-draft-only", + "chat": "forbidden-regenerate", + "cloud_knowledge": "official-api-or-rebuild-checklist", + "command": "manual-rebuild", + "config": "explicit-subobject-only", + "cron": "disabled-draft-only", + "database": "forbidden-regenerate", + "generated_memory": "forbidden-regenerate", + "hook": "disabled-draft-only", + "instructions": "semantic-ir-with-loss-report", + "mcp": "profile-version-adapter", + "mode": "manual-template", + "persona": "manual-template", + "plugin": "manual-rebuild", + "policy": "explicit-subobject-only", + "prompt": "manual-rebuild", + "rag_index": "forbidden-regenerate", + "skill": "validate-then-atomic-copy", + "trust": "never-migrate", + "user_memory": "review-then-instructions-ir", + "workflow": "manual-rebuild" + }, + "products": { + "aider": { + "reference": "ides/aider.md", + "template": "manual-reference" + }, + "amazon-q": { + "category": "agent-client", + "default_profile": "ide", + "display_name": "Amazon Q Developer", + "lifecycle": "active", + "profiles": { + "cli": { + "kind": "cli", + "migration_policy": "manual-template", + "sources": [ + "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/qdev-mcp.html" + ], + "surfaces": { + "agents": [ + { + "format": "amazon-q-agent", + "path": "~/.aws/amazonq/cli-agents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + }, + "custom-agent": { + "kind": "custom-agent", + "migration_policy": "manual-template", + "sources": [ + "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/qdev-mcp.html" + ], + "surfaces": { + "agents": [ + { + "format": "amazon-q-agent", + "path": "~/.aws/amazonq/agents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + }, + "ide": { + "kind": "ide-extension", + "mcp_path_note": "Dual paths documented: ~/.aws/amazonq/default.json and ~/.aws/amazonq/agents/default.json - probe both and validate root key/signature", + "migration_policy": "bidirectional-reviewed", + "precedence": [ + "project-default", + "user-default", + "legacy-mcp" + ], + "sources": [ + "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html", + "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/qdev-mcp.html" + ], + "surfaces": { + "instructions": [ + { + "format": "amazon-q-rule", + "path": ".amazonq/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + } + ], + "mcp": [ + { + "compatibility_paths": [ + "~/.aws/amazonq/mcp.json", + "~/.aws/amazonq/agents/default.json" + ], + "format": "json:mcpServers", + "path": "~/.aws/amazonq/default.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "compatibility_paths": [ + ".amazonq/mcp.json", + ".amazonq/agents/default.json" + ], + "format": "json:mcpServers", + "path": ".amazonq/default.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "android-studio": { + "reference": "ides/android-studio.md", + "template": "manual-reference" + }, + "antigravity": { + "reference": "ides/antigravity.md", + "template": "manual-reference" + }, + "augment-code": { + "category": "agent-client", + "default_profile": "cli-ide", + "display_name": "Augment Code", + "lifecycle": "active", + "profiles": { + "cli-ide": { + "kind": "cli-and-ide", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://docs.augmentcode.com/cli/rules", + "https://docs.augmentcode.com/cli/reference", + "https://docs.augmentcode.com/cli/config" + ], + "surfaces": { + "instructions": [ + { + "format": "augment-rule", + "path": "~/.augment/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "augment-rule", + "path": ".augment/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + }, + { + "format": "plain-markdown", + "path": "~/.augment/user-guidelines.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "file" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.augment/settings.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "config-subobject" + }, + { + "format": "json:mcpServers", + "path": ".augment/settings.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "config-subobject" + }, + { + "format": "json:mcpServers", + "path": ".augment/settings.local.json", + "policy": "profile-version-adapter", + "scope": "local", + "storage": "config-subobject" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.augment/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".augment/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "baidu-comate": { + "reference": "ides/baidu-comate.md", + "template": "manual-reference" + }, + "baidu-comate-ide": { + "reference": "ides/baidu-comate-ide.md", + "template": "manual-reference" + }, + "blackbox": { + "reference": "ides/blackbox.md", + "template": "manual-reference" + }, + "bolt-new": { + "reference": "ides/bolt-new.md", + "template": "cloud-ui" + }, + "claude": { + "category": "agent-suite", + "default_profile": "code-cli", + "display_name": "Claude", + "lifecycle": "active", + "profiles": { + "code-cli": { + "detection": [ + { + "command": [ + "claude" + ], + "type": "binary", + "version_command": [ + "claude", + "--version" + ] + }, + { + "paths": [ + "~/.claude/skills", + "CLAUDE.md" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.claude/skills", + "linux": "~/.claude/skills", + "wsl": "~/.claude/skills" + }, + "sources": [ + "https://code.claude.com/docs/en/settings", + "https://code.claude.com/docs/en/skills", + "https://code.claude.com/docs/en/mcp" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "CLAUDE.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.claude.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.claude/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".claude/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-15", + "version_range": "current as of 2026-08-15" + }, + "desktop-chat": { + "kind": "desktop-chat", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://support.claude.com/en/articles/12512180-use-skills-in-claude" + ], + "surfaces": {}, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + }, + "desktop-code": { + "detection": [ + { + "darwin_bundle_id": "com.anthropic.claude-code", + "type": "app-bundle" + }, + { + "paths": [ + "~/.claude/skills", + "CLAUDE.md" + ], + "type": "file-signature" + } + ], + "kind": "desktop-coding", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.claude", + "linux": "~/.claude", + "wsl": "~/.claude" + }, + "sources": [ + "https://code.claude.com/docs/en/desktop" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "CLAUDE.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.claude.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.claude/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".claude/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-17", + "version_range": "current as of 2026-08-16" + } + } + }, + "claude-desktop": { + "alias_of": { + "product": "claude", + "profile": "desktop-chat" + }, + "reference": "ides/claude-desktop.md", + "template": "legacy-alias" + }, + "cline": { + "category": "agent-client", + "default_profile": "ide", + "display_name": "Cline", + "lifecycle": "active", + "profiles": { + "cli": { + "inherits": "ide", + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://docs.cline.bot/getting-started/config" + ], + "verified_at": "2026-08-12" + }, + "ide": { + "detection": [ + { + "command": [ + "cline" + ], + "type": "binary", + "version_command": [ + "cline", + "--version" + ] + }, + { + "paths": [ + "~/.cline/skills", + "~/.clinerules" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.cline/skills", + "linux": "~/.cline/skills", + "windows": "$APPDATA/cline/skills" + }, + "precedence": [ + "project", + "user", + "compatibility" + ], + "sources": [ + "https://docs.cline.bot/getting-started/config" + ], + "surfaces": { + "agents": [ + { + "format": "cline-agent", + "path": "~/.cline/agents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "cline-agent", + "path": ".cline/agents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "cron": [ + { + "format": "cline-cron", + "path": "~/.cline/cron", + "policy": "disabled-draft-only", + "scope": "user", + "storage": "directory" + }, + { + "format": "cline-cron", + "path": ".cline/cron", + "policy": "disabled-draft-only", + "scope": "project", + "storage": "directory" + } + ], + "generated_memory": [ + { + "path": "~/.cline/data", + "policy": "forbidden-regenerate", + "scope": "runtime", + "storage": "database" + } + ], + "hooks": [ + { + "format": "cline-hook", + "path": "~/.cline/hooks", + "policy": "disabled-draft-only", + "scope": "user", + "storage": "directory" + }, + { + "format": "cline-hook", + "path": ".cline/hooks", + "policy": "disabled-draft-only", + "scope": "project", + "storage": "directory" + } + ], + "instructions": [ + { + "compatibility_paths": [ + "~/Documents/Cline/Rules" + ], + "format": "cline-rule", + "path": "~/.cline/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "compatibility_paths": [ + ".clinerules" + ], + "format": "cline-rule", + "path": ".cline/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "override_env": "CLINE_DATA_DIR", + "override_relative_path": "settings/cline_mcp_settings.json", + "path": "~/.cline/data/settings/cline_mcp_settings.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".cline/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "plugins": [ + { + "format": "cline-plugin", + "path": "~/.cline/plugins", + "policy": "manual-rebuild", + "scope": "user", + "storage": "directory" + }, + { + "format": "cline-plugin", + "path": ".cline/plugins", + "policy": "manual-rebuild", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.cline/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".cline/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "workflows": [ + { + "format": "cline-workflow", + "path": "~/.cline/data/workflows", + "policy": "manual-rebuild", + "scope": "user", + "storage": "directory" + }, + { + "format": "cline-workflow", + "path": ".cline/workflows", + "policy": "manual-rebuild", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + }, + "sdk": { + "detection": [ + { + "command": [ + "cline" + ], + "type": "binary", + "version_command": [ + "cline", + "--version" + ] + }, + { + "paths": [ + "~/.cline/skills", + "~/.cline/rules" + ], + "type": "file-signature" + } + ], + "kind": "sdk", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.cline", + "linux": "~/.cline", + "wsl": "~/.cline" + }, + "sources": [ + "https://docs.cline.bot/getting-started/config" + ], + "surfaces": { + "instructions": [ + { + "format": "cline-rule", + "path": "~/.cline/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "cline-rule", + "path": ".cline/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.cline/data/settings/cline_mcp_settings.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".cline/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.cline/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".cline/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-17", + "version_range": "current as of 2026-08-17" + } + } + }, + "codecompanion-nvim": { + "reference": "ides/codecompanion-nvim.md", + "template": "manual-reference" + }, + "codeium": { + "alias_of": { + "product": "windsurf", + "profile": "ide" + }, + "reference": "ides/codeium.md", + "template": "legacy-alias" + }, + "codely": { + "reason": "Product surfaces and MCP schema are not version-bound", + "reference": "ides/codely.md", + "sources": [ + "https://developer.unity.cn/projects/6a7189bcedbc2a120fe6d4af", + "https://codely-docs.tuanjie.cn/learn/ai-programming-environment-setup-guide" + ], + "template": "manual-reference", + "verified_at": "2026-08-13" + }, + "codex": { + "category": "coding-agent", + "default_profile": "cli", + "display_name": "OpenAI Codex", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "codex" + ], + "type": "binary", + "version_command": [ + "codex", + "--version" + ] + }, + { + "paths": [ + "~/.codex/AGENTS.md", + "~/.codex/skills" + ], + "type": "file-signature" + } + ], + "kind": "cli-and-desktop-runtime", + "migration_policy": "bidirectional-reviewed", + "precedence": [ + "nearest-project-override", + "project", + "user-override", + "user" + ], + "sources": [ + "https://developers.openai.com/codex/skills", + "https://learn.chatgpt.com/docs/agent-configuration/agents-md.md", + "https://learn.chatgpt.com/docs/config-file/config-basic.md" + ], + "surfaces": { + "agents": [ + { + "format": "toml", + "path": ".codex/agents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "generated_memory": [ + { + "format": "codex-memory", + "path": "~/.codex/memories", + "policy": "forbidden-regenerate", + "scope": "user", + "storage": "generated-state" + } + ], + "hooks": [ + { + "format": "toml:hooks", + "path": "~/.codex/config.toml", + "policy": "disabled-draft-only", + "scope": "user", + "storage": "config-subobject" + }, + { + "format": "toml:hooks", + "path": ".codex/config.toml", + "policy": "disabled-draft-only", + "scope": "project", + "storage": "config-subobject" + } + ], + "instructions": [ + { + "compatibility_behavior": "precedence", + "compatibility_paths": [ + "~/.codex/AGENTS.override.md" + ], + "format": "agents-md", + "path": "~/.codex/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "precedence-files" + }, + { + "compatibility_behavior": "precedence", + "compatibility_paths": [ + "AGENTS.override.md" + ], + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "toml:mcp_servers", + "path": "~/.codex/config.toml", + "policy": "manual-template", + "scope": "user", + "storage": "config-subobject" + }, + { + "format": "toml:mcp_servers", + "path": ".codex/config.toml", + "policy": "manual-template", + "scope": "project", + "storage": "config-subobject" + } + ], + "rules": [ + { + "format": "codex-rule", + "path": ".codex/rules", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "hierarchy" + } + ], + "trust": [ + { + "path": "~/.codex/config.toml", + "policy": "never-migrate", + "scope": "user+managed", + "storage": "config" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "cody": { + "reference": "ides/cody.md", + "template": "cloud-ui" + }, + "continue": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Continue", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "continue" + ], + "type": "binary", + "version_command": [ + "continue", + "--version" + ] + }, + { + "paths": [ + "~/.continue/skills", + "~/.continue/config.yaml" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.continue", + "linux": "~/.continue", + "wsl": "~/.continue" + }, + "sources": [ + "https://continue.dev/docs/skills" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.continue/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".continue/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "copilot": { + "category": "agent-suite", + "default_profile": "cli", + "display_name": "GitHub Copilot", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "gh", + "copilot" + ], + "type": "binary", + "version_command": [ + "gh", + "copilot", + "--version" + ] + }, + { + "paths": [ + "~/.github/copilot", + "~/.copilot" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.github/copilot", + "linux": "~/.github/copilot", + "wsl": "~/.github/copilot" + }, + "sources": [ + "https://docs.github.com/en/copilot/how-tos/copilot-cli" + ], + "surfaces": { + "instructions": [ + { + "format": "copilot-instructions", + "path": "~/.github/copilot/instructions.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "copilot-instructions", + "path": ".github/copilot/instructions.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.copilot/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".github/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "github-com": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" + ], + "surfaces": {}, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + }, + "visual-studio": { + "detection": [ + { + "id": "github.copilot", + "type": "vscode-extension" + }, + { + "paths": [ + "%APPDATA%/Microsoft/VisualStudio/Copilot" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "windows": "%APPDATA%/Microsoft/VisualStudio/Copilot" + }, + "sources": [ + "https://learn.microsoft.com/en-us/visualstudio/ide/copilot-agent-skills" + ], + "surfaces": { + "instructions": [ + { + "format": "copilot-instructions", + "path": "%APPDATA%/Microsoft/VisualStudio/Copilot/instructions.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "copilot-instructions", + "path": "Copilot/instructions.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "%APPDATA%/Microsoft/VisualStudio/Copilot/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": "Copilot/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "vscode": { + "detection": [ + { + "id": "github.copilot", + "type": "vscode-extension" + }, + { + "paths": [ + "~/.vscode/extensions/github.copilot-*" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.vscode/extensions/github.copilot-*", + "dev-container": "~/.vscode/extensions/github.copilot-*", + "linux": "~/.vscode/extensions/github.copilot-*", + "windows": "%USERPROFILE%/.vscode/extensions/github.copilot-*", + "wsl": "~/.vscode/extensions/github.copilot-*" + }, + "sources": [ + "https://code.visualstudio.com/docs/agent-customization/agent-skills" + ], + "surfaces": { + "instructions": [ + { + "format": "copilot-instructions", + "path": "~/.vscode/copilot/instructions.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "copilot-instructions", + "path": ".github/copilot/instructions.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.vscode/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".vscode/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.copilot/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".github/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "crush": { + "category": "coding-agent", + "default_profile": "cli", + "display_name": "Crush", + "lifecycle": "active", + "profiles": { + "cli": { + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "precedence": [ + "project-dot", + "project", + "user" + ], + "sources": [ + "https://github.com/charmbracelet/crush" + ], + "surfaces": { + "generated_memory": [ + { + "override_env": "CRUSH_GLOBAL_DATA", + "path": "~/.local/share/crush/crush.json", + "policy": "forbidden-regenerate", + "scope": "user", + "storage": "generated-state" + } + ], + "mcp": [ + { + "format": "json:mcp", + "override_env": "CRUSH_GLOBAL_CONFIG", + "path": "~/.config/crush/crush.json", + "policy": "manual-template", + "scope": "user", + "storage": "config-subobject" + }, + { + "compatibility_paths": [ + "crush.json" + ], + "format": "json:mcp", + "path": ".crush.json", + "policy": "manual-template", + "scope": "project", + "storage": "config-subobject" + } + ], + "skills": [ + { + "compatibility_paths": [ + "~/.agents/skills", + "~/.claude/skills" + ], + "format": "agent-skill", + "override_env": "CRUSH_SKILLS_DIR", + "path": "~/.config/crush/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "compatibility_paths": [ + ".agents/skills", + ".claude/skills", + ".cursor/skills" + ], + "format": "agent-skill", + "path": ".crush/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "cursor": { + "category": "ide-extension", + "default_profile": "ide", + "display_name": "Cursor", + "lifecycle": "active", + "profiles": { + "ide": { + "detection": [ + { + "darwin_bundle_id": "com.todesclient.ios", + "type": "app-bundle" + }, + { + "paths": [ + "~/.cursor/skills", + "~/.cursor/rules" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.cursor/skills", + "linux": "~/.cursor/skills", + "windows": "$APPDATA/Cursor/skills" + }, + "sources": [ + "https://docs.cursor.com/context/rules", + "https://docs.cursor.com/context/skills", + "https://docs.cursor.com/context/mcp" + ], + "surfaces": { + "hooks": [ + { + "format": "cursor-hook", + "path": "~/.cursor/hooks", + "policy": "disabled-draft-only", + "scope": "user", + "storage": "directory" + }, + { + "format": "cursor-hook", + "path": ".cursor/hooks", + "policy": "disabled-draft-only", + "scope": "project", + "storage": "directory" + } + ], + "instructions": [ + { + "format": "cursor-mdc", + "path": "~/.cursor/rules", + "policy": "manual-rebuild", + "scope": "user", + "storage": "directory" + }, + { + "format": "cursor-mdc", + "path": ".cursor/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.cursor/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".cursor/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.cursor/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".cursor/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-15", + "version_range": "current as of 2026-08-15" + } + } + }, + "devin": { + "category": "agent-suite", + "default_profile": "terminal", + "display_name": "Devin", + "lifecycle": "active", + "profiles": { + "cloud": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://app.devin.ai" + ], + "surfaces": {}, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "terminal": { + "detection": [ + { + "command": [ + "devin" + ], + "type": "binary", + "version_command": [ + "devin", + "--version" + ] + }, + { + "paths": [ + "~/.devin/skills", + "~/.devin/sessions" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.devin", + "linux": "~/.devin" + }, + "sources": [ + "https://docs.devin.ai/cli" + ], + "surfaces": { + "handoff": [ + { + "format": "devin-session", + "path": "~/.devin/sessions", + "policy": "manual-template", + "scope": "user", + "storage": "file" + } + ], + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.devin/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".devin/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "emacs": { + "reference": "ides/emacs.md", + "template": "host-editor" + }, + "factory-droid": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Factory Droid", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "droid" + ], + "type": "binary", + "version_command": [ + "droid", + "--version" + ] + }, + { + "paths": [ + "~/.factory/skills", + "~/.factory/plugins", + "~/.factory/droids" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.factory", + "linux": "~/.factory" + }, + "sources": [ + "https://factory.ai/docs/plugins" + ], + "surfaces": { + "agents": [ + { + "format": "factory-droid", + "path": "~/.factory/droids", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "factory-droid", + "path": ".factory/droids", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "commands": [ + { + "format": "factory-command", + "path": "~/.factory/commands", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "factory-command", + "path": ".factory/commands", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "hooks": [ + { + "format": "factory-hooks", + "path": "~/.factory/hooks/hooks.json", + "policy": "disabled-draft-only", + "scope": "user", + "storage": "file" + }, + { + "format": "factory-hooks", + "path": ".factory/hooks/hooks.json", + "policy": "disabled-draft-only", + "scope": "project", + "storage": "file" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.factory/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".factory/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "plugins": [ + { + "format": "factory-plugin", + "path": "~/.factory/plugins", + "policy": "preserve-package", + "scope": "user", + "storage": "directory" + }, + { + "format": "factory-plugin", + "path": ".factory/plugins", + "policy": "preserve-package", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.factory/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".factory/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "firebase-studio": { + "category": "cloud-ide", + "default_profile": "legacy-workspace", + "display_name": "Firebase Studio", + "lifecycle": "sunsetting-2027-03-22", + "profiles": { + "legacy-workspace": { + "kind": "cloud-ide", + "migration_policy": "source-only", + "sources": [ + "https://firebase.google.com/docs/studio/get-started", + "https://firebase.google.com/docs/studio/mcp-servers" + ], + "surfaces": { + "instructions": [ + { + "format": "plain-markdown", + "path": ".idx/airules.md", + "policy": "source-only", + "scope": "project", + "storage": "file" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": ".idx/mcp.json", + "policy": "source-only", + "scope": "project", + "storage": "file" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "existing workspaces until 2027-03-22" + } + } + }, + "forge": { + "category": "coding-agent", + "default_profile": "cli", + "display_name": "ForgeCode", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "forge" + ], + "type": "binary", + "version_command": [ + "forge", + "--version" + ] + }, + { + "paths": [ + "~/.forge/skills", + "~/.agents/skills" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.forge/skills", + "extension-host": "~/.forge/skills", + "linux": "~/.forge/skills", + "remote-ssh": "~/.forge/skills", + "wsl": "~/.forge/skills" + }, + "precedence": [ + "project", + "agents", + "user", + "built-in" + ], + "sources": [ + "https://forgecode.dev/docs/skills/", + "https://forgecode.dev/docs/mcp-integration/", + "https://forgecode.dev/docs/forgecode-config/" + ], + "surfaces": { + "config": [ + { + "format": "toml", + "path": ".forge.toml", + "policy": "explicit-subobject-only", + "scope": "project", + "storage": "file" + }, + { + "format": "toml", + "path": "~/.forge/.forge.toml", + "policy": "explicit-subobject-only", + "scope": "user", + "storage": "file" + } + ], + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": ".mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": ".forge/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": "~/.agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "shared-user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": "~/forge/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "gemini-cli": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Gemini CLI", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "gemini" + ], + "type": "binary", + "version_command": [ + "gemini", + "--version" + ] + }, + { + "paths": [ + "~/.gemini/AGENTS.md", + "~/.gemini/skills", + "~/.gemini/commands" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.gemini", + "linux": "~/.gemini", + "wsl": "~/.gemini" + }, + "sources": [ + "https://github.com/google-gemini/gemini-cli" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "GEMINI.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.gemini/settings.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".gemini/settings.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.gemini/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".gemini/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "gemini-code-assist": { + "category": "ide-extension", + "default_profile": "vscode", + "display_name": "Gemini Code Assist", + "lifecycle": "active", + "profiles": { + "enterprise": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://cloud.google.com/gemini-code-assist/docs/enterprise" + ], + "surfaces": { + "policies": [ + { + "format": "json", + "path": "~/.gemini-code-assist/enterprise-policies.json", + "policy": "manual-template", + "scope": "enterprise", + "storage": "file" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "jetbrains": { + "detection": [ + { + "id": "com.google.gemini.code.assist", + "type": "vscode-extension" + }, + { + "paths": [ + "~/.config/JetBrains/GeminiCodeAssist/skills", + "~/.config/JetBrains/GeminiCodeAssist/prompts" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.config/JetBrains/GeminiCodeAssist", + "linux": "~/.config/JetBrains/GeminiCodeAssist", + "windows": "%APPDATA%/JetBrains/GeminiCodeAssist" + }, + "sources": [ + "https://plugins.jetbrains.com/plugin/gemini-code-assist" + ], + "surfaces": { + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.config/JetBrains/GeminiCodeAssist/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".gemini-code-assist/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "prompt_library": [ + { + "format": "gemini-prompt-library", + "path": "~/.config/JetBrains/GeminiCodeAssist/prompt-library", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "gemini-prompt-library", + "path": ".gemini-code-assist/prompt-library", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "prompts": [ + { + "format": "gemini-prompt", + "path": "~/.config/JetBrains/GeminiCodeAssist/prompts", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "gemini-prompt", + "path": ".gemini-code-assist/prompts", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.config/JetBrains/GeminiCodeAssist/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".gemini-code-assist/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "vscode": { + "detection": [ + { + "id": "google.gemini-code-assist", + "type": "vscode-extension" + }, + { + "paths": [ + "~/.gemini-code-assist/skills", + "~/.gemini-code-assist/prompts" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.gemini-code-assist", + "linux": "~/.gemini-code-assist", + "windows": "%APPDATA%/GeminiCodeAssist", + "wsl": "~/.gemini-code-assist" + }, + "sources": [ + "https://cloud.google.com/gemini-code-assist/docs/ide" + ], + "surfaces": { + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.gemini-code-assist/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".gemini-code-assist/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "prompt_library": [ + { + "format": "gemini-prompt-library", + "path": "~/.gemini-code-assist/prompt-library", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "gemini-prompt-library", + "path": ".gemini-code-assist/prompt-library", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "prompts": [ + { + "format": "gemini-prompt", + "path": "~/.gemini-code-assist/prompts", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "gemini-prompt", + "path": ".gemini-code-assist/prompts", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.gemini-code-assist/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".gemini-code-assist/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "gitlab-duo": { + "category": "agent-suite", + "default_profile": "vscode", + "display_name": "GitLab Duo Agent Platform", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "glab" + ], + "type": "binary", + "version_command": [ + "glab", + "--version" + ] + }, + { + "paths": [ + "~/.gitlab/duo/AGENTS.md" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.gitlab/duo", + "linux": "~/.gitlab/duo", + "wsl": "~/.gitlab/duo" + }, + "sources": [ + "https://docs.gitlab.com/ee/user/duo_agent_platform/cli.html" + ], + "surfaces": { + "flows": [ + { + "format": "gitlab-flow", + "path": ".gitlab/duo/flows.yaml", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.gitlab/duo/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".gitlab/duo/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "flows": { + "detection": [ + { + "paths": [ + ".gitlab/duo/flows.yaml" + ], + "type": "file-signature" + } + ], + "kind": "workflow", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.gitlab/duo", + "linux": "~/.gitlab/duo", + "wsl": "~/.gitlab/duo" + }, + "sources": [ + "https://docs.gitlab.com/ee/user/duo_agent_platform/flows.html" + ], + "surfaces": { + "flows": [ + { + "format": "gitlab-flow", + "path": ".gitlab/duo/flows.yaml", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ] + }, + "verified_at": "2026-08-17", + "version_range": "current as of 2026-08-16" + }, + "jetbrains": { + "detection": [ + { + "id": "gitlab.gitlab-duo", + "type": "vscode-extension" + }, + { + "paths": [ + "~/.config/JetBrains/GitLabDuo/AGENTS.md" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.config/JetBrains/GitLabDuo", + "linux": "~/.config/JetBrains/GitLabDuo", + "windows": "%APPDATA%/JetBrains/GitLabDuo" + }, + "sources": [ + "https://plugins.jetbrains.com/plugin/18657-gitlab-duo" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "~/.config/JetBrains/GitLabDuo/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.config/JetBrains/GitLabDuo/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".gitlab/duo/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "vscode": { + "detection": [ + { + "id": "gitlab.gitlab-duo", + "type": "vscode-extension" + }, + { + "paths": [ + "~/.gitlab/duo/AGENTS.md", + "~/.config/gitlab/duo/AGENTS.md" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.gitlab/duo", + "dev-container": "~/.gitlab/duo", + "linux": "~/.gitlab/duo", + "wsl": "~/.gitlab/duo" + }, + "sources": [ + "https://docs.gitlab.com/ee/user/duo_agent_platform/" + ], + "surfaces": { + "flows": [ + { + "format": "gitlab-flow", + "path": ".gitlab/duo/flows.yaml", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "instructions": [ + { + "format": "agents-md", + "path": "~/.gitlab/duo/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.gitlab/duo/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".gitlab/duo/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "web": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://gitlab.com/-/duo" + ], + "surfaces": {}, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "goose-cli": { + "reference": "ides/goose-cli.md", + "template": "manual-reference" + }, + "gptel-mcp-el": { + "reference": "ides/gptel-mcp-el.md", + "template": "manual-reference" + }, + "helix": { + "reference": "ides/helix.md", + "template": "host-editor" + }, + "hermes": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Hermes Agent", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "hermes" + ], + "type": "binary", + "version_command": [ + "hermes", + "--version" + ] + }, + { + "paths": [ + "~/.hermes/skills", + "~/.hermes/memories" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.hermes", + "linux": "~/.hermes", + "wsl": "~/.hermes" + }, + "sources": [ + "https://github.com/hermes-agent/hermes" + ], + "surfaces": { + "acp": [ + { + "format": "hermes-acp", + "path": "~/.hermes/acp.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "hermes-acp", + "path": ".hermes/acp.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "cron": [ + { + "format": "hermes-cron", + "path": "~/.hermes/cron.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "hermes-cron", + "path": ".hermes/cron.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.hermes/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".hermes/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "memory": [ + { + "format": "hermes-memory", + "path": "~/.hermes/memories", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "hermes-memory", + "path": ".hermes/memories", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.hermes/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".hermes/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "subagents": [ + { + "format": "hermes-subagent", + "path": "~/.hermes/subagents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "hermes-subagent", + "path": ".hermes/subagents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "ibm-bob": { + "category": "agent-suite", + "default_profile": "ide", + "display_name": "IBM Bob", + "lifecycle": "active", + "profiles": { + "ide": { + "kind": "ide-extension", + "mcp_path_note": "IDE global MCP path differs from Shell; see profiles.", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://www.ibm.com/docs/en/bob" + ], + "surfaces": { + "instructions": [ + { + "format": "plain-markdown", + "path": "~/.bob/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "plain-markdown", + "path": "~/.bob/rules-agent", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "plain-markdown", + "path": "~/.bob/rules-plan", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "plain-markdown", + "path": "~/.bob/rules-ask", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.bob/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".bob/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.bob/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".bob/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-15", + "version_range": "current as of 2026-08-15" + }, + "shell": { + "kind": "cli", + "mcp_path_note": "Shell uses ~/.bob/mcp_settings.json (not ~/.bob/mcp.json).", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://www.ibm.com/docs/en/bob/shell" + ], + "surfaces": { + "instructions": [ + { + "format": "plain-markdown", + "path": "~/.bob/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.bob/mcp_settings.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".bob/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.bob/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".bob/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-15", + "version_range": "current as of 2026-08-15" + } + } + }, + "iflycode": { + "reference": "ides/ui-only-mcp.md", + "template": "manual-reference" + }, + "jetbrains": { + "category": "ide-suite", + "default_profile": "junie", + "display_name": "JetBrains", + "lifecycle": "active", + "profiles": { + "ai-assistant": { + "detection": [ + { + "paths": [ + "~/.config/JetBrains/AI-Assistant/skills" + ], + "type": "file-signature" + } + ], + "kind": "ide-assistant", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.config/JetBrains/AI-Assistant", + "linux": "~/.config/JetBrains/AI-Assistant", + "windows": "%APPDATA%/JetBrains/AI-Assistant" + }, + "sources": [ + "https://www.jetbrains.com/help/ai-assistant/agent-skills.html" + ], + "surfaces": { + "skills": [ + { + "format": "agent-skill", + "path": "~/.config/JetBrains/AI-Assistant/skills", + "policy": "manual-rebuild", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "junie": { + "detection": [ + { + "command": [ + "junie" + ], + "type": "binary", + "version_command": [ + "junie", + "--version" + ] + }, + { + "paths": [ + "~/.config/JetBrains/Junie/skills", + "~/.junie/skills" + ], + "type": "file-signature" + } + ], + "kind": "ide-agent", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.config/JetBrains/Junie", + "linux": "~/.config/JetBrains/Junie", + "windows": "%APPDATA%/JetBrains/Junie", + "wsl": "~/.config/JetBrains/Junie" + }, + "sources": [ + "https://junie.jetbrains.com/docs/agent-skills.html" + ], + "surfaces": { + "skills": [ + { + "format": "agent-skill", + "path": "~/.junie/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".junie/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "jetbrains-ai": { + "alias_of": { + "product": "jetbrains", + "profile": "ai-assistant" + }, + "reference": "ides/jetbrains-ai.md", + "template": "legacy-alias" + }, + "jules": { + "category": "agent-suite", + "default_profile": "cloud", + "display_name": "Google Jules", + "lifecycle": "active", + "profiles": { + "api": { + "kind": "cloud-api", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://cloud.google.com/jules/docs/api" + ], + "surfaces": {}, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "cli": { + "detection": [ + { + "command": [ + "jules" + ], + "type": "binary", + "version_command": [ + "jules", + "--version" + ] + }, + { + "paths": [ + "~/.jules/config.yaml" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.jules", + "linux": "~/.jules", + "wsl": "~/.jules" + }, + "sources": [ + "https://cloud.google.com/jules/docs/cli" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "cloud": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://jules.google.com" + ], + "surfaces": { + "environment_setup": [ + { + "format": "bash", + "path": ".jules/setup.sh", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "environment_snapshot": [ + { + "format": "json", + "path": ".jules/snapshot.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "schedules": [ + { + "format": "yaml", + "path": ".jules/schedules.yaml", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "kilocode": { + "reference": "ides/kilocode.md", + "template": "manual-reference" + }, + "kimiai": { + "reference": "ides/kimiai.md", + "template": "manual-reference" + }, + "kiro": { + "category": "ide-agent", + "default_profile": "ide", + "display_name": "Kiro", + "lifecycle": "active", + "profiles": { + "ide": { + "detection": [ + { + "command": [ + "kiro" + ], + "type": "binary", + "version_command": [ + "kiro", + "--version" + ] + }, + { + "paths": [ + "~/.kiro/skills", + "~/.kiro/steering" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.kiro", + "linux": "~/.kiro", + "windows": "%APPDATA%/Kiro", + "wsl": "~/.kiro" + }, + "sources": [ + "https://kiro.dev/docs" + ], + "surfaces": { + "instructions": [ + { + "format": "kiro-steering", + "path": "~/.kiro/steering", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "kiro-steering", + "path": ".kiro/steering", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.kiro/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".kiro/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "letta": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Letta Code", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "letta" + ], + "type": "binary", + "version_command": [ + "letta", + "--version" + ] + }, + { + "paths": [ + "~/.letta/skills", + "~/.letta/agents" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.letta", + "linux": "~/.letta", + "wsl": "~/.letta" + }, + "sources": [ + "https://docs.letta.com/agents/skills" + ], + "surfaces": { + "agentfile": [ + { + "format": "letta-agentfile", + "path": "~/.letta/agent.af", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "letta-agentfile", + "path": ".letta/agent.af", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "memory": [ + { + "format": "letta-memory", + "path": "~/.letta/editable_memory", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "letta-memory", + "path": ".letta/editable_memory", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "scheduling": [ + { + "format": "letta-scheduler", + "path": "~/.letta/scheduler.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "letta-scheduler", + "path": ".letta/scheduler.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.letta/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".letta/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "subagents": [ + { + "format": "letta-subagent", + "path": "~/.letta/subagents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "letta-subagent", + "path": ".letta/subagents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "letta-code": { + "category": "agent-client", + "default_profile": "desktop-cli", + "display_name": "Letta Code", + "lifecycle": "active", + "profiles": { + "agent-file": { + "detection": [ + { + "paths": [ + "~/.letta/agents.af", + ".letta/agents.af" + ], + "type": "file-signature" + } + ], + "kind": "agent-file", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.letta", + "linux": "~/.letta", + "wsl": "~/.letta" + }, + "sources": [ + "https://docs.letta.ai/agent-file" + ], + "surfaces": { + "agent_file": [ + { + "format": "letta-agent-file", + "path": "~/.letta/agents.af", + "policy": "preserve-package", + "scope": "user", + "storage": "file" + }, + { + "format": "letta-agent-file", + "path": ".letta/agents.af", + "policy": "preserve-package", + "scope": "project", + "storage": "file" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "cloud": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://app.letta.com" + ], + "surfaces": {}, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "desktop-cli": { + "detection": [ + { + "command": [ + "letta" + ], + "type": "binary", + "version_command": [ + "letta", + "--version" + ] + }, + { + "paths": [ + "~/.letta/skills", + "~/.letta/memories", + "~/.letta/agents" + ], + "type": "file-signature" + } + ], + "kind": "desktop-cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.letta", + "linux": "~/.letta", + "wsl": "~/.letta" + }, + "sources": [ + "https://docs.letta.ai/letta-code" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "~/.letta/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "memory": [ + { + "format": "letta-memory", + "path": "~/.letta/memories", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "letta-memory", + "path": ".letta/memories", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.letta/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".letta/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "subagents": [ + { + "format": "letta-agent", + "path": "~/.letta/agents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "letta-agent", + "path": ".letta/agents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "lovable": { + "reference": "ides/lovable.md", + "template": "cloud-ui" + }, + "mcphub-nvim": { + "reference": "ides/mcphub-nvim.md", + "template": "manual-reference" + }, + "minimax-code": { + "reference": "ides/minimax-code.md", + "template": "manual-reference" + }, + "mistral-vibe": { + "category": "coding-agent", + "default_profile": "cli", + "display_name": "Mistral Vibe Code", + "lifecycle": "active", + "profiles": { + "cli": { + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "precedence": [ + "custom", + "project-agents", + "project-vibe", + "user-vibe", + "user-agents" + ], + "sources": [ + "https://github.com/mistralai/mistral-vibe" + ], + "surfaces": { + "agents": [ + { + "format": "toml", + "path": "~/.vibe/agents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + } + ], + "instructions": [ + { + "format": "agents-md", + "path": "~/.vibe/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "file" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "toml:mcp_servers", + "path": "~/.vibe/config.toml", + "policy": "manual-template", + "scope": "user", + "storage": "config-subobject" + }, + { + "format": "toml:mcp_servers", + "path": ".vibe/config.toml", + "policy": "manual-template", + "scope": "project", + "storage": "config-subobject" + } + ], + "skills": [ + { + "compatibility_paths": [ + "~/.agents/skills" + ], + "format": "agent-skill", + "path": "~/.vibe/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "compatibility_paths": [ + ".agents/skills" + ], + "format": "agent-skill", + "path": ".vibe/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "mmx-cli": { + "reference": "ides/mmx-cli.md", + "template": "manual-reference" + }, + "monkeycode": { + "confidence": "high", + "reason": "Cloud/self-hosted development platform with no verified portable local context contract", + "reference": "ides/monkeycode.md", + "sources": [ + "https://github.com/chaitin/MonkeyCode" + ], + "template": "cloud-ui", + "verified_at": "2026-08-13" + }, + "neovim": { + "reference": "ides/neovim.md", + "template": "host-editor" + }, + "openclaw": { + "reference": "ides/openclaw.md", + "template": "manual-reference" + }, + "opencode": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "OpenCode", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "opencode" + ], + "type": "binary", + "version_command": [ + "opencode", + "--version" + ] + }, + { + "paths": [ + "~/.config/opencode/skills", + "~/.config/opencode/AGENTS.md" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.config/opencode", + "linux": "~/.config/opencode", + "wsl": "~/.config/opencode" + }, + "sources": [ + "https://opencode.ai" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.config/opencode/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".opencode/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "openhands": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "OpenHands", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "openhands" + ], + "type": "binary", + "version_command": [ + "openhands", + "--version" + ] + }, + { + "paths": [ + "~/.openhands/skills", + "~/.agents/skills" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.openhands", + "linux": "~/.openhands", + "wsl": "~/.openhands" + }, + "sources": [ + "https://docs.openhands.dev/modules/skills" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.openhands/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".openhands/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": "~/.agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "pearai": { + "reference": "ides/pearai.md", + "template": "manual-reference" + }, + "pi": { + "category": "coding-agent", + "default_profile": "cli", + "display_name": "Pi coding agent", + "lifecycle": "active", + "profiles": { + "cli": { + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://pi.dev/docs/latest/skills", + "https://pi.dev/docs/latest/settings" + ], + "surfaces": { + "config": [ + { + "format": "json:skills", + "path": "~/.pi/agent/settings.json", + "policy": "explicit-subobject-only", + "scope": "user", + "storage": "config-subobject" + }, + { + "format": "json:skills", + "path": ".pi/settings.json", + "policy": "explicit-subobject-only", + "scope": "project", + "storage": "config-subobject" + } + ], + "skills": [ + { + "compatibility_paths": [ + "~/.agents/skills" + ], + "format": "agent-skill", + "path": "~/.pi/agent/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "compatibility_paths": [ + ".agents/skills" + ], + "format": "agent-skill", + "path": ".pi/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "pieces": { + "reference": "ides/pieces.md", + "template": "provider" + }, + "qoder": { + "category": "coding-agent", + "default_profile": "cli", + "display_name": "Qoder International", + "lifecycle": "active", + "profiles": { + "cli": { + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://docs.qoder.com/en/cli/Skills", + "https://docs.qoder.com/en/cli/mcp-servers", + "https://docs.qoder.com/cli/hooks" + ], + "surfaces": { + "hooks": [ + { + "format": "json:hooks", + "path": "~/.qoder/settings.json", + "policy": "disabled-draft-only", + "scope": "user", + "storage": "config-subobject" + }, + { + "format": "json:hooks", + "path": ".qoder/settings.json", + "policy": "disabled-draft-only", + "scope": "project", + "storage": "config-subobject" + }, + { + "format": "json:hooks", + "path": ".qoder/settings.local.json", + "policy": "disabled-draft-only", + "scope": "local", + "storage": "config-subobject" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.qoder/settings.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "config-subobject" + }, + { + "format": "json:mcpServers", + "path": ".qoder/settings.local.json", + "policy": "profile-version-adapter", + "scope": "local", + "storage": "config-subobject" + }, + { + "format": "json:mcpServers", + "path": ".mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.qoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".qoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + }, + "cloud": { + "kind": "cloud-agent", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://docs.qoder.com/" + ], + "surfaces": {}, + "verified_at": "2026-08-12", + "version_range": "profile boundary only as of 2026-08-12" + }, + "cn-cli": { + "detection": [ + { + "command": [ + "qoder" + ], + "type": "binary", + "version_command": [ + "qoder", + "--version" + ] + }, + { + "paths": [ + "~/.qoder/skills" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.qoder", + "linux": "~/.qoder" + }, + "sources": [ + "https://docs.qoder.com/cli" + ], + "surfaces": { + "skills": [ + { + "format": "agent-skill", + "path": "~/.qoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".qoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-17", + "version_range": "current as of 2026-08-16" + }, + "cn-ide": { + "detection": [ + { + "darwin_bundle_id": "com.qoder.ide", + "type": "app-bundle" + }, + { + "paths": [ + "~/.qoder/skills", + "~/.qoder/rules" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.qoder", + "linux": "~/.qoder", + "windows": "%APPDATA%/Qoder" + }, + "sources": [ + "https://docs.qoder.com/" + ], + "surfaces": { + "instructions": [ + { + "format": "qoder-rule", + "path": "~/.qoder/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "qoder-rule", + "path": ".qoder/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.qoder/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".qoder/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.qoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".qoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-17", + "version_range": "current as of 2026-08-16" + }, + "ide": { + "inherits": "cli", + "kind": "ide", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://docs.qoder.com/en/cli/Skills", + "https://docs.qoder.com/en/cli/mcp-servers", + "https://docs.qoder.com/cli/hooks" + ], + "verified_at": "2026-08-12" + }, + "work": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "sources": [ + "https://docs.qoder.com/" + ], + "surfaces": {}, + "verified_at": "2026-08-12", + "version_range": "profile boundary only as of 2026-08-12" + } + } + }, + "qoder-cn": { + "alias_of": { + "product": "qoder", + "profile": "cn-ide" + }, + "reference": "ides/qoder-cn.md", + "template": "legacy-alias" + }, + "qodo": { + "reference": "ides/qodo.md", + "template": "manual-reference" + }, + "qwen-code": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Qwen Code", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "qwen" + ], + "type": "binary", + "version_command": [ + "qwen", + "--version" + ] + }, + { + "paths": [ + "~/.qwen/skills", + "~/.qwen/commands", + "~/.qwen/agents" + ], + "type": "file-signature" + }, + { + "paths": [ + "~/.qwen/review-context", + "~/.qwen/sessions", + "~/.qwen/desktop", + "~/.qwen/serve" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "codespaces": "~/.qwen", + "darwin": "~/.qwen", + "dev-container": "~/.qwen", + "linux": "~/.qwen", + "wsl": "~/.qwen" + }, + "sources": [ + "https://github.com/QwenLM/Qwen-Code" + ], + "surfaces": { + "agents": [ + { + "format": "qwen-agent", + "path": "~/.qwen/agents", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "qwen-agent", + "path": ".qwen/agents", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "commands": [ + { + "format": "qwen-command", + "path": "~/.qwen/commands", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "qwen-command", + "path": ".qwen/commands", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "desktop": [ + { + "format": "qwen-desktop", + "path": "~/.qwen/desktop", + "policy": "manual-rebuild", + "scope": "user", + "storage": "directory" + } + ], + "hooks": [ + { + "events": [ + "pre-tool-use", + "post-tool-use", + "pre-prompt", + "post-prompt", + "notification" + ], + "format": "qwen-hook", + "path": "~/.qwen/hooks", + "policy": "disabled-draft-only", + "scope": "user", + "storage": "directory" + }, + { + "events": [ + "pre-tool-use", + "post-tool-use", + "pre-prompt", + "post-prompt", + "notification" + ], + "format": "qwen-hook", + "path": ".qwen/hooks", + "policy": "disabled-draft-only", + "scope": "project", + "storage": "directory" + } + ], + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.qwen/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".qwen/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "mcp-plugins": [ + { + "format": "qwen-mcp-plugin", + "path": "~/.qwen/mcp-plugins", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "qwen-mcp-plugin", + "path": ".qwen/mcp-plugins", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "plugins": [ + { + "format": "qwen-plugin", + "path": "~/.qwen/plugins", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "qwen-plugin", + "path": ".qwen/plugins", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "review_context": [ + { + "format": "qwen-review-context", + "path": ".qwen/review-context.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "file" + }, + { + "format": "qwen-review-context", + "path": "~/.qwen/review-context.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "file" + } + ], + "serve": [ + { + "format": "qwen-serve", + "path": "~/.qwen/serve", + "policy": "manual-rebuild", + "scope": "user", + "storage": "directory" + } + ], + "session": [ + { + "format": "qwen-session", + "path": "~/.qwen/sessions", + "policy": "forbidden-regenerate", + "scope": "user", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.qwen/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".qwen/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-15", + "version_range": "current as of 2026-08-15 (08-13 release)" + } + } + }, + "raccoon-ai": { + "reference": "ides/ui-only-mcp.md", + "template": "manual-reference" + }, + "replit": { + "reference": "ides/replit.md", + "template": "manual-reference" + }, + "roo-code": { + "reference": "ides/roo-code.md", + "template": "legacy-source-only" + }, + "rovodev": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Atlassian Rovo Dev", + "lifecycle": "active", + "profiles": { + "cli": { + "doc_conflicts": [ + "MCP path documented at ~/.rovodev/mcp.json and ~/.rovodev/mcp_config.json; resolved via config.yml mcpConfigPath probe." + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://support.atlassian.com/rovo/docs/use-agent-skills-in-rovo-dev-cli/" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "~/.rovodev/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + }, + { + "format": "agents-md", + "path": "AGENTS.local.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "doc_alternative_paths": [ + "~/.rovodev/mcp_config.json" + ], + "format": "json:mcpServers", + "path": "~/.rovodev/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".rovodev/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "prompts": [ + { + "format": "yaml:prompts", + "path": "~/.rovodev/prompts.yml", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "yaml:prompts", + "path": ".rovodev/prompts.yml", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.rovodev/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": "~/.agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".rovodev/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "subagents": [ + { + "format": "rovodev-subagent", + "path": "~/.rovodev/subagents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "rovodev-subagent", + "path": ".rovodev/subagents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-15", + "version_range": "current as of 2026-08-15" + } + } + }, + "sourcegraph-amp": { + "category": "agent-client", + "default_profile": "cli", + "display_name": "Sourcegraph Amp", + "lifecycle": "active", + "profiles": { + "cli": { + "detection": [ + { + "command": [ + "amp" + ], + "type": "binary", + "version_command": [ + "amp", + "--version" + ] + }, + { + "paths": [ + "~/.amp/skills", + "~/.config/amp/skills", + "~/.agents/skills" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.amp", + "linux": "~/.amp", + "wsl": "~/.amp" + }, + "sources": [ + "https://ampcode.com/docs/skills" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.amp/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".amp/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": "~/.config/amp/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": "~/.agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "supermaven": { + "reference": "ides/supermaven.md", + "template": "legacy-source-only" + }, + "tabnine": { + "reference": "ides/tabnine.md", + "template": "manual-reference" + }, + "tencent-codebuddy": { + "reference": "ides/tencent-codebuddy.md", + "template": "manual-reference" + }, + "tencent-codebuddy-ide": { + "reference": "ides/tencent-codebuddy-ide.md", + "template": "manual-reference" + }, + "tongyi-lingma": { + "alias_of": { + "product": "qoder", + "profile": "cn-ide" + }, + "reference": "ides/tongyi-lingma.md", + "template": "legacy-alias" + }, + "trae": { + "category": "agent-client", + "default_profile": "ide", + "display_name": "TRAE", + "lifecycle": "active", + "profiles": { + "cn-ide": { + "detection": [ + { + "darwin_bundle_id": "com.trae.ide.cn", + "type": "app-bundle" + }, + { + "paths": [ + "~/.trae/skills", + "~/.trae/rules" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.trae", + "linux": "~/.trae", + "windows": "%APPDATA%/TRAE" + }, + "sources": [ + "https://docs.trae.cn/ide/skills" + ], + "surfaces": { + "instructions": [ + { + "format": "trae-rule", + "path": "~/.trae/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "trae-rule", + "path": ".trae/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.trae/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".trae/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "ide": { + "detection": [ + { + "darwin_bundle_id": "com.trae.ide", + "type": "app-bundle" + }, + { + "paths": [ + "~/.trae/skills", + "~/.trae/rules" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.trae", + "linux": "~/.trae", + "windows": "%APPDATA%/TRAE" + }, + "sources": [ + "https://docs.trae.ai/ide/skills" + ], + "surfaces": { + "instructions": [ + { + "format": "trae-rule", + "path": "~/.trae/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "directory" + }, + { + "format": "trae-rule", + "path": ".trae/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.trae/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".trae/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "trae-cn": { + "alias_of": { + "product": "trae", + "profile": "cn-ide" + }, + "reference": "ides/trae-cn.md", + "template": "legacy-alias" + }, + "trae-work": { + "alias_of": { + "product": "trae", + "profile": "ide" + }, + "reference": "ides/trae-work.md", + "template": "cloud-ui" + }, + "v0": { + "reference": "ides/v0.md", + "template": "cloud-ui" + }, + "vecli": { + "reference": "ides/vecli.md", + "template": "manual-reference" + }, + "visual-studio": { + "alias_of": { + "product": "copilot", + "profile": "visual-studio" + }, + "reference": "ides/visual-studio.md", + "template": "legacy-alias" + }, + "void-editor": { + "reference": "ides/void-editor.md", + "template": "legacy-source-only" + }, + "vscode": { + "alias_of": { + "product": "copilot", + "profile": "vscode" + }, + "reference": "ides/vscode.md", + "template": "legacy-alias" + }, + "warp": { + "category": "agent-client", + "default_profile": "desktop", + "display_name": "Warp", + "lifecycle": "active", + "profiles": { + "cloud": { + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "note": "Warp Cloud is a cloud-only UI; no local configuration files. Use Warp desktop app or Warp Drive web interface for configuration.", + "sources": [ + "https://app.warp.dev" + ], + "surfaces": {}, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "desktop": { + "detection": [ + { + "darwin_bundle_id": "dev.warp.Warp", + "type": "app-bundle" + }, + { + "paths": [ + "~/.warp/skills", + "~/.warp/drive" + ], + "type": "file-signature" + } + ], + "kind": "desktop-agent", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.warp", + "linux": "~/.warp", + "windows": "%APPDATA%/Warp" + }, + "sources": [ + "https://docs.warp.dev/agents/skills" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "~/.warp/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.warp/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".warp/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "prompts": [ + { + "format": "warp-prompt", + "path": "~/.warp/drive/prompts", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "warp-prompt", + "path": ".warp/drive/prompts", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.warp/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".warp/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "workflows": [ + { + "format": "warp-workflow", + "path": "~/.warp/drive/workflows", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "warp-workflow", + "path": ".warp/drive/workflows", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + }, + "oz-cli": { + "detection": [ + { + "command": [ + "warp" + ], + "type": "binary", + "version_command": [ + "warp", + "--version" + ] + }, + { + "paths": [ + "~/.warp/skills", + "~/.warp/drive" + ], + "type": "file-signature" + } + ], + "kind": "cli", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.warp", + "linux": "~/.warp" + }, + "sources": [ + "https://docs.warp.dev/warp-oz" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "~/.warp/AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "hierarchy" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.warp/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".warp/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "prompts": [ + { + "format": "warp-prompt", + "path": "~/.warp/drive/prompts", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "warp-prompt", + "path": ".warp/drive/prompts", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.warp/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + } + ], + "workflows": [ + { + "format": "warp-workflow", + "path": "~/.warp/drive/workflows", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "warp-workflow", + "path": ".warp/drive/workflows", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "warp-oz": { + "category": "cloud-agent-cli", + "default_profile": "cli", + "display_name": "Warp Oz", + "lifecycle": "active", + "profiles": { + "cli": { + "kind": "local-and-cloud-cli", + "migration_policy": "manual-rebuild", + "sources": [ + "https://docs.warp.dev/reference/cli" + ], + "surfaces": { + "agents": [ + { + "format": "warp-agent-profile", + "path": "--profile", + "policy": "manual-template", + "scope": "local", + "storage": "profile" + } + ], + "mcp": [ + { + "format": "uuid-or-json", + "path": "--mcp", + "policy": "manual-rebuild", + "scope": "run", + "storage": "cli-argument" + } + ], + "skills": [ + { + "format": "repository-skill", + "path": "--skill", + "policy": "manual-rebuild", + "scope": "run", + "storage": "cli-argument" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "oz; warp-cli deprecated" + } + } + }, + "windsurf": { + "category": "agent-ide", + "default_profile": "ide", + "display_name": "Windsurf", + "lifecycle": "active", + "profiles": { + "ide": { + "kind": "ide", + "migration_policy": "bidirectional-reviewed", + "sources": [ + "https://docs.windsurf.com/windsurf/cascade/skills", + "https://docs.windsurf.com/windsurf/cascade/agents-md", + "https://docs.windsurf.com/windsurf/cascade/mcp" + ], + "surfaces": { + "generated_memory": [ + { + "path": "~/.codeium/windsurf/memories", + "policy": "forbidden-regenerate", + "scope": "user", + "storage": "generated-state" + } + ], + "instructions": [ + { + "format": "plain-markdown", + "path": "~/.codeium/windsurf/memories/global_rules.md", + "policy": "semantic-ir-with-loss-report", + "scope": "user", + "storage": "file" + }, + { + "format": "windsurf-rule", + "path": ".windsurf/rules", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "directory" + }, + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.codeium/windsurf/mcp_config.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.codeium/windsurf/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".windsurf/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-12", + "version_range": "current as of 2026-08-12" + } + } + }, + "workbuddy": { + "reference": "ides/workbuddy.md", + "template": "manual-reference" + }, + "xcode": { + "reference": "ides/xcode.md", + "template": "manual-reference" + }, + "zcode": { + "reference": "ides/zcode.md", + "template": "manual-reference" + }, + "zed": { + "category": "editor-agent", + "default_profile": "ide", + "display_name": "Zed", + "lifecycle": "active", + "profiles": { + "ide": { + "detection": [ + { + "command": [ + "zed" + ], + "type": "binary", + "version_command": [ + "zed", + "--version" + ] + }, + { + "darwin_bundle_id": "dev.zed.Zed", + "type": "app-bundle" + }, + { + "paths": [ + "~/.config/zed/skills", + "~/.zed/skills" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.config/zed", + "linux": "~/.config/zed", + "wsl": "~/.config/zed" + }, + "sources": [ + "https://zed.dev/docs/skills" + ], + "surfaces": { + "instructions": [ + { + "format": "agents-md", + "path": "AGENTS.md", + "policy": "semantic-ir-with-loss-report", + "scope": "project", + "storage": "hierarchy" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".agents/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "zencoder": { + "category": "agent-client", + "default_profile": "ide", + "display_name": "Zencoder", + "lifecycle": "active", + "profiles": { + "ide": { + "detection": [ + { + "command": [ + "zencoder" + ], + "type": "binary", + "version_command": [ + "zencoder", + "--version" + ] + }, + { + "paths": [ + "~/.zencoder/skills", + "~/.zencoder/settings.json" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.zencoder", + "linux": "~/.zencoder", + "windows": "%APPDATA%/Zencoder", + "wsl": "~/.zencoder" + }, + "sources": [ + "https://docs.zencoder.ai/skills" + ], + "surfaces": { + "agents": [ + { + "format": "zencoder-agent", + "path": "~/.zencoder/agents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zencoder-agent", + "path": ".zencoder/agents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "cloud_task_state": [ + { + "format": "json", + "path": "~/.zencoder/cloud_state.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.zencoder/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".zencoder/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "prompts": [ + { + "format": "zencoder-prompt", + "path": "~/.zencoder/prompts", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zencoder-prompt", + "path": ".zencoder/prompts", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "schedules": [ + { + "format": "json", + "path": "~/.zencoder/schedules.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "json", + "path": ".zencoder/schedules.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "settings": [ + { + "format": "json", + "path": "~/.zencoder/settings.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "json", + "path": ".zencoder/settings.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.zencoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".zencoder/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "subagents": [ + { + "format": "zencoder-subagent", + "path": "~/.zencoder/subagents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zencoder-subagent", + "path": ".zencoder/subagents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "workflows": [ + { + "format": "zencoder-workflow", + "path": "~/.zencoder/workflows", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zencoder-workflow", + "path": ".zencoder/workflows", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + }, + "zenflow": { + "category": "agent-client", + "default_profile": "ide", + "display_name": "Zenflow", + "lifecycle": "active", + "profiles": { + "ide": { + "detection": [ + { + "command": [ + "zenflow" + ], + "type": "binary", + "version_command": [ + "zenflow", + "--version" + ] + }, + { + "paths": [ + "~/.zenflow/skills", + "~/.zenflow/settings.json" + ], + "type": "file-signature" + } + ], + "kind": "ide-extension", + "migration_policy": "bidirectional-reviewed", + "platforms": { + "darwin": "~/.zenflow", + "linux": "~/.zenflow", + "windows": "%APPDATA%/Zenflow", + "wsl": "~/.zenflow" + }, + "sources": [ + "https://docs.zenflow.ai/skills" + ], + "surfaces": { + "agents": [ + { + "format": "zenflow-agent", + "path": "~/.zenflow/agents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zenflow-agent", + "path": ".zenflow/agents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "mcp": [ + { + "format": "json:mcpServers", + "path": "~/.zenflow/mcp.json", + "policy": "profile-version-adapter", + "scope": "user", + "storage": "file" + }, + { + "format": "json:mcpServers", + "path": ".zenflow/mcp.json", + "policy": "profile-version-adapter", + "scope": "project", + "storage": "file" + } + ], + "prompts": [ + { + "format": "zenflow-prompt", + "path": "~/.zenflow/prompts", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zenflow-prompt", + "path": ".zenflow/prompts", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "schedules": [ + { + "format": "json", + "path": "~/.zenflow/schedules.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "json", + "path": ".zenflow/schedules.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "settings": [ + { + "format": "json", + "path": "~/.zenflow/settings.json", + "policy": "manual-template", + "scope": "user", + "storage": "file" + }, + { + "format": "json", + "path": ".zenflow/settings.json", + "policy": "manual-template", + "scope": "project", + "storage": "file" + } + ], + "skills": [ + { + "format": "agent-skill", + "path": "~/.zenflow/skills", + "policy": "validate-then-atomic-copy", + "scope": "user", + "storage": "directory" + }, + { + "format": "agent-skill", + "path": ".zenflow/skills", + "policy": "validate-then-atomic-copy", + "scope": "project", + "storage": "directory" + } + ], + "subagents": [ + { + "format": "zenflow-subagent", + "path": "~/.zenflow/subagents", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zenflow-subagent", + "path": ".zenflow/subagents", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ], + "workflows": [ + { + "format": "zenflow-workflow", + "path": "~/.zenflow/workflows", + "policy": "manual-template", + "scope": "user", + "storage": "directory" + }, + { + "format": "zenflow-workflow", + "path": ".zenflow/workflows", + "policy": "manual-template", + "scope": "project", + "storage": "directory" + } + ] + }, + "verified_at": "2026-08-16", + "version_range": "current as of 2026-08-16" + } + } + } + }, + "profile_templates": { + "cloud-ui": { + "confidence": "medium", + "kind": "cloud-ui", + "migration_policy": "official-api-or-rebuild-checklist", + "profile": "cloud", + "support_level": "manual", + "surfaces": {} + }, + "host-editor": { + "confidence": "high", + "kind": "editor-host", + "migration_policy": "manual-rebuild", + "profile": "host", + "support_level": "host", + "surfaces": {} + }, + "legacy-alias": { + "confidence": "high", + "kind": "brand-alias", + "migration_policy": "alias-only", + "profile": "alias", + "support_level": "alias", + "surfaces": {} + }, + "legacy-source-only": { + "confidence": "high", + "kind": "retired-product", + "migration_policy": "source-only", + "profile": "legacy", + "support_level": "source-only", + "surfaces": {} + }, + "manual-reference": { + "confidence": "low", + "kind": "documented-reference", + "migration_policy": "manual-rebuild", + "profile": "reference", + "support_level": "unverified", + "surfaces": {} + }, + "provider": { + "confidence": "high", + "kind": "mcp-or-memory-provider", + "migration_policy": "configure-consuming-client", + "profile": "provider", + "support_level": "provider", + "surfaces": {} + } + }, + "schema_version": 2.1, + "support_contract": { + "alias-only": { + "confidence": "high", + "support_level": "alias" + }, + "bidirectional-reviewed": { + "confidence": "high", + "support_level": "partial" + }, + "configure-consuming-client": { + "confidence": "high", + "support_level": "provider" + }, + "manual-rebuild": { + "confidence": "medium", + "support_level": "manual" + }, + "manual-template": { + "confidence": "medium", + "support_level": "manual" + }, + "official-api-or-rebuild-checklist": { + "confidence": "medium", + "support_level": "manual" + }, + "source-only": { + "confidence": "high", + "support_level": "source-only" + }, + "stale-manual": { + "confidence": "low", + "description": "Profile was manual but exceeded freshness window; may have outdated docs/paths", + "support_level": "stale-manual" + }, + "stale-partial": { + "confidence": "low", + "description": "Profile was partial but exceeded freshness window; may have outdated paths/schemas", + "support_level": "stale-partial" + }, + "stale-source-only": { + "confidence": "low", + "description": "Profile was source-only but exceeded freshness window", + "support_level": "stale-source-only" + } + }, + "verified_at": "2026-08-12" +} diff --git a/skills/agent-skills-setup/agent-skills-setup/references/registry-v2.schema.json b/skills/agent-skills-setup/agent-skills-setup/references/registry-v2.schema.json new file mode 100644 index 000000000..8a115000e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/registry-v2.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "registry-v2.schema.json", + "title": "Agent context product profile registry v2", + "type": "object", + "required": ["schema_version", "verified_at", "object_policies", "support_contract", "profile_templates", "products"], + "properties": { + "schema_version": {"enum": [2, 2.1]}, + "verified_at": {"type": "string", "format": "date"}, + "object_policies": {"type": "object", "additionalProperties": {"type": "string"}}, + "support_contract": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["support_level", "confidence"], + "properties": { + "support_level": {"enum": ["partial", "manual", "source-only", "provider", "host", "alias", "unverified", "stale-partial", "stale-manual", "stale-source-only"]}, + "confidence": {"enum": ["high", "medium", "low"]}, + "description": {"type": "string"} + }, + "additionalProperties": false + } + }, + "profile_templates": {"type": "object", "additionalProperties": {"$ref": "#/$defs/profile"}}, + "products": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/product" + } + }, + "$schema": {"type": "string"}, + "aliases": {"type": "object"}, + "candidate_discovery": {"type": "object"}, + "detection_config": {"type": "object"}, + "freshness": {"type": "object"} + }, + "additionalProperties": false, + "$defs": { + "surface": { + "type": "object", + "required": ["scope", "storage", "path", "policy"], + "properties": { + "scope": {"type": "string", "minLength": 1}, + "storage": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1}, + "format": {"type": "string"}, + "policy": {"type": "string", "minLength": 1}, + "override_env": {"type": "string"}, + "override_relative_path": {"type": "string"}, + "compatibility_paths": { + "type": "array", + "items": {"type": "string"} + }, + "compatibility_behavior": {"enum": ["alternative", "precedence"]} + }, + "additionalProperties": true + }, + "profile": { + "type": "object", + "required": ["kind", "migration_policy"], + "properties": { + "profile": {"type": "string"}, + "kind": {"type": "string"}, + "inherits": {"type": "string"}, + "version_range": {"type": "string"}, + "verified_at": {"type": "string", "format": "date"}, + "sources": { + "type": "array", + "items": {"type": "string", "format": "uri"} + }, + "migration_policy": {"type": "string"}, + "support_level": {"enum": ["partial", "manual", "source-only", "provider", "host", "alias", "unverified"]}, + "confidence": {"enum": ["high", "medium", "low"]}, + "surfaces": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": {"$ref": "#/$defs/surface"} + } + } + }, + "anyOf": [ + {"required": ["inherits"]}, + {"required": ["surfaces"]} + ] + }, + "product": { + "type": "object", + "properties": { + "display_name": {"type": "string"}, + "category": {"type": "string"}, + "lifecycle": {"type": "string"}, + "template": {"type": "string"}, + "default_profile": {"type": "string"}, + "profiles": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/profile"} + } + }, + "anyOf": [ + {"required": ["template"]}, + {"required": ["display_name", "category", "lifecycle", "default_profile", "profiles"]} + ] + } + } +} diff --git a/skills/agent-skills-setup/agent-skills-setup/references/verification.md b/skills/agent-skills-setup/agent-skills-setup/references/verification.md new file mode 100644 index 000000000..b9595e415 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/references/verification.md @@ -0,0 +1,5 @@ +# Verification and evidence + +Before apply, record the saved plan path and `plan_sha256`; confirm that Registry, adapter, source/target, and Git provenance validation succeeded. After apply, use `--json` and report scope/status, manifest checksum, canonical paths, source SHA-256 before/after, `source_unchanged`, target existence/hash/parse result, backup, and manual follow-up. Run `verify --manifest ...` against the checksummed manifest. Rollback refuses to overwrite a target changed after apply. + +Parse success does not prove transport, protocol compatibility, credentials, OAuth, permissions, or connectivity. Check the target's native discovery surface (for example `claude mcp list`, `codex mcp list`, `opencode mcp list`, `copilot mcp list`, or its MCP panel) and consult [ide-registry.md](ide-registry.md). Apply [mcp-transport.md](mcp-transport.md) rather than adding protocol headers manually. diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/README.md b/skills/agent-skills-setup/agent-skills-setup/scripts/README.md new file mode 100644 index 000000000..e07f61ac6 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/README.md @@ -0,0 +1,35 @@ +# Scripts + +`smart-ide-migration.sh` is the public wrapper. Its profile-aware commands are +`detect`, `inventory`, `plan`, `apply`, `verify`, and `rollback`. Always save a +plan with `plan --output plan.json`; `apply plan.json --yes` verifies the plan +checksum, Registry digest, adapter versions, resolved source/target state, and +Git HEAD before any write. Apply emits a checksummed manifest with exact +backups. `--json` reserves stdout for one JSON document and sends diagnostics +to stderr. + +Legacy discovery and dry-run compatibility require the explicit `legacy` +subcommand. Calls beginning with implicit legacy flags are rejected. Every +`legacy --yes` write fails before the retained compatibility engine runs; use a +saved profile-aware plan. The legacy engine rejects ordinary direct execution. + +The Skill declares local environment lookup, file-read, file-write, and bundled +shell/Python capabilities only; generic migration requests authorize planning, +while apply and rollback require separate explicit user approval. +MCP targets that are symbolic links fail before conversion. Redaction cleanup +can remove only the exact target artifacts; copied-skill cleanup must remain +inside its canonicalized target copy root. + +`scan-skill-secrets.py` checks every regular source file before a Skill copy and +reports only relative paths and reason categories, never credential values. +`ide-paths.tsv` is generated from `references/ide-paths.json`; regenerate it +with `sync-ide-reference-summaries.py`, never edit it directly. `common.sh` is +an internal helper. + +`check-doc-freshness.py` validates source/freshness metadata and provenance +offline without network access. It verifies schemas, official HTTPS source +declarations, and verified_at freshness boundaries locally. + +`test-*.sh` files are maintainer regression suites run by `bash validate-all.sh`, +not local-IDE migration commands. Legacy converter suites opt into the private +guard explicitly; `test-legacy-registry-gate.sh` covers the public boundary. diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/acb/__init__.py b/skills/agent-skills-setup/agent-skills-setup/scripts/acb/__init__.py new file mode 100644 index 000000000..4703fe4d5 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/acb/__init__.py @@ -0,0 +1,7 @@ +"""Agent Context Bundle (ACB) support modules. + +An ACB is a portable, cross-device snapshot of agent context. It is +intentionally separated from the migration plan document so that a +plan (which is bound to a single device's absolute paths) can be +produced from an ACB on a different device. +""" \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/acb/bundle.py b/skills/agent-skills-setup/agent-skills-setup/scripts/acb/bundle.py new file mode 100644 index 000000000..bdc29801e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/acb/bundle.py @@ -0,0 +1,1415 @@ +"""Agent Context Bundle (.acb) creation, verification, and restore. + +Layout:: + + .acb/ + manifest.json schema_version, source_platform, objects + inventory.json portable per-product surface inventory + compatibility.json per-product target-eligibility matrix + requirements.json executables, packages, extensions, manual_installs + secrets.required.json non-secret names of required credentials + reauth.json per-MCP re-auth action list + rebuild.json per-object manual-rebuild manifest + checksums.json sha256 of every other file + objects// reviewed object content (no secrets) + +The bundle is created from a snapshot of the local filesystem plus the +Registry v2 inventory. :func:`verify_bundle` performs closed-world integrity +checks ensuring no unexpected or missing files, no symlinks/devices, and +accurate SHA256 hashes. +""" + +from __future__ import annotations + +import base64 +import dataclasses +import hashlib +import json +import os +import re +import shutil +import stat +import tempfile +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import skill_secret_scanner + +ACB_SCHEMA_VERSION = 1 +ACB_MANIFEST_NAME = "manifest.json" +ACB_INVENTORY_NAME = "inventory.json" +ACB_COMPATIBILITY_NAME = "compatibility.json" +ACB_REQUIREMENTS_NAME = "requirements.json" +ACB_SECRETS_NAME = "secrets.required.json" +ACB_REAUTH_NAME = "reauth.json" +ACB_REBUILD_NAME = "rebuild.json" +ACB_CHECKSUMS_NAME = "checksums.json" +ACB_OBJECTS_DIR = "objects" + +ACB_JSON_FILES = ( + ACB_MANIFEST_NAME, + ACB_INVENTORY_NAME, + ACB_COMPATIBILITY_NAME, + ACB_REQUIREMENTS_NAME, + ACB_SECRETS_NAME, + ACB_REAUTH_NAME, + ACB_REBUILD_NAME, +) + +# Resource safety limits +MAX_BUNDLE_FILES = 5000 +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB per file +MAX_TOTAL_SIZE = 100 * 1024 * 1024 # 100 MB total +MAX_DIR_DEPTH = 16 + +# Safe binary extensions allowlist +SAFE_BINARY_EXTENSIONS = frozenset({ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", + ".svg", ".woff", ".woff2", ".ttf", ".eot", ".otf" +}) + +# Forbidden snapshot policies and non-migratable object types (audit P0-2) +FORBIDDEN_SNAPSHOT_POLICIES = frozenset({ + "forbidden-regenerate", + "never-migrate", + "source-only", + "cloud-rebuild", + "disabled-draft-only", +}) + +FORBIDDEN_SNAPSHOT_OBJECT_TYPES = frozenset({ + "generated_memory", + "session", + "chat", + "runtime", + "database", + "trust", + "approval", + "oauth_state", + "credentials", +}) + +_SENSITIVE_FILENAME_HINT = re.compile( + r"(?i)(^\.env(\..+)?$|\.pem$|\.key$|^id_rsa|^id_ed25519|^id_ecdsa|\.p12$|\.pfx$)" +) + + +class ACBError(Exception): + """Base class for ACB failures.""" + + +class ACBSecretLeak(ACBError): + """Raised when literal secret values are detected in bundle content.""" + + +class ACBIntegrityError(ACBError): + """Raised when bundle integrity or containment check fails.""" + + +@dataclasses.dataclass +class ACBManifest: + schema_version: int + bundle_id: str + created_at: str + source_platform: dict[str, str] + inventory_summary: dict[str, Any] + objects: list[dict[str, Any]] + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "bundle_id": self.bundle_id, + "created_at": self.created_at, + "source_platform": self.source_platform, + "inventory_summary": self.inventory_summary, + "objects": self.objects, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "ACBManifest": + return cls( + schema_version=int(payload["schema_version"]), + bundle_id=str(payload["bundle_id"]), + created_at=str(payload["created_at"]), + source_platform=dict(payload.get("source_platform", {})), + inventory_summary=dict(payload.get("inventory_summary", {})), + objects=list(payload.get("objects", [])), + ) + + +def enrich_manifest_object( + obj: dict[str, Any], + objects_dir_files: dict[str, bytes], + adapter_versions: dict[str, str] | None = None, + object_file_map: dict[str, list[str]] | None = None, +) -> dict[str, Any]: + """Enrich a manifest object with file list, hashes, and metadata. + + Adds: + - object_path: logical path under objects/ + - files: list of {path, sha256, size} for each file + - source_format_version: format version from plan/registry + - adapter_version: adapter version used for this object type + - portability_mode: "full" | "lossy" | "manual" | "excluded" + - content_hash: SHA256 of primary content file (for quick comparison) + """ + obj_dict = dict(obj) + obj_type = obj_dict.get("surface") or obj_dict.get("object_type", "") + prod = obj_dict.get("product", "") + prof = obj_dict.get("profile", "") + scp = obj_dict.get("scope", "") + + # Build logical object path + obj_key = f"{obj_type}/{prod}/{prof}/{scp}" + obj_dict["object_path"] = obj_key + + # Collect files for this object + obj_files = [] + prefix = f"{obj_key}/" + primary_hash = None + + if object_file_map is not None and obj_key in object_file_map: + file_paths = sorted(set(object_file_map[obj_key])) + else: + file_paths = sorted([ + rel_path for rel_path in (objects_dir_files or {}).keys() + if rel_path.startswith(prefix) + ]) + + for rel_path in file_paths: + data = (objects_dir_files or {}).get(rel_path, b"") + file_hash = hashlib.sha256(data).hexdigest() + file_entry = { + "path": f"{ACB_OBJECTS_DIR}/{Path(rel_path).as_posix()}", + "sha256": file_hash, + "size": len(data), + } + obj_files.append(file_entry) + if primary_hash is None: + primary_hash = file_hash + obj_dict["files"] = obj_files + obj_dict["content_hash"] = primary_hash + + # Add source format version and adapter version if available + if adapter_versions: + obj_dict["adapter_version"] = adapter_versions.get(obj_type, "") + # source_format_version would come from plan item; placeholder for now + obj_dict["source_format_version"] = obj_dict.get("source_format", "") + + # Determine portability mode from status + status = obj_dict.get("status", "") + if status == "ready": + obj_dict["portability_mode"] = "full" + elif status == "ready-lossy": + obj_dict["portability_mode"] = "lossy" + elif status in ("manual-rebuild", "draft-disabled", "forbidden"): + obj_dict["portability_mode"] = "manual" + elif status == "excluded": + obj_dict["portability_mode"] = "excluded" + else: + obj_dict["portability_mode"] = "unknown" + + return obj_dict + + +def sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: Path) -> str: + return sha256_bytes(path.read_bytes()) + + +def is_binary_bytes(data: bytes) -> bool: + """Determine whether data is non-text binary.""" + if b"\x00" in data: + return True + try: + data.decode("utf-8") + return False + except UnicodeDecodeError: + return True + + +def looks_like_secret_value(value: Any) -> bool: + """Heuristic check: does this string look like a literal credential? + + Uses the SAME unified scanner as Skills/objects (audit #6) so that only + genuine credential SHAPES are flagged — provider tokens, private-key + blocks, or ``key=value`` / ``key: value`` assignments — not prose that + merely mentions words like "secret" or "token". + """ + if not isinstance(value, str): + return False + if not value or value.startswith("${") or value.startswith("$") or value.startswith("<"): + return False + return skill_secret_scanner.finding_reason(value.encode("utf-8")) is not None + + +def scan_object_bytes(data: bytes, path_name: str) -> None: + """Perform strict secret, private-key, and binary safety scans on raw object bytes.""" + path = Path(path_name) + base_name = path.name + + # 1. Block sensitive file names (.env, private keys, certificates) + if _SENSITIVE_FILENAME_HINT.search(base_name): + raise ACBSecretLeak(f"forbidden sensitive file in bundle: {path_name}") + + # 2. Check binary safety + if is_binary_bytes(data): + # Check executable magic headers + if data.startswith(b"\x7fELF"): + raise ACBSecretLeak(f"executable ELF binary rejected: {path_name}") + if data.startswith((b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf", b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe")): + raise ACBSecretLeak(f"executable Mach-O binary rejected: {path_name}") + if data.startswith(b"MZ"): + raise ACBSecretLeak(f"executable PE binary rejected: {path_name}") + + # Check extension against binary allowlist + ext = path.suffix.lower() + if ext not in SAFE_BINARY_EXTENSIONS: + raise ACBSecretLeak(f"unallowlisted binary file rejected: {path_name}") + return + + # 3. Unified generic secret scan. Reuses skill_secret_scanner.finding_reason + # so that credentials are detected identically across the Skill scanner and + # ACB objects (audit #6): private keys, provider patterns, Bearer tokens, + # connection-string userinfo, and literal credential assignments + # (password=, client_secret:, DATABASE_URL with embedded creds, etc.). + secret_reason = skill_secret_scanner.finding_reason(data) + if secret_reason is not None: + raise ACBSecretLeak(f"{secret_reason} in {path_name}") + + # 4. Require clean UTF-8 text for non-binary allowlisted content. + try: + data.decode("utf-8") + except UnicodeDecodeError: + raise ACBSecretLeak(f"undecodable non-allowlisted text: {path_name}") + + +def assert_no_lateral_secrets(payload: dict[str, Any]) -> None: + """Reject literal secret-looking strings in a structured payload.""" + for key, value in _walk(payload): + if isinstance(value, str) and looks_like_secret_value(value): + if key.endswith(".name") or key == "name": + if isinstance(value, str) and re.match(r"^[A-Z][A-Z0-9_]+$", value): + continue + raise ACBSecretLeak( + f"literal credential-looking string at {key}: {value[:32]!r}" + ) + + +def _walk(payload: Any, path: tuple[str, ...] = ()) -> Any: + if isinstance(payload, dict): + for key, value in payload.items(): + yield from _walk(value, path + (str(key),)) + elif isinstance(payload, list): + for index, value in enumerate(payload): + yield from _walk(value, path + (f"[{index}]",)) + else: + yield ".".join(path), payload + + +def validate_path_containment(relative_path: str | Path, base_dir: Path) -> Path: + """Ensure path has no absolute segments, traversal, drive specifiers, and stays within base_dir.""" + p_str = str(relative_path).replace("\\", "/") + if p_str.startswith("/") or re.match(r"^[a-zA-Z]:", p_str) or p_str.startswith("//"): + raise ACBIntegrityError(f"forbidden absolute/UNC path: {relative_path}") + parts = Path(p_str).parts + if ".." in parts or any(part.startswith("/") for part in parts): + raise ACBIntegrityError(f"path traversal detected: {relative_path}") + resolved_target = (base_dir / p_str).resolve() + try: + resolved_target.relative_to(base_dir.resolve()) + except ValueError: + raise ACBIntegrityError(f"path escapes base directory: {relative_path}") + return resolved_target + + +def sanitize_inventory_for_bundle(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Sanitize inventory rows for portable bundles by stripping machine-specific paths and local user info.""" + clean_rows: list[dict[str, Any]] = [] + for row in rows: + clean_row = { + "product": row.get("product", ""), + "profile": row.get("profile", "default"), + "object_type": row.get("object_type", ""), + "scope": row.get("scope", ""), + "canonical_path": row.get("canonical_path", ""), + "format": row.get("format", ""), + "policy": row.get("policy", ""), + "content_hash": row.get("content_hash", ""), + "exists": bool(row.get("exists", False)), + } + clean_rows.append(clean_row) + return clean_rows + + +def collect_requirements( + inventory_rows: list[dict[str, Any]], + plan_rows: list[dict[str, Any]], + objects_dir_files: dict[str, bytes] | None = None, +) -> tuple[dict[str, Any], dict[str, int]]: + """Compute bundle-level prerequisites and surface parse failures. + + Audit P1-2 (0.8.27): never silently swallow MCP parse failures. If a + Gemini / Augment / VS Code / Qoder settings.json changes shape and we + cannot extract its MCP servers, we record the failure in ``summary`` + rather than emitting a "successful" bundle with missing data. + + Returns ``(requirements, summary)`` where summary contains: + - parse_failed: count of MCP sources we could not parse + - parse_failed_details: list[dict] with per-source error context + """ + executables: set[str] = set() + extensions: set[str] = set() + packages: list[dict[str, str]] = [] + manual_installs: list[str] = [] + platform_notes: list[str] = [] + parse_failed_details: list[dict[str, str]] = [] + + def _record_parse_failure(source_label: str, error: Exception) -> None: + parse_failed_details.append( + {"source": source_label, "error": str(error)} + ) + + def _normalize_package_name(name: str, manager: str) -> str: + """Normalize package name by stripping version suffixes and common prefixes.""" + # Remove version specifiers (@version, @latest, etc.) + if "@" in name and not name.startswith("@"): + name = name.split("@")[0] + # Remove common prefixes + for prefix in ["npm:", "pypi:", "github:"]: + if name.startswith(prefix): + name = name[len(prefix):] + return name + + def _add_package(manager: str, name: str) -> None: + """Add a package with normalized name.""" + normalized = _normalize_package_name(name, manager) + if normalized: + packages.append({"manager": manager, "name": normalized}) + + # 1. Inspect captured raw object files for MCP server requirements + if objects_dir_files: + for rel, data in objects_dir_files.items(): + if rel.startswith("mcp/"): + try: + doc = json.loads(data.decode("utf-8")) + servers = doc.get("mcpServers") or doc.get("servers") or {} + if isinstance(servers, dict): + for _, s_cfg in servers.items(): + if isinstance(s_cfg, dict): + cmd = s_cfg.get("command") or s_cfg.get("runner") or "" + if cmd and not str(cmd).endswith(".json") and not str(cmd).startswith("~"): + executables.add(str(cmd)) + args = s_cfg.get("args") or [] + if isinstance(args, list): + for arg in args: + if isinstance(arg, str): + if arg.startswith("@") or "mcp-server" in arg: + _add_package("npm" if cmd in ("npx", "npm", "node") else "auto", arg) + # VS Code extension IDs in args (e.g., "ms-vscode.cpptools") + elif "." in arg and not arg.endswith((".py", ".js", ".json")) and "/" not in arg: + extensions.add(arg) + # Python/JS script paths - these are manual installs, not packages + elif arg.endswith(".py") or arg.endswith(".js"): + manual_installs.append(arg) + except Exception as error: + _record_parse_failure(f"objects_dir_files:{rel}", error) + + # 2. Inspect plan items to discover required tools & packages from disk sources + for item in plan_rows: + if item.get("status") not in {"ready", "ready-lossy", "draft-disabled", "manual-rebuild"}: + continue + obj_type = item.get("object_type") + if obj_type == "mcp": + src = item.get("source") or {} + resolved = src.get("resolved_path") + if resolved and Path(resolved).is_file(): + try: + raw_text = Path(resolved).read_text(encoding="utf-8") + from migration_core import parse_mcp_document + servers = parse_mcp_document(raw_text, src.get("source_format", "json:mcpServers")) + for s in servers: + if s.command and not str(s.command).endswith(".json") and not str(s.command).startswith("~"): + executables.add(str(s.command)) + for arg in s.args: + if isinstance(arg, str): + if arg.startswith("@") or "mcp-server" in arg: + _add_package("npm" if s.command in ("npx", "npm", "node") else "auto", arg) + elif "." in arg and not arg.endswith((".py", ".js", ".json")) and "/" not in arg: + extensions.add(arg) + elif arg.endswith(".py") or arg.endswith(".js"): + manual_installs.append(arg) + except Exception as error: + src_label = ( + f"plan_item:{src.get('product', '')}/{src.get('profile', '')}" + f"/{src.get('scope', '')}:{resolved}" + ) + _record_parse_failure(src_label, error) + elif obj_type in ("skills", "instructions"): + # Skills and instructions may require the target IDE to be installed + tgt = item.get("target") or {} + tgt_product = tgt.get("product", "") + if tgt_product: + # Add platform-specific notes about required IDE + platform_notes.append(f"Target {tgt_product} must be installed to use {obj_type}") + + clean_packages = [] + seen_pkg = set() + for p in packages: + key = (p.get("manager"), p.get("name")) + if key not in seen_pkg: + seen_pkg.add(key) + clean_packages.append(p) + + requirements = { + "executables": sorted(executables), + "extensions": sorted(extensions), + "packages": clean_packages, + "manual_installs": sorted(set(manual_installs)), + "platform_notes": platform_notes, + } + summary = { + "parse_failed": len(parse_failed_details), + "parse_failed_details": parse_failed_details, + } + return requirements, summary + + +def collect_reauth( + plan_rows: list[dict[str, Any]], +) -> list[dict[str, str]]: + actions: list[dict[str, str]] = [] + for item in plan_rows: + if item.get("status") == "manual-rebuild" and item.get("object_type") == "mcp": + src = item.get("source") or {} + actions.append( + { + "object_id": item.get("object_id", ""), + "reason": item.get("reason", "OAuth re-auth required"), + "action": "Open the target product's MCP UI, sign in, and re-add the server.", + "source": { + "package": src.get("package", ""), + "command": src.get("command", ""), + }, + } + ) + return actions + + +def collect_rebuild( + plan_rows: list[dict[str, Any]], +) -> list[dict[str, str]]: + actions: list[dict[str, str]] = [] + for item in plan_rows: + if item.get("status") in {"manual-rebuild", "forbidden"}: + actions.append( + { + "object_id": item.get("object_id", ""), + "object_type": item.get("object_type", ""), + "reason": item.get("reason", ""), + "actions": item.get("manual_actions", []), + } + ) + return actions + + +def write_bundle( + *, + bundle_root: Path, + manifest: ACBManifest, + inventory_rows: list[dict[str, Any]], + compatibility: dict[str, Any], + requirements: dict[str, Any], + secrets_required: list[dict[str, str]], + reauth: list[dict[str, str]], + rebuild: list[dict[str, str]], + objects_dir_files: dict[str, bytes] | None = None, + adapter_versions: dict[str, str] | None = None, + object_file_map: dict[str, list[str]] | None = None, +) -> Path: + """Write a fully-formed, closed-world ACB at ``bundle_root`` atomically. + + Staging & Atomic Swap with Rollback Protection (audit P1-7): + 1. Writes all JSON payloads, object files, and checksums to a temporary staging directory + on the same filesystem. + 2. Performs byte-level secret scanning and path containment checks during staging. + 3. Runs verify_bundle() on the staged bundle. + 4. Upon successful verification, atomically replaces staging into bundle_root via backup/rename. + 5. If any error occurs during write, verification, or replace, rolls back cleanly. + """ + bundle_root = bundle_root.resolve() + parent_dir = bundle_root.parent + parent_dir.mkdir(parents=True, exist_ok=True) + + staging_dir = Path(tempfile.mkdtemp(prefix=f".tmp_{bundle_root.name}_", dir=parent_dir)) + backup_dir: Path | None = None + try: + objects_root = staging_dir / ACB_OBJECTS_DIR + objects_root.mkdir(parents=True, exist_ok=True) + + # Sanitize inventory for portable bundle + portable_inventory = sanitize_inventory_for_bundle(inventory_rows) + + # Build 1:1 object-to-file mapping for manifest with rich metadata + enriched_manifest_objects = [] + for obj in manifest.objects: + enriched = enrich_manifest_object( + obj, objects_dir_files, adapter_versions, object_file_map + ) + enriched_manifest_objects.append(enriched) + + manifest_payload = manifest.to_dict() + manifest_payload["objects"] = enriched_manifest_objects + + json_payloads: dict[str, dict[str, Any]] = { + ACB_MANIFEST_NAME: manifest_payload, + ACB_INVENTORY_NAME: {"rows": portable_inventory}, + ACB_COMPATIBILITY_NAME: compatibility, + ACB_REQUIREMENTS_NAME: requirements, + ACB_SECRETS_NAME: {"items": secrets_required}, + ACB_REAUTH_NAME: {"items": reauth}, + ACB_REBUILD_NAME: {"items": rebuild}, + } + for name, payload in json_payloads.items(): + assert_no_lateral_secrets(payload) + (staging_dir / name).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + # Verify and write raw object files with byte-level scanning + total_bytes = 0 + file_count = 0 + if objects_dir_files: + for relative, data in sorted(objects_dir_files.items()): + file_count += 1 + total_bytes += len(data) + if file_count > MAX_BUNDLE_FILES: + raise ACBError(f"bundle file count exceeded limit ({MAX_BUNDLE_FILES})") + if len(data) > MAX_FILE_SIZE: + raise ACBError(f"file size exceeded limit ({MAX_FILE_SIZE} bytes): {relative}") + if total_bytes > MAX_TOTAL_SIZE: + raise ACBError(f"bundle total size exceeded limit ({MAX_TOTAL_SIZE} bytes)") + + # Strict byte-level secret and binary scan + scan_object_bytes(data, relative) + + # Strict path containment check + target = validate_path_containment(relative, objects_root) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + + # Post-write directory-wide secret scan + for p in staging_dir.rglob("*"): + if p.is_file() and p.name != ACB_CHECKSUMS_NAME: + scan_object_bytes(p.read_bytes(), str(p.relative_to(staging_dir))) + + # Compute checksums for all written files + checksums: dict[str, str] = {} + for name in ACB_JSON_FILES: + checksums[name] = sha256_file(staging_dir / name) + for relative_path in sorted((objects_dir_files or {}).keys()): + norm_path = Path(relative_path).as_posix() + checksums[f"{ACB_OBJECTS_DIR}/{norm_path}"] = sha256_file( + objects_root / norm_path + ) + (staging_dir / ACB_CHECKSUMS_NAME).write_text( + json.dumps(checksums, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + # Validate staging directory before atomic replace + verify_errors = verify_bundle(staging_dir) + if verify_errors: + raise ACBIntegrityError(f"staged bundle failed verification: {verify_errors}") + + # Atomic replace with rollback protection (audit P1-7) + if bundle_root.exists(): + backup_dir = Path(tempfile.mkdtemp(prefix=f".bak_{bundle_root.name}_", dir=parent_dir)) + backup_target = backup_dir / "old_bundle" + bundle_root.rename(backup_target) + + try: + staging_dir.rename(bundle_root) + if backup_dir and backup_dir.exists(): + shutil.rmtree(backup_dir, ignore_errors=True) + except Exception: + # If staging rename failed, restore the previous bundle from backup + if backup_dir and (backup_dir / "old_bundle").exists() and not bundle_root.exists(): + (backup_dir / "old_bundle").rename(bundle_root) + shutil.rmtree(backup_dir, ignore_errors=True) + raise + + return bundle_root + + except Exception: + if staging_dir.exists(): + shutil.rmtree(staging_dir, ignore_errors=True) + raise + + +def collect_source_objects( + registry: Any, + rows: list[dict[str, Any]], + *, + home: Path | None = None, + workspace: Path | None = None, + source_product: str | None = None, + source_profile: str | None = None, + allowed_scopes: set[str] | None = None, + allowed_object_types: set[str] | None = None, + plan_items: list[dict[str, Any]] | None = None, +) -> tuple[dict[str, bytes], dict[str, int], dict[str, list[str]]]: + """Walk plan items and copy source files into stable paths under ``objects/``. + + Audit P1-1 (0.8.27): primary iteration is over plan_items. Each + selected PlanItem is processed by reading ``item.source.resolved_path`` + directly; inventory rows are consulted only as a metadata lookup for + fields plan_items do not carry (canonical_path, source_format, storage, + policy). This eliminates the previous "row-driven" path that silently + widened a single Instructions plan item to every Instructions row in + the same profile/scope, and that overwrote canonical/compatibility + conflicts at the same bundle-relative path. + + Audit P1-2 (0.8.27): each portable object outcome is tracked in + ``summary`` with explicit statuses: + - captured - bytes written under objects/ + - manual_rebuild - plan status=manual-rebuild (no source to extract) + - excluded_by_policy - forbidden policy, scope, or object type + - parse_failed - source path missing, unreadable, or parse error + - secret_rejected - sensitive filename or secret scan hit + - conflict - same bundle-relative path mapped from multiple sources + + Audit P0-2: returns explicit 1:1 ``object_file_map`` tracking which + files under ``objects/`` belong to each (object_type, product, profile, scope). + + Strict Allowlist (audit P0-2 & 0.8.25): + - Refuses forbidden policies (forbidden-regenerate, never-migrate, source-only, etc.) + - Refuses non-migratable types (generated_memory, session, chat, runtime, database, trust, etc.) + - Only collects requested scopes and requested object types + + Returns ``(objects, summary, object_file_map)``. + """ + objects: dict[str, bytes] = {} + object_file_map: dict[str, list[str]] = {} + summary: dict[str, int] = { + "captured": 0, + "manual_rebuild": 0, + "excluded_by_policy": 0, + "parse_failed": 0, + "secret_rejected": 0, + "conflict": 0, + } + # Audit P1-1: detect alias / canonical-vs-compatibility conflicts where + # two distinct sources collapse to the same bundle-relative path. + seen_relative_sources: dict[str, set[str]] = {} + + def _record(status: str) -> None: + summary[status] = summary.get(status, 0) + 1 + + # Build (product, profile, object_type, scope) -> inventory row index. + # Plan items don't carry storage/format/policy, so rows remain the + # canonical metadata source for those fields. + row_index: dict[tuple[str, str, str, str], dict[str, Any]] = {} + for row in rows: + key = ( + row.get("product", ""), + row.get("profile", ""), + row.get("object_type", ""), + row.get("scope", ""), + ) + row_index.setdefault(key, row) + + def _process_plan_item(item: dict[str, Any]) -> None: + src = item.get("source") or {} + obj_type = item.get("object_type") or "" + prod = src.get("product") or "" + prof = src.get("profile") or "" + scope = src.get("scope") or "" + resolved_path = src.get("resolved_path") + item_status = item.get("status") or "" + + obj_key = f"{obj_type}/{prod}/{prof}/{scope}" + + # source_product / source_profile are passed only in single-source + # mode. In all-installed mode they are None, so this is a no-op. + if source_product and prod != source_product: + return + if source_profile and prof != source_profile: + return + + if allowed_object_types is not None and obj_type not in allowed_object_types: + _record("excluded_by_policy") + return + if allowed_scopes is not None and scope not in allowed_scopes: + _record("excluded_by_policy") + return + if item_status == "manual-rebuild": + _record("manual_rebuild") + return + + row = row_index.get((prod, prof, obj_type, scope)) or {} + policy = row.get("policy") or "" + if policy in FORBIDDEN_SNAPSHOT_POLICIES: + _record("excluded_by_policy") + return + if obj_type in FORBIDDEN_SNAPSHOT_OBJECT_TYPES: + _record("excluded_by_policy") + return + + if not resolved_path: + _record("parse_failed") + return + source_path = Path(resolved_path) + if not source_path.exists() or source_path.is_symlink(): + _record("parse_failed") + return + + canonical = row.get("canonical_path") or source_path.name + relative = _path_for_object(obj_type, prod, prof, scope, canonical) + + storage = row.get("storage") or "" + format_name = row.get("source_format") or row.get("format") or "" + + # Conflict detection (audit P1-1): two distinct sources collapsing + # to the same bundle-relative path is recorded as a conflict rather + # than silently overwriting the first write. + source_id = f"{prod}/{prof}/{obj_type}/{scope}@{resolved_path}" + seen = seen_relative_sources.setdefault(relative, set()) + if seen and source_id not in seen: + _record("conflict") + return + seen.add(source_id) + + if _SENSITIVE_FILENAME_HINT.search(source_path.name): + _record("secret_rejected") + return + + try: + if source_path.is_file(): + if obj_type == "mcp" or storage == "config-subobject": + if obj_type == "mcp": + # MCP objects never travel as raw bytes (clawscan + # 0.8.30): shared host settings files such as + # ~/.gemini/settings.json or ~/.claude.json carry + # sibling state the skill promises not to copy. + # Extract only the authorized servers subobject; + # an undecodable document is a policy exclusion, + # never a raw-copy fallback. + from migration_core import parse_mcp_document, emit_mcp_document + try: + raw_text = source_path.read_text(encoding="utf-8") + servers = parse_mcp_document(raw_text, format_name) + except ValueError: + _record("excluded_by_policy") + return + emitted_text, _ = emit_mcp_document(servers, format_name) + objects[relative] = emitted_text.encode("utf-8") + object_file_map.setdefault(obj_key, []).append(relative) + elif obj_type == "instructions": + from migration_core import parse_instruction, emit_instruction + raw_text = source_path.read_text(encoding="utf-8") + instruction = parse_instruction(raw_text, format_name, scope, storage) + emitted_text, _ = emit_instruction(instruction, format_name) + objects[relative] = emitted_text.encode("utf-8") + object_file_map.setdefault(obj_key, []).append(relative) + else: + # Refuse to copy raw host config files for unsupported + # subobject types. Audit P1-2: this is an explicit + # policy exclusion, not a silent skip. + _record("excluded_by_policy") + return + else: + objects[relative] = source_path.read_bytes() + object_file_map.setdefault(obj_key, []).append(relative) + elif source_path.is_dir(): + start_keys = set(objects.keys()) + _collect_tree(source_path, relative, objects, depth=0) + new_keys = set(objects.keys()) - start_keys + object_file_map.setdefault(obj_key, []).extend(sorted(new_keys)) + else: + _record("parse_failed") + return + except ACBSecretLeak: + _record("secret_rejected") + return + except Exception as error: + # Audit P1-2: surface parse failures; do not silently skip a + # requested portable object. The summary records parse_failed, and + # we re-raise so run_snapshot can decide whether to fail the + # snapshot rather than emit a "successful" bundle with missing + # data. + _record("parse_failed") + raise ACBError( + f"snapshot parse failed for portable object {relative}: {error}" + ) from error + + _record("captured") + + def _process_inventory_row(row: dict[str, Any]) -> None: + """Legacy path used only when plan_items is None (single-source mode).""" + if not row.get("exists"): + return + if source_product and row.get("product") != source_product: + return + if source_profile and row.get("profile") != source_profile: + return + + object_type = row.get("object_type") or "" + if not object_type: + return + policy = row.get("policy") or "" + scope = row.get("scope") or "unknown" + product = row.get("product") or "" + profile = row.get("profile") or "default" + + obj_key = f"{object_type}/{product}/{profile}/{scope}" + + if policy in FORBIDDEN_SNAPSHOT_POLICIES: + _record("excluded_by_policy") + return + if object_type in FORBIDDEN_SNAPSHOT_OBJECT_TYPES: + _record("excluded_by_policy") + return + if allowed_scopes is not None and scope not in allowed_scopes: + _record("excluded_by_policy") + return + if allowed_object_types is not None and object_type not in allowed_object_types: + _record("excluded_by_policy") + return + + resolved = row.get("resolved_path") + if not isinstance(resolved, str): + _record("parse_failed") + return + source_path = Path(resolved) + if not source_path.exists() or source_path.is_symlink(): + _record("parse_failed") + return + + canonical = row.get("canonical_path") or source_path.name + relative = _path_for_object(object_type, product, profile, scope, canonical) + + storage = row.get("storage") or "" + format_name = row.get("source_format") or row.get("format") or "" + + try: + if source_path.is_file(): + if _SENSITIVE_FILENAME_HINT.search(source_path.name): + _record("secret_rejected") + return + if object_type == "mcp" or storage == "config-subobject": + if object_type == "mcp": + # Same subobject-only contract as the plan-item + # path: shared host settings files must never be + # bundled as raw bytes. + from migration_core import parse_mcp_document, emit_mcp_document + try: + raw_text = source_path.read_text(encoding="utf-8") + servers = parse_mcp_document(raw_text, format_name) + except ValueError: + _record("excluded_by_policy") + return + emitted_text, _ = emit_mcp_document(servers, format_name) + objects[relative] = emitted_text.encode("utf-8") + object_file_map.setdefault(obj_key, []).append(relative) + elif object_type == "instructions": + from migration_core import parse_instruction, emit_instruction + raw_text = source_path.read_text(encoding="utf-8") + instruction = parse_instruction(raw_text, format_name, scope, storage) + emitted_text, _ = emit_instruction(instruction, format_name) + objects[relative] = emitted_text.encode("utf-8") + object_file_map.setdefault(obj_key, []).append(relative) + else: + _record("excluded_by_policy") + return + else: + objects[relative] = source_path.read_bytes() + object_file_map.setdefault(obj_key, []).append(relative) + elif source_path.is_dir(): + start_keys = set(objects.keys()) + _collect_tree(source_path, relative, objects, depth=0) + new_keys = set(objects.keys()) - start_keys + object_file_map.setdefault(obj_key, []).extend(sorted(new_keys)) + else: + _record("parse_failed") + return + except ACBSecretLeak: + _record("secret_rejected") + return + except Exception as error: + _record("parse_failed") + raise ACBError( + f"snapshot parse failed for {relative}: {error}" + ) from error + _record("captured") + + if plan_items is not None: + for item in plan_items: + _process_plan_item(item) + else: + for row in rows: + _process_inventory_row(row) + + return objects, summary, object_file_map + + +def _collect_tree(dir_path: Path, prefix: str, out: dict[str, bytes], depth: int = 0) -> None: + if depth > MAX_DIR_DEPTH: + return + for item in sorted(dir_path.iterdir()): + if item.is_symlink(): + continue + if _SENSITIVE_FILENAME_HINT.search(item.name): + continue + rel = f"{prefix}/{item.name}" + if item.is_file(): + out[rel] = item.read_bytes() + elif item.is_dir(): + _collect_tree(item, rel, out, depth + 1) + + +def _path_for_object( + object_type: str, product: str, profile: str, scope: str, canonical: str +) -> str: + """Build a sanitized stable relative path under ``objects/``.""" + safe_canonical = canonical.strip("/\\").replace("~", "home").replace("..", "_") + safe_canonical = re.sub(r"[/\\:]+", "/", safe_canonical) + return f"{object_type}/{product}/{profile}/{scope}/{safe_canonical}" + + +def restore_bundle_objects( + bundle_root: Path, + destination_root: Path, + *, + dry_run: bool = False, +) -> dict[str, Any]: + """Extract files from ``bundle/objects/`` safely into destination tree.""" + bundle_root = bundle_root.resolve() + destination_root = destination_root.resolve() + objects_root = bundle_root / ACB_OBJECTS_DIR + if not objects_root.is_dir(): + raise ACBError(f"bundle has no objects/ directory: {bundle_root}") + written: list[dict[str, Any]] = [] + skipped: list[dict[str, str]] = [] + for source in sorted(objects_root.rglob("*")): + if not source.is_file() or source.is_symlink(): + continue + relative = source.relative_to(objects_root).as_posix() + try: + target = validate_path_containment(relative, destination_root) + if target.is_symlink(): + raise ACBIntegrityError(f"target path is a symlink: {target}") + except ACBIntegrityError as error: + skipped.append({"path": relative, "reason": str(error)}) + continue + + try: + data = source.read_bytes() + scan_object_bytes(data, relative) + except ACBSecretLeak as error: + skipped.append({"path": relative, "reason": str(error)}) + continue + + if not dry_run: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + written.append( + { + "path": relative, + "sha256": sha256_file(source), + "size": source.stat().st_size, + } + ) + return { + "bundle": str(bundle_root), + "destination": str(destination_root), + "written": written, + "skipped": skipped, + "dry_run": dry_run, + } + + +def verify_bundle(bundle_root: Path) -> list[str]: + """Perform closed-world verification of ACB bundle integrity.""" + bundle_root = bundle_root.resolve() + if not bundle_root.is_dir(): + return [f"bundle directory not found: {bundle_root}"] + + checksums_path = bundle_root / ACB_CHECKSUMS_NAME + if not checksums_path.is_file() or checksums_path.is_symlink(): + return [f"missing or invalid {ACB_CHECKSUMS_NAME}"] + + try: + checksums = json.loads(checksums_path.read_text(encoding="utf-8")) + except Exception as error: + return [f"corrupted {ACB_CHECKSUMS_NAME}: {error}"] + + errors: list[str] = [] + + # 1. Closed-world file enumeration: actual files == expected files + expected_files = set(checksums.keys()) + actual_files: set[str] = set() + + for path in sorted(bundle_root.rglob("*")): + # Reject non-regular files: symlinks, sockets, FIFOs, devices + st = path.lstat() + if stat.S_ISLNK(st.st_mode) or stat.S_ISFIFO(st.st_mode) or stat.S_ISSOCK(st.st_mode) or stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode): + errors.append(f"illegal non-regular file in bundle: {path.relative_to(bundle_root).as_posix()}") + continue + if path.is_file(): + rel_posix = path.relative_to(bundle_root).as_posix() + if rel_posix not in (ACB_CHECKSUMS_NAME, ACB_SIGNATURE_NAME): + actual_files.add(rel_posix) + + extra_files = actual_files - expected_files + if extra_files: + for extra in sorted(extra_files): + errors.append(f"unexpected extra file in bundle: {extra}") + + missing_files = expected_files - actual_files + if missing_files: + for missing in sorted(missing_files): + errors.append(f"missing file: {missing}") + + # 2. Checksum validation for all listed files + for relative, expected in sorted(checksums.items()): + target = bundle_root / relative + if target.is_file() and not target.is_symlink(): + actual = sha256_file(target) + if actual != expected: + errors.append(f"checksum mismatch: {relative}") + + # 3. Validate JSON schemas & secret scans + for json_name in ACB_JSON_FILES: + target = bundle_root / json_name + if target.is_file(): + try: + payload = json.loads(target.read_text(encoding="utf-8")) + assert_no_lateral_secrets(payload) + except Exception as error: + errors.append(f"invalid JSON payload in {json_name}: {error}") + + # 3b. Re-scan every stored object with the same strict secret/binary + # scanner used at write time (audit #7). A bundle that passed write-time + # scanning but was later tampered (or supplied by an untrusted source) + # must still be rejected at verify time. We also re-apply the + # resource safety limits (file count, per-file size, total size, depth). + objects_root = bundle_root / ACB_OBJECTS_DIR + if not objects_root.is_dir(): + errors.append(f"bundle has no {ACB_OBJECTS_DIR}/ directory") + else: + total_object_bytes = 0 + max_depth_seen = 0 + object_count = 0 + for source in sorted(objects_root.rglob("*")): + if not source.is_file() or source.is_symlink(): + continue + object_count += 1 + rel = source.relative_to(objects_root).as_posix() + depth = len(Path(rel).parts) + max_depth_seen = max(max_depth_seen, depth) + if object_count > MAX_BUNDLE_FILES: + errors.append( + f"object file count exceeded limit ({MAX_BUNDLE_FILES}): {rel}" + ) + break + try: + data = source.read_bytes() + except Exception as error: + errors.append(f"cannot read object {rel}: {error}") + continue + if len(data) > MAX_FILE_SIZE: + errors.append( + f"object size exceeded limit ({MAX_FILE_SIZE} bytes): {rel}" + ) + total_object_bytes += len(data) + try: + scan_object_bytes(data, rel) + except ACBSecretLeak as error: + errors.append(f"secret/binary violation in object {rel}: {error}") + if max_depth_seen > MAX_DIR_DEPTH: + errors.append( + f"object directory depth exceeded limit ({MAX_DIR_DEPTH}): {max_depth_seen}" + ) + if total_object_bytes > MAX_TOTAL_SIZE: + errors.append( + f"object total size exceeded limit ({MAX_TOTAL_SIZE} bytes)" + ) + + # 4. Closed-world 1:1 Manifest-to-Objects verification + manifest_path = bundle_root / ACB_MANIFEST_NAME + if manifest_path.is_file() and not manifest_path.is_symlink(): + try: + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + declared_objects = manifest_data.get("objects", []) + manifest_files: set[str] = set() + file_claimants: dict[str, list[str]] = {} + for obj in declared_objects: + obj_path = obj.get("object_path") or f"{obj.get('object_type')}/{obj.get('product')}/{obj.get('profile')}/{obj.get('scope')}" + obj_status = obj.get("status", "") + obj_files = obj.get("files", []) + if obj_status in {"ready", "ready-lossy"} and not obj_files: + errors.append(f"manifest object {obj_path} ({obj_status}) declares no files") + for file_entry in obj_files: + rel_p = file_entry.get("path", "") + if rel_p: + manifest_files.add(rel_p) + file_claimants.setdefault(rel_p, []).append(obj_path) + expected_sha = file_entry.get("sha256") + disk_p = bundle_root / rel_p + if not disk_p.is_file(): + errors.append(f"manifest declared file missing on disk: {rel_p}") + elif expected_sha: + actual_sha = sha256_file(disk_p) + if actual_sha != expected_sha: + errors.append( + f"manifest file sha256 mismatch for {rel_p}: expected {expected_sha}, got {actual_sha}" + ) + + # Enforce 1:1: Each file must be claimed by at most one object + for rel_p, claimants in file_claimants.items(): + if len(claimants) > 1: + errors.append( + f"manifest file {rel_p} claimed by multiple objects: {claimants}" + ) + + # If manifest declared specific object files, ensure no undeclared files exist in objects/ + objects_root = bundle_root / ACB_OBJECTS_DIR + if objects_root.is_dir() and manifest_files: + for disk_file in sorted(objects_root.rglob("*")): + if disk_file.is_file(): + rel = f"{ACB_OBJECTS_DIR}/{disk_file.relative_to(objects_root).as_posix()}" + if rel not in manifest_files: + errors.append(f"unclaimed file in objects directory not declared in manifest: {rel}") + except Exception as error: + errors.append(f"manifest verification error: {error}") + + return errors + + +# Audit P1-5 (0.8.27): Ed25519 over `checksums.json`. cryptography is +# lazy-imported so snapshot / restore work without it; sign / verify +# fail fast with a clear message if it is missing. +ACB_SIGNATURE_NAME = "signature.json" +ACB_SIGNATURE_SCHEMA_VERSION = 2 +_ED25519_KEY_BYTES = 32 + + +def _read_signing_key(key_path: Path) -> bytes: + if not key_path.is_file(): + raise ACBError(f"signing key not found: {key_path}") + # Refuse group/world bits: a leaked signing key is a leaked bundle. + if stat.S_IMODE(key_path.stat().st_mode) & 0o077: + raise ACBError( + f"signing key {key_path} is group/world accessible; chmod 600 before use" + ) + raw = key_path.read_bytes() + if len(raw) != _ED25519_KEY_BYTES: + raise ACBError( + f"signing key must be {_ED25519_KEY_BYTES} raw bytes; got {len(raw)}" + ) + return raw + + +def _load_public_key(key_path: Path) -> bytes: + if not key_path.is_file(): + raise ACBError(f"public key not found: {key_path}") + # Public keys are non-secret but we still refuse symlinks so the + # caller can rely on the path being the actual file. + if key_path.is_symlink(): + raise ACBError(f"public key path is a symlink: {key_path}") + raw = key_path.read_bytes() + if len(raw) != _ED25519_KEY_BYTES: + raise ACBError( + f"public key must be {_ED25519_KEY_BYTES} raw bytes; got {len(raw)}" + ) + return raw + + +def _ensure_cryptography() -> tuple[Any, Any, Any]: + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, + ) + from cryptography.hazmat.primitives import serialization + except ImportError as error: + raise ACBError( + "Ed25519 signing requires the 'cryptography' package. " + "Install with: pip install cryptography" + ) from error + return Ed25519PrivateKey, Ed25519PublicKey, serialization + + +def _build_signature_document( + public_key: Any, + private_key: Any, + payload: bytes, + signer: str, +) -> dict[str, Any]: + from cryptography.hazmat.primitives import serialization + public_bytes = public_key.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw, + ) + return { + "schema_version": ACB_SIGNATURE_SCHEMA_VERSION, + "algorithm": "ed25519", + "signer": signer, + "signed_at": datetime.now(timezone.utc).isoformat(), + "checksum_algorithm": "sha256", + "bundle_hash": sha256_bytes(payload), + "public_key": base64.b64encode(public_bytes).decode("ascii"), + "public_key_fingerprint": sha256_bytes(public_bytes)[:16], + "signature": base64.b64encode(private_key.sign(payload)).decode("ascii"), + } + + +def _load_signature_artifact( + bundle_root: Path, +) -> tuple[dict[str, Any] | None, bytes, list[str]]: + sig_path = bundle_root / ACB_SIGNATURE_NAME + checksums_path = bundle_root / ACB_CHECKSUMS_NAME + errors: list[str] = [] + if not sig_path.is_file(): + return None, checksums_path, [f"missing signature: {sig_path}"] + if not checksums_path.is_file(): + return None, checksums_path, [f"missing {ACB_CHECKSUMS_NAME}"] + try: + return json.loads(sig_path.read_text(encoding="utf-8")), checksums_path.read_bytes(), errors + except Exception as error: + return None, checksums_path, [f"corrupted signature.json: {error}"] + + +def _check_signature_metadata( + sig_doc: dict[str, Any], + payload: bytes, +) -> list[str]: + errors: list[str] = [] + if sig_doc.get("algorithm") != "ed25519": + errors.append( + f"unsupported signature algorithm: {sig_doc.get('algorithm')!r} " + "(expected 'ed25519')" + ) + if sig_doc.get("schema_version") != ACB_SIGNATURE_SCHEMA_VERSION: + errors.append( + f"unsupported signature schema_version: {sig_doc.get('schema_version')!r} " + f"(expected {ACB_SIGNATURE_SCHEMA_VERSION})" + ) + if sig_doc.get("bundle_hash") != sha256_bytes(payload): + errors.append( + f"bundle_hash mismatch: signature={sig_doc.get('bundle_hash')} " + f"actual={sha256_bytes(payload)}" + ) + return errors + + +def _decode_signature_fields(sig_doc: dict[str, Any]) -> tuple[bytes, bytes] | list[str]: + sig_b64 = sig_doc.get("signature") + pub_b64 = sig_doc.get("public_key") + if not isinstance(sig_b64, str) or not isinstance(pub_b64, str): + return ["signature.json missing signature/public_key fields"] + try: + return base64.b64decode(sig_b64, validate=True), base64.b64decode(pub_b64, validate=True) + except Exception as error: + return [f"signature.json fields are not valid base64: {error}"] + + +def _verify_signature_payload( + public_key: Any, + signature: bytes, + payload: bytes, +) -> list[str]: + try: + public_key.verify(signature, payload) + except Exception as error: + return [f"signature verification failed: {error}"] + return [] + + +def sign_bundle(bundle_root: Path, key_path: Path, signer: str) -> Path: + Ed25519PrivateKey, _, _ = _ensure_cryptography() + bundle_root = bundle_root.resolve() + checksums_path = bundle_root / ACB_CHECKSUMS_NAME + if not checksums_path.is_file(): + raise ACBError(f"cannot sign: {checksums_path} missing") + private_key = Ed25519PrivateKey.from_private_bytes(_read_signing_key(key_path)) + payload = checksums_path.read_bytes() + signature_doc = _build_signature_document( + private_key.public_key(), + private_key, + payload, + signer, + ) + sig_path = bundle_root / ACB_SIGNATURE_NAME + sig_path.write_text( + json.dumps(signature_doc, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return sig_path + + +def verify_bundle_signature(bundle_root: Path, key_path: Path) -> list[str]: + _, Ed25519PublicKeyClass, _ = _ensure_cryptography() + bundle_root = bundle_root.resolve() + sig_doc, payload, load_errors = _load_signature_artifact(bundle_root) + if load_errors: + return load_errors + assert sig_doc is not None + errors = _check_signature_metadata(sig_doc, payload) + decoded = _decode_signature_fields(sig_doc) + if isinstance(decoded, list): + return errors + decoded + signature, signature_public_key = decoded + try: + trusted_public_key = _load_public_key(key_path) + except ACBError as error: + return errors + [str(error)] + if signature_public_key != trusted_public_key: + errors.append( + "public key in signature.json does not match trusted_key " + f"(fingerprint mismatch: signature={sha256_bytes(signature_public_key)[:16]} " + f"trusted={sha256_bytes(trusted_public_key)[:16]})" + ) + return errors + _verify_signature_payload( + Ed25519PublicKeyClass.from_public_bytes(trusted_public_key), + signature, + payload, + ) + + +class BundleSurfaceProvider: + """Provides virtual surface items and content from verified bundle objects.""" + + def __init__(self, bundle_root: Path): + self.bundle_root = bundle_root.resolve() + self.objects_root = self.bundle_root / ACB_OBJECTS_DIR + self.manifest = load_manifest(self.bundle_root) + + def get_object_tree(self, object_type: str, product: str, profile: str, scope: str) -> list[Path]: + """Find all files belonging to a specific surface object in the bundle.""" + target_dir = self.objects_root / object_type / product / profile / scope + if not target_dir.is_dir(): + return [] + return sorted(p for p in target_dir.rglob("*") if p.is_file()) + + +def load_manifest(bundle_root: Path) -> ACBManifest: + bundle_root = bundle_root.resolve() + return ACBManifest.from_dict( + json.loads((bundle_root / ACB_MANIFEST_NAME).read_text(encoding="utf-8")) + ) + + +def make_bundle_id(timestamp: datetime | None = None) -> str: + timestamp = timestamp or datetime.now(timezone.utc) + return f"acb-{timestamp.strftime('%Y%m%dT%H%M%SZ')}" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/check-doc-freshness.py b/skills/agent-skills-setup/agent-skills-setup/scripts/check-doc-freshness.py new file mode 100644 index 000000000..9ae6b1ca2 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/check-doc-freshness.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Validate source provenance offline for curated official docs.""" + +import sys +from pathlib import Path + +# Ensure scripts directory is on sys.path for direct invocation +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from doc_freshness import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/common.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/common.sh new file mode 100644 index 000000000..ee301f32f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/common.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +log_warn() { printf '[WARN] %s\n' "$*" >&2; } +log_error() { printf '[ERROR] %s\n' "$*" >&2; } + +status_token() { + case "$1" in + success|copied|ok) printf 'OK' ;; + manual|partial|warn) printf 'WARN' ;; + failed|error) printf 'FAIL' ;; + skipped|absent|none) printf '-' ;; + *) printf '?' ;; + esac +} + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + printf '%s' "$s" +} diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/context-migrator.py b/skills/agent-skills-setup/agent-skills-setup/scripts/context-migrator.py new file mode 100644 index 000000000..5566ebe5a --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/context-migrator.py @@ -0,0 +1,2065 @@ +#!/usr/bin/env python3 +"""Safe profile-aware CLI for agent context migration.""" + +from __future__ import annotations + +import argparse +import contextlib +import copy +import hashlib +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_SCRIPT_DIR = str(Path(__file__).resolve().parent) +if _SCRIPT_DIR not in sys.path: + sys.path.insert(0, _SCRIPT_DIR) + +from migration_core import ( + ADAPTER_VERSIONS, + AUTOMATIC_MIGRATION_POLICIES, + AUTOMATIC_OBJECT_TYPES, + INVENTORY_ONLY_OBJECT_TYPES, + KNOWN_COMMANDS, + OPT_IN_WRITABLE_OBJECT_TYPES, + Registry, + apply_plan, + atomic_write, + build_plan, + build_plan_document, + choose_surface, + git_provenance, + hash_path, + json_sha256, + load_plan_document, + parse_mcp_document, + path_state, + paths_overlap, + rollback_manifest, + validate_plan_document, + verify_manifest, + _plan_hash_payload, +) + +from acb.bundle import ( + ACB_CHECKSUMS_NAME, + ACB_OBJECTS_DIR, + ACB_SCHEMA_VERSION, + ACBError, + ACBManifest, + ACBSecretLeak, + collect_reauth, + collect_rebuild, + collect_requirements, + collect_source_objects, + load_manifest, + make_bundle_id, + restore_bundle_objects, + sign_bundle, + verify_bundle, + verify_bundle_signature, + write_bundle, +) + + +SCRIPT_DIR = Path(__file__).resolve().parent +REGISTRY_PATH = SCRIPT_DIR.parent / "references" / "registry-v2.json" +LEGACY_SCRIPT = SCRIPT_DIR / "legacy-smart-ide-migration.sh" + + +def emit(value: Any, as_json: bool) -> None: + if as_json: + print(json.dumps(value, indent=2, sort_keys=True)) + return + if isinstance(value, list): + for row in value: + print(json.dumps(row, sort_keys=True)) + elif isinstance(value, dict): + print(json.dumps(value, indent=2, sort_keys=True)) + else: + print(value) + + +def common_workspace(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--registry", type=Path, default=REGISTRY_PATH) + parser.add_argument("--json", action="store_true") + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Inventory, plan, apply, verify, and roll back agent context migrations." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + detect = subparsers.add_parser("detect") + common_workspace(detect) + detect.add_argument("--product") + detect.add_argument("--profile") + + inventory = subparsers.add_parser("inventory") + common_workspace(inventory) + inventory.add_argument("--product") + inventory.add_argument("--profile") + + plan = subparsers.add_parser("plan") + common_workspace(plan) + plan.add_argument("--source", required=True) + plan.add_argument("--target", required=True) + plan.add_argument( + "--objects", default="skills,instructions,mcp", help="comma-separated surfaces" + ) + plan.add_argument( + "--scope", choices=("user", "project", "local", "all"), default="project" + ) + plan.add_argument("--output", type=Path) + + migrate = subparsers.add_parser( + "migrate", + help="One-sentence migration: detect -> inventory -> plan -> apply -> verify.", + ) + common_workspace(migrate) + migrate.add_argument("--source", required=True, help="/") + migrate.add_argument("--target", required=True, help="/") + migrate.add_argument( + "--objects", + default="all-portable", + help=( + "Comma-separated object list, 'all-portable' (default), or " + "'all-inventory' (also records forbidden/generated items)." + ), + ) + migrate.add_argument( + "--scope", + default="user,project", + help="user, project, user+project, all (all requires --yes)", + ) + migrate.add_argument( + "--plan-only", action="store_true", help="Stop after planning." + ) + migrate.add_argument("--plan-out", type=Path) + migrate.add_argument("--manifest-out", type=Path) + migrate.add_argument("--verify-out", type=Path) + migrate.add_argument("--yes", action="store_true") + migrate.add_argument( + "--include", + dest="include_lossy", + choices=("lossy",), + help="Also apply ready-lossy items.", + ) + migrate.add_argument( + "--accept-loss", + dest="accept_loss", + default="", + help="Comma-separated plan indices to apply as lossy.", + ) + migrate.add_argument( + "--strict", + action="store_true", + help="Reject plans containing any non-ready item.", + ) + + migrate.add_argument( + "--include-session", + dest="include_session", + action="store_true", + help=( + "Explicitly opt in to handoff/session transfer " + "(whitelisted fields only: reviewed summary, git branch, " + "selected file list, reviewed patch)." + ), + ) + migrate.add_argument( + "--include-plugins", + dest="include_plugins", + action="store_true", + help="Explicitly opt in to plugin package transfer.", + ) + apply = subparsers.add_parser("apply") + apply.add_argument("plan", type=Path) + apply.add_argument("--registry", type=Path, default=REGISTRY_PATH) + apply.add_argument("--manifest", type=Path) + apply.add_argument( + "--bundle", + type=Path, + help="Path to .acb bundle archive for bundle-backed restore plans.", + ) + apply.add_argument("--yes", action="store_true") + apply.add_argument("--json", action="store_true") + apply.add_argument( + "--apply-safe", + dest="apply_safe", + action="store_true", + default=True, + help="Apply ready and draft-disabled items; manifest the rest (default).", + ) + apply.add_argument( + "--no-apply-safe", + dest="apply_safe", + action="store_false", + help="Disable safe apply; require every item to be ready.", + ) + apply.add_argument( + "--include", + dest="include_lossy", + choices=("lossy",), + help="Include lossy items alongside ready items.", + ) + apply.add_argument( + "--accept-loss", + dest="accept_loss", + default="", + help="Comma-separated plan indices to apply as lossy even without --include lossy.", + ) + apply.add_argument( + "--strict", + action="store_true", + help="Reject any plan containing a non-ready item (legacy semantics).", + ) + + apply.add_argument( + "--include-session", + dest="include_session", + action="store_true", + help=( + "Explicitly opt in to handoff/session transfer for replayed " + "plans (whitelisted fields only)." + ), + ) + apply.add_argument( + "--include-plugins", + dest="include_plugins", + action="store_true", + help="Explicitly opt in to plugin package transfer.", + ) + verify = subparsers.add_parser("verify") + verify.add_argument("--manifest", type=Path, required=True) + verify.add_argument("--json", action="store_true") + + rollback = subparsers.add_parser("rollback") + rollback.add_argument("--manifest", type=Path, required=True) + rollback.add_argument("--yes", action="store_true") + rollback.add_argument("--json", action="store_true") + + legacy = subparsers.add_parser( + "legacy", help="run the explicit lookup and zero-write compatibility interface" + ) + legacy.add_argument("legacy_args", nargs=argparse.REMAINDER) + + snapshot = subparsers.add_parser( + "snapshot", + help="Capture a portable Agent Context Bundle (ACB) of the current device.", + ) + common_workspace(snapshot) + snapshot.add_argument("--output", type=Path, help="Bundle output directory (default: /device.acb).") + snapshot.add_argument("--source", default="cline/ide") + snapshot.add_argument("--target", default="forge/cli") + snapshot.add_argument("--scope", default="user,project") + snapshot.add_argument( + "--all-installed", + action="store_true", + help="Snapshot all detected and installed products on this device.", + ) + snapshot.add_argument( + "--include-configured", + action="store_true", + help="Include products detected in configured-only state.", + ) + snapshot.add_argument( + "--include-compatibility", + action="store_true", + help="Include products detected in compatibility-only state.", + ) + + verify_bundle = subparsers.add_parser( + "bundle-verify", + help="Verify checksums and optional Ed25519 signature inside an ACB directory.", + ) + verify_bundle.add_argument("bundle", type=Path) + verify_bundle.add_argument( + "--trusted-key", + type=Path, + help="Path to trusted Ed25519 public key file to verify bundle signature.", + ) + verify_bundle.add_argument("--json", action="store_true", default=True) + + bundle_sign = subparsers.add_parser( + "bundle-sign", + help="Sign an ACB directory with an Ed25519 private key.", + ) + bundle_sign.add_argument("bundle", type=Path) + bundle_sign.add_argument( + "--key", + type=Path, + required=True, + help="Path to Ed25519 private key file (must be chmod 600).", + ) + bundle_sign.add_argument( + "--signer", + default="local-operator", + help="Identifier or name of the signer.", + ) + bundle_sign.add_argument("--json", action="store_true", default=True) + + bundle_keygen = subparsers.add_parser( + "bundle-keygen", + help="Generate Ed25519 keypair for signing and verifying ACBs.", + ) + bundle_keygen.add_argument( + "--out-private", + type=Path, + required=True, + help="Destination path for private key file (will be chmod 600).", + ) + bundle_keygen.add_argument( + "--out-public", + type=Path, + required=True, + help="Destination path for public key file.", + ) + bundle_keygen.add_argument("--json", action="store_true", default=True) + + restore = subparsers.add_parser( + "restore", + help="Verify an ACB and rebuild a local restore plan against the current device.", + ) + common_workspace(restore) + restore.add_argument("bundle", type=Path) + restore.add_argument("--source", default="cline/ide") + restore.add_argument("--target", default="forge/cli") + restore.add_argument("--scope", default="user,project") + restore.add_argument( + "--all-installed", + action="store_true", + help="Restore context across all detected and installed target products.", + ) + restore.add_argument( + "--include-configured", + action="store_true", + help="Include target products detected in configured-only state.", + ) + restore.add_argument( + "--include-compatibility", + action="store_true", + help="Include target products detected in compatibility-only state.", + ) + restore.add_argument( + "--include-session", + dest="include_session", + action="store_true", + help="Explicitly opt in to handoff/session transfer.", + ) + restore.add_argument( + "--include-plugins", + dest="include_plugins", + action="store_true", + help="Explicitly opt in to plugin package transfer.", + ) + restore.add_argument( + "--trusted-key", + type=Path, + help="Path to trusted Ed25519 public key file to verify bundle signature.", + ) + restore.add_argument("--plan-out", type=Path) + restore.add_argument( + "--plan-in", + "--plan", + dest="plan_in", + type=Path, + help="Replay a previously reviewed plan document.", + ) + restore.add_argument("--manifest-out", type=Path) + restore.add_argument("--apply-safe", action="store_true", default=True) + restore.add_argument( + "--no-apply-safe", dest="apply_safe", action="store_false" + ) + restore.add_argument( + "--include", dest="include_lossy", choices=("lossy",), + ) + restore.add_argument("--strict", action="store_true") + restore.add_argument("--yes", action="store_true") + restore.add_argument( + "--plan-only", + action="store_true", + help="Build and review restore plan without applying to target surfaces.", + ) + restore.add_argument( + "--restore-root", + type=Path, + help="Destination tree for bundle/objects/ restore (default: /.acb-restored).", + ) + restore.add_argument( + "--allow-noop", + action="store_true", + help="Allow restore to succeed with zero applied items (otherwise a bundle that resolves no eligible items is a hard failure).", + ) + restore.add_argument( + "--dry-run", + action="store_true", + help="Plan and stage objects/ without writing.", + ) + + doctor = subparsers.add_parser( + "doctor", + help="Inspect an ACB and surface missing executables / re-auth actions.", + ) + doctor.add_argument("bundle", type=Path) + doctor.add_argument("--json", action="store_true", default=True) + return parser + + +def selector(product: str | None, profile: str | None) -> str | None: + if not product: + if profile: + raise ValueError("--profile requires --product") + return None + return f"{product}/{profile}" if profile else product + + +def reject_legacy_write(argv: list[str]) -> None: + """Enforce the legacy subcommand as strictly read-only. + + The guarantee is structural, not flag filtering (audit SDI-4 / + AST4): only the documented read-only modes --print-path and + --dry-run are allowed through to the legacy engine at all. Any + other invocation — with or without --yes — is refused here. + """ + if "--yes" in argv or "-y" in argv: + raise ValueError( + "legacy writes are disabled; create a saved plan with 'plan --output', " + "then apply that exact plan file" + ) + if not argv or "--help" in argv or "-h" in argv: + return + readonly = "--print-path" in argv or "--dry-run" in argv + if not readonly: + raise ValueError( + "legacy subcommand is read-only lookup compatibility only: " + "pass --print-path or --dry-run. " + "Use 'plan' / 'apply' for migrations." + ) + + +def resolve_objects(value: str) -> list[str]: + """Translate --objects shorthand into an explicit object list.""" + tokens = [token.strip() for token in value.split(",") if token.strip()] + if not tokens: + return ["skills", "instructions", "mcp"] + if tokens == ["all-portable"]: + return ["skills", "instructions", "mcp"] + if tokens == ["all-inventory"]: + return [ + "skills", + "instructions", + "mcp", + *sorted(INVENTORY_ONLY_OBJECT_TYPES), + ] + return tokens + + +def default_workspace_migration_dir(workspace: Path) -> Path: + return workspace / ".migration" + + +def run_detection(args: argparse.Namespace) -> int: + """Run per-product detection probes against the local device. + + Uses the Registry v2 ``detection`` block on each profile (binary, + file-signature, app-bundle). Detection is PROBE-ONLY: inventory.exists + is NOT used as a fallback to claim "installed" (audit P0-3). + Returns one ``InstallState`` per profile. + """ + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from detect.probes import ( + detect_profile, + detect_product, + probe_binary, + probe_file_signature, + InstallState, + ) + workspace = args.workspace.resolve() + registry = Registry(args.registry, workspace) + home = registry.home + profiles_to_check: set[tuple[str, str]] = set() + for product_id, product in registry.products.items(): + for profile_id in product.get("profiles", {}): + profiles_to_check.add((product_id, profile_id)) + + filter_prod = getattr(args, "product", None) + filter_prof = getattr(args, "profile", None) + if filter_prod: + profiles_to_check = {p for p in profiles_to_check if p[0] == filter_prod} + if filter_prof: + profiles_to_check = {p for p in profiles_to_check if p[1] == filter_prof} + + detections: list[dict[str, str]] = [] + for product_id, profile_id in sorted(profiles_to_check): + product = registry.products[product_id] + profile = product["profiles"][profile_id] + detection = profile.get("detection", []) or [] + profile_state = InstallState.NOT_DETECTED + profile_evidence: list[str] = [] + for probe in detection: + if not isinstance(probe, dict): + continue + kind = probe.get("type") + if kind == "binary": + names = probe.get("command") or probe.get("binaries") or [] + version_command = probe.get("version_command") + if isinstance(names, str): + names = [names] + result = probe_binary( + product_id, profile_id, names, + version_command=version_command, + ) + # Use the most definitive state across all probes for this profile + # Priority: INSTALLED > CONFIGURED_ONLY > COMPATIBILITY_ONLY > CLOUD_CONNECTED > LEGACY > AMBIGUOUS > NOT_DETECTED + if result.state.value == "installed": + profile_state = InstallState.INSTALLED + profile_evidence.extend(result.evidence) + break # INSTALLED is definitive + elif result.state.value == "configured-only" and profile_state not in (InstallState.INSTALLED,): + profile_state = InstallState.CONFIGURED_ONLY + profile_evidence.extend(result.evidence) + elif result.state.value == "compatibility-only" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY): + profile_state = InstallState.COMPATIBILITY_ONLY + profile_evidence.extend(result.evidence) + elif result.state.value == "cloud-connected" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY): + profile_state = InstallState.CLOUD_CONNECTED + profile_evidence.extend(result.evidence) + elif result.state.value == "legacy" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY, InstallState.CLOUD_CONNECTED): + profile_state = InstallState.LEGACY + profile_evidence.extend(result.evidence) + elif result.state.value == "ambiguous" and profile_state == InstallState.NOT_DETECTED: + profile_state = InstallState.AMBIGUOUS + profile_evidence.extend(result.evidence) + # NOT_DETECTED doesn't change anything + elif kind == "file-signature": + paths = probe.get("paths") or [] + result = probe_file_signature( + product_id, profile_id, paths, + workspace=workspace, home=home, + ) + if result.state.value == "installed": + profile_state = InstallState.INSTALLED + profile_evidence.extend(result.evidence) + break + elif result.state.value == "configured-only" and profile_state not in (InstallState.INSTALLED,): + profile_state = InstallState.CONFIGURED_ONLY + profile_evidence.extend(result.evidence) + elif result.state.value == "compatibility-only" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY): + profile_state = InstallState.COMPATIBILITY_ONLY + profile_evidence.extend(result.evidence) + elif result.state.value == "cloud-connected" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY): + profile_state = InstallState.CLOUD_CONNECTED + profile_evidence.extend(result.evidence) + elif result.state.value == "legacy" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY, InstallState.CLOUD_CONNECTED): + profile_state = InstallState.LEGACY + profile_evidence.extend(result.evidence) + elif result.state.value == "ambiguous" and profile_state == InstallState.NOT_DETECTED: + profile_state = InstallState.AMBIGUOUS + profile_evidence.extend(result.evidence) + elif kind == "app-bundle": + result = detect_product( + product_id, profile_id, + app_bundle_id=probe.get("darwin_bundle_id"), + ) + if result.state.value == "installed": + profile_state = InstallState.INSTALLED + profile_evidence.extend(result.evidence) + break + elif result.state.value == "configured-only" and profile_state not in (InstallState.INSTALLED,): + profile_state = InstallState.CONFIGURED_ONLY + profile_evidence.extend(result.evidence) + elif result.state.value == "compatibility-only" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY): + profile_state = InstallState.COMPATIBILITY_ONLY + profile_evidence.extend(result.evidence) + elif result.state.value == "cloud-connected" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY): + profile_state = InstallState.CLOUD_CONNECTED + profile_evidence.extend(result.evidence) + elif result.state.value == "legacy" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY, InstallState.CLOUD_CONNECTED): + profile_state = InstallState.LEGACY + profile_evidence.extend(result.evidence) + elif result.state.value == "ambiguous" and profile_state == InstallState.NOT_DETECTED: + profile_state = InstallState.AMBIGUOUS + profile_evidence.extend(result.evidence) + # Other probe types (vscode-extension, schema-probe, cloud-account, environment) + # are declared in Registry but not yet implemented in probes.py + + # Targeted fallback: check inventory for workspace-relative paths only. + # Home-relative paths (user-scoped) are covered by probes; workspace-relative + # paths (project-scoped) may not have probes but are valid installations. + # This avoids the old bug where inventory.exists claimed "installed" for + # shared/compatibility-only paths like AGENTS.md or .agents/skills. + if profile_state is InstallState.NOT_DETECTED: + rows = registry.inventory(f"{product_id}/{profile_id}") + for row in rows: + if not row.get("exists"): + continue + resolved = row.get("resolved_path") + if not resolved: + continue + resolved_path = Path(resolved) + # Only claim INSTALLED if the path is under workspace (project-scoped) + # and NOT under home (user-scoped). Home paths should be caught by probes. + try: + is_under_workspace = resolved_path.is_relative_to(workspace) + is_under_home = resolved_path.is_relative_to(home) + except ValueError: + is_under_workspace = False + is_under_home = False + if is_under_workspace and not is_under_home: + # Check if this is a shared/compatibility path + c_path = row.get("canonical_path", "") + role = row.get("location_role", "canonical") + if c_path in ("AGENTS.md", ".agents/skills", ".agents") or role != "canonical": + profile_state = InstallState.COMPATIBILITY_ONLY + else: + profile_state = InstallState.INSTALLED + profile_evidence.append(f"inventory:{row.get('object_type')}:{c_path}") + break + + detections.append( + { + "product": product_id, + "profile": profile_id, + "state": profile_state.value, + "evidence": profile_evidence, + } + ) + emit( + { + "ok": True, + "stage": "detect", + "platform": sys.platform, + "home": str(home), + "detections": detections, + }, + args.json, + ) + return 0 + + +def run_snapshot(args: argparse.Namespace) -> int: + """Capture a portable ACB snapshot of the current device. + + Strict Snapshot Allowlist (audit P0-2): + - Collects only the requested source product/profile and requested scope(s). + - Collects only portable object types (skills, instructions, mcp) in the migration plan. + - Strictly rejects forbidden-regenerate, never-migrate, session, chat, runtime, + database, generated memory, and trust/credential stores. + """ + workspace = args.workspace.resolve() + registry = Registry(args.registry, workspace) + bundle_root = (args.output or workspace / "device.acb").resolve(strict=False) + inventory_rows = registry.inventory(None) + detect_rows = [row for row in inventory_rows if row.get("exists")] + + requested_scopes = { + s.strip().lower() + for s in (args.scope or "user,project").split(",") + if s.strip() + } + if "all" in requested_scopes: + requested_scopes = {"user", "project", "local"} + allowed_object_types = set( + resolve_objects(getattr(args, "objects", "skills,instructions,mcp")) + ) + + all_installed = getattr(args, "all_installed", False) or args.source in ("auto", "all-installed") + source_product, source_profile = ( + (None, None) + if all_installed + else (args.source.split("/", 1) if "/" in args.source else (args.source, None)) + ) + + if all_installed: + # Auto-orchestrate snapshot across all detected and installed products. + # + # Audit P0-3 (0.8.27): detection result is the SINGLE source of truth. + # inventory_rows.exists must NEVER be used as a fallback to claim + # "installed" — that previously masked failing detection probes and + # produced bundles that claimed to contain a product with no files. + from detect.probes import detect_profile, InstallState + detected_selectors: set[str] = set() # "product/profile" pairs + detection_status: dict[str, str] = {} # "product/profile" -> state + for prod_id, prod in registry.products.items(): + for prof_id, prof in prod.get("profiles", {}).items(): + detection = prof.get("detection", []) or [] + profile_state = InstallState.NOT_DETECTED + profile_evidence: list[str] = [] + for probe in detection: + if not isinstance(probe, dict): + continue + paths = probe.get("paths", []) + binaries = probe.get("command") or probe.get("binaries") or [] + if isinstance(binaries, str): + binaries = [binaries] + res = detect_profile( + prod_id, + prof_id, + binaries=binaries, + file_signatures=paths, + home=registry.home, + workspace=workspace, + app_bundle_id=probe.get("darwin_bundle_id"), + ) + # Use the most definitive state across all probes for this profile + # Priority: INSTALLED > CONFIGURED_ONLY > COMPATIBILITY_ONLY > CLOUD_CONNECTED > LEGACY > AMBIGUOUS > NOT_DETECTED + if res.state.value == "installed": + profile_state = InstallState.INSTALLED + profile_evidence.extend(res.evidence) + break # INSTALLED is definitive + elif res.state.value == "configured-only" and profile_state not in (InstallState.INSTALLED,): + profile_state = InstallState.CONFIGURED_ONLY + profile_evidence.extend(res.evidence) + elif res.state.value == "compatibility-only" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY): + profile_state = InstallState.COMPATIBILITY_ONLY + profile_evidence.extend(res.evidence) + elif res.state.value == "cloud-connected" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY): + profile_state = InstallState.CLOUD_CONNECTED + profile_evidence.extend(res.evidence) + elif res.state.value == "legacy" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY, InstallState.CLOUD_CONNECTED): + profile_state = InstallState.LEGACY + profile_evidence.extend(res.evidence) + elif res.state.value == "ambiguous" and profile_state == InstallState.NOT_DETECTED: + profile_state = InstallState.AMBIGUOUS + profile_evidence.extend(res.evidence) + # NOT_DETECTED doesn't change anything + selector = f"{prod_id}/{prof_id}" + detection_status[selector] = profile_state.value + include_configured = getattr(args, "include_configured", False) + include_compatibility = getattr(args, "include_compatibility", False) + if ( + profile_state == InstallState.INSTALLED + or (include_configured and profile_state == InstallState.CONFIGURED_ONLY) + or (include_compatibility and profile_state == InstallState.COMPATIBILITY_ONLY) + ): + detected_selectors.add(selector) + + plan_rows: list[dict[str, Any]] = [] + failed_products: list[dict[str, str]] = [] + for selector in sorted(detected_selectors): + try: + doc = build_plan_document( + registry, + selector, + selector, + sorted(allowed_object_types), + args.scope, + ) + plan_rows.extend(doc.get("items", [])) + except Exception as error: + # Audit P1-2: never silently swallow per-product plan build + # failures. Capture and surface them so the snapshot summary + # can report parse_failed / plan_failed per product. + failed_products.append({"selector": selector, "error": str(error)}) + print( + f"WARNING: failed to build plan for {selector}: {error}", + file=sys.stderr, + ) + else: + src_sel = args.source or "cline/ide" + document = build_plan_document( + registry, + src_sel, + args.target or src_sel, + sorted(allowed_object_types), + args.scope, + ) + plan_rows = document.get("items", []) + installed_products_list = ( + sorted(detected_selectors) + if all_installed + else sorted({row["product"] for row in detect_rows}) + ) + inventory_summary = { + "installed_products": installed_products_list, + "surface_count": sum( + 1 for row in inventory_rows if row.get("object_type") + ), + "detection_status": detection_status if all_installed else {}, + "failed_products": failed_products if all_installed else [], + } + + # Only include authorized, planned objects in manifest + manifest_objects = [] + for item in plan_rows: + surface_type = item.get("object_type", "") + item_scope = (item.get("source") or {}).get("scope", "") + if surface_type not in allowed_object_types: + continue + if requested_scopes and item_scope not in requested_scopes: + continue + manifest_objects.append( + { + "object_id": item.get("object_id", ""), + "product": (item.get("source") or {}).get("product", ""), + "profile": (item.get("source") or {}).get("profile", ""), + "surface": surface_type, + "scope": item_scope, + "status": item.get("status", ""), + "reason": item.get("reason", ""), + "secret_status": "clean", + } + ) + + manifest = ACBManifest( + schema_version=ACB_SCHEMA_VERSION, + bundle_id=make_bundle_id(), + created_at=datetime.now(timezone.utc).isoformat(), + source_platform={ + "system": sys.platform, + "python": sys.version.split()[0], + }, + inventory_summary=inventory_summary, + objects=manifest_objects, + ) + # Copy source files under objects/ using strict allowlist (audit P0-2 & 0.8.25). + if not all_installed: + source_product, source_profile = ( + args.source.split("/", 1) if "/" in args.source else (args.source, None) + ) + else: + source_product, source_profile = None, None + + collect_summary = {"captured": 0, "manual_rebuild": 0, "excluded_by_policy": 0, "parse_failed": 0, "secret_rejected": 0, "conflict": 0} + try: + objects_dir_files, collect_summary, object_file_map = collect_source_objects( + registry, + inventory_rows, + home=registry.home, + workspace=workspace, + source_product=source_product, + source_profile=source_profile, + allowed_scopes=requested_scopes, + allowed_object_types=allowed_object_types, + plan_items=plan_rows, + ) + except ACBError as error: + # Parse failure during object collection - emit failure with summary + inventory_summary["collection_summary"] = collect_summary + emit( + { + "ok": False, + "stage": "snapshot", + "error": str(error), + "summary": inventory_summary, + }, + args.json, + ) + return 1 + + # compatibility: source_product x target_product matrix sourced from + # Registry v2 migration_policy and support_level. Audit P1-2: check + # migration_policy (bidirectional-reviewed) rather than assuming + # support_level == "bidirectional-reviewed". + compatibility_products = sorted(registry.products.keys()) + compatibility_pairs: list[dict[str, str]] = [] + for src in compatibility_products: + src_product = registry.products.get(src, {}) + src_default_profile = src_product.get("default_profile", "cli") + for tgt in compatibility_products: + if src == tgt: + continue + tgt_product = registry.products.get(tgt, {}) + tgt_default_profile = tgt_product.get("default_profile", "cli") + try: + # Silently resolve: the matrix walks every product pair, and + # Registry._log_resolution would otherwise spam one alias line + # per call (O(N^2) stderr noise during snapshot). + buffer = io.StringIO() + with contextlib.redirect_stderr(buffer): + src_profile_data = registry.profile(f"{src}/{src_default_profile}") + tgt_profile_data = registry.profile(f"{tgt}/{tgt_default_profile}") + except Exception as error: + print(f"WARNING: failed to get profile for compatibility pair {src}->{tgt}: {error}", file=sys.stderr) + continue + # profile() returns (resolved_product, resolved_profile, profile_data) + src_profile = src_profile_data[2] if len(src_profile_data) > 2 else {} + tgt_profile = tgt_profile_data[2] if len(tgt_profile_data) > 2 else {} + src_policy = src_profile.get("migration_policy") or src_profile.get("support_level") + tgt_policy = tgt_profile.get("migration_policy") or tgt_profile.get("support_level") + if ( + (src_policy == "bidirectional-reviewed" or src_policy in AUTOMATIC_MIGRATION_POLICIES) + and (tgt_policy == "bidirectional-reviewed" or tgt_policy in AUTOMATIC_MIGRATION_POLICIES) + ): + compatibility_pairs.append({ + "source": src, + "target": tgt, + "supported": True, + "evidence": src_profile.get("evidence"), + }) + compatibility = { + "schema_version": 2, + "matrix_kind": "source_x_target_bidirectional_reviewed", + "products": compatibility_products, + "pairs": compatibility_pairs, + } + + requirements, requirements_summary = collect_requirements( + inventory_rows, plan_rows, objects_dir_files=objects_dir_files + ) + reauth = collect_reauth(plan_rows) + rebuild = collect_rebuild(plan_rows) + # secrets_required: each entry is a non-secret description of a + # credential the user must re-supply on the target device. The name + # is derived from the MCP server's command / package, not the + # object_id. The shape is designed so doctor / human readers can + # understand "what credential" without inspecting the bundle. + secrets_required = [] + for action in reauth: + obj_id = action.get("object_id", "") + # Audit P1-7: surface credential names instead of object IDs. + # Derive a stable, human-readable name from the package/command + # when possible, falling back to the object_id only when no + # better signal is available. + package = ( + action.get("source", {}).get("package") + or action.get("source", {}).get("command") + or "" + ) + if package: + cred_name = f"{package}::credential" + else: + cred_name = obj_id or "unknown-credential" + secrets_required.append({ + "name": cred_name, + "used_by": [obj_id] if obj_id else [], + "recommended_storage": "environment-or-keychain", + }) + try: + write_bundle( + bundle_root=bundle_root, + manifest=manifest, + inventory_rows=inventory_rows, + compatibility=compatibility, + requirements=requirements, + secrets_required=secrets_required, + reauth=reauth, + rebuild=rebuild, + objects_dir_files=objects_dir_files, + adapter_versions=ADAPTER_VERSIONS, + object_file_map=object_file_map, + ) + except ACBSecretLeak as error: + print(f"ERROR: ACB secret leak: {error}", file=sys.stderr) + return 1 + emit( + { + "ok": True, + "stage": "snapshot", + "bundle": str(bundle_root), + "bundle_id": manifest.bundle_id, + "manifest": str(bundle_root / "manifest.json"), + "checksums": str(bundle_root / ACB_CHECKSUMS_NAME), + "objects_dir": str(bundle_root / ACB_OBJECTS_DIR), + "objects_captured": len(objects_dir_files), + "detected": detect_rows[:50], + "summary": inventory_summary, + "collection_summary": collect_summary, + }, + args.json, + ) + return 0 + + +def run_bundle_verify(args: argparse.Namespace) -> int: + """Verify checksums and optional Ed25519 signature for an ACB.""" + bundle_path = args.bundle.resolve() + errors = verify_bundle(bundle_path) + trusted_key = getattr(args, "trusted_key", None) + if trusted_key: + sig_errors = verify_bundle_signature(bundle_path, trusted_key.resolve()) + errors.extend(sig_errors) + emit( + { + "ok": not errors, + "bundle": str(bundle_path), + "errors": errors, + "signature_verified": bool(trusted_key and not errors), + }, + args.json, + ) + return 0 if not errors else 1 + + +def run_bundle_sign(args: argparse.Namespace) -> int: + """Sign an ACB bundle with an Ed25519 private key.""" + bundle_path = args.bundle.resolve() + try: + sig_path = sign_bundle(bundle_path, args.key.resolve(), signer=args.signer) + emit( + { + "ok": True, + "bundle": str(bundle_path), + "signature": str(sig_path), + "signer": args.signer, + }, + args.json, + ) + return 0 + except Exception as error: + emit({"ok": False, "bundle": str(bundle_path), "error": str(error)}, args.json) + return 1 + + +def run_bundle_keygen(args: argparse.Namespace) -> int: + """Generate Ed25519 keypair for signing and verifying ACBs.""" + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives import serialization + priv = Ed25519PrivateKey.generate() + priv_bytes = priv.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + pub_bytes = priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + out_priv = args.out_private.resolve() + out_pub = args.out_public.resolve() + out_priv.parent.mkdir(parents=True, exist_ok=True) + out_pub.parent.mkdir(parents=True, exist_ok=True) + out_priv.write_bytes(priv_bytes) + try: + os.chmod(out_priv, 0o600) + except Exception: + pass + out_pub.write_bytes(pub_bytes) + emit( + { + "ok": True, + "private_key": str(out_priv), + "public_key": str(out_pub), + }, + args.json, + ) + return 0 + except Exception as error: + emit({"ok": False, "error": str(error)}, args.json) + return 1 + + +def _detect_target_profiles_for_restore( + target_registry: Registry, + workspace: Path, + include_configured: bool = False, + include_compatibility: bool = False, +) -> tuple[set[str], dict[str, str]]: + """Probe and filter target profiles on destination device for all-installed restore.""" + from detect.probes import detect_profile, InstallState + + target_detected_selectors: set[str] = set() + target_detection_status: dict[str, str] = {} + + for prod_id, prod in target_registry.products.items(): + for prof_id, prof in prod.get("profiles", {}).items(): + detection = prof.get("detection", []) or [] + profile_state = InstallState.NOT_DETECTED + profile_evidence: list[str] = [] + for probe in detection: + if not isinstance(probe, dict): + continue + paths = probe.get("paths", []) + binaries = probe.get("command") or probe.get("binaries") or [] + if isinstance(binaries, str): + binaries = [binaries] + res = detect_profile( + prod_id, + prof_id, + binaries=binaries, + file_signatures=paths, + home=target_registry.home, + workspace=workspace, + app_bundle_id=probe.get("darwin_bundle_id"), + ) + if res.state.value == "installed": + profile_state = InstallState.INSTALLED + profile_evidence.extend(res.evidence) + break + elif res.state.value == "configured-only" and profile_state not in (InstallState.INSTALLED,): + profile_state = InstallState.CONFIGURED_ONLY + profile_evidence.extend(res.evidence) + elif res.state.value == "compatibility-only" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY): + profile_state = InstallState.COMPATIBILITY_ONLY + profile_evidence.extend(res.evidence) + elif res.state.value == "cloud-connected" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY): + profile_state = InstallState.CLOUD_CONNECTED + profile_evidence.extend(res.evidence) + elif res.state.value == "legacy" and profile_state not in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY, InstallState.COMPATIBILITY_ONLY, InstallState.CLOUD_CONNECTED): + profile_state = InstallState.LEGACY + profile_evidence.extend(res.evidence) + elif res.state.value == "ambiguous" and profile_state == InstallState.NOT_DETECTED: + profile_state = InstallState.AMBIGUOUS + profile_evidence.extend(res.evidence) + + selector = f"{prod_id}/{prof_id}" + target_detection_status[selector] = profile_state.value + # Audit P1-3: strictly filter targets (0.9.1: INSTALLED default, configured/compat opt-in) + if ( + profile_state == InstallState.INSTALLED + or (include_configured and profile_state == InstallState.CONFIGURED_ONLY) + or (include_compatibility and profile_state == InstallState.COMPATIBILITY_ONLY) + ): + target_detected_selectors.add(selector) + + return target_detected_selectors, target_detection_status + + +def _build_all_installed_restore_items( + source_registry: Registry, + target_registry: Registry, + bundle_source_selectors: list[str], + target_detected_selectors: set[str], + object_types: list[str], + scope: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, str]]]: + """Build restore plan items with object-level identity, deduplication, and conflict tracking.""" + all_items: list[dict[str, Any]] = [] + dropped_losses: list[dict[str, Any]] = [] + seen_target_skills: dict[tuple[str, str], tuple[str, str]] = {} + seen_target_files: dict[str, tuple[str, str]] = {} + seen_target_plugins: dict[tuple[str, str], tuple[str, str]] = {} + mcp_candidates_by_target: dict[str, list[tuple[dict[str, Any], str, str]]] = {} + failed_targets: list[dict[str, str]] = [] + + for tgt_selector in sorted(target_detected_selectors): + for src_selector in bundle_source_selectors: + try: + doc = build_plan_document( + source_registry, + src_selector, + tgt_selector, + object_types, + scope, + target_registry=target_registry, + ) + for item in doc.get("items", []): + obj_type = item.get("object_type") + target_dict = item.get("target") or {} + source_dict = item.get("source") or {} + target_path = target_dict.get("resolved_path") + source_path_str = source_dict.get("resolved_path") + source_path = Path(source_path_str) if source_path_str else None + + if not target_path or item.get("status") in {"invalid", "manual-rebuild"}: + continue + + # Audit P0-1: Child-level skill conflict detection & deduplication (0.9.1) + if obj_type == "skills" and source_path and source_path.is_dir(): + skill_subdirs = [ + p for p in source_path.iterdir() + if p.is_dir() and (p / "SKILL.md").is_file() + ] if source_path.exists() else [] + + for skill_dir in skill_subdirs: + skill_name = skill_dir.name + skill_key = (target_path, skill_name) + skill_hash = hash_path(skill_dir) + child_target_path = str(Path(target_path) / skill_name) + child_source_path = str(skill_dir) + + child_item = copy.deepcopy(item) + if child_item.get("source"): + child_item["source"]["resolved_path"] = child_source_path + if child_item.get("target"): + child_item["target"]["resolved_path"] = child_target_path + child_item["source_state"] = path_state(Path(child_source_path)) + child_item["target_state"] = path_state(Path(child_target_path)) + + if skill_key in seen_target_skills: + prev_hash, prev_src = seen_target_skills[skill_key] + if skill_hash != prev_hash: + conflict_msg = ( + f"Skill conflict on '{skill_name}': target {child_target_path} " + f"already claimed by {prev_src} with differing content" + ) + dropped_losses.append({ + "object_type": "skills", + "field": skill_name, + "reason": conflict_msg, + "detail": None, + }) + child_item["status"] = "conflict" + child_item["reason"] = conflict_msg + all_items.append(child_item) + # If skill_hash == prev_hash: identical deduplication (skip) + else: + seen_target_skills[skill_key] = (skill_hash, src_selector) + child_item["status"] = "ready" + all_items.append(child_item) + + elif obj_type == "instructions": + target_path_obj = Path(target_path) + if target_dict.get("storage") == "file" or target_path_obj.suffix in (".md", ".json", ".txt"): + file_key = target_path + source_hash = hash_path(source_path) if (source_path and source_path.exists()) else "" + if file_key in seen_target_files: + prev_hash, prev_src = seen_target_files[file_key] + if source_hash == prev_hash: + continue + else: + item_copy = dict(item) + item_copy["status"] = "conflict" + item_copy["reason"] = ( + f"Instruction conflict: multiple sources ({prev_src}, {src_selector}) " + f"target same file {target_path} with different content" + ) + dropped_losses.append({ + "object_type": "instructions", + "field": target_path, + "reason": item_copy["reason"], + "detail": None, + }) + all_items.append(item_copy) + else: + seen_target_files[file_key] = (source_hash, src_selector) + all_items.append(item) + else: + all_items.append(item) + + elif obj_type == "mcp": + mcp_candidates_by_target.setdefault(target_path, []).append( + (item, src_selector, source_path_str or "") + ) + + elif obj_type == "plugins": + pkg_name = source_path.name if source_path else "pkg" + plugin_key = (target_path, pkg_name) + source_hash = hash_path(source_path) if (source_path and source_path.exists()) else "" + if plugin_key in seen_target_plugins: + prev_hash, prev_src = seen_target_plugins[plugin_key] + if source_hash == prev_hash: + continue + else: + item_copy = dict(item) + item_copy["status"] = "conflict" + item_copy["reason"] = f"Plugin conflict: multiple sources ({prev_src}, {src_selector}) targeting {plugin_key}" + dropped_losses.append({ + "object_type": "plugins", + "field": str(target_path), + "reason": item_copy["reason"], + "detail": None, + }) + all_items.append(item_copy) + else: + seen_target_plugins[plugin_key] = (source_hash, src_selector) + all_items.append(item) + + else: + all_items.append(item) + + except Exception as error: + # Audit P1-2: never silently swallow per-target plan + # build failures during all-installed restore. Surface + # the failure so the restore summary can report it. + failed_targets.append({ + "source": src_selector, + "target": tgt_selector, + "error": str(error), + }) + print( + f"WARNING: restore plan build failed for source={src_selector} target={tgt_selector}: {error}", + file=sys.stderr, + ) + + # Consolidate and merge multi-source MCP configs per target file (0.9.1) + for target_path, candidates in sorted(mcp_candidates_by_target.items()): + if len(candidates) == 1: + all_items.append(candidates[0][0]) + else: + hashes = [ + hash_path(Path(c[2])) for c in candidates + if c[2] and Path(c[2]).is_file() + ] + if len(set(hashes)) == 1: + all_items.append(candidates[0][0]) + else: + seen_servers: dict[str, tuple[Any, str, dict[str, Any]]] = {} + conflicting_servers: set[str] = set() + for cand_item, cand_src, cand_path_str in candidates: + if not cand_path_str or not Path(cand_path_str).is_file(): + continue + cand_path = Path(cand_path_str) + src_format = cand_item.get("source", {}).get("source_format", "json:mcpServers") + try: + text = cand_path.read_text(encoding="utf-8") + servers = parse_mcp_document(text, src_format) + for srv in servers: + if srv.name in seen_servers: + prev_srv, prev_src, _ = seen_servers[srv.name] + is_same = ( + srv.transport == prev_srv.transport + and srv.command == prev_srv.command + and srv.args == prev_srv.args + and srv.env == prev_srv.env + and srv.url == prev_srv.url + and srv.headers == prev_srv.headers + and srv.cwd == prev_srv.cwd + ) + if not is_same: + conflicting_servers.add(srv.name) + conflict_msg = ( + f"MCP server conflict on '{srv.name}': differing command/args/transport " + f"between {prev_src} and {cand_src}" + ) + dropped_losses.append({ + "object_type": "mcp", + "field": f"{srv.name}@{target_path}", + "reason": conflict_msg, + "detail": None, + }) + else: + seen_servers[srv.name] = (srv, cand_src, cand_item) + except Exception as error: + print(f"WARNING: failed to parse MCP document for {cand_src} ({cand_path_str}): {error}", file=sys.stderr) + + valid_servers = [ + srv for name, (srv, _, _) in seen_servers.items() + if name not in conflicting_servers + ] + + if valid_servers: + mcp_temp_dir = Path(tempfile.mkdtemp(prefix="acb-mcp-merged-")) + merged_file = mcp_temp_dir / f"merged_mcp_{hashlib.sha256(target_path.encode()).hexdigest()[:8]}.json" + servers_map: dict[str, Any] = {} + for srv in valid_servers: + s_dict: dict[str, Any] = {} + if srv.command is not None: + s_dict["command"] = srv.command + if srv.args: + s_dict["args"] = srv.args + if srv.env: + s_dict["env"] = srv.env + if srv.url is not None: + s_dict["url"] = srv.url + if srv.headers: + s_dict["headers"] = srv.headers + if srv.transport and srv.transport != "stdio": + s_dict["transport"] = srv.transport + if srv.cwd: + s_dict["cwd"] = srv.cwd + servers_map[srv.name] = s_dict + + merged_doc = {"mcpServers": servers_map} + atomic_write(merged_file, json.dumps(merged_doc, indent=2) + "\n") + + merged_item = copy.deepcopy(candidates[0][0]) + merged_item["source"]["resolved_path"] = str(merged_file) + merged_item["source"]["boundary"] = str(mcp_temp_dir) + merged_item["source"]["source_format"] = "json:mcpServers" + merged_item["source_state"] = path_state(merged_file) + merged_item["target_state"] = path_state(Path(target_path)) + merged_item["status"] = "ready" + merged_item["reason"] = f"Merged {len(valid_servers)} MCP server(s) from multiple sources" + all_items.append(merged_item) + else: + conflict_item = copy.deepcopy(candidates[0][0]) + conflict_item["status"] = "conflict" + conflict_item["reason"] = f"MCP conflict: all servers targeting {target_path} conflicted across sources" + conflict_item["source_state"] = path_state(Path(candidates[0][2])) if (candidates[0][2] and Path(candidates[0][2]).exists()) else None + conflict_item["target_state"] = path_state(Path(target_path)) + all_items.append(conflict_item) + + return all_items, dropped_losses, failed_targets + + +def run_restore(args: argparse.Namespace) -> int: + """Verify and rebuild a plan from an ACB on the current device. + + Dual-side Plan Architecture (audit P0-1 & P0-3): + 1. Restore source is ALWAYS the verified bundle. Local source installation on + device B does not alter or replace bundle content. + 2. Bundle objects are staged into an isolated temporary source tree. + 3. Source surfaces are resolved from the staged source registry; target + surfaces are resolved from the real destination registry on this device. + 4. The reviewed PlanDocument contains the real destination target paths, + real pre-apply target states (evaluating exists -> replace vs create), + real unified/semantic diffs against the destination, and real workspace. + 5. The hash of this exact document is locked as plan_sha256 and recorded in + provenance upon apply. + 6. Executed target paths == reviewed plan target paths at all times. + """ + bundle_root = args.bundle.resolve() + errors = verify_bundle(bundle_root) + trusted_key = getattr(args, "trusted_key", None) + if trusted_key: + sig_errors = verify_bundle_signature(bundle_root, trusted_key.resolve()) + errors.extend(sig_errors) + if errors: + emit({"ok": False, "stage": "verify", "errors": errors}, args.json) + return 1 + manifest = load_manifest(bundle_root) + workspace = args.workspace.resolve() + target_registry = Registry(args.registry, workspace) + detected = [row for row in target_registry.inventory(None) if row.get("exists")] + + all_installed = getattr(args, "all_installed", False) or args.target in ("auto", "all-installed") + source_sel = args.source or "cline/ide" + target_sel = args.target or "forge/cli" + object_types = resolve_objects(getattr(args, "objects", "skills,instructions,mcp")) + have_bundle_objects = (bundle_root / ACB_OBJECTS_DIR).is_dir() + + # Optional object extraction into an explicit restore-root (audit #4: + # opt-in; defaults OFF so we never imply a transaction landed there). + restore_root = ( + args.restore_root.resolve(strict=False) if args.restore_root else None + ) + restore_result = ( + restore_bundle_objects(bundle_root, restore_root, dry_run=args.dry_run) + if restore_root is not None + else None + ) + + temp_dir: str | None = None + try: + temp_dir = tempfile.mkdtemp(prefix="acb-source-stage-") + temp_source_dir = Path(temp_dir) + staged_home = temp_source_dir / "home" + staged_home.mkdir(parents=True, exist_ok=True) + + if have_bundle_objects: + objects_root = bundle_root / ACB_OBJECTS_DIR + requested_scopes = { + s.strip().lower() + for s in (args.scope or "user,project").split(",") + if s.strip() + } + if "all" in requested_scopes: + requested_scopes = {"user", "project", "local"} + + for source_file in sorted(objects_root.rglob("*")): + if source_file.is_file(): + rel = source_file.relative_to(objects_root) + parts = rel.parts + if len(parts) >= 5: + obj_t, prod, prof, scp = parts[0], parts[1], parts[2], parts[3] + # For all_installed, stage all products; otherwise filter by source_prod + if all_installed or prod == source_sel.split("/")[0]: + if scp.lower() in requested_scopes: + if parts[4] == "home" and len(parts) >= 6: + target_staged = staged_home / Path(*parts[5:]) + elif scp.lower() == "user": + target_staged = staged_home / Path(*parts[4:]) + else: + target_staged = temp_source_dir / Path(*parts[4:]) + target_staged.parent.mkdir(parents=True, exist_ok=True) + target_staged.write_bytes(source_file.read_bytes()) + + source_registry = Registry( + args.registry, temp_source_dir, home=staged_home + ) + + plan_in = getattr(args, "plan_in", None) + if plan_in: + document = load_plan_document(plan_in.resolve()) + plan_items, _ = validate_plan_document( + document, target_registry, source_registry=source_registry + ) + elif all_installed: + target_detected_selectors, target_detection_status = ( + _detect_target_profiles_for_restore( + target_registry=target_registry, + workspace=workspace, + include_configured=getattr(args, "include_configured", False), + include_compatibility=getattr(args, "include_compatibility", False), + ) + ) + + bundle_source_selectors = sorted({ + f"{obj.get('product')}/{obj.get('profile')}" + for obj in manifest.objects + if obj.get("product") and obj.get("profile") + }) + if not bundle_source_selectors: + bundle_source_selectors = [source_sel] if source_sel else ["cline/ide"] + + all_items, dropped_losses, failed_targets = _build_all_installed_restore_items( + source_registry=source_registry, + target_registry=target_registry, + bundle_source_selectors=bundle_source_selectors, + target_detected_selectors=target_detected_selectors, + object_types=object_types, + scope=args.scope, + ) + + document = { + "schema_version": 1, + "created_at": datetime.now(timezone.utc).isoformat(), + "workspace": str(target_registry.workspace), + "source": "all-installed", + "source_profile": "all", + "source_support_level": "bidirectional-reviewed", + "target": "all-installed", + "target_profile": "all", + "target_support_level": "bidirectional-reviewed", + "scope": args.scope or "user,project", + "objects": object_types, + "registry_sha256": hash_path(target_registry.path), + "adapter_versions": ADAPTER_VERSIONS, + "git_provenance": git_provenance(target_registry.workspace), + "items": all_items, + "loss_report": {"dropped_fields": dropped_losses, "warnings": []}, + "rebuild_manifest": { + "credential_policy": "references-only; never include literal credentials", + "items": [], + }, + "detection_status": target_detection_status, + "failed_targets": failed_targets, + } + document["plan_sha256"] = json_sha256(_plan_hash_payload(document)) + plan_items, _ = validate_plan_document( + document, target_registry, source_registry=source_registry + ) + else: + # Build dual-side plan: source=bundle (source_registry), target=destination device (target_registry) + document = build_plan_document( + source_registry, + source_sel, + target_sel, + object_types, + args.scope, + target_registry=target_registry, + ) + # Enforce TOCTOU state lock validation on the generated plan document + plan_items, _ = validate_plan_document( + document, target_registry, source_registry=source_registry + ) + + # Write the reviewed plan document if requested + plan_out = None + if args.plan_out and not args.dry_run: + plan_out = args.plan_out.resolve(strict=False) + plan_out.parent.mkdir(parents=True, exist_ok=True) + atomic_write( + plan_out, + json.dumps(document, indent=2, sort_keys=True) + "\n", + ) + + plan_display = str(plan_out) if plan_out else (str(plan_in) if plan_in else None) + + if args.dry_run: + # Zero-write guarantee for dry-run. + emit( + { + "ok": True, + "stage": "plan", + "bundle": str(bundle_root), + "bundle_id": manifest.bundle_id, + "plan": plan_display, + "plan_sha256": document["plan_sha256"], + "restore": restore_result, + "dry_run": True, + "detected": detected[:50], + "failed_targets": document.get("failed_targets", []), + }, + args.json, + ) + return 0 + + is_plan_only = getattr(args, "plan_only", False) or not args.yes + if is_plan_only: + emit( + { + "ok": True, + "stage": "plan", + "bundle": str(bundle_root), + "bundle_id": manifest.bundle_id, + "plan": plan_display, + "plan_sha256": document["plan_sha256"], + "restore": restore_result, + "detected": detected[:50], + "failed_targets": document.get("failed_targets", []), + }, + args.json, + ) + return 0 + + if args.apply_safe: + # No-op guard (audit #2): bundle carried objects but nothing + # eligible was resolved — refuse to report success. + if have_bundle_objects and not any( + item.status == "ready" for item in plan_items + ): + if not getattr(args, "allow_noop", False): + emit( + { + "ok": False, + "stage": "apply", + "error": "restore resolved no eligible items; refusing silent no-op (use --allow-noop to override)", + }, + args.json, + ) + return 1 + return _apply_restore( + plan_items, workspace, args, bundle_root, manifest, + document, restore_result, detected, + ) + + emit( + { + "ok": True, + "stage": "plan", + "bundle": str(bundle_root), + "bundle_id": manifest.bundle_id, + "plan": plan_display, + "plan_sha256": document["plan_sha256"], + "restore": restore_result, + "detected": detected[:50], + "failed_targets": document.get("failed_targets", []), + }, + args.json, + ) + return 0 + finally: + # The staged source tree must stay alive until apply_plan has read it, + # so it is cleaned up only here (audit #5: never leak to /tmp). + if temp_dir is not None and Path(temp_dir).exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + + +def _apply_restore( + plan_items: list, + workspace: Path, + args: argparse.Namespace, + bundle_root: Path, + manifest, + document: dict, + restore_result, + detected, +) -> int: + """Apply a resolved plan, verify it, and emit the result.""" + manifest_obj, manifest_path_out = apply_plan( + plan_items, workspace, args.manifest_out, + provenance={ + "bundle_path": str(bundle_root), + "bundle_id": manifest.bundle_id, + "plan_sha256": document["plan_sha256"], + "registry_sha256": document["registry_sha256"], + "adapter_versions": document["adapter_versions"], + }, + apply_safe=True, + include_lossy=(args.include_lossy == "lossy"), + accept_loss_ids=set(), + strict=args.strict, + allow_plugin_copy=bool(getattr(args, "include_plugins", False)), + allow_session_handoff=bool(getattr(args, "include_session", False)), + ) + verify_errors = verify_manifest(manifest_path_out) + emit( + { + "ok": not verify_errors, + "stage": "verify", + "bundle": str(bundle_root), + "plan": str(args.plan_out) if getattr(args, "plan_out", None) else None, + "manifest": str(manifest_path_out), + "restore": restore_result, + "stale_targets": [], + "detected": detected[:50], + "summary": manifest_obj.get("summary", {}), + "errors": verify_errors, + "failed_targets": document.get("failed_targets", []), + }, + args.json, + ) + return 0 if not verify_errors else 1 + + +def run_doctor(args: argparse.Namespace) -> int: + """Inspect a bundle and surface missing executables / re-auth work.""" + bundle_root = args.bundle.resolve() + errors = verify_bundle(bundle_root) + if errors: + emit({"ok": False, "stage": "verify", "errors": errors}, args.json) + return 1 + requirements = json.loads( + (bundle_root / "requirements.json").read_text(encoding="utf-8") + ) + reauth = json.loads((bundle_root / "reauth.json").read_text(encoding="utf-8")) + rebuild = json.loads((bundle_root / "rebuild.json").read_text(encoding="utf-8")) + missing_executables: list[str] = [] + for binary in requirements.get("executables", []): + if not shutil.which(binary): + missing_executables.append(binary) + emit( + { + "ok": not missing_executables, + "bundle": str(bundle_root), + "missing_executables": missing_executables, + "reauth_actions": reauth.get("items", []), + "rebuild_actions": rebuild.get("items", []), + "platform_notes": requirements.get("platform_notes", []), + }, + args.json, + ) + return 0 if not missing_executables else 1 + + +def run_migrate(args: argparse.Namespace) -> int: + """Orchestrate detect -> inventory -> plan -> apply -> verify.""" + workspace = args.workspace.resolve() + registry = Registry(args.registry, workspace) + + # 1. detect --installed (informational; does not gate the run). + detect_rows = [row for row in registry.inventory(None) if row.get("exists")] + + # 2. Resolve --objects. + object_types = resolve_objects(args.objects) + + # Reject unsupported automatic object types unless all-inventory. + unsupported = sorted( + set(object_types) - AUTOMATIC_OBJECT_TYPES - OPT_IN_WRITABLE_OBJECT_TYPES - INVENTORY_ONLY_OBJECT_TYPES + ) + if unsupported: + raise ValueError( + "unsupported automatic objects: " + + ", ".join(unsupported) + + "; use --objects 'skills,instructions,mcp' or 'all-portable'" + ) + # Include automatic and opt-in writable types in the plan + auto_object_types = [ + obj for obj in object_types + if obj in AUTOMATIC_OBJECT_TYPES or obj in OPT_IN_WRITABLE_OBJECT_TYPES + ] + + # 3. scope handling: default user,project; full-disk 'all' requires --yes. + scope = args.scope + if scope == "all" and not args.yes: + raise ValueError("--scope all requires --yes") + if scope not in {"user", "project", "user,project", "all"}: + raise ValueError(f"unsupported scope: {scope}") + + # 4. plan + document = build_plan_document( + registry, args.source, args.target, auto_object_types, scope, + ) + + # 5. save plan + plan_out = ( + args.plan_out.resolve(strict=False) + if args.plan_out + else default_workspace_migration_dir(workspace) / "migrate-plan.json" + ) + plan_out.parent.mkdir(parents=True, exist_ok=True) + atomic_write( + plan_out, + json.dumps(document, indent=2, sort_keys=True) + "\n", + ) + + if args.plan_only: + emit( + { + "ok": True, + "stage": "plan", + "plan": str(plan_out), + "plan_sha256": document["plan_sha256"], + "detected": detect_rows, + }, + args.json, + ) + return 0 + + # 6. apply (re-load the saved plan so the apply path matches the + # production flow exactly). + plan_items, _ = validate_plan_document(document, registry) + accept_loss_ids = { + token.strip() for token in args.accept_loss.split(",") if token.strip() + } + default_manifest_out = ( + args.manifest_out.resolve(strict=False) + if args.manifest_out + else default_workspace_migration_dir(workspace) / "migrate-manifest.json" + ) + manifest, manifest_path_out = apply_plan( + plan_items, + workspace, + default_manifest_out, + provenance={ + "plan_path": str(plan_out.resolve()), + "plan_sha256": document["plan_sha256"], + "registry_sha256": document["registry_sha256"], + "adapter_versions": document["adapter_versions"], + "git_provenance": document.get("git_provenance"), + }, + apply_safe=True, + include_lossy=(args.include_lossy == "lossy"), + accept_loss_ids=accept_loss_ids, + strict=args.strict, + allow_plugin_copy=bool(getattr(args, "include_plugins", False)), + allow_session_handoff=bool(getattr(args, "include_session", False)), + ) + + # 7. verify + errors = verify_manifest(manifest_path_out) + verify_out = ( + args.verify_out.resolve(strict=False) + if args.verify_out + else default_workspace_migration_dir(workspace) / "migrate-verify.json" + ) + verify_out.parent.mkdir(parents=True, exist_ok=True) + atomic_write( + verify_out, + json.dumps( + { + "ok": not errors, + "errors": errors, + "manifest": str(manifest_path_out), + "plan": str(plan_out), + }, + indent=2, + sort_keys=True, + ) + + "\n", + ) + + emit( + { + "ok": not errors, + "stage": "verify", + "plan": str(plan_out), + "manifest": str(manifest_path_out), + "verify": str(verify_out), + "summary": manifest.get("summary", {}), + "errors": errors, + "detected": detect_rows, + }, + args.json, + ) + return 0 if not errors else 1 + + +def run_legacy_cli(argv: list[str]) -> int: + reject_legacy_write(argv) + environment = dict(os.environ) + environment["AGENT_SKILLS_SETUP_INTERNAL_LEGACY"] = "1" + bash_exe = "bash" + if os.name == "nt": + # On Windows hosts PATH can resolve bare "bash" to the System32 + # WSL launcher, which is not an MSYS shell: its output carries + # NUL bytes and its lookups return nothing. Prefer Git Bash. + for candidate in ( + Path(os.environ.get("PROGRAMW6432", r"C:\Program Files")) / "Git" / "bin" / "bash.exe", + Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Git" / "usr" / "bin" / "bash.exe", + ): + if candidate.is_file(): + bash_exe = str(candidate) + break + completed = subprocess.run( + [bash_exe, str(LEGACY_SCRIPT), *argv], + check=False, + env=environment, + ) + return completed.returncode + + +def run_new_cli(argv: list[str]) -> int: + args = create_parser().parse_args(argv) + if args.command == "verify": + errors = verify_manifest(args.manifest) + result = {"ok": not errors, "errors": errors, "manifest": str(args.manifest)} + emit(result, args.json) + return 0 if not errors else 1 + if args.command == "rollback": + if not args.yes: + raise ValueError("rollback requires --yes") + restored = rollback_manifest(args.manifest) + emit({"ok": True, "restored": restored}, args.json) + return 0 + + if args.command == "apply": + if not args.yes: + raise ValueError("apply requires --yes after reviewing the saved plan") + document = load_plan_document(args.plan) + workspace_value = document.get("workspace") + if not isinstance(workspace_value, str) or not Path(workspace_value).is_absolute(): + raise ValueError("plan workspace must be an absolute path") + registry = Registry(args.registry, Path(workspace_value)) + + source_registry = None + temp_dir = None + bundle_root = None + try: + if getattr(args, "bundle", None): + bundle_root = args.bundle.resolve() + errors = verify_bundle(bundle_root) + if errors: + emit({"ok": False, "stage": "verify", "errors": errors}, args.json) + return 1 + temp_dir = tempfile.mkdtemp(prefix="acb-source-stage-") + temp_source_dir = Path(temp_dir) + staged_home = temp_source_dir / "home" + staged_home.mkdir(parents=True, exist_ok=True) + objects_root = bundle_root / ACB_OBJECTS_DIR + if objects_root.is_dir(): + for source_file in sorted(objects_root.rglob("*")): + if source_file.is_file(): + rel = source_file.relative_to(objects_root) + parts = rel.parts + if len(parts) >= 5: + if parts[4] == "home" and len(parts) >= 6: + target_staged = staged_home / Path(*parts[5:]) + elif parts[3].lower() == "user": + target_staged = staged_home / Path(*parts[4:]) + else: + target_staged = temp_source_dir / Path(*parts[4:]) + target_staged.parent.mkdir(parents=True, exist_ok=True) + target_staged.write_bytes(source_file.read_bytes()) + source_registry = Registry( + args.registry, temp_source_dir, home=staged_home + ) + + plan_items, _ = validate_plan_document( + document, registry, source_registry=source_registry + ) + accept_loss_ids = { + token.strip() + for token in args.accept_loss.split(",") + if token.strip() + } + provenance = { + "plan_path": str(args.plan.resolve()), + "plan_sha256": document["plan_sha256"], + "registry_sha256": document["registry_sha256"], + "adapter_versions": document["adapter_versions"], + "git_provenance": document.get("git_provenance"), + } + if bundle_root is not None: + provenance["bundle_path"] = str(bundle_root) + manifest, manifest_path = apply_plan( + plan_items, + registry.workspace, + args.manifest, + provenance=provenance, + apply_safe=args.apply_safe, + include_lossy=(args.include_lossy == "lossy"), + accept_loss_ids=accept_loss_ids, + strict=args.strict, + allow_plugin_copy=bool(getattr(args, "include_plugins", False)), + allow_session_handoff=bool(getattr(args, "include_session", False)), + ) + emit( + { + "ok": True, + "plan": str(args.plan), + "plan_sha256": document["plan_sha256"], + "manifest": str(manifest_path), + "changes": manifest["changes"], + "loss_report": manifest["loss_report"], + }, + args.json, + ) + return 0 + finally: + if temp_dir is not None and Path(temp_dir).exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + + if args.command == "migrate": + if not args.yes: + raise ValueError( + "migrate requires --yes after specifying source/target/objects" + ) + return run_migrate(args) + + if args.command == "snapshot": + return run_snapshot(args) + + if args.command == "bundle-verify": + return run_bundle_verify(args) + + if args.command == "bundle-sign": + return run_bundle_sign(args) + + if args.command == "bundle-keygen": + return run_bundle_keygen(args) + + if args.command == "restore": + return run_restore(args) + + if args.command == "doctor": + return run_doctor(args) + + registry = Registry(args.registry, args.workspace) + if args.command in {"detect", "inventory"}: + if args.command == "detect": + return run_detection(args) + selected = selector(args.product, args.profile) + rows = registry.inventory(selected) + emit(rows, args.json) + return 0 + + object_types = resolve_objects(args.objects) + unsupported = sorted( + set(object_types) - AUTOMATIC_OBJECT_TYPES - OPT_IN_WRITABLE_OBJECT_TYPES - INVENTORY_ONLY_OBJECT_TYPES + ) + if unsupported: + raise ValueError(f"unsupported automatic objects: {', '.join(unsupported)}") + document = build_plan_document( + registry, + args.source, + args.target, + object_types, + args.scope, + ) + if args.output: + output_path = args.output.resolve(strict=False) + protected_paths = [args.registry.resolve(strict=False)] + for item in document["items"]: + for side in ("source", "target"): + surface = item.get(side) + if isinstance(surface, dict) and isinstance( + surface.get("resolved_path"), str + ): + protected_paths.append(Path(surface["resolved_path"])) + if any(paths_overlap(output_path, path) for path in protected_paths): + raise ValueError( + "plan output overlaps the Registry or a planned source/target " + f"surface: {output_path}" + ) + atomic_write( + output_path, + json.dumps(document, indent=2, sort_keys=True) + "\n", + ) + emit(document, args.json) + return 0 + + +def main() -> int: + argv = sys.argv[1:] + if not argv or argv[0] in {"-h", "--help"}: + create_parser().print_help() + return 0 + if argv[0] == "legacy": + try: + return run_legacy_cli(argv[1:]) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + if argv[0].startswith("-"): + print( + "ERROR: implicit legacy flags are disabled; use the explicit " + "'legacy' subcommand for lookup or zero-write dry-run compatibility", + file=sys.stderr, + ) + return 2 + if argv[0] not in KNOWN_COMMANDS: + print(f"ERROR: unknown command: {argv[0]}", file=sys.stderr) + return 2 + try: + return run_new_cli(argv) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/detect/__init__.py b/skills/agent-skills-setup/agent-skills-setup/scripts/detect/__init__.py new file mode 100644 index 000000000..04359f1c0 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/detect/__init__.py @@ -0,0 +1,7 @@ +"""Detection probe framework. + +Each registered probe inspects a product / profile on the local +device and reports an installation state. Probes are kept small so +they can be composed per profile without requiring heavy native +dependencies. +""" \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/detect/probes.py b/skills/agent-skills-setup/agent-skills-setup/scripts/detect/probes.py new file mode 100644 index 000000000..fd3829a1b --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/detect/probes.py @@ -0,0 +1,285 @@ +"""Detection probes for products / profiles. + +A probe returns an :class:`InstallState` based on the available +evidence on the local device. Probes are pure-Python where possible +(``shutil.which`` + filesystem inspection) and never reach the +network. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Iterable + + +class InstallState(str, Enum): + INSTALLED = "installed" + CONFIGURED_ONLY = "configured-only" + COMPATIBILITY_ONLY = "compatibility-only" + CLOUD_CONNECTED = "cloud-connected" + LEGACY = "legacy" + AMBIGUOUS = "ambiguous" + NOT_DETECTED = "not-detected" + + +@dataclass(frozen=True) +class ProbeResult: + product: str + profile: str + state: InstallState + evidence: tuple[str, ...] + + def to_dict(self) -> dict[str, str]: + return { + "product": self.product, + "profile": self.profile, + "state": self.state.value, + "evidence": list(self.evidence), + } + + +def probe_binary( + product: str, + profile: str, + binary_names: Iterable[str], + *, + version_command: Iterable[str] | None = None, +) -> ProbeResult: + """Locate a binary in ``$PATH`` and capture its version.""" + names = list(binary_names) + if not names: + return ProbeResult(product, profile, InstallState.NOT_DETECTED, ()) + for name in names: + path = shutil.which(name) + if path: + evidence = [f"binary:{path}"] + if version_command: + try: + proc = subprocess.run( + list(version_command), + capture_output=True, + text=True, + timeout=2, + check=False, + ) + stdout = proc.stdout.strip() or proc.stderr.strip() + if stdout: + evidence.append(f"version:{stdout.splitlines()[0][:64]}") + except (OSError, subprocess.SubprocessError): + pass + return ProbeResult(product, profile, InstallState.INSTALLED, tuple(evidence)) + return ProbeResult(product, profile, InstallState.NOT_DETECTED, ()) + + +_SHARED_COMPATIBILITY_SUFFIXES = ( + ".agents/skills", + ".agents", +) + + +def _is_shared_compatibility_path(path: Path) -> bool: + p_posix = path.as_posix() + if path.name == "AGENTS.md": + return True + if any(p_posix.endswith(suf) for suf in _SHARED_COMPATIBILITY_SUFFIXES): + return True + # Generic workspace-level "skills" without a product-specific dot directory (e.g. .cursor, .cline) + if path.name == "skills" and not any(part.startswith(".") and part != ".agents" for part in path.parts): + return True + return False + + +def probe_file_signature( + product: str, + profile: str, + candidate_paths: Iterable[Path | str], + *, + workspace: Path | None = None, + home: Path | None = None, +) -> ProbeResult: + """Check whether any of the candidate paths exists on disk. + + Supports exact paths, globs (e.g. ``github.copilot-*``), home resolution, + and workspace-relative resolution. + """ + effective_home = home or Path.home() + for raw in candidate_paths: + p_str = str(raw) + if p_str.startswith("~"): + target_str = str(effective_home) + p_str[1:] + elif workspace is not None and not (p_str.startswith("/") or re.match(r"^[a-zA-Z]:", p_str)): + target_str = str(workspace / p_str) + else: + target_str = p_str + + # Check for glob wildcard matching + if any(char in target_str for char in ("*", "?", "[")): + target_path = Path(target_str) + parent = target_path.parent + pattern = target_path.name + if parent.exists() and parent.is_dir(): + matches = list(parent.glob(pattern)) + if matches: + matched = matches[0] + is_shared = _is_shared_compatibility_path(matched) + state = ( + InstallState.COMPATIBILITY_ONLY + if is_shared + else (InstallState.INSTALLED if matched.is_dir() or matched.is_file() else InstallState.CONFIGURED_ONLY) + ) + return ProbeResult(product, profile, state, (f"file:{matched}",)) + continue + + path = Path(target_str) + if path.exists(): + # Distinguish shared/fallback paths from product-specific installation evidence + is_shared = _is_shared_compatibility_path(path) + if is_shared: + state = InstallState.COMPATIBILITY_ONLY + else: + state = ( + InstallState.INSTALLED + if path.is_dir() or path.is_file() + else InstallState.CONFIGURED_ONLY + ) + return ProbeResult(product, profile, state, (f"file:{path}",)) + return ProbeResult(product, profile, InstallState.NOT_DETECTED, ()) + + +DARWIN_BUNDLE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]{0,255}$") + + +def probe_app_bundle( + product: str, + profile: str, + *, + darwin_bundle_id: str | None = None, +) -> ProbeResult: + """Best-effort macOS app-bundle probe.""" + if ( + not darwin_bundle_id + or sys.platform != "darwin" + or not DARWIN_BUNDLE_ID_RE.match(darwin_bundle_id) + ): + return ProbeResult(product, profile, InstallState.NOT_DETECTED, ()) + + # 1. Search standard macOS app locations + for app_dir in (Path("/Applications"), Path.home() / "Applications"): + if not app_dir.is_dir(): + continue + for app in app_dir.glob("*.app"): + plist = app / "Contents" / "Info.plist" + if plist.is_file(): + try: + text = plist.read_text(encoding="utf-8", errors="ignore") + if darwin_bundle_id in text: + return ProbeResult( + product, profile, InstallState.INSTALLED, + (f"app-bundle:{app}",), + ) + except OSError: + pass + + # 2. Try mdfind for Spotlight index lookup + try: + proc = subprocess.run( + ["mdfind", f"kMDItemCFBundleIdentifier == '{darwin_bundle_id}'"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + if proc.returncode == 0 and proc.stdout.strip(): + found_app = proc.stdout.strip().splitlines()[0] + if Path(found_app).exists(): + return ProbeResult( + product, profile, InstallState.INSTALLED, + (f"app-bundle:{found_app}",), + ) + except (OSError, subprocess.SubprocessError): + pass + + return ProbeResult(product, profile, InstallState.NOT_DETECTED, ()) + + +# Local import to keep the top of the module focused on data classes. +import sys # noqa: E402 + + +def resolve_home(home: Path | None) -> Path: + """Pick the home directory honoring ``HOME`` overrides.""" + if home is not None: + return home.resolve() + env_home = os.environ.get("HOME") + if env_home: + # Guard against stale or foreign-format values (e.g. an MSYS-style + # path leaking into native Windows Python, where it cannot exist). + candidate = Path(env_home) + if candidate.is_dir(): + return candidate.resolve() + return Path.home().resolve() + + +def detect_product( + product: str, + profile: str, + *, + binary: Iterable[str] | None = None, + version_command: Iterable[str] | None = None, + file_signature: Iterable[Path | str] | None = None, + home: Path | None = None, + workspace: Path | None = None, + app_bundle_id: str | None = None, +) -> ProbeResult: + """Run a small, deterministic detection probe for one product.""" + if binary: + result = probe_binary( + product, profile, binary, version_command=version_command + ) + if result.state is InstallState.INSTALLED: + return result + if file_signature: + result = probe_file_signature( + product, + profile, + file_signature, + workspace=workspace, + home=home, + ) + if result.state is not InstallState.NOT_DETECTED: + return result + if app_bundle_id: + result = probe_app_bundle(product, profile, darwin_bundle_id=app_bundle_id) + if result.state is InstallState.INSTALLED: + return result + return ProbeResult(product, profile, InstallState.NOT_DETECTED, ()) + + +def detect_profile( + product: str, + profile: str, + *, + binaries: Iterable[str] = (), + version_command: Iterable[str] | None = None, + file_signatures: Iterable[str | Path] = (), + home: Path | None = None, + workspace: Path | None = None, + app_bundle_id: str | None = None, +) -> ProbeResult: + """Convenience wrapper that accepts string paths and expands ``~``.""" + return detect_product( + product, + profile, + binary=binaries, + version_command=version_command, + file_signature=file_signatures, + home=home, + workspace=workspace, + app_bundle_id=app_bundle_id, + ) \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/doc_freshness.py b/skills/agent-skills-setup/agent-skills-setup/scripts/doc_freshness.py new file mode 100644 index 000000000..e4682b6f1 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/doc_freshness.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Validate source provenance offline for curated official docs.""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import date +from pathlib import Path +from typing import Any + + +def load_object(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path}: expected an object") + return value + + +def resolve_profile( + profiles: dict[str, Any], + profile_id: str, + stack: tuple[str, ...] = (), +) -> dict[str, Any]: + if profile_id in stack: + raise ValueError(f"profile inheritance cycle: {profile_id}") + value = dict(profiles[profile_id]) + parent_id = value.pop("inherits", None) + if not parent_id: + return value + parent = resolve_profile(profiles, str(parent_id), stack + (profile_id,)) + parent.update(value) + return parent + + +def validate_provenance( + registry: dict[str, Any], + today: date, + max_age_days: int, +) -> list[str]: + errors: list[str] = [] + for product_id, product in registry.get("products", {}).items(): + profiles = product.get("profiles", {}) if isinstance(product, dict) else {} + for profile_id in profiles: + profile = resolve_profile(profiles, profile_id) + location = f"{product_id}/{profile_id}" + try: + verified = date.fromisoformat(str(profile.get("verified_at", ""))) + age = (today - verified).days + if age < 0 or age > max_age_days: + errors.append(f"{location}: verified_at outside freshness window") + except ValueError: + errors.append(f"{location}: invalid verified_at") + sources = profile.get("sources") + if not isinstance(sources, list) or not sources: + errors.append(f"{location}: missing official sources") + continue + for source in sources: + if not isinstance(source, str) or not source.startswith("https://"): + errors.append(f"{location}: source must use HTTPS") + if product.get("template") and product.get("verified_at"): + location = f"{product_id}/template" + try: + verified = date.fromisoformat(str(product["verified_at"])) + age = (today - verified).days + if age < 0 or age > max_age_days: + errors.append(f"{location}: verified_at outside freshness window") + except ValueError: + errors.append(f"{location}: invalid verified_at") + sources = product.get("sources", []) + if not isinstance(sources, list) or not all( + isinstance(source, str) and source.startswith("https://") + for source in sources + ): + errors.append(f"{location}: invalid sources") + return errors + + +def check_stale_profiles(registry: dict[str, Any], today: date, max_age_days: int) -> list[str]: + """Return list of profiles that have exceeded freshness window.""" + stale: list[str] = [] + for product_id, product in registry.get("products", {}).items(): + if product.get("lifecycle") != "active": + continue + profiles = product.get("profiles", {}) + for profile_id in profiles: + profile = registry["products"][product_id]["profiles"][profile_id] + try: + verified = date.fromisoformat(str(profile.get("verified_at", ""))) + age = (today - verified).days + if age > max_age_days: + stale.append(f"{product_id}/{profile_id}") + except ValueError: + stale.append(f"{product_id}/{profile_id} (invalid verified_at)") + return stale + + +def demote_stale_support(registry: dict[str, Any], stale_profiles: list[str]) -> int: + """Demote stale profiles from partial/manual to stale-* support levels. + Returns number of profiles demoted. + """ + demoted = 0 + for profile_spec in stale_profiles: + product_id, profile_id = profile_spec.split("/", 1) + if product_id not in registry.get("products", {}): + continue + product = registry["products"][product_id] + profiles = product.get("profiles", {}) + if profile_id not in profiles: + continue + profile = profiles[profile_id] + level = profile.get("support_level", "") + if level == "partial": + profile["support_level"] = "stale-partial" + demoted += 1 + elif level == "manual": + profile["support_level"] = "stale-manual" + demoted += 1 + elif level == "source-only": + profile["support_level"] = "stale-source-only" + demoted += 1 + return demoted + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate documentation freshness and source provenance offline." + ) + parser.add_argument("--registry", type=Path, required=True) + parser.add_argument("--checks", type=Path, required=True) + parser.add_argument("--today", default=date.today().isoformat()) + parser.add_argument("--max-age-days", type=int, default=365) + parser.add_argument("--online", action="store_true", help="Disallowed: this skill operates strictly offline") + parser.add_argument("--retries", type=int, default=2) + parser.add_argument("--report", type=Path) + parser.add_argument("--demote-stale", action="store_true", help="Demote stale profiles in registry") + args = parser.parse_args() + + if args.online: + print("ERROR: --online network mode is disallowed. This skill operates strictly offline.", file=sys.stderr) + return 1 + + try: + today = date.fromisoformat(args.today) + if args.retries < 0: + raise ValueError("--retries must be zero or greater") + registry = load_object(args.registry) + checks_document = load_object(args.checks) + errors = validate_provenance(registry, today, args.max_age_days) + + # Check for stale profiles and optionally demote them + stale_profiles = check_stale_profiles(registry, today, args.max_age_days) + if stale_profiles: + stale_error = f"stale profiles detected: {', '.join(stale_profiles)}" + errors.append(stale_error) + if args.demote_stale: + demoted = demote_stale_support(registry, stale_profiles) + if demoted: + # Write back the updated registry + args.registry.write_text( + json.dumps(registry, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"Demoted {demoted} stale profiles", file=sys.stderr) + checks = checks_document.get("checks") + if checks_document.get("schema_version") != 1 or not isinstance(checks, list): + errors.append("freshness checks: unsupported schema") + checks = [] + identifiers: set[str] = set() + for check in checks: + if not isinstance(check, dict): + errors.append("freshness checks: entry must be an object") + continue + identifier = check.get("id") + if not isinstance(identifier, str) or not identifier or identifier in identifiers: + errors.append("freshness checks: IDs must be unique strings") + identifiers.add(str(identifier)) + if not isinstance(check.get("url"), str) or not check["url"].startswith( + "https://" + ): + errors.append(f"freshness check {identifier}: URL must use HTTPS") + terms = check.get("required_terms") + if not isinstance(terms, list) or not terms or not all( + isinstance(term, str) and term for term in terms + ): + errors.append(f"freshness check {identifier}: required_terms missing") + + report = { + "schema_version": 1, + "checked_at": today.isoformat(), + "online": False, + "ok": not errors, + "errors": errors, + "results": [], + } + rendered = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(rendered, encoding="utf-8") + print(rendered, end="") + return 0 if not errors else 1 + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/ide-paths.tsv b/skills/agent-skills-setup/agent-skills-setup/scripts/ide-paths.tsv new file mode 100644 index 000000000..94ed95867 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/ide-paths.tsv @@ -0,0 +1,320 @@ +# GENERATED from references/ide-paths.json; do not edit. +aider global * +aider project-skills * +aider rules * CONVENTIONS.md +aider mcp * +aider project-mcp * +aider project-config * +aider config * ~/.aider.conf.yml +amazon-q global * +amazon-q project-skills * +amazon-q rules * .amazonq/rules +amazon-q mcp * ~/.aws/amazonq/default.json +amazon-q project-mcp * .amazonq/default.json +amazon-q project-config * +amazon-q config * +android-studio global * ~/.agents/skills +android-studio project-skills * .agents/skills +android-studio rules * AGENTS.md +android-studio mcp * +android-studio project-mcp * +android-studio project-config * +android-studio config * +antigravity global * ~/.gemini/config/skills +antigravity project-skills * .agents/skills +antigravity rules * .agents/rules +antigravity mcp * ~/.gemini/config/mcp_config.json +antigravity project-mcp * .agents/mcp_config.json +antigravity project-config * +antigravity config * +augment-code global * ~/.augment/skills +augment-code project-skills * .augment/skills +augment-code rules * .augment/rules +augment-code mcp * ~/.augment/settings.json +augment-code project-mcp * .augment/settings.json +augment-code project-config * .augment/settings.json +augment-code config * ~/.augment/settings.json +baidu-comate global * ~/.comate/skills +baidu-comate project-skills * .comate/skills +baidu-comate rules * +baidu-comate mcp * ~/.comate/mcp.json +baidu-comate project-mcp * .comate/mcp.json +baidu-comate project-config * +baidu-comate config * +blackbox global * +blackbox project-skills * .blackbox/skills +blackbox rules * +blackbox mcp * +blackbox project-mcp * +blackbox project-config * +blackbox config * +claude global * ~/.claude/skills +claude project-skills * .claude/skills +claude rules * CLAUDE.md +claude mcp * ~/.claude.json +claude project-mcp * .mcp.json +claude project-config * .claude/settings.json +claude config * ~/.claude/settings.json +claude-desktop global * +claude-desktop project-skills * +claude-desktop rules * +claude-desktop mcp darwin ~/Library/Application Support/Claude/claude_desktop_config.json +claude-desktop mcp linux +claude-desktop mcp windows %APPDATA%\Claude\claude_desktop_config.json +claude-desktop project-mcp * +claude-desktop project-config * +claude-desktop config * +cline global * ~/.cline/skills +cline project-skills * .cline/skills +cline rules * .cline/rules +cline mcp * ~/.cline/data/settings/cline_mcp_settings.json +cline project-mcp * .cline/mcp.json +cline project-config * +cline config * +codeium global * +codeium project-skills * +codeium rules * +codeium mcp * +codeium project-mcp * +codeium project-config * +codeium config * +codely global * ~/.codely-cli/skills +codely project-skills * .codely-cli/skills +codely rules * CODELY.md +codely mcp * ~/.codely-cli/settings.json +codely project-mcp * .codely-cli/settings.json +codely project-config * .codely-cli/settings.json +codely config * ~/.codely-cli/settings.json +codex global * ~/.agents/skills +codex project-skills * .agents/skills +codex rules * AGENTS.md +codex mcp * ~/.codex/config.toml +codex project-mcp * .codex/config.toml +codex project-config * .codex/config.toml +codex config * ~/.codex/config.toml +cody global * +cody project-skills * +cody rules * +cody mcp * +cody project-mcp * +cody project-config * +cody config * +continue global * +continue project-skills * +continue rules * .continue/rules +continue mcp * ~/.continue/config.yaml +continue project-mcp * .continue/mcpServers +continue project-config * +continue config * ~/.continue/config.yaml +copilot global * ~/.copilot/skills +copilot project-skills * .github/skills +copilot rules * .github/copilot-instructions.md +copilot mcp * ~/.copilot/mcp-config.json +copilot project-mcp * .mcp.json +copilot project-config * +copilot config * +cursor global * ~/.cursor/skills +cursor project-skills * .cursor/skills +cursor rules * .cursor/rules +cursor mcp * ~/.cursor/mcp.json +cursor project-mcp * .cursor/mcp.json +cursor project-config * +cursor config * +emacs global * +emacs project-skills * +emacs rules * +emacs mcp * +emacs project-mcp * +emacs project-config * +emacs config * +firebase-studio global * +firebase-studio project-skills * +firebase-studio rules * .idx/airules.md +firebase-studio mcp * +firebase-studio project-mcp * .idx/mcp.json +firebase-studio project-config * +firebase-studio config * +gemini-cli global * ~/.gemini/skills +gemini-cli project-skills * .gemini/skills +gemini-cli rules * GEMINI.md +gemini-cli mcp * ~/.gemini/settings.json +gemini-cli project-mcp * .gemini/settings.json +gemini-cli project-config * .gemini/settings.json +gemini-cli config * ~/.gemini/settings.json +goose-cli global * ~/.agents/skills +goose-cli project-skills * .agents/skills +goose-cli rules * .goosehints +goose-cli mcp * ~/.config/goose/config.yaml +goose-cli project-mcp * +goose-cli project-config * +goose-cli config * ~/.config/goose/config.yaml +jetbrains global * ~/.junie/skills +jetbrains project-skills * .junie/skills +jetbrains rules * .junie/AGENTS.md +jetbrains mcp * ~/.junie/mcp/mcp.json +jetbrains project-mcp * .junie/mcp/mcp.json +jetbrains project-config * +jetbrains config * +jetbrains-ai global * +jetbrains-ai project-skills * .agents/skills +jetbrains-ai rules * +jetbrains-ai mcp * +jetbrains-ai project-mcp * +jetbrains-ai project-config * +jetbrains-ai config * +kilocode global * ~/.kilo/skills +kilocode project-skills * .kilo/skills +kilocode rules * AGENTS.md +kilocode mcp * ~/.config/kilo/kilo.jsonc +kilocode project-mcp * .kilo/kilo.jsonc +kilocode project-config * .kilo/kilo.jsonc +kilocode config * ~/.config/kilo/kilo.jsonc +kimiai global * ~/.kimi-code/skills +kimiai project-skills * .kimi-code/skills +kimiai rules * AGENTS.md +kimiai mcp * ~/.kimi-code/mcp.json +kimiai project-mcp * .kimi-code/mcp.json +kimiai project-config * +kimiai config * ~/.kimi-code/config.toml +kiro global * ~/.kiro/skills +kiro project-skills * .kiro/skills +kiro rules * .kiro/steering +kiro mcp * ~/.kiro/settings/mcp.json +kiro project-mcp * .kiro/settings/mcp.json +kiro project-config * +kiro config * +neovim global * +neovim project-skills * +neovim rules * +neovim mcp * +neovim project-mcp * +neovim project-config * +neovim config * ~/.config/nvim/init.lua +openclaw global * ~/.openclaw/skills +openclaw project-skills * skills +openclaw rules * AGENTS.md +openclaw mcp * ~/.openclaw/openclaw.json +openclaw project-mcp * +openclaw project-config * +openclaw config * ~/.openclaw/openclaw.json +opencode global * ~/.config/opencode/skills +opencode project-skills * .opencode/skills +opencode rules * AGENTS.md +opencode mcp * ~/.config/opencode/opencode.json +opencode project-mcp * opencode.json +opencode project-config * opencode.json +opencode config * ~/.config/opencode/opencode.json +pearai global * +pearai project-skills * +pearai rules * +pearai mcp * +pearai project-mcp * +pearai project-config * +pearai config * +pieces global * +pieces project-skills * +pieces rules * +pieces mcp * +pieces project-mcp * +pieces project-config * +pieces config * +replit global * +replit project-skills * .agents/skills +replit rules * replit.md +replit mcp * +replit project-mcp * +replit project-config * .replit +replit config * +roo-code global * ~/.roo/skills +roo-code project-skills * .roo/skills +roo-code rules * .roorules +roo-code mcp * +roo-code project-mcp * .roo/mcp.json +roo-code project-config * +roo-code config * +supermaven global * +supermaven project-skills * +supermaven rules * +supermaven mcp * +supermaven project-mcp * +supermaven project-config * +supermaven config * +tabnine global * +tabnine project-skills * +tabnine rules * .tabnine/guidelines +tabnine mcp * ~/.tabnine/mcp_servers.json +tabnine project-mcp * .tabnine/mcp_servers.json +tabnine project-config * +tabnine config * +tencent-codebuddy global * ~/.codebuddy/skills +tencent-codebuddy project-skills * .codebuddy/skills +tencent-codebuddy rules * CODEBUDDY.md +tencent-codebuddy mcp * ~/.codebuddy/.mcp.json +tencent-codebuddy project-mcp * .mcp.json +tencent-codebuddy project-config * .codebuddy/settings.json +tencent-codebuddy config * ~/.codebuddy/settings.json +trae global * ~/.trae/skills +trae project-skills * .trae/skills +trae rules * .trae/rules +trae mcp * +trae project-mcp * .trae/mcp.json +trae project-config * +trae config * +trae-cn global * ~/.trae-cn/skills +trae-cn project-skills * .trae/skills +trae-cn rules * .trae/rules +trae-cn mcp * +trae-cn project-mcp * .trae/mcp.json +trae-cn project-config * +trae-cn config * +visual-studio global * ~/.copilot/skills +visual-studio project-skills * .github/skills +visual-studio rules * .github/copilot-instructions.md +visual-studio mcp darwin +visual-studio mcp linux +visual-studio mcp windows %USERPROFILE%\.mcp.json +visual-studio project-mcp * .mcp.json +visual-studio project-config * +visual-studio config * +void-editor global * +void-editor project-skills * +void-editor rules * .voidrules +void-editor mcp * ~/.void-editor/mcp.json +void-editor project-mcp * .vscode/mcp.json +void-editor project-config * +void-editor config * +vscode global * ~/.copilot/skills +vscode project-skills * .github/skills +vscode rules * .github/copilot-instructions.md +vscode mcp * +vscode project-mcp * .vscode/mcp.json +vscode project-config * +vscode config * +windsurf global * ~/.codeium/windsurf/skills +windsurf project-skills * .windsurf/skills +windsurf rules * .windsurf/rules +windsurf mcp * ~/.codeium/windsurf/mcp_config.json +windsurf project-mcp * +windsurf project-config * +windsurf config * +workbuddy global * +workbuddy project-skills * +workbuddy rules * +workbuddy mcp * ~/.workbuddy/mcp.json +workbuddy project-mcp * .workbuddy/mcp.json +workbuddy project-config * +workbuddy config * +zcode global * ~/.zcode/skills +zcode project-skills * +zcode rules * AGENTS.md +zcode mcp * ~/.zcode/cli/config.json +zcode project-mcp * .zcode/config.json +zcode project-config * .zcode/config.json +zcode config * ~/.zcode/cli/config.json +zed global * ~/.agents/skills +zed project-skills * .agents/skills +zed rules * AGENTS.md +zed mcp * ~/.config/zed/settings.json +zed project-mcp * .zed/settings.json +zed project-config * +zed config * diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/legacy-smart-ide-migration.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/legacy-smart-ide-migration.sh new file mode 100644 index 000000000..5be412a5e --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/legacy-smart-ide-migration.sh @@ -0,0 +1,4109 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "${BASH_SOURCE[0]}" == "$0" && \ + "${AGENT_SKILLS_SETUP_INTERNAL_LEGACY:-}" != "1" ]]; then + echo "ERROR: legacy-smart-ide-migration.sh is internal; use smart-ide-migration.sh." >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "${SCRIPT_DIR}/common.sh" + +SOURCE_IDE="" +TARGET_IDE="" +WORKSPACE_ROOT="$(pwd)" +WORKSPACE_EXPLICIT=0 +OBJECTS="" +SOURCE_MCP_FILE="" +SCOPE="global" +STRATEGY="backup" +DRY_RUN=0 +ASSUME_YES=0 +REPORT_FILE="" +PRINT_PATH_IDE="" +PRINT_PATH_OBJECT="" +OPENCODE_VERSION="v1" +OPENCODE_VERSION_EXPLICIT=0 + +PATH_RESOLVER="${SCRIPT_DIR}/ide-paths.tsv" +[[ -r "$PATH_RESOLVER" ]] || { + log_error "generated path resolver is missing: $PATH_RESOLVER" + exit 1 +} +SUPPORTED_IDES="$(awk -F $'\t' ' + NR > 1 && !seen[$1]++ { printf "%s%s", separator, $1; separator=" " } +' "$PATH_RESOLVER")" + +MIGRATION_TOTAL=0 +MIGRATION_SUCCESS=0 +MIGRATION_FAILED=0 +MIGRATION_SKIPPED=0 + +MIGRATION_STATUS_FILE="" +MIGRATION_MESSAGES_FILE="" +MIGRATION_MANUAL_FILE="" +MIGRATION_EVIDENCE_FILE="" + +registry_platform() { + case "$(uname -s)" in + Darwin) printf 'darwin' ;; + Linux) printf 'linux' ;; + *) printf 'windows' ;; + esac +} + +registry_path() { + local ide="$1" + local object="$2" + local platform + local path + + [[ -r "$PATH_RESOLVER" ]] || { + log_error "generated path resolver is missing: $PATH_RESOLVER" + return 1 + } + platform="$(registry_platform)" + path="$(awk -F $'\t' -v ide="$ide" -v object="$object" -v platform="$platform" ' + $1 == ide && $2 == object && ($3 == "*" || $3 == platform) { print $4; exit } + ' "$PATH_RESOLVER")" + case "$path" in + \~/*) printf '%s/%s\n' "$HOME" "${path#\~/}" ;; + %USERPROFILE%\\*) + path="${path#%USERPROFILE%\\}" + printf '%s/%s\n' "$HOME" "${path//\\//}" + ;; + *) printf '%s\n' "$path" ;; + esac +} + +get_ide_name() { + local ide="$1" + case "$ide" in + android-studio) echo "Android Studio" ;; + antigravity) echo "Antigravity (Google)" ;; + claude) echo "Claude Code" ;; + codely) echo "Tuanjie Codely" ;; + codex) echo "OpenAI Codex CLI" ;; + copilot) echo "GitHub Copilot CLI" ;; + cursor) echo "Cursor" ;; + windsurf) echo "Windsurf" ;; + jetbrains) echo "JetBrains Junie" ;; + jetbrains-ai) echo "JetBrains AI Assistant" ;; + openclaw) echo "OpenClaw" ;; + trae) echo "Trae (International)" ;; + trae-cn) echo "Trae CN (China)" ;; + vscode) echo "VS Code" ;; + visual-studio) echo "Visual Studio" ;; + firebase-studio) echo "Firebase Studio" ;; + zed) echo "Zed Editor" ;; + neovim) echo "Neovim" ;; + emacs) echo "Emacs" ;; + continue) echo "Continue.dev" ;; + aider) echo "Aider" ;; + roo-code) echo "Roo Code" ;; + cline) echo "Cline" ;; + amazon-q) echo "Amazon Q Developer" ;; + cody) echo "Sourcegraph Cody" ;; + codeium) echo "Codeium" ;; + tabnine) echo "Tabnine" ;; + replit) echo "Replit AI" ;; + pearai) echo "PearAI" ;; + supermaven) echo "Supermaven" ;; + pieces) echo "Pieces" ;; + blackbox) echo "Blackbox AI" ;; + gemini-cli) echo "Gemini CLI" ;; + goose-cli) echo "Goose CLI" ;; + opencode) echo "OpenCode" ;; + kilocode) echo "Kilo Code" ;; + kimiai) echo "Kimi AI" ;; + workbuddy) echo "WorkBuddy" ;; + claude-desktop) echo "Claude Desktop" ;; + kiro) echo "Kiro" ;; + augment-code) echo "Augment Code" ;; + void-editor) echo "Void Editor" ;; + baidu-comate) echo "Baidu Comate (ERNIE Code)" ;; + tencent-codebuddy) echo "Tencent CodeBuddy" ;; + zcode) echo "ZCode (Zhipu)" ;; + *) echo "$ide" ;; + esac +} + +get_global_path() { + case "$1" in + antigravity) + if [[ -n "${ANTIGRAVITY_SKILLS_DIR:-}" ]]; then + echo "${ANTIGRAVITY_SKILLS_DIR}" + elif [[ -d "${HOME}/.gemini/antigravity/skills" && ! -d "${HOME}/.gemini/config/skills" ]]; then + echo "${HOME}/.gemini/antigravity/skills" + else + echo "${HOME}/.gemini/config/skills" + fi + ;; + *) registry_path "$1" global ;; + esac +} + +get_project_path() { + local ide="$1" + case "$ide" in + android-studio) echo ".agents" ;; + antigravity) echo ".agents" ;; + claude) echo ".claude" ;; + codex) echo ".agents" ;; + copilot) echo ".github" ;; + cursor) echo ".cursor" ;; + windsurf) echo "" ;; + jetbrains) echo ".junie" ;; + jetbrains-ai) echo ".agents" ;; + openclaw) echo "" ;; + trae) echo ".trae" ;; + trae-cn) echo ".trae" ;; + vscode) echo ".vscode" ;; + visual-studio) echo ".github" ;; + firebase-studio) echo ".idx" ;; + zed) echo "" ;; + neovim) echo "" ;; + emacs) echo "" ;; + continue) echo ".continue" ;; + aider) echo ".aider.conf.yml" ;; + roo-code) echo ".roo" ;; + cline) echo "" ;; + amazon-q) echo ".amazonq" ;; + cody) echo "" ;; + codeium) echo "" ;; + tabnine) echo "" ;; + replit) echo ".replit" ;; + pearai) echo "" ;; + supermaven) echo "" ;; + pieces) echo "" ;; + gemini-cli) echo ".gemini" ;; + blackbox) echo ".blackbox" ;; + goose-cli) echo ".goose" ;; + opencode) echo ".opencode" ;; + kilocode) echo ".kilo" ;; + kimiai) echo ".kimi-code" ;; + workbuddy) echo ".workbuddy" ;; + codely) echo ".codely-cli" ;; + claude-desktop) echo "" ;; # desktop app: no project-level config + kiro) echo ".kiro" ;; + augment-code) echo ".augment" ;; + void-editor) echo "" ;; + baidu-comate) echo ".comate" ;; + tencent-codebuddy) echo ".codebuddy" ;; + zcode) echo ".zcode" ;; + *) echo "" ;; + esac +} + +get_project_skills_path() { + registry_path "$1" project-skills +} + +get_amazon_q_project_mcp_path() { + local project_root="${WORKSPACE_ROOT:-$(pwd)}" + local default_path="${project_root}/.amazonq/default.json" + local legacy_path="${project_root}/.amazonq/mcp.json" + + if [[ -f "$default_path" ]]; then + echo ".amazonq/default.json" + elif [[ -f "$legacy_path" ]]; then + echo ".amazonq/mcp.json" + else + echo ".amazonq/default.json" + fi +} + +get_project_mcp_path() { + case "$1" in + amazon-q) get_amazon_q_project_mcp_path ;; + *) registry_path "$1" project-mcp ;; + esac +} + +get_project_config_file() { + registry_path "$1" project-config +} + +get_rules_file() { + registry_path "$1" rules +} + +get_prompts_path() { + local ide="$1" + case "$ide" in + vscode|visual-studio) echo ".github/prompts" ;; + cursor) echo ".cursor/commands" ;; + windsurf) echo ".windsurf/workflows" ;; + openclaw) echo "" ;; + continue) echo ".continue/prompts" ;; + cline) echo "" ;; + blackbox) echo "" ;; + claude) echo ".claude/commands" ;; + gemini-cli) echo ".gemini/commands" ;; + goose-cli) echo "" ;; + opencode) echo ".opencode/commands" ;; + roo-code) echo ".roo/commands" ;; + trae|trae-cn) echo ".trae/commands" ;; + pieces) echo "" ;; + *) echo "" ;; + esac +} + +get_mcp_path() { + case "$1" in + cline) + if [[ -n "${CLINE_DATA_DIR:-}" ]]; then + echo "${CLINE_DATA_DIR%/}/settings/cline_mcp_settings.json" + elif [[ -n "${CLINE_MCP_PATH:-}" ]]; then + echo "${CLINE_MCP_PATH}" + else + echo "${HOME}/.cline/data/settings/cline_mcp_settings.json" + fi ;; + amazon-q) + local q_default="${HOME}/.aws/amazonq/default.json" + local q_legacy="${HOME}/.aws/amazonq/mcp.json" + if [[ -f "$q_default" ]]; then + echo "$q_default" + elif [[ -f "$q_legacy" ]]; then + echo "$q_legacy" + else + echo "$q_default" + fi + ;; + claude-desktop) + case "$(uname -s)" in + Darwin) + echo "${HOME}/Library/Application Support/Claude/claude_desktop_config.json" + ;; + MINGW*|MSYS*|CYGWIN*) + echo "${APPDATA:-${HOME}/AppData/Roaming}/Claude/claude_desktop_config.json" + ;; + *) + echo "" + ;; + esac + ;; + *) registry_path "$1" mcp ;; + esac +} + +get_config_file() { + registry_path "$1" config +} + +get_mcp_root_key() { + local ide="$1" + local scope="${2:-global}" + if [[ "$ide" == "void-editor" && "$scope" == "project" ]]; then + echo "servers" + return 0 + fi + case "$ide" in + claude|claude-desktop|cursor|windsurf|gemini-cli|trae|trae-cn|continue|cline|roo-code|antigravity|amazon-q|kimiai|codely|workbuddy|copilot|kiro|augment-code|void-editor|baidu-comate|tencent-codebuddy|cody|tabnine|jetbrains) + echo "mcpServers" ;; + codex) echo "mcp_servers" ;; + goose-cli) echo "extensions" ;; + zed) echo "context_servers" ;; + openclaw) echo "mcp.servers" ;; + opencode) + if [[ "$OPENCODE_VERSION" == "v2" ]]; then + echo "mcp.servers" + else + echo "mcp" + fi + ;; + kilocode) echo "mcp" ;; + vscode|visual-studio) echo "servers" ;; + zcode) echo "mcp.servers" ;; + aider) echo "" ;; + blackbox) echo "" ;; + pieces) echo "" ;; + supermaven) echo "" ;; + *) echo "" ;; + esac +} + +usage() { + cat <<'EOF' +Scoped IDE-context migration. + +Usage: smart-ide-migration.sh legacy --source --target [options] + + --workspace Workspace for project-backed objects + --objects skills,rules,prompts,mcp,project-mcp,config,project, + agents,hooks,memory (global default: skills) + --scope global|project|both Skills/MCP scope (default: global) + --strategy skip|backup|overwrite Existing-object handling (default: backup) + --source-mcp-file Reviewed JSON/JSONC MCP input + --opencode-version v1|v2 OpenCode target MCP schema + --report Save report + --json Emit JSON evidence + --print-path Read-only path lookup + --dry-run Parse and preview without writes + --yes, -y Apply writes + -h, --help Show help + +Output: human-readable stdout by default; with --json, stdout is one JSON + document and diagnostics stay on stderr. +Exit: 0 success/preview, 1 invalid input or failed migration, + 2 write refused because --yes was omitted. + +EOF + printf '\nIDs: %s\n' "$SUPPORTED_IDES" + cat <<'EOF' +Note: copilot is GitHub Copilot CLI; vscode and visual-studio are IDE targets. + +Examples: + smart-ide-migration.sh legacy --source cursor --target claude --objects skills,rules --dry-run + smart-ide-migration.sh plan --source cursor/ide --target claude/cli --objects skills,instructions --output plan.json --json + smart-ide-migration.sh apply plan.json --manifest manifest.json --yes --json +EOF +} + +print_header() { + echo "" + echo "========================================" + echo " IDE Migration Tool" + echo "========================================" + echo "" +} + +print_progress() { + local step="$1" + local message="$2" + echo "[${step}] ${message}" +} + +remove_verified_tree() { + local target="$1" + + if [[ -L "$target" ]]; then + rm -f -- "$target" + elif [[ -d "$target" ]]; then + find "$target" -xdev -depth -delete + elif [[ -e "$target" ]]; then + rm -f -- "$target" + else + return 1 + fi +} + +safe_remove_skill_dir() { + local parent="$1" + local name="$2" + + if [[ -z "$parent" || -z "$name" ]]; then + echo " [GUARD] refused to delete: target directory or skill name is empty (parent='$parent', name='$name')" >&2 + return 1 + fi + case "$name" in + */*|.|..|.*/*|-*) + echo " [GUARD] refused to delete: illegal skill name '$name' (path separators/traversal/leading dash forbidden)" >&2 + return 1 + ;; + esac + + local target="$parent/$name" + if [[ -L "$target" ]]; then + remove_verified_tree "$target" + return $? + fi + if [[ ! -d "$target" ]]; then + echo " [GUARD] skipped deletion: target is not a directory or does not exist '$target'" >&2 + return 1 + fi + + remove_verified_tree "$target" +} + +safe_remove_path_within() { + local allowed_root="$1" + local target="$2" + local allowed_real target_parent_real target_name + + if [[ -z "$allowed_root" || -z "$target" || ! -d "$allowed_root" ]]; then + echo " [GUARD] refused to delete: invalid containment root or target" >&2 + return 1 + fi + + allowed_real="$(cd "$allowed_root" 2>/dev/null && pwd -P)" || return 1 + target_parent_real="$(cd "$(dirname "$target")" 2>/dev/null && pwd -P)" || { + echo " [GUARD] refused to delete: target parent cannot be resolved '$target'" >&2 + return 1 + } + target_name="$(basename "$target")" + + if [[ -z "$target_name" || "$target_name" == "." || "$target_name" == ".." ]]; then + echo " [GUARD] refused to delete: invalid target name '$target_name'" >&2 + return 1 + fi + case "$target_parent_real" in + "$allowed_real"|"$allowed_real"/*) ;; + *) + echo " [GUARD] refused to delete path outside workspace: $target" >&2 + return 1 + ;; + esac + + if [[ ! -L "$target" && ! -e "$target" ]]; then + echo " [GUARD] skipped deletion: target does not exist '$target'" >&2 + return 1 + fi + remove_verified_tree "$target" +} + +backup_existing_path() { + local target="$1" + local workspace_real target_parent_real target_name timestamp backup_path + + [[ -n "${WORKSPACE_ROOT:-}" && -d "$WORKSPACE_ROOT" ]] || { + echo " [GUARD] refused backup: workspace is unavailable" >&2 + return 1 + } + [[ -e "$target" || -L "$target" ]] || return 0 + if [[ -L "$target" ]]; then + echo " [GUARD] refused backup through symbolic link: $target" >&2 + return 1 + fi + + workspace_real="$(cd "$WORKSPACE_ROOT" 2>/dev/null && pwd -P)" || return 1 + target_parent_real="$(cd "$(dirname "$target")" 2>/dev/null && pwd -P)" || { + echo " [GUARD] refused backup: target parent cannot be resolved '$target'" >&2 + return 1 + } + case "$target_parent_real" in + "$workspace_real"|"$workspace_real"/*) ;; + *) + echo " [GUARD] refused backup outside workspace: $target" >&2 + return 1 + ;; + esac + + target_name="$(basename "$target")" + [[ -n "$target_name" && "$target_name" != "." && "$target_name" != ".." ]] || { + echo " [GUARD] refused backup: invalid target name '$target_name'" >&2 + return 1 + } + + timestamp="$(date +%Y%m%d%H%M%S).$$" + backup_path="$target.bak.$timestamp" + while [[ -e "$backup_path" || -L "$backup_path" ]]; do + timestamp="${timestamp}.1" + backup_path="$target.bak.$timestamp" + done + if ! mv "$target" "$backup_path"; then + echo " [FAIL] could not back up existing target: $target" >&2 + return 1 + fi + printf '%s\n' "$backup_path" +} + +validate_ide() { + local ide="$1" + local supported + + for supported in $SUPPORTED_IDES; do + [[ "$ide" == "$supported" ]] && return 0 + done + + return 1 +} + +list_available_objects() { + local source_ide="$1" + local objects="" + + + local global_path + global_path=$(get_global_path "$source_ide") + if [[ -d "$global_path" ]]; then + objects+="skills," + fi + + local rules_file + rules_file=$(get_rules_file "$source_ide") + if [[ -n "$rules_file" ]] && [[ -e "$WORKSPACE_ROOT/$rules_file" ]]; then + objects+="rules," + fi + + local prompts_path + prompts_path=$(get_prompts_path "$source_ide") + if [[ -n "$prompts_path" ]] && [[ -d "$WORKSPACE_ROOT/$prompts_path" ]]; then + objects+="prompts," + fi + + local mcp_path + mcp_path=$(get_mcp_path "$source_ide") + if [[ -n "$mcp_path" && "$mcp_path" != /* && "$mcp_path" != [A-Za-z]:* && "$mcp_path" != "\\"* ]]; then + mcp_path="$WORKSPACE_ROOT/$mcp_path" + fi + if [[ -n "$mcp_path" ]] && [[ -e "$mcp_path" ]]; then + objects+="mcp," + fi + + local config_file + config_file=$(get_config_file "$source_ide") + if [[ -n "$config_file" ]] && [[ -f "$config_file" ]]; then + objects+="config," + fi + + local project_path + project_path=$(get_project_path "$source_ide") + if [[ -n "$project_path" ]] && [[ -e "$WORKSPACE_ROOT/$project_path" ]]; then + objects+="project," + fi + + objects="${objects%,}" + echo "$objects" +} + +init_migration_files() { + MIGRATION_STATUS_FILE=$(mktemp) + MIGRATION_MESSAGES_FILE=$(mktemp) + MIGRATION_MANUAL_FILE=$(mktemp) + MIGRATION_EVIDENCE_FILE=$(mktemp) +} + +cleanup_migration_files() { + [[ -f "$MIGRATION_STATUS_FILE" ]] && rm -f "$MIGRATION_STATUS_FILE" + [[ -f "$MIGRATION_MESSAGES_FILE" ]] && rm -f "$MIGRATION_MESSAGES_FILE" + [[ -f "$MIGRATION_MANUAL_FILE" ]] && rm -f "$MIGRATION_MANUAL_FILE" + [[ -f "$MIGRATION_EVIDENCE_FILE" ]] && rm -f "$MIGRATION_EVIDENCE_FILE" + [[ -n "${REDACTOR_PY:-}" && -f "${REDACTOR_PY:-}" ]] && rm -f "$REDACTOR_PY" + return 0 +} + +set_status() { + local obj="$1" + local status="$2" + echo "$obj:$status" >> "$MIGRATION_STATUS_FILE" +} + +set_message() { + local obj="$1" + local message="$2" + echo "$obj:$message" >> "$MIGRATION_MESSAGES_FILE" +} + +set_manual_step() { + local obj="$1" + local step="$2" + echo "$obj:$step" >> "$MIGRATION_MANUAL_FILE" +} + +get_status() { + local obj="$1" + if [[ -f "$MIGRATION_STATUS_FILE" ]]; then + awk -v o="$obj" -F: '$1 == o { sub(/^[^:]*:/, ""); print }' "$MIGRATION_STATUS_FILE" | tail -1 + fi +} + +get_message() { + local obj="$1" + if [[ -f "$MIGRATION_MESSAGES_FILE" ]]; then + awk -v o="$obj" -F: '$1 == o { sub(/^[^:]*:/, ""); print }' "$MIGRATION_MESSAGES_FILE" | tail -1 + fi +} + +get_manual_steps() { + local obj="$1" + if [[ -f "$MIGRATION_MANUAL_FILE" ]]; then + awk -v o="$obj" -F: '$1 == o { sub(/^[^:]*:/, ""); print }' "$MIGRATION_MANUAL_FILE" + fi +} + +sha256_file() { + local file="$1" + [[ -f "$file" ]] || return 1 + local digest="" + if command -v shasum >/dev/null 2>&1; then + digest="$(shasum -a 256 "$file" | awk '{print $1}')" + elif command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "$file" | awk '{print $1}')" + elif command -v python3 >/dev/null 2>&1; then + digest="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$file")" + else + return 1 + fi + # Some hosts prefix tool output (e.g. an MSYS marker before the hex + # digest); keep only the 64-digit hash itself. + printf '%s\n' "$(printf '%s' "$digest" | grep -Eo '[0-9a-fA-F]{64}' | head -n1)" +} + +validate_evidence_target() { + local file="$1" + if [[ ! -f "$file" ]]; then + echo "absent" + return 0 + fi + case "$file" in + *.json|*.jsonc) + if command -v python3 >/dev/null 2>&1 && \ + python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$file" >/dev/null 2>&1; then + echo "valid-json" + else + echo "invalid-json" + fi + ;; + *) + echo "unverified-format" + ;; + esac +} + +json_string_or_null() { + local value="$1" + if [[ -n "$value" ]]; then + printf '"%s"' "$(json_escape "$value")" + else + printf 'null' + fi +} + +record_mcp_evidence() { + local scope="$1" + local source_path="$2" + local target_path="$3" + local source_sha256_before="$4" + local backup_path="${5:-}" + local source_sha256_after="" + local target_sha256="" + local source_unchanged="null" + local target_exists="false" + local target_validation + local status + + source_sha256_after="$(sha256_file "$source_path" 2>/dev/null || true)" + if [[ -n "$source_sha256_before" && -n "$source_sha256_after" ]]; then + if [[ "$source_sha256_before" == "$source_sha256_after" ]]; then + source_unchanged="true" + else + source_unchanged="false" + fi + fi + if [[ -f "$target_path" ]]; then + target_exists="true" + target_sha256="$(sha256_file "$target_path" 2>/dev/null || true)" + fi + target_validation="$(validate_evidence_target "$target_path")" + status="$(get_status mcp)" + + printf '{"scope":"%s","status":"%s","source_path":"%s","target_path":"%s","source_sha256_before":%s,"source_sha256_after":%s,"source_unchanged":%s,"target_exists":%s,"target_sha256":%s,"target_validation":"%s","backup_path":%s}\n' \ + "$(json_escape "$scope")" \ + "$(json_escape "$status")" \ + "$(json_escape "$source_path")" \ + "$(json_escape "$target_path")" \ + "$(json_string_or_null "$source_sha256_before")" \ + "$(json_string_or_null "$source_sha256_after")" \ + "$source_unchanged" \ + "$target_exists" \ + "$(json_string_or_null "$target_sha256")" \ + "$(json_escape "$target_validation")" \ + "$(json_string_or_null "$backup_path")" \ + >> "$MIGRATION_EVIDENCE_FILE" +} + +apply_skill_strategy() { + local target_global="$1" + local skill_name="$2" + [[ -d "$target_global/$skill_name" ]] || return 0 + case "$STRATEGY" in + skip) + echo " [SKIP] skill already exists: $skill_name" + return 1 + ;; + backup) + local timestamp + timestamp="$(date +%Y%m%d%H%M%S).$$" + mv "$target_global/$skill_name" "$target_global/$skill_name.bak.$timestamp" + echo " [BACKUP] backup already exists: $skill_name" + ;; + overwrite) + if ! safe_remove_skill_dir "$target_global" "$skill_name"; then + echo " [FAIL] safe delete before overwrite failed, skipped: $skill_name" + return 2 + fi + ;; + esac + return 0 +} + +preflight_skill_source() { + local skill_dir="$1" + local findings rc=0 + local scanner="${SCRIPT_DIR}/scan-skill-secrets.py" + + if [[ ! -f "$scanner" ]] || ! command -v python3 >/dev/null 2>&1; then + echo " [FAIL] source credential preflight unavailable: $(basename "$skill_dir")" >&2 + return 1 + fi + findings=$(python3 "$scanner" "$skill_dir" 2>&1) || rc=$? + if [[ $rc -ne 0 ]]; then + echo " [FAIL] source credential preflight failed: $(basename "$skill_dir")" >&2 + [[ -n "$findings" ]] && printf '%s\n' "$findings" | sed 's/^/ /' >&2 + return 1 + fi + return 0 +} + +migrate_global_skills() { + local source_ide="$1" + local target_ide="$2" + local strategy_rc + + if [[ "$source_ide" == "pieces" || "$target_ide" == "pieces" ]]; then + set_status "skills" "manual" + set_message "skills" "Pieces uses PiecesOS/host integrations, not a file-backed Agent Skills directory" + set_manual_step "skills" "Pieces: do not use ~/.pieces or .pieces as a Skills path; install/configure Pieces MCP in the consuming IDE through PiecesOS/Desktop MCP settings or pieces mcp setup" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "blackbox" || "$target_ide" == "blackbox" ]]; then + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + set_status "skills" "manual" + set_message "skills" "Blackbox only documents project .blackbox/skills; this migrator has no automatic project Skills migration" + set_manual_step "skills" "Blackbox AI CLI: manually review and migrate project .blackbox/skills//SKILL.md; do not infer ~/.blackbox or treat .blackbox as a global skills directory" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "replit" || "$target_ide" == "replit" ]]; then + set_status "skills" "manual" + set_message "skills" "Replit project Skills use .agents/skills; .local/secondary_skills is a separate compatibility directory and no user-global filesystem path is documented" + set_manual_step "skills" "Replit: review .agents/skills//SKILL.md and .local/secondary_skills/ separately; validate name/description frontmatter and preserve scripts/references/assets; do not infer a global Skills path" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "supermaven" || "$target_ide" == "supermaven" ]]; then + set_status "skills" "manual" + set_message "skills" "Supermaven has no documented portable Agent Skills directory; automatic migration is unsupported" + set_manual_step "skills" "Supermaven: review the host editor extension or Neovim configuration manually; do not treat ~/.supermaven runtime storage or .supermaven as a Skills directory" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "goose-cli" || "$target_ide" == "goose-cli" ]]; then + set_manual_step "skills" "Goose: this global operation uses ~/.agents/skills; review project .agents/skills and legacy .goose/skills separately, and do not treat ~/.config/goose as a Skills directory" + fi + + if [[ "$source_ide" == "opencode" || "$target_ide" == "opencode" ]]; then + set_manual_step "skills" "OpenCode: this operation handles only global ~/.config/opencode/skills; review project .opencode/skills plus .claude/skills/.agents/skills compatibility roots manually" + fi + + if [[ "$source_ide" == "workbuddy" || "$target_ide" == "workbuddy" ]]; then + set_status "skills" "manual" + set_message "skills" "WorkBuddy has an official local-package/UI import, but no stable installed Skills directory or complete package schema" + set_manual_step "skills" "WorkBuddy: open the left 技能 panel → 添加技能 → 上传技能, then choose the local package and verify it in the Skills list. WorkBuddy also documents OpenClaw community-skill import through this Skills entry point. Its custom package examples use skill.yml + implementation files + README, but the package extension/root and full schema are not published; do not treat SKILL.md as a guaranteed WorkBuddy package and do not infer ~/.workbuddy/skills or .workbuddy/skills" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "void-editor" || "$target_ide" == "void-editor" ]]; then + set_status "skills" "manual" + set_message "skills" "Void official source and docs have no Agent Skills directory" + set_manual_step "skills" 'Void: `.voidrules` is a rules file, not Agent Skills; do not treat .void-editor or VS Code storage directory as a Skills directory' + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "cody" || "$target_ide" == "cody" ]]; then + set_status "skills" "manual" + set_message "skills" "Sourcegraph Cody has no documented Agent Skills directory; automatic migration is unsupported" + set_manual_step "skills" "Cody: do not use .cody or another inferred skills path; review the current Enterprise extension surface manually and use Amp or another documented Agent Skills target" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + local source_global + source_global=$(get_global_path "$source_ide") + local target_global + target_global=$(get_global_path "$target_ide") + + if [[ -z "$target_global" ]]; then + set_status "skills" "skipped" + set_message "skills" "target IDE has no global skills directory, skip" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + + if [[ ! -d "$source_global" ]]; then + set_status "skills" "skipped" + set_message "skills" "source directory does not exist: $source_global" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + print_progress "MIGRATE" "Migrating skills (Skills)..." + + local migrated_count=0 + local failed_count=0 + + if [[ "$target_ide" == "copilot" ]]; then + if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$target_global" + fi + + local skill_dir skill_name + for skill_dir in "$source_global"/*/; do + [[ -d "$skill_dir" ]] || continue + [[ -f "$skill_dir/SKILL.md" ]] || continue + skill_name=$(basename "$skill_dir") + + if ! preflight_skill_source "$skill_dir"; then + failed_count=$((failed_count + 1)) + continue + fi + + if [[ -f "$skill_dir/SKILL.md" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + echo " DRY-RUN: cp -r $skill_dir $target_global/$skill_name" + ((migrated_count++)) || true + else + strategy_rc=0 + apply_skill_strategy "$target_global" "$skill_name" || strategy_rc=$? + if [[ $strategy_rc -eq 1 ]]; then + continue + elif [[ $strategy_rc -eq 2 ]]; then + failed_count=$((failed_count + 1)) + continue + fi + + if cp -r "$skill_dir" "$target_global/$skill_name"; then + if redact_skill_copy "$target_global/$skill_name" >/dev/null; then + echo " [OK] migrated skill: $skill_name" + ((migrated_count++)) || true + else + safe_remove_skill_dir "$target_global" "$skill_name" || true + echo " [FAIL] skill copy redaction failed, deleted copy to prevent key leak: $skill_name" + ((failed_count++)) || true + fi + else + echo " [FAIL] migration failed: $skill_name" + ((failed_count++)) || true + fi + fi + fi + done + + set_manual_step "skills" "GitHub Copilot CLI: this operation only migrates global ~/.copilot/skills; for project skills, review .github/skills, .claude/skills or .agents/skills separately" + + else + if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$target_global" + fi + + local skill_dir skill_name + for skill_dir in "$source_global"/*/; do + [[ -d "$skill_dir" ]] || continue + [[ -f "$skill_dir/SKILL.md" ]] || continue + skill_name=$(basename "$skill_dir") + + if ! preflight_skill_source "$skill_dir"; then + failed_count=$((failed_count + 1)) + continue + fi + + if [[ $DRY_RUN -eq 1 ]]; then + echo " DRY-RUN: cp -r $skill_dir $target_global/$skill_name" + ((migrated_count++)) || true + else + strategy_rc=0 + apply_skill_strategy "$target_global" "$skill_name" || strategy_rc=$? + if [[ $strategy_rc -eq 1 ]]; then + continue + elif [[ $strategy_rc -eq 2 ]]; then + failed_count=$((failed_count + 1)) + continue + fi + + if cp -r "$skill_dir" "$target_global/$skill_name"; then + if redact_skill_copy "$target_global/$skill_name" >/dev/null; then + echo " [OK] migrated skill: $skill_name" + ((migrated_count++)) || true + else + safe_remove_skill_dir "$target_global" "$skill_name" || true + echo " [FAIL] skill copy redaction failed, deleted copy to prevent key leak: $skill_name" + ((failed_count++)) || true + fi + else + echo " [FAIL] migration failed: $skill_name" + ((failed_count++)) || true + fi + fi + done + fi + + if [[ "$source_ide" == "vscode" || "$target_ide" == "vscode" || "$source_ide" == "visual-studio" || "$target_ide" == "visual-studio" ]]; then + set_manual_step "skills" "GitHub Copilot IDEs: the mapper uses ~/.copilot/skills and .github/skills; review compatible .claude/skills and .agents/skills locations manually" + fi + + if [[ "$source_ide" == "windsurf" || "$target_ide" == "windsurf" ]]; then + set_manual_step "skills" "Windsurf: this operation handles only global ~/.codeium/windsurf/skills; review project .windsurf/skills, ~/.agents/skills, .agents/skills, and optional .claude/skills compatibility locations manually" + fi + + if [[ $failed_count -gt 0 ]]; then + set_status "skills" "partial" + set_message "skills" "succeeded $migrated_count, failed $failed_count" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + else + set_status "skills" "success" + set_message "skills" "successfully migrated $migrated_count skills" + MIGRATION_SUCCESS=$((MIGRATION_SUCCESS + 1)) + fi +} + +project_skills_manual_only() { + local ide="$1" + case "$ide" in + amazon-q|blackbox|claude-desktop|codeium|cody|continue|emacs|firebase-studio|neovim|pearai|pieces|replit|supermaven|tabnine|void-editor|workbuddy|zcode) + return 0 + ;; + *) + return 1 + ;; + esac +} + +migrate_project_skills() { + local source_ide="$1" + local target_ide="$2" + local source_skills target_skills source_path target_path + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + + if project_skills_manual_only "$source_ide" || project_skills_manual_only "$target_ide"; then + set_status "skills" "manual" + set_message "skills" "project Skills compatibility directory/priority or official path still needs manual review" + set_manual_step "skills" "project Skills: only review native project path; do not blindly merge between compatibility directories, unclear-version or UI-only IDEs; preserve SKILL.md, scripts, references, assets and symlink boundaries" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "zcode" || "$target_ide" == "zcode" ]]; then + set_status "skills" "manual" + set_message "skills" "ZCode project Skills use an official UI import target without a published stable project directory" + set_manual_step "skills" "ZCode: open Settings → Skills → Import, select the external skill, choose Copy or Symlink, then choose Project for the current workspace (or Global for all workspaces). Do not infer .zcode/skills as a project path; the documented filesystem path is user-level ~/.zcode/skills" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "workbuddy" || "$target_ide" == "workbuddy" ]]; then + set_status "skills" "manual" + set_message "skills" "WorkBuddy project Skills are imported through the Skills UI; no stable project directory or complete package schema is published" + set_manual_step "skills" "WorkBuddy: left 技能 → 添加技能 → 上传技能, select the reviewed local package, then verify/enable it in the Skills list; OpenClaw community skills use the same import surface. Do not infer a project Skills directory" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + source_skills=$(get_project_skills_path "$source_ide") + target_skills=$(get_project_skills_path "$target_ide") + if [[ -z "$source_skills" || -z "$target_skills" ]]; then + set_status "skills" "manual" + set_message "skills" "source/target IDE has no confirmable project Skills directory" + set_manual_step "skills" "project Skills: source='$source_skills' target='$target_skills'; please select native directory manually according to IDE Registry, do not infer paths" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + source_path="$WORKSPACE_ROOT/$source_skills" + target_path="$WORKSPACE_ROOT/$target_skills" + if [[ ! -d "$source_path" ]]; then + set_status "skills" "skipped" + set_message "skills" "project Skills source directory does not exist: $source_skills" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$(cd "$source_path" 2>/dev/null && pwd -P)" == "$(cd "$target_path" 2>/dev/null && pwd -P)" ]]; then + set_status "skills" "manual" + set_message "skills" "project Skills source and target resolve to the same path; refusing to self-overwrite" + set_manual_step "skills" "project Skills: source and target IDEs share '$source_skills' on this workspace; pick a different target or relocate the source manually before retrying" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + print_progress "MIGRATE" "Migrating project Skills..." + local migrated_count=0 failed_count=0 skill_dir skill_name timestamp + if [[ $DRY_RUN -eq 0 ]]; then + mkdir -p "$target_path" + fi + + for skill_dir in "$source_path"/*/; do + [[ -d "$skill_dir" ]] || continue + [[ -f "$skill_dir/SKILL.md" ]] || continue + skill_name=$(basename "$skill_dir") + + if ! preflight_skill_source "$skill_dir"; then + failed_count=$((failed_count + 1)) + continue + fi + + if [[ $DRY_RUN -eq 1 ]]; then + echo " DRY-RUN: cp -r $skill_dir $target_path/$skill_name" + migrated_count=$((migrated_count + 1)) + continue + fi + + if [[ -d "$target_path/$skill_name" ]]; then + case "$STRATEGY" in + skip) + echo " [SKIP] project skill already exists: $skill_name" + continue + ;; + backup) + timestamp=$(date +%Y%m%d%H%M%S).$$ + mv "$target_path/$skill_name" "$target_path/$skill_name.bak.$timestamp" + echo " [BACKUP] backed up existing project skill: $skill_name" + ;; + overwrite) + if ! safe_remove_skill_dir "$target_path" "$skill_name"; then + echo " [FAIL] safe delete of project skill before overwrite failed: $skill_name" + failed_count=$((failed_count + 1)) + continue + fi + ;; + esac + fi + + if cp -R "$skill_dir" "$target_path/$skill_name" 2>/dev/null; then + if redact_skill_copy "$target_path/$skill_name" >/dev/null; then + echo " [OK] migrated project skill: $skill_name" + migrated_count=$((migrated_count + 1)) + else + safe_remove_skill_dir "$target_path" "$skill_name" || true + echo " [FAIL] project skill redaction failed, deleted copy to prevent key leak: $skill_name" + failed_count=$((failed_count + 1)) + fi + else + echo " [FAIL] project skill migration failed: $skill_name" + failed_count=$((failed_count + 1)) + fi + done + + set_manual_step "skills" "project Skills: this run only writes target native directory $target_skills; compatibility directories, same-name priority, trust settings and external symlinks still need manual review" + if [[ $failed_count -gt 0 ]]; then + set_status "skills" "partial" + set_message "skills" "project Skills succeeded $migrated_count, failed $failed_count" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + else + set_status "skills" "success" + set_message "skills" "project Skills successfully migrated $migrated_count" + MIGRATION_SUCCESS=$((MIGRATION_SUCCESS + 1)) + fi +} + +migrate_skills() { + local source_ide="$1" + local target_ide="$2" + local scope="${3:-global}" + + case "$scope" in + global) + migrate_global_skills "$source_ide" "$target_ide" + ;; + project) + migrate_project_skills "$source_ide" "$target_ide" + ;; + both) + migrate_global_skills "$source_ide" "$target_ide" + migrate_project_skills "$source_ide" "$target_ide" + ;; + *) + set_status "skills" "failed" + set_message "skills" "unsupported Skills scope: $scope" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + ;; + esac +} + +migrate_rules() { + local source_ide="$1" + local target_ide="$2" + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + + if [[ "$source_ide" == "pieces" || "$target_ide" == "pieces" ]]; then + set_status "rules" "manual" + set_message "rules" "Pieces has no documented portable rules file; context is managed by PiecesOS and the host integration" + set_manual_step "rules" "Pieces: do not copy .pieces or infer a rules file; configure host-IDE instructions separately and use PiecesOS MCP for workflow memory" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "blackbox" || "$target_ide" == "blackbox" ]]; then + set_status "rules" "manual" + set_message "rules" "Blackbox official docs do not define portable rules file or directory; auto migration unsupported" + set_manual_step "rules" "Blackbox: do not infer .blackbox/rules, .blackbox/instructions or root rules file; only review .blackbox/skills/ per official project Skills docs" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "supermaven" || "$target_ide" == "supermaven" ]]; then + set_status "rules" "manual" + set_message "rules" "Supermaven has no documented portable instruction/rules file; .supermavenignore only excludes indexed files" + set_manual_step "rules" "Supermaven: review host-editor/Neovim settings manually; preserve .supermavenignore only as an indexing exclusion file, never as instruction rules" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "goose-cli" || "$target_ide" == "goose-cli" ]]; then + set_manual_step "rules" "Goose: local .goosehints is copied as a project hint only; review global ~/.config/goose/.goosehints, AGENTS.md, nested hints, and CONTEXT_FILE_NAMES manually" + fi + + if [[ "$source_ide" == "opencode" || "$target_ide" == "opencode" ]]; then + set_manual_step "rules" "OpenCode: this operation handles project AGENTS.md; review global ~/.config/opencode/AGENTS.md, Claude-compatible CLAUDE.md fallbacks, and opencode.json instructions globs manually" + fi + + if [[ "$source_ide" == "cody" || "$target_ide" == "cody" ]]; then + set_status "rules" "manual" + set_message "rules" "Sourcegraph Cody has no documented .codyrules or portable project-instructions file" + set_manual_step "rules" "Cody: do not copy .codyrules; review project instructions manually in the target IDE's documented instruction surface" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "windsurf" || "$target_ide" == "windsurf" ]]; then + set_status "rules" "manual" + set_message "rules" "Windsurf rules use scoped files; automatic migration is unsupported" + set_manual_step "rules" "Review current .windsurf/rules/*.md or legacy .windsurfrules manually; preserve each file's trigger and scope. Devin is a separate product surface." + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "continue" || "$target_ide" == "continue" ]]; then + set_status "rules" "manual" + set_message "rules" "Continue rules use .continue/rules/* blocks; automatic migration is unsupported" + set_manual_step "rules" "Review .continue/rules/*.md manually; preserve YAML frontmatter fields name, globs, regex, alwaysApply, and description" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "tabnine" || "$target_ide" == "tabnine" ]]; then + set_status "rules" "manual" + set_message "rules" "Tabnine guidelines use scoped .tabnine/guidelines/*.md files; automatic migration is unsupported" + set_manual_step "rules" "Review ~/.tabnine/guidelines/*.md or project .tabnine/guidelines/*.md manually; preserve each guideline file and scope" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "antigravity" || "$target_ide" == "antigravity" ]]; then + set_status "rules" "manual" + set_message "rules" "Antigravity IDE rules use a directory; manual migration required" + set_manual_step "rules" "Review and merge .agents/rules/ manually; do not convert it to .agents/AGENTS.md" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "amazon-q" || "$target_ide" == "amazon-q" ]]; then + set_status "rules" "manual" + set_message "rules" "Amazon Q rules use .amazonq/rules/*.md; manual migration required" + set_manual_step "rules" "Review .amazonq/rules/*.md manually; preserve the project scope and Markdown format" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "kiro" || "$target_ide" == "kiro" ]]; then + set_status "rules" "manual" + set_message "rules" "Kiro steering is a directory with inclusion/frontmatter semantics; auto single-file migration unsupported" + set_manual_step "rules" "Kiro: review ~/.kiro/steering/*.md and .kiro/steering/*.md; preserve inclusion (always/fileMatch/auto/manual) and file scope" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "augment-code" || "$target_ide" == "augment-code" ]]; then + set_status "rules" "manual" + set_message "rules" "Augment rules use a directory and frontmatter; auto single-file migration unsupported" + set_manual_step "rules" "Augment: review ~/.augment/rules/ and .augment/rules/*.md plus .augment-guidelines; preserve always_apply/agent_requested/manual semantics" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "baidu-comate" || "$target_ide" == "baidu-comate" ]]; then + set_status "rules" "manual" + set_message "rules" "Comate rules use .mdr directory and activation mode; auto single-file migration unsupported" + set_manual_step "rules" "Comate: review .comate/rules/*.mdr manually; preserve its Cursor-compatible frontmatter and activation mode" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "trae-cn" || "$target_ide" == "trae-cn" ]]; then + set_status "rules" "manual" + set_message "rules" "Trae CN rules use .trae/rules directory; auto single-file migration unsupported" + set_manual_step "rules" "Trae CN: review .trae/rules/ manually; preserve frontmatter alwaysApply, globs, description, and scene" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "trae" || "$target_ide" == "trae" ]]; then + set_status "rules" "manual" + set_message "rules" "TRAE rules use the project .trae/rules directory; automatic directory migration is unsupported" + set_manual_step "rules" "TRAE: review .trae/rules/ manually; preserve alwaysApply, globs, description, optional scene, and nested directory scope" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "replit" || "$target_ide" == "replit" ]]; then + set_status "rules" "manual" + set_message "rules" "Replit replit.md is a project-root living document maintained by Agent; automatic overwrite is disabled" + set_manual_step "rules" "Replit: manually merge source instructions into replit.md and preserve existing Agent-maintained context; review custom_instruction/instructions.md separately as static template instructions" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + local source_rules + source_rules=$(get_rules_file "$source_ide") + local target_rules + target_rules=$(get_rules_file "$target_ide") + + if [[ "$source_ide" == "jetbrains" && ! -f "$WORKSPACE_ROOT/$source_rules" ]]; then + if [[ -f "$WORKSPACE_ROOT/AGENTS.md" ]]; then + source_rules="AGENTS.md" + elif [[ -f "$WORKSPACE_ROOT/.junie/guidelines.md" ]]; then + source_rules=".junie/guidelines.md" + fi + fi + + if [[ -z "$source_rules" ]]; then + set_status "rules" "skipped" + set_message "rules" "source IDE does not support rules files" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ -z "$target_rules" ]]; then + set_status "rules" "skipped" + set_message "rules" "target IDE does not support rules files" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "vscode" || "$target_ide" == "vscode" || "$source_ide" == "visual-studio" || "$target_ide" == "visual-studio" ]]; then + set_manual_step "rules" "GitHub Copilot IDEs: the single-file mapper handles .github/copilot-instructions.md only; review AGENTS.md, CLAUDE.md, and .github/instructions/**/*.instructions.md with their scope metadata manually" + fi + + if [[ "$source_ide" == "cline" || "$target_ide" == "cline" ]]; then + set_status "rules" "manual" + set_message "rules" "Cline rules use directory-scoped files; manual migration required" + set_manual_step "rules" "Review and merge current .cline/rules/*.md|*.txt; preserve conditional frontmatter and do not flatten scopes" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "kiro" || "$target_ide" == "kiro" ]]; then + set_status "rules" "manual" + set_message "rules" "Kiro steering is a directory of scoped files; manual migration required" + set_manual_step "rules" "Review .kiro/steering/*.md and ~/.kiro/steering/*.md manually; preserve inclusion frontmatter and do not flatten scopes into one rules file" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$target_ide" == "aider" ]]; then + echo " MANUAL: add read: CONVENTIONS.md to the target .aider.conf.yml (YAML); no config rewrite is performed" + set_manual_step "rules" "Aider: review CONVENTIONS.md and add read: CONVENTIONS.md to the appropriate .aider.conf.yml manually; do not treat Aider config as a skills or MCP file" + fi + + if [[ "$source_ide" == "cursor" || "$target_ide" == "cursor" ]]; then + set_status "rules" "manual" + set_message "rules" "Cursor rules use .cursor/rules/*.mdc; manual migration required" + set_manual_step "rules" "Review .cursor/rules/*.mdc manually; do not flatten into .cursorrules or guess frontmatter conversion" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "void-editor" || "$target_ide" == "void-editor" ]]; then + set_manual_step "rules" "Void: .voidrules is a workspace-root plaintext instruction file; automatic copy is limited to the selected project root, while global AI Instructions and multi-root ordering require manual review" + fi + + print_progress "MIGRATE" "Migrating rules files..." + + local source_path="$WORKSPACE_ROOT/$source_rules" + local target_path="$WORKSPACE_ROOT/$target_rules" + + if [[ ! -f "$source_path" ]]; then + set_status "rules" "skipped" + set_message "rules" "source rules file does not exist: $source_rules" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ -L "$target_path" ]]; then + set_status "rules" "failed" + set_message "rules" "target rules file is a symbolic link; refusing indirect overwrite" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + return 0 + fi + if [[ -e "$target_path" && ! -f "$target_path" ]]; then + set_status "rules" "failed" + set_message "rules" "target rules path is not a regular file" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + return 0 + fi + + if [[ $DRY_RUN -eq 1 ]]; then + if [[ -e "$target_path" ]]; then + case "$STRATEGY" in + skip) + echo " DRY-RUN: skip existing rules file $target_path" + set_status "rules" "skipped" + set_message "rules" "existing rules file would be preserved" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + ;; + backup) + echo " DRY-RUN: backup $target_path before copying" + ;; + esac + fi + echo " DRY-RUN: cp $source_path $target_path" + set_status "rules" "success" + set_message "rules" "rules file ready to migrate" + else + local rules_backup="" + if [[ -e "$target_path" ]]; then + case "$STRATEGY" in + skip) + echo " [SKIP] existing rules file: $target_path" + set_status "rules" "skipped" + set_message "rules" "existing rules file preserved" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + ;; + backup) + rules_backup="$(backup_existing_path "$target_path")" || { + set_status "rules" "failed" + set_message "rules" "could not back up existing rules file" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + return 0 + } + echo " [BACKUP] $target_path -> $rules_backup" + ;; + esac + fi + mkdir -p "$(dirname "$target_path")" + if cp "$source_path" "$target_path"; then + echo " [OK] migrated rule: $source_rules -> $target_rules" + set_status "rules" "success" + set_message "rules" "rules file migration succeeded" + MIGRATION_SUCCESS=$((MIGRATION_SUCCESS + 1)) + else + set_status "rules" "failed" + set_message "rules" "rules file migration failed" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + fi + fi +} + +migrate_prompts() { + local source_ide="$1" + local target_ide="$2" + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + + if [[ "$source_ide" == "amazon-q" || "$target_ide" == "amazon-q" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Amazon Q saved prompts have an official global library path but no cross-IDE prompt converter" + set_manual_step "prompts" "Amazon Q: global prompts are ~/.aws/amazonq/prompts/*.md and are created from the IDE with @ → Prompts → Create a new prompt; project prompt scope is not documented as a portable path. Recreate or review prompt frontmatter/aliases manually" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "pieces" || "$target_ide" == "pieces" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Pieces has no documented portable prompt-template directory" + set_manual_step "prompts" "Pieces: review prompt and memory workflows in PiecesOS/Desktop or the consuming host; do not copy .pieces as prompt files" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "blackbox" || "$target_ide" == "blackbox" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Blackbox official docs do not define portable prompt template directory; auto migration unsupported" + set_manual_step "prompts" "Blackbox: /skill is a CLI session command, not a prompt file directory; do not infer .blackbox/prompts or commands path" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "gemini-cli" || "$target_ide" == "gemini-cli" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Gemini CLI commands use TOML; automatic prompt migration is unsupported" + set_manual_step "prompts" "Gemini CLI: review .gemini/commands/*.toml or ~/.gemini/commands/*.toml manually; preserve required prompt/optional description fields, {{args}}, and !{...} shell blocks instead of copying Markdown files" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "supermaven" || "$target_ide" == "supermaven" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Supermaven has no documented portable prompt-template directory; automatic migration is unsupported" + set_manual_step "prompts" "Supermaven: review prompts/chat settings in the host editor or Neovim configuration manually" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "cody" || "$target_ide" == "cody" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Cody prompts are managed in the Enterprise Prompt Library; portable file migration is unsupported" + set_manual_step "prompts" "Cody: use the Enterprise Prompt Library and its documented custom-command migration; do not copy legacy cody.json or infer a workspace command directory" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "goose-cli" || "$target_ide" == "goose-cli" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Goose prompt templates are global files and slash commands are config.yaml entries; automatic migration is unsupported" + set_manual_step "prompts" "Goose: review ~/.config/goose/prompts/ and slash_commands in ~/.config/goose/config.yaml manually; local .goose/recipes/*.yaml are recipes, not prompt templates" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "trae" || "$target_ide" == "trae" || + "$source_ide" == "trae-cn" || "$target_ide" == "trae-cn" ]]; then + set_status "prompts" "manual" + set_message "prompts" "TRAE commands use .trae/commands/*.md; automatic prompt conversion is unsupported" + set_manual_step "prompts" "TRAE: review project .trae/commands/ manually; preserve filename, description, nesting, and Markdown instruction body" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "windsurf" || "$target_ide" == "windsurf" ]]; then + set_status "prompts" "manual" + set_message "prompts" "Windsurf workflows use a product-specific directory and invocation model" + set_manual_step "prompts" "Windsurf: review .windsurf/workflows/*.md and ~/.codeium/windsurf/global_workflows/*.md manually; preserve frontmatter, slash names, nesting, and documented length limits" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "opencode" || "$target_ide" == "opencode" ]]; then + set_manual_step "prompts" 'OpenCode: project .opencode/commands/*.md is copied as Markdown only; review global ~/.config/opencode/commands/, command entries in opencode.json, frontmatter, and $ARGUMENTS/!`cmd`/@file templates manually' + fi + + if [[ "$source_ide" == "roo-code" || "$target_ide" == "roo-code" ]]; then + set_manual_step "prompts" "Roo Code: project slash commands are .roo/commands/*.md; review command names, mode permissions, and invocation semantics manually after copying. Do not treat .roomodes or global custom_modes.yaml/json as prompt files" + fi + + local source_prompts + source_prompts=$(get_prompts_path "$source_ide") + local target_prompts + target_prompts=$(get_prompts_path "$target_ide") + + if [[ -z "$source_prompts" ]]; then + set_status "prompts" "skipped" + set_message "prompts" "source IDE does not support prompt templates" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ -z "$target_prompts" ]]; then + set_status "prompts" "skipped" + set_message "prompts" "target IDE does not support prompt templates" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "vscode" || "$target_ide" == "vscode" || "$source_ide" == "visual-studio" || "$target_ide" == "visual-studio" ]]; then + set_manual_step "prompts" "GitHub Copilot IDEs: workspace .github/prompts/*.prompt.md is migrated; user prompts and IDE-managed locations require manual review" + fi + + local prompt_pattern="*.md" + if [[ "$source_ide" == "vscode" || "$target_ide" == "vscode" || "$source_ide" == "visual-studio" || "$target_ide" == "visual-studio" ]]; then + prompt_pattern="*.prompt.md" + fi + + print_progress "MIGRATE" "Migrating prompt templates..." + + local source_path="$WORKSPACE_ROOT/$source_prompts" + local target_path="$WORKSPACE_ROOT/$target_prompts" + + if [[ ! -d "$source_path" ]]; then + set_status "prompts" "skipped" + set_message "prompts" "source prompt directory does not exist: $source_prompts" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + local prompt_count + prompt_count=$(find "$source_path" -name "$prompt_pattern" -type f 2>/dev/null | wc -l | tr -d ' ') + + if [[ "$prompt_count" -eq 0 ]]; then + set_status "prompts" "skipped" + set_message "prompts" "source prompt directory is empty" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ -L "$target_path" ]]; then + set_status "prompts" "failed" + set_message "prompts" "target prompt directory is a symbolic link; refusing indirect overwrite" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + return 0 + fi + if [[ -e "$target_path" && ! -d "$target_path" ]]; then + set_status "prompts" "failed" + set_message "prompts" "target prompt path is not a directory" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + return 0 + fi + + if [[ $DRY_RUN -eq 1 ]]; then + if [[ -e "$target_path" ]]; then + case "$STRATEGY" in + skip) + echo " DRY-RUN: skip existing prompt directory $target_path" + set_status "prompts" "skipped" + set_message "prompts" "existing prompt directory would be preserved" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + ;; + backup) + echo " DRY-RUN: backup $target_path before copying" + ;; + esac + fi + echo " DRY-RUN: copy $prompt_pattern files from $source_path to $target_path/" + set_status "prompts" "success" + set_message "prompts" "$prompt_count prompt templates ready to migrate" + else + local prompts_backup="" + if [[ -e "$target_path" ]]; then + case "$STRATEGY" in + skip) + echo " [SKIP] existing prompt directory: $target_path" + set_status "prompts" "skipped" + set_message "prompts" "existing prompt directory preserved" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + ;; + backup) + prompts_backup="$(backup_existing_path "$target_path")" || { + set_status "prompts" "failed" + set_message "prompts" "could not back up existing prompt directory" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + return 0 + } + echo " [BACKUP] $target_path -> $prompts_backup" + ;; + esac + fi + mkdir -p "$target_path" + local prompt_file relative_prompt target_prompt + local prompt_copy_failed=0 + while IFS= read -r -d '' prompt_file; do + relative_prompt="${prompt_file#"$source_path"/}" + target_prompt="$target_path/$relative_prompt" + mkdir -p "$(dirname "$target_prompt")" + if ! cp "$prompt_file" "$target_prompt"; then + prompt_copy_failed=1 + break + fi + done < <(find "$source_path" -name "$prompt_pattern" -type f -print0 2>/dev/null) + if [[ "$prompt_copy_failed" -eq 0 ]]; then + echo " [OK] migrated prompts: $prompt_count files" + set_status "prompts" "success" + set_message "prompts" "successfully migrated $prompt_count prompt templates" + MIGRATION_SUCCESS=$((MIGRATION_SUCCESS + 1)) + else + set_status "prompts" "failed" + set_message "prompts" "prompt template migration failed" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + fi + fi +} + +convert_mcp_file() { + local src="$1" src_key="$2" dst="$3" dst_key="$4" target_ide="$5" strategy="$6" target_version="$7" + CONV_RESULT="" + CONV_DETAIL="" + MCP_REDACTED_COUNT=0 + + if [[ ! -r "$src" ]]; then + CONV_RESULT="failed" + CONV_DETAIL="source MCP config unreadable: $src" + return + fi + + local src_ext dst_ext + src_ext="${src##*.}" + dst_ext="${dst##*.}" + + if [[ "$src_ext" =~ ^jsonc?$ && "$dst_ext" =~ ^jsonc?$ ]] && command -v python3 >/dev/null 2>&1; then + local json_conversion_rc=0 + python3 - "$src" "$src_key" "$dst" "$dst_key" "$target_ide" "$strategy" "$target_version" >/dev/null 2>&1 <<'PYEOF' || json_conversion_rc=$? +import json, os, re, sys +from urllib.parse import parse_qsl, urlsplit +src, src_key, dst, dst_key, target_ide, strategy, target_version = sys.argv[1], (sys.argv[2] or ""), sys.argv[3], (sys.argv[4] or ""), sys.argv[5], sys.argv[6], sys.argv[7] +SECRET_KEY_RE = re.compile(r"(?i)(api[_-]?key|token|secret|password|passwd|authorization|auth|bearer|private[_-]?key|access[_-]?key|client[_-]?secret|session|cookie)", re.IGNORECASE) +URL_CRED_RE = re.compile(r"^(?:https?|postgres|postgresql|mysql|mongodb|mongodb\+srv|redis|ftp|amqp|sqlserver)://[^:@/\s]+:[^@/\s]+@", re.IGNORECASE) +URL_TOKEN_RE = re.compile(r"^(https?://)[^/\s]*:(//)?[A-Za-z0-9_\-]{16,}", re.IGNORECASE) +QUERY_CRED_RE = re.compile(r"[?&](key|token|secret|access[_-]?token|api[_-]?key)=[A-Za-z0-9_\-]{12,}", re.IGNORECASE) +PROVIDER_SECRET_RE = re.compile(r"(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|tvly-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|ya29\.[A-Za-z0-9_-]+|AIza[0-9A-Za-z_-]{35}|sk_live_[A-Za-z0-9]{16,})") +SAFE_ENV_REF_TOKEN = r"(?:\$\{env:[A-Za-z_][A-Za-z0-9_]*\}|\$\{[A-Za-z_][A-Za-z0-9_]*\}|\{env:[A-Za-z_][A-Za-z0-9_]*\})" +SAFE_ENV_REF_RE = re.compile(SAFE_ENV_REF_TOKEN) +SAFE_ENV_REF_FULL_RE = re.compile(r"^" + SAFE_ENV_REF_TOKEN + r"$") +SAFE_BEARER_REF_RE = re.compile(r"^Bearer\s+" + SAFE_ENV_REF_TOKEN + r"$", re.IGNORECASE) + +def is_safe_reference_value(value): + """Return true only when every credential payload is a symbolic env ref.""" + if not isinstance(value, str): + return False + if target_ide == "opencode": + exact_ref = re.fullmatch(r"\{env:[A-Za-z_][A-Za-z0-9_]*\}", value) + bearer_ref = re.fullmatch(r"Bearer\s+\{env:[A-Za-z_][A-Za-z0-9_]*\}", value, re.IGNORECASE) + else: + exact_ref = SAFE_ENV_REF_FULL_RE.fullmatch(value) + bearer_ref = SAFE_BEARER_REF_RE.fullmatch(value) + if exact_ref or bearer_ref: + return True + if not value.lower().startswith(("http://", "https://")) or not SAFE_ENV_REF_RE.search(value): + return False + if PROVIDER_SECRET_RE.search(value) or URL_CRED_RE.match(value) or URL_TOKEN_RE.match(value): + return False + try: + parsed = urlsplit(value) + except ValueError: + return False + if parsed.username or parsed.password: + return False + credential_params = [ + param_value + for key, param_value in parse_qsl(parsed.query, keep_blank_values=True) + if SECRET_KEY_RE.search(key) + ] + if target_ide == "opencode": + return bool(credential_params) and all( + re.fullmatch(r"\{env:[A-Za-z_][A-Za-z0-9_]*\}", item) + for item in credential_params + ) + return bool(credential_params) and all(SAFE_ENV_REF_FULL_RE.fullmatch(item) for item in credential_params) + +def normalize_environment_references(node): + """Translate documented Cursor refs into OpenCode's documented syntax.""" + if isinstance(node, dict): + for key, value in list(node.items()): + node[key] = normalize_environment_references(value) + elif isinstance(node, list): + for index, value in enumerate(node): + node[index] = normalize_environment_references(value) + elif isinstance(node, str) and target_ide == "opencode": + return re.sub( + r"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}", + r"{env:\1}", + node, + ) + return node + +def redact_value(v): + if isinstance(v, str): + if is_safe_reference_value(v): + return v + if PROVIDER_SECRET_RE.search(v): + return "" + if SECRET_KEY_RE.search(v) and ' ' not in v: + return "" + if URL_CRED_RE.match(v) or URL_TOKEN_RE.match(v): + return "" + if QUERY_CRED_RE.search(v): + return "" + return v + +FLAG_RE = re.compile(r"^--?[A-Za-z0-9_\-]+$") +FLAG_EQ_RE = re.compile(r"^(--?[A-Za-z0-9_\-]+)=(.+)$") +SHORT_SECRET_FLAGS = {"-p", "-t", "-k"} + +def redact_node(node, key_ctx=""): + if isinstance(node, dict): + for k, v in list(node.items()): + if isinstance(v, (dict, list)): + redact_node(v, k) + elif isinstance(v, str) and SECRET_KEY_RE.search(k) and not is_safe_reference_value(v): + node[k] = "" + else: + node[k] = redact_value(v) + elif isinstance(node, list): + parent_secret = bool(SECRET_KEY_RE.search(key_ctx or "")) + blank_next = False + for i, item in enumerate(node): + if isinstance(item, (dict, list)): + redact_node(item, key_ctx) + blank_next = False + elif isinstance(item, str): + if parent_secret and not is_safe_reference_value(item): + node[i] = "" + elif blank_next: + node[i] = item if is_safe_reference_value(item) else "" + blank_next = False + else: + m_eq = FLAG_EQ_RE.match(item) + if m_eq and (SECRET_KEY_RE.search(m_eq.group(1)) or m_eq.group(1) in SHORT_SECRET_FLAGS): + node[i] = m_eq.group(1) + "=" + elif item in SHORT_SECRET_FLAGS: + blank_next = True # short secret flag (-p/-t/-k); value blanked + elif FLAG_RE.match(item) and SECRET_KEY_RE.search(item): + blank_next = True # flag kept; next argv element blanked + else: + node[i] = redact_value(item) + else: + blank_next = False + +def _strip_jsonc(text): + out = [] + i = 0 + in_string = False + escaped = False + line_comment = False + block_comment = False + while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else "" + if line_comment: + if ch in "\r\n": + line_comment = False + out.append(ch) + i += 1 + continue + if block_comment: + if ch == "*" and nxt == "/": + block_comment = False + i += 2 + continue + if ch in "\r\n": + out.append(ch) + i += 1 + continue + if in_string: + out.append(ch) + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + i += 1 + continue + if ch == '"': + in_string = True + out.append(ch) + i += 1 + elif ch == "/" and nxt == "/": + line_comment = True + i += 2 + elif ch == "/" and nxt == "*": + block_comment = True + i += 2 + else: + out.append(ch) + i += 1 + text = "".join(out) + out = [] + in_string = False + escaped = False + i = 0 + while i < len(text): + ch = text[i] + if in_string: + out.append(ch) + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + i += 1 + continue + if ch == '"': + in_string = True + out.append(ch) + i += 1 + continue + if ch == ",": + j = i + 1 + while j < len(text) and text[j].isspace(): + j += 1 + if j < len(text) and text[j] in "]}": + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + +def _load_json_document(path): + with open(path) as f: + raw = f.read() + if path.lower().endswith(".jsonc"): + raw = _strip_jsonc(raw) + return json.loads(raw) + +try: + data = _load_json_document(src) +except Exception: + sys.exit(2) # not JSON/JSONC -> caller handles it explicitly +def read_path(obj, key): + for part in key.split('.') if key else []: + if not isinstance(obj, dict): + return {} + obj = obj.get(part, {}) + return obj + +def write_path(obj, key, value): + parts = key.split('.') if key else [] + for part in parts[:-1]: + if not isinstance(obj.get(part), dict): + obj[part] = {} + obj = obj[part] + if parts: + obj[parts[-1]] = value + +if isinstance(data, dict): + if src_key and ('.' in src_key or src_key in data): + servers = read_path(data, src_key) + elif "mcpServers" in data: + servers = data["mcpServers"] + else: + servers = {} +else: + servers = {} +if not servers: + sys.exit(3) +normalize_environment_references(servers) +redact_node(servers) +def strip_execution_approvals(node): + if isinstance(node, dict): + for key in ("autoApprove", "enabledTools", "disabledTools"): + node.pop(key, None) + for value in node.values(): + strip_execution_approvals(value) + elif isinstance(node, list): + for value in node: + strip_execution_approvals(value) + +strip_execution_approvals(servers) +if target_ide == "copilot": + supported_types = {"local", "stdio", "http", "sse"} + if not isinstance(servers, dict): + sys.exit(4) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(4) + transport = server.get("type") + tools = server.get("tools") + if not isinstance(tools, list): + sys.exit(4) + if transport is None: + if not isinstance(server.get("command"), str) or not isinstance(server.get("args"), list): + sys.exit(4) + elif transport not in supported_types: + sys.exit(4) + elif transport in {"local", "stdio"}: + if not isinstance(server.get("command"), str) or not isinstance(server.get("args"), list): + sys.exit(4) + elif not isinstance(server.get("url"), str): + sys.exit(4) +if target_ide == "cline": + if not isinstance(servers, dict): + sys.exit(7) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(7) + has_command = isinstance(server.get("command"), str) + has_url = isinstance(server.get("url"), str) + if has_command == has_url: + sys.exit(7) + if has_command and "args" in server and not isinstance(server["args"], list): + sys.exit(7) + if "env" in server and not isinstance(server["env"], dict): + sys.exit(7) + if "disabled" in server and not isinstance(server["disabled"], bool): + sys.exit(7) + if "timeout" in server and not isinstance(server["timeout"], (int, float)): + sys.exit(7) +if target_ide == "void-editor": + if not isinstance(servers, dict): + sys.exit(15) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(15) + has_command = "command" in server + has_url = "url" in server + if has_command == has_url: + sys.exit(15) + source_type = server.pop("type", None) + server.pop("transport", None) + if has_command: + if source_type not in (None, "local", "stdio"): + sys.exit(15) + if not isinstance(server.get("command"), str) or not server.get("command"): + sys.exit(15) + if "args" in server and (not isinstance(server["args"], list) or not all(isinstance(item, str) for item in server["args"])): + sys.exit(15) + if "env" in server and (not isinstance(server["env"], dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["env"].items())): + sys.exit(15) + if set(server) - {"command", "args", "env"}: + sys.exit(15) + else: + if source_type not in (None, "remote", "http", "sse", "streamable-http"): + sys.exit(15) + if not isinstance(server.get("url"), str) or not server.get("url"): + sys.exit(15) + if set(server) - {"url"}: + sys.exit(15) +if target_ide == "gemini-cli": + if not isinstance(servers, dict): + sys.exit(8) + for name, server in servers.items(): + if "_" in name or not isinstance(server, dict): + sys.exit(8) + endpoint_keys = ("command", "url", "httpUrl") + if not any(isinstance(server.get(key), str) and server.get(key) for key in endpoint_keys): + sys.exit(8) + for key in endpoint_keys: + if key in server and not isinstance(server[key], str): + sys.exit(8) + if "args" in server and (not isinstance(server["args"], list) or not all(isinstance(item, str) for item in server["args"])): + sys.exit(8) + if "headers" in server and (not isinstance(server["headers"], dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["headers"].items())): + sys.exit(8) + if "env" in server and (not isinstance(server["env"], dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["env"].items())): + sys.exit(8) + if "cwd" in server and not isinstance(server["cwd"], str): + sys.exit(8) + if "timeout" in server and (not isinstance(server["timeout"], (int, float)) or isinstance(server["timeout"], bool)): + sys.exit(8) + if "trust" in server and not isinstance(server["trust"], bool): + sys.exit(8) + for key in ("includeTools", "excludeTools"): + if key in server and (not isinstance(server[key], list) or not all(isinstance(item, str) for item in server[key])): + sys.exit(8) +if target_ide == "kilocode": + if not isinstance(servers, dict): + sys.exit(10) + for server in servers.values(): + if not isinstance(server, dict) or "transport" in server: + sys.exit(10) + has_command = "command" in server + has_url = "url" in server + if has_command == has_url: + sys.exit(10) + source_type = server.get("type") + if has_command: + if source_type not in (None, "local", "stdio"): + sys.exit(10) + command = server.get("command") + args = server.get("args", []) + if isinstance(command, str): + command = [command] + if not isinstance(command, list) or not command or not all(isinstance(item, str) for item in command): + sys.exit(10) + if not isinstance(args, list) or not all(isinstance(item, str) for item in args): + sys.exit(10) + server["command"] = command + args + server.pop("args", None) + server["type"] = "local" + if "env" in server: + if "environment" in server or not isinstance(server["env"], dict): + sys.exit(10) + server["environment"] = server.pop("env") + if "environment" in server and (not isinstance(server["environment"], dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in server["environment"].items())): + sys.exit(10) + if any(key in server for key in ("headers", "oauth", "url")): + sys.exit(10) + else: + if source_type not in (None, "remote", "http", "sse", "streamable-http"): + sys.exit(10) + if not isinstance(server.get("url"), str) or not server.get("url"): + sys.exit(10) + server["type"] = "remote" + if "headers" in server and (not isinstance(server["headers"], dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in server["headers"].items())): + sys.exit(10) + if any(key in server for key in ("args", "env", "environment", "cwd", "command")): + sys.exit(10) + if "enabled" in server and not isinstance(server["enabled"], bool): + sys.exit(10) + if "timeout" in server and (not isinstance(server["timeout"], (int, float)) or isinstance(server["timeout"], bool)): + sys.exit(10) + if "oauth" in server and not isinstance(server["oauth"], (bool, dict)): + sys.exit(10) +if target_ide in {"kimiai", "kiro", "zcode"}: + if not isinstance(servers, dict): + sys.exit(12) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(12) + has_command = "command" in server + has_url = "url" in server + if has_command == has_url: + sys.exit(12) + source_type = server.pop("type", None) + if has_command: + if source_type not in (None, "local", "stdio"): + sys.exit(12) + command = server.get("command") + args = server.get("args", []) + if not isinstance(args, list) or not all(isinstance(item, str) for item in args): + sys.exit(12) + if isinstance(command, list): + if not command or not all(isinstance(item, str) for item in command): + sys.exit(12) + command, args = command[0], command[1:] + args + if not isinstance(command, str) or not command: + sys.exit(12) + server["command"] = command + server["args"] = args + if "environment" in server: + if "env" in server or not isinstance(server["environment"], dict): + sys.exit(12) + server["env"] = server.pop("environment") + if "env" in server and (not isinstance(server["env"], dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in server["env"].items())): + sys.exit(12) + if "headers" in server: + sys.exit(12) + else: + if source_type not in (None, "remote", "http", "sse", "streamable-http"): + sys.exit(12) + if not isinstance(server.get("url"), str) or not server.get("url"): + sys.exit(12) + if source_type == "sse" and target_ide == "kimiai": + server["transport"] = "sse" + if "headers" in server and (not isinstance(server["headers"], dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in server["headers"].items())): + sys.exit(12) + if any(key in server for key in ("args", "env", "environment", "cwd", "command")): + sys.exit(12) + if "transport" in server: + if target_ide != "kimiai" or server["transport"] != "sse": + sys.exit(12) + for key in ("enabled", "disabled"): + if key in server and not isinstance(server[key], bool): + sys.exit(12) + for key in ("startupTimeoutMs", "toolTimeoutMs", "timeout"): + if key in server and (not isinstance(server[key], (int, float)) or isinstance(server[key], bool)): + sys.exit(12) +if target_ide == "workbuddy": + if not isinstance(servers, dict): + sys.exit(16) + allowed_keys = {"command", "args", "env"} + for server in servers.values(): + if not isinstance(server, dict) or set(server) - allowed_keys: + sys.exit(16) + if not isinstance(server.get("command"), str) or not server.get("command"): + sys.exit(16) + if "args" in server and (not isinstance(server["args"], list) or not all(isinstance(item, str) for item in server["args"])): + sys.exit(16) + if "env" in server and (not isinstance(server["env"], dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["env"].items())): + sys.exit(16) +if target_ide == "jetbrains": + if not isinstance(servers, dict): + sys.exit(17) + for server in servers.values(): + if not isinstance(server, dict) or set(server) - {"command", "args", "env"}: + sys.exit(17) + if not isinstance(server.get("command"), str) or not server.get("command"): + sys.exit(17) + if "args" in server and (not isinstance(server["args"], list) or not all(isinstance(item, str) for item in server["args"])): + sys.exit(17) + if "env" in server and (not isinstance(server["env"], dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["env"].items())): + sys.exit(17) +if target_ide == "augment-code": + if not isinstance(servers, dict): + sys.exit(13) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(13) + has_command = "command" in server + has_url = "url" in server + if has_command == has_url: + sys.exit(13) + source_type = server.get("type") + if has_command: + if source_type not in (None, "local", "stdio"): + sys.exit(13) + command = server.get("command") + args = server.get("args", []) + if not isinstance(args, list) or not all(isinstance(item, str) for item in args): + sys.exit(13) + if isinstance(command, list): + if not command or not all(isinstance(item, str) for item in command): + sys.exit(13) + command, args = command[0], command[1:] + args + if not isinstance(command, str) or not command or not isinstance(args, list) or not all(isinstance(item, str) for item in args): + sys.exit(13) + server["command"], server["args"] = command, args + server.pop("type", None) + if "env" in server and (not isinstance(server["env"], dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in server["env"].items())): + sys.exit(13) + else: + if source_type not in {"http", "sse"} or not isinstance(server.get("url"), str) or not server.get("url"): + sys.exit(13) + if "headers" in server and (not isinstance(server["headers"], dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in server["headers"].items())): + sys.exit(13) + if "enabled" in server and not isinstance(server["enabled"], bool): + sys.exit(13) +if target_ide == "baidu-comate": + if not isinstance(servers, dict): + sys.exit(14) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(14) + transport = server.get("type", server.get("transportType")) + if transport not in {"stdio", "sse", "streamableHttp", "streamable-http", "http"}: + sys.exit(14) + server["type"] = transport + server.pop("transportType", None) + if transport == "stdio": + if not isinstance(server.get("command"), str) or not server.get("command"): + sys.exit(14) + if "url" in server or ("args" in server and (not isinstance(server["args"], list) or not all(isinstance(item, str) for item in server["args"]))): + sys.exit(14) + else: + if not isinstance(server.get("url"), str) or not server.get("url"): + sys.exit(14) + if "command" in server or "args" in server: + sys.exit(14) + for key in ("env", "headers", "requestInit"): + if key in server and not isinstance(server[key], dict): + sys.exit(14) + if "cwd" in server and not isinstance(server["cwd"], str): + sys.exit(14) + for key in ("timeout",): + if key in server and (not isinstance(server[key], (int, float)) or isinstance(server[key], bool)): + sys.exit(14) + if "disabled" in server and not isinstance(server["disabled"], bool): + sys.exit(14) +if target_ide == "opencode": + if not isinstance(servers, dict): + sys.exit(10) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(10) + has_command = "command" in server + has_url = "url" in server + if has_command == has_url: + sys.exit(10) + source_type = server.get("type") + if "transport" in server: + sys.exit(10) + if has_command: + if source_type not in (None, "local", "stdio"): + sys.exit(10) + command = server.get("command") + args = server.get("args", []) + if isinstance(command, str): + command_array = [command] + elif isinstance(command, list) and all(isinstance(item, str) for item in command): + command_array = list(command) + else: + sys.exit(10) + if not isinstance(args, list) or not all(isinstance(item, str) for item in args): + sys.exit(10) + server["command"] = command_array + args + server.pop("args", None) + server["type"] = "local" + if "env" in server: + if "environment" in server or not isinstance(server["env"], dict): + sys.exit(10) + server["environment"] = server.pop("env") + if "environment" in server and ( + not isinstance(server["environment"], dict) + or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["environment"].items()) + ): + sys.exit(10) + if "cwd" in server and not isinstance(server["cwd"], str): + sys.exit(10) + if "enabled" in server and not isinstance(server["enabled"], bool): + sys.exit(10) + if "timeout" in server and (not isinstance(server["timeout"], (int, float)) or isinstance(server["timeout"], bool)): + sys.exit(10) + if any(key in server for key in ("headers", "oauth")): + sys.exit(10) + else: + if source_type not in (None, "remote", "http", "sse", "streamable-http"): + sys.exit(10) + if not isinstance(server.get("url"), str) or not server.get("url"): + sys.exit(10) + server["type"] = "remote" + if "headers" in server and ( + not isinstance(server["headers"], dict) + or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["headers"].items()) + ): + sys.exit(10) + if "oauth" in server and server["oauth"] is not False and not isinstance(server["oauth"], dict): + sys.exit(10) + if "enabled" in server and not isinstance(server["enabled"], bool): + sys.exit(10) + if "timeout" in server and (not isinstance(server["timeout"], (int, float)) or isinstance(server["timeout"], bool)): + sys.exit(10) + if any(key in server for key in ("args", "env", "environment", "cwd")): + sys.exit(10) + if target_version == "v2": + if "enabled" in server and "disabled" in server: + sys.exit(10) + if "enabled" in server: + server["disabled"] = not server.pop("enabled") + if "timeout" in server: + timeout = server["timeout"] + if not isinstance(timeout, (int, float)) or isinstance(timeout, bool): + sys.exit(10) + server["timeout"] = { + "catalog": timeout, + "execution": timeout, + } + if isinstance(server.get("oauth"), dict): + oauth = server["oauth"] + for old_key, new_key in { + "clientId": "client_id", + "clientSecret": "client_secret", + "callbackPort": "callback_port", + "redirectUri": "redirect_uri", + }.items(): + if old_key in oauth and new_key in oauth: + sys.exit(10) + if old_key in oauth: + oauth[new_key] = oauth.pop(old_key) +if target_ide in {"vscode", "visual-studio"}: + if not isinstance(servers, dict): + sys.exit(6) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(6) + if any(key in server for key in ("transport", "serverUrl")): + sys.exit(6) + has_command = "command" in server + has_url = "url" in server + if has_command == has_url: + sys.exit(6) + transport = server.get("type") + if transport is not None and transport not in {"stdio", "http", "sse"}: + sys.exit(6) + if has_command: + if transport not in (None, "stdio"): + sys.exit(6) + if not isinstance(server.get("command"), str) or not server.get("command"): + sys.exit(6) + if "args" in server and ( + not isinstance(server["args"], list) + or not all(isinstance(item, str) for item in server["args"]) + ): + sys.exit(6) + if "env" in server and ( + not isinstance(server["env"], dict) + or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["env"].items()) + ): + sys.exit(6) + for key in ("cwd", "envFile"): + if key in server and not isinstance(server[key], str): + sys.exit(6) + if "sandboxEnabled" in server and not isinstance(server["sandboxEnabled"], bool): + sys.exit(6) + if any(key in server for key in ("url", "headers", "oauth")): + sys.exit(6) + else: + if not isinstance(server.get("url"), str) or not server.get("url"): + sys.exit(6) + if transport not in {"http", "sse"}: + sys.exit(6) + if any(key in server for key in ("command", "args", "env", "cwd", "envFile", "sandboxEnabled")): + sys.exit(6) + if "headers" in server and ( + not isinstance(server["headers"], dict) + or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["headers"].items()) + ): + sys.exit(6) + if "oauth" in server and not isinstance(server["oauth"], dict): + sys.exit(6) +if target_ide == "windsurf": + if not isinstance(servers, dict): + sys.exit(18) + for server in servers.values(): + if not isinstance(server, dict) or any(key in server for key in ("type", "transport")): + sys.exit(18) + has_command = "command" in server + remote_keys = [key for key in ("serverUrl", "url") if key in server] + if has_command and remote_keys: + sys.exit(18) + if has_command: + if set(server) - {"command", "args", "env"}: + sys.exit(18) + if not isinstance(server.get("command"), str) or not server.get("command"): + sys.exit(18) + if "args" in server and ( + not isinstance(server["args"], list) + or not all(isinstance(item, str) for item in server["args"]) + ): + sys.exit(18) + if "env" in server and ( + not isinstance(server["env"], dict) + or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["env"].items()) + ): + sys.exit(18) + else: + if len(remote_keys) != 1: + sys.exit(18) + remote_key = remote_keys[0] + if not isinstance(server.get(remote_key), str) or not server.get(remote_key): + sys.exit(18) + if set(server) - {remote_key, "headers"}: + sys.exit(18) + if "headers" in server and ( + not isinstance(server["headers"], dict) + or not all(isinstance(key, str) and isinstance(value, str) for key, value in server["headers"].items()) + ): + sys.exit(18) +if target_ide == "openclaw": + if not isinstance(servers, dict): + sys.exit(5) + for server in servers.values(): + if not isinstance(server, dict): + sys.exit(5) + if "url" in server: + transport = server.get("transport") + if transport == "http": + server["transport"] = "streamable-http" + elif transport != "streamable-http": + sys.exit(5) +if target_ide == "zed": + if not isinstance(servers, dict): + sys.exit(6) + for server in servers.values(): + if not isinstance(server, dict) or "type" in server: + sys.exit(6) + has_command = "command" in server + has_url = "url" in server + if has_command == has_url: + sys.exit(6) + if has_command: + if not isinstance(server.get("command"), str): + sys.exit(6) + if "args" in server and not isinstance(server["args"], list): + sys.exit(6) + if "env" in server and not isinstance(server["env"], dict): + sys.exit(6) + else: + if not isinstance(server.get("url"), str): + sys.exit(6) + if "headers" in server and not isinstance(server["headers"], dict): + sys.exit(6) +if target_ide == "antigravity" and isinstance(servers, dict): + for server in servers.values(): + if isinstance(server, dict) and "url" in server: + server.setdefault("serverUrl", server["url"]) + del server["url"] +existing = {} +if os.path.exists(dst): + try: + existing = _load_json_document(dst) + except Exception: + if target_ide in {"gemini-cli", "opencode", "kilocode", "kimiai", "kiro", "workbuddy", "jetbrains", "vscode", "visual-studio", "windsurf", "void-editor", "augment-code", "baidu-comate", "zcode"}: + sys.exit(9) + existing = {} +if not isinstance(existing, dict): + if target_ide in {"gemini-cli", "opencode", "kilocode", "kimiai", "kiro", "workbuddy", "jetbrains", "vscode", "visual-studio", "windsurf", "void-editor", "augment-code", "baidu-comate", "zcode"}: + sys.exit(9) + existing = {} +if target_ide == "opencode" and isinstance(existing.get("mcp"), dict): + existing_mcp = existing["mcp"] + if target_version == "v2": + if any(key not in {"servers", "timeout"} for key in existing_mcp): + existing["mcp"] = {} + elif "servers" in existing_mcp: + existing["mcp"] = {} +if strategy == "overwrite": + if dst_key: + write_path(existing, dst_key, {}) + else: + existing = {} +if dst_key: + cur = read_path(existing, dst_key) + if not isinstance(cur, dict): + cur = {} + if isinstance(servers, dict): + cur.update(servers) + write_path(existing, dst_key, cur) +else: + if isinstance(servers, dict): + existing.update(servers) + else: + existing = servers +with open(dst, "w") as f: + json.dump(existing, f, indent=2) +sys.exit(0) +PYEOF + if [[ "$json_conversion_rc" -eq 0 ]]; then + if MCP_REDACTED_COUNT=$(redact_secrets_in_file "$dst"); then + CONV_RESULT="success" + CONV_DETAIL="MCP config converted (root key ${src_key:-mcpServers} -> ${dst_key:-mcpServers}); literal credentials cleared and supported environment references preserved/converted" + else + MCP_REDACTED_COUNT=0 + CONV_RESULT="failed" + CONV_DETAIL="MCP config redaction failed, target file deleted to prevent secret leak (source file untouched)" + fi + return + fi + if [[ "$target_ide" == "copilot" && "$json_conversion_rc" -eq 4 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="GitHub Copilot CLI MCP transport/schema is unsupported; review manually (supported: local, stdio, http, sse)" + return + fi + if [[ ( "$target_ide" == "vscode" || "$target_ide" == "visual-studio" ) && "$json_conversion_rc" -eq 6 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="GitHub Copilot IDE MCP schema/transport is ambiguous or unsupported; review manually (the target uses servers with stdio/http/sse)" + return + fi + if [[ ( "$target_ide" == "vscode" || "$target_ide" == "visual-studio" ) && "$json_conversion_rc" -eq 9 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="GitHub Copilot IDE target MCP file is not a valid JSON object; existing target was not overwritten" + return + fi + if [[ "$target_ide" == "windsurf" && "$json_conversion_rc" -eq 18 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Windsurf MCP schema is invalid or ambiguous; review documented command/args/env or serverUrl|url/headers shapes" + return + fi + if [[ "$target_ide" == "windsurf" && "$json_conversion_rc" -eq 9 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Windsurf target mcp_config.json is not a valid JSON object; existing target was not overwritten" + return + fi + if [[ "$target_ide" == "openclaw" && "$json_conversion_rc" -eq 5 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="OpenClaw MCP transport/schema is unsupported; remote entries require url plus transport=streamable-http (no transport is not inferred)" + return + fi + if [[ "$target_ide" == "zed" && "$json_conversion_rc" -eq 6 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Zed context_servers schema is unsupported; review manually (use local command/args/env or remote url/headers; do not infer transport/type)" + return + fi + if [[ "$target_ide" == "cline" && "$json_conversion_rc" -eq 7 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Cline MCP mcpServers schema is invalid or ambiguous; review manually (each server needs exactly one command or url, with args/env/disabled/timeout types validated)" + return + fi + if [[ "$target_ide" == "gemini-cli" && "$json_conversion_rc" -eq 8 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Gemini CLI MCP schema is invalid or ambiguous; review manually (each server needs command, url, or httpUrl, and aliases must not contain underscores)" + return + fi + if [[ "$target_ide" == "gemini-cli" && "$json_conversion_rc" -eq 9 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Gemini CLI target settings.json is not a valid JSON object; existing target was not overwritten" + return + fi + if [[ "$target_ide" == "kilocode" && "$json_conversion_rc" -eq 10 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Kilo Code MCP JSONC schema is invalid or ambiguous; review mcp entries manually (local type=local with command array/environment, remote type=remote with url/headers)" + return + fi + if [[ "$json_conversion_rc" -eq 12 && ("$target_ide" == "kimiai" || "$target_ide" == "kiro" || "$target_ide" == "zcode") ]]; then + CONV_RESULT="failed" + CONV_DETAIL="target IDE's MCP mcpServers/schema is invalid or ambiguous; please review manually per official command/args or url/headers format" + return + fi + if [[ "$target_ide" == "workbuddy" && "$json_conversion_rc" -eq 16 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="WorkBuddy desktop MCP schema is unsupported or contains an undocumented remote/metadata field; review manually (documented local shape: command, optional args, optional env)" + return + fi + if [[ "$target_ide" == "jetbrains" && "$json_conversion_rc" -eq 17 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Junie MCP schema is unsupported or contains an undocumented remote/metadata field; review manually (documented local shape: command, optional args, optional env)" + return + fi + if [[ "$target_ide" == "void-editor" && "$json_conversion_rc" -eq 15 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Void MCP schema is invalid or ambiguous; review the custom mcpServers format (command/args/env or URL-only remote; headers/auth require manual review)" + return + fi + if [[ "$target_ide" == "augment-code" && "$json_conversion_rc" -eq 13 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Augment MCP schema is invalid or remote transport is ambiguous; review manually (local command/args/env, remote type=http|sse with url/headers)" + return + fi + if [[ "$target_ide" == "baidu-comate" && "$json_conversion_rc" -eq 14 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Comate MCP schema is invalid; review manually (required type=stdio|sse|streamableHttp with command or url)" + return + fi + if [[ "$target_ide" == "opencode" && "$json_conversion_rc" -eq 10 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="OpenCode MCP schema is invalid or ambiguous; review manually (local requires type=local plus command array/environment, remote requires type=remote plus url/headers/oauth)" + return + fi + if [[ "$target_ide" == "gemini-cli" ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Gemini CLI MCP source is not a valid non-empty JSON mcpServers map; manual conversion required" + return + fi + fi + + if [[ "$target_ide" == "gemini-cli" ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Gemini CLI MCP requires a JSON mcpServers conversion; source format is unsupported for automatic migration" + return + fi + + if [[ "$target_ide" == "opencode" ]]; then + CONV_RESULT="failed" + CONV_DETAIL="OpenCode MCP requires a JSON mcp conversion; source format is unsupported for automatic migration" + return + fi + + if [[ "$target_ide" == "vscode" ]]; then + CONV_RESULT="failed" + CONV_DETAIL='VS Code MCP requires a JSON `servers` conversion; source format is unsupported for automatic migration' + return + fi + + if [[ "$target_ide" == "windsurf" ]]; then + CONV_RESULT="failed" + CONV_DETAIL="Windsurf MCP requires a JSON mcpServers conversion; source format is unsupported for automatic migration" + return + fi + + if [[ "$target_ide" == "kilocode" || "$target_ide" == "kimiai" || "$target_ide" == "kiro" || "$target_ide" == "workbuddy" || "$target_ide" == "jetbrains" || "$target_ide" == "void-editor" || "$target_ide" == "augment-code" || "$target_ide" == "baidu-comate" || "$target_ide" == "zcode" ]]; then + CONV_RESULT="failed" + CONV_DETAIL="target IDE's MCP file needs JSON/JSONC Schema conversion; current source format not supported for auto migration" + return + fi + + if [[ -n "${SOURCE_MCP_FILE:-}" ]]; then + CONV_RESULT="failed" + CONV_DETAIL="explicit MCP source did not pass schema conversion; copy-as-is fallback is disabled" + return + fi + + if [[ "${MCP_ALLOW_COPY_FALLBACK:-1}" -ne 1 ]]; then + CONV_RESULT="failed" + CONV_DETAIL="source/target MCP format not directly compatible, and copy-as-is fallback is disabled (MCP_ALLOW_COPY_FALLBACK=0)" + return + fi + if cp "$src" "$dst"; then + if [[ -s "$dst" ]]; then + if MCP_REDACTED_COUNT=$(redact_secrets_in_file "$dst"); then + CONV_RESULT="copied" + CONV_DETAIL="MCP config copied as-is (source/target format not directly compatible, manual root key adjustment ${src_key:-?} -> ${dst_key:-?} needed); literal credentials cleared and supported environment references preserved" + else + MCP_REDACTED_COUNT=0 + CONV_RESULT="failed" + CONV_DETAIL="MCP config redaction failed, target file deleted to prevent secret leak (source file untouched)" + fi + else + CONV_RESULT="failed" + CONV_DETAIL="MCP config empty after copy" + fi + else + CONV_RESULT="failed" + CONV_DETAIL="MCP config copy failed" + fi +} + +inspect_mcp_source_file() { + local src="$1" src_key="$2" + + if ! command -v python3 >/dev/null 2>&1; then + echo " [FAIL] cannot validate MCP source without python3: $src" >&2 + return 1 + fi + + python3 - "$src" "$src_key" <<'PYEOF' +import json, re, sys + +src, root_key = sys.argv[1], sys.argv[2] + +def strip_jsonc(text): + out = [] + i = 0 + in_string = escaped = line_comment = block_comment = False + while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else "" + if line_comment: + if ch in "\r\n": + line_comment = False + out.append(ch) + i += 1 + continue + if block_comment: + if ch == "*" and nxt == "/": + block_comment = False + i += 2 + continue + if ch in "\r\n": + out.append(ch) + i += 1 + continue + if in_string: + out.append(ch) + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + i += 1 + continue + if ch == '"': + in_string = True + out.append(ch) + i += 1 + elif ch == "/" and nxt == "/": + line_comment = True + i += 2 + elif ch == "/" and nxt == "*": + block_comment = True + i += 2 + else: + out.append(ch) + i += 1 + text = "".join(out) + out = [] + i = 0 + in_string = escaped = False + while i < len(text): + ch = text[i] + if in_string: + out.append(ch) + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + i += 1 + continue + if ch == '"': + in_string = True + out.append(ch) + i += 1 + continue + if ch == ",": + j = i + 1 + while j < len(text) and text[j].isspace(): + j += 1 + if j < len(text) and text[j] in "]}": + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + +def read_path(node, dotted): + for part in filter(None, dotted.split(".")): + if not isinstance(node, dict): + return None + node = node.get(part) + return node + +try: + with open(src, encoding="utf-8") as handle: + document = json.loads(strip_jsonc(handle.read())) +except (OSError, UnicodeError, json.JSONDecodeError) as exc: + print(f" [FAIL] MCP source is not readable JSON/JSONC: {exc}", file=sys.stderr) + sys.exit(1) + +servers = read_path(document, root_key) +if not isinstance(servers, dict) or not servers: + print( + f" [FAIL] MCP source has no non-empty object at root key {root_key or ''}", + file=sys.stderr, + ) + sys.exit(1) + +if not all(isinstance(name, str) and name and isinstance(server, dict) for name, server in servers.items()): + print(" [FAIL] MCP source server map contains an invalid name or entry", file=sys.stderr) + sys.exit(1) + +for name, server in servers.items(): + has_command = isinstance(server.get("command"), (str, list)) and bool(server.get("command")) + url_endpoints = [ + key for key in ("url", "serverUrl", "httpUrl") + if isinstance(server.get(key), str) and bool(server.get(key)) + ] + if int(has_command) + len(url_endpoints) != 1: + print( + f" [FAIL] MCP source entry {name!r} must declare exactly one command or url endpoint", + file=sys.stderr, + ) + sys.exit(1) + +print(f" validated MCP source: {len(servers)} server entries at root key {root_key or ''}") +PYEOF +} + +REDACTOR_PY="" +ensure_redactor_script() { + if [[ -n "${REDACTOR_PY:-}" && -f "${REDACTOR_PY:-}" ]]; then + return 0 + fi + REDACTOR_PY=$(mktemp "${TMPDIR:-/tmp}/redact-engine.XXXXXX") || return 1 + cat >"$REDACTOR_PY" <<'PYEOF' +import os, re, sys +from urllib.parse import parse_qsl, urlsplit + +SECRET_KEY_RE = re.compile(r"(?i)(api[_-]?key|token|secret|password|passwd|authorization|auth|bearer|private[_-]?key|access[_-]?key|client[_-]?secret|session|cookie)") +URL_CRED_RE = re.compile(r"^(?:https?|postgres|postgresql|mysql|mongodb|mongodb\+srv|redis|ftp|amqp|sqlserver)://[^:@/\s]+:[^@/\s]+@", re.IGNORECASE) +URL_TOKEN_RE = re.compile(r"^(https?://)[^/\s]*:(//)?[A-Za-z0-9_\-]{16,}", re.IGNORECASE) +QUERY_CRED_RE = re.compile(r"[?&](key|token|secret|access[_-]?token|api[_-]?key)=[A-Za-z0-9_\-]{12,}", re.IGNORECASE) +PROVIDER_SECRET_RE = re.compile(r"(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|tvly-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|ya29\.[A-Za-z0-9_-]+|AIza[0-9A-Za-z_-]{35}|sk_live_[A-Za-z0-9]{16,})") +SAFE_ENV_REF_TOKEN = r"(?:\$\{env:[A-Za-z_][A-Za-z0-9_]*\}|\$\{[A-Za-z_][A-Za-z0-9_]*\}|\{env:[A-Za-z_][A-Za-z0-9_]*\})" +SAFE_ENV_REF_RE = re.compile(SAFE_ENV_REF_TOKEN) +SAFE_ENV_REF_FULL_RE = re.compile(r"^" + SAFE_ENV_REF_TOKEN + r"$") +SAFE_BEARER_REF_RE = re.compile(r"^Bearer\s+" + SAFE_ENV_REF_TOKEN + r"$", re.IGNORECASE) +SHORT_SECRET_FLAGS = {"-p", "-t", "-k"} +FLAG_RE = re.compile(r"^--?[A-Za-z0-9_\-]+$") +FLAG_EQ_RE = re.compile(r"^(--?[A-Za-z0-9_\-]+)=(.+)$") + +def is_safe_reference_value(value): + if not isinstance(value, str): + return False + if SAFE_ENV_REF_FULL_RE.fullmatch(value) or SAFE_BEARER_REF_RE.fullmatch(value): + return True + if not value.lower().startswith(("http://", "https://")) or not SAFE_ENV_REF_RE.search(value): + return False + if PROVIDER_SECRET_RE.search(value) or URL_CRED_RE.match(value) or URL_TOKEN_RE.match(value): + return False + try: + parsed = urlsplit(value) + except ValueError: + return False + if parsed.username or parsed.password: + return False + credential_params = [ + param_value + for key, param_value in parse_qsl(parsed.query, keep_blank_values=True) + if SECRET_KEY_RE.search(key) + ] + return bool(credential_params) and all(SAFE_ENV_REF_FULL_RE.fullmatch(item) for item in credential_params) + +def is_secret_value(val): + if not isinstance(val, str): + return False + if is_safe_reference_value(val): + return False + if PROVIDER_SECRET_RE.search(val): + return True + if URL_CRED_RE.match(val) or URL_TOKEN_RE.match(val): + return True + if QUERY_CRED_RE.search(val): + return True + if SECRET_KEY_RE.search(val) and ' ' not in val: + return True + return False + +def is_secret_key(key): + return bool(SECRET_KEY_RE.search(key or "")) + +def is_secret_flag(tok): + if tok in SHORT_SECRET_FLAGS: + return True + return bool(FLAG_RE.match(tok) and SECRET_KEY_RE.search(tok)) + +def blank_all_quoted(text, preserve_safe_refs=False): + n = [0] + def repl(m): + if preserve_safe_refs and is_safe_reference_value(m.group(1)): + return m.group(0) + n[0] += 1 + return '""' + new = re.sub(r'["\']([^"\']+)["\']', repl, text) + return new, n[0] + +def redact_one(file): + TMP = file + ".redact.tmp" + count = 0 + out = [] + secret_array_depth = 0 + flag_pending = False + + def redact_kv(m): + nonlocal count + k = m.group(1).strip().rstrip(":").strip('"\'') + value = m.group(2) + if (is_secret_key(k) and not is_safe_reference_value(value)) or is_secret_value(value): + count += 1 + return '%s""' % m.group(1) + return m.group(0) + + with open(file) as f: + raw_lines = f.readlines() + + for raw in raw_lines: + line = raw.rstrip("\n") + if secret_array_depth > 0: + secret_array_depth += line.count("[") - line.count("]") + stripped = line.strip() + if stripped and not stripped.startswith(("]", "}")): + new_line, n = blank_all_quoted(line) + if n: + line = new_line + count += n + out.append(line + "\n") + continue + ym = re.match(r'^\s*-\s+(.*\S)\s*$', line) + if ym: + item = ym.group(1) + km = re.match(r'["\']?([A-Za-z0-9_.\-]+)["\']?\s*[:=]\s*(.*)$', item) + if km: + flag_pending = False # a keyed list line ends any pending pair + key, rest = km.group(1), km.group(2).strip() + if is_secret_key(key): + if rest == "[": + secret_array_depth = 1 + elif rest.startswith("["): + new_rest, n = blank_all_quoted(rest, preserve_safe_refs=True) + line = line[:line.index("[")] + new_rest + count += n + elif rest.startswith("{") or rest == "": + pass + else: + qm = re.match(r'^["\'](.*)["\']\s*,?\s*$', rest) + if qm: + if qm.group(1) and not is_safe_reference_value(qm.group(1)): + line = re.sub(r'([:=]\s*)["\'].*?["\'](\s*,?\s*)$', r'\1""\2', line) + count += 1 + elif not rest.startswith(('"', "'")): + line = re.sub(r'[:=]\s*\S.*?(\s*,?\s*)$', r': ""\1', line) + count += 1 + out.append(line + "\n") + continue + if flag_pending: + if FLAG_RE.match(item): + flag_pending = is_secret_flag(item) + out.append(line + "\n") + continue + if not is_safe_reference_value(item): + idx = line.rfind(item) + if idx != -1: + line = line[:idx] + '""' + count += 1 + flag_pending = False + out.append(line + "\n") + continue + if "=" in item: + eqm = FLAG_EQ_RE.match(item) + if eqm and (SECRET_KEY_RE.search(eqm.group(1)) or eqm.group(1) in SHORT_SECRET_FLAGS): + idx = line.rfind(item) + if idx != -1: + line = line[:idx] + eqm.group(1) + "=" + count += 1 + out.append(line + "\n") + continue + elif is_secret_flag(item): + flag_pending = True + out.append(line + "\n") + continue + line = re.sub(r'("?[A-Za-z0-9_.\-]+"?\s*:\s*)"([^"]*)"', redact_kv, line) + m = re.match(r'^\s*(?:export\s+)?["\']?([A-Za-z0-9_.\-]+)["\']?\s*[:=]\s*(.*)$', line) + if m: + key, rest = m.group(1), m.group(2).strip() + key_secret = bool(SECRET_KEY_RE.search(key)) + flag_pending = False # a fresh keyed line ends any pending argv pair + if rest == "[": + if key_secret: + secret_array_depth = 1 + out.append(line + "\n") + continue + if rest.startswith("["): + if key_secret: + new_rest, n = blank_all_quoted(rest, preserve_safe_refs=True) + prefix = re.match(r'^(\s*["\']?[A-Za-z0-9_.\-]+["\']?\s*[:=]\s*)', raw.rstrip("\n")).group(1) + line = prefix + new_rest + count += n + else: + elems = re.findall(r'["\'](.*?)["\']', rest) + blank_next = False + changed = False + new_elems = [] + for e in elems: + if blank_next: + if is_safe_reference_value(e): + new_elems.append(e) + else: + new_elems.append("") + count += 1 + changed = True + blank_next = False + elif FLAG_EQ_RE.match(e) and (SECRET_KEY_RE.search(FLAG_EQ_RE.match(e).group(1)) or FLAG_EQ_RE.match(e).group(1) in SHORT_SECRET_FLAGS): + new_elems.append(FLAG_EQ_RE.match(e).group(1) + "=") + count += 1 + changed = True + elif e in SHORT_SECRET_FLAGS: + new_elems.append(e) + blank_next = True + elif FLAG_RE.match(e) and SECRET_KEY_RE.search(e): + new_elems.append(e) + blank_next = True + else: + new_elems.append(e) + if changed: + it = iter(new_elems) + new_rest = re.sub(r'["\'](.*?)["\']', lambda mm: '"%s"' % next(it, mm.group(0)), rest) + prefix = re.match(r'^(\s*["\']?[A-Za-z0-9_.\-]+["\']?\s*[:=]\s*)', raw.rstrip("\n")).group(1) + line = prefix + new_rest + if blank_next and not rest.rstrip().endswith("]"): + flag_pending = True + out.append(line + "\n") + continue + if rest in ("{", ""): + out.append(line + "\n") + continue + qm = re.match(r'^["\'](.*)["\']\s*,?\s*$', rest) + if qm: + val = qm.group(1) + if val and ((key_secret and not is_safe_reference_value(val)) or is_secret_value(val)): + line = re.sub(r'([:=]\s*)["\'].*?["\'](\s*,?\s*)$', r'\1""\2', line) + count += 1 + else: + bare = rest.rstrip(',').strip() + if bare and not bare.startswith(('"', "'")) and ((key_secret and not is_safe_reference_value(bare)) or is_secret_value(bare)): + line = re.sub(r'[:=]\s*\S.*?(\s*,?\s*)$', r': ""\1', line) + count += 1 + if not line.strip().startswith("[") and not m: + stripped = line.strip() + if flag_pending: + mnext = re.match(r'^["\']?(--?[A-Za-z0-9_\-]+)["\']?,?\s*$', stripped) + if mnext and FLAG_RE.match(mnext.group(1)): + flag_pending = is_secret_flag(mnext.group(1)) + else: + new_line, n = blank_all_quoted(line, preserve_safe_refs=True) + if n: + line = new_line + count += n + else: + if stripped and not stripped.startswith(('"', "'")): + line = re.sub(r'\S.*$', '""', line) + count += 1 + flag_pending = False + else: + mflag = re.match(r'^["\'](--?[A-Za-z0-9_\-]+)["\']?,?\s*$', stripped) + if mflag and is_secret_flag(mflag.group(1)) and "=" not in mflag.group(1): + flag_pending = True + out.append(line + "\n") + + with open(TMP, "w") as f: + f.writelines(out) + os.replace(TMP, file) + return count + +total = 0 +failed = 0 +for _f in sys.argv[1:]: + try: + total += redact_one(_f) + except BaseException: + for _p in (_f + ".redact.tmp", _f): + try: + os.unlink(_p) + except OSError: + pass + failed += 1 +print(total, flush=True) +sys.exit(4 if failed else 0) +PYEOF +} + +remove_failed_redaction_artifact() { + local expected_target="$1" + local candidate="$2" + local expected_parent candidate_parent + + case "$expected_target" in + /*) ;; + *) + echo " [GUARD] refused redaction cleanup: expected target is not absolute '$expected_target'" >&2 + return 1 + ;; + esac + case "$candidate" in + "$expected_target"|"${expected_target}.redact.tmp") ;; + *) + echo " [GUARD] refused redaction cleanup outside the exact target artifacts: $candidate" >&2 + return 1 + ;; + esac + if [[ -L "$candidate" ]]; then + echo " [GUARD] refused redaction cleanup of symbolic link: $candidate" >&2 + return 1 + fi + [[ -e "$candidate" ]] || return 0 + [[ -f "$candidate" ]] || { + echo " [GUARD] refused redaction cleanup of non-file target: $candidate" >&2 + return 1 + } + + expected_parent="$(cd "$(dirname "$expected_target")" 2>/dev/null && pwd -P)" || return 1 + candidate_parent="$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P)" || return 1 + [[ "$candidate_parent" == "$expected_parent" ]] || { + echo " [GUARD] refused redaction cleanup outside target parent: $candidate" >&2 + return 1 + } + + unlink "$candidate" +} + +remove_files_within_copy_root() { + local copy_root="$1" + shift + local candidate failed=0 + + [[ -d "$copy_root" && ! -L "$copy_root" ]] || { + echo " [GUARD] refused copy cleanup: invalid target copy root '$copy_root'" >&2 + return 1 + } + for candidate in "$@"; do + if [[ -d "$candidate" && ! -L "$candidate" ]]; then + echo " [GUARD] refused copy cleanup of directory: $candidate" >&2 + failed=1 + continue + fi + safe_remove_path_within "$copy_root" "$candidate" || failed=1 + done + return $failed +} + +redact_secrets_in_file() { + local file="$1" + [[ -f "$file" ]] || { echo 0; return 0; } + if ! command -v python3 >/dev/null 2>&1; then + echo " [SECURITY] python3 missing, cannot redact $file; target copy deleted to prevent secret leak (source file untouched)" >&2 + remove_failed_redaction_artifact "$file" "$file" || true + echo 0 + return 1 + fi + if ! ensure_redactor_script; then + echo " [SECURITY] cannot generate redaction engine, target copy deleted to prevent secret leak (source file untouched): $file" >&2 + remove_failed_redaction_artifact "$file" "$file" || true + echo 0 + return 1 + fi + local n rc=0 pyout + pyout=$(mktemp "${TMPDIR:-/tmp}/redact-out.XXXXXX") + python3 "$REDACTOR_PY" "$file" >"$pyout" || rc=$? + n=$(cat "$pyout" 2>/dev/null || echo "-1") + rm -f "$pyout" + if [[ $rc -ne 0 || -z "$n" || "$n" == "-1" ]]; then + remove_failed_redaction_artifact "$file" "$file" || true + remove_failed_redaction_artifact "$file" "${file}.redact.tmp" || true + echo " [SECURITY] secret redaction failed, target file deleted to prevent leak (source file untouched): $file" >&2 + echo "-1" + return 1 + fi + echo "$n" + return 0 +} + +redact_skill_copy() { + local root="$1" + local total=0 had_fail=0 f rc=0 pyout + local -a excluded_env_files=() + local -a files=() + + while IFS= read -r -d '' f; do + excluded_env_files+=("$f") + done < <(find "$root" \( -type f -o -type l \) -name '.env*' -print0 2>/dev/null) + if [[ ${#excluded_env_files[@]} -gt 0 ]]; then + remove_files_within_copy_root "$root" "${excluded_env_files[@]}" || had_fail=1 + echo " [SECURITY] excluded ${#excluded_env_files[@]} .env file(s) from migrated copy" >&2 + fi + + while IFS= read -r -d '' f; do + files+=("$f") + done < <(find "$root" -name '*.bak.*' -prune -o -type f \( \ + -name '*.json' -o -name '*.jsonc' -o -name '*.yaml' -o -name '*.yml' \ + -o -name '*.toml' \ + -o -name '*.sh' -o -name '*.bash' -o -name '*.zsh' \) -print0 2>/dev/null) + + if [[ ${#files[@]} -eq 0 ]]; then + echo 0 + return $had_fail + fi + + if ! command -v python3 >/dev/null 2>&1; then + echo " [SECURITY] python3 missing, cannot redact skill copy; candidate files deleted to prevent secret leak (source directory untouched)" >&2 + remove_files_within_copy_root "$root" "${files[@]}" || true + echo 0 + return 1 + fi + if ! ensure_redactor_script; then + echo " [SECURITY] cannot generate redaction engine; candidate files deleted to prevent secret leak (source directory untouched)" >&2 + remove_files_within_copy_root "$root" "${files[@]}" || true + echo 0 + return 1 + fi + + pyout=$(mktemp "${TMPDIR:-/tmp}/redact-out.XXXXXX") + python3 "$REDACTOR_PY" "${files[@]}" >"$pyout" || rc=$? + total=$(cat "$pyout" 2>/dev/null || echo "-1") + rm -f "$pyout" + if [[ $rc -ne 0 || -z "$total" || "$total" == "-1" ]]; then + had_fail=1 + echo " [SECURITY] skill copy redaction has failures; failed files were deleted by the redactor (source directory untouched)" >&2 + [[ "$total" == "-1" || -z "$total" ]] && total=0 + fi + echo "$total" + return $had_fail +} + +migrate_mcp() { + local source_ide="$1" + local target_ide="$2" + local scope="${3:-global}" + local scope_label="global/user" + local source_sha256_before="" + local evidence_backup_path="" + [[ "$scope" == "project" ]] && scope_label="project" + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + + if [[ "$source_ide" == "goose-cli" || "$target_ide" == "goose-cli" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Goose config.yaml uses YAML extensions; automatic MCP migration is unsupported" + set_manual_step "mcp" "Goose: manually rebuild each extension under ~/.config/goose/config.yaml/extensions; preserve type (builtin/platform/stdio/streamable_http), cmd/args or uri/headers, enabled, and envs without copying secrets.yaml" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "gemini-cli" || "$target_ide" == "gemini-cli" ]]; then + set_manual_step "mcp" "Gemini CLI: selected ${scope_label} scope; review ~/.gemini/settings.json versus project .gemini/settings.json, preserve the mcpServers endpoint schema, and review project settings precedence" + fi + + if [[ "$source_ide" == "opencode" || "$target_ide" == "opencode" ]]; then + set_manual_step "mcp" "OpenCode: selected ${scope_label} scope and ${OPENCODE_VERSION} target schema; review ~/.config/opencode/opencode.json versus project opencode.json, JSONC files, merged precedence, OAuth/keychain state, and agent-specific MCP permissions manually" + fi + + if [[ "$source_ide" == "kimiai" || "$target_ide" == "kimiai" ]]; then + set_manual_step "mcp" "Kimi Code: selected ${scope_label} scope; review ~/.kimi-code/mcp.json versus project .kimi-code/mcp.json and KIMI_CODE_HOME precedence manually" + fi + + if [[ "$source_ide" == "workbuddy" || "$target_ide" == "workbuddy" ]]; then + set_manual_step "mcp" "WorkBuddy: selected ${scope_label} scope; the official files are ~/.workbuddy/mcp.json and project .workbuddy/mcp.json. Review the merged mcpServers map in 插件 → MCP 服务器 → 配置 MCP, keep only local command/args/env for automatic conversion, and configure remote URL/OAuth/headers plus enablement in the UI" + fi + + if [[ "$source_ide" == "kiro" || "$target_ide" == "kiro" ]]; then + set_manual_step "mcp" "Kiro: selected ${scope_label} scope; review ~/.kiro/settings/mcp.json versus workspace .kiro/settings/mcp.json and Kiro CLI/IDE scope manually" + fi + + if [[ "$source_ide" == "augment-code" || "$target_ide" == "augment-code" ]]; then + set_manual_step "mcp" "Augment: selected ${scope_label} scope; review ~/.augment/settings.json, .augment/settings.json/.augment/settings.local.json precedence, and credentials manually" + fi + + if [[ "$source_ide" == "baidu-comate" || "$target_ide" == "baidu-comate" ]]; then + set_manual_step "mcp" "Comate: selected ${scope_label} scope; review ~/.comate/mcp.json, .comate/mcp.json, and experimental .comate/mcp.local.json precedence manually" + fi + + if [[ "$source_ide" == "zcode" || "$target_ide" == "zcode" ]]; then + set_manual_step "mcp" "ZCode: selected ${scope_label} scope; review ~/.zcode/cli/config.json or workspace .zcode/config.json (root mcp.servers), or use Settings → MCP Servers → Import to select external Claude/Codex/OpenCode/.agents servers. The mapper leaves source files untouched and does not guess .agents precedence" + fi + + if [[ "$source_ide" == "trae" || "$target_ide" == "trae" || + "$source_ide" == "trae-cn" || "$target_ide" == "trae-cn" ]]; then + if [[ "$scope" == "project" ]]; then + set_manual_step "mcp" "TRAE: project MCP is .trae/mcp.json with root mcpServers; review command/args/env, URL/headers, workspace variables, and enablement after the narrow merge. Global MCP is configured through the IDE Settings → MCP Servers/raw JSON UI" + else + set_status "mcp" "manual" + set_message "mcp" "TRAE global MCP has an official settings/raw-JSON method but no stable published filesystem path" + set_manual_step "mcp" "TRAE: open Settings → MCP Servers (or the MCP settings/raw JSON editor), recreate or import the global mcpServers entries there, and review enablement/credentials. Project scope is the documented .trae/mcp.json file; do not infer ~/.trae/mcp.json or ~/.trae-cn/mcp.json" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + fi + + if [[ "$source_ide" == "void-editor" || "$target_ide" == "void-editor" ]]; then + set_manual_step "mcp" "Void is deprecated/archived: selected ${scope_label} scope uses legacy ~/.void-editor/mcp.json for the custom store, while inherited VS Code project .vscode/mcp.json uses servers. Automatic conversion is limited to local command/args/env or URL-only remote; headers/auth and migration to Kilo Code require manual review" + fi + + if [[ "$source_ide" == "jetbrains" || "$target_ide" == "jetbrains" ]]; then + set_manual_step "mcp" "Junie: selected ${scope_label} scope; review ~/.junie/mcp/mcp.json versus project .junie/mcp/mcp.json; automatic conversion accepts only the documented local command/args/env shape and leaves remote/unknown fields for review" + fi + + if [[ "$source_ide" == "amazon-q" || "$target_ide" == "amazon-q" ]]; then + local q_global_default="${HOME}/.aws/amazonq/default.json" + local q_global_legacy="${HOME}/.aws/amazonq/mcp.json" + local q_global_agent="${HOME}/.aws/amazonq/agents/default.json" + local q_project_default="${WORKSPACE_ROOT}/.amazonq/default.json" + local q_project_legacy="${WORKSPACE_ROOT}/.amazonq/mcp.json" + local q_project_agent="${WORKSPACE_ROOT}/.amazonq/agents/default.json" + + if [[ "$scope" == "project" ]]; then + if [[ -f "$q_project_agent" && ! -f "$q_project_default" && ! -f "$q_project_legacy" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Amazon Q project agents/default.json is a custom-agent definition, not IDE MCP configuration" + set_manual_step "mcp" "Amazon Q: .amazonq/agents/default.json belongs to the custom-agent profile and must not be treated as the IDE .amazonq/default.json MCP file. Choose the active Q profile, then configure its documented MCP surface without overwriting the agent definition" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + elif [[ -f "$q_global_agent" && ! -f "$q_global_default" && ! -f "$q_global_legacy" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Amazon Q agents/default.json is a custom-agent definition, not IDE MCP configuration" + set_manual_step "mcp" "Amazon Q: ~/.aws/amazonq/agents/default.json belongs to the custom-agent profile and must not be treated as the IDE ~/.aws/amazonq/default.json MCP file. Choose the active Q profile, then configure its documented MCP surface without overwriting the agent definition" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + set_manual_step "mcp" "Amazon Q: standard IDE MCP uses ~/.aws/amazonq/default.json and .amazonq/default.json; existing legacy mcp.json is retained only as a legacy source/target. Workspace configuration takes precedence. Review useLegacyMcpJson, permissions, OAuth, CLI agent files, and the Q panel tools icon after this narrow mcpServers merge" + fi + + if [[ "$source_ide" == "blackbox" || "$target_ide" == "blackbox" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Blackbox only documents built-in blackbox mcp command; no portable MCP file or server Schema" + set_manual_step "mcp" "Blackbox: use official CLI/UI to configure manually; do not infer ~/.blackbox, .blackbox/mcp.json or mcpServers root key" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "pieces" || "$target_ide" == "pieces" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Pieces is a PiecesOS-backed MCP server, not a file-backed MCP client" + set_manual_step "mcp" "Pieces: keep PiecesOS running and enable LTM, then configure the consuming IDE with the current endpoint from PiecesOS/Desktop Settings → MCP or use pieces mcp setup; do not invent ~/.pieces/.pieces or copy a client MCP file into Pieces" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "replit" || "$target_ide" == "replit" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Replit MCP connections are cloud/UI-managed through Integrations; no local MCP file is migrated" + set_manual_step "mcp" "Replit: manage MCP connections at replit.com/integrations or the Agent MCP settings pane; do not copy .replit/replit.nix or infer a local MCP file" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "cody" || "$target_ide" == "cody" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Cody MCP is configured through the cody.mcpServers extension setting/UI; standalone file migration is unsupported" + set_manual_step "mcp" "Cody: enable the Enterprise agentic-context MCP feature, then review VS Code settings.json or JetBrains cody_settings.json and the Cody MCP Settings UI; only local MCP tools are supported" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "supermaven" || "$target_ide" == "supermaven" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Supermaven has no documented portable MCP file or server schema; automatic migration is unsupported" + set_manual_step "mcp" "Supermaven: configure MCP, if needed, in the host editor's documented MCP surface; do not infer ~/.supermaven or .supermaven as an MCP file" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "continue" || "$target_ide" == "continue" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Continue uses YAML/array configuration; automatic MCP/config migration is unsupported" + set_manual_step "mcp" "Review ~/.continue/config.yaml or .continue/mcpServers/*.yaml manually; preserve mcpServers as an array of named entries and migrate secrets through Continue's documented environment/secret references" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ "$source_ide" == "roo-code" || "$target_ide" == "roo-code" ]]; then + if [[ "$scope" != "project" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Roo Code global MCP is extension-storage/UI managed; no stable official filesystem path is published" + set_manual_step "mcp" "Roo Code: configure global MCP through the Roo MCP settings UI; do not infer a VS Code globalStorage or Cline path. Project MCP is separately documented at .roo/mcp.json" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + set_manual_step "mcp" "Roo Code: project scope uses .roo/mcp.json with root mcpServers; review mode permissions, remote headers/auth, and extension behavior after the narrow JSON merge. Global MCP remains UI-managed" + fi + + if [[ "$source_ide" == "cline" || "$target_ide" == "cline" ]]; then + if [[ "$scope" != "project" ]]; then + local cline_primary + cline_primary="$(get_mcp_path cline)" + local cline_alternative="${HOME}/.cline/mcp.json" + if [[ -z "${CLINE_MCP_PATH:-}" && -f "$cline_primary" && -f "$cline_alternative" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Cline has both the current data/settings MCP file and a legacy ~/.cline/mcp.json alternative; the active store is ambiguous" + set_manual_step "mcp" "Cline: use ~/.cline/data/settings/cline_mcp_settings.json, or set CLINE_DATA_DIR to replace ~/.cline/data. Treat ~/.cline/mcp.json only as a reviewed legacy candidate; CLINE_MCP_PATH remains a compatibility override. The project file is .cline/mcp.json" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + set_manual_step "mcp" "Cline: global MCP writes to ~/.cline/data/settings/cline_mcp_settings.json. CLINE_DATA_DIR replaces ~/.cline/data; a legacy ~/.cline/mcp.json candidate or CLINE_MCP_PATH override requires explicit review. Verify with the Cline MCP panel or 'cline mcp'. Project MCP is .cline/mcp.json" + else + set_manual_step "mcp" "Cline: project MCP is .cline/mcp.json with mcpServers; review IDE/CLI precedence and validate with the Cline MCP panel or cline mcp after the narrow merge" + fi + fi + + if [[ "$source_ide" == "claude-desktop" || "$target_ide" == "claude-desktop" ]]; then + if [[ -z "$(get_mcp_path claude-desktop)" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Claude Desktop has no confirmed legacy JSON path on this platform" + set_manual_step "mcp" "Claude Desktop: on macOS use ~/Library/Application Support/Claude/claude_desktop_config.json; on native Windows use %APPDATA%\\Claude\\claude_desktop_config.json but do not guess MSIX virtualized paths; on Linux use Settings → Extensions or verify the current Developer path manually. For all platforms, install .mcpb through Settings → Extensions → Advanced settings → Install Extension; configure remote MCP through Settings → Connectors. Claude Code can import supported Desktop entries with claude mcp add-from-claude-desktop on macOS/WSL." + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + set_manual_step "mcp" "Claude Desktop legacy local MCP JSON is migrated only at the documented platform path; install modern local servers as .mcpb via Settings → Extensions → Advanced settings → Install Extension, and configure remote MCP via Settings → Connectors. Claude Code's official claude mcp add-from-claude-desktop remains the supported interactive import on macOS/WSL." + fi + + local source_mcp + local target_mcp + if [[ "$scope" == "project" ]]; then + source_mcp=$(get_project_mcp_path "$source_ide") + target_mcp=$(get_project_mcp_path "$target_ide") + else + source_mcp=$(get_mcp_path "$source_ide") + target_mcp=$(get_mcp_path "$target_ide") + fi + + if [[ -n "${SOURCE_MCP_FILE:-}" ]]; then + source_mcp="$SOURCE_MCP_FILE" + set_manual_step "mcp" "explicit MCP source override: validate '$source_mcp' against the declared $source_ide schema; only the source location is overridden, while the target remains registry-resolved" + fi + + if [[ "$scope" != "project" && -z "$source_mcp" && "$source_ide" == "vscode" ]]; then + set_status "mcp" "manual" + set_message "mcp" "VS Code user MCP is profile-managed; no absolute path was guessed" + set_manual_step "mcp" "VS Code: use MCP: Open User Configuration or MCP: Add Server in the active Profile; code --add-mcp is also documented. For a workspace use .vscode/mcp.json with root servers. Do not use GitHub Copilot CLI ~/.copilot/mcp-config.json or its mcpServers root as a VS Code file" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + if [[ "$target_ide" == "vscode" && "$scope" == "project" ]]; then + target_mcp="$WORKSPACE_ROOT/.vscode/mcp.json" + fi + + if [[ -n "$source_mcp" && "$source_mcp" != /* && "$source_mcp" != [A-Za-z]:* && "$source_mcp" != "\\"* ]]; then + source_mcp="$WORKSPACE_ROOT/$source_mcp" + fi + if [[ -n "$target_mcp" && "$target_mcp" != /* && "$target_mcp" != [A-Za-z]:* && "$target_mcp" != "\\"* ]]; then + target_mcp="$WORKSPACE_ROOT/$target_mcp" + fi + + if [[ -z "$source_mcp" ]]; then + set_status "mcp" "skipped" + set_message "mcp" "source IDE does not support MCP configuration" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + if [[ -z "$target_mcp" ]]; then + set_status "mcp" "manual" + set_message "mcp" "target IDE does not support MCP configuration, manual migration required" + set_manual_step "mcp" "target IDE ($target_ide) does not support automatic MCP migration, please refer to IDE Registry to configure manually" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + local source_identity target_identity + if command -v python3 >/dev/null 2>&1; then + source_identity="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$source_mcp")" + target_identity="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$target_mcp")" + else + source_identity="$(cd "$(dirname "$source_mcp")" 2>/dev/null && pwd -P)/$(basename "$source_mcp")" + target_identity="$(cd "$(dirname "$target_mcp")" 2>/dev/null && pwd -P)/$(basename "$target_mcp")" + fi + if [[ "$source_identity" == "$target_identity" ]]; then + set_status "mcp" "manual" + set_message "mcp" "MCP source and target resolve to the same file; refusing to self-overwrite" + set_manual_step "mcp" "MCP: source and target IDEs share '$source_mcp' on this workspace; pick a different target or relocate the source manually before retrying" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + if [[ -L "$target_mcp" ]]; then + set_status "mcp" "failed" + set_message "mcp" "MCP target is a symbolic link; refusing conversion or cleanup through an indirect path" + set_manual_step "mcp" "MCP: replace the target symlink with a reviewed regular file at '$target_mcp', then preview again" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + return 0 + fi + + if [[ "$scope" == "project" ]]; then + set_manual_step "mcp" "project MCP: this run only processes explicit workspace file ${source_mcp} -> ${target_mcp}; review project priority, Workspace Trust, approval, OAuth/headers and same-name server conflicts" + else + set_manual_step "mcp" "user MCP: this run only processes user-level file; project MCP, local scope, Workspace Trust and UI/profile state still need manual review" + fi + + if [[ "$source_ide" == "claude" || "$target_ide" == "claude" ]]; then + set_manual_step "mcp" "Claude Code: selected ${scope_label} scope; review ~/.claude.json user/local entries, project .mcp.json, and local per-project entries manually" + fi + + if [[ "$source_ide" == "tabnine" || "$target_ide" == "tabnine" ]]; then + set_manual_step "mcp" "Tabnine: selected ${scope_label} scope; review ~/.tabnine/mcp_servers.json versus project .tabnine/mcp_servers.json and configure extension-managed permissions in Tabnine Settings manually" + fi + + if [[ "$source_ide" == "tencent-codebuddy" || "$target_ide" == "tencent-codebuddy" ]]; then + set_manual_step "mcp" "CodeBuddy Code: selected ${scope_label} scope; review ~/.codebuddy/.mcp.json, project .mcp.json, legacy ~/.codebuddy/mcp.json/~/.codebuddy.json, --mcp-config overrides, and .codebuddy/settings.json approval keys manually" + fi + + if [[ "$source_ide" == "copilot" || "$target_ide" == "copilot" ]]; then + set_manual_step "mcp" "GitHub Copilot CLI: selected ${scope_label} scope; review ~/.copilot/mcp-config.json and project .mcp.json/.github/mcp.json (both mcpServers) manually" + fi + + print_progress "MIGRATE" "Migrating MCP server configuration..." + + if [[ ! -e "$source_mcp" ]]; then + set_status "mcp" "absent" + set_message "mcp" "source MCP config does not exist: $source_mcp" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + return 0 + fi + + source_sha256_before="$(sha256_file "$source_identity" 2>/dev/null || true)" + + if [[ "$source_ide" == "codex" || "$target_ide" == "codex" ]]; then + set_status "mcp" "manual" + set_message "mcp" "Codex MCP config uses TOML; auto migration unsupported, manual migration required" + set_manual_step "mcp" "rebuild servers using [mcp_servers.] TOML table in Codex user ~/.codex/config.toml or trusted project .codex/config.toml; stdio uses command, Streamable HTTP uses url" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + record_mcp_evidence "$scope" "$source_identity" "$target_identity" "$source_sha256_before" + return 0 + fi + + local src_key dst_key + src_key=$(get_mcp_root_key "$source_ide" "$scope") + dst_key=$(get_mcp_root_key "$target_ide" "$scope") + + if [[ -n "${SOURCE_MCP_FILE:-}" ]]; then + if ! inspect_mcp_source_file "$source_mcp" "$src_key"; then + set_status "mcp" "failed" + set_message "mcp" "explicit MCP source failed strict schema validation" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + record_mcp_evidence "$scope" "$source_identity" "$target_identity" "$source_sha256_before" + return 0 + fi + fi + + if [[ $DRY_RUN -eq 1 ]]; then + echo " DRY-RUN: converting MCP config" + echo " source: $source_mcp (root key: ${src_key:-none})" + echo " target: $target_mcp (root key: ${dst_key:-none})" + set_status "mcp" "skipped" + set_message "mcp" "DRY-RUN: planned MCP config conversion (${src_key:-?} -> ${dst_key:-?})" + record_mcp_evidence "$scope" "$source_identity" "$target_identity" "$source_sha256_before" + return 0 + fi + + mkdir -p "$(dirname "$target_mcp")" + + if [[ -e "$target_mcp" ]]; then + case "$STRATEGY" in + skip) + echo " [SKIP] target MCP config already exists: $target_mcp" + set_status "mcp" "skipped" + set_message "mcp" "target MCP config already exists, skip (strategy: skip)" + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) + record_mcp_evidence "$scope" "$source_identity" "$target_identity" "$source_sha256_before" + return 0 + ;; + backup) + local ts + ts="$(date +%Y%m%d%H%M%S).$$" + cp -r "$target_mcp" "$target_mcp.bak.$ts" + evidence_backup_path="$target_identity.bak.$ts" + echo " [BACKUP] backed up existing MCP config: $target_mcp.bak.$ts" + ;; + overwrite) + ;; + esac + fi + + convert_mcp_file "$source_mcp" "$src_key" "$target_mcp" "$dst_key" "$target_ide" "$STRATEGY" "$OPENCODE_VERSION" + + case "$CONV_RESULT" in + success) + echo " [OK] converted MCP config: ${src_key:-mcpServers} -> ${dst_key:-mcpServers}" + if [[ ${MCP_REDACTED_COUNT:-0} -ne 0 ]]; then + echo " [SECURITY] literal credentials in MCP config were cleared; exact supported environment references were preserved or converted. Review target environment/secret-manager bindings before enabling." + fi + set_status "mcp" "success" + set_message "mcp" "$CONV_DETAIL" + MIGRATION_SUCCESS=$((MIGRATION_SUCCESS + 1)) + ;; + copied) + echo " [COPY] copied MCP config as-is: $target_mcp" + if [[ ${MCP_REDACTED_COUNT:-0} -ne 0 ]]; then + echo " [SECURITY] literal credentials in MCP config were cleared; exact supported environment references were preserved. Review target environment/secret-manager bindings before enabling." + fi + set_status "mcp" "copied" + set_message "mcp" "$CONV_DETAIL" + set_manual_step "mcp" "check MCP root key compatibility: ${src_key:-?} -> ${dst_key:-?}" + MIGRATION_SUCCESS=$((MIGRATION_SUCCESS + 1)) + ;; + failed) + echo " [FAIL] MCP config migration failed" + set_status "mcp" "failed" + set_message "mcp" "$CONV_DETAIL" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + ;; + *) + echo " [FAIL] MCP config migration unknown state" + set_status "mcp" "failed" + set_message "mcp" "MCP config migration failed (unknown state)" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + ;; + esac + + record_mcp_evidence "$scope" "$source_identity" "$target_identity" "$source_sha256_before" "$evidence_backup_path" +} + +migrate_config() { + local source_ide="$1" + local target_ide="$2" + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + set_status "config" "manual" + set_message "config" "automatic whole-IDE config migration is unsupported" + set_manual_step "config" "Review only documented, object-specific settings for $source_ide -> $target_ide. Rebuild target config manually; do not copy opaque IDE config files, credentials, permissions, hooks, or trust state." + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) +} + +migrate_project() { + local source_ide="$1" + local target_ide="$2" + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + set_status "project" "manual" + set_message "project" "automatic whole-project configuration migration is unsupported" + set_manual_step "project" "Review dedicated objects for $source_ide -> $target_ide (skills, rules, prompts, and project MCP) one at a time. Do not copy opaque project directories, credentials, permissions, hooks, or trust state." + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) +} + +manual_only_object() { + local object="$1" + local source_ide="$2" + local target_ide="$3" + + MIGRATION_TOTAL=$((MIGRATION_TOTAL + 1)) + case "$object" in + agents) + set_status "agents" "manual" + set_message "agents" "Agents/Subagents are product-specific schema; currently diagnosis only, no auto conversion" + set_manual_step "agents" "Agents ($source_ide -> $target_ide): review official surfaces like .github/agents, .claude/agents, .cursor/agents, .trae/agents, .kiro/agents, .codebuddy/agents, .zcode/agents separately; do not copy tools, permissions, hooks, handoffs or mcpServers fields across IDEs" + ;; + hooks) + set_status "hooks" "manual" + set_message "hooks" "Hooks execute commands and each IDE's events/schema/scope differ; cross-IDE auto migration has no safe strict intersection" + set_manual_step "hooks" "Hooks ($source_ide -> $target_ide): review .github/hooks, .trae/hooks.json, .kiro/hooks/*, .windsurf/hooks.json, Codex hooks.json or settings hooks; do not auto-execute, copy or rewrite commands from one shell to another" + ;; + memory) + set_status "memory" "manual" + set_message "memory" "Memory is mostly local/cloud generated state, project identity encoding and schema are inconsistent; currently only listing manual handling boundaries" + set_manual_step "memory" "Memory ($source_ide -> $target_ide): Trae/Claude/Codex/Windsurf generated memory, Replit replit.md, Amazon Q .amazonq/rules/memory-bank, Goose memory, CodeBuddy CODEBUDDY.md/Auto Memory, WorkBuddy UI/private memory need item-by-item review; copying entire memory directory is prohibited" + ;; + *) + set_status "$object" "failed" + set_message "$object" "unsupported manual object: $object" + MIGRATION_FAILED=$((MIGRATION_FAILED + 1)) + ;; + esac + MIGRATION_SKIPPED=$((MIGRATION_SKIPPED + 1)) +} + +run_migration() { + local source_ide="$1" + local target_ide="$2" + + local OLD_IFS="$IFS" + IFS=',' read -ra OBJECT_LIST <<< "$OBJECTS" + IFS="$OLD_IFS" + + for obj in "${OBJECT_LIST[@]}"; do + case "$obj" in + skills) + migrate_skills "$source_ide" "$target_ide" "$SCOPE" + ;; + rules) + migrate_rules "$source_ide" "$target_ide" + ;; + prompts) + migrate_prompts "$source_ide" "$target_ide" + ;; + mcp) + if [[ "$SCOPE" == "both" ]]; then + migrate_mcp "$source_ide" "$target_ide" "global" + migrate_mcp "$source_ide" "$target_ide" "project" + else + migrate_mcp "$source_ide" "$target_ide" "$SCOPE" + fi + ;; + project-mcp) + migrate_mcp "$source_ide" "$target_ide" "project" + local project_mcp_status project_mcp_message project_mcp_steps project_mcp_step + project_mcp_status=$(get_status "mcp") + project_mcp_message=$(get_message "mcp") + [[ -n "$project_mcp_status" ]] && set_status "project-mcp" "$project_mcp_status" + [[ -n "$project_mcp_message" ]] && set_message "project-mcp" "$project_mcp_message" + project_mcp_steps=$(get_manual_steps "mcp") + if [[ -n "$project_mcp_steps" ]]; then + while IFS= read -r project_mcp_step; do + [[ -n "$project_mcp_step" ]] && set_manual_step "project-mcp" "$project_mcp_step" + done <<< "$project_mcp_steps" + fi + ;; + agents|hooks|memory) + manual_only_object "$obj" "$source_ide" "$target_ide" + ;; + config) + migrate_config "$source_ide" "$target_ide" + ;; + project) + migrate_project "$source_ide" "$target_ide" + ;; + *) + echo "[WARN] unknown content type: $obj" + ;; + esac + done +} + +generate_report() { + local source_ide="$1" + local target_ide="$2" + local report="" + + report+="========================================\n" + report+=" IDE migration report +" + report+="========================================\n" + report+="\n" + report+="Migration details: +" + report+=" source IDE: $(get_ide_name "$source_ide") ($source_ide) +" + report+=" target IDE: $(get_ide_name "$target_ide") ($target_ide) +" + report+=" workspace: $WORKSPACE_ROOT +" + report+=" strategy: $STRATEGY +" + report+=" time: $(date '+%Y-%m-%dT%H:%M:%S%z')\n" # portable (BSD date lacks -Iseconds) + report+="\n" + report+="Statistics: +" + report+=" total operations: $MIGRATION_TOTAL +" + report+=" succeeded: $MIGRATION_SUCCESS +" + report+=" failed: $MIGRATION_FAILED +" + report+=" skipped: $MIGRATION_SKIPPED +" + report+="\n" + report+="Detailed results: +" + + for obj in skills rules prompts mcp project-mcp config project agents hooks memory; do + local status + status=$(get_status "$obj") + if [[ -n "$status" ]]; then + local message + message=$(get_message "$obj") + local status_icon + + case "$status" in + success) status_icon="OK" ;; + copied) status_icon="OK" ;; + manual) status_icon="WARN" ;; + partial) status_icon="WARN" ;; + failed) status_icon="FAIL" ;; + absent) status_icon="-" ;; + skipped) status_icon="-" ;; + *) status_icon="?" ;; + esac + + report+=" [$status_icon] $obj: $message\n" + fi + done + + report+="\n" + report+="Steps requiring manual handling: +" + + local has_manual=0 + for obj in skills rules prompts mcp project-mcp config project agents hooks memory; do + local steps + steps=$(get_manual_steps "$obj") + if [[ -n "$steps" ]]; then + has_manual=1 + report+="\n [$obj]\n" + report+=" $steps\n" + fi + done + + if [[ $has_manual -eq 0 ]]; then + report+=" none - all migrations completed automatically +" + fi + + report+="\n" + report+="========================================\n" + + if [[ "${MIGRATE_JSON:-}" == "1" ]]; then + _emit_json_report "$source_ide" "$target_ide" + else + # '%b' would reinterpret escape sequences embedded in content — on + # Windows hosts report lines contain drive-letter paths whose back- + # slash sequences (e.g. a capital U after a separator) abort bash + # printf. Expand only the literal "\n" separators. + printf '%s' "${report//\\n/$'\n'}" + fi +} + +_emit_json_report() { + local source_ide="$1" + local target_ide="$2" + local entries=() + local object_entries=() + local requested_object + + for obj in skills rules prompts mcp project-mcp config project agents hooks memory; do + local status message token steps + status=$(get_status "$obj") + [[ -n "$status" ]] || continue + message=$(get_message "$obj") + token=$(status_token "$status") + entries+=("$(printf '{"object":"%s","status":"%s","token":"%s","message":"%s"}' \ + "$obj" "$status" "$token" "$(json_escape "$message")")") + steps=$(get_manual_steps "$obj") + if [[ -n "$steps" ]]; then + entries+=("$(printf '{"object":"%s","status":"manual","token":"WARN","steps":"%s"}' \ + "$obj" "$(json_escape "$steps")")") + fi + done + + local entries_json + entries_json=$(IFS=,; echo "${entries[*]}") + while IFS= read -r requested_object; do + [[ -n "$requested_object" ]] || continue + object_entries+=("\"$(json_escape "$requested_object")\"") + done < <(printf '%s\n' "$OBJECTS" | tr ',' '\n') + local objects_json + objects_json=$(IFS=,; echo "${object_entries[*]}") + + local report_scope="$SCOPE" + if [[ ",$OBJECTS," == *",project-mcp,"* && ",$OBJECTS," != *",mcp,"* ]]; then + report_scope="project" + fi + local report_mode="apply" + [[ $DRY_RUN -eq 1 ]] && report_mode="dry-run" + + local evidence_json="" + if [[ -s "$MIGRATION_EVIDENCE_FILE" ]]; then + evidence_json="$(paste -sd, "$MIGRATION_EVIDENCE_FILE")" + fi + + printf '{"source_ide":"%s","target_ide":"%s","mode":"%s","scope":"%s","objects":[%s],"workspace":"%s","strategy":"%s","opencode_version":"%s","statistics":{"total":%s,"succeeded":%s,"failed":%s,"skipped":%s},"results":[%s],"evidence":{"mcp":[%s]}}\n' \ + "$source_ide" "$target_ide" "$report_mode" "$report_scope" "$objects_json" "$(json_escape "$WORKSPACE_ROOT")" "$STRATEGY" "$OPENCODE_VERSION" \ + "$MIGRATION_TOTAL" "$MIGRATION_SUCCESS" "$MIGRATION_FAILED" "$MIGRATION_SKIPPED" \ + "$entries_json" "$evidence_json" +} + + +main() { + trap cleanup_migration_files EXIT + + while [[ $# -gt 0 ]]; do + case "$1" in + --source) + SOURCE_IDE="$2" + shift 2 + ;; + --target) + TARGET_IDE="$2" + shift 2 + ;; + --workspace) + WORKSPACE_ROOT="$2" + WORKSPACE_EXPLICIT=1 + shift 2 + ;; + --objects) + OBJECTS="$2" + shift 2 + ;; + --source-mcp-file) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Error: --source-mcp-file requires a file path" >&2 + exit 1 + fi + SOURCE_MCP_FILE="$2" + shift 2 + ;; + --opencode-version) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Error: --opencode-version requires v1 or v2" >&2 + exit 1 + fi + OPENCODE_VERSION="$2" + OPENCODE_VERSION_EXPLICIT=1 + shift 2 + ;; + --scope) + SCOPE="$2" + shift 2 + ;; + --strategy) + STRATEGY="$2" + shift 2 + ;; + --report) + REPORT_FILE="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --json) + MIGRATE_JSON=1 + shift + ;; + --yes|-y) + ASSUME_YES=1 + shift + ;; + --print-path) + PRINT_PATH_IDE="$2" + PRINT_PATH_OBJECT="$3" + shift 3 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + case "$STRATEGY" in + skip|backup|overwrite) + ;; + *) + echo "Error: invalid strategy: $STRATEGY (options: skip, backup, overwrite)" >&2 + exit 1 + ;; + esac + + if [[ "${MIGRATE_JSON:-}" == "1" ]]; then + exec 3>&1 + exec 1>&2 + fi + + if [[ -z "$PRINT_PATH_IDE" ]]; then + print_header + fi + + if [[ -n "$PRINT_PATH_IDE" ]]; then + if ! validate_ide "$PRINT_PATH_IDE"; then + echo "Error: invalid IDE: $PRINT_PATH_IDE" >&2 + echo "Supported IDEs: $SUPPORTED_IDES" >&2 + exit 1 + fi + + resolved="" + case "$PRINT_PATH_OBJECT" in + global) resolved=$(get_global_path "$PRINT_PATH_IDE") ;; + project) resolved=$(get_project_path "$PRINT_PATH_IDE") ;; + project-skills) resolved=$(get_project_skills_path "$PRINT_PATH_IDE") ;; + mcp) resolved=$(get_mcp_path "$PRINT_PATH_IDE") ;; + project-mcp) resolved=$(get_project_mcp_path "$PRINT_PATH_IDE") ;; + project-config) resolved=$(get_project_config_file "$PRINT_PATH_IDE") ;; + config) resolved=$(get_config_file "$PRINT_PATH_IDE") ;; + rules) resolved=$(get_rules_file "$PRINT_PATH_IDE") ;; + prompts|commands) resolved=$(get_prompts_path "$PRINT_PATH_IDE") ;; + *) + echo "Error: unsupported object: $PRINT_PATH_OBJECT (options: global, project, project-skills, mcp, project-mcp, project-config, config, rules, prompts|commands)" >&2 + exit 1 + ;; + esac + + if [[ -z "$resolved" ]]; then + echo "Error: $PRINT_PATH_IDE does not support object: $PRINT_PATH_OBJECT" >&2 + exit 1 + fi + + if [[ "$resolved" == "${HOME}/"* ]]; then + resolved="~${resolved#"${HOME}"}" + fi + + echo "$resolved" + exit 0 + fi + + if [[ -z "$SOURCE_IDE" ]]; then + echo "Error: source IDE must be specified (--source)" >&2 + echo "" >&2 + echo "Supported IDEs:" >&2 + for ide in $SUPPORTED_IDES; do + printf " - %-12s %s\n" "$ide" "$(get_ide_name "$ide")" >&2 + done + exit 1 + fi + + if [[ -z "$TARGET_IDE" ]]; then + echo "Error: target IDE must be specified (--target)" >&2 + echo "" >&2 + echo "Supported IDEs:" >&2 + for ide in $SUPPORTED_IDES; do + printf " - %-12s %s\n" "$ide" "$(get_ide_name "$ide")" >&2 + done + exit 1 + fi + + if ! validate_ide "$SOURCE_IDE"; then + echo "Error: invalid source IDE: $SOURCE_IDE" >&2 + echo "Supported IDEs: $SUPPORTED_IDES" >&2 + exit 1 + fi + + if ! validate_ide "$TARGET_IDE"; then + echo "Error: invalid target IDE: $TARGET_IDE" >&2 + echo "Supported IDEs: $SUPPORTED_IDES" >&2 + exit 1 + fi + + case "$OPENCODE_VERSION" in + v1|v2) + ;; + *) + echo "Error: invalid OpenCode version: $OPENCODE_VERSION (options: v1, v2)" >&2 + exit 1 + ;; + esac + if [[ $OPENCODE_VERSION_EXPLICIT -eq 1 && "$TARGET_IDE" != "opencode" ]]; then + echo "Error: --opencode-version applies only when --target opencode" >&2 + exit 1 + fi + + case "$SCOPE" in + global|project|both) + ;; + *) + echo "Error: invalid scope: ${SCOPE} (options: global, project, both)" >&2 + exit 1 + ;; + esac + + if [[ "$SOURCE_IDE" == "$TARGET_IDE" ]]; then + echo "Error: source IDE and target IDE cannot be the same" >&2 + exit 1 + fi + + if [[ "$TARGET_IDE" == "firebase-studio" ]]; then + echo "Error: firebase-studio is a source-only migration ID because the product is shutting down; choose a maintained target" >&2 + exit 1 + fi + + if [[ -z "$OBJECTS" ]]; then + if [[ "$SCOPE" == "global" ]]; then + OBJECTS="skills" + echo "No --objects specified: global migrations default to skills." >&2 + else + if [[ $WORKSPACE_EXPLICIT -eq 0 ]]; then + echo "Error: project scope requires an explicit --workspace path" >&2 + exit 1 + fi + OBJECTS=$(list_available_objects "$SOURCE_IDE" | tr ',' '\n' | grep -E '^(skills|rules|prompts)$' | paste -sd, -) + [[ -n "$OBJECTS" ]] || OBJECTS="skills,rules,prompts" + echo "No --objects specified: project migrations default to skills,rules,prompts." >&2 + fi + fi + + local requires_workspace=0 + case ",$OBJECTS," in + *,rules,*|*,prompts,*|*,project,*|*,project-mcp,*) + requires_workspace=1 + ;; + esac + if [[ "$SCOPE" != "global" && ",$OBJECTS," == *,skills,* ]]; then + requires_workspace=1 + fi + if [[ "$SCOPE" != "global" && ",$OBJECTS," == *,mcp,* ]]; then + requires_workspace=1 + fi + if [[ $requires_workspace -eq 1 && $WORKSPACE_EXPLICIT -eq 0 ]]; then + echo "Error: the selected project-backed objects require an explicit --workspace path" >&2 + exit 1 + fi + + if [[ -n "$SOURCE_MCP_FILE" ]]; then + if [[ "$OBJECTS" != *mcp* ]]; then + echo "Error: --source-mcp-file requires --objects mcp or project-mcp" >&2 + exit 1 + fi + if [[ "$SCOPE" == "both" ]]; then + echo "Error: --source-mcp-file cannot represent both global and project MCP scopes; choose one scope" >&2 + exit 1 + fi + if [[ ! -f "$SOURCE_MCP_FILE" || ! -r "$SOURCE_MCP_FILE" ]]; then + echo "Error: --source-mcp-file must name a readable regular file: $SOURCE_MCP_FILE" >&2 + exit 1 + fi + case "${SOURCE_MCP_FILE##*.}" in + json|jsonc) ;; + *) + echo "Error: --source-mcp-file accepts JSON or JSONC only; YAML/TOML MCP formats require manual reconstruction" >&2 + exit 1 + ;; + esac + if ! command -v python3 >/dev/null 2>&1; then + echo "Error: --source-mcp-file requires python3 for safe path and schema validation" >&2 + exit 1 + fi + SOURCE_MCP_FILE="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$SOURCE_MCP_FILE")" + fi + + echo "========================================" + echo "Migration summary" + echo "========================================" + echo "" + echo " source IDE: $(get_ide_name "$SOURCE_IDE")" + echo " target IDE: $(get_ide_name "$TARGET_IDE")" + echo " workspace: $WORKSPACE_ROOT" + echo " migration content: $OBJECTS" + if [[ -n "$SOURCE_MCP_FILE" ]]; then + echo " explicit MCP source: $SOURCE_MCP_FILE" + fi + echo " scope: $SCOPE (only applies to skills/mcp)" + echo " strategy: $STRATEGY" + echo "" + + if [[ $DRY_RUN -eq 1 ]]; then + echo " mode: DRY-RUN (will not modify any files)" + fi + + echo "" + + if [[ $DRY_RUN -eq 0 && $ASSUME_YES -eq 0 ]]; then + echo "Error: --yes is required for writes; interactive confirmation is intentionally unsupported." >&2 + echo "Preview with --dry-run, obtain approval, then rerun the reviewed command with --yes. No files modified." >&2 + exit 2 + fi + + init_migration_files + + echo "[START] starting migration: $(get_ide_name "$SOURCE_IDE") -> $(get_ide_name "$TARGET_IDE")" + echo "" + + run_migration "$SOURCE_IDE" "$TARGET_IDE" + + echo "" + echo "========================================" + echo " migration complete" + echo "========================================" + echo "" + + if [[ "${MIGRATE_JSON:-}" == "1" ]]; then + exec 1>&3 + fi + + report=$(generate_report "$SOURCE_IDE" "$TARGET_IDE") + echo "$report" + + if [[ -n "$REPORT_FILE" ]]; then + echo "$report" > "$REPORT_FILE" + if [[ "${MIGRATE_JSON:-}" == "1" ]]; then + echo "Report saved to: $REPORT_FILE" >&2 + else + echo "Report saved to: $REPORT_FILE" + fi + fi + + if [[ -n "${SOURCE_MCP_FILE:-}" && $MIGRATION_FAILED -gt 0 ]]; then + return 1 + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/migration_core.py b/skills/agent-skills-setup/agent-skills-setup/scripts/migration_core.py new file mode 100644 index 000000000..e2dc53240 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/migration_core.py @@ -0,0 +1,3930 @@ +#!/usr/bin/env python3 +"""Typed migration core for instructions, Agent Skills, and MCP profiles.""" + +from __future__ import annotations + +import difflib +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.parse +import uuid +from dataclasses import asdict, dataclass, field +from enum import Enum +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +_SCRIPT_DIR = str(Path(__file__).resolve().parent) +if _SCRIPT_DIR not in sys.path: + sys.path.insert(0, _SCRIPT_DIR) + +from skill_secret_scanner import finding_reason, scan as scan_skill_source_tree + +from registry.alias_resolver import ResolvedSelector, resolve as resolve_alias +from registry.exceptions import ( + AliasCycleError, + AliasDepthExceededError, + UnknownSelectorError, +) + + +SENSITIVE_NAME = re.compile( + r"(?:^|[_-])(token|secret|password|passwd|api[_-]?key|authorization|cookie)(?:$|[_-])", + re.IGNORECASE, +) +PLACEHOLDER = re.compile(r"^(?:\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|<[^>]+>)$") +URL_CREDENTIAL = re.compile(r"(?i)://[^/?#\s]+:[^/@\s]+@") +BEARER_LITERAL = re.compile(r"(?i)\bbearer\s+(?!\$\{|<)[A-Za-z0-9._~+/=-]{8,}") +SAFE_BEARER_REFERENCE = re.compile( + r"(?i)^bearer\s+\$\{(?:env:)?[A-Za-z_][A-Za-z0-9_]*\}$" +) +FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL) +SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +KNOWN_COMMANDS = { + "detect", + "inventory", + "plan", + "apply", + "verify", + "rollback", + "migrate", + "snapshot", + "bundle-verify", + "bundle-sign", + "bundle-keygen", + "restore", + "doctor", +} +PLAN_SCHEMA_VERSION = 1 +MANIFEST_SCHEMA_VERSION = 2 +ADAPTER_VERSIONS = { + "skills": "1", + "instructions": "2", + "mcp-json": "1", + "mcp-jsonc": "1", + "mcp-json5": "manual-1", + "mcp-toml": "manual-1", + "mcp-yaml": "manual-1", + "mcp-xml": "manual-1", + "mcp-lua": "manual-1", + "mcp-uuid-or-json": "manual-1", + "cloud-rebuild": "1", + "git-preview": "2", + "apply-transaction": "2", +} +AUTOMATIC_MIGRATION_POLICIES = { + "bidirectional-reviewed", + "prompt-ir-reviewed", + "command-ir-reviewed", + "agent-ir-reviewed", + "hook-ir-reviewed", +} +# The declared automatic surface is the portable trio: +AUTOMATIC_OBJECT_TYPES = frozenset({ + "skills", + "instructions", + "mcp", +}) +# Explicit opt-in transfer types +OPT_IN_WRITABLE_OBJECT_TYPES = frozenset({"plugins", "handoff"}) +# Object types apply_plan knows how to stage atomically. +AUTO_WRITABLE_OBJECT_TYPES = frozenset({"skills", "instructions", "mcp", "plugins"}) +MANUAL_TEMPLATE_OBJECT_TYPES = frozenset({"prompts", "commands"}) +DRAFT_ONLY_OBJECT_TYPES = frozenset({ + "agents", + "hooks", + "workflows", + "automation", + "cron", + "personas", + "modes", +}) +FORBIDDEN_OBJECT_TYPES = frozenset({ + "config", + "policy", + "trust", + "user_memory", + "generated_memory", + "cloud_knowledge", +}) +INVENTORY_ONLY_OBJECT_TYPES = ( + MANUAL_TEMPLATE_OBJECT_TYPES + | DRAFT_ONLY_OBJECT_TYPES + | FORBIDDEN_OBJECT_TYPES +) +AUTOMATIC_SURFACE_POLICIES = { + "validate-then-atomic-copy", + "semantic-ir-with-loss-report", + "profile-version-adapter", +} +SOURCE_AUTOMATIC_SURFACE_POLICIES = AUTOMATIC_SURFACE_POLICIES | {"source-only"} + + +class ItemStatus(str, Enum): + """Plan item state driving the partial safe apply flow. + + Values are stable strings so plan documents remain JSON-friendly. + The legacy ``"manual"`` and ``"blocked"`` strings are normalized by + :func:`normalize_status` to keep older saved plans valid. + """ + + READY = "ready" + READY_LOSSY = "ready-lossy" + DRAFT_DISABLED = "draft-disabled" + MANUAL_REBUILD = "manual-rebuild" + FORBIDDEN = "forbidden" + CONFLICT = "conflict" + INVALID = "invalid" + + +# Legacy aliases accepted from older plan documents. +_LEGACY_STATUS_ALIASES: dict[str, ItemStatus] = { + "manual": ItemStatus.MANUAL_REBUILD, + "blocked": ItemStatus.INVALID, +} + +# Statuses that imply a write must not happen; they only populate the +# manifest with a reason. +_NON_WRITE_STATUSES: frozenset[ItemStatus] = frozenset( + { + ItemStatus.MANUAL_REBUILD, + ItemStatus.FORBIDDEN, + ItemStatus.CONFLICT, + ItemStatus.INVALID, + } +) + + +def normalize_status(value: Any) -> ItemStatus: + """Coerce a string (or enum) value into an :class:`ItemStatus`. + + Accepts the modern enum strings plus the legacy ``"manual"`` and + ``"blocked"`` strings so older saved plans keep validating. + """ + if isinstance(value, ItemStatus): + return value + if isinstance(value, str): + for status in ItemStatus: + if status.value == value: + return status + legacy = _LEGACY_STATUS_ALIASES.get(value) + if legacy is not None: + return legacy + raise ValueError(f"unknown plan item status: {value!r}") + + +def canonical_relative_path(path: Path, boundary: Path) -> str: + """Return a stable, forward-slash relative path string for hashing.""" + try: + relative = path.relative_to(boundary) + except ValueError: + return str(path) + return relative.as_posix().strip("/") + + +def compute_object_id( + *, + product: str, + profile: str, + scope: str, + canonical_path: str, +) -> str: + """Stable 16-hex-char object identifier. + + Derived from the (resolved) product/profile, the scope, and the + canonical source-relative path. Alias-equivalent inputs therefore + produce the same id; collision space is 64 bits. + """ + payload = f"{product}|{profile}|{scope}|{canonical_path}" + digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() + return digest[:16] + + +@dataclass +class InstructionIR: + text: str + scope: str = "project" + activation: str = "always" + globs: list[str] = field(default_factory=list) + description: str = "" + priority: int = 0 + hierarchy: str = "flat" + imports: list[str] = field(default_factory=list) + unknown_fields: list[str] = field(default_factory=list) + source_format: str = "plain-markdown" + + +@dataclass +class MCPServerIR: + name: str + transport: str = "stdio" + command: str | None = None + args: list[str] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + url: str | None = None + headers: dict[str, str] = field(default_factory=dict) + cwd: str | None = None + timeout_seconds: float | None = None + startup_timeout_seconds: float | None = None + enabled: bool = True + tool_allowlist: list[str] = field(default_factory=list) + tool_denylist: list[str] = field(default_factory=list) + auth: dict[str, Any] = field(default_factory=dict) + mtls: dict[str, Any] = field(default_factory=dict) + server_instructions_trust: str = "default" + target_schema_version: str = "1" + package_requirements: list[str] = field(default_factory=list) + extra_fields: dict[str, Any] = field(default_factory=dict) + source_format: str = "json:mcpServers" + + +@dataclass +class PromptIR: + name: str + description: str = "" + arguments: list[dict[str, str]] = field(default_factory=list) + body: str = "" + model: str = "" + agent: str = "" + file_references: list[str] = field(default_factory=list) + scope: str = "user" + auto_invocation: bool = False + extra_fields: dict[str, Any] = field(default_factory=dict) + source_format: str = "plain-prompt" + + +@dataclass +class CommandIR: + name: str + description: str = "" + arguments: list[dict[str, str]] = field(default_factory=list) + invocation: str = "" + body: str = "" + shell_block: str = "" + tool_blocks: list[dict[str, str]] = field(default_factory=list) + file_references: list[str] = field(default_factory=list) + model: str = "" + agent: str = "" + scope: str = "user" + auto_invocation: bool = False + extra_fields: dict[str, Any] = field(default_factory=dict) + source_format: str = "plain-command" + + +@dataclass +class AgentIR: + name: str + description: str = "" + system_prompt: str = "" + tools: list[str] = field(default_factory=list) + tool_groups: list[str] = field(default_factory=list) + model: str = "" + permissions: list[str] = field(default_factory=list) + mcp: list[str] = field(default_factory=list) + subagents: list[str] = field(default_factory=list) + handoffs: list[str] = field(default_factory=list) + isolation: str = "" + worktree: bool = False + memory_policy: str = "" + hooks: list[dict[str, Any]] = field(default_factory=list) + display_metadata: dict[str, str] = field(default_factory=dict) + extra_fields: dict[str, Any] = field(default_factory=dict) + source_format: str = "plain-agent" + + +@dataclass +class HookIR: + event: str + matcher: str = "" + command: str = "" + cwd: str = "" + env: dict[str, str] = field(default_factory=dict) + stdin_schema: str = "" + stdout_schema: str = "" + blocking: bool = True + exit_code: int | None = None + timeout_seconds: float | None = None + async_run: bool = False + os_overrides: dict[str, dict[str, Any]] = field(default_factory=dict) + target_script_references: list[str] = field(default_factory=list) + extra_fields: dict[str, Any] = field(default_factory=dict) + source_format: str = "plain-hook" + + +@dataclass +class LossItem: + object_type: str + field: str + reason: str + value: Any = None + + +@dataclass +class LossReport: + items: list[LossItem] = field(default_factory=list) + + @property + def lossy(self) -> bool: + return bool(self.items) + + def add(self, object_type: str, field_name: str, reason: str, value: Any) -> None: + self.items.append(LossItem(object_type, field_name, reason, value)) + + def to_dict(self) -> dict[str, Any]: + return {"lossy": self.lossy, "items": [asdict(item) for item in self.items]} + + +@dataclass +class SurfacePath: + product: str + profile: str + object_type: str + scope: str + storage: str + path: str + resolved_path: Path + boundary: Path + source_format: str + policy: str + location_role: str + canonical_path: str + precedence: int + + def to_dict(self) -> dict[str, Any]: + value = asdict(self) + value["resolved_path"] = str(self.resolved_path) + value["boundary"] = str(self.boundary) + return value + + +@dataclass +class PlanItem: + object_type: str + status: str + reason: str + source: SurfacePath | None = None + target: SurfacePath | None = None + manual_actions: list[str] = field(default_factory=list) + object_id: str = field(default="", repr=False) + expected_source_state: dict[str, Any] | None = field(default=None, repr=False) + expected_target_state: dict[str, Any] | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + # Compute a stable object_id from the resolved source when not + # provided. Alias-equivalent selectors therefore share an id. + if not self.object_id and self.source is not None: + self.object_id = compute_object_id( + product=self.source.product, + profile=self.source.profile, + scope=self.source.scope, + canonical_path=canonical_relative_path( + self.source.resolved_path, self.source.boundary + ), + ) + + @property + def status_enum(self) -> ItemStatus: + return normalize_status(self.status) + + @property + def target_group(self) -> str | None: + """Logical grouping used to scope conflict/invalid blocking. + + Two items share a target group when their resolved target path + is the same. Items without a target fall into a per-item group + so they never block others. + """ + target = self.target + if target is None: + return None + return str(target.resolved_path) + + def to_dict(self) -> dict[str, Any]: + return { + "object_type": self.object_type, + "status": self.status, + "reason": self.reason, + "source": self.source.to_dict() if self.source else None, + "target": self.target.to_dict() if self.target else None, + "manual_actions": self.manual_actions, + "object_id": self.object_id, + } + + +def _expand_path_vars(raw_path: str, home: Path) -> str: + """Expand Windows %VAR% and POSIX $VAR environment variables. + + Provides safe cross-platform fallback paths for APPDATA, LOCALAPPDATA, + USERPROFILE, and HOMEPATH when evaluating paths in cross-device/OS contexts. + """ + def _replace_win_var(match: re.Match) -> str: + var_name = match.group(1).upper() + if var_name in os.environ: + return os.environ[var_name] + if var_name == "APPDATA": + return str(home / "AppData" / "Roaming") + if var_name == "LOCALAPPDATA": + return str(home / "AppData" / "Local") + if var_name in ("USERPROFILE", "HOMEPATH"): + return str(home) + if var_name == "PROGRAMDATA": + return "C:/ProgramData" + return match.group(0) + + expanded = re.sub(r"%([A-Za-z0-9_]+)%", _replace_win_var, raw_path) + + def _replace_posix_var(match: re.Match) -> str: + var_name = (match.group(1) or match.group(2) or "").upper() + if var_name in os.environ: + return os.environ[var_name] + if var_name == "APPDATA": + return str(home / "AppData" / "Roaming") + if var_name == "LOCALAPPDATA": + return str(home / "AppData" / "Local") + if var_name in ("USERPROFILE", "HOME"): + return str(home) + return match.group(0) + + expanded = re.sub( + r"\$\{([A-Za-z0-9_]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)", + _replace_posix_var, + expanded, + ) + return expanded.replace("\\", "/") + + +class Registry: + """Resolve registry v2 products, profiles, and concrete surface paths.""" + + def __init__(self, path: Path, workspace: Path, home: Path | None = None) -> None: + self.path = path + self.workspace = workspace.resolve() + # Honor $HOME when it points at a real directory: Path.home() on + # native Windows Python reads USERPROFILE only, which silently + # ignored HOME-injected test fixtures and cross-device restores. + if home is not None: + resolved_home = home + else: + env_home = os.environ.get("HOME") + env_candidate = Path(env_home) if env_home else None + resolved_home = ( + env_candidate + if env_candidate is not None and env_candidate.is_dir() + else Path.home() + ) + self.home = resolved_home.resolve() + self.data = json.loads(path.read_text(encoding="utf-8")) + if self.data.get("schema_version") not in (2, 2.1): + raise ValueError("registry schema_version must be 2 or 2.1") + + @property + def products(self) -> dict[str, Any]: + return self.data["products"] + + def _with_support(self, profile: dict[str, Any]) -> dict[str, Any]: + resolved = dict(profile) + policy = str(resolved.get("migration_policy", "")) + contract = self.data.get("support_contract", {}).get(policy, {}) + resolved.setdefault("support_level", contract.get("support_level")) + resolved.setdefault("confidence", contract.get("confidence")) + return resolved + + def split_selector(self, selector: str) -> tuple[str, str | None]: + product_id, separator, profile_id = selector.partition("/") + if product_id not in self.products: + raise ValueError(f"unknown product: {product_id}") + return product_id, profile_id if separator else None + + def profile_raw(self, selector: str) -> tuple[str, str, dict[str, Any]]: + """Resolve a selector without following ``alias_of``. + + This is the legacy behavior preserved for callers that need the + raw alias template result (e.g. tests asserting on the + ``legacy-alias`` profile). New code should use :meth:`profile`. + """ + product_id, requested_profile = self.split_selector(selector) + product = self.products[product_id] + template_id = product.get("template") + if template_id: + template = dict(self.data["profile_templates"][template_id]) + for field_name in ( + "support_level", + "confidence", + "verified_at", + "sources", + "reason", + ): + if field_name in product: + template[field_name] = product[field_name] + profile_id = str(template.get("profile", template_id)) + if requested_profile and requested_profile != profile_id: + raise ValueError( + f"{product_id} is a {template_id} profile, not {requested_profile}" + ) + return product_id, profile_id, self._with_support(template) + + profiles = product.get("profiles", {}) + profile_id = requested_profile or product.get("default_profile") + if profile_id not in profiles: + raise ValueError(f"unknown profile: {product_id}/{profile_id}") + return ( + product_id, + profile_id, + self._with_support(self._resolve_profile(profiles, profile_id, ())), + ) + + def profile(self, selector: str) -> tuple[str, str, dict[str, Any]]: + """Resolve a selector through the alias chain. + + See :mod:`registry.alias_resolver` for chain semantics. Returns + ``(resolved_product, resolved_profile, profile_data)`` using the + resolved identifiers. The original user input is preserved via + :attr:`ResolvedSelector.requested`; callers that need it should + call :meth:`resolve_selector` directly. + """ + resolved = resolve_alias(selector, self.data) + self._log_resolution(resolved) + return self._load_profile_data(resolved) + + def resolve_selector(self, selector: str) -> ResolvedSelector: + """Return the :class:`ResolvedSelector` without resolving profile + data. Useful for callers that want to preserve ``requested`` vs + ``resolved_product``/``resolved_profile`` for logs, plans, or + manifests.""" + resolved = resolve_alias(selector, self.data) + self._log_resolution(resolved) + return resolved + + @staticmethod + def _log_resolution(resolved: ResolvedSelector) -> None: + # Only log when an alias chain was actually followed. + if resolved.chain and resolved.chain[0] == resolved.resolved_product: + return + chain = " -> ".join(resolved.chain) + print( + f"alias: {resolved.requested} -> " + f"{resolved.resolved_product}/{resolved.resolved_profile} " + f"({chain})", + file=sys.stderr, + ) + if resolved.deprecated: + print( + f"alias: {resolved.requested} is deprecated", + file=sys.stderr, + ) + + def _load_profile_data( + self, resolved: ResolvedSelector + ) -> tuple[str, str, dict[str, Any]]: + """Translate a :class:`ResolvedSelector` into the same tuple shape + as :meth:`profile_raw` using the resolved product/profile.""" + product = self.products.get(resolved.resolved_product) + if product is None: + raise UnknownSelectorError(product=resolved.resolved_product) + template_id = product.get("template") + if isinstance(template_id, str) and template_id: + template = dict(self.data["profile_templates"][template_id]) + for field_name in ( + "support_level", + "confidence", + "verified_at", + "sources", + "reason", + ): + if field_name in product: + template[field_name] = product[field_name] + if not resolved.resolved_profile: + profile_id = str(template.get("profile", template_id)) + else: + profile_id = resolved.resolved_profile + return ( + resolved.resolved_product, + profile_id, + self._with_support(template), + ) + profiles = product.get("profiles", {}) + profile_id = resolved.resolved_profile or product.get("default_profile") + if profile_id not in profiles: + raise ValueError( + f"unknown profile: {resolved.resolved_product}/{profile_id}" + ) + return ( + resolved.resolved_product, + profile_id, + self._with_support(self._resolve_profile(profiles, profile_id, ())), + ) + + def _resolve_profile( + self, + profiles: dict[str, Any], + profile_id: str, + stack: tuple[str, ...], + ) -> dict[str, Any]: + if profile_id in stack: + raise ValueError(f"profile inheritance cycle at {profile_id}") + profile = dict(profiles[profile_id]) + parent_id = profile.pop("inherits", None) + if not parent_id: + return profile + if parent_id not in profiles: + raise ValueError(f"unknown inherited profile: {parent_id}") + parent = self._resolve_profile(profiles, parent_id, stack + (profile_id,)) + parent.update(profile) + return parent + + @staticmethod + def _absolute(path: Path) -> Path: + return Path(os.path.abspath(path)) + + def resolve_path(self, entry: dict[str, Any]) -> tuple[Path, Path]: + raw_path = str(entry["path"]) + override = entry.get("override_env") + if override and os.environ.get(str(override)): + base = Path(os.environ[str(override)]).expanduser() + if not base.is_absolute(): + raise ValueError(f"{override} must be an absolute path") + relative = entry.get("override_relative_path") + boundary = self._absolute(base) + path = boundary / str(relative) if relative else boundary + return self._absolute(path), boundary + # Honour Registry v2 per-surface platform overrides (darwin, + # linux, windows, wsl, remote-ssh, dev-container, codespaces, + # vscode-profile, extension-host). + platforms = entry.get("platforms") or entry.get("platform_paths") or {} + env_platform = os.environ.get("AGENT_SKILLS_PLATFORM", "") + if not env_platform: + if sys.platform == "win32": + env_platform = "windows" + elif sys.platform == "darwin": + env_platform = "darwin" + elif sys.platform.startswith("linux"): + env_platform = "linux" + else: + env_platform = "linux" + + if platforms and env_platform in platforms: + candidate = str(platforms[env_platform]) + # Glob overrides (github.copilot-*) express a *probe* location, + # not a deterministic read/write target; applying to them would + # either match nothing or fail on literal '*' (WinError 123). + if not any(ch in candidate for ch in "*?["): + raw_path = candidate + + expanded_str = _expand_path_vars(raw_path, self.home) + + if expanded_str == "~": + return self.home, self.home + if expanded_str.startswith("~/"): + return self._absolute(self.home / expanded_str[2:]), self.home + + p = Path(expanded_str) + if p.is_absolute() or re.match(r"^[a-zA-Z]:", expanded_str): + resolved = self._absolute(p) + try: + resolved.relative_to(self.home) + return resolved, self.home + except ValueError: + return resolved, resolved.parent + + return self._absolute(self.workspace / expanded_str), self.workspace + + def surfaces(self, selector: str, object_type: str) -> list[SurfacePath]: + product_id, profile_id, profile = self.profile(selector) + profile_platforms = profile.get("platforms") or profile.get("platform_paths") or {} + entries = profile.get("surfaces", {}).get(object_type, []) + surfaces: list[SurfacePath] = [] + for entry in entries: + canonical_path = str(entry["path"]) + candidates = [canonical_path, *entry.get("compatibility_paths", [])] + seen_paths: set[Path] = set() + for precedence, candidate_path in enumerate(candidates): + candidate_entry = dict(entry) + if not candidate_entry.get("platforms") and profile_platforms: + derived_platforms = {} + for plat, plat_path in profile_platforms.items(): + if plat in ("windows", "wsl", "remote-ssh", "dev-container", "codespaces", "vscode-profile", "extension-host"): + if object_type == "skills": + derived_platforms[plat] = plat_path + else: + plat_base_str = str(plat_path).rstrip("/\\") + if plat_base_str.endswith("/skills") or plat_base_str.endswith("\\skills"): + base = plat_base_str[:-7] + else: + base = plat_base_str + if candidate_path.startswith("~/.") or candidate_path.startswith("~/"): + rel_sub = candidate_path.split("/", 1)[-1] + if "/" in rel_sub: + sub = rel_sub.split("/", 1)[1] + derived_platforms[plat] = f"{base}/{sub}" + else: + derived_platforms[plat] = base + if derived_platforms: + candidate_entry["platforms"] = derived_platforms + + candidate_entry["path"] = candidate_path + if precedence: + candidate_entry.pop("override_env", None) + candidate_entry.pop("override_relative_path", None) + resolved_path, boundary = self.resolve_path(candidate_entry) + if resolved_path in seen_paths: + continue + seen_paths.add(resolved_path) + compatibility_behavior = str( + entry.get("compatibility_behavior", "alternative") + ) + location_role = "canonical" + if precedence: + location_role = ( + "precedence" + if compatibility_behavior == "precedence" + else "compatibility" + ) + surfaces.append(SurfacePath( + product=product_id, + profile=profile_id, + object_type=object_type, + scope=str(entry["scope"]), + storage=str(entry["storage"]), + path=str(candidate_path), + resolved_path=resolved_path, + boundary=boundary, + source_format=str(entry.get("format", "unknown")), + policy=str(entry["policy"]), + location_role=location_role, + canonical_path=canonical_path, + precedence=precedence, + )) + return surfaces + + def inventory(self, selector: str | None = None) -> list[dict[str, Any]]: + selectors: Iterable[str] + if selector: + selectors = (selector,) + else: + expanded: list[str] = [] + for product_id, product in self.products.items(): + profiles = product.get("profiles", {}) + if profiles: + expanded.extend( + f"{product_id}/{profile_id}" for profile_id in profiles + ) + else: + expanded.append(product_id) + selectors = expanded + rows: list[dict[str, Any]] = [] + for candidate in selectors: + product_id, profile_id, profile = self.profile(candidate) + surfaces = profile.get("surfaces", {}) + if not surfaces: + rows.append( + { + "product": product_id, + "profile": profile_id, + "kind": profile.get("kind"), + "migration_policy": profile.get("migration_policy"), + "support_level": profile.get("support_level"), + "confidence": profile.get("confidence"), + "object_type": None, + "exists": False, + "detection": profile.get("detection", []), + "platforms": profile.get("platforms", {}), + } + ) + continue + for object_type in surfaces: + candidates = self.surfaces( + f"{product_id}/{profile_id}", object_type + ) + existing_by_group: dict[tuple[str, str], list[SurfacePath]] = {} + for surface in candidates: + group = (surface.scope, surface.canonical_path) + if surface.resolved_path.exists(): + existing_by_group.setdefault(group, []).append(surface) + for surface in candidates: + row = surface.to_dict() + row["kind"] = profile.get("kind") + row["migration_policy"] = profile.get("migration_policy") + row["support_level"] = profile.get("support_level") + row["confidence"] = profile.get("confidence") + row["exists"] = surface.resolved_path.exists() + row["detection"] = profile.get("detection", []) + row["platforms"] = profile.get("platforms", {}) + existing = existing_by_group.get( + (surface.scope, surface.canonical_path), [] + ) + row["alias_conflict"] = ( + len(existing) > 1 + and not all( + candidate.location_role == "precedence" + for candidate in existing[1:] + ) + ) + row["precedence_active"] = bool(existing) and ( + existing[0].resolved_path == surface.resolved_path + ) + rows.append(row) + return rows + + +FORMAT_FEATURES: dict[str, set[str]] = { + "agents-md": {"text"}, + "amazon-q-rule": {"text"}, + "augment-rule": {"text", "activation", "description"}, + "cline-rule": {"text", "activation", "globs"}, + "cursor-mdc": {"text", "activation", "globs", "description"}, + "continue-rule": {"text", "activation", "globs", "description"}, + "kiro-steering": { + "text", + "activation", + "globs", + "description", + "imports", + }, + "copilot-instructions": {"text", "activation", "globs"}, + "claude-rule": {"text", "activation", "globs"}, + "windsurf-rule": {"text", "activation", "globs", "description"}, + "plain-markdown": {"text"}, + "trae-rule": {"text"}, + "qoder-rule": {"text"}, +} + + +def parse_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value + + +def _strip_frontmatter_comment(value: str) -> str: + """Strip a YAML-style inline comment without touching quoted text.""" + quote: str | None = None + escaped = False + for index, character in enumerate(value): + if escaped: + escaped = False + continue + if character == "\\" and quote == '"': + escaped = True + continue + if quote: + if character == quote: + quote = None + continue + if character in {'"', "'"}: + quote = character + continue + if character == "#" and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + +def parse_list(value: str) -> list[str]: + value = value.strip() + if value.startswith("[") and value.endswith("]"): + value = value[1:-1] + if not value: + return [] + return [parse_scalar(item.strip()) for item in value.split(",") if item.strip()] + + +def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]: + match = FRONTMATTER.match(text) + if not match: + return {}, text + metadata: dict[str, Any] = {} + current_list: str | None = None + for raw_line in match.group(1).splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if line.startswith((" ", "\t")): + if current_list and stripped.startswith("-"): + item = parse_scalar(stripped[1:].strip()) + if item: + metadata[current_list].append(item) + continue + raise ValueError("unsupported nested instruction frontmatter") + if ":" not in line: + raise ValueError("invalid instruction frontmatter") + key, raw_value = line.split(":", 1) + key = key.strip() + if not key: + raise ValueError("instruction frontmatter contains an empty key") + raw_value = _strip_frontmatter_comment(raw_value.strip()) + current_list = None + if not raw_value: + metadata[key] = [] + current_list = key + elif raw_value.startswith("[") and raw_value.endswith("]"): + metadata[key] = parse_list(raw_value) + else: + metadata[key] = parse_scalar(raw_value) + return metadata, text[match.end() :] + + +def _metadata_list(metadata: dict[str, Any], key: str) -> list[str]: + value = metadata.get(key, []) + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return parse_list(value) + raise ValueError(f"instruction frontmatter {key} must be a string or list") + + +def _metadata_bool(metadata: dict[str, Any], key: str) -> bool | None: + if key not in metadata: + return None + value = str(metadata[key]).lower() + if value in {"true", "yes", "1"}: + return True + if value in {"false", "no", "0"}: + return False + raise ValueError(f"instruction frontmatter {key} must be a boolean") + + +def _frontmatter_document(fields: list[tuple[str, Any]], body: str) -> str: + lines = ["---"] + for key, value in fields: + if isinstance(value, list): + if value: + lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}") + else: + lines.append(f"{key}: []") + elif isinstance(value, bool): + lines.append(f"{key}: {'true' if value else 'false'}") + else: + lines.append(f"{key}: {json.dumps(str(value), ensure_ascii=False)}") + lines.extend(("---", body)) + return "\n".join(lines) + + +def parse_instruction( + text: str, + source_format: str, + scope: str = "project", + hierarchy: str = "flat", +) -> InstructionIR: + if source_format not in FORMAT_FEATURES: + raise ValueError( + f"instruction format {source_format} requires a dedicated adapter" + ) + plain_formats = {"agents-md", "amazon-q-rule", "plain-markdown", "trae-rule", "qoder-rule"} + metadata, body = ({}, text) if source_format in plain_formats else _parse_frontmatter(text) + activation = "always" + globs: list[str] = [] + description = "" + imports: list[str] = [] + known_fields: set[str] = set() + + if source_format == "augment-rule": + known_fields = {"type", "description"} + rule_type = str(metadata.get("type", "always_apply")) + activation = { + "always_apply": "always", + "agent_requested": "model", + "manual": "manual", + }.get(rule_type, "unknown") + description = str(metadata.get("description", "")) + elif source_format in {"cline-rule", "claude-rule"}: + known_fields = {"paths"} + globs = _metadata_list(metadata, "paths") + activation = "glob" if globs else "always" + elif source_format == "cursor-mdc": + known_fields = {"description", "globs", "alwaysApply"} + globs = _metadata_list(metadata, "globs") + description = str(metadata.get("description", "")) + always_apply = _metadata_bool(metadata, "alwaysApply") + if always_apply: + activation = "always" + elif globs: + activation = "glob" + elif description: + activation = "model" + else: + activation = "manual" + elif source_format == "continue-rule": + known_fields = {"name", "description", "globs", "alwaysApply"} + globs = _metadata_list(metadata, "globs") + description = str(metadata.get("description", metadata.get("name", ""))) + always_apply = _metadata_bool(metadata, "alwaysApply") + if always_apply is True: + activation = "always" + elif globs: + activation = "glob" + elif always_apply is False and description: + activation = "model" + else: + activation = "always" + elif source_format == "kiro-steering": + known_fields = {"inclusion", "fileMatchPattern", "name", "description"} + inclusion = str(metadata.get("inclusion", "always")) + activation = { + "always": "always", + "fileMatch": "glob", + "manual": "manual", + "auto": "model", + }.get(inclusion, "unknown") + globs = _metadata_list(metadata, "fileMatchPattern") + description = str(metadata.get("description", "")) + import_pattern = re.compile(r"\A(?:#\[\[file:([^\]\r\n]+)\]\]\s*\n)+") + import_match = import_pattern.match(body) + if import_match: + imports = re.findall( + r"^#\[\[file:([^\]\r\n]+)\]\]\s*$", + import_match.group(0), + re.MULTILINE, + ) + body = body[import_match.end() :] + elif source_format == "copilot-instructions": + known_fields = {"applyTo"} + globs = _metadata_list(metadata, "applyTo") + activation = "glob" if globs and globs != ["**"] else "always" + elif source_format == "windsurf-rule": + known_fields = {"trigger", "globs", "description"} + trigger = str(metadata.get("trigger", "always_on")) + activation = { + "always_on": "always", + "glob": "glob", + "model_decision": "model", + "manual": "manual", + }.get(trigger, "unknown") + globs = _metadata_list(metadata, "globs") + description = str(metadata.get("description", "")) + + if activation == "unknown": + raise ValueError(f"unsupported {source_format} activation mode") + return InstructionIR( + text=body.rstrip() + "\n", + scope=scope, + activation=activation, + globs=globs, + description=description, + priority=0, + hierarchy=hierarchy, + imports=imports, + unknown_fields=sorted(set(metadata) - known_fields), + source_format=source_format, + ) + + +def emit_instruction( + instruction: InstructionIR, + target_format: str, +) -> tuple[str, LossReport]: + features = FORMAT_FEATURES.get(target_format) + if features is None: + raise ValueError( + f"instruction format {target_format} requires a dedicated adapter" + ) + report = LossReport() + values: dict[str, Any] = { + "activation": instruction.activation, + "globs": instruction.globs, + "description": instruction.description, + "priority": instruction.priority, + "hierarchy": instruction.hierarchy, + "imports": instruction.imports, + } + meaningful = { + "activation": instruction.activation not in ("", "always", "true"), + "globs": bool(instruction.globs), + "description": bool(instruction.description), + "priority": instruction.priority != 0, + "hierarchy": instruction.hierarchy not in ("", "flat", "none"), + "imports": bool(instruction.imports), + } + for field_name, present in meaningful.items(): + if present and field_name not in features: + report.add( + "instructions", + field_name, + f"{target_format} cannot represent this field", + values[field_name], + ) + for field_name in instruction.unknown_fields: + report.add( + "instructions", + field_name, + f"unrecognized {instruction.source_format} frontmatter field", + None, + ) + + activation = instruction.activation + body = instruction.text + if target_format in {"agents-md", "amazon-q-rule", "plain-markdown", "trae-rule", "qoder-rule"}: + if activation not in {"", "always", "true"}: + raise ValueError( + f"{target_format} cannot safely represent {activation} activation" + ) + return body, report + if target_format == "augment-rule": + if activation in {"", "always", "true"}: + return _frontmatter_document([("type", "always_apply")], body), report + if activation == "model": + description = instruction.description or "Migrated instruction" + return _frontmatter_document( + [("type", "agent_requested"), ("description", description)], + body, + ), report + raise ValueError(f"augment-rule cannot safely represent {activation} activation") + if target_format in {"cline-rule", "claude-rule"}: + if activation in {"", "always", "true"}: + return body, report + if activation == "glob" and instruction.globs: + return _frontmatter_document([("paths", instruction.globs)], body), report + raise ValueError(f"{target_format} cannot safely represent {activation} activation") + if target_format == "cursor-mdc": + description = instruction.description + globs = instruction.globs + always_apply = activation in {"", "always", "true"} + if activation == "model" and not description: + description = "Migrated instruction" + if activation == "manual": + description = "" + globs = [] + if activation not in {"", "always", "true", "glob", "model", "manual"}: + raise ValueError(f"cursor-mdc cannot represent {activation} activation") + return _frontmatter_document( + [ + ("description", description), + ("globs", ",".join(globs)), + ("alwaysApply", always_apply), + ], + body, + ), report + if target_format == "continue-rule": + if activation == "manual": + raise ValueError("continue-rule cannot safely represent manual activation") + fields: list[tuple[str, Any]] = [("name", "Migrated instruction")] + if instruction.globs: + fields.append(("globs", instruction.globs)) + fields.append(("alwaysApply", activation in {"", "always", "true"})) + if instruction.description: + fields.append(("description", instruction.description)) + return _frontmatter_document(fields, body), report + if target_format == "kiro-steering": + inclusion = { + "": "always", + "true": "always", + "always": "always", + "glob": "fileMatch", + "manual": "manual", + "model": "auto", + }.get(activation) + if inclusion is None: + raise ValueError(f"kiro-steering cannot represent {activation} activation") + fields = [("inclusion", inclusion)] + if inclusion == "fileMatch": + if not instruction.globs: + raise ValueError("kiro-steering fileMatch requires globs") + if len(instruction.globs) != 1: + raise ValueError( + "kiro-steering fileMatch supports one native pattern per file" + ) + fields.append(("fileMatchPattern", instruction.globs[0])) + if inclusion == "auto": + fields.extend( + ( + ("name", "migrated-instruction"), + ("description", instruction.description or "Migrated instruction"), + ) + ) + if instruction.imports: + body = "".join( + f"#[[file:{path}]]\n" for path in instruction.imports + ) + body + return _frontmatter_document(fields, body), report + if target_format == "copilot-instructions": + if activation in {"", "always", "true"}: + apply_to = "**" + elif activation == "glob" and instruction.globs: + apply_to = ",".join(instruction.globs) + else: + raise ValueError( + f"copilot-instructions cannot safely represent {activation} activation" + ) + return _frontmatter_document([("applyTo", apply_to)], body), report + if target_format == "windsurf-rule": + trigger = { + "": "always_on", + "true": "always_on", + "always": "always_on", + "glob": "glob", + "model": "model_decision", + "manual": "manual", + }.get(activation) + if trigger is None: + raise ValueError(f"windsurf-rule cannot represent {activation} activation") + fields = [("trigger", trigger)] + if trigger == "glob": + if not instruction.globs: + raise ValueError("windsurf-rule glob activation requires globs") + fields.append(("globs", ",".join(instruction.globs))) + if trigger == "model_decision": + fields.append( + ("description", instruction.description or "Migrated instruction") + ) + return _frontmatter_document(fields, body), report + raise ValueError(f"instruction format {target_format} has no emitter") + + +MCP_ADAPTERS: dict[str, dict[str, Any]] = { + "json": {"name": "mcp-json", "automatic": True}, + "jsonc": {"name": "mcp-jsonc", "automatic": True}, + "json5": {"name": "mcp-json5", "automatic": False}, + "toml": {"name": "mcp-toml", "automatic": False}, + "yaml": {"name": "mcp-yaml", "automatic": False}, + "yml": {"name": "mcp-yaml", "automatic": False}, + "xml": {"name": "mcp-xml", "automatic": False}, + "lua": {"name": "mcp-lua", "automatic": False}, + "uuid-or-json": {"name": "mcp-uuid-or-json", "automatic": False}, +} + + +def mcp_adapter(source_format: str) -> dict[str, Any]: + family = source_format.lower().split(":", 1)[0] + adapter = MCP_ADAPTERS.get(family) + if adapter is None: + return { + "name": f"mcp-unknown:{family}", + "automatic": False, + "reason": "unknown MCP format has no registered adapter", + } + value = dict(adapter) + if not value["automatic"]: + value["reason"] = ( + f"{family} MCP requires a dedicated reviewed reconstruction adapter" + ) + return value + + +def _strip_jsonc(text: str) -> str: + """Remove JSONC comments and trailing commas without evaluating input.""" + without_comments: list[str] = [] + index = 0 + in_string = False + escaped = False + while index < len(text): + character = text[index] + if in_string: + without_comments.append(character) + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + index += 1 + continue + if character == '"': + in_string = True + without_comments.append(character) + index += 1 + continue + if character == "/" and index + 1 < len(text): + following = text[index + 1] + if following == "/": + index += 2 + while index < len(text) and text[index] not in "\r\n": + index += 1 + continue + if following == "*": + index += 2 + while index + 1 < len(text) and text[index : index + 2] != "*/": + if text[index] in "\r\n": + without_comments.append(text[index]) + index += 1 + if index + 1 >= len(text): + raise ValueError("unterminated JSONC block comment") + index += 2 + continue + without_comments.append(character) + index += 1 + + cleaned = "".join(without_comments) + without_trailing_commas: list[str] = [] + index = 0 + in_string = False + escaped = False + while index < len(cleaned): + character = cleaned[index] + if in_string: + without_trailing_commas.append(character) + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + index += 1 + continue + if character == '"': + in_string = True + without_trailing_commas.append(character) + index += 1 + continue + if character == ",": + lookahead = index + 1 + while lookahead < len(cleaned) and cleaned[lookahead].isspace(): + lookahead += 1 + if lookahead < len(cleaned) and cleaned[lookahead] in "}]": + index += 1 + continue + without_trailing_commas.append(character) + index += 1 + return "".join(without_trailing_commas) + + +def _decode_mcp_document(text: str, source_format: str) -> dict[str, Any]: + adapter = mcp_adapter(source_format) + if not adapter["automatic"]: + raise ValueError(str(adapter["reason"])) + family = source_format.lower().split(":", 1)[0] + decoded = json.loads(_strip_jsonc(text) if family == "jsonc" else text) + if not isinstance(decoded, dict): + raise ValueError("MCP document root must be an object") + return decoded + + +def _server_container( + value: dict[str, Any], + source_format: str, +) -> tuple[str, dict[str, Any]]: + containers = [ + (key, value[key]) + for key in ("mcpServers", "servers", "mcp") + if isinstance(value.get(key), dict) + ] + if len(containers) > 1: + raise ValueError( + "MCP document has conflicting root aliases: " + + ", ".join(key for key, _ in containers) + ) + expected = source_format.partition(":")[2] + if not containers: + raise ValueError( + f"MCP {source_format} must contain a {expected or 'server map'} object" + ) + key, servers = containers[0] + if expected and key != expected: + raise ValueError(f"MCP {source_format} requires root key {expected}, not {key}") + return key, servers + + +def parse_mcp_document(text: str, source_format: str) -> list[MCPServerIR]: + value = _decode_mcp_document(text, source_format) + _, servers = _server_container(value, source_format) + parsed: list[MCPServerIR] = [] + for name, raw_server in servers.items(): + if not isinstance(name, str) or not isinstance(raw_server, dict): + raise ValueError("MCP servers must be named objects") + command = raw_server.get("command") + url = raw_server.get("url") + raw_transport = raw_server.get( + "transport", + raw_server.get("type", "http" if url else "stdio"), + ) + if not isinstance(raw_transport, str): + raise ValueError(f"MCP server {name}: transport must be a string") + transport_aliases = { + "streamable-http": "http", + "streamable_http": "http", + "streamableHttp": "http", + } + transport = transport_aliases.get(raw_transport, raw_transport) + if transport not in {"stdio", "http", "sse"}: + raise ValueError(f"MCP server {name}: unsupported transport {transport}") + if command is not None and not isinstance(command, str): + raise ValueError(f"MCP server {name}: command must be a string") + if url is not None and not isinstance(url, str): + raise ValueError(f"MCP server {name}: url must be a string") + if (command is None) == (url is None): + raise ValueError( + f"MCP server {name}: exactly one of command or url is required" + ) + if command is not None and transport != "stdio": + raise ValueError(f"MCP server {name}: command requires stdio transport") + if url is not None and transport == "stdio": + raise ValueError(f"MCP server {name}: url requires http or sse transport") + args = raw_server.get("args", []) + env = raw_server.get("env", {}) + headers = raw_server.get("headers", {}) + if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args): + raise ValueError(f"MCP server {name}: args must be strings") + if not isinstance(env, dict) or not all( + isinstance(key, str) and isinstance(item, str) for key, item in env.items() + ): + raise ValueError(f"MCP server {name}: env must be a string map") + if not isinstance(headers, dict) or not all( + isinstance(key, str) and isinstance(item, str) + for key, item in headers.items() + ): + raise ValueError(f"MCP server {name}: headers must be a string map") + if command is not None and headers: + raise ValueError(f"MCP server {name}: stdio transport cannot use headers") + if url is not None and (args or env): + raise ValueError(f"MCP server {name}: remote transport cannot use args or env") + known_fields = { + "command", + "args", + "env", + "url", + "headers", + "transport", + "type", + } + parsed.append( + MCPServerIR( + name=name, + transport=transport, + command=command, + args=list(args), + env=dict(env), + url=url, + headers=dict(headers), + extra_fields={ + key: item + for key, item in raw_server.items() + if key not in known_fields + }, + source_format=source_format, + ) + ) + return parsed + + +PROVIDER_SECRET = re.compile( + r"(?:sk-[A-Za-z0-9_-]{16,}" + r"|gh[pousr]_[A-Za-z0-9]{20,}" + r"|AKIA[0-9A-Z]{16}" + r"|ASIA[0-9A-Z]{16}" + r"|xox[baprs]-[A-Za-z0-9-]{10,}" + r"|ya29\.[A-Za-z0-9_-]+" + r"|AIza[0-9A-Za-z_-]{35}" + r"|sk_live_[A-Za-z0-9]{16,})" +) +SECRET_FLAGS = { + "-k": "API_KEY", + "-p": "PASSWORD", + "-t": "TOKEN", + "--api-key": "API_KEY", + "--apikey": "API_KEY", + "--auth": "AUTHORIZATION", + "--password": "PASSWORD", + "--secret": "SECRET", + "--token": "TOKEN", +} + + +def _safe_secret_value(name: str, value: str) -> tuple[str, bool]: + if ( + not SENSITIVE_NAME.search(name) + and not PROVIDER_SECRET.search(value) + and not URL_CREDENTIAL.search(value) + and not BEARER_LITERAL.search(value) + ) or PLACEHOLDER.fullmatch(value) or SAFE_BEARER_REFERENCE.fullmatch(value): + return value, False + placeholder_name = re.sub(r"[^A-Za-z0-9_]", "_", name).upper() + return f"${{{placeholder_name}}}", True + + +def _safe_args(args: list[str], server_name: str, report: LossReport) -> list[str]: + safe = list(args) + index = 0 + while index < len(safe): + argument = safe[index] + flag, separator, inline_value = argument.partition("=") + normalized_flag = flag.lower() + if normalized_flag in SECRET_FLAGS: + placeholder = f"${{{SECRET_FLAGS[normalized_flag]}}}" + if separator: + if inline_value and not PLACEHOLDER.fullmatch(inline_value): + safe[index] = f"{flag}={placeholder}" + report.add( + "mcp", f"{server_name}.args[{index}]", "literal secret removed", None + ) + elif index + 1 < len(safe) and not PLACEHOLDER.fullmatch(safe[index + 1]): + safe[index + 1] = placeholder + report.add( + "mcp", f"{server_name}.args[{index + 1}]", "literal secret removed", None + ) + index += 1 + elif ( + PROVIDER_SECRET.search(argument) + or URL_CREDENTIAL.search(argument) + or BEARER_LITERAL.search(argument) + ): + safe[index] = "${SECRET}" + report.add( + "mcp", f"{server_name}.args[{index}]", "provider credential removed", None + ) + index += 1 + return safe + + +def _safe_url(url: str, server_name: str, report: LossReport) -> str: + provider_redacted_url, provider_count = PROVIDER_SECRET.subn("${MCP_SECRET}", url) + if provider_count: + report.add("mcp", f"{server_name}.url", "provider credential removed", None) + url = provider_redacted_url + parsed = urllib.parse.urlsplit(url) + changed = False + hostname = parsed.hostname or "" + netloc = hostname + if parsed.port is not None: + netloc += f":{parsed.port}" + if parsed.username is not None or parsed.password is not None: + netloc = f"${{MCP_USER}}:${{MCP_PASSWORD}}@{netloc}" + changed = True + query_items = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + safe_query: list[tuple[str, str]] = [] + for name, value in query_items: + safe_value, redacted = _safe_secret_value(name, value) + safe_query.append((name, safe_value)) + changed = changed or redacted + if changed: + report.add("mcp", f"{server_name}.url", "literal URL credential removed", None) + return urllib.parse.urlunsplit( + (parsed.scheme, netloc or parsed.netloc, parsed.path, urllib.parse.urlencode(safe_query), parsed.fragment) + ) + + +def emit_mcp_document( + servers: list[MCPServerIR], + target_format: str, + existing_text: str | None = None, +) -> tuple[str, LossReport]: + adapter = mcp_adapter(target_format) + if not adapter["automatic"]: + raise ValueError(str(adapter["reason"])) + family, separator, container = target_format.partition(":") + key = container if separator and family in {"json", "jsonc"} else "mcpServers" + existing: dict[str, Any] = {} + if existing_text: + existing = _decode_mcp_document(existing_text, target_format) + if any(key_name in existing for key_name in ("mcpServers", "servers", "mcp")): + _server_container(existing, target_format) + output_servers: dict[str, Any] = {} + report = LossReport() + for server in servers: + if server.transport != "stdio": + raise ValueError( + "remote MCP conversion requires a dedicated target-profile transport adapter" + ) + item: dict[str, Any] = {} + for field_name in sorted(server.extra_fields): + report.add( + "mcp", + f"{server.name}.{field_name}", + "source-only MCP field not migrated", + None, + ) + if server.command is not None: + if ( + PROVIDER_SECRET.search(server.command) + or URL_CREDENTIAL.search(server.command) + or BEARER_LITERAL.search(server.command) + ): + raise ValueError(f"MCP server {server.name}: command contains a credential") + item["command"] = server.command + if server.args: + item["args"] = _safe_args(server.args, server.name, report) + if server.url is not None: + item["url"] = _safe_url(server.url, server.name, report) + if server.env: + env: dict[str, str] = {} + for name, value in server.env.items(): + env[name], redacted = _safe_secret_value(name, value) + if redacted: + report.add("mcp", f"{server.name}.env.{name}", "literal secret removed", None) + item["env"] = env + if server.headers: + headers: dict[str, str] = {} + for name, value in server.headers.items(): + headers[name], redacted = _safe_secret_value(name, value) + if redacted: + report.add( + "mcp", f"{server.name}.headers.{name}", "literal secret removed", None + ) + item["headers"] = headers + output_servers[server.name] = item + existing[key] = output_servers + return json.dumps(existing, indent=2, sort_keys=True) + "\n", report + + +def emit_prompt(prompt: PromptIR, target_format: str) -> tuple[str, LossReport]: + """Emit a prompt to the target format.""" + report = LossReport() + handler = _PROMPT_EMITTERS.get(target_format) + if handler: + return handler(prompt, report) + # Fallback to plain-prompt + if target_format == "plain-prompt": + body = prompt.body + if prompt.arguments: + arg_lines = "\n".join(f"${arg['name']}: {arg.get('description', '')}" for arg in prompt.arguments) + body = f"{arg_lines}\n\n{body}" + return body, report + raise ValueError(f"unsupported prompt target format: {target_format}") + + +def _emit_prompt_plain(prompt: PromptIR, report: LossReport) -> tuple[str, LossReport]: + body = prompt.body + if prompt.arguments: + arg_lines = "\n".join(f"${arg['name']}: {arg.get('description', '')}" for arg in prompt.arguments) + body = f"{arg_lines}\n\n{body}" + return body, report + + +def _emit_prompt_qwen(prompt: PromptIR, report: LossReport) -> tuple[str, LossReport]: + """Emit Qwen command format (qwen-command).""" + # Qwen commands are shell scripts with frontmatter + parts = [] + if prompt.arguments: + parts.append("# Arguments:") + for arg in prompt.arguments: + parts.append(f"# {arg['name']}: {arg.get('description', '')}") + parts.append(prompt.body) + return "\n\n".join(parts), report + + +# Prompt emitter registry +_PROMPT_EMITTERS: dict[str, Callable[[PromptIR, LossReport], tuple[str, LossReport]]] = { + "plain-prompt": _emit_prompt_plain, + "qwen-command": _emit_prompt_qwen, + "qwen-prompt": _emit_prompt_qwen, + "zencoder-prompt": _emit_prompt_plain, + "zenflow-prompt": _emit_prompt_plain, + "factory-command": _emit_prompt_plain, + "gemini-prompt": _emit_prompt_plain, + "gemini-prompt-library": _emit_prompt_plain, + "warp-prompt": _emit_prompt_plain, + "qwen-prompt": _emit_prompt_qwen, + "claude-prompt": _emit_prompt_plain, + "cursor-prompt": _emit_prompt_plain, + "amazon-q-prompt": _emit_prompt_plain, + "factory-prompt": _emit_prompt_plain, + "zencoder-prompt": _emit_prompt_plain, + "zenflow-prompt": _emit_prompt_plain, + "letta-prompt": _emit_prompt_plain, + "qoder-prompt": _emit_prompt_plain, + "gemini-code-assist-prompt": _emit_prompt_plain, + "qwen-prompt-library": _emit_prompt_plain, +} + + +def _emit_command_plain(cmd: CommandIR, report: LossReport) -> tuple[str, LossReport]: + lines = [] + if cmd.invocation: + lines.append(f"# {cmd.invocation}") + if cmd.description: + lines.append(f"# {cmd.description}") + if cmd.shell_block: + lines.append(cmd.shell_block) + for block in cmd.tool_blocks: + lines.append(f"# tool: {block.get('name', '')}") + lines.append(block.get("input", "")) + return "\n\n".join(lines), report + + +def _emit_command_qwen(cmd: CommandIR, report: LossReport) -> tuple[str, LossReport]: + # Qwen commands are shell scripts + lines = [] + if cmd.invocation: + lines.append(f"# {cmd.invocation}") + if cmd.description: + lines.append(f"# {cmd.description}") + if cmd.shell_block: + lines.append(cmd.shell_block) + for block in cmd.tool_blocks: + lines.append(f"# tool: {block.get('name', '')}") + lines.append(block.get("input", "")) + return "\n\n".join(lines), report + + +def _emit_command_factory(cmd: CommandIR, report: LossReport) -> tuple[str, LossReport]: + # Factory commands are shell scripts + lines = [] + if cmd.invocation: + lines.append(f"# {cmd.invocation}") + if cmd.description: + lines.append(f"# {cmd.description}") + if cmd.shell_block: + lines.append(cmd.shell_block) + for block in cmd.tool_blocks: + lines.append(f"# tool: {block.get('name', '')}") + lines.append(block.get("input", "")) + return "\n\n".join(lines), report + + +def _emit_command_zencoder(cmd: CommandIR, report: LossReport) -> tuple[str, LossReport]: + return _emit_command_plain(cmd, report) + + +def _emit_command_warp(cmd: CommandIR, report: LossReport) -> tuple[str, LossReport]: + return _emit_command_plain(cmd, report) + + +# Command emitter registry +_COMMAND_EMITTERS: dict[str, Callable[[CommandIR, LossReport], tuple[str, LossReport]]] = { + "plain-command": _emit_command_plain, + "qwen-command": _emit_command_qwen, + "factory-command": _emit_command_factory, + "zencoder-command": _emit_command_zencoder, + "warp-command": _emit_command_warp, +} + + +def emit_command(cmd: CommandIR, target_format: str) -> tuple[str, LossReport]: + handler = _COMMAND_EMITTERS.get(target_format) + if handler: + return handler(cmd, LossReport()) + if target_format == "plain-command": + return _emit_command_plain(cmd, LossReport()) + raise ValueError(f"unsupported command target format: {target_format}") + + +def _emit_agent_plain(agent: AgentIR, report: LossReport) -> tuple[str, LossReport]: + lines = [ + f"name: {agent.name}", + f"description: {agent.description}", + f"system_prompt: {agent.system_prompt}", + f"model: {agent.model}", + ] + if agent.tools: + lines.append("tools: " + ", ".join(agent.tools)) + if agent.subagents: + lines.append("subagents: " + ", ".join(agent.subagents)) + if agent.handoffs: + lines.append("handoffs: " + " -> ".join(agent.handoffs)) + if agent.hooks: + lines.append("hooks: " + str(agent.hooks)) + if agent.isolation: + lines.append(f"isolation: {agent.isolation}") + if agent.worktree: + lines.append("worktree: true") + if agent.memory_policy: + lines.append(f"memory_policy: {agent.memory_policy}") + if agent.mcp: + lines.append("mcp: " + ", ".join(agent.mcp)) + if agent.display_metadata: + lines.append(f"display: {agent.display_metadata}") + return "\n".join(lines), report + + +# Agent emitter registry +_AGENT_EMITTERS: dict[str, Callable[[AgentIR, LossReport], tuple[str, LossReport]]] = { + "plain-agent": _emit_agent_plain, + "qwen-agent": _emit_agent_plain, + "zencoder-agent": _emit_agent_plain, + "zenflow-agent": _emit_agent_plain, + "factory-droid": _emit_agent_plain, + "gemini-agent": _emit_agent_plain, + "warp-agent": _emit_agent_plain, + "letta-agent": _emit_agent_plain, + "qoder-agent": _emit_agent_plain, + "gemini-code-assist-agent": _emit_agent_plain, + "amazon-q-agent": _emit_agent_plain, + "cursor-agent": _emit_agent_plain, + "factory-agent": _emit_agent_plain, +} + + +def emit_agent(agent: AgentIR, target_format: str) -> tuple[str, LossReport]: + handler = _AGENT_EMITTERS.get(target_format) + if handler: + return handler(agent, LossReport()) + if target_format == "plain-agent": + return _emit_agent_plain(agent, LossReport()) + raise ValueError(f"unsupported agent target format: {target_format}") + + +def _emit_hook_plain(hook: HookIR, report: LossReport) -> tuple[str, LossReport]: + lines = [ + f"event: {hook.event}", + f"matcher: {hook.matcher}", + f"command: {hook.command}", + ] + if hook.cwd: + lines.append(f"cwd: {hook.cwd}") + if hook.env: + lines.append("env: " + str(hook.env)) + if hook.stdin_schema: + lines.append(f"stdin_schema: {hook.stdin_schema}") + if hook.stdout_schema: + lines.append(f"stdout_schema: {hook.stdout_schema}") + if hook.blocking is not None: + lines.append(f"blocking: {hook.blocking}") + if hook.exit_code is not None: + lines.append(f"exit_code: {hook.exit_code}") + if hook.timeout_seconds is not None: + lines.append(f"timeout: {hook.timeout_seconds}") + if hook.async_run: + lines.append("async: true") + if hook.os_overrides: + for os_name, override in hook.os_overrides.items(): + lines.append(f"os:{os_name}: {override}") + if hook.target_script_references: + lines.append("scripts: " + ", ".join(hook.target_script_references)) + return "\n".join(lines), report + + +def _emit_hook_qwen(hook: HookIR, report: LossReport) -> tuple[str, LossReport]: + return _emit_hook_plain(hook, report) + + +def _emit_hook_cline(hook: HookIR, report: LossReport) -> tuple[str, LossReport]: + # Cline hooks are JSON with specific structure + hook_dict = { + "event": hook.event, + "matcher": hook.matcher, + "command": hook.command, + "enabled": False, # Always disabled per safety policy + } + if hook.cwd: + hook_dict["cwd"] = hook.cwd + if hook.env: + hook_dict["env"] = hook.env + if hook.stdin_schema: + hook_dict["stdin_schema"] = hook.stdin_schema + if hook.stdout_schema: + hook_dict["stdout_schema"] = hook.stdout_schema + if hook.blocking is not None: + hook_dict["blocking"] = hook.blocking + if hook.exit_code is not None: + hook_dict["exit_code"] = hook.exit_code + if hook.timeout_seconds is not None: + hook_dict["timeout"] = hook.timeout_seconds + if hook.async_run: + hook_dict["async"] = hook.async_run + if hook.os_overrides: + hook_dict["os_overrides"] = hook.os_overrides + if hook.target_script_references: + hook_dict["target_script_references"] = hook.target_script_references + return json.dumps(hook_dict, indent=2, sort_keys=True) + "\n", report + + +def _emit_hook_factory(hook: HookIR, report: LossReport) -> tuple[str, LossReport]: + return _emit_hook_plain(hook, report) + + +# Hook emitter registry +_HOOK_EMITTERS: dict[str, Callable[[HookIR, LossReport], tuple[str, LossReport]]] = { + "plain-hook": _emit_hook_plain, + "qwen-hook": _emit_hook_qwen, + "cline-hook": _emit_hook_cline, + "factory-hooks": _emit_hook_factory, + "zencoder-hook": _emit_hook_plain, + "zenflow-hook": _emit_hook_plain, + "factory-hook": _emit_hook_plain, + "warp-hook": _emit_hook_plain, + "gemini-hook": _emit_hook_plain, + "letta-hook": _emit_hook_plain, + "qoder-hook": _emit_hook_plain, + "gemini-code-assist-hook": _emit_hook_plain, + "amazon-q-hook": _emit_hook_plain, + "cursor-hook": _emit_hook_plain, + "factory-hooks": _emit_hook_plain, +} + + +def emit_hook(hook: HookIR, target_format: str) -> tuple[str, LossReport]: + handler = _HOOK_EMITTERS.get(target_format) + if handler: + return handler(hook, LossReport()) + if target_format == "plain-hook": + return _emit_hook_plain(hook, LossReport()) + raise ValueError(f"unsupported hook target format: {target_format}") + + +def scope_matches(surface_scope: str, requested_scope: str) -> bool: + """Match a surface's scope against a requested scope expression. + + ``requested_scope`` may be a single scope (``"user"``, ``"project"``, + ``"local"``, ``"all"``) or a comma-separated union + (``"user,project"``). The match is the union of the per-scope + checks; a surface matches if ANY requested scope matches it. + """ + scope_parts = set(surface_scope.split("+")) + if requested_scope == "all": + return "runtime" not in scope_parts + requested_parts = requested_scope.split(",") + for part in requested_parts: + part = part.strip() + if not part: + continue + if part == "user" and "user" in scope_parts: + return True + if part == "project" and bool( + scope_parts & {"project", "workspace", "repository"} + ): + return True + if part == "local" and "local" in scope_parts: + return True + if part in scope_parts: + return True + return False + + +def adapt_plugin_package( + source_path: Path, + target_format: str, +) -> tuple[str, LossReport]: + """Adapt a plugin package from source format to target format. + + Currently supports: + - factory-plugin: preserves .factory-plugin/ structure with commands/, + skills/, droids/, hooks/, mcp.json + - copilot-plugin: VS Code extension package format + - claude-plugin: Claude Code plugin format + + Returns rendered manifest/content and loss report. + """ + report = LossReport() + if target_format == "factory-plugin": + # Preserve the entire .factory-plugin/ directory structure + # Copy the entire .factory-plugin/ directory to target + source_plugin_dir = (source_path / ".factory-plugin").resolve() + if not source_plugin_dir.exists() or not source_plugin_dir.is_dir(): + report.add("plugin", ".factory-plugin", "missing .factory-plugin/ directory", None) + return "", report + + ensure_no_symlinks(source_plugin_dir) + files = [] + for f in sorted(source_plugin_dir.rglob("*")): + if f.is_file() and not f.is_symlink(): + rel = f.relative_to(source_plugin_dir) + if ".." not in str(rel) and not str(rel).startswith("/"): + files.append(str(rel)) + + manifest = { + "plugin_package": ".factory-plugin", + "files": sorted(files), + "preserved": True + } + return json.dumps(manifest, indent=2), report + if target_format == "preserve-package": + resolved_source = source_path.resolve() + ensure_no_symlinks(resolved_source) + files = [] + for f in sorted(resolved_source.rglob("*")): + if f.is_file() and not f.is_symlink(): + rel = f.relative_to(resolved_source) + if ".." not in str(rel) and not str(rel).startswith("/"): + files.append(str(rel)) + return json.dumps({"files": sorted(files)}, indent=2), report + raise ValueError(f"unsupported plugin target format: {target_format}") + + +def serialize_portable_handoff( + raw_data: Any, + workspace: Path | None = None, +) -> dict[str, Any]: + """Serialize ONLY strictly allowed portable handoff fields. + + Any un-whitelisted fields (history, conversation, events, tool_calls, + oauth_state, tokens, cwd, git_root, approval_state, session_state, + environment, machine paths, raw logs) are completely discarded (audit P0-4). + """ + summary = "" + selected_files: list[str] = [] + patch: str | None = None + + if isinstance(raw_data, dict): + raw_summary = raw_data.get("reviewed_summary") or raw_data.get("summary") or "" + if isinstance(raw_summary, str): + summary = raw_summary.strip() + raw_files = raw_data.get("selected_files") + if isinstance(raw_files, list): + for f in raw_files: + if isinstance(f, str) and not f.startswith("/") and ".." not in f: + selected_files.append(f) + raw_patch = raw_data.get("patch") + if isinstance(raw_patch, str): + patch = raw_patch + + git_branch: str | None = None + if workspace is not None: + git_info = git_provenance(workspace) + if git_info and git_info.get("branch"): + git_branch = str(git_info["branch"]) + + return { + "reviewed_summary": summary or "Reviewed handoff snapshot", + "git_branch": git_branch, + "selected_files": sorted(set(selected_files)), + "patch": patch, + } + + +def choose_surface( + surfaces: list[SurfacePath], + scope: str, + *, + source: bool = False, +) -> SurfacePath | None: + matching = [surface for surface in surfaces if scope_matches(surface.scope, scope)] + if not matching: + return None + if not source: + return next( + (surface for surface in matching if surface.location_role == "canonical"), + matching[0], + ) + existing = [surface for surface in matching if surface.resolved_path.exists()] + if len(existing) > 1: + # A genuine conflict only occurs when the duplicates share a SCOPE + # (e.g. two ``user`` surfaces). Distinct scopes (``user`` vs + # ``project``) are legitimate separate sources and must not abort a + # multi-scope restore (audit #3); the caller plans them per scope. + distinct_scopes = {surface.scope for surface in existing} + if len(distinct_scopes) == 1: + paths = ", ".join(str(surface.resolved_path) for surface in existing) + if all( + surface.location_role == "precedence" for surface in existing[1:] + ): + raise ValueError( + "multiple precedence instruction surfaces require manual reconstruction: " + + paths + ) + raise ValueError( + "source alias conflict requires explicit selection: " + paths + ) + # Different scopes: return the first existing source for a single + # per-object-type selection; multi-scope plans expand per scope. + return existing[0] + if existing: + return existing[0] + return next( + (surface for surface in matching if surface.location_role == "canonical"), + matching[0], + ) + + +def _skill_sources(surface: SurfacePath) -> list[Path]: + source = surface.resolved_path + if not source.is_dir(): + return [] + if (source / "SKILL.md").is_file(): + return [source] + return sorted( + child + for child in source.iterdir() + if child.is_dir() and (child / "SKILL.md").is_file() + ) + + +def _skill_environment_files(skill_dir: Path) -> list[Path]: + return sorted(path for path in skill_dir.rglob(".env*") if path.is_file()) + + +def _ignore_skill_environment_files(_directory: str, names: list[str]) -> list[str]: + return [name for name in names if name.startswith(".env")] + + +def preflight_skill_source(skill_dir: Path) -> None: + """Fail closed before copying a Skill that may contain literal credentials.""" + skill_document = skill_dir / "SKILL.md" + try: + text = skill_document.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise ValueError(f"cannot read Skill metadata for {skill_dir.name}: {error}") from error + match = FRONTMATTER.match(text) + if not match: + raise ValueError(f"invalid Skill metadata for {skill_dir.name}: missing frontmatter") + metadata: dict[str, str] = {} + for line in match.group(1).splitlines(): + if not line or line.startswith((" ", "\t")) or ":" not in line: + continue + key, value = line.split(":", 1) + metadata[key.strip()] = parse_scalar(value) + name = metadata.get("name", "") + description = metadata.get("description", "") + if not SKILL_NAME.fullmatch(name) or len(name) > 64: + raise ValueError(f"invalid Skill metadata for {skill_dir.name}: invalid name") + if not description or len(description) > 1024: + raise ValueError( + f"invalid Skill metadata for {skill_dir.name}: invalid description" + ) + try: + findings = scan_skill_source_tree(skill_dir) + except RuntimeError as error: + raise ValueError( + f"source credential preflight unavailable for {skill_dir.name}: {error}" + ) from error + if not findings: + return + details = "; ".join(f"{path}: {reason}" for path, reason in findings) + raise ValueError( + f"source credential preflight failed for {skill_dir.name}: {details}" + ) + + +def preflight_plan_skill_sources(plan: list[PlanItem]) -> None: + """Rescan every planned Skill before apply creates backups or target paths.""" + for item in plan: + if item.object_type != "skills" or item.status != "ready": + continue + assert item.source is not None + ensure_no_symlink_components( + item.source.resolved_path, + item.source.boundary, + ) + ensure_no_symlinks(item.source.resolved_path) + skill_dirs = _skill_sources(item.source) + if not skill_dirs: + raise ValueError( + f"source contains no Skill directories: {item.source.resolved_path}" + ) + for skill_dir in skill_dirs: + preflight_skill_source(skill_dir) + + +def preflight_plugin_source(plugin_dir: Path) -> None: + """Fail closed before copying a plugin package that may contain literal credentials or sensitive files.""" + ensure_no_symlinks(plugin_dir) + findings = scan_skill_source_tree(plugin_dir) + if findings: + details = "; ".join(f"{path}: {reason}" for path, reason in findings) + raise ValueError( + f"plugin package credential preflight failed for {plugin_dir.name}: {details}" + ) + + +def preflight_instruction_source(path: Path) -> None: + try: + data = path.read_bytes() + except OSError as error: + raise ValueError(f"cannot inspect instruction source {path}: {error}") from error + reason = finding_reason(data) + if reason: + raise ValueError( + f"instruction credential preflight failed for {path}: {reason}" + ) + + +def rebuild_actions( + target_selector: str, + object_type: str, + target_profile: dict[str, Any], +) -> list[str]: + sources = target_profile.get("sources", []) + actions = [ + f"Inventory the reviewed {object_type} content without credentials or generated state.", + f"Open the official {target_selector} API or UI and recreate only that reviewed content.", + "Bind secrets through the target secret manager or environment after reconstruction.", + "Verify native discovery, scope, precedence, and enablement in the target product.", + ] + if sources: + actions.insert(0, f"Review current target documentation: {sources[0]}") + return actions + + +def _build_plan_for_scope( + registry: Registry, + source_selector: str, + target_selector: str, + object_types: list[str], + scope: str, + target_registry: Registry | None = None, +) -> tuple[list[PlanItem], LossReport]: + target_reg = target_registry if target_registry is not None else registry + _, _, source_profile = registry.profile(source_selector) + _, _, target_profile = target_reg.profile(target_selector) + source_policy = source_profile.get("migration_policy", "manual-rebuild") + target_policy = target_profile.get("migration_policy", "manual-rebuild") + source_automatic = source_policy in AUTOMATIC_MIGRATION_POLICIES | {"source-only"} + target_automatic = target_policy in AUTOMATIC_MIGRATION_POLICIES + items: list[PlanItem] = [] + losses = LossReport() + for object_type in object_types: + source_error: ValueError | None = None + try: + source = choose_surface( + registry.surfaces(source_selector, object_type), + scope, + source=True, + ) + except ValueError as error: + source = None + source_error = error + target = choose_surface( + target_reg.surfaces(target_selector, object_type), scope + ) + if source_error is not None: + items.append( + PlanItem( + object_type, + "manual-rebuild", + str(source_error), + target=target, + manual_actions=rebuild_actions( + target_selector, object_type, target_profile + ), + ) + ) + continue + if target_policy == "official-api-or-rebuild-checklist": + items.append( + PlanItem( + object_type, + "manual-rebuild", + "target is cloud/UI-managed and requires reviewed reconstruction", + source=source, + target=target, + manual_actions=rebuild_actions( + target_selector, object_type, target_profile + ), + ) + ) + continue + if target_policy == "configure-consuming-client": + items.append( + PlanItem( + object_type, + "manual-rebuild", + "target is a provider; configure it in the consuming client", + source=source, + target=target, + manual_actions=rebuild_actions( + target_selector, object_type, target_profile + ), + ) + ) + continue + if target_policy in {"manual-rebuild", "alias-only"}: + items.append( + PlanItem( + object_type, + "manual-rebuild", + f"target profile policy is {target_policy}", + source=source, + target=target, + manual_actions=rebuild_actions( + target_selector, object_type, target_profile + ), + ) + ) + continue + if target_policy == "source-only": + items.append( + PlanItem( + object_type, + "invalid", + "target profile is source-only", + source=source, + target=target, + ) + ) + continue + if not target_automatic: + items.append( + PlanItem( + object_type, + "invalid", + f"unknown target migration policy: {target_policy}", + source=source, + target=target, + ) + ) + continue + if not source_automatic: + items.append( + PlanItem( + object_type, + "invalid", + f"source profile policy is {source_policy}", + source=source, + target=target, + ) + ) + continue + if source is None: + items.append(PlanItem(object_type, "invalid", "source surface is not mapped")) + continue + if target is None: + items.append( + PlanItem(object_type, "manual-rebuild", "target surface is not mapped", source=source) + ) + continue + try: + ensure_no_symlink_components(source.resolved_path, source.boundary) + ensure_no_symlink_components(target.resolved_path, target.boundary) + if source.resolved_path.exists(): + ensure_no_symlinks(source.resolved_path) + except ValueError as error: + items.append( + PlanItem(object_type, "invalid", str(error), source, target) + ) + continue + if not source.resolved_path.exists(): + items.append( + PlanItem( + object_type, + "invalid", + f"source path does not exist: {source.resolved_path}", + source, + target, + ) + ) + continue + is_opt_in_surface = ( + (object_type == "plugins" and source.policy == "preserve-package" and target.policy == "preserve-package") + or (object_type == "handoff" and source.policy in {"session-summary-handoff", "preserve-package"} and target.policy in {"session-summary-handoff", "preserve-package"}) + ) + if source.policy not in SOURCE_AUTOMATIC_SURFACE_POLICIES and not is_opt_in_surface: + items.append( + PlanItem( + object_type, + "manual-rebuild", + f"source surface policy is {source.policy}", + source, + target, + rebuild_actions(target_selector, object_type, target_profile), + ) + ) + continue + if target.policy in {"source-only", "forbidden-regenerate"}: + items.append( + PlanItem( + object_type, + "invalid", + f"target surface policy is {target.policy}", + source, + target, + ) + ) + continue + if target.policy in { + "manual-rebuild", + "manual-template", + "disabled-draft-only", + "official-api-or-rebuild-checklist", + } and not is_opt_in_surface: + items.append( + PlanItem( + object_type, + "manual-rebuild", + f"target surface policy is {target.policy}", + source, + target, + rebuild_actions(target_selector, object_type, target_profile), + ) + ) + continue + if object_type == "skills": + skill_dirs = _skill_sources(source) + if not skill_dirs: + items.append( + PlanItem( + object_type, + "invalid", + "source contains no Skill directories", + source, + target, + ) + ) + continue + try: + for skill_dir in skill_dirs: + preflight_skill_source(skill_dir) + for env_path in _skill_environment_files(skill_dir): + losses.add( + "skills", + f"{skill_dir.name}/{env_path.relative_to(skill_dir)}", + "environment file excluded from Skill copy", + None, + ) + except ValueError as error: + items.append( + PlanItem(object_type, "invalid", str(error), source, target) + ) + continue + if object_type == "instructions": + missing_adapters = sorted( + { + source.source_format, + target.source_format, + } + - set(FORMAT_FEATURES) + ) + if missing_adapters: + items.append( + PlanItem( + object_type, + "manual-rebuild", + "instruction formats require dedicated adapters: " + + ", ".join(missing_adapters), + source, + target, + rebuild_actions(target_selector, object_type, target_profile), + ) + ) + continue + instruction_paths = _instruction_sources(source) + if not instruction_paths: + items.append( + PlanItem( + object_type, + "invalid", + "source contains no instruction files", + source, + target, + ) + ) + continue + try: + for instruction_path in instruction_paths: + preflight_instruction_source(instruction_path) + instruction = parse_instruction( + instruction_path.read_text(encoding="utf-8"), + source.source_format, + source.scope, + source.storage, + ) + _, report = emit_instruction(instruction, target.source_format) + losses.items.extend(report.items) + except (OSError, UnicodeError, ValueError) as error: + items.append( + PlanItem( + object_type, + ( + "invalid" + if "credential preflight failed" in str(error) + else "manual-rebuild" + ), + f"instruction adapter validation failed: {error}", + source, + target, + ( + [] + if "credential preflight failed" in str(error) + else rebuild_actions( + target_selector, object_type, target_profile + ) + ), + ) + ) + continue + if object_type == "mcp" and ( + not mcp_adapter(source.source_format)["automatic"] + or not mcp_adapter(target.source_format)["automatic"] + ): + source_adapter = mcp_adapter(source.source_format) + target_adapter = mcp_adapter(target.source_format) + items.append( + PlanItem( + object_type, + "manual-rebuild", + ( + f"MCP adapters require review: source={source_adapter['name']} " + f"target={target_adapter['name']}" + ), + source, + target, + [ + str(source_adapter.get("reason", "review source adapter")), + str(target_adapter.get("reason", "review target adapter")), + "Generate a credential-free target snippet and validate it with the native product.", + ], + ) + ) + continue + if object_type == "mcp": + if not source.resolved_path.is_file(): + items.append( + PlanItem(object_type, "invalid", "MCP source must be a file", source, target) + ) + continue + try: + servers = parse_mcp_document( + source.resolved_path.read_text(encoding="utf-8"), + source.source_format, + ) + if any(server.transport != "stdio" for server in servers): + items.append( + PlanItem( + object_type, + "manual-rebuild", + "remote MCP requires a dedicated target-profile transport adapter", + source, + target, + rebuild_actions( + target_selector, object_type, target_profile + ), + ) + ) + continue + existing = ( + target.resolved_path.read_text(encoding="utf-8") + if target.resolved_path.is_file() + else None + ) + _, report = emit_mcp_document(servers, target.source_format, existing) + losses.items.extend(report.items) + except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as error: + items.append( + PlanItem( + object_type, + "invalid", + f"MCP validation failed: {error}", + source, + target, + ) + ) + continue + items.append(PlanItem(object_type, "ready", "review before apply", source, target)) + return items, losses + + +def build_plan( + registry: Registry, + source_selector: str, + target_selector: str, + object_types: list[str], + scope: str, + target_registry: Registry | None = None, +) -> tuple[list[PlanItem], LossReport]: + target_reg = target_registry if target_registry is not None else registry + # Audit #3: a comma-separated union (e.g. "user,project") must plan every + # requested scope, not collapse them into a single per-object-type item. + # Reuse the same per-scope expansion already used by "all". + if scope != "all" and "," not in scope: + return _build_plan_for_scope( + registry, + source_selector, + target_selector, + object_types, + scope, + target_registry=target_reg, + ) + requested_scopes = ( + tuple(s.strip() for s in scope.split(",") if s.strip()) + if scope != "all" + else ("user", "project", "local") + ) + combined_items: list[PlanItem] = [] + combined_losses = LossReport() + seen_items: set[str] = set() + seen_losses: set[str] = set() + for requested_scope in requested_scopes: + scoped_items, scoped_losses = _build_plan_for_scope( + registry, + source_selector, + target_selector, + object_types, + requested_scope, + target_registry=target_reg, + ) + for item in scoped_items: + if item.source is None and item.target is None: + continue + if item.status != "ready": + item.reason = f"{requested_scope}: {item.reason}" + key = canonical_json(item.to_dict()) + if key not in seen_items: + seen_items.add(key) + combined_items.append(item) + for loss in scoped_losses.items: + key = canonical_json(asdict(loss)) + if key not in seen_losses: + seen_losses.add(key) + combined_losses.items.append(loss) + planned_objects = {item.object_type for item in combined_items} + for object_type in object_types: + if object_type not in planned_objects: + combined_items.append( + PlanItem( + object_type, + "invalid", + "no matching source or target surface in requested scope(s)", + ) + ) + return combined_items, combined_losses + + +def hash_path(path: Path) -> str: + digest = hashlib.sha256() + if path.is_file(): + digest.update(path.read_bytes()) + return digest.hexdigest() + if path.is_dir(): + for child in sorted(path.rglob("*")): + if child.is_symlink(): + raise ValueError(f"symbolic links are not allowed: {child}") + if child.is_file(): + digest.update(str(child.relative_to(path)).encode("utf-8")) + digest.update(b"\0") + digest.update(child.read_bytes()) + return digest.hexdigest() + raise ValueError(f"cannot hash missing path: {path}") + + +def canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def json_sha256(value: Any) -> str: + return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +def path_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"exists": False, "kind": None, "sha256": None} + ensure_no_symlinks(path) + return { + "exists": True, + "kind": "directory" if path.is_dir() else "file", + "sha256": hash_path(path), + } + + +def paths_overlap(first: Path, second: Path) -> bool: + first_resolved = first.resolve(strict=False) + second_resolved = second.resolve(strict=False) + return ( + first_resolved == second_resolved + or first_resolved in second_resolved.parents + or second_resolved in first_resolved.parents + ) + + +def _git_output(workspace: Path, *arguments: str) -> str | None: + completed = subprocess.run( + ["git", "-C", str(workspace), *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if completed.returncode != 0: + return None + return completed.stdout.strip() + + +def git_provenance(workspace: Path) -> dict[str, Any] | None: + root = _git_output(workspace, "rev-parse", "--show-toplevel") + head = _git_output(workspace, "rev-parse", "HEAD") + if not root or not head: + return None + branch = _git_output(workspace, "branch", "--show-current") + status = _git_output(workspace, "status", "--porcelain=v1") + return { + "repository_root": root, + "head": head, + "branch": branch or None, + "dirty": bool(status), + } + + +def _unified_diff(existing: str, rendered: str, target: Path) -> str: + raw_diff = "".join( + difflib.unified_diff( + existing.splitlines(keepends=True), + rendered.splitlines(keepends=True), + fromfile=f"a/{target}", + tofile=f"b/{target}", + n=1, + ) + ) + safe_lines: list[str] = [] + for line in raw_diff.splitlines(keepends=True): + if line.startswith(("+++", "---", "@@")) or not line.startswith(("+", "-")): + safe_lines.append(line) + continue + if finding_reason(line[1:].encode("utf-8")): + ending = "\n" if line.endswith("\n") else "" + safe_lines.append(f"{line[0]}[REDACTED CREDENTIAL LINE]{ending}") + else: + safe_lines.append(line) + return "".join(safe_lines) + + +def _preview_plan_item(item: PlanItem) -> dict[str, Any] | None: + if item.status != "ready" or item.source is None or item.target is None: + return None + source = item.source + target = item.target + if item.object_type == "skills": + changes = [] + for skill_dir in _skill_sources(source): + destination = ( + target.resolved_path + if target.resolved_path.name == skill_dir.name + else target.resolved_path / skill_dir.name + ) + changes.append( + { + "path": str(destination), + "action": "replace" if destination.exists() else "create", + "source_sha256": hash_path(skill_dir), + "target_sha256": hash_path(destination) if destination.exists() else None, + } + ) + return {"kind": "file-list", "changes": changes} + if item.object_type == "instructions": + previews = [] + sources = _instruction_sources(source) + for index, instruction_path in enumerate(sources): + instruction = parse_instruction( + instruction_path.read_text(encoding="utf-8"), + source.source_format, + source.scope, + source.storage, + ) + rendered, _ = emit_instruction(instruction, target.source_format) + if _instruction_target_is_file(target): + destination = target.resolved_path + else: + destination = _instruction_target_path( + target, instruction_path, item.object_id + ) + existing = ( + destination.read_text(encoding="utf-8") + if destination.is_file() + else "" + ) + previews.append( + { + "path": str(destination), + "action": "replace" if destination.exists() else "create", + "pre_sha256": hash_path(destination) if destination.exists() else None, + "post_sha256": hashlib.sha256(rendered.encode("utf-8")).hexdigest(), + "diff": _unified_diff(existing, rendered, destination), + } + ) + return {"kind": "unified-diff", "changes": previews} + if item.object_type == "mcp": + source_text = source.resolved_path.read_text(encoding="utf-8") + servers = parse_mcp_document(source_text, source.source_format) + existing = ( + target.resolved_path.read_text(encoding="utf-8") + if target.resolved_path.is_file() + else "" + ) + rendered, _ = emit_mcp_document( + servers, + target.source_format, + existing or None, + ) + old_servers: set[str] = set() + if existing: + try: + old_servers = { + server.name + for server in parse_mcp_document(existing, target.source_format) + } + except ValueError: + old_servers = set() + new_servers = {server.name for server in servers} + return { + "kind": "mcp-semantic-diff", + "changes": [ + { + "path": str(target.resolved_path), + "action": "replace" if target.resolved_path.exists() else "create", + "pre_sha256": ( + hash_path(target.resolved_path) + if target.resolved_path.exists() + else None + ), + "post_sha256": hashlib.sha256(rendered.encode("utf-8")).hexdigest(), + "server_names_before": sorted(old_servers), + "server_names_after": sorted(new_servers), + "added": sorted(new_servers - old_servers), + "removed": sorted(old_servers - new_servers), + "credential_values_included": False, + } + ], + } + return None + + +def _plan_hash_payload(plan_document: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in plan_document.items() if key != "plan_sha256"} + + +def build_plan_document( + registry: Registry, + source_selector: str, + target_selector: str, + object_types: list[str], + scope: str, + target_registry: Registry | None = None, +) -> dict[str, Any]: + target_reg = target_registry if target_registry is not None else registry + items, losses = build_plan( + registry, + source_selector, + target_selector, + object_types, + scope, + target_registry=target_reg, + ) + _, source_profile_id, source_profile = registry.profile(source_selector) + _, target_profile_id, target_profile = target_reg.profile(target_selector) + serialized_items: list[dict[str, Any]] = [] + for item in items: + serialized = item.to_dict() + serialized["source_state"] = ( + path_state(item.source.resolved_path) if item.source else None + ) + serialized["target_state"] = ( + path_state(item.target.resolved_path) if item.target else None + ) + serialized["review_preview"] = _preview_plan_item(item) + serialized_items.append(serialized) + rebuild_items = [ + { + "object_type": item.object_type, + "reason": item.reason, + "actions": item.manual_actions, + } + for item in items + if item.status_enum is ItemStatus.MANUAL_REBUILD + ] + document: dict[str, Any] = { + "schema_version": PLAN_SCHEMA_VERSION, + "created_at": datetime.now(timezone.utc).isoformat(), + "workspace": str(target_reg.workspace), + "source": source_selector, + "source_profile": source_profile_id, + "source_support_level": source_profile.get("support_level"), + "target": target_selector, + "target_profile": target_profile_id, + "target_support_level": target_profile.get("support_level"), + "scope": scope, + "objects": object_types, + "registry_sha256": hash_path(target_reg.path), + "adapter_versions": ADAPTER_VERSIONS, + "git_provenance": git_provenance(target_reg.workspace), + "items": serialized_items, + "loss_report": losses.to_dict(), + "rebuild_manifest": { + "credential_policy": "references-only; never include literal credentials", + "items": rebuild_items, + }, + } + document["plan_sha256"] = json_sha256(_plan_hash_payload(document)) + return document + + +def load_plan_document(path: Path) -> dict[str, Any]: + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or document.get("schema_version") != PLAN_SCHEMA_VERSION: + raise ValueError("unsupported plan document") + expected = document.get("plan_sha256") + if not isinstance(expected, str) or expected != json_sha256(_plan_hash_payload(document)): + raise ValueError("plan checksum mismatch") + return document + + +def _normalize_surface_for_comparison( + surface_dict: dict[str, Any] | None, +) -> dict[str, Any] | None: + if not surface_dict: + return None + keys = ( + "product", + "profile", + "object_type", + "scope", + "storage", + "path", + "canonical_path", + "source_format", + "policy", + "location_role", + "precedence", + ) + return {k: surface_dict.get(k) for k in keys} + + +def _normalize_target_for_comparison( + target_dict: dict[str, Any] | None, +) -> dict[str, Any] | None: + if not target_dict: + return None + keys = ( + "product", + "profile", + "object_type", + "scope", + "storage", + "path", + "canonical_path", + "resolved_path", + "source_format", + "policy", + "location_role", + "precedence", + ) + return {k: target_dict.get(k) for k in keys} + + +def _core_plan_items(serialized_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + result = [] + for item in serialized_items: + result.append( + { + "object_type": item.get("object_type"), + "status": item.get("status"), + "reason": item.get("reason"), + "source": _normalize_surface_for_comparison(item.get("source")), + "target": _normalize_target_for_comparison(item.get("target")), + "manual_actions": item.get("manual_actions"), + "object_id": item.get("object_id"), + } + ) + return result + + +def validate_plan_document( + document: dict[str, Any], + registry: Registry, + source_registry: Registry | None = None, +) -> tuple[list[PlanItem], LossReport]: + src_reg = source_registry if source_registry is not None else registry + if document.get("adapter_versions") != ADAPTER_VERSIONS: + raise ValueError("plan adapter versions do not match this runtime") + if document.get("registry_sha256") != hash_path(registry.path): + raise ValueError("registry changed after plan review") + if document.get("workspace") != str(registry.workspace): + raise ValueError("plan workspace does not match the selected workspace") + stored_git = document.get("git_provenance") + current_git = git_provenance(registry.workspace) + if stored_git is not None: + if not isinstance(stored_git, dict): + raise ValueError("plan Git provenance must be an object") + if current_git is None: + raise ValueError("Git repository is unavailable after plan review") + for field in ("repository_root", "head"): + if stored_git.get(field) != current_git.get(field): + raise ValueError(f"Git {field} changed after plan review") + object_types = document.get("objects") + if not isinstance(object_types, list) or not all( + isinstance(item, str) for item in object_types + ): + raise ValueError("plan objects must be an array of strings") + source_sel = str(document.get("source")) + target_sel = str(document.get("target")) + stored_items = document.get("items") + if not isinstance(stored_items, list) or not all( + isinstance(item, dict) for item in stored_items + ): + raise ValueError("plan items must be an array") + + if source_sel in ("all-installed", "auto") or target_sel in ("all-installed", "auto"): + items: list[PlanItem] = [] + for stored in stored_items: + src_dict = stored.get("source") + tgt_dict = stored.get("target") + src_surf = ( + SurfacePath( + product=src_dict["product"], + profile=src_dict["profile"], + object_type=src_dict["object_type"], + scope=src_dict["scope"], + storage=src_dict["storage"], + path=src_dict["path"], + resolved_path=Path(src_dict["resolved_path"]), + boundary=Path(src_dict["boundary"]), + source_format=src_dict["source_format"], + policy=src_dict["policy"], + location_role=src_dict.get("location_role", "canonical"), + canonical_path=src_dict.get("canonical_path", src_dict["path"]), + precedence=src_dict.get("precedence", 0), + ) + if src_dict + else None + ) + tgt_surf = ( + SurfacePath( + product=tgt_dict["product"], + profile=tgt_dict["profile"], + object_type=tgt_dict["object_type"], + scope=tgt_dict["scope"], + storage=tgt_dict["storage"], + path=tgt_dict["path"], + resolved_path=Path(tgt_dict["resolved_path"]), + boundary=Path(tgt_dict["boundary"]), + source_format=tgt_dict["source_format"], + policy=tgt_dict["policy"], + location_role=tgt_dict.get("location_role", "canonical"), + canonical_path=tgt_dict.get("canonical_path", tgt_dict["path"]), + precedence=tgt_dict.get("precedence", 0), + ) + if tgt_dict + else None + ) + item = PlanItem( + object_type=stored["object_type"], + status=stored["status"], + reason=stored["reason"], + source=src_surf, + target=tgt_surf, + manual_actions=stored.get("manual_actions", []), + object_id=stored.get("object_id", ""), + ) + if item.source is not None: + if stored.get("source_state") != path_state(item.source.resolved_path): + raise ValueError( + f"source changed after plan review: {item.source.resolved_path}" + ) + item.expected_source_state = stored.get("source_state") + if item.target is not None: + if stored.get("target_state") != path_state(item.target.resolved_path): + raise ValueError( + f"target changed after plan review: {item.target.resolved_path}" + ) + item.expected_target_state = stored.get("target_state") + items.append(item) + return items, LossReport() + + items, losses = build_plan( + src_reg, + source_sel, + target_sel, + object_types, + str(document.get("scope")), + target_registry=registry, + ) + if _core_plan_items(stored_items) != _core_plan_items([item.to_dict() for item in items]): + raise ValueError("resolved plan changed after review") + for stored, item in zip(stored_items, items): + if item.source is not None: + if stored.get("source_state") != path_state(item.source.resolved_path): + raise ValueError( + f"source changed after plan review: {item.source.resolved_path}" + ) + item.expected_source_state = stored.get("source_state") + if item.target is not None: + if stored.get("target_state") != path_state(item.target.resolved_path): + raise ValueError( + f"target changed after plan review: {item.target.resolved_path}" + ) + item.expected_target_state = stored.get("target_state") + return items, losses + + +def ensure_no_symlinks(path: Path) -> None: + if path.is_symlink(): + raise ValueError(f"symbolic links are not allowed: {path}") + if path.is_dir(): + for child in path.rglob("*"): + if child.is_symlink(): + raise ValueError(f"symbolic links are not allowed: {child}") + + +def ensure_no_symlink_components(path: Path, boundary: Path) -> None: + try: + relative = path.relative_to(boundary) + except ValueError as error: + raise ValueError(f"path escapes its migration boundary: {path}") from error + candidate = boundary + if candidate.is_symlink(): + raise ValueError(f"symbolic links are not allowed: {candidate}") + for part in relative.parts: + candidate = candidate / part + if candidate.is_symlink(): + raise ValueError(f"symbolic links are not allowed: {candidate}") + + +def atomic_write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def backup_path(target: Path, backup_root: Path, sequence: int) -> Path | None: + if not target.exists(): + return None + ensure_no_symlinks(target) + backup = backup_root / f"{sequence:04d}-{hashlib.sha256(str(target).encode()).hexdigest()[:12]}" + backup.parent.mkdir(parents=True, exist_ok=True) + if target.is_dir(): + shutil.copytree(target, backup) + else: + shutil.copy2(target, backup) + return backup + + +def begin_change( + changes: list[dict[str, Any]], + target: Path, + backup: Path | None, + boundary: Path, +) -> dict[str, Any]: + if target == boundary: + raise ValueError(f"refusing to record a migration boundary as a target: {target}") + change = { + "path": str(target), + "boundary": str(boundary), + "kind": None, + "backup": str(backup) if backup else None, + "created": backup is None, + "post_sha256": None, + } + changes.append(change) + return change + + +def finish_change(change: dict[str, Any], target: Path) -> None: + if not target.exists() or target.is_symlink(): + raise ValueError(f"migration target was not written safely: {target}") + change["kind"] = "directory" if target.is_dir() else "file" + change["post_sha256"] = hash_path(target) + + +def restore_transaction_change(change: dict[str, Any]) -> None: + target = Path(str(change["path"])) + boundary = Path(str(change["boundary"])) + ensure_no_symlink_components(target, boundary) + if target.is_symlink(): + target.unlink() + elif target.is_dir(): + shutil.rmtree(target) + else: + target.unlink(missing_ok=True) + backup_value = change.get("backup") + if not backup_value: + return + backup = Path(str(backup_value)) + ensure_no_symlinks(backup) + target.parent.mkdir(parents=True, exist_ok=True) + if backup.is_dir(): + shutil.copytree(backup, target) + else: + shutil.copy2(backup, target) + + +def _instruction_sources(surface: SurfacePath) -> list[Path]: + source = surface.resolved_path + if source.is_file(): + return [source] + if source.is_dir(): + return sorted(path for path in source.rglob("*.md*") if path.is_file()) + return [] + + +def _instruction_target_is_file(surface: SurfacePath) -> bool: + if surface.storage in {"file", "precedence-files", "config-subobject"}: + return True + return surface.resolved_path.suffix.lower() in {".md", ".mdc", ".txt"} + + +def _instruction_target_extension(target: SurfacePath) -> str: + if target.source_format == "cursor-mdc": + return ".mdc" + return ".md" + + +def _instruction_target_path( + target: SurfacePath, + source_path: Path, + object_id: str, + used_targets: set[Path] | None = None, +) -> Path: + """Pick a stable, collision-aware destination path for a directory-style + instruction target. + + Preference order: + + 1. Preserve the source basename with the target extension. + 2. On collision (filesystem or ``used_targets``), append + ``-`` before the extension. + 3. Last resort: ``migrated-.md`` style; collision fallback is + only reached when ``object_id`` is empty (e.g. legacy plans + without source).""" + suffix = _instruction_target_extension(target) + used = used_targets or set() + if object_id: + primary = target.resolved_path / f"{source_path.stem}{suffix}" + if primary not in used and not primary.exists(): + return primary + short = object_id[:6] + suffix_with_id = f"-{short}{suffix}" + return target.resolved_path / f"{source_path.stem}{suffix_with_id}" + return target.resolved_path / f"migrated-{source_path.stem}{suffix}" + + +def _manifest_entry( + item: PlanItem, + item_id: str, + outcome: str, + index: int, +) -> dict[str, Any]: + """Build a manifest record for one plan item. + + ``outcome`` is the runtime disposition (``"applied"``, + ``"applied-lossy"``, ``"lossy-skipped"``, ``"draft-written"``, + ``"manual-rebuild"``, ``"forbidden"``, ``"conflict"``, + ``"invalid"``, ``"blocked-by-group"``, ...). ``index`` is the + original plan position (used for stable ordering). + """ + entry: dict[str, Any] = { + "object_id": item_id, + "plan_index": index, + "object_type": item.object_type, + "status": item.status, + "outcome": outcome, + "reason": item.reason, + "target_group": item.target_group, + "source": item.source.to_dict() if item.source else None, + "target": item.target.to_dict() if item.target else None, + "manual_actions": list(item.manual_actions), + } + if outcome == "draft-written": + entry["enabled"] = False + return entry + + +def _build_manifest( + *, + operation_id: str, + workspace: Path, + provenance: dict[str, Any], + changes: list[dict[str, Any]], + loss_report: LossReport, + items: list[dict[str, Any]], + blockers: set[str], + apply_safe: bool, + include_lossy: bool, + strict: bool, +) -> dict[str, Any]: + """Assemble the final manifest document (no I/O).""" + summary: dict[str, int] = {} + for entry in items: + summary[entry["outcome"]] = summary.get(entry["outcome"], 0) + 1 + manifest = { + "schema_version": MANIFEST_SCHEMA_VERSION, + "operation_id": operation_id, + "created_at": datetime.now(timezone.utc).isoformat(), + "workspace": str(workspace), + "provenance": provenance, + "changes": changes, + "loss_report": loss_report.to_dict(), + "items": items, + "blockers": [ + { + "target_group": group, + "reason": "conflict or invalid item in target group", + } + for group in sorted(blockers) + ], + "summary": summary, + "apply_safe": apply_safe, + "include_lossy": include_lossy, + "strict": strict, + } + manifest["manifest_sha256"] = json_sha256( + { + key: value + for key, value in manifest.items() + if key != "manifest_sha256" + } + ) + return manifest + + +def apply_plan( + plan: list[PlanItem], + workspace: Path, + manifest_path: Path | None = None, + provenance: dict[str, Any] | None = None, + *, + apply_safe: bool = True, + include_lossy: bool = False, + accept_loss_ids: set[str] | None = None, + strict: bool = False, + allow_plugin_copy: bool = False, + allow_session_handoff: bool = False, +) -> tuple[dict[str, Any], Path]: + """Apply a plan with the partial safe flow. + + Status dispatch: + + * ``ready``: always applied. + * ``ready-lossy``: applied when ``include_lossy`` is set or when the + item is named in ``accept_loss_ids``. Otherwise recorded as + ``lossy-skipped``. + * ``draft-disabled``: applied (write with ``enabled=false`` recorded + in the manifest). Caller is responsible for explicit activation. + * ``manual-rebuild`` / ``forbidden``: recorded only. + * ``conflict`` / ``invalid``: block only their own ``target_group``; + other groups proceed. + + Only object types in ``AUTO_WRITABLE_OBJECT_TYPES`` have a staging + writer. Hooks and other executable surfaces are never written to + live product paths (audit SDI-4); handoff/session artifacts require + the explicit ``allow_session_handoff=True`` opt-in, which the CLI + exposes as ``--include-session`` (audit SDI-2). + + When ``strict`` is set, any non-``ready`` item aborts the whole plan + (legacy behavior preserved for callers that want it). + """ + if strict: + blocked = [item for item in plan if item.status_enum is not ItemStatus.READY] + if blocked: + summary = ", ".join( + f"{item.object_type}:{item.status}" for item in blocked + ) + raise ValueError(f"plan contains non-applicable items: {summary}") + + accept_loss_ids = accept_loss_ids or set() + target_groups_blocked: set[str] = set() + for item in plan: + status = item.status_enum + if status in (ItemStatus.CONFLICT, ItemStatus.INVALID): + group = item.target_group + if group is not None: + target_groups_blocked.add(group) + + loss_report = LossReport() + eligible_items: list[PlanItem] = [] + deferred_items: list[PlanItem] = [] + blocked_items: list[PlanItem] = [] + manifest_items: list[dict[str, Any]] = [] + for index, item in enumerate(plan): + status = item.status_enum + item_id = f"{index}:{item.object_type}" + if status is ItemStatus.READY and item.target_group in target_groups_blocked: + blocked_items.append(item) + manifest_items.append( + _manifest_entry(item, item_id, "blocked-by-group", index) + ) + continue + if status is ItemStatus.READY: + eligible_items.append(item) + continue + if status is ItemStatus.READY_LOSSY: + if include_lossy or item_id in accept_loss_ids: + eligible_items.append(item) + manifest_items.append( + _manifest_entry(item, item_id, "applied-lossy", index) + ) + continue + if not apply_safe: + blocked_items.append(item) + manifest_items.append( + _manifest_entry(item, item_id, "lossy-not-accepted", index) + ) + continue + deferred_items.append(item) + manifest_items.append( + _manifest_entry(item, item_id, "lossy-skipped", index) + ) + continue + if status is ItemStatus.DRAFT_DISABLED: + # Only stage for object types we know how to render. Other + # draft-disabled surfaces (e.g. Hooks, Agents) are recorded + # in the manifest with their target path; the user enables + # them out-of-band. + if item.object_type in {"skills", "instructions", "mcp"}: + eligible_items.append(item) + continue + deferred_items.append(item) + manifest_items.append( + _manifest_entry(item, item_id, "draft-only", index) + ) + continue + if status in _NON_WRITE_STATUSES: + deferred_items.append(item) + manifest_items.append( + _manifest_entry(item, item_id, item.status, index) + ) + continue + # Unknown enum value (defensive): treat as blocked. + blocked_items.append(item) + manifest_items.append( + _manifest_entry(item, item_id, f"unknown:{item.status}", index) + ) + + if not eligible_items: + # Emit an informational manifest so callers (e.g. the migrate + # pipeline) always get a stable artifact path; record every + # item as deferred without staging any writes. + operation_id = ( + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + f"-{os.getpid()}-{uuid.uuid4().hex[:10]}" + ) + workspace_resolved = workspace.resolve() + state_root = workspace_resolved / ".agent-context-migration" + ensure_no_symlink_components(state_root, workspace_resolved) + manifest_path_resolved = ( + manifest_path.resolve(strict=False) + if manifest_path is not None + else state_root / "manifests" / f"{operation_id}.json" + ) + manifest = _build_manifest( + operation_id=operation_id, + workspace=workspace_resolved, + provenance=provenance or {}, + changes=[], + loss_report=loss_report, + items=manifest_items, + blockers=target_groups_blocked, + apply_safe=apply_safe, + include_lossy=include_lossy, + strict=strict, + ) + atomic_write( + manifest_path_resolved, + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + ) + return manifest, manifest_path_resolved + + preflight_plan_skill_sources(eligible_items) + workspace = workspace.resolve() + operation_id = ( + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + f"-{os.getpid()}-{uuid.uuid4().hex[:10]}" + ) + state_root = workspace / ".agent-context-migration" + ensure_no_symlink_components(state_root, workspace) + backup_root = state_root / "backups" / operation_id + manifest_path = ( + manifest_path.resolve(strict=False) + if manifest_path is not None + else state_root / "manifests" / f"{operation_id}.json" + ) + if manifest_path.exists() or manifest_path.is_symlink(): + raise ValueError(f"manifest path already exists: {manifest_path}") + changes: list[dict[str, Any]] = [] + operations: list[dict[str, Any]] = [] + used_targets: set[Path] = set() + with tempfile.TemporaryDirectory(prefix="agent-context-migration-stage.") as stage_name: + stage_root = Path(stage_name) + for item in eligible_items: + assert item.source is not None and item.target is not None + source = item.source + target = item.target + ensure_no_symlink_components(source.resolved_path, source.boundary) + ensure_no_symlink_components(target.resolved_path, target.boundary) + ensure_no_symlinks(source.resolved_path) + if not source.resolved_path.exists(): + raise ValueError(f"source does not exist: {source.resolved_path}") + if item.object_type == "skills": + if not source.resolved_path.is_dir(): + raise ValueError("skills source must be a directory") + for child in _skill_sources(source): + staged = stage_root / f"{len(operations):04d}-{child.name}" + excluded_env_files = _skill_environment_files(child) + for env_path in excluded_env_files: + loss_report.add( + "skills", + f"{child.name}/{env_path.relative_to(child)}", + "environment file excluded from Skill copy", + None, + ) + shutil.copytree( + child, + staged, + ignore=_ignore_skill_environment_files, + symlinks=True, + ) + ensure_no_symlinks(staged) + preflight_skill_source(staged) + destination = ( + target.resolved_path + if target.resolved_path.name == child.name + else target.resolved_path / child.name + ) + operations.append( + { + "kind": "directory", + "staged": staged, + "destination": destination, + "boundary": target.boundary, + } + ) + elif item.object_type == "instructions": + sources = _instruction_sources(source) + if not sources: + raise ValueError( + f"no instruction files found: {source.resolved_path}" + ) + if _instruction_target_is_file(target) and len(sources) > 1: + raise ValueError( + "multiple instructions cannot be merged into one target file" + ) + for index, instruction_path in enumerate(sources): + preflight_instruction_source(instruction_path) + instruction = parse_instruction( + instruction_path.read_text(encoding="utf-8"), + source.source_format, + source.scope, + source.storage, + ) + rendered, report = emit_instruction( + instruction, target.source_format + ) + loss_report.items.extend(report.items) + if _instruction_target_is_file(target): + destination = target.resolved_path + else: + file_object_id = compute_object_id( + product=item.source.product, + profile=item.source.profile, + scope=item.source.scope, + canonical_path=canonical_relative_path( + instruction_path, item.source.boundary + ), + ) + destination = _instruction_target_path( + target, + instruction_path, + file_object_id, + used_targets, + ) + used_targets.add(destination) + staged = stage_root / f"{len(operations):04d}-instruction" + atomic_write(staged, rendered) + operations.append( + { + "kind": "file", + "staged": staged, + "destination": destination, + "boundary": target.boundary, + } + ) + elif item.object_type == "mcp": + source_text = source.resolved_path.read_text(encoding="utf-8") + servers = parse_mcp_document(source_text, source.source_format) + existing = ( + target.resolved_path.read_text(encoding="utf-8") + if target.resolved_path.is_file() + else None + ) + rendered, report = emit_mcp_document( + servers, target.source_format, existing + ) + loss_report.items.extend(report.items) + staged = stage_root / f"{len(operations):04d}-mcp" + atomic_write(staged, rendered) + operations.append( + { + "kind": "file", + "staged": staged, + "destination": target.resolved_path, + "boundary": target.boundary, + } + ) + elif item.object_type == "plugins": + # Plugin packages transfer is opt-in only (audit 0.9.1): + # the CLI exposes this as --include-plugins. Without the opt-in, + # apply fails closed. + if not allow_plugin_copy: + raise ValueError( + "plugins transfer requires explicit opt-in " + "(--include-plugins); refusing to apply item: " + f"{item.object_type}" + ) + # Plugin packages: copy entire .factory-plugin/ directory structure + # preserving all subdirectories (commands/, skills/, droids/, hooks/, mcp.json, plugin.json) + if not source.resolved_path.is_dir(): + raise ValueError("plugins source must be a directory") + ensure_no_symlinks(source.resolved_path) + preflight_plugin_source(source.resolved_path) + for child in sorted(source.resolved_path.iterdir()): + if child.is_symlink(): + continue + if child.is_file(): + staged = stage_root / f"{len(operations):04d}-{child.name}" + shutil.copy2(child, staged) + operations.append( + { + "kind": "file", + "staged": staged, + "destination": target.resolved_path / child.name, + "boundary": target.boundary, + } + ) + elif child.is_dir(): + ensure_no_symlinks(child) + staged_dir = stage_root / f"{len(operations):04d}-{child.name}" + shutil.copytree(child, staged_dir) + operations.append( + { + "kind": "directory", + "staged": staged_dir, + "destination": target.resolved_path / child.name, + "boundary": target.boundary, + } + ) + elif item.object_type == "handoff": + # Session-derived transfer is opt-in only (audit SDI-2): + # the CLI exposes this as --include-session. Without the + # opt-in the apply fails closed rather than moving + # session artifacts. + if not allow_session_handoff: + raise ValueError( + "handoff/session transfer requires explicit opt-in " + "(--include-session); refusing to apply item: " + f"{item.object_type}" + ) + # Strict whitelist serialization (P0-4): only transfer reviewed_summary, + # git_branch, selected_files, patch. Discard all raw conversation, logs, + # tokens, machine paths, cwd, git_root, oauth/session state. + if source.resolved_path.is_file(): + source_text = source.resolved_path.read_text(encoding="utf-8") + try: + session_raw = json.loads(source_text) + except json.JSONDecodeError: + session_raw = {"reviewed_summary": "Reviewed handoff snapshot"} + portable_data = serialize_portable_handoff(session_raw, workspace) + rendered = json.dumps(portable_data, indent=2, sort_keys=True) + "\n" + staged = stage_root / f"{len(operations):04d}-handoff" + atomic_write(staged, rendered) + operations.append( + { + "kind": "file", + "staged": staged, + "destination": target.resolved_path, + "boundary": target.boundary, + } + ) + elif source.resolved_path.is_dir(): + for session_file in sorted(source.resolved_path.iterdir()): + if session_file.is_file(): + session_text = session_file.read_text(encoding="utf-8") + try: + session_raw = json.loads(session_text) + except json.JSONDecodeError: + session_raw = {"reviewed_summary": "Reviewed handoff snapshot"} + portable_data = serialize_portable_handoff(session_raw, workspace) + rendered = json.dumps(portable_data, indent=2, sort_keys=True) + "\n" + staged = stage_root / f"{len(operations):04d}-{session_file.name}" + atomic_write(staged, rendered) + operations.append( + { + "kind": "file", + "staged": staged, + "destination": target.resolved_path / session_file.name, + "boundary": target.boundary, + } + ) + else: + # Fail closed (audit SDI-4): executable surfaces such as + # hooks and agents have no staging writer, so an eligible + # item of that kind must never be recorded as applied. + raise ValueError( + "object type has no automatic writer and must be " + f"rebuilt manually: {item.object_type}" + ) + destinations = [operation["destination"] for operation in operations] + if len(destinations) != len(set(destinations)): + raise ValueError("plan resolves multiple writes to the same target") + protected_surfaces = [ + surface.resolved_path + for item in plan + for surface in (item.source, item.target) + if surface is not None + ] + if any(paths_overlap(manifest_path, path) for path in protected_surfaces): + raise ValueError( + "manifest path overlaps a planned source or target surface: " + f"{manifest_path}" + ) + for item in plan: + # Items recorded as deferred/blocked may have source or + # target None; only check state for items with surfaces. + if item.source is None or item.target is None: + continue + if ( + item.expected_source_state is not None + and path_state(item.source.resolved_path) + != item.expected_source_state + ): + raise ValueError( + f"source changed while staging: {item.source.resolved_path}" + ) + if ( + item.expected_target_state is not None + and path_state(item.target.resolved_path) + != item.expected_target_state + ): + raise ValueError( + f"target changed while staging: {item.target.resolved_path}" + ) + + for index, operation in enumerate(operations): + operation["backup"] = backup_path( + operation["destination"], backup_root, index + ) + for item in plan: + # Skip items without a target surface. + if item.target is None: + continue + if ( + item.expected_target_state is not None + and path_state(item.target.resolved_path) + != item.expected_target_state + ): + raise ValueError( + f"target changed before transaction commit: {item.target.resolved_path}" + ) + + created_directories: list[Path] = [] + try: + for operation in operations: + destination = operation["destination"] + boundary = operation["boundary"] + change = begin_change( + changes, + destination, + operation["backup"], + boundary, + ) + missing_parents: list[Path] = [] + candidate = destination.parent + while candidate != boundary and not candidate.exists(): + missing_parents.append(candidate) + candidate = candidate.parent + destination.parent.mkdir(parents=True, exist_ok=True) + created_directories.extend(reversed(missing_parents)) + if operation["kind"] == "directory": + temporary = destination.parent / ( + f".{destination.name}.migration-{operation_id}" + ) + try: + shutil.copytree(operation["staged"], temporary) + ensure_no_symlinks(temporary) + if hash_path(temporary) != hash_path(operation["staged"]): + raise ValueError( + f"staged Skill hash changed during commit: {destination}" + ) + if destination.is_symlink(): + raise ValueError( + f"symbolic links are not allowed: {destination}" + ) + if destination.is_dir(): + shutil.rmtree(destination) + else: + destination.unlink(missing_ok=True) + os.replace(temporary, destination) + finally: + if temporary.is_symlink(): + temporary.unlink() + elif temporary.exists(): + shutil.rmtree(temporary) + else: + atomic_write( + destination, + operation["staged"].read_text(encoding="utf-8"), + ) + finish_change(change, destination) + + # Record each actually applied eligible item in the manifest + # alongside the deferred/blocked entries from dispatch. + for index, item in enumerate(eligible_items): + status = item.status_enum + item_id = f"{index}:{item.object_type}" + if status is ItemStatus.DRAFT_DISABLED: + manifest_items.append( + _manifest_entry(item, item_id, "draft-written", index) + ) + elif status is ItemStatus.READY_LOSSY: + # Lossy items already recorded as applied-lossy above; + # nothing to update here. + continue + else: + manifest_items.append( + _manifest_entry(item, item_id, "applied", index) + ) + + summary: dict[str, int] = {} + for entry in manifest_items: + summary[entry["outcome"]] = summary.get(entry["outcome"], 0) + 1 + + manifest = _build_manifest( + operation_id=operation_id, + workspace=workspace, + provenance=provenance or {}, + changes=changes, + loss_report=loss_report, + items=manifest_items, + blockers=target_groups_blocked, + apply_safe=apply_safe, + include_lossy=include_lossy, + strict=strict, + ) + manifest["manifest_sha256"] = json_sha256( + { + key: value + for key, value in manifest.items() + if key != "manifest_sha256" + } + ) + atomic_write( + manifest_path, + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + ) + except BaseException as error: + rollback_errors: list[str] = [] + for change in reversed(changes): + try: + restore_transaction_change(change) + except (OSError, ValueError) as rollback_error: + rollback_errors.append(str(rollback_error)) + for directory in reversed(created_directories): + try: + directory.rmdir() + except OSError: + pass + if rollback_errors: + raise RuntimeError( + "transaction failed and automatic rollback was incomplete: " + + "; ".join(rollback_errors) + ) from error + raise + return manifest, manifest_path + + +def load_manifest(path: Path) -> dict[str, Any]: + manifest = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or manifest.get("schema_version") not in { + 1, + MANIFEST_SCHEMA_VERSION, + }: + raise ValueError("unsupported manifest") + if manifest.get("schema_version") == MANIFEST_SCHEMA_VERSION: + expected = manifest.get("manifest_sha256") + payload = { + key: value for key, value in manifest.items() if key != "manifest_sha256" + } + if not isinstance(expected, str) or expected != json_sha256(payload): + raise ValueError("manifest checksum mismatch") + changes = manifest.get("changes") + if not isinstance(changes, list): + raise ValueError("manifest changes must be an array") + return manifest + + +def verify_manifest(path: Path) -> list[str]: + manifest = load_manifest(path) + errors: list[str] = [] + for change in manifest["changes"]: + target = Path(change["path"]) + boundary = Path(change.get("boundary", "")) + if not boundary.is_absolute() or target == boundary: + errors.append(f"unsafe manifest boundary for: {target}") + continue + try: + ensure_no_symlink_components(target, boundary) + except ValueError as error: + errors.append(str(error)) + continue + if not target.exists(): + errors.append(f"missing: {target}") + continue + try: + current = hash_path(target) + except ValueError as error: + errors.append(str(error)) + continue + if current != change.get("post_sha256"): + errors.append(f"changed after apply: {target}") + return errors + + +def rollback_manifest(path: Path) -> int: + manifest = load_manifest(path) + errors = verify_manifest(path) + if errors: + raise ValueError("rollback refused: " + "; ".join(errors)) + restored = 0 + for change in reversed(manifest["changes"]): + target = Path(change["path"]) + backup_value = change.get("backup") + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink(missing_ok=True) + if backup_value: + backup = Path(backup_value) + if not backup.exists(): + raise ValueError(f"missing rollback backup: {backup}") + target.parent.mkdir(parents=True, exist_ok=True) + if backup.is_dir(): + shutil.copytree(backup, target) + else: + shutil.copy2(backup, target) + restored += 1 + return restored diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/registry/__init__.py b/skills/agent-skills-setup/agent-skills-setup/scripts/registry/__init__.py new file mode 100644 index 000000000..5334f3d26 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/registry/__init__.py @@ -0,0 +1,5 @@ +"""Registry v2 support modules (alias resolver, schema helpers). + +This package is loaded as a sibling of :mod:`migration_core`; the public +CLI adds ``scripts/`` to ``sys.path`` automatically. +""" \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/registry/alias_resolver.py b/skills/agent-skills-setup/agent-skills-setup/scripts/registry/alias_resolver.py new file mode 100644 index 000000000..7bd293221 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/registry/alias_resolver.py @@ -0,0 +1,138 @@ +"""Recursive ``alias_of`` resolver for Registry v2 selectors. + +The resolver follows ``alias_of`` chains iteratively with a visited set +and a depth bound (see :data:`MAX_ALIAS_DEPTH`). Once the chain is +exhausted, the final product's ``profile_templates`` entry (or +``default_profile``) supplies the resolved profile id when the user did +not specify one. + +The resolver returns a :class:`ResolvedSelector` so callers can preserve +the original user input (``requested``), the resolved target +(``resolved_product`` / ``resolved_profile``), the traversal trace +(``chain``), and a deprecation flag aggregated across the chain. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from registry.exceptions import ( + AliasCycleError, + AliasDepthExceededError, + UnknownSelectorError, +) + +MAX_ALIAS_DEPTH = 16 + + +@dataclass(frozen=True) +class ResolvedSelector: + """Outcome of resolving a user-provided selector through ``alias_of``.""" + + requested: str + resolved_product: str + resolved_profile: str + chain: tuple[str, ...] + deprecated: bool + + +def resolve(selector: str, registry_data: dict[str, Any]) -> ResolvedSelector: + """Resolve a user selector to its target product/profile. + + Parameters + ---------- + selector: + ``""`` or ``"/"`` form. When the user + provides a profile id and the chain defines one, the chain's + profile id wins (aliases are authoritative). + registry_data: + Parsed Registry v2 JSON document. + + Raises + ------ + UnknownSelectorError + Initial or chained product is not present in ``registry_data``. + AliasCycleError + The ``alias_of`` chain returns to a visited product. + AliasDepthExceededError + The chain exceeds :data:`MAX_ALIAS_DEPTH`. + """ + if not isinstance(selector, str) or not selector: + raise UnknownSelectorError(product=str(selector)) + + products = registry_data.get("products", {}) + profile_templates = registry_data.get("profile_templates", {}) + + requested = selector + product_id, separator, profile_id = selector.partition("/") + requested_profile = profile_id if separator else None + + if product_id not in products: + raise UnknownSelectorError(product=product_id) + + chain: list[str] = [product_id] + visited: set[str] = {product_id} + + # Follow alias_of iteratively with explicit depth/visited guards. + depth = 0 + while True: + product = products[product_id] + alias_of = product.get("alias_of") + if not isinstance(alias_of, dict) or not alias_of: + break + target_product = alias_of.get("product") + target_profile = alias_of.get("profile") + if not isinstance(target_product, str) or not target_product: + break + if target_product in visited: + raise AliasCycleError(chain=tuple(chain)) + depth += 1 + if depth > MAX_ALIAS_DEPTH: + raise AliasDepthExceededError( + chain=tuple(chain), limit=MAX_ALIAS_DEPTH + ) + product_id = target_product + if isinstance(target_profile, str) and target_profile: + # Alias-specified profile overrides the user-requested one. + profile_id = target_profile + elif not profile_id and requested_profile: + profile_id = requested_profile + if product_id not in products: + raise UnknownSelectorError(product=product_id) + visited.add(product_id) + chain.append( + f"{product_id}/{profile_id}" if profile_id else product_id + ) + + # Final product has no alias_of; pick a profile if not already set. + if not profile_id: + product = products[product_id] + template_id = product.get("template") + if isinstance(template_id, str) and template_id in profile_templates: + template = profile_templates[template_id] + profile_id = str(template.get("profile", template_id)) + else: + default_profile = product.get("default_profile") + if isinstance(default_profile, str): + profile_id = default_profile + + # Always record the final resolved form in the trace for consistency. + final_label = ( + f"{product_id}/{profile_id}" if profile_id else product_id + ) + if not chain or chain[-1] != final_label: + chain.append(final_label) + + deprecated = any( + bool(products.get(name, {}).get("deprecated")) + for name in visited + ) + + return ResolvedSelector( + requested=requested, + resolved_product=product_id, + resolved_profile=profile_id or "", + chain=tuple(chain), + deprecated=deprecated, + ) \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/registry/exceptions.py b/skills/agent-skills-setup/agent-skills-setup/scripts/registry/exceptions.py new file mode 100644 index 000000000..485814a3f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/registry/exceptions.py @@ -0,0 +1,41 @@ +"""Typed exceptions for the alias resolver. + +Centralized here so :mod:`migration_core` and any future schema tooling +can raise the same exception types without importing each other in a +cycle. +""" + +from __future__ import annotations + + +class AliasError(Exception): + """Base class for alias resolution failures.""" + + +class UnknownSelectorError(AliasError): + """Raised when a product selector is not in the registry.""" + + def __init__(self, product: str) -> None: + super().__init__(f"unknown product: {product}") + self.product = product + + +class AliasCycleError(AliasError): + """Raised when an ``alias_of`` chain returns to a visited product.""" + + def __init__(self, chain: tuple[str, ...]) -> None: + message = "alias cycle detected: " + " -> ".join(chain) + super().__init__(message) + self.chain = chain + + +class AliasDepthExceededError(AliasError): + """Raised when an ``alias_of`` chain exceeds the maximum depth.""" + + def __init__(self, chain: tuple[str, ...], limit: int) -> None: + message = ( + f"alias depth exceeded ({limit}): " + " -> ".join(chain) + ) + super().__init__(message) + self.chain = chain + self.limit = limit \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/scan-skill-secrets.py b/skills/agent-skills-setup/agent-skills-setup/scripts/scan-skill-secrets.py new file mode 100644 index 000000000..4ed6a3b87 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/scan-skill-secrets.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""CLI wrapper for the shared Skill source credential scanner.""" + +from skill_secret_scanner import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/skill_secret_scanner.py b/skills/agent-skills-setup/agent-skills-setup/scripts/skill_secret_scanner.py new file mode 100644 index 000000000..3f20947c3 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/skill_secret_scanner.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Reject a Skill source tree that contains likely literal credentials.""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path +from typing import Sequence + + +PROVIDER_PATTERNS = ( + re.compile(rb"sk-[A-Za-z0-9_-]{20,}"), + re.compile(rb"gh[pousr]_[A-Za-z0-9]{20,}"), + re.compile(rb"AKIA[0-9A-Z]{16}"), + re.compile(rb"ASIA[0-9A-Z]{16}"), + re.compile(rb"xox[baprs]-[A-Za-z0-9-]{10,}"), + re.compile(rb"ya29\.[A-Za-z0-9_-]+"), + re.compile(rb"AIza[0-9A-Za-z_-]{35}"), + re.compile(rb"sk_live_[A-Za-z0-9]{16,}"), + # Bearer authorization tokens: "Bearer eyJhbGci...", "bearer abc123..." + re.compile(rb"(?i)bearer[ \t]+[A-Za-z0-9._~+/-]{16,}"), +) +PRIVATE_KEY = re.compile( + rb"-----BEGIN (?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----" +) +# Credentials embedded in a connection-string userinfo component: +# postgres://user:pass@host:5432/db +# redis://:secret@cache:6379/ +# amqp://guest:guest@broker/ +# The username component is optional (redis://:pass@host); +# the colon separating userinfo from password is required so that a plain +# email-style URL (https://user@example.com) is NOT flagged. +CONNECTION_STRING_USERINFO = re.compile( + rb"(?i)[a-z][a-z0-9+.\-]*://[^\s:/@\"'\(\)]*:[^\s:/@\"'\(\)]+@[^\s\"'\)]+" +) +SECRET_ASSIGNMENT = re.compile( + r"(?im)(? bool: + lowered = value.lower() + return any(word in lowered for word in PLACEHOLDER_WORDS) + + +def finding_reason(data: bytes) -> str | None: + if PRIVATE_KEY.search(data): + return "private key block" + if any(pattern.search(data) for pattern in PROVIDER_PATTERNS): + return "provider credential pattern" + if CONNECTION_STRING_USERINFO.search(data): + return "credential embedded in connection-string userinfo" + if b"\x00" in data[:8192]: + return None + text = data.decode("utf-8", errors="replace") + for match in SECRET_ASSIGNMENT.finditer(text): + value = match.group(1) + if ( + not value.startswith("$(") + and not SAFE_REFERENCE.fullmatch(value) + and not is_placeholder(value) + ): + return "literal value assigned to a credential field" + return None + + +def symlink_reason(path: Path, root: Path) -> str | None: + target = os.readlink(path) + if os.path.isabs(target): + return "absolute symbolic link" + resolved = (path.parent / target).resolve(strict=False) + try: + resolved.relative_to(root) + except ValueError: + return "symbolic link escapes the Skill directory" + return None + + +def scan(root: Path) -> list[tuple[Path, str]]: + findings: list[tuple[Path, str]] = [] + root = root.resolve() + + def walk_error(error: OSError) -> None: + path = Path(error.filename) if error.filename else root + try: + display = path.relative_to(root) + except ValueError: + display = path + raise RuntimeError(f"cannot inspect {display}: {error.strerror}") + + for directory, dirnames, filenames in os.walk( + root, followlinks=False, onerror=walk_error + ): + for name in (*dirnames, *filenames): + path = Path(directory, name) + if path.is_symlink(): + reason = symlink_reason(path, root) + if reason: + findings.append((path.relative_to(root), reason)) + for filename in filenames: + path = Path(directory, filename) + if path.is_symlink() or not path.is_file(): + continue + try: + reason = finding_reason(path.read_bytes()) + except OSError as error: + raise RuntimeError( + f"cannot read {path.relative_to(root)}: {error}" + ) from error + if reason: + findings.append((path.relative_to(root), reason)) + return findings + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if len(arguments) != 1: + print("usage: scan-skill-secrets.py SKILL_DIR", file=sys.stderr) + return 2 + root = Path(arguments[0]) + if not root.is_dir(): + print(f"invalid Skill directory: {root}", file=sys.stderr) + return 2 + try: + findings = scan(root) + except RuntimeError as error: + print(error, file=sys.stderr) + return 2 + for path, reason in findings: + print(f"{path}: {reason}") + return 1 if findings else 0 diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/smart-ide-migration.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/smart-ide-migration.sh new file mode 100644 index 000000000..3ed5cf8e3 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/smart-ide-migration.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v python3 >/dev/null 2>&1; then + if [[ "${1:-}" != "legacy" ]]; then + echo "ERROR: Python 3 is required for profile-aware commands; use the explicit 'legacy' subcommand for lookup or zero-write dry-run compatibility." >&2 + exit 1 + fi + shift + for argument in "$@"; do + if [[ "$argument" == "--yes" || "$argument" == "-y" ]]; then + echo "ERROR: Python 3 and Registry v2 authorization are required for writes." >&2 + exit 1 + fi + done + export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 + exec bash "$SCRIPT_DIR/legacy-smart-ide-migration.sh" "$@" +fi + +exec python3 "$SCRIPT_DIR/context-migrator.py" "$@" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/sync-ide-reference-summaries.py b/skills/agent-skills-setup/agent-skills-setup/scripts/sync-ide-reference-summaries.py new file mode 100644 index 000000000..778cf772d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/sync-ide-reference-summaries.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Synchronize generated path summaries in per-IDE reference files.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +OBJECT_LABELS = { + "global_skills": "Global skills", + "project_skills": "Project skills", + "rules": "Rules", + "mcp": "MCP", + "project_mcp": "Project MCP", + "project_config": "Project config", + "config": "Config", +} +RESOLVER_OBJECTS = ( + ("global", "global_skills"), + ("project-skills", "project_skills"), + ("rules", "rules"), + ("mcp", "mcp"), + ("project-mcp", "project_mcp"), + ("project-config", "project_config"), + ("config", "config"), +) +START = "" +END = "" +BLOCK_PATTERN = re.compile( + rf"{re.escape(START)}\n.*?{re.escape(END)}\n?", re.DOTALL +) + + +def format_value(value: object) -> str: + if isinstance(value, str): + return f"`{value}`" if value else "Not mapped" + if isinstance(value, dict): + rows = [ + f"{platform}: `{path}`" + for platform, path in sorted(value.items()) + if path + ] + return "
".join(rows) if rows else "Not mapped" + return "Not mapped" + + +def summary(ide: str, values: dict[str, object]) -> str: + if not any( + format_value(values.get(key, "")) != "Not mapped" + for key in OBJECT_LABELS + ): + return "\n".join( + [ + START, + "", + "All automatic paths are unsupported.", + "", + END, + "", + ] + ) + + rows = [ + START, + "", + "| Object | Documented path |", + "| --- | --- |", + ] + for key, label in OBJECT_LABELS.items(): + rows.append(f"| {label} | {format_value(values.get(key, ''))} |") + rows.extend(["", END, ""]) + return "\n".join(rows) + + +def update_reference(path: Path, generated: str, check: bool) -> bool: + text = path.read_text(encoding="utf-8") + if BLOCK_PATTERN.search(text): + updated = BLOCK_PATTERN.sub(lambda _: generated, text) + else: + heading_end = text.find("\n") + if heading_end < 0 or not text.startswith("# "): + raise ValueError(f"{path}: expected a level-one Markdown heading") + updated = text[: heading_end + 1] + "\n" + generated + text[heading_end + 1 :].lstrip("\n") + + if text == updated: + return False + if check: + raise ValueError(f"{path}: generated path summary is out of date") + path.write_text(updated, encoding="utf-8") + return True + + +def resolver_table(values: dict[str, dict[str, object]]) -> str: + rows = ["# GENERATED from references/ide-paths.json; do not edit."] + for ide, mapping in sorted(values.items()): + for object_name, json_key in RESOLVER_OBJECTS: + value = mapping.get(json_key, "") + entries = value.items() if isinstance(value, dict) else (("*", value),) + for platform, path in sorted(entries): + if not isinstance(path, str) or "\t" in path or "\n" in path: + raise ValueError(f"{ide}/{json_key}: invalid resolver path") + row = f"{ide}\t{object_name}\t{platform}" + rows.append(f"{row}\t{path}" if path else row) + return "\n".join(rows) + "\n" + + +def update_resolver(path: Path, generated: str, check: bool) -> bool: + current = path.read_text(encoding="utf-8") if path.is_file() else "" + if current == generated: + return False + if check: + raise ValueError(f"{path}: generated resolver is out of date") + path.write_text(generated, encoding="utf-8") + return True + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--paths", type=Path, required=True) + parser.add_argument("--references", type=Path, required=True) + parser.add_argument("--resolver", type=Path) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + + values = json.loads(args.paths.read_text(encoding="utf-8")) + changed = 0 + try: + for ide, mapping in sorted(values.items()): + reference = args.references / f"{ide}.md" + if not reference.is_file(): + raise ValueError(f"missing IDE reference: {reference}") + if update_reference(reference, summary(ide, mapping), args.check): + changed += 1 + resolver_changed = ( + update_resolver(args.resolver, resolver_table(values), args.check) + if args.resolver + else False + ) + except ValueError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + verb = "Verified" if args.check else "Updated" + resolver_status = "" if not args.resolver else f"; resolver ({int(resolver_changed)} changed)" + print(f"{verb} generated summaries for {len(values)} IDE references ({changed} changed){resolver_status}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-all-installed.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-all-installed.sh new file mode 100644 index 000000000..5042f7e4a --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-all-installed.sh @@ -0,0 +1,671 @@ +#!/usr/bin/env bash +# ============================================================================== +# test-acb-all-installed.sh: E2E tests for --all-installed multi-IDE snapshot, +# multi-target restore, 1:1 manifest binding, and atomic staging. +# ============================================================================== +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so Path.home()/os.environ["HOME"] resolution sees a real dir. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WORKSPACE="$(mktemp -d /tmp/acb-all-inst-XXXXXX)" +HOME_SRC="$WORKSPACE/home_src" +WS_SRC="$WORKSPACE/ws_src" +HOME_DST="$WORKSPACE/home_dst" +WS_DST="$WORKSPACE/ws_dst" + +mkdir -p "$HOME_SRC" "$WS_SRC" "$HOME_DST" "$WS_DST" +trap 'rm -rf "$WORKSPACE"' EXIT + +MIGRATOR="python3 $SCRIPT_DIR/context-migrator.py" +REGISTRY="$SCRIPT_DIR/../references/registry-v2.json" + +echo "=== Test 1: Setup multiple simulated source IDEs on Device A ===" +# 1. Cline (user skills + user mcp) +mkdir -p "$HOME_SRC/.cline/skills/cline-helper" "$HOME_SRC/.cline/data/settings" +cat <<'EOF' > "$HOME_SRC/.cline/skills/cline-helper/SKILL.md" +--- +name: cline-helper +description: Cline helper skill +--- +# Cline Helper +EOF + +cat <<'EOF' > "$HOME_SRC/.cline/data/settings/cline_mcp_settings.json" +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } +} +EOF + +# 2. Cursor (user skills + project rules) +mkdir -p "$HOME_SRC/.cursor/skills/cursor-helper" "$WS_SRC/.cursor/rules" +cat <<'EOF' > "$HOME_SRC/.cursor/skills/cursor-helper/SKILL.md" +--- +name: cursor-helper +description: Cursor helper skill +--- +# Cursor Helper +EOF + +cat <<'EOF' > "$WS_SRC/.cursor/rules/lint.mdc" +--- +description: lint rule +globs: ["*.py"] +--- +Always lint Python code. +EOF + +# 3. Claude Code (user skills + project mcp) +mkdir -p "$HOME_SRC/.claude/skills/claude-helper" +cat <<'EOF' > "$HOME_SRC/.claude/skills/claude-helper/SKILL.md" +--- +name: claude-helper +description: Claude Code helper skill +--- +# Claude Helper +EOF + +cat <<'EOF' > "$WS_SRC/.mcp.json" +{ + "mcpServers": { + "git": { + "command": "uvx", + "args": ["mcp-server-git"] + } + } +} +EOF + +echo "OK source fixtures initialized" + +echo "=== Test 2: snapshot --all-installed ===" +BUNDLE="$WORKSPACE/multi-device.acb" +SNAPSHOT_OUT="$(HOME="$(native_path "$HOME_SRC")" $MIGRATOR snapshot \ + --registry "$REGISTRY" \ + --workspace "$WS_SRC" \ + --output "$BUNDLE" \ + --all-installed \ + --scope user,project \ + --json)" + +echo "$SNAPSHOT_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +assert data.get("objects_captured", 0) >= 3, f"Expected at least 3 captured objects, got: {data}" +print("OK snapshot --all-installed captured objects:", data["objects_captured"]) +' + +# Verify manifest and closed-world 1:1 file bindings +python3 -c " +import json +from pathlib import Path +bundle = Path(r'''$(native_path "$BUNDLE")''') +manifest = json.loads((bundle / 'manifest.json').read_text()) +objects = manifest.get('objects', []) +assert len(objects) >= 3, f'Expected >= 3 manifest objects, got {len(objects)}' + +products = {obj.get('product') for obj in objects} +assert 'cline' in products, f'cline missing from manifest products: {products}' +assert 'cursor' in products, f'cursor missing from manifest products: {products}' +assert 'claude' in products, f'claude missing from manifest products: {products}' + +for obj in objects: + files = obj.get('files', []) + portability = obj.get('portability_mode', 'unknown') + # Only portable objects (full/lossy) must have files; manual/excluded may have empty + if portability in ('full', 'lossy'): + assert len(files) >= 1, f'Portable object {obj} missing files array' + for f in files: + disk_file = bundle / f['path'] + assert disk_file.is_file(), f'Declared file missing: {disk_file}' + +print('OK manifest 1:1 file binding verified across products:', sorted(products)) +" + +echo "=== Test 3: bundle-verify on multi-product bundle ===" +VERIFY_OUT="$($MIGRATOR bundle-verify "$BUNDLE" --json)" +echo "$VERIFY_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +assert data.get("errors") == [], data +print("OK bundle-verify clean for multi-product bundle") +' + +echo "=== Test 4: doctor requirements inspection ===" +DOCTOR_OUT="$($MIGRATOR doctor "$BUNDLE" --json)" +ACB_BUNDLE="$(native_path "$BUNDLE")" python3 - "$DOCTOR_OUT" <<'PY' +import json, os, sys +from pathlib import Path +data = json.loads(sys.argv[1]) +assert data.get("ok") is True, data +bundle = Path(os.environ["ACB_BUNDLE"]) +reqs = json.loads((bundle / 'requirements.json').read_text()) +executables = reqs.get("executables", []) +packages = reqs.get("packages", []) + +# Verify IDE names are not mistaken for executables +assert "cursor" not in executables, f"cursor should not be in executables: {executables}" +assert "cline" not in executables, f"cline should not be in executables: {executables}" + +# Verify real command runners and packages are present +# Only Cline user MCP (with filesystem server) matches a registry surface +pkg_names = [p.get("name") for p in packages] +assert any("@modelcontextprotocol/server-filesystem" in p for p in pkg_names), f"Missing filesystem package: {packages}" +# npx should be in executables +assert "npx" in executables, f"npx should be in executables: {executables}" +print("OK doctor requirements accurately parsed command runners and packages:", pkg_names) +PY + +echo "=== Test 5: restore --all-installed onto Device B ===" +# Setup Device B with simulated installed IDEs: Windsurf and Forge +mkdir -p "$HOME_DST/.codeium/windsurf/skills" "$HOME_DST/forge/skills" "$WS_DST/.cursor/rules" + +RESTORE_OUT="$(HOME="$(native_path "$HOME_DST")" $MIGRATOR restore \ + "$BUNDLE" \ + --registry "$REGISTRY" \ + --workspace "$WS_DST" \ + --all-installed \ + --scope user,project \ + --apply-safe \ + --yes \ + --json)" + +echo "$RESTORE_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +summary = data.get("summary", {}) +assert summary.get("applied", 0) >= 1, f"Expected applied >= 1, got {summary}" +print("OK restore --all-installed applied summary:", summary) +' + +echo "=== Test 6: Verify restored files on Device B (P0-1: all sources restored) ===" +# Check skills landed in destination IDEs +find "$HOME_DST" -type f | sort +python3 -c " +from pathlib import Path +home_dst = Path(r'''$(native_path "$HOME_DST")''') +ws_dst = Path(r'''$(native_path "$WS_DST")''') + +# Check skill restoration: ensure skills from ALL sources landed (no silent drops) +found_skills = [str(s.name) for s in home_dst.rglob('SKILL.md')] +assert len(found_skills) >= 3, f'Expected skills from multiple sources, got {found_skills}' +skill_dirs = {s.parent.name for s in home_dst.rglob('SKILL.md')} +assert 'cline-helper' in skill_dirs, f'Missing cline-helper in {skill_dirs}' +assert 'cursor-helper' in skill_dirs, f'Missing cursor-helper in {skill_dirs}' +assert 'claude-helper' in skill_dirs, f'Missing claude-helper in {skill_dirs}' +print('OK P0-1 verified: all three source skills landed without collision loss:', sorted(skill_dirs)) +" + +echo "=== Test 7: Compatibility Matrix Non-Empty (P1-2) ===" +python3 -c " +import json +from pathlib import Path +bundle = Path(r'''$(native_path "$BUNDLE")''') +compat = json.loads((bundle / 'compatibility.json').read_text(encoding='utf-8')) +pairs = compat.get('pairs', []) +assert len(pairs) > 0, f'Expected non-empty compatibility pairs, got {compat}' +print(f'OK P1-2 verified: compatibility matrix contains {len(pairs)} bidirectional-reviewed pairs') +" + +echo "=== Test 8: Ed25519 Keygen, Sign, and Verify (P1-6) ===" +PRIV_KEY="$WORKSPACE/test_key.priv" +PUB_KEY="$WORKSPACE/test_key.pub" + +if python3 -c "import cryptography" 2>/dev/null; then + # 8a: Keygen + $MIGRATOR bundle-keygen --out-private "$PRIV_KEY" --out-public "$PUB_KEY" --json + [[ -f "$PRIV_KEY" ]] || { echo "FAIL: private key not generated"; exit 1; } + [[ -f "$PUB_KEY" ]] || { echo "FAIL: public key not generated"; exit 1; } + + # 8b: Sign + SIGN_OUT="$($MIGRATOR bundle-sign "$BUNDLE" --key "$PRIV_KEY" --signer "test-signer" --json)" + echo "$SIGN_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +print("OK bundle-sign emitted valid signature") +' + + # 8c: Verify with trusted key + VERIFY_OUT="$($MIGRATOR bundle-verify "$BUNDLE" --trusted-key "$PUB_KEY" --json)" + echo "$VERIFY_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +assert data.get("signature_verified") is True, data +print("OK bundle-verify successfully verified Ed25519 signature with trusted public key") +' +else + echo "SKIP: cryptography library not installed in this environment; skipping live Ed25519 signing test" +fi + +echo "=== Test 9: Atomic bundle creation rollback on failure (P1-7) ===" +# Create a valid pre-existing bundle +EXISTING_BUNDLE="$WORKSPACE/existing.acb" +HOME="$(native_path "$HOME_SRC")" $MIGRATOR snapshot \ + --registry "$REGISTRY" \ + --workspace "$WS_SRC" \ + --source cline/ide \ + --output "$EXISTING_BUNDLE" \ + --json >/dev/null + +PRE_MTIME=$(stat -f %m "$EXISTING_BUNDLE/manifest.json" 2>/dev/null || stat -c %Y "$EXISTING_BUNDLE/manifest.json") + +# Attempt writing a bundle with an injected secret to the same output path +python3 -c " +import sys +from pathlib import Path +sys.path.insert(0, r'''$(native_path "$SCRIPT_DIR")''') +from acb.bundle import write_bundle, ACBManifest, ACBSecretLeak, make_bundle_id + +manifest = ACBManifest( + schema_version=1, + bundle_id=make_bundle_id(), + created_at='2026-08-20T00:00:00Z', + source_platform={'system': 'darwin'}, + inventory_summary={}, + objects=[{'product': 'cline', 'surface': 'skills', 'files': []}], +) + +# Object containing private key leak +leak_objects = {'skills/cline/ide/user/key.pem': b'-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0...'} +try: + write_bundle( + bundle_root=Path(r'''$(native_path "$EXISTING_BUNDLE")'''), + manifest=manifest, + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files=leak_objects, + ) + raise SystemExit('FAIL: write_bundle did not reject secret') +except ACBSecretLeak: + print('OK write_bundle rejected secret during atomic staging') +" + +# Verify pre-existing bundle was NOT corrupted or wiped +assert_exists() { + [[ -f "$EXISTING_BUNDLE/manifest.json" ]] || { echo "FAIL: existing manifest.json was destroyed"; exit 1; } + [[ -f "$EXISTING_BUNDLE/checksums.json" ]] || { echo "FAIL: existing checksums.json was destroyed"; exit 1; } +} +assert_exists +echo "OK atomic staging safely preserved pre-existing bundle on write failure" + +echo "=== Test 10: Strict 1:1 Manifest Binding Rejects Duplicate Claimants (P0-2) ===" +python3 -c " +import sys, json +from pathlib import Path +sys.path.insert(0, r'''$(native_path "$SCRIPT_DIR")''') +from acb.bundle import verify_bundle + +bundle_path = Path(r'''$(native_path "$BUNDLE")''') +manifest_file = bundle_path / 'manifest.json' +manifest_data = json.loads(manifest_file.read_text(encoding='utf-8')) + +# Duplicate a file entry into two distinct objects +objects = manifest_data.get('objects', []) +if len(objects) >= 2 and objects[0].get('files'): + first_file = objects[0]['files'][0] + objects[1].setdefault('files', []).append(first_file) + # Write tampered manifest to a temp copy + import tempfile, shutil + tmp_dir = Path(tempfile.mkdtemp()) + try: + shutil.copytree(bundle_path, tmp_dir / 'bundle') + (tmp_dir / 'bundle' / 'manifest.json').write_text(json.dumps(manifest_data), encoding='utf-8') + # Update checksums.json for manifest.json so checksum passes + from acb.bundle import sha256_file + ck = json.loads((tmp_dir / 'bundle' / 'checksums.json').read_text(encoding='utf-8')) + ck['manifest.json'] = sha256_file(tmp_dir / 'bundle' / 'manifest.json') + (tmp_dir / 'bundle' / 'checksums.json').write_text(json.dumps(ck), encoding='utf-8') + + errors = verify_bundle(tmp_dir / 'bundle') + assert any('claimed by multiple objects' in e for e in errors), f'Expected duplicate claimant error, got: {errors}' + print('OK P0-2 verified: verify_bundle successfully rejected manifest with duplicate file claimants') + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) +" + +echo "=== Test 11: Child-level Skill Conflict Isolation (v0.9.1 regression) ===" +WS_SKILL_SRC="$WORKSPACE/ws_skill_src" +HOME_SKILL_SRC="$WORKSPACE/home_skill_src" +HOME_SKILL_DST="$WORKSPACE/home_skill_dst" +WS_SKILL_DST="$WORKSPACE/ws_skill_dst" +mkdir -p "$WS_SKILL_SRC" "$HOME_SKILL_SRC" "$HOME_SKILL_DST" "$WS_SKILL_DST" + +# Source A (Cline): shared-skill (v1) + unique-a +mkdir -p "$HOME_SKILL_SRC/.cline/skills/shared-skill" "$HOME_SKILL_SRC/.cline/skills/unique-a" +cat <<'EOF' > "$HOME_SKILL_SRC/.cline/skills/shared-skill/SKILL.md" +--- +name: shared-skill +description: Shared skill version 1 from Cline +--- +# Shared Skill V1 +EOF +cat <<'EOF' > "$HOME_SKILL_SRC/.cline/skills/unique-a/SKILL.md" +--- +name: unique-a +description: Unique A skill from Cline +--- +# Unique A +EOF + +# Source B (Cursor): conflicting shared-skill (v2) + unique-b +mkdir -p "$HOME_SKILL_SRC/.cursor/skills/shared-skill" "$HOME_SKILL_SRC/.cursor/skills/unique-b" +cat <<'EOF' > "$HOME_SKILL_SRC/.cursor/skills/shared-skill/SKILL.md" +--- +name: shared-skill +description: Conflicting shared skill version 2 from Cursor +--- +# Shared Skill V2 (Different Hash) +EOF +cat <<'EOF' > "$HOME_SKILL_SRC/.cursor/skills/unique-b/SKILL.md" +--- +name: unique-b +description: Unique B skill from Cursor +--- +# Unique B +EOF + +SKILL_BUNDLE="$WORKSPACE/skill-conflict.acb" +HOME="$(native_path "$HOME_SKILL_SRC")" $MIGRATOR snapshot \ + --registry "$REGISTRY" \ + --workspace "$WS_SKILL_SRC" \ + --output "$SKILL_BUNDLE" \ + --all-installed \ + --scope user \ + --json >/dev/null + +# Destination: Cursor installed on Device B +mkdir -p "$HOME_SKILL_DST/.cursor/skills" "$HOME_SKILL_DST/.cursor/rules" + +SKILL_RESTORE_OUT="$(HOME="$(native_path "$HOME_SKILL_DST")" $MIGRATOR restore \ + "$SKILL_BUNDLE" \ + --registry "$REGISTRY" \ + --workspace "$WS_SKILL_DST" \ + --all-installed \ + --scope user \ + --apply-safe \ + --yes \ + --json)" + +echo "$SKILL_RESTORE_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +' + +python3 -c " +from pathlib import Path +home_dst = Path(r'''$(native_path "$HOME_SKILL_DST")''') +skills_dir = home_dst / '.cursor' / 'skills' + +unique_a = skills_dir / 'unique-a' / 'SKILL.md' +unique_b = skills_dir / 'unique-b' / 'SKILL.md' +shared = skills_dir / 'shared-skill' / 'SKILL.md' + +assert unique_a.is_file(), f'unique-a was blocked or not restored: {list(skills_dir.rglob(\"*\"))}' +assert unique_b.is_file(), f'unique-b was blocked or not restored: {list(skills_dir.rglob(\"*\"))}' +assert not shared.is_file(), f'conflicting shared-skill should not have been written' +print('OK v0.9.1 verified: unique sibling skills (unique-a, unique-b) restored cleanly despite shared-skill conflict') +" + +echo "=== Test 12: Multi-source MCP Server-Level Merge (v0.9.1 regression) ===" +WS_MCP_SRC="$WORKSPACE/ws_mcp_src" +HOME_MCP_SRC="$WORKSPACE/home_mcp_src" +HOME_MCP_DST="$WORKSPACE/home_mcp_dst" +WS_MCP_DST="$WORKSPACE/ws_mcp_dst" +mkdir -p "$WS_MCP_SRC" "$HOME_MCP_SRC" "$HOME_MCP_DST" "$WS_MCP_DST" + +# Source A (Cline user MCP): filesystem (v1) + git +mkdir -p "$HOME_MCP_SRC/.cline/data/settings" "$HOME_MCP_SRC/.cline/skills/dummy" +cat <<'EOF' > "$HOME_MCP_SRC/.cline/skills/dummy/SKILL.md" +--- +name: dummy +description: Cline helper test skill for mcp +--- +# Dummy Skill +EOF +cat <<'EOF' > "$HOME_MCP_SRC/.cline/data/settings/cline_mcp_settings.json" +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + }, + "git": { + "command": "uvx", + "args": ["mcp-server-git"] + } + } +} +EOF + +# Source B (Claude user MCP): linear + git + conflicting filesystem +mkdir -p "$HOME_MCP_SRC/.claude" "$HOME_MCP_SRC/.claude/skills/dummy2" +cat <<'EOF' > "$HOME_MCP_SRC/.claude/skills/dummy2/SKILL.md" +--- +name: dummy2 +description: Claude helper test skill for mcp +--- +# Dummy Skill 2 +EOF +cat <<'EOF' > "$HOME_MCP_SRC/.claude.json" +{ + "mcpServers": { + "linear": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-linear"] + }, + "git": { + "command": "uvx", + "args": ["mcp-server-git"] + }, + "filesystem": { + "command": "docker", + "args": ["run", "-i", "mcp/filesystem", "/conflicting/path"] + } + } +} +EOF + +# Source C (Cursor project MCP): conflicting filesystem (v2) +mkdir -p "$HOME_MCP_SRC/.cursor/skills/dummy3" "$WS_MCP_SRC/.cursor/rules" +cat <<'EOF' > "$HOME_MCP_SRC/.cursor/skills/dummy3/SKILL.md" +--- +name: dummy3 +description: Cursor helper test skill for mcp +--- +# Dummy Skill 3 +EOF +cat <<'EOF' > "$WS_MCP_SRC/.cursor/mcp.json" +{ + "mcpServers": { + "filesystem": { + "command": "docker", + "args": ["run", "-i", "mcp/filesystem", "/conflicting/path"] + } + } +} +EOF + +MCP_BUNDLE="$WORKSPACE/mcp-merge.acb" +HOME="$(native_path "$HOME_MCP_SRC")" $MIGRATOR snapshot \ + --registry "$REGISTRY" \ + --workspace "$WS_MCP_SRC" \ + --output "$MCP_BUNDLE" \ + --all-installed \ + --scope user,project \ + --json >/dev/null + +echo "=== MCP BUNDLE MANIFEST ===" +python3 -c " +import json +from pathlib import Path +b = Path(r'''$(native_path "$MCP_BUNDLE")''') +m = json.loads((b / 'manifest.json').read_text()) +for o in m['objects']: + print(' ', o.get('product'), o.get('profile'), o.get('surface'), len(o.get('files', []))) +" + +# Destination: Claude Code on Device B (reads user .claude.json and workspace .mcp.json) +mkdir -p "$HOME_MCP_DST/.claude/skills" +touch "$WS_MCP_DST/CLAUDE.md" + +MCP_RESTORE_OUT="$(HOME="$(native_path "$HOME_MCP_DST")" $MIGRATOR restore \ + "$MCP_BUNDLE" \ + --registry "$REGISTRY" \ + --workspace "$WS_MCP_DST" \ + --all-installed \ + --scope user,project \ + --apply-safe \ + --yes \ + --json)" + +echo "RESTORE OUT: $MCP_RESTORE_OUT" +echo "$MCP_RESTORE_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +' + +python3 -c " +import json +from pathlib import Path +home_dst = Path(r'''$(native_path "$HOME_MCP_DST")''') +ws_dst = Path(r'''$(native_path "$WS_MCP_DST")''') + +print('Files in home_dst:', list(home_dst.rglob('*'))) +print('Files in ws_dst:', list(ws_dst.rglob('*'))) + +claude_json = home_dst / '.claude.json' +ws_mcp = ws_dst / '.mcp.json' + +found_servers = {} +if claude_json.is_file(): + data = json.loads(claude_json.read_text(encoding='utf-8')) + found_servers.update(data.get('mcpServers', {})) +if ws_mcp.is_file(): + data = json.loads(ws_mcp.read_text(encoding='utf-8')) + found_servers.update(data.get('mcpServers', {})) + +print('Restored MCP servers:', list(found_servers.keys())) +assert 'git' in found_servers, f'git server missing: {found_servers}' +assert 'linear' in found_servers, f'linear server missing: {found_servers}' +assert 'filesystem' not in found_servers, f'conflicting filesystem server should not be present: {found_servers}' +print('OK v0.9.1 verified: git deduplicated, linear merged, conflicting filesystem isolated') +" + +echo "=== Test 13: Strict Detection Include Flags (v0.9.1 regression) ===" +WS_DETECT="$WORKSPACE/ws_detect" +HOME_DETECT="$WORKSPACE/home_detect" +mkdir -p "$WS_DETECT/.agents/skills/shared-skill" "$HOME_DETECT/.agents/skills/shared-skill" +cat <<'EOF' > "$WS_DETECT/.agents/skills/shared-skill/SKILL.md" +--- +name: shared-skill +description: compatibility-only shared skill +--- +EOF +cat <<'EOF' > "$HOME_DETECT/.agents/skills/shared-skill/SKILL.md" +--- +name: shared-skill +description: compatibility-only shared skill +--- +EOF + +DETECT_OUT_DEFAULT="$(HOME="$(native_path "$HOME_DETECT")" $MIGRATOR snapshot \ + --registry "$REGISTRY" \ + --workspace "$WS_DETECT" \ + --all-installed \ + --output "$WORKSPACE/compat-default.acb" \ + --json)" + +echo "$DETECT_OUT_DEFAULT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +det_status = data.get("summary", {}).get("detection_status", {}) +installed = data.get("summary", {}).get("installed_products", []) +assert det_status.get("forge/cli") == "compatibility-only", det_status +assert "forge/cli" not in installed, installed +print("OK v0.9.1 verified: compatibility-only products excluded by default") +' + +DETECT_OUT_OPTIN="$(HOME="$(native_path "$HOME_DETECT")" $MIGRATOR snapshot \ + --registry "$REGISTRY" \ + --workspace "$WS_DETECT" \ + --all-installed \ + --include-compatibility \ + --output "$WORKSPACE/compat-optin.acb" \ + --json)" + +echo "$DETECT_OUT_OPTIN" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +det_status = data.get("summary", {}).get("detection_status", {}) +installed = data.get("summary", {}).get("installed_products", []) +assert det_status.get("forge/cli") == "compatibility-only", det_status +assert "forge/cli" in installed, installed +print("OK v0.9.1 verified: compatibility-only products included when --include-compatibility is provided") +' + +echo "=== Test 14: Plugin & Session Handoff Opt-in Flags (v0.9.1 regression) ===" +WS_OPTIN="$WORKSPACE/ws_optin" +mkdir -p "$WS_OPTIN/.factory/plugins/plugin-pkg" +cat <<'EOF' > "$WS_OPTIN/.factory/plugins/plugin-pkg/package.json" +{ + "name": "plugin-pkg", + "version": "1.0.0" +} +EOF + +# Plan with plugins +PLAN_PLUGINS="$WORKSPACE/plan-plugins.json" +$MIGRATOR plan \ + --registry "$REGISTRY" \ + --workspace "$WS_OPTIN" \ + --source factory-droid/cli \ + --target factory-droid/cli \ + --objects plugins \ + --output "$PLAN_PLUGINS" \ + --json >/dev/null + +# Apply without --include-plugins should fail +if $MIGRATOR apply "$PLAN_PLUGINS" --registry "$REGISTRY" --yes --json 2>/dev/null; then + echo "FAIL: apply plugins succeeded without --include-plugins" + exit 1 +fi +echo "OK v0.9.1 verified: apply plugins fails closed without --include-plugins" + +# Apply with --include-plugins should succeed +APPLY_OPTIN_OUT="$($MIGRATOR apply "$PLAN_PLUGINS" --registry "$REGISTRY" --include-plugins --yes --json)" +echo "$APPLY_OPTIN_OUT" | python3 -c ' +import json, sys +data = json.load(sys.stdin) +assert data.get("ok") is True, data +print("OK v0.9.1 verified: apply plugins succeeds with --include-plugins") +' + +echo +echo "All all-installed multi-IDE E2E tests PASSED!" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-bundle-backed-plan.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-bundle-backed-plan.sh new file mode 100644 index 000000000..2e1a9ca3f --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-bundle-backed-plan.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# +# Audit #1 / #4 regression tests: +# #1 The reviewed plan document (--plan-out) must be backed by the SAME +# source that produces the executed items. On a clean device the local +# registry resolves nothing, so the plan must be rebuilt from the bundle; +# its ready-item count must equal the number of items actually applied. +# #4 Object extraction into .acb-restored is OPT-IN (--restore-root); when +# omitted, nothing is written there, and we never imply a transaction +# landed there. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +TMP_ROOT="$(mktemp -d /tmp/acb-bundle-backed.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +BUNDLE="$TMP_ROOT/device-a.acb" +HOME_A="$TMP_ROOT/home_device_a" +WS_A="$TMP_ROOT/ws_device_a" +HOME_B="$TMP_ROOT/home_device_b" +WS_B="$TMP_ROOT/ws_device_b" +PLAN="$TMP_ROOT/restore-plan.json" + +mkdir -p "$HOME_A/.cline/skills/awesome-skill" "$WS_A" +mkdir -p "$HOME_B" "$WS_B" + +cat > "$HOME_A/.cline/skills/awesome-skill/SKILL.md" <<'EOF' +--- +name: awesome-skill +description: Skill captured on Device A +metadata: + version: "1.0.0" +--- +# Awesome Skill from Device A +EOF + +# 1. Snapshot on Device A +if ! HOME="$(native_path "$HOME_A")" "$WRAPPER" snapshot \ + --workspace "$WS_A" \ + --source cline/ide --target forge/cli \ + --scope user \ + --output "$BUNDLE" \ + --json >"$TMP_ROOT/snapshot.json"; then + echo "FAIL: snapshot exited non-zero; captured output:" >&2 + cat "$TMP_ROOT/snapshot.json" >&2 || true + exit 1 +fi +python3 - "$TMP_ROOT/snapshot.json" <<'PY' +import json, sys +out = json.load(open(sys.argv[1])) +assert out.get("ok") is True, out +captured = out.get("objects_captured", 0) +assert captured >= 1, ( + f"snapshot captured {captured} objects; summary={json.dumps(out.get('summary', {}))} " + f"collection={json.dumps(out.get('collection_summary', {}))}" +) +print(f"OK Device A snapshot generated ({captured} objects)") +PY + +# 2. Restore on clean Device B with a reviewed plan, but WITHOUT --restore-root. +if ! HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope user \ + --plan-out "$PLAN" \ + --apply-safe \ + --yes \ + --json >"$TMP_ROOT/restore.json"; then + echo "FAIL: restore exited non-zero; captured output:" >&2 + cat "$TMP_ROOT/restore.json" >&2 || true + echo "--- bundle manifest ---" >&2 + cat "$BUNDLE/manifest.json" >&2 || true + exit 1 +fi + +# Extract the JSON object from the (possibly noisy) output. +RESTORE_JSON="$(python3 - "$TMP_ROOT/restore.json" <<'PY' +import json, re, sys +text = open(sys.argv[1]).read() +obj = json.loads(re.search(r'\{.*\}', text, re.DOTALL).group(0)) +print(json.dumps(obj)) +PY +)" + +echo "$RESTORE_JSON" | python3 -c " +import json, sys +out = json.load(sys.stdin) +assert out['ok'] is True, out +assert out['stage'] == 'verify', out +print('OK restore reported ok, stage=verify') +" + +# 3. #1: reviewed plan == executed plan with strict dual-side assertions. +python3 - "$PLAN" "$RESTORE_JSON" "$WS_B" "$HOME_B" <<'PY' +import json, sys +from pathlib import Path + +plan = json.load(open(sys.argv[1])) +restore = json.loads(sys.argv[2]) +ws_b = Path(sys.argv[3]).resolve() +home_b = Path(sys.argv[4]).resolve() + +manifest_path = restore.get('manifest') +manifest = json.load(open(manifest_path)) + +# P0-1 assertion: plan workspace == real workspace +plan_ws = Path(plan.get('workspace', '')).resolve() +assert plan_ws == ws_b, f"plan workspace {plan_ws} != real workspace {ws_b}" + +# P0-1 assertion: plan_sha256 in provenance matches reviewed plan_sha256 +plan_sha = plan.get('plan_sha256') +prov_sha = manifest.get('provenance', {}).get('plan_sha256') +assert plan_sha == prov_sha, f"manifest provenance plan_sha ({prov_sha}) != reviewed plan_sha ({plan_sha})" + +plan_ready = [it for it in plan.get('items', []) if it.get('status') == 'ready'] +applied = restore.get('summary', {}).get('applied', 0) + +assert plan_ready, f"reviewed plan has no ready items: {plan.get('items')}" +assert len(plan_ready) == applied, ( + f"reviewed plan ready count ({len(plan_ready)}) != applied ({applied}); " + "reviewed plan diverged from executed plan" +) + +# P0-1 assertion: plan target path matches the real target on Device B (not a temporary stage path) +for item in plan_ready: + target_path = Path(item.get('target', {}).get('resolved_path', '')).resolve() + assert target_path, f"missing target path in plan item: {item}" + assert "/tmp/acb-source-stage-" not in str(target_path), f"leak of temporary stage path into reviewed plan target: {target_path}" + assert target_path == home_b / "forge/skills" or home_b in target_path.parents or target_path == ws_b or ws_b in target_path.parents, f"target path not rooted in Device B: {target_path}" + + # Check review preview + preview = item.get('review_preview') + assert preview is not None, f"missing review_preview in plan item: {item}" + for change in preview.get('changes', []): + change_path = Path(change.get('path', '')).resolve() + assert "/tmp/acb-source-stage-" not in str(change_path), f"temporary stage path in preview change: {change_path}" + assert home_b in change_path.parents or ws_b in change_path.parents, f"change path not rooted in Device B: {change_path}" + +# P0-1 assertion: manifest applied changes match reviewed plan target paths +manifest_changes = manifest.get('changes', []) +for change in manifest_changes: + dest_str = change.get('path') or change.get('destination') or '' + assert dest_str, f"missing path in manifest change: {change}" + dest = Path(dest_str).resolve() + assert "/tmp/acb-source-stage-" not in str(dest), f"temporary stage path in manifest change: {dest}" + assert home_b in dest.parents or ws_b in dest.parents or dest == home_b / "forge/skills/awesome-skill", f"manifest destination not rooted in Device B: {dest}" + +print(f"OK #1 reviewed plan ready items ({len(plan_ready)}) == applied ({applied})") +print(f"OK #1 reviewed plan target ({target_path}) == manifest target ({dest}) == written target") +print(f"OK #1 plan workspace ({plan_ws}) == real workspace ({ws_b})") +print(f"OK #1 plan_sha256 matches manifest provenance ({plan_sha})") +PY + +# 4. The target skill actually landed. +TARGET_SKILL="$HOME_B/forge/skills/awesome-skill/SKILL.md" +[[ -f "$TARGET_SKILL" ]] || { echo "FAIL: skill did not land on Device B"; exit 1; } +echo "OK restored skill landed on clean Device B" + +# 5. #4: NO .acb-restored was written (extraction is opt-in). +if [[ -e "$WS_B/.acb-restored" ]]; then + echo "FAIL #4: .acb-restored was created without --restore-root" + exit 1 +fi +echo "OK #4 no .acb-restored created without --restore-root (opt-in)" + +# 6. #4: WITH --restore-root, extraction does happen. +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope user \ + --restore-root "$WS_B/.acb-restored" \ + --apply-safe \ + --yes \ + --json >/dev/null +if [[ ! -d "$WS_B/.acb-restored" ]]; then + echo "FAIL #4: .acb-restored not created even with --restore-root" + exit 1 +fi +echo "OK #4 --restore-root opts in to object extraction" + +echo +echo "ACB bundle-backed plan + opt-in extraction tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-multiscope-restore.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-multiscope-restore.sh new file mode 100644 index 000000000..c9becd570 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-multiscope-restore.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# Audit #3 regression test: multi-scope (user + project) ACB restore. +# A bundle captured with --scope user,project must restore BOTH a user-scope +# skill (lands under ~) and a project-scope skill (lands under the workspace), +# because the staged source tree serves as both the home and workspace root. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +TMP_ROOT="$(mktemp -d /tmp/acb-multiscope.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +BUNDLE="$TMP_ROOT/device-a.acb" +HOME_A="$TMP_ROOT/home_device_a" +WS_A="$TMP_ROOT/ws_device_a" +HOME_B="$TMP_ROOT/home_device_b" +WS_B="$TMP_ROOT/ws_device_b" + +# User-scope skill (under ~) and project-scope skill (under workspace). +mkdir -p "$HOME_A/.cline/skills/user-skill" "$WS_A/.cline/skills/proj-skill" +mkdir -p "$HOME_B" "$WS_B" + +cat > "$HOME_A/.cline/skills/user-skill/SKILL.md" <<'EOF' +--- +name: user-skill +description: User-scope skill on Device A +metadata: + version: "1.0.0" +--- +# User Skill +EOF + +cat > "$WS_A/.cline/skills/proj-skill/SKILL.md" <<'EOF' +--- +name: proj-skill +description: Project-scope skill on Device A +metadata: + version: "1.0.0" +--- +# Project Skill +EOF + +# 1. Snapshot BOTH scopes. +HOME="$(native_path "$HOME_A")" "$WRAPPER" snapshot \ + --workspace "$WS_A" \ + --source cline/ide --target forge/cli \ + --scope user,project \ + --output "$BUNDLE" \ + --json >/dev/null +echo "OK Device A multi-scope snapshot generated" + +# 2. Restore on clean Device B across both scopes. +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope user,project \ + --apply-safe \ + --yes \ + --json >"$TMP_ROOT/restore.json" + +python3 - "$TMP_ROOT/restore.json" <<'PY' +import json, re, sys +text = open(sys.argv[1]).read() +out = json.loads(re.search(r'\{.*\}', text, re.DOTALL).group(0)) +assert out['ok'] is True, out +applied = out.get('summary', {}).get('applied', 0) +assert applied >= 2, f"expected >=2 applied items (user+project), got {applied}: {out['summary']}" +print(f"OK restore applied {applied} item(s) across scopes") +PY + +# 3. Both skills landed on the correct TARGET (forge/cli) roots: +# user-scope -> ~/forge/skills, project-scope -> .forge/skills +USER_TARGET="$HOME_B/forge/skills/user-skill/SKILL.md" +PROJ_TARGET="$WS_B/.forge/skills/proj-skill/SKILL.md" + +[[ -f "$USER_TARGET" ]] || { echo "FAIL: user-scope skill not restored under ~/forge/skills"; exit 1; } +[[ -f "$PROJ_TARGET" ]] || { echo "FAIL: project-scope skill not restored under .forge/skills"; exit 1; } +echo "OK user-scope skill restored under ~ and project-scope skill under workspace" + +echo +echo "ACB multi-scope restore tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-p0-audit-regressions.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-p0-audit-regressions.sh new file mode 100644 index 000000000..a34bd751d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-p0-audit-regressions.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# +# Regression tests for 0.8.22 P0 Audit items: +# P0-1: Plan target == Executed target, pre-existing target shown as "replace", +# target_state and previews reflect real destination device. +# P0-2: Snapshot strict allowlist: excludes generated_memory, session, runtime, +# and unrequested scopes/types even when files exist on disk. +# P0-3: Restore always uses bundle as source: when Device B has a local source IDE +# installed with conflicting content, the bundle content wins. +# P0-4: Strict handoff whitelist: only reviewed_summary, git_branch, selected_files, +# and patch survive; drops history, conversation, tokens, oauth_state, cwd, raw. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +TMP_ROOT="$(mktemp -d /tmp/acb-p0-regressions.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +HOME_A="$TMP_ROOT/home_a" +WS_A="$TMP_ROOT/ws_a" +HOME_B="$TMP_ROOT/home_b" +WS_B="$TMP_ROOT/ws_b" +BUNDLE="$TMP_ROOT/device-a.acb" + +mkdir -p "$HOME_A" "$WS_A" "$HOME_B" "$WS_B" + +# ----------------------------------------------------------------------------- +# Test 1: P0-2 Snapshot strict allowlist +# ----------------------------------------------------------------------------- +echo "=== Test 1: P0-2 Snapshot strict allowlist ===" +# Setup Device A with: +# 1. Portable user skill (eligible) +# 2. Forbidden generated memory (~/.cline/data) (must be excluded) +# 3. Project rule (.clinerules) +mkdir -p "$HOME_A/.cline/skills/portable-skill" +cat > "$HOME_A/.cline/skills/portable-skill/SKILL.md" <<'EOF' +--- +name: portable-skill +description: A portable skill from Device A +metadata: + version: "1.0.0" +--- +# Portable Skill +EOF + +mkdir -p "$HOME_A/.cline/data" +cat > "$HOME_A/.cline/data/generated-state.json" <<'EOF' +{"session_history": "secret conversation", "memory": "forbidden generated memory"} +EOF + +cat > "$WS_A/.clinerules" <<'EOF' +# Project Rules +EOF + +# Snapshot with scope=user (should include portable-skill, but exclude .cline/data and project rules) +HOME="$(native_path "$HOME_A")" "$WRAPPER" snapshot \ + --workspace "$WS_A" \ + --source cline/ide --target forge/cli \ + --scope user \ + --output "$BUNDLE" \ + --json >/dev/null + +python3 - "$BUNDLE" <<'PY' +import json, sys +from pathlib import Path + +bundle_root = Path(sys.argv[1]) +manifest = json.loads((bundle_root / "manifest.json").read_text()) +checksums = json.loads((bundle_root / "checksums.json").read_text()) + +# Assert generated_memory was NOT collected +for key in checksums.keys(): + assert "generated_memory" not in key, f"forbidden generated_memory leaked into bundle: {key}" + assert "data" not in key.split("/"), f"forbidden data directory leaked into bundle: {key}" + assert "session" not in key, f"forbidden session leaked into bundle: {key}" + +for obj in manifest.get("objects", []): + assert obj.get("surface") != "generated_memory", f"manifest contains generated_memory: {obj}" + assert obj.get("scope") == "user", f"user snapshot contains non-user scope: {obj}" + +print("OK P0-2: snapshot excluded forbidden generated_memory and unrequested scopes") +PY + +# ----------------------------------------------------------------------------- +# Test 2: P0-3 Restore source precedence (bundle wins over Device B local source) +# ----------------------------------------------------------------------------- +echo "=== Test 2: P0-3 Bundle wins over Device B local source ===" +# On Device B, also install Cline with DIFFERENT skill content: +mkdir -p "$HOME_B/.cline/skills/portable-skill" +cat > "$HOME_B/.cline/skills/portable-skill/SKILL.md" <<'EOF' +--- +name: portable-skill +description: Skill on Device B that should NOT be used during bundle restore +metadata: + version: "1.0.0" +--- +# Device B local content (must NOT overwrite bundle) +EOF + +PLAN_OUT="$TMP_ROOT/restore-plan.json" +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope user \ + --plan-out "$PLAN_OUT" \ + --apply-safe \ + --yes \ + --json >"$TMP_ROOT/restore_out.json" + +TARGET_SKILL="$HOME_B/forge/skills/portable-skill/SKILL.md" +if ! grep -q "Portable Skill from Device A" "$TARGET_SKILL" 2>/dev/null && ! grep -q "A portable skill from Device A" "$TARGET_SKILL" 2>/dev/null; then + echo "FAIL: restore wrote Device B local content instead of bundle content!" + cat "$TARGET_SKILL" + exit 1 +fi +echo "OK P0-3: bundle content won over Device B local source" + +# ----------------------------------------------------------------------------- +# Test 3: P0-1 Plan target == Executed target, pre-existing target shown as replace +# ----------------------------------------------------------------------------- +echo "=== Test 3: P0-1 Pre-existing target shown as replace and plan target matches ===" +# Now run restore again when the target ALREADY exists on Device B +PLAN_OUT_2="$TMP_ROOT/restore-plan-replace.json" +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope user \ + --plan-out "$PLAN_OUT_2" \ + --plan-only \ + --json >/dev/null + +python3 - "$PLAN_OUT_2" "$HOME_B" <<'PY' +import json, sys +from pathlib import Path + +plan = json.load(open(sys.argv[1])) +home_b = Path(sys.argv[2]).resolve() + +item = [it for it in plan.get("items", []) if it.get("status") == "ready"][0] +target_state = item.get("target_state") +assert target_state is not None and target_state.get("exists") is True, f"target_state should exist: {target_state}" + +preview = item.get("review_preview", {}) +changes = preview.get("changes", []) +assert changes, f"missing preview changes: {preview}" +for change in changes: + assert change.get("action") == "replace", f"action for existing target should be replace: {change}" + existing_sha = change.get("target_sha256") or change.get("pre_sha256") + assert existing_sha is not None, f"existing target sha should be present for replace: {change}" + +target_path = Path(item.get("target", {}).get("resolved_path", "")).resolve() +assert "/tmp/acb-source-stage-" not in str(target_path), f"stage path leaked into plan target: {target_path}" +assert target_path == home_b / "forge/skills" or home_b in target_path.parents, f"target path not rooted in Device B: {target_path} vs {home_b}" + +print("OK P0-1: existing target correctly evaluated as replace in reviewed plan") +PY + +# ----------------------------------------------------------------------------- +# Test 4: P0-4 Strict handoff whitelist serialization +# ----------------------------------------------------------------------------- +echo "=== Test 4: P0-4 Strict handoff whitelist ===" +python3 - <<'PY' +import sys +from pathlib import Path + +# Add scripts directory +sys.path.insert(0, str(Path("skills/agent-skills-setup/scripts").resolve())) +from migration_core import serialize_portable_handoff + +dirty_session = { + "summary": "Implement feature X", + "raw": "SECRET_RAW_LOGS", + "messages": [{"role": "user", "content": "hello"}], + "history": [{"role": "system", "text": "system log"}], + "conversation": "raw conversation text", + "tool_calls": [{"name": "bash", "command": "rm -rf /"}], + "oauth_state": {"token": "ya29.secret_token"}, + "tokens": 15000, + "cwd": "/Users/victim/secret/project", + "git_root": "/Users/victim/secret", + "approval_state": {"approved": True}, + "session_state": {"active": True}, + "environment": {"AWS_SECRET_KEY": "AKIAEXAMPLE"}, + "selected_files": ["src/main.py", "README.md", "/etc/passwd", "../traversal.py"], + "patch": "diff --git a/src/main.py b/src/main.py\n..." +} + +clean = serialize_portable_handoff(dirty_session, workspace=None) + +# Whitelist verification: only reviewed_summary, git_branch, selected_files, patch +allowed_keys = {"reviewed_summary", "git_branch", "selected_files", "patch"} +assert set(clean.keys()) == allowed_keys, f"unexpected keys in handoff: {set(clean.keys()) - allowed_keys}" +assert clean["reviewed_summary"] == "Implement feature X" +assert clean["patch"] is not None +# Absolute paths and traversal filtered from selected_files +assert clean["selected_files"] == ["README.md", "src/main.py"], f"file sanitization failed: {clean['selected_files']}" + +print("OK P0-4: strict handoff whitelist successfully dropped all unsafe session fields") +PY + +# ----------------------------------------------------------------------------- +# Test 5: Replayable Restore Plan with --plan-out, --plan-in, and TOCTOU state guard +# ----------------------------------------------------------------------------- +echo "=== Test 5: Replayable Restore Plan with --plan-in and TOCTOU state guard ===" +SAVED_PLAN="$TMP_ROOT/reviewed-replayable-plan.json" +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope user \ + --plan-out "$SAVED_PLAN" \ + --plan-only \ + --json >/dev/null + +[ -f "$SAVED_PLAN" ] || { echo "FAIL: plan-out did not create plan file"; exit 1; } + +# Replay the exact reviewed plan with --plan-in and --yes +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --plan-in "$SAVED_PLAN" \ + --apply-safe \ + --yes \ + --json >"$TMP_ROOT/replay_applied.json" + +grep -q '"stage": "verify"' "$TMP_ROOT/replay_applied.json" || { + echo "FAIL: replay with --plan-in did not succeed:" + cat "$TMP_ROOT/replay_applied.json" + exit 1 +} +echo "OK Test 5a: reviewed plan successfully replayed via --plan-in" + +# Test TOCTOU state guard: modify destination file so expected_target_state mismatches +echo "tampered content" > "$HOME_B/forge/skills/portable-skill/SKILL.md" +if HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --plan-in "$SAVED_PLAN" \ + --apply-safe \ + --yes \ + --json >"$TMP_ROOT/toctou_tampered.json" 2>&1; then + echo "FAIL: restore --plan-in did not abort when target was modified (TOCTOU violation)!" + cat "$TMP_ROOT/toctou_tampered.json" + exit 1 +fi +echo "OK Test 5b: TOCTOU state guard rejected tampered target state" + +# ----------------------------------------------------------------------------- +# Test 6: Sub-object Field-Level Whitelist (config-subobject data minimization) +# ----------------------------------------------------------------------------- +echo "=== Test 6: Sub-object Field-Level Whitelist (config-subobject isolation) ===" +HOME_MCP_A="$TMP_ROOT/home_mcp_a" +WS_MCP_A="$TMP_ROOT/ws_mcp_a" +HOME_MCP_B="$TMP_ROOT/home_mcp_b" +WS_MCP_B="$TMP_ROOT/ws_mcp_b" +BUNDLE_MCP="$TMP_ROOT/mcp-subobject.acb" +mkdir -p "$HOME_MCP_A/.augment" "$WS_MCP_A" "$HOME_MCP_B" "$WS_MCP_B" + +# Write Augment settings.json with mcpServers AND unrelated/sensitive sibling keys +cat > "$HOME_MCP_A/.augment/settings.json" <<'EOF' +{ + "augment.apiKey": "sk-unrelated-provider-token-123456", + "telemetry.enabled": true, + "editor.theme": "dark-plus", + "org_confidential_policy": "do-not-leak", + "mcpServers": { + "weather-server": { + "command": "python3", + "args": ["-m", "weather_mcp"] + } + } +} +EOF + +HOME="$(native_path "$HOME_MCP_A")" "$WRAPPER" snapshot \ + --workspace "$WS_MCP_A" \ + --source augment-code/cli-ide --target cline/ide \ + --scope user \ + --output "$BUNDLE_MCP" \ + --json >/dev/null + +python3 - "$BUNDLE_MCP" <<'PY' +import json, sys +from pathlib import Path + +bundle_root = Path(sys.argv[1]) +objects_dir = bundle_root / "objects" + +# Find settings.json in bundle +settings_files = list(objects_dir.rglob("settings.json")) +assert settings_files, f"settings.json not found in bundle objects: {list(objects_dir.rglob('*'))}" +content = settings_files[0].read_text(encoding="utf-8") +parsed = json.loads(content) + +# Sibling keys must NOT exist in the bundle +assert "augment.apiKey" not in parsed, "leaked sibling key augment.apiKey in bundle!" +assert "telemetry.enabled" not in parsed, "leaked sibling key telemetry.enabled in bundle!" +assert "org_confidential_policy" not in parsed, "leaked sibling key org_confidential_policy in bundle!" +assert "editor.theme" not in parsed, "leaked sibling key editor.theme in bundle!" + +# Only mcpServers should be present +assert "mcpServers" in parsed, f"mcpServers missing from subobject export: {parsed}" +assert "weather-server" in parsed["mcpServers"], f"weather-server missing: {parsed}" +print("OK Test 6: config-subobject exported ONLY mcpServers slice without sibling config leakage") +PY + +# ----------------------------------------------------------------------------- +# Test 6b: Shared-settings MCP files are subobject-extracted even when the +# registry marks them storage=file (clawscan 0.8.30: "may copy more of a +# local settings file than the skill promises"). +# ----------------------------------------------------------------------------- +echo "=== Test 6b: storage=file shared settings get subobject extraction ===" +HOME_GEMINI="$TMP_ROOT/home_gemini" +WS_GEMINI="$TMP_ROOT/ws_gemini" +BUNDLE_GEMINI="$TMP_ROOT/gemini-subobject.acb" +mkdir -p "$HOME_GEMINI/.gemini" "$WS_GEMINI" + +cat > "$HOME_GEMINI/.gemini/settings.json" <<'EOF' +{ + "model": "gemini-2.5-pro", + "theme": "auto", + "telemetry": {"enabled": true}, + "organization_internal_flag": "do-not-leak", + "mcpServers": { + "fixture-server": { + "command": "python3", + "args": ["-m", "fixture_mcp"] + } + } +} +EOF + +HOME="$(native_path "$HOME_GEMINI")" "$WRAPPER" snapshot \ + --workspace "$WS_GEMINI" \ + --source gemini-cli/cli --target cline/ide \ + --scope user \ + --output "$BUNDLE_GEMINI" \ + --json >/dev/null + +python3 - "$BUNDLE_GEMINI" <<'PY' +import json, sys +from pathlib import Path + +bundle_root = Path(sys.argv[1]) +objects_dir = bundle_root / "objects" + +mcp_files = list(objects_dir.rglob("*.json")) +assert mcp_files, f"no MCP object exported from gemini settings: {list(objects_dir.rglob('*'))}" +parsed = json.loads(mcp_files[0].read_text(encoding="utf-8")) + +for sibling in ("model", "theme", "telemetry", "organization_internal_flag"): + assert sibling not in parsed, f"leaked sibling key {sibling!r} from shared settings file" + +assert "mcpServers" in parsed or "servers" in parsed, f"MCP servers missing: {parsed}" +servers = parsed.get("mcpServers") or parsed.get("servers") +assert "fixture-server" in servers, f"fixture-server missing: {servers}" +print("OK Test 6b: storage=file gemini settings.json exported ONLY its mcpServers slice") +PY + +# ----------------------------------------------------------------------------- +# Test 7: Cross-Platform & Windows Path Resolver +# ----------------------------------------------------------------------------- +echo "=== Test 7: Cross-Platform and Windows Path Resolver ===" +python3 - <<'PY' +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path("skills/agent-skills-setup/scripts").resolve())) +from migration_core import Registry, _expand_path_vars + +fake_home = Path("/fake/home") +saved_env = {} +for var in ("APPDATA", "LOCALAPPDATA", "USERPROFILE", "HOMEPATH"): + saved_env[var] = os.environ.pop(var, None) + +def _assert_tail(res, tail): + # Separator- and drive-agnostic: on native Windows the fake home + # resolves against the current drive (C:/fake/home) with backslashes. + res_posix = Path(res).as_posix().lower() + assert res_posix.endswith(tail), f"unexpected expansion: {res}" + +try: + # With no environment overrides the vars fall back to the given home. + appdata_res = _expand_path_vars("%APPDATA%/Code/User/settings.json", fake_home) + _assert_tail(appdata_res, "fake/home/appdata/roaming/code/user/settings.json") + + userprofile_res = _expand_path_vars("%USERPROFILE%/.cursor/skills", fake_home) + _assert_tail(userprofile_res, "fake/home/.cursor/skills") + + posix_appdata = _expand_path_vars("$APPDATA/app/config.json", fake_home) + _assert_tail(posix_appdata, "fake/home/appdata/roaming/app/config.json") +finally: + for var, value in saved_env.items(): + if value is not None: + os.environ[var] = value + +# When the environment defines APPDATA it wins over the home fallback. +if os.environ.get("APPDATA"): + env_res = _expand_path_vars("%APPDATA%/x.json", fake_home) + assert Path(env_res).as_posix().startswith( + Path(os.environ["APPDATA"]).as_posix() + ), f"expected real APPDATA to win: {env_res}" + +print("OK Test 7: %APPDATA%, %USERPROFILE%, and $APPDATA correctly expanded across platforms") +PY + +# ----------------------------------------------------------------------------- +# Test 8: Detection Probe Fidelity (Shared Path Classification) +# ----------------------------------------------------------------------------- +echo "=== Test 8: Detection Probe Fidelity ===" +python3 - <<'PY' +import sys +from pathlib import Path + +sys.path.insert(0, str(Path("skills/agent-skills-setup/scripts").resolve())) +from detect.probes import probe_file_signature, InstallState + +fake_tmp = Path("/tmp/fake-probe-test") +fake_tmp.mkdir(parents=True, exist_ok=True) +shared_file = fake_tmp / "AGENTS.md" +shared_file.write_text("# Shared agents") + +# Probe against shared file +res = probe_file_signature("generic-ide", "default", [shared_file]) +assert res.state == InstallState.COMPATIBILITY_ONLY, f"shared file should be COMPATIBILITY_ONLY, got: {res.state}" + +# Probe against product-specific file +specific_file = fake_tmp / ".clinerules" +specific_file.write_text("# Cline rules") +res_specific = probe_file_signature("cline", "ide", [specific_file]) +assert res_specific.state in (InstallState.INSTALLED, InstallState.CONFIGURED_ONLY), f"specific file state: {res_specific.state}" + +print("OK Test 8: probe correctly classified shared paths as compatibility-only") +PY + +echo +echo "All P0 Audit regression tests PASSED" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-restore-noop.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-restore-noop.sh new file mode 100644 index 000000000..b30138ccb --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-restore-noop.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash + +# Verify audit #2 (no silent no-op) and #5 (temp staging dir cleanup) for +# context-migrator restore. A bundle that carries matching objects but resolves +# no eligible items must FAIL (never report success), unless --allow-noop is +# given. The temp staging directory must never leak to /tmp. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +TMP_ROOT="$(mktemp -d /tmp/acb-restore-noop.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +BUNDLE="$TMP_ROOT/device-a.acb" +HOME_A="$TMP_ROOT/home_device_a" +WS_A="$TMP_ROOT/ws_device_a" +HOME_B="$TMP_ROOT/home_device_b" +WS_B="$TMP_ROOT/ws_device_b" + +mkdir -p "$HOME_A/.cline/skills/awesome-skill" "$WS_A" +mkdir -p "$HOME_B" "$WS_B" + +cat > "$HOME_A/.cline/skills/awesome-skill/SKILL.md" <<'EOF' +--- +name: awesome-skill +description: Skill captured on Device A +metadata: + version: "1.0.0" +--- +# Awesome Skill from Device A +EOF + +# Snapshot a USER-scope skill on Device A. +HOME="$(native_path "$HOME_A")" "$WRAPPER" snapshot \ + --workspace "$WS_A" \ + --source cline/ide --target forge/cli \ + --scope user \ + --output "$BUNDLE" \ + --json >/dev/null + +echo "OK Device A snapshot generated" + +# Restore on a clean Device B with a MISMATCHED scope (project). The bundle +# only has user-scope objects, so staging resolves nothing eligible. This must +# be a hard failure, not a silent no-op. +set +e +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope project \ + --apply-safe \ + --yes \ + --json >"$TMP_ROOT/restore.json" 2>&1 +RC=$? +set -e + +[[ $RC -ne 0 ]] || { + echo "FAIL: restore with no eligible items should fail (got rc=$RC)" + cat "$TMP_ROOT/restore.json" + exit 1 +} +grep -q '"ok": *false' "$TMP_ROOT/restore.json" || { + echo "FAIL: expected ok:false in output" + cat "$TMP_ROOT/restore.json" + exit 1 +} +echo "OK restore refused silent no-op (rc=$RC, ok:false)" + +# --allow-noop must let it succeed (informational, zero applied). +set +e +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope project \ + --apply-safe \ + --yes \ + --allow-noop \ + --json >"$TMP_ROOT/restore_noop.json" 2>&1 +RC2=$? +set -e + +[[ $RC2 -eq 0 ]] || { + echo "FAIL: --allow-noop should succeed (got rc=$RC2)" + cat "$TMP_ROOT/restore_noop.json" + exit 1 +} +echo "OK --allow-noop permitted empty restore" + +# Temp staging directory must not leak to /tmp. +LEAK="$(ls -d /tmp/acb-source-stage-* 2>/dev/null | head -1 || true)" +[[ -z "$LEAK" ]] || { + echo "FAIL: temp staging dir leaked: $LEAK" + exit 1 +} +echo "OK no /tmp/acb-source-stage-* leak after restore" + +echo +echo "ACB restore no-op guard + temp cleanup tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-secret-scan-unified.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-secret-scan-unified.sh new file mode 100644 index 000000000..58fad7e55 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-secret-scan-unified.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Audit #6 / #7 regression tests: +# #6 Every ACB object is scanned by the SAME generic scanner +# (skill_secret_scanner.finding_reason) used for Skills, catching +# password=, client_secret:, DATABASE_URL userinfo, Bearer tokens, etc. +# #7 verify_bundle re-scans objects/ at verify time and re-enforces the +# resource safety limits (file count, per-file size, total size, depth). +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TMP_ROOT="$(mktemp -d /tmp/acb-secret-scan.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +python3 - "$SCRIPT_DIR" "$TMP_ROOT" <<'PY' +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from acb.bundle import ( + ACB_SCHEMA_VERSION, + ACBManifest, + write_bundle, + verify_bundle, + scan_object_bytes, + ACBSecretLeak, + make_bundle_id, +) +import skill_secret_scanner + +script_dir = Path(sys.argv[1]) +tmp = Path(sys.argv[2]) + + +def manifest(): + return ACBManifest( + schema_version=ACB_SCHEMA_VERSION, + bundle_id=make_bundle_id(), + created_at="2026-08-17T00:00:00Z", + source_platform={"system": "darwin"}, + inventory_summary={}, + objects=[], + ) + + +def blank(): + return dict( + bundle_root=tmp / "out.acb", + manifest=manifest(), + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files={}, + ) + + +# --- #6: secret assignment forms caught by the unified scanner ------------- +leak_cases = { + "password=": b'config.toml: password = "' + b"super" + b"secret" + b"password123" + b'"\n', + "client_secret:": b'{"client_secret": "' + b"abc123" + b"def456" + b"ghi789" + b"jklmno" + b'"}\n', + "DATABASE_URL userinfo": b"DATABASE_URL=postgres://admin:" + b"s3cr3t" + b"P@ss" + b"@db.host:5432/app\n", + "redis userinfo": b"redis://:" + b"top" + b"secret" + b"@cache.example:6379/0\n", + "Bearer token": b"Authorization: Bearer " + b"eyJhbGciOi" + b"JIUzI1NiIsInR5cCI6IkpXVCJ9xxxx" + b"\n", + "sk- provider": b'api_key="' + b"sk-" + b"1234567890abcdef" + b"1234567890abcd" + b'"\n', + "private key": b"-----BEGIN " + b"RSA PRIVATE KEY-----\n" + b"MIIEowIBAAKCAQEA0\n" + b"-----END " + b"RSA PRIVATE KEY-----\n", +} + +for label, payload in leak_cases.items(): + # (a) scan_object_bytes must raise on raw object bytes + try: + scan_object_bytes(payload, f"skills/x/{label}.txt") + except ACBSecretLeak: + pass + else: + raise SystemExit(f"FAIL #6: scan_object_bytes missed {label}") + # (b) finding_reason must explain the reason + reason = skill_secret_scanner.finding_reason(payload) + assert reason is not None, f"FAIL #6: finding_reason missed {label}" + print(f"OK #6 [{label}] rejected by unified scanner: {reason}") + +# --- #6: benign content must NOT be flagged ------------------------------- +benign_cases = { + "placeholder password": b'password = "your_password_here"\n', + "env reference": b'token = "${API_TOKEN}"\n', + "plain prose": b'This skill reminds you to rotate your password every 90 days.\n', + "allowlisted png": b"\x89PNG\r\n\x1a\n" + b"\x00" * 48, + "normal json": b'{"name": "demo", "version": "1.0.0", "scope": "user"}\n', +} +for label, payload in benign_cases.items(): + reason = skill_secret_scanner.finding_reason(payload) + assert reason is None, f"FAIL #6: false positive on {label}: {reason}" + # scan_object_bytes should also accept (png is allowlisted binary) + scan_object_bytes(payload, f"skills/x/{label}.txt" if not label.endswith("png") else "skills/x/icon.png") + print(f"OK #6 [{label}] accepted as safe") + +# --- #7: verify_bundle re-scans injected object secrets -------------------- +inj = tmp / "secret-injected.acb" +write_bundle( + bundle_root=inj, + manifest=manifest(), + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files={ + "skills/clean/SKILL.md": b"# Clean\nname: clean\ndescription: demo\n", + }, +) + +# Inject a secret-laden object directly into the written bundle and verify. +leak_path = inj / "objects" / "skills" / "leak" / "config.txt" +leak_path.parent.mkdir(parents=True, exist_ok=True) +leak_path.write_bytes(b'password = "' + b"injected" + b"secret" + b'value123"\n') + +errors = verify_bundle(inj) +assert any("secret/binary violation in object" in e for e in errors), \ + f"FAIL #7: verify_bundle did not re-scan injected object secret: {errors}" +print("OK #7 verify_bundle re-scanned objects/ and rejected injected secret") + +# --- #7: verify_bundle re-enforces resource limits on objects ------------- +size_bundle = tmp / "size.acb" +write_bundle( + bundle_root=size_bundle, + manifest=manifest(), + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files={"skills/big/ok.png": b"\x89PNG\r\n" + b"\x00" * 16}, +) +# Inject an oversized allowlisted binary; verify must flag the size limit. +big = size_bundle / "objects" / "skills" / "big" / "huge.png" +big.write_bytes(b"\x89PNG\r\n" + b"\x00" * (10 * 1024 * 1024 + 1024)) +errors = verify_bundle(size_bundle) +assert any("object size exceeded limit" in e for e in errors), \ + f"FAIL #7: verify_bundle did not flag oversized object: {errors}" +print("OK #7 verify_bundle re-enforced per-object size limit on tampered object") + +print() +print("ACB unified secret scan + verify rescan tests passed") +PY diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-security-boundary.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-security-boundary.sh new file mode 100644 index 000000000..94a0aadc4 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-security-boundary.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +TMP_ROOT="$(mktemp -d /tmp/acb-security-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +BUNDLE="$TMP_ROOT/valid.acb" +HOME_DIR="$TMP_ROOT/home" +WS_DIR="$TMP_ROOT/ws" +mkdir -p "$HOME_DIR/.cline/skills/test-skill" "$WS_DIR" + +cat > "$HOME_DIR/.cline/skills/test-skill/SKILL.md" <<'EOF' +--- +name: test-skill +description: Clean test skill +metadata: + version: "1.0.0" +--- +# Clean skill +EOF + +# 1. Snapshot a clean bundle +HOME="$(native_path "$HOME_DIR")" "$WRAPPER" snapshot \ + --workspace "$WS_DIR" \ + --source cline/ide --target forge/cli \ + --scope user \ + --output "$BUNDLE" \ + --json >/dev/null + +echo "OK clean snapshot generated" + +# 2. Verify clean bundle passes +python3 - "$BUNDLE" "$SCRIPT_DIR" <<'PY' +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[2]) +from acb.bundle import verify_bundle + +errors = verify_bundle(Path(sys.argv[1])) +assert not errors, f"clean bundle should have no errors: {errors}" +print("OK clean bundle verified with 0 errors") +PY + +# 3. P0-3: Test closed-world integrity by injecting an extra file into bundle +EXTRA_FILE="$BUNDLE/objects/extra_unlisted_file.txt" +echo "malicious payload" > "$EXTRA_FILE" +python3 - "$BUNDLE" "$SCRIPT_DIR" <<'PY' +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[2]) +from acb.bundle import verify_bundle + +errors = verify_bundle(Path(sys.argv[1])) +assert any("unexpected extra file" in e for e in errors), f"expected extra file error, got: {errors}" +print("OK verify_bundle detected and rejected extra unlisted file in bundle") +PY +rm -f "$EXTRA_FILE" + +# 4. P0-3: Test missing file is rejected +MANIFEST_FILE="$BUNDLE/manifest.json" +MANIFEST_BACKUP="$TMP_ROOT/manifest.bak" +mv "$MANIFEST_FILE" "$MANIFEST_BACKUP" +python3 - "$BUNDLE" "$SCRIPT_DIR" <<'PY' +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[2]) +from acb.bundle import verify_bundle + +errors = verify_bundle(Path(sys.argv[1])) +assert any("missing file" in e for e in errors), f"expected missing file error, got: {errors}" +print("OK verify_bundle rejected missing expected file") +PY +mv "$MANIFEST_BACKUP" "$MANIFEST_FILE" + +# 5. P0-2: Test secret scanning on raw object bytes +python3 - "$TMP_ROOT" "$SCRIPT_DIR" <<'PY' +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[2]) +from acb.bundle import ( + ACB_SCHEMA_VERSION, + ACBManifest, + write_bundle, + ACBSecretLeak, + make_bundle_id, +) + +tmp_bundle = Path(sys.argv[1]) / "secret-leak.acb" +manifest = ACBManifest( + schema_version=ACB_SCHEMA_VERSION, + bundle_id=make_bundle_id(), + created_at="2026-08-17T00:00:00Z", + source_platform={"system": "darwin"}, + inventory_summary={}, + objects=[], +) + +# A: Private key in raw bytes +try: + write_bundle( + bundle_root=tmp_bundle, + manifest=manifest, + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files={ + "skills/key.pem": b"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0...", + }, + ) +except ACBSecretLeak: + print("OK write_bundle rejected raw object containing RSA private key") +else: + raise SystemExit("FAIL: write_bundle accepted private key in raw object") + +# B: .env sensitive filename +try: + write_bundle( + bundle_root=tmp_bundle, + manifest=manifest, + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files={ + "skills/.env": b"DATABASE_URL=postgres://...", + }, + ) +except ACBSecretLeak: + print("OK write_bundle rejected .env file in raw objects") +else: + raise SystemExit("FAIL: write_bundle accepted .env file in raw objects") + +# C: Provider credential in raw text +try: + write_bundle( + bundle_root=tmp_bundle, + manifest=manifest, + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files={ + "skills/config.json": b'{"api_key": "sk-1234567890abcdef1234567890"}', + }, + ) +except ACBSecretLeak: + print("OK write_bundle rejected provider API key in raw object bytes") +else: + raise SystemExit("FAIL: write_bundle accepted provider API key in raw objects") + +# D: Executable binary (ELF header) +try: + write_bundle( + bundle_root=tmp_bundle, + manifest=manifest, + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + objects_dir_files={ + "skills/malicious_bin": b"\x7fELF\x02\x01\x01\x00...", + }, + ) +except ACBSecretLeak: + print("OK write_bundle rejected executable ELF binary in raw objects") +else: + raise SystemExit("FAIL: write_bundle accepted executable binary in raw objects") +PY + +# 6. P0-7: Test portable inventory contains NO machine-specific paths +python3 - "$BUNDLE" <<'PY' +import json, sys +from pathlib import Path + +inv_path = Path(sys.argv[1]) / "inventory.json" +inventory = json.loads(inv_path.read_text(encoding="utf-8")) +for row in inventory.get("rows", []): + assert "resolved_path" not in row, f"resolved_path leaked into portable bundle: {row}" + assert "boundary" not in row, f"boundary leaked into portable bundle: {row}" + assert "git_root" not in row, f"git_root leaked into portable bundle: {row}" +print("OK portable inventory contains no resolved_path or machine-specific leakage") +PY + +# 7. P0-6: Test dry-run guarantees zero writes +RESTORE_TARGET="$TMP_ROOT/dry_run_target" +"$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_DIR" \ + --restore-root "$RESTORE_TARGET" \ + --source cline/ide --target forge/cli \ + --scope user \ + --dry-run \ + --json >/dev/null + +if [[ -d "$RESTORE_TARGET" ]] && [[ -n "$(ls -A "$RESTORE_TARGET" 2>/dev/null)" ]]; then + echo "FAIL: restore --dry-run wrote files to $RESTORE_TARGET" + exit 1 +fi +echo "OK restore --dry-run wrote zero files to disk" + +echo +echo "ACB security boundary tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-snapshot-restore.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-snapshot-restore.sh new file mode 100644 index 000000000..f00e51f48 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-snapshot-restore.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +WS_OLD="$(mktemp -d /tmp/acb-old.XXXXXX)" +HOME_OLD="$(mktemp -d /tmp/acb-home-old.XXXXXX)" +WS_NEW="$(mktemp -d /tmp/acb-new.XXXXXX)" +HOME_NEW="$(mktemp -d /tmp/acb-home-new.XXXXXX)" +BUNDLE="$(mktemp -d /tmp/acb-bundle.XXXXXX)" +trap 'rm -rf "$WS_OLD" "$HOME_OLD" "$WS_NEW" "$HOME_NEW" "$BUNDLE"' EXIT + +# Stage a cline skill in the OLD device home. +mkdir -p "$HOME_OLD/.cline/skills/fixture-skill" +cat > "$HOME_OLD/.cline/skills/fixture-skill/SKILL.md" <<'SKILL' +--- +name: fixture-skill +description: Test skill for ACB. +metadata: + version: '1' +--- +# fixture +SKILL + +# Stage the same skill on the NEW device so the restore apply path can +# write it. (Full bundle-content restore arrives in a later PR; this +# test verifies the ACB snapshot/verify/restore orchestration.) +mkdir -p "$HOME_NEW/.cline/skills/fixture-skill" +cp "$HOME_OLD/.cline/skills/fixture-skill/SKILL.md" "$HOME_NEW/.cline/skills/fixture-skill/" + +# --- snapshot ---------------------------------------------------------- +SNAPSHOT_OUT="$(HOME="$(native_path "$HOME_OLD")" "$WRAPPER" snapshot \ + --workspace "$WS_OLD" \ + --source cline/ide --target forge/cli \ + --scope user \ + --output "$BUNDLE" \ + --json 2>&1)" || { + echo "snapshot failed: $SNAPSHOT_OUT" | head -30 + exit 1 +} + +echo "$SNAPSHOT_OUT" | python3 -c " +import json, sys, re +text = sys.stdin.read() +m = re.search(r'\{.*\}', text, re.DOTALL) +out = json.loads(m.group(0)) +assert out['ok'] is True, out +assert out['stage'] == 'snapshot', out +assert out['bundle'], out +assert out['objects_captured'] >= 1, out +print('OK snapshot bundle:', out['bundle']) +print('OK bundle_id:', out['bundle_id']) +print('OK objects_captured:', out['objects_captured']) +" + +[[ -f "$BUNDLE/manifest.json" ]] || { echo "FAIL: manifest.json missing"; exit 1; } +[[ -f "$BUNDLE/inventory.json" ]] || { echo "FAIL: inventory.json missing"; exit 1; } +[[ -f "$BUNDLE/compatibility.json" ]] || { echo "FAIL: compatibility.json missing"; exit 1; } +[[ -f "$BUNDLE/requirements.json" ]] || { echo "FAIL: requirements.json missing"; exit 1; } +[[ -f "$BUNDLE/secrets.required.json" ]] || { echo "FAIL: secrets.required.json missing"; exit 1; } +[[ -f "$BUNDLE/reauth.json" ]] || { echo "FAIL: reauth.json missing"; exit 1; } +[[ -f "$BUNDLE/rebuild.json" ]] || { echo "FAIL: rebuild.json missing"; exit 1; } +[[ -f "$BUNDLE/checksums.json" ]] || { echo "FAIL: checksums.json missing"; exit 1; } +[[ -d "$BUNDLE/objects" ]] || { echo "FAIL: objects/ missing"; exit 1; } +# Verify the captured skill source bytes are in objects/ (object_id +# derived from product|profile|scope|canonical). +SKILL_IN_OBJECTS=$(find "$BUNDLE/objects" -name "SKILL.md" -path "*fixture-skill*" | head -1) +[[ -n "$SKILL_IN_OBJECTS" ]] || { + echo "FAIL: SKILL.md not captured under objects/" + exit 1 +} +echo "OK ACB objects/ captured source bytes (SKILL.md under $(echo "$SKILL_IN_OBJECTS" | sed "s|$BUNDLE/objects/||"))" +echo "OK ACB layout complete (manifest/inventory/compatibility/requirements/secrets/reauth/rebuild/checksums/objects + captured objects)" + +# --- bundle verify ----------------------------------------------------- +VERIFY_OUT="$("$WRAPPER" bundle-verify "$BUNDLE" --json)" +echo "$VERIFY_OUT" | python3 -c " +import json, sys +out = json.load(sys.stdin) +assert out['ok'] is True, out +assert out['errors'] == [], out +print('OK bundle-verify clean') +" + +# --- detect literal-secret leak --------------------------------------- +# Inject a synthetic bundle where the manifest payload contains a +# secret-looking string and ensure write_bundle refuses. +python3 - "$SCRIPT_DIR" <<'PY' +import sys +sys.path.insert(0, sys.argv[1]) +from acb.bundle import ( + ACB_SCHEMA_VERSION, + ACBManifest, + write_bundle, + ACBSecretLeak, + make_bundle_id, +) +import tempfile, pathlib, json +tmp = pathlib.Path(tempfile.mkdtemp(prefix='acb-leak-')) +manifest = ACBManifest( + schema_version=ACB_SCHEMA_VERSION, + bundle_id=make_bundle_id(), + created_at='2026-08-15T00:00:00Z', + source_platform={'system': 'darwin'}, + inventory_summary={}, + objects=[{'product': 'demo', 'surface': 'mcp', 'secret': 'token=AKIA1234567890ABCDEF'}], +) +try: + write_bundle( + bundle_root=tmp, + manifest=manifest, + inventory_rows=[], + compatibility={}, + requirements={}, + secrets_required=[], + reauth=[], + rebuild=[], + ) +except ACBSecretLeak: + print('OK write_bundle refused literal secret leak') +else: + print('FAIL: write_bundle accepted a literal secret leak') + raise SystemExit(1) +PY + +# --- restore ----------------------------------------------------------- +RESTORE_OUT="$(HOME="$(native_path "$HOME_NEW")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_NEW" \ + --source cline/ide --target forge/cli \ + --scope user \ + --restore-root "$WS_NEW/.acb-restored" \ + --apply-safe \ + --yes --json 2>&1)" || { + echo "restore failed: $RESTORE_OUT" | head -30 + exit 1 +} +echo "$RESTORE_OUT" | python3 -c " +import json, sys, re +text = sys.stdin.read() +m = re.search(r'\{.*\}', text, re.DOTALL) +out = json.loads(m.group(0)) +assert out['ok'] is True, out +assert out['stage'] == 'verify', out +assert out['summary'].get('applied', 0) >= 1, out['summary'] +print('OK restore summary:', out['summary']) +" + +# Verify the skill landed on the NEW device. +[[ -f "$HOME_NEW/forge/skills/fixture-skill/SKILL.md" ]] || { + echo "FAIL: skill did not land on the new device" + exit 1 +} +echo "OK restore landed skill on new device" + +# Verify objects/ were replayed into the restore-root. +RESTORE_ROOT="$WS_NEW/.acb-restored" +find "$RESTORE_ROOT" -name "SKILL.md" -path "*fixture-skill*" | grep -q . || { + echo "FAIL: bundle/objects/ was not replayed into $RESTORE_ROOT" + exit 1 +} +echo "OK restore replayed bundle/objects/ into $RESTORE_ROOT (found SKILL.md under fixture-skill)" + +# --- doctor ------------------------------------------------------------ +DOCTOR_OUT="$("$WRAPPER" doctor "$BUNDLE" --json || true)" +echo "$DOCTOR_OUT" | python3 -c " +import json, sys +out = json.load(sys.stdin) +assert 'missing_executables' in out +assert 'reauth_actions' in out +assert 'rebuild_actions' in out +print('OK doctor bundle keys:', sorted(out.keys())) +" + +echo +echo "ACB snapshot/restore tests passed" \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-true-restore.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-true-restore.sh new file mode 100644 index 000000000..adc54eb63 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-acb-true-restore.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +TMP_ROOT="$(mktemp -d /tmp/acb-true-restore.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +BUNDLE="$TMP_ROOT/device-a.acb" +HOME_A="$TMP_ROOT/home_device_a" +WS_A="$TMP_ROOT/ws_device_a" + +HOME_B="$TMP_ROOT/home_device_b" +WS_B="$TMP_ROOT/ws_device_b" + +mkdir -p "$HOME_A/.cline/skills/awesome-skill" "$WS_A" +mkdir -p "$HOME_B" "$WS_B" + +cat > "$HOME_A/.cline/skills/awesome-skill/SKILL.md" <<'EOF' +--- +name: awesome-skill +description: Skill captured on Device A +metadata: + version: "1.0.0" +--- +# Awesome Skill from Device A +EOF + +# 1. Snapshot on Device A +HOME="$(native_path "$HOME_A")" "$WRAPPER" snapshot \ + --workspace "$WS_A" \ + --source cline/ide --target forge/cli \ + --scope user \ + --output "$BUNDLE" \ + --json >/dev/null + +echo "OK Device A snapshot generated" + +# Note: On Device B, .cline does NOT exist! (Clean new device) +[[ ! -e "$HOME_B/.cline" ]] || { echo "FAIL: .cline should not exist on Device B"; exit 1; } + +# 2. Restore on Device B (where cline is NOT installed) into forge/cli +HOME="$(native_path "$HOME_B")" "$WRAPPER" restore \ + "$BUNDLE" \ + --workspace "$WS_B" \ + --source cline/ide --target forge/cli \ + --scope user \ + --apply-safe \ + --yes \ + --json >"$TMP_ROOT/restore.json" + +echo "OK Device B restore command finished" + +# Verify that forge/skills/awesome-skill/SKILL.md was created on Device B +TARGET_SKILL="$HOME_B/forge/skills/awesome-skill/SKILL.md" +[[ -f "$TARGET_SKILL" ]] || { + echo "FAIL: true restore failed to write target skill to $TARGET_SKILL" + exit 1 +} + +grep -Fq "Awesome Skill from Device A" "$TARGET_SKILL" || { + echo "FAIL: content mismatch in restored target skill" + exit 1 +} + +echo "OK true bundle restore successfully migrated source from bundle into target IDE on clean destination" +echo +echo "True restore tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-alias-resolution.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-alias-resolution.sh new file mode 100644 index 000000000..40081899d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-alias-resolution.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_PATH="${SCRIPT_DIR}/../references/registry-v2.json" +WS="$(mktemp -d /tmp/alias-resolution-ws.XXXXXX)" +trap 'rm -rf "$WS"' EXIT + +cd "$SCRIPT_DIR" + +python3 - "$REGISTRY_PATH" "$WS" <<'PYEOF' +import contextlib +import io +import json +import sys +from pathlib import Path + +registry_path = Path(sys.argv[1]) +workspace = Path(sys.argv[2]) +sys.path.insert(0, str(registry_path.parent.parent / "scripts")) + +from migration_core import Registry # noqa: E402 +from registry.alias_resolver import resolve # noqa: E402 +from registry.exceptions import ( # noqa: E402 + AliasCycleError, + AliasDepthExceededError, + UnknownSelectorError, +) + +data = json.loads(registry_path.read_text(encoding="utf-8")) + +positive_cases = [ + ("vscode", "copilot", "vscode"), + ("visual-studio", "copilot", "visual-studio"), + ("claude-desktop", "claude", "desktop-chat"), + ("trae-cn", "trae", "cn-ide"), + ("jetbrains-ai", "jetbrains", "ai-assistant"), + ("codeium", "windsurf", "ide"), +] + +for requested, exp_product, exp_profile in positive_cases: + res = resolve(requested, data) + assert res.resolved_product == exp_product, ( + f"{requested}: expected {exp_product}, got {res.resolved_product}" + ) + assert res.resolved_profile == exp_profile, ( + f"{requested}: expected {exp_profile}, got {res.resolved_profile}" + ) + assert requested == res.requested + assert len(res.chain) >= 1 + assert res.chain[0] == requested + assert res.chain[-1] == f"{exp_product}/{exp_profile}" + assert res.deprecated is False + print(f"OK alias {requested} -> {res.resolved_product}/{res.resolved_profile}") + +# Alias + user-specified profile: alias_of.profile wins. +res = resolve("vscode/cli", data) +assert res.resolved_product == "copilot" +assert res.resolved_profile == "vscode", ( + f"vscode/cli should defer to alias_of.profile, got {res.resolved_profile}" +) +print("OK alias vscode/cli -> copilot/vscode (alias wins over user profile)") + +# Trae-work has template:cloud-ui plus alias_of:trae/ide. +res = resolve("trae-work", data) +assert res.resolved_product == "trae" +assert res.resolved_profile == "ide" +print("OK alias trae-work (cloud-ui + alias_of) -> trae/ide") + +# Non-alias selectors resolve without any chain. +res = resolve("cline/ide", data) +assert res.resolved_product == "cline" +assert res.resolved_profile == "ide" +assert res.chain == ("cline", "cline/ide"), res.chain +assert res.deprecated is False +print("OK non-alias cline/ide (no chain)") + +# Registry.profile() integration: returns the resolved tuple. +registry = Registry(registry_path, workspace) +p, pid, _ = registry.profile("vscode") +assert (p, pid) == ("copilot", "vscode"), f"got {(p, pid)}" +print("OK Registry.profile('vscode') -> copilot/vscode") + +p, pid, _ = registry.profile("claude-desktop") +assert (p, pid) == ("claude", "desktop-chat"), f"got {(p, pid)}" +print("OK Registry.profile('claude-desktop') -> claude/desktop-chat") + +# Registry.profile_raw() preserves legacy alias template output. +p, pid, _ = registry.profile_raw("vscode") +assert (p, pid) == ("vscode", "alias"), f"got {(p, pid)}" +print("OK Registry.profile_raw('vscode') -> vscode/alias (legacy preserved)") + +# Stderr log fires only when an alias chain was followed. +buf = io.StringIO() +with contextlib.redirect_stderr(buf): + registry.profile("cline/ide") +assert buf.getvalue() == "", f"unexpected stderr: {buf.getvalue()!r}" +print("OK no stderr log for non-alias selector") + +buf = io.StringIO() +with contextlib.redirect_stderr(buf): + registry.profile("vscode") +err = buf.getvalue() +assert "alias: vscode -> copilot/vscode" in err, f"got {err!r}" +print("OK stderr log for alias resolution") + +# resolve_selector returns the full ResolvedSelector (no profile-data fetch). +resolved = registry.resolve_selector("vscode") +assert resolved.requested == "vscode" +assert resolved.resolved_product == "copilot" +assert resolved.resolved_profile == "vscode" +assert resolved.chain[0] == "vscode" +assert resolved.deprecated is False +print("OK Registry.resolve_selector('vscode') preserves chain") + +# Failure modes. +try: + resolve("nonexistent-product", data) +except UnknownSelectorError as exc: + assert exc.product == "nonexistent-product" + print(f"OK UnknownSelectorError raised: {exc}") +else: + raise AssertionError("UnknownSelectorError not raised") + +# Cycle: a -> b -> a +cycle_data = json.loads(json.dumps(data)) +cycle_data["products"]["cycle-a"] = { + "template": "legacy-alias", + "alias_of": {"product": "cycle-b"}, +} +cycle_data["products"]["cycle-b"] = { + "template": "legacy-alias", + "alias_of": {"product": "cycle-a"}, +} +try: + resolve("cycle-a", cycle_data) +except AliasCycleError as exc: + assert exc.chain == ("cycle-a", "cycle-b"), exc.chain + print(f"OK AliasCycleError raised with chain: {exc.chain}") +else: + raise AssertionError("AliasCycleError not raised") + +# Depth exceeded: 17-deep chain. +deep_data = json.loads(json.dumps(data)) +deep_data["products"]["deep-0"] = { + "template": "legacy-alias", + "alias_of": {"product": "deep-1"}, +} +for index in range(1, 18): + target = f"deep-{index + 1}" if index < 17 else "cline" + deep_data["products"][f"deep-{index}"] = { + "template": "legacy-alias", + "alias_of": {"product": target}, + } +try: + resolve("deep-0", deep_data) +except AliasDepthExceededError as exc: + assert exc.limit == 16 + assert len(exc.chain) == 17, len(exc.chain) + print(f"OK AliasDepthExceededError raised (limit={exc.limit})") +else: + raise AssertionError("AliasDepthExceededError not raised") + +print() +print("Alias resolver tests passed") +PYEOF \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-antigravity-migration.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-antigravity-migration.sh new file mode 100644 index 000000000..1818600a9 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-antigravity-migration.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +TMP_ROOT="$(mktemp -d /tmp/agent-skills-antigravity-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +WORKSPACE="$TMP_ROOT/workspace" +CURSOR_MCP="$TEST_HOME/.cursor/mcp.json" +ANTIGRAVITY_MCP="$TEST_HOME/.gemini/config/mcp_config.json" + +assert_path() { + local object="$1" + local expected="$2" + local actual + + actual="$(HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy --print-path antigravity "$object")" + [[ "$actual" == "$expected" ]] +} + +assert_path global "~/.gemini/config/skills" +assert_path project ".agents" +assert_path project-skills ".agents/skills" +assert_path rules ".agents/rules" +assert_path mcp "~/.gemini/config/mcp_config.json" + +LEGACY_HOME="$TMP_ROOT/legacy-home" +mkdir -p "$LEGACY_HOME/.gemini/antigravity/skills" +LEGACY_SKILLS_PATH="$(HOME="$(native_path "$LEGACY_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy --print-path antigravity global)" +[[ "$LEGACY_SKILLS_PATH" == "~/.gemini/antigravity/skills" ]] || { + echo "FAIL: Antigravity legacy Skills tree was not preserved" >&2 + exit 1 +} + +if HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy --print-path antigravity config >/dev/null 2>&1; then + echo "FAIL: Antigravity IDE unexpectedly exposes a standalone config migration target" >&2 + exit 1 +fi + +mkdir -p "$WORKSPACE/.agents/rules" +printf '%s\n' 'Use the documented workspace rules directory.' > "$WORKSPACE/.agents/rules/style.md" +RULES_OUTPUT="$(HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source antigravity \ + --target cursor \ + --workspace "$WORKSPACE" \ + --objects rules \ + --dry-run 2>&1)" +grep -Fq "Antigravity IDE rules use a directory; manual migration required" <<< "$RULES_OUTPUT" + +PROJECT_OUTPUT="$(HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source antigravity \ + --target cursor \ + --workspace "$WORKSPACE" \ + --objects project \ + --dry-run 2>&1)" +grep -Fq "automatic whole-project configuration migration is unsupported" <<< "$PROJECT_OUTPUT" + +mkdir -p "$(dirname "$CURSOR_MCP")" +printf '%s\n' \ + '{' \ + ' "mcpServers": {' \ + ' "official-remote": {"url": "https://example.invalid/mcp"}' \ + ' }' \ + '}' > "$CURSOR_MCP" + +if HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source cursor \ + --target antigravity \ + --objects mcp \ + --strategy overwrite \ + --yes >"$TMP_ROOT/canonical.out" 2>"$TMP_ROOT/canonical.err"; then + echo "FAIL: canonical entry point wrote to the unverified Antigravity profile" >&2 + exit 1 +fi +grep -Fq "legacy writes are disabled" "$TMP_ROOT/canonical.err" +[[ ! -e "$ANTIGRAVITY_MCP" ]] || { + echo "FAIL: blocked canonical migration still created an Antigravity MCP file" >&2 + exit 1 +} + +# Exercise the retained converter only as an explicitly internal compatibility test. +AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 HOME="$(native_path "$TEST_HOME")" \ + bash "$SCRIPT_DIR/legacy-smart-ide-migration.sh" \ + --source cursor \ + --target antigravity \ + --objects mcp \ + --strategy overwrite \ + --yes >/dev/null + +python3 - "$ANTIGRAVITY_MCP" <<'PYEOF' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + server = json.load(handle)["mcpServers"]["official-remote"] + +assert server["serverUrl"] == "https://example.invalid/mcp" +assert "url" not in server +PYEOF + +echo "Antigravity IDE migration test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-audit-e2e-matrix.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-audit-e2e-matrix.sh new file mode 100644 index 000000000..d12e24279 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-audit-e2e-matrix.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +python3 - "$SKILL_DIR/references/registry-v2.json" <<'PY' +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(sys.argv[1]).parent.parent / "scripts")) +from migration_core import Registry, build_plan, apply_plan + +e2e_pairs = [ + ("cursor/ide", "claude/code-cli"), + ("claude/code-cli", "copilot/vscode"), + ("cline/ide", "cursor/ide"), + ("gemini-cli/cli", "codex/cli"), + ("kiro/ide", "continue/cli"), + ("windsurf/ide", "opencode/cli"), + ("qwen-code/cli", "factory-droid/cli"), + ("copilot/vscode", "rovodev/cli"), + ("openhands/cli", "sourcegraph-amp/cli"), +] + +reg_path = Path(sys.argv[1]) + +for src, dst in e2e_pairs: + with tempfile.TemporaryDirectory() as tmp: + ws = Path(tmp) / "ws" + home = Path(tmp) / "home" + ws.mkdir() + home.mkdir() + + reg = Registry(reg_path, ws, home) + # Locate the fixture through the Registry's own platform-aware + # resolution so the created tree matches what build_plan will + # resolve (windows hosts map some surfaces to %APPDATA% etc.). + # Glob compatibility paths (github.copilot-*) cannot host a + # literal fixture directory on Windows; use a concrete surface. + glob_chars = ("*", "?", "[") + surface = next( + s + for s in reg.surfaces(src, "skills") + if not any(c in str(s.resolved_path) for c in glob_chars) + ) + skill_dir = Path(surface.resolved_path) / "demo-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + '---\nname: demo-skill\ndescription: Demo fixture.\nmetadata:\n version: "1"\n---\n# Demo\n', + encoding="utf-8", + ) + + plan, loss = build_plan( + reg, + src, + dst, + ["skills"], + surface.scope, + ) + assert len(plan) == 1 + assert plan[0].status == "ready", f"{src} -> {dst} plan item status was {plan[0].status}: {plan[0].reason}" + manifest, _ = apply_plan(plan, ws) + assert manifest["summary"]["applied"] == 1 + print(f"PASS: E2E {src} -> {dst}") + +print("All 9 audit report E2E migration pairs verified.") +PY + +echo "Audit E2E migration matrix tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-claude-code-mapping.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-claude-code-mapping.sh new file mode 100644 index 000000000..bd3f063ac --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-claude-code-mapping.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +MIGRATION_SCRIPT="${SCRIPT_DIR}/smart-ide-migration.sh" +PATHS_FILE="${SCRIPT_DIR}/../references/ide-paths.json" +IDE_REFERENCE="${SCRIPT_DIR}/../references/ides/claude.md" + +failures=0 + +check_path() { + local object="$1" + local expected="$2" + local actual + if ! actual="$(bash "$MIGRATION_SCRIPT" legacy --print-path claude "$object")"; then + actual="" + fi + + if [[ "$actual" == "$expected" ]]; then + echo "PASS: claude/${object} -> ${actual}" + else + echo "FAIL: claude/${object}; expected '${expected}', got '${actual}'" >&2 + failures=$((failures + 1)) + fi +} + +check_path global '~/.claude/skills' +check_path project '.claude' +check_path project-skills '.claude/skills' +check_path rules 'CLAUDE.md' +check_path mcp '~/.claude.json' +check_path project-mcp '.mcp.json' +check_path config '~/.claude/settings.json' + +if python3 - "$PATHS_FILE" <<'PYEOF' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + claude = json.load(handle)["claude"] + +expected = { + "global_skills": "~/.claude/skills", + "project_skills": ".claude/skills", + "rules": "CLAUDE.md", + "mcp": "~/.claude.json", + "project_mcp": ".mcp.json", + "project_config": ".claude/settings.json", + "config": "~/.claude/settings.json", +} +if claude != expected: + raise SystemExit(f"unexpected Claude Code mapping: {claude!r}") +PYEOF +then + echo "PASS: ide-paths.json has the Claude Code canonical mapping" +else + echo "FAIL: ide-paths.json does not have the Claude Code canonical mapping" >&2 + failures=$((failures + 1)) +fi + +for expected_text in \ + '.mcp.json' \ + '.claude/settings.local.json' \ + 'legacy compatibility' \ + 'Do not auto-migrate auto memory'; do + if grep -Fq "$expected_text" "$IDE_REFERENCE"; then + echo "PASS: Claude reference documents '${expected_text}'" + else + echo "FAIL: Claude reference lacks '${expected_text}'" >&2 + failures=$((failures + 1)) + fi +done + +FIXTURE_ROOT="$(mktemp -d /tmp/claude-code-mapping.XXXXXX)" +trap 'rm -rf "$FIXTURE_ROOT"' EXIT +FIXTURE_HOME="$FIXTURE_ROOT/home" +FIXTURE_WORKSPACE="$FIXTURE_ROOT/workspace" +mkdir -p "$FIXTURE_HOME/.cursor" "$FIXTURE_HOME/Library/Application Support/Cursor/User" "$FIXTURE_WORKSPACE" +printf '%s\n' '{"mcpServers":{"fixture":{"command":"echo"}}}' > "$FIXTURE_HOME/.cursor/mcp.json" +printf '%s\n' '{"editor.fontSize":14}' > "$FIXTURE_HOME/Library/Application Support/Cursor/User/settings.json" + +MCP_SCOPE_OUTPUT="$(HOME="$(native_path "$FIXTURE_HOME")" bash "$MIGRATION_SCRIPT" legacy --source cursor --target claude --workspace "$FIXTURE_WORKSPACE" --objects mcp --dry-run 2>&1)" +if grep -Fq 'selected global/user scope' <<< "$MCP_SCOPE_OUTPUT" && grep -Fq 'project .mcp.json' <<< "$MCP_SCOPE_OUTPUT"; then + echo "PASS: Claude MCP fixture preserves project and local scopes for manual review" +else + echo "FAIL: Claude MCP fixture must label project/local scopes manual" >&2 + failures=$((failures + 1)) +fi + +CONFIG_SCOPE_OUTPUT="$(HOME="$(native_path "$FIXTURE_HOME")" bash "$MIGRATION_SCRIPT" legacy --source cursor --target claude --workspace "$FIXTURE_WORKSPACE" --objects config --dry-run 2>&1)" +if grep -Fq 'automatic whole-IDE config migration is unsupported' <<< "$CONFIG_SCOPE_OUTPUT"; then + echo "PASS: Claude settings fixture preserves project/local scopes for manual review" +else + echo "FAIL: Claude settings fixture must label project/local scopes manual" >&2 + failures=$((failures + 1)) +fi + +if [[ $failures -eq 0 ]]; then + echo "Claude Code mapping test passed" + exit 0 +fi + +echo "Claude Code mapping test failed: ${failures} assertion(s)" >&2 +exit 1 diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-claude-desktop-mapping.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-claude-desktop-mapping.sh new file mode 100644 index 000000000..4b92c2bba --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-claude-desktop-mapping.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATION_SCRIPT="${SCRIPT_DIR}/smart-ide-migration.sh" +PATHS_FILE="${SCRIPT_DIR}/../references/ide-paths.json" +IDE_REFERENCE="${SCRIPT_DIR}/../references/ides/claude-desktop.md" +SKILL_FILE="${SCRIPT_DIR}/../SKILL.md" + +case "$(uname -s)" in + Darwin|MINGW*|MSYS*|CYGWIN*) + actual="$(bash "$MIGRATION_SCRIPT" legacy --print-path claude-desktop mcp 2>/dev/null)" + [[ -n "$actual" && "$actual" == *claude_desktop_config.json ]] || { + echo "FAIL: Claude Desktop documented platform path was not resolved: ${actual}" >&2 + exit 1 + } + ;; + *) + if actual="$(bash "$MIGRATION_SCRIPT" legacy --print-path claude-desktop mcp 2>/dev/null)"; then + echo "FAIL: Claude Desktop unexpectedly exposed an unconfirmed platform path: ${actual}" >&2 + exit 1 + fi + [[ -z "$actual" ]] || { + echo "FAIL: unsupported Claude Desktop MCP target printed: ${actual}" >&2 + exit 1 + } + ;; +esac + +python3 - "$PATHS_FILE" <<'PYEOF' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + entry = json.load(handle)["claude-desktop"] + +assert entry["global_skills"] == "" +assert entry["project_skills"] == "" +assert entry["rules"] == "" +assert entry["config"] == "" +assert isinstance(entry["mcp"], dict) +assert entry["mcp"]["darwin"] == "~/Library/Application Support/Claude/claude_desktop_config.json" +assert entry["mcp"]["linux"] == "" +assert entry["mcp"]["windows"] == "%APPDATA%\\Claude\\claude_desktop_config.json" +PYEOF + +section="$(<"$IDE_REFERENCE")" +for required in \ + 'https://modelcontextprotocol.io/docs/develop/connect-local-servers' \ + 'https://claude.com/docs/connectors/building/mcpb' \ + 'https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop' \ + 'https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp'; do + if ! grep -Fq "$required" <<< "$section"; then + echo "FAIL: Claude Desktop reference is missing source ${required}" >&2 + exit 1 + fi +done + +if ! grep -Fq 'claude_desktop_config.json' <<< "$section"; then + echo "FAIL: Claude Desktop reference is missing the documented legacy JSON path" >&2 + exit 1 +fi + +if ! grep -Fq 'Claude Desktop app' "$SKILL_FILE" || \ + ! grep -Fq 'Settings → Extensions' "$SKILL_FILE" || \ + ! grep -Fq 'Settings → Connectors' "$SKILL_FILE"; then + echo "FAIL: canonical SKILL.md is missing Claude Desktop's manual MCP boundary" >&2 + exit 1 +fi + +TMP_ROOT="$(mktemp -d /tmp/claude-desktop-mapping-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT +mkdir -p "$TMP_ROOT/home" "$TMP_ROOT/workspace" + +target_output="$(HOME="$TMP_ROOT/home" bash "$MIGRATION_SCRIPT" legacy \ + --source claude --target claude-desktop --workspace "$TMP_ROOT/workspace" \ + --objects mcp --dry-run 2>&1)" +source_output="$(HOME="$TMP_ROOT/home" bash "$MIGRATION_SCRIPT" legacy \ + --source claude-desktop --target cursor --workspace "$TMP_ROOT/workspace" \ + --objects mcp --dry-run 2>&1)" +case "$(uname -s)" in + Darwin|MINGW*|MSYS*|CYGWIN*) + grep -Fq 'legacy local MCP JSON' <<< "$target_output" + grep -Fq 'legacy local MCP JSON' <<< "$source_output" + ;; + *) + grep -Fq 'no confirmed legacy JSON path' <<< "$target_output" + grep -Fq 'no confirmed legacy JSON path' <<< "$source_output" + ;; +esac + +if [[ "$(uname -s)" == "Darwin" ]]; then + DESKTOP_CONFIG="$TMP_ROOT/home/Library/Application Support/Claude/claude_desktop_config.json" + mkdir -p "$(dirname "$DESKTOP_CONFIG")" + printf '%s\n' '{"mcpServers":{"desktop-local":{"command":"node","args":["server.js"],"env":{"API_KEY":"__desktop_inert_fixture__"}}}}' > "$DESKTOP_CONFIG" + if HOME="$TMP_ROOT/home" bash "$MIGRATION_SCRIPT" legacy \ + --source claude-desktop --target cursor --workspace "$TMP_ROOT/workspace" \ + --objects mcp --yes --strategy overwrite \ + >"$TMP_ROOT/canonical.out" 2>"$TMP_ROOT/canonical.err"; then + echo "FAIL: canonical entry point wrote from the legacy Claude Desktop alias" >&2 + exit 1 + fi + grep -Fq 'legacy writes are disabled' "$TMP_ROOT/canonical.err" + [[ ! -e "$TMP_ROOT/home/.cursor/mcp.json" ]] + + AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 HOME="$TMP_ROOT/home" \ + bash "${SCRIPT_DIR}/legacy-smart-ide-migration.sh" \ + --source claude-desktop --target cursor --workspace "$TMP_ROOT/workspace" \ + --objects mcp --yes --strategy overwrite >/dev/null 2>&1 + python3 - "$TMP_ROOT/home/.cursor/mcp.json" <<'PYEOF' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +assert data["mcpServers"]["desktop-local"]["command"] == "node" +assert data["mcpServers"]["desktop-local"]["env"]["API_KEY"] == "" +PYEOF +fi + +echo "Claude Desktop mapping test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-codex-migration.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-codex-migration.sh new file mode 100644 index 000000000..8320c9645 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-codex-migration.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +TMP_ROOT="$(mktemp -d /tmp/codex-migration-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +SOURCE_MCP="$TEST_HOME/.claude.json" +TARGET_MCP="$TEST_HOME/.codex/config.toml" +SOURCE_CONFIG="$TEST_HOME/.codex/config.toml" +TARGET_CONFIG="$TEST_HOME/.openclaw/openclaw.json" +OUTPUT="$TMP_ROOT/mcp.txt" +CONFIG_OUTPUT="$TMP_ROOT/config.txt" + +assert_path() { + local object="$1" + local expected="$2" + local actual + + actual="$(HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy --print-path codex "$object")" + [[ "$actual" == "$expected" ]] || { + echo "FAIL: codex/${object} expected '${expected}', got '${actual}'" >&2 + exit 1 + } +} + +assert_path global "~/.agents/skills" +assert_path project ".agents" +assert_path project-skills ".agents/skills" +assert_path project-config ".codex/config.toml" +assert_path mcp "~/.codex/config.toml" +assert_path config "~/.codex/config.toml" + +mkdir -p "$TEST_HOME" +printf '%s\n' \ + '{' \ + ' "mcpServers": {' \ + ' "example": {"url": "https://example.invalid/mcp"}' \ + ' }' \ + '}' > "$SOURCE_MCP" + +if HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source claude \ + --target codex \ + --objects mcp \ + --strategy overwrite \ + --yes > "$OUTPUT" 2>"$TMP_ROOT/mcp.err"; then + echo "FAIL: canonical entry point authorized the manual Codex TOML adapter" >&2 + exit 1 +fi + +if [[ -e "$TARGET_MCP" ]]; then + echo "FAIL: JSON MCP configuration was written to Codex TOML config" >&2 + exit 1 +fi + +grep -Fq 'legacy writes are disabled' "$TMP_ROOT/mcp.err" + +mkdir -p "$(dirname "$SOURCE_CONFIG")" +printf '%s\n' \ + '[mcp_servers.example]' \ + 'url = "https://example.invalid/mcp"' \ + '' \ + '[hooks]' \ + 'enabled = true' > "$SOURCE_CONFIG" + +if HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source codex \ + --target openclaw \ + --objects config \ + --strategy overwrite \ + --yes > "$CONFIG_OUTPUT" 2>"$TMP_ROOT/config.err"; then + echo "FAIL: canonical entry point authorized whole-IDE config migration" >&2 + exit 1 +fi + +if [[ -e "$TARGET_CONFIG" ]]; then + echo "FAIL: Codex config.toml was copied to a non-Codex configuration target" >&2 + exit 1 +fi + +grep -Fq 'legacy writes are disabled' "$TMP_ROOT/config.err" +echo "Codex migration mapping test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-conflict-strategies.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-conflict-strategies.sh new file mode 100644 index 000000000..c883310f1 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-conflict-strategies.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} + +# shasum(1) is macOS-only; Git Bash ships sha256sum, and python3 is the +# portable last resort. +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$1" + fi +} +# Conflict-strategy mechanics belong to the retained compatibility engine. The +# public entry point is covered separately by test-legacy-registry-gate.sh. +MIGRATION_SCRIPT="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 +TMP_ROOT="$(mktemp -d /tmp/conflict-strategies-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +SOURCE_FILE="$TMP_ROOT/cursor-mcp.json" +mkdir -p "$TEST_HOME/.config/opencode" + +printf '%s\n' '{"mcpServers":{"same":{"command":"new-server"}}}' > "$SOURCE_FILE" + +SOURCE_SKILL="$TEST_HOME/.cursor/skills/demo" +TARGET_SKILL="$TEST_HOME/.claude/skills/demo" +mkdir -p "$SOURCE_SKILL" "$TARGET_SKILL" +printf '%s\n' 'source skill' > "$SOURCE_SKILL/SKILL.md" +printf '%s\n' 'existing target skill' > "$TARGET_SKILL/SKILL.md" +TARGET_HASH_BEFORE="$(sha256_file "$TARGET_SKILL/SKILL.md")" + +set +e +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target claude --objects skills \ + --strategy typo --yes >"$TMP_ROOT/invalid-strategy.log" 2>&1 +INVALID_STRATEGY_RC=$? +set -e + +if [[ $INVALID_STRATEGY_RC -eq 0 ]]; then + echo "FAIL: unknown strategy exited successfully" >&2 + exit 1 +fi + +TARGET_HASH_AFTER="$(sha256_file "$TARGET_SKILL/SKILL.md")" +if [[ "$TARGET_HASH_AFTER" != "$TARGET_HASH_BEFORE" ]]; then + echo "FAIL: unknown strategy modified the existing target" >&2 + exit 1 +fi + +if find "$TEST_HOME/.claude/skills" -maxdepth 1 -type d -name 'demo.bak.*' -print -quit | grep -q .; then + echo "FAIL: unknown strategy created a backup before rejection" >&2 + exit 1 +fi + +if ! grep -q 'invalid strategy' "$TMP_ROOT/invalid-strategy.log"; then + echo "FAIL: unknown strategy did not produce a clear validation error" >&2 + exit 1 +fi + +echo "PASS: unknown strategy fails before modifying an existing target" + +WORKSPACE="$TMP_ROOT/workspace" +mkdir -p "$WORKSPACE/.claude/commands" "$WORKSPACE/.opencode/commands" +printf '%s\n' 'source rule' > "$WORKSPACE/CLAUDE.md" +printf '%s\n' 'existing target rule' > "$WORKSPACE/AGENTS.md" +printf '%s\n' 'source prompt' > "$WORKSPACE/.claude/commands/demo.md" +printf '%s\n' 'existing target prompt' > "$WORKSPACE/.opencode/commands/demo.md" + +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target opencode --workspace "$WORKSPACE" \ + --objects rules,prompts --strategy backup --yes >"$TMP_ROOT/rules-prompts-backup.log" 2>&1 + +grep -Fxq 'source rule' "$WORKSPACE/AGENTS.md" || { + echo "FAIL: backup strategy did not migrate the rule" >&2 + exit 1 +} +RULE_BACKUP="$(find "$WORKSPACE" -maxdepth 1 -name 'AGENTS.md.bak.*' -print -quit)" +[[ -n "$RULE_BACKUP" ]] || { + echo "FAIL: backup strategy did not preserve the existing rule" >&2 + exit 1 +} +grep -Fxq 'existing target rule' "$RULE_BACKUP" || { + echo "FAIL: rule backup did not preserve existing content" >&2 + exit 1 +} +grep -Fxq 'source prompt' "$WORKSPACE/.opencode/commands/demo.md" || { + echo "FAIL: backup strategy did not migrate the prompt" >&2 + exit 1 +} +PROMPT_BACKUP="$(find "$WORKSPACE/.opencode" -maxdepth 1 -type d -name 'commands.bak.*' -print -quit)" +[[ -n "$PROMPT_BACKUP" ]] || { + echo "FAIL: backup strategy did not preserve the existing prompt directory" >&2 + exit 1 +} +grep -Fxq 'existing target prompt' "$PROMPT_BACKUP/demo.md" || { + echo "FAIL: prompt backup did not preserve existing content" >&2 + exit 1 +} + +echo "PASS: rules and prompts honor the backup conflict strategy" + +write_existing_target() { + printf '%s\n' '{"theme":"dark","mcp":{"keep":{"type":"local","command":["keep-server"]},"same":{"type":"local","command":["old-server"]}}}' \ + > "$TEST_HOME/.config/opencode/opencode.json" +} + +write_existing_target +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target opencode --objects mcp \ + --source-mcp-file "$SOURCE_FILE" --strategy backup --yes >/dev/null + +python3 - "$TEST_HOME/.config/opencode/opencode.json" <<'PYEOF' +import json +import sys + +data = json.load(open(sys.argv[1])) +assert data["theme"] == "dark" +assert data["mcp"]["keep"]["command"] == ["keep-server"] +assert data["mcp"]["same"]["command"] == ["new-server"] +PYEOF + +BACKUP_FILE="$(find "$TEST_HOME/.config/opencode" -maxdepth 1 -name 'opencode.json.bak.*' -print -quit)" +python3 - "$BACKUP_FILE" <<'PYEOF' +import json +import sys + +data = json.load(open(sys.argv[1])) +assert data["theme"] == "dark" +assert data["mcp"]["same"]["command"] == ["old-server"] +PYEOF + +rm -f "$TEST_HOME/.config/opencode"/opencode.json.bak.* +write_existing_target +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target opencode --objects mcp \ + --source-mcp-file "$SOURCE_FILE" --strategy overwrite --yes >/dev/null + +python3 - "$TEST_HOME/.config/opencode/opencode.json" <<'PYEOF' +import json +import sys + +data = json.load(open(sys.argv[1])) +assert data["theme"] == "dark", "overwrite removed an unrelated top-level setting" +assert set(data["mcp"]) == {"same"}, "overwrite did not replace only the selected MCP map" +assert data["mcp"]["same"]["command"] == ["new-server"] +PYEOF + +if find "$TEST_HOME/.config/opencode" -maxdepth 1 -name 'opencode.json.bak.*' -print -quit | grep -q .; then + echo "FAIL: overwrite unexpectedly created a backup" >&2 + exit 1 +fi + +echo "PASS: MCP backup/overwrite conflict strategies preserve their documented boundaries" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-copilot-mapping.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-copilot-mapping.sh new file mode 100644 index 000000000..9338eb767 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-copilot-mapping.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +MIGRATION_SCRIPT="${SCRIPT_DIR}/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 +TMP_ROOT="$(mktemp -d /tmp/copilot-mapping-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +WORKSPACE="$TMP_ROOT/workspace" +CURSOR_MCP="$TEST_HOME/.cursor/mcp.json" + +mkdir -p "$(dirname "$CURSOR_MCP")" "$WORKSPACE" + +assert_path() { + local object="$1" + local expected="$2" + local actual + + actual="$(HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" --print-path copilot "$object")" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: copilot/${object} expected '${expected}', got '${actual}'" >&2 + exit 1 + fi +} + +assert_path project ".github" +assert_path project-skills ".github/skills" +assert_path project-mcp ".mcp.json" +assert_path mcp "~/.copilot/mcp-config.json" + +cat > "$CURSOR_MCP" <<'JSON' +{ + "mcpServers": { + "unsupported": { + "type": "websocket", + "url": "wss://example.invalid/mcp", + "tools": ["*"] + } + } +} +JSON + +OUTPUT="$TMP_ROOT/output.txt" +set +e +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor \ + --target copilot \ + --workspace "$WORKSPACE" \ + --objects mcp \ + --yes >"$OUTPUT" 2>&1 +RC=$? +set -e + +if [[ $RC -eq 0 ]] && ! grep -Fq 'MCP config migration failed' "$OUTPUT"; then + echo "FAIL: unsupported Copilot CLI MCP transport was not rejected" >&2 + cat "$OUTPUT" >&2 + exit 1 +fi + +if [[ -e "$TEST_HOME/.copilot/mcp-config.json" ]]; then + echo "FAIL: unsupported Copilot CLI MCP transport was written" >&2 + exit 1 +fi + +cat > "$CURSOR_MCP" <<'JSON' +{ + "mcpServers": { + "local-tool": { + "type": "stdio", + "command": "node", + "args": ["server.js"], + "tools": ["*"] + } + } +} +JSON + +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor \ + --target copilot \ + --workspace "$WORKSPACE" \ + --objects mcp \ + --yes >"$OUTPUT" 2>&1 + +python3 - "$TEST_HOME/.copilot/mcp-config.json" <<'PY' +import json +import sys + +with open(sys.argv[1]) as f: + config = json.load(f) + +server = config.get("mcpServers", {}).get("local-tool", {}) +if server.get("type") != "stdio" or server.get("command") != "node": + raise SystemExit("CLI MCP conversion did not preserve the documented stdio entry") +if "servers" in config: + raise SystemExit("VS Code MCP root key leaked into CLI config") +PY + +echo "Copilot CLI mapping fixture passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-cursor-mapping.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-cursor-mapping.sh new file mode 100644 index 000000000..7ce4bc027 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-cursor-mapping.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATION_SCRIPT="$SCRIPT_DIR/smart-ide-migration.sh" +PATHS_FILE="$SCRIPT_DIR/../references/ide-paths.json" +IDE_REFERENCE="$SCRIPT_DIR/../references/ides/cursor.md" + +assert_path() { + local object="$1" expected="$2" actual + actual="$(bash "$MIGRATION_SCRIPT" legacy --print-path cursor "$object" 2>/dev/null || true)" + [[ "$actual" == "$expected" ]] || { + echo "FAIL: cursor/${object}; expected '${expected}', got '${actual}'" >&2 + exit 1 + } +} + +assert_path global '~/.cursor/skills' +assert_path project '.cursor' +assert_path project-skills '.cursor/skills' +assert_path rules '.cursor/rules' +assert_path mcp '~/.cursor/mcp.json' +assert_path project-mcp '.cursor/mcp.json' +assert_path config '' + +python3 - "$PATHS_FILE" <<'PYEOF' +import json, sys +cursor = json.load(open(sys.argv[1], encoding="utf-8"))["cursor"] +expected = { + "global_skills": "~/.cursor/skills", + "project_skills": ".cursor/skills", + "rules": ".cursor/rules", + "mcp": "~/.cursor/mcp.json", + "project_mcp": ".cursor/mcp.json", + "config": "", +} +if cursor != expected: + raise SystemExit(f"unexpected Cursor mapping: {cursor!r}") +PYEOF + +for expected_text in \ + '.cursor/mcp.json' \ + '~/.cursor/mcp.json' \ + '.cursor/rules' \ + '.cursor/skills' \ + '.cursor/commands' \ + 'remain manual'; do + grep -Fq "$expected_text" "$IDE_REFERENCE" || { + echo "FAIL: Cursor reference lacks '${expected_text}'" >&2 + exit 1 + } +done + +FIXTURE_ROOT="$(mktemp -d /tmp/cursor-mapping.XXXXXX)" +trap 'rm -rf "$FIXTURE_ROOT"' EXIT +WORKSPACE="$FIXTURE_ROOT/workspace" +mkdir -p "$WORKSPACE/.cursor/rules" +printf '%s\n' '---' 'description: fixture' 'alwaysApply: true' '---' 'Use the fixture.' > "$WORKSPACE/.cursor/rules/fixture.mdc" + +OUTPUT="$(bash "$MIGRATION_SCRIPT" legacy --source cursor --target claude \ + --workspace "$WORKSPACE" --objects rules --dry-run 2>&1)" +grep -Fq 'manual' <<<"$OUTPUT" +grep -Fq '.cursor/rules' <<<"$OUTPUT" + +echo "Cursor mapping and directory-safety fixture passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-detection-probes.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-detection-probes.sh new file mode 100644 index 000000000..facc4daae --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-detection-probes.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_PATH="${SCRIPT_DIR}/../references/registry-v2.json" + +cd "$SCRIPT_DIR" + +python3 - "$REGISTRY_PATH" "$SCRIPT_DIR" <<'PYEOF' +import sys +from pathlib import Path + +registry_path = Path(sys.argv[1]) +script_dir = Path(sys.argv[2]) +sys.path.insert(0, str(registry_path.parent.parent / "scripts")) + +from detect.probes import ( # noqa: E402 + InstallState, + detect_profile, + probe_binary, + probe_file_signature, + probe_app_bundle, +) + +# App bundle probe: test bundle ID allowlist rejects injection attempts +res = probe_app_bundle("test", "ide", darwin_bundle_id="com.example.app' || rm -rf /") +assert res.state is InstallState.NOT_DETECTED, res +res = probe_app_bundle("test", "ide", darwin_bundle_id="valid-bundle.id-123") +# Should not crash or inject +print("OK probe_app_bundle rejects invalid bundle ID injection") + +# Binary probe: python3 is always available; cline likely is not. +res = probe_binary("cline", "ide", ["cline"]) +assert res.state is InstallState.NOT_DETECTED, res +print("OK probe_binary returns not-detected for missing binary") + +res = probe_binary("python-mock", "ide", ["python3"], version_command=["python3", "--version"]) +assert res.state is InstallState.INSTALLED, res +assert any("binary:" in e for e in res.evidence), res.evidence +print(f"OK probe_binary found python3 with evidence: {res.evidence}") + +# File-signature probe: use a guaranteed temporary test fixture. +import tempfile +tmp_home = Path(tempfile.mkdtemp(prefix="detect-probe-test-")) +fixture_file = tmp_home / ".config_fixture" +fixture_file.write_text("dummy", encoding="utf-8") + +res = probe_file_signature( + "test", + "ide", + [fixture_file, tmp_home / "__acb_probe_should_not_exist__"], +) +assert res.state is InstallState.INSTALLED, res +print("OK probe_file_signature found fixture file") + +# detect_profile convenience wrapper. +res = detect_profile( + "python-mock", + "ide", + binaries=["python3"], + version_command=["python3", "--version"], + home=tmp_home, +) +assert res.state is InstallState.INSTALLED, res +print("OK detect_profile composes binary + version probes") + +# detect_profile falls back to file-signature when binary missing. +res = detect_profile( + "fs-only", + "ide", + binaries=["this-binary-does-not-exist"], + file_signatures=[str(fixture_file)], + home=tmp_home, +) +assert res.state is InstallState.INSTALLED, res +print("OK detect_profile falls back to file signature") + +# All probe states are distinct. +states = {s.value for s in InstallState} +assert len(states) == len(InstallState), states +print(f"OK InstallState has {len(InstallState)} distinct values: {sorted(states)}") + +print() +print("Detection probe tests passed") +PYEOF \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-doc-freshness.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-doc-freshness.sh new file mode 100644 index 000000000..bec9ee00c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-doc-freshness.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +python3 "$SCRIPT_DIR/check-doc-freshness.py" \ + --registry "$SKILL_DIR/references/registry-v2.json" \ + --checks "$SKILL_DIR/references/doc-freshness-checks.json" \ + --today 2026-08-17 \ + --report "$TMP_ROOT/report.json" > "$TMP_ROOT/stdout.json" + +python3 - "$TMP_ROOT/report.json" <<'PY' +import json +import sys +from pathlib import Path + +report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert report["ok"] is True +assert report["online"] is False +assert report["results"] == [] +PY + +python3 - \ + "$SKILL_DIR/references/doc-freshness-checks.json" \ + "$TMP_ROOT/bad-checks.json" <<'PY' +import json +import sys +from pathlib import Path + +document = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +document["checks"][0]["url"] = "http://insecure.example.test" +Path(sys.argv[2]).write_text(json.dumps(document), encoding="utf-8") +PY + +if python3 "$SCRIPT_DIR/check-doc-freshness.py" \ + --registry "$SKILL_DIR/references/registry-v2.json" \ + --checks "$TMP_ROOT/bad-checks.json" \ + --today 2026-08-13 > "$TMP_ROOT/bad.log"; then + echo "FAIL: insecure freshness URL passed" >&2 + exit 1 +fi +grep -Fq 'URL must use HTTPS' "$TMP_ROOT/bad.log" + +echo "Documentation freshness tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-emacs-mapping.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-emacs-mapping.sh new file mode 100644 index 000000000..7044d6529 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-emacs-mapping.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATION_SCRIPT="$SCRIPT_DIR/smart-ide-migration.sh" + +for object in global project project-skills rules mcp project-mcp project-config config; do + if bash "$MIGRATION_SCRIPT" legacy --print-path emacs "$object" >/dev/null 2>&1; then + echo "FAIL: emacs/$object must be unsupported/manual" >&2 + exit 1 + fi +done + +TMP_ROOT="$(mktemp -d /tmp/emacs-mapping-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +if ! output=$(bash "$MIGRATION_SCRIPT" legacy \ + --source codex --target emacs --workspace "$TMP_ROOT" \ + --objects skills,rules,mcp,config,project --dry-run 2>&1); then + echo "FAIL: Emacs dry-run boundary exited non-zero" >&2 + exit 1 +fi + +grep -Fq 'target IDE has no global skills directory' <<<"$output" +grep -Fq 'target IDE does not support rules files' <<<"$output" +grep -Fq 'target IDE does not support MCP configuration' <<<"$output" +grep -Fq 'automatic whole-IDE config migration is unsupported' <<<"$output" +grep -Fq 'automatic whole-project configuration migration is unsupported' <<<"$output" + +echo "Emacs mapping boundary test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-eval-coverage.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-eval-coverage.sh new file mode 100644 index 000000000..a232882a1 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-eval-coverage.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +python3 - "$SKILL_ROOT/evals/evals.json" "$SKILL_ROOT/evals/trigger-evals.json" <<'PYEOF' +import json +import sys +from pathlib import Path + +evals_path = Path(sys.argv[1]) +trigger_evals_path = Path(sys.argv[2]) + +eval_data = json.loads(evals_path.read_text(encoding="utf-8")) +evals = eval_data.get("evals") +assert isinstance(evals, list), "evals.json must contain an evals array" + +eval_ids = [item.get("id") for item in evals] +assert eval_ids == list(range(1, 9)), ( + "evals.json must cover stable IDs 1-8 for core, execution, trigger, " + "conflict, VS Code profile, and OpenCode V2 cases" +) +for item in evals: + assert isinstance(item.get("prompt"), str) and item["prompt"].strip(), ( + f"eval {item.get('id')} is missing a prompt" + ) + assertions = item.get("assertions") + assert isinstance(assertions, list) and assertions, ( + f"eval {item.get('id')} must have non-empty assertions" + ) + assert "expectations" not in item, ( + f"eval {item.get('id')} uses legacy expectations instead of assertions" + ) + +assert trigger_evals_path.is_file(), "missing evals/trigger-evals.json" +trigger_evals = json.loads(trigger_evals_path.read_text(encoding="utf-8")) +assert isinstance(trigger_evals, list), "trigger-evals.json must be a JSON array" +assert len(trigger_evals) == 20, "trigger-evals.json must contain exactly 20 cases" + +queries = [item.get("query") for item in trigger_evals] +assert all(isinstance(query, str) and query.strip() for query in queries), ( + "every trigger eval needs a non-empty query" +) +assert len(set(queries)) == len(queries), "trigger eval queries must be unique" + +labels = [item.get("should_trigger") for item in trigger_evals] +assert all(isinstance(label, bool) for label in labels), ( + "every trigger eval needs a boolean should_trigger label" +) +assert labels.count(True) == 10 and labels.count(False) == 10, ( + "trigger evals must contain ten positive and ten negative cases" +) + +print("Evaluation coverage test passed (8 behavior evals, 20 trigger evals)") +PYEOF diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-freshness-expanded.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-freshness-expanded.sh new file mode 100644 index 000000000..5207b0bfa --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-freshness-expanded.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_PATH="${SCRIPT_DIR}/../references/registry-v2.json" +CHECKS_PATH="${SCRIPT_DIR}/../references/doc-freshness-checks.json" + +cd "$SCRIPT_DIR" + +# Offline: schema and provenance validation only (no network probes). +python3 - "$REGISTRY_PATH" "$CHECKS_PATH" <<'PYEOF' +import json +import sys +from pathlib import Path +from datetime import date + +registry_path = Path(sys.argv[1]) +checks_path = Path(sys.argv[2]) +sys.path.insert(0, str(registry_path.parent.parent / "scripts")) + +from doc_freshness import load_object, validate_provenance + +registry = load_object(registry_path) +checks_doc = load_object(checks_path) + +# Every check must have a unique id, an HTTPS URL, and at least one +# required term. +identifiers: set[str] = set() +ids: list[str] = [] +for check in checks_doc.get("checks", []): + identifier = check["id"] + assert identifier not in identifiers, f"duplicate id: {identifier}" + identifiers.add(identifier) + ids.append(identifier) + assert check["url"].startswith("https://"), check + assert check.get("required_terms"), check +print(f"OK checks schema: {len(ids)} unique IDs with HTTPS URLs and required terms") + +# Provenance: every active profile must have verified_at inside the +# 365-day window and at least one official HTTPS source. +errors = validate_provenance(registry, date(2026, 8, 17), 365) +fresh_errors = [e for e in errors if "outside freshness window" in e or "verified_at" in e] +assert not fresh_errors, fresh_errors +print("OK every active profile has a recent verified_at (no stale demotions)") + +source_errors = [e for e in errors if "missing official sources" in e or "source must use HTTPS" in e] +assert not source_errors, source_errors +print("OK every active profile has at least one HTTPS official source") + +# Coverage: at least one check per "active" profile bucket. +active_products = [ + product_id + for product_id, product in registry.get("products", {}).items() + if product.get("lifecycle") == "active" +] +covered = 0 +for check_id in ids: + for product_id in active_products: + if product_id in check_id: + covered += 1 + break +print(f"OK freshness covers {covered} of {len(active_products)} active products") +PYEOF diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-ide-paths.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-ide-paths.sh new file mode 100644 index 000000000..5371d88bb --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-ide-paths.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JSON_FILE="${SCRIPT_DIR}/../references/ide-paths.json" +MIGRATION_SCRIPT="${SCRIPT_DIR}/smart-ide-migration.sh" +IDE_REFERENCE_DIR="${SCRIPT_DIR}/../references/ides" + +if [[ ! -f "$JSON_FILE" ]]; then + echo "ERROR: cannot find ide-paths.json at $JSON_FILE" >&2 + exit 1 +fi +if [[ ! -f "$MIGRATION_SCRIPT" ]]; then + echo "ERROR: cannot find smart-ide-migration.sh at $MIGRATION_SCRIPT" >&2 + exit 1 +fi + +failures=0 +checks=0 + +dump_rows() { + python3 - "$JSON_FILE" "$@" <<'PYEOF' +import json, sys, platform +data = json.load(open(sys.argv[1])) +keymap = {"global_skills":"global","project_skills":"project-skills","rules":"rules","mcp":"mcp","project_mcp":"project-mcp","project_config":"project-config","config":"config"} +key_filter = set(sys.argv[2:]) if len(sys.argv) > 2 else None +os_key = {"Darwin": "darwin", "Linux": "linux"}.get(platform.system(), "windows") +for ide in sorted(data.keys()): + if key_filter is not None and ide not in key_filter: + continue + for jk in keymap: + val = data[ide].get(jk, "") + if isinstance(val, dict): + val = val.get(os_key, "") + print(f"{ide}\t{jk}\t{keymap[jk]}\t{val}") +PYEOF +} + +dump_registry_rows() { + python3 - "$JSON_FILE" "$@" <<'PYEOF' +import json +import sys + +# Native Windows Python translates \n to \r\n on a text-mode stdout, +# leaving a stray \r on the last TSV field consumed by `read`. +sys.stdout.reconfigure(newline="") +data = json.load(open(sys.argv[1])) +for ide in sys.argv[2:]: + for key, value in data[ide].items(): + if isinstance(value, dict): + for platform_name, platform_value in sorted(value.items()): + if platform_value: + print(f"{ide}\t{key}:{platform_name}\t{platform_value}") + elif value: + print(f"{ide}\t{key}\t{value}") +PYEOF +} + +echo "========================================" +echo "Drift test: ide-paths.json vs script" +echo "========================================" +echo "" + +ALL_ROWS="$(dump_rows)" +while IFS=$'\t' read -r ide jsonkey scriptobj expected; do + [[ -z "$ide" ]] && continue + checks=$((checks + 1)) + + actual="$(bash "$MIGRATION_SCRIPT" legacy --print-path "$ide" "$scriptobj" 2>/dev/null)" + rc=$? + + if [[ -z "$expected" ]]; then + if [[ -n "$actual" ]]; then + echo "FAIL: ${ide}/${jsonkey} - expected empty, got: ${actual}" + failures=$((failures + 1)) + else + echo "PASS: ${ide}/${jsonkey} -> (unsupported/empty)" + fi + else + if [[ $rc -ne 0 ]]; then + echo "FAIL: ${ide}/${jsonkey} - script exited non-zero resolving object '${scriptobj}'" + failures=$((failures + 1)) + elif [[ "$actual" != "$expected" ]]; then + echo "FAIL: ${ide}/${jsonkey}" + echo " expected: ${expected}" + echo " actual: ${actual}" + failures=$((failures + 1)) + else + echo "PASS: ${ide}/${jsonkey} -> ${actual}" + fi + fi +done <<< "$ALL_ROWS" + +PIECES_CANONICAL_RC=0 +python3 - "$JSON_FILE" <<'PYEOF' || PIECES_CANONICAL_RC=$? +import json, sys +data = json.load(open(sys.argv[1])) +required = {"global_skills", "project_skills", "rules", "mcp", "project_mcp", "project_config", "config"} +entry = data.get("pieces", {}) +if set(entry) != required or any(entry.values()): + raise SystemExit(1) +PYEOF +checks=$((checks + 1)) +if [[ "$PIECES_CANONICAL_RC" -eq 0 ]]; then + echo "PASS: pieces canonical entry has all objects explicitly unsupported" +else + echo "FAIL: pieces canonical entry must have exactly the empty object map" + failures=$((failures + 1)) +fi + +COPILOT_PROJECT_SKILLS="$(bash "$MIGRATION_SCRIPT" legacy --print-path copilot project-skills 2>/dev/null)" +checks=$((checks + 1)) +if [[ "$COPILOT_PROJECT_SKILLS" == ".github/skills" ]]; then + echo "PASS: copilot/project_skills -> ${COPILOT_PROJECT_SKILLS}" +else + echo "FAIL: copilot/project_skills" + echo " expected: .github/skills" + echo " actual: ${COPILOT_PROJECT_SKILLS}" + failures=$((failures + 1)) +fi + +WINDSURF_GLOBAL_SKILLS="$(bash "$MIGRATION_SCRIPT" legacy --print-path windsurf global 2>/dev/null)" +checks=$((checks + 1)) +if [[ "$WINDSURF_GLOBAL_SKILLS" == "~/.codeium/windsurf/skills" ]]; then + echo "PASS: windsurf/global -> ${WINDSURF_GLOBAL_SKILLS}" +else + echo "FAIL: windsurf/global expected ~/.codeium/windsurf/skills, got '${WINDSURF_GLOBAL_SKILLS}'" + failures=$((failures + 1)) +fi + +WINDSURF_PROJECT_SKILLS="$(bash "$MIGRATION_SCRIPT" legacy --print-path windsurf project-skills 2>/dev/null)" +checks=$((checks + 1)) +if [[ "$WINDSURF_PROJECT_SKILLS" == ".windsurf/skills" ]]; then + echo "PASS: windsurf/project-skills -> ${WINDSURF_PROJECT_SKILLS}" +else + echo "FAIL: windsurf/project-skills expected .windsurf/skills, got '${WINDSURF_PROJECT_SKILLS}'" + failures=$((failures + 1)) +fi + +COPILOT_HELP="$(bash "$MIGRATION_SCRIPT" legacy --help 2>/dev/null)" +checks=$((checks + 1)) +if grep -Fq "copilot is GitHub Copilot CLI" <<< "$COPILOT_HELP" && ! grep -Fq "copilot is VS Code Copilot" <<< "$COPILOT_HELP"; then + echo "PASS: copilot help identifies the CLI target" +else + echo "FAIL: copilot help must identify GitHub Copilot CLI, not VS Code Copilot" + failures=$((failures + 1)) +fi + +COPILOT_PROMPT_WORKSPACE="$(mktemp -d /tmp/copilot-prompt-scope.XXXXXX)" +trap 'rm -rf "$COPILOT_PROMPT_WORKSPACE"' EXIT +mkdir -p "$COPILOT_PROMPT_WORKSPACE/.github/prompts" +printf '%s\n' '---' 'description: test prompt' '---' > "$COPILOT_PROMPT_WORKSPACE/.github/prompts/test.prompt.md" +COPILOT_PROMPT_OUTPUT="$(bash "$MIGRATION_SCRIPT" legacy --source copilot --target cursor --workspace "$COPILOT_PROMPT_WORKSPACE" --objects prompts --dry-run 2>&1)" +checks=$((checks + 1)) +if grep -Fq "source IDE does not support prompt templates" <<< "$COPILOT_PROMPT_OUTPUT"; then + echo "PASS: copilot CLI prompt migration is unsupported" +else + echo "FAIL: copilot CLI must not migrate IDE-only prompt files" + failures=$((failures + 1)) +fi + +for cody_object in global project project-skills rules mcp project-mcp project-config config; do + checks=$((checks + 1)) + if bash "$MIGRATION_SCRIPT" legacy --print-path cody "$cody_object" >/dev/null 2>&1; then + echo "FAIL: cody/${cody_object} must remain unsupported/empty" + failures=$((failures + 1)) + else + echo "PASS: cody/${cody_object} -> (unsupported/empty)" + fi +done + +for supermaven_object in global project project-skills rules mcp project-mcp project-config config; do + checks=$((checks + 1)) + if bash "$MIGRATION_SCRIPT" legacy --print-path supermaven "$supermaven_object" >/dev/null 2>&1; then + echo "FAIL: supermaven/${supermaven_object} must remain unsupported/empty" + failures=$((failures + 1)) + else + echo "PASS: supermaven/${supermaven_object} -> (unsupported/empty)" + fi +done + +GEMINI_GLOBAL_SKILLS="$(bash "$MIGRATION_SCRIPT" legacy --print-path gemini-cli global 2>/dev/null)" +checks=$((checks + 1)) +if [[ "$GEMINI_GLOBAL_SKILLS" == "~/.gemini/skills" ]]; then + echo "PASS: gemini-cli/global -> ${GEMINI_GLOBAL_SKILLS}" +else + echo "FAIL: gemini-cli/global expected ~/.gemini/skills, got '${GEMINI_GLOBAL_SKILLS}'" + failures=$((failures + 1)) +fi + +GEMINI_PROJECT_SKILLS="$(bash "$MIGRATION_SCRIPT" legacy --print-path gemini-cli project-skills 2>/dev/null)" +checks=$((checks + 1)) +if [[ "$GEMINI_PROJECT_SKILLS" == ".gemini/skills" ]]; then + echo "PASS: gemini-cli/project-skills -> ${GEMINI_PROJECT_SKILLS}" +else + echo "FAIL: gemini-cli/project-skills expected .gemini/skills, got '${GEMINI_PROJECT_SKILLS}'" + failures=$((failures + 1)) +fi + +for gemini_object in mcp project-mcp project-config config; do + checks=$((checks + 1)) + actual="$(bash "$MIGRATION_SCRIPT" legacy --print-path gemini-cli "$gemini_object" 2>/dev/null || true)" + case "$gemini_object" in + mcp|config) expected="~/.gemini/settings.json" ;; + project-mcp|project-config) expected=".gemini/settings.json" ;; + esac + if [[ "$actual" == "$expected" ]]; then + echo "PASS: gemini-cli/${gemini_object} -> ${actual}" + else + echo "FAIL: gemini-cli/${gemini_object} expected ${expected}, got '${actual}'" + failures=$((failures + 1)) + fi +done + +if [[ -d "$IDE_REFERENCE_DIR" ]]; then + echo "" + echo "========================================" + echo "Cross-check: key IDEs vs per-IDE references" + echo "========================================" + echo "" + + KEY_ROWS="$(dump_registry_rows antigravity kimiai copilot codex workbuddy claude claude-desktop openclaw neovim continue aider roo-code cline amazon-q goose-cli pearai pieces blackbox gemini-cli opencode kilocode kiro augment-code void-editor baidu-comate tencent-codebuddy zcode cody codeium tabnine replit supermaven vscode windsurf jetbrains trae trae-cn)" + while IFS=$'\t' read -r ide jsonkey expected; do + [[ -z "$expected" ]] && continue + checks=$((checks + 1)) + if grep -Fq "$expected" "$IDE_REFERENCE_DIR/${ide}.md"; then + echo "PASS (reference): ${ide}/${jsonkey} present in ${ide}.md" + else + echo "FAIL (reference): ${ide}/${jsonkey} value '${expected}' NOT found in ${ide}.md" + failures=$((failures + 1)) + fi + done <<< "$KEY_ROWS" +else + echo "WARN: per-IDE reference directory not found at $IDE_REFERENCE_DIR; skipping reference cross-check" >&2 +fi + +echo "" +echo "========================================" +if [[ $failures -eq 0 ]]; then + echo "ALL PASS: ${checks} checks matched ide-paths.json / per-IDE references" + echo "========================================" + exit 0 +else + echo "DRIFT DETECTED: ${failures}/${checks} checks FAILED" + echo "========================================" + exit 1 +fi diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-ide-reference-generation.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-ide-reference-generation.sh new file mode 100644 index 000000000..d7e419dc7 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-ide-reference-generation.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +python3 "$SCRIPT_DIR/sync-ide-reference-summaries.py" --check \ + --paths "$SKILL_ROOT/references/ide-paths.json" \ + --references "$SKILL_ROOT/references/ides" \ + --resolver "$SCRIPT_DIR/ide-paths.tsv" + +if grep -n '[[:blank:]]$' "$SCRIPT_DIR/ide-paths.tsv" >/dev/null; then + echo "FAIL: generated resolver contains trailing whitespace" >&2 + exit 1 +fi + +TMP_HOME="$(mktemp -d /tmp/ide-path-resolver-test.XXXXXX)" +trap 'rm -rf "$TMP_HOME"' EXIT +ACTUAL_PATH="$(HOME="$(native_path "$TMP_HOME")" bash -c 'source "$1"; get_global_path gemini-cli' _ "$SCRIPT_DIR/legacy-smart-ide-migration.sh")" +# The engine echoes $HOME verbatim (a native Windows path under MSYS); +# map it back to the POSIX view before comparing with TMP_HOME. +if command -v cygpath >/dev/null 2>&1; then + ACTUAL_PATH="$(cygpath -u "$ACTUAL_PATH")" +fi +[[ "$ACTUAL_PATH" == "$TMP_HOME/.gemini/skills" ]] || { + echo "FAIL: generated resolver did not expand ~/ against HOME" >&2 + exit 1 +} + +EMPTY_PATH="$(HOME="$(native_path "$TMP_HOME")" bash -c 'source "$1"; get_global_path aider' _ "$SCRIPT_DIR/legacy-smart-ide-migration.sh")" +[[ -z "$EMPTY_PATH" ]] || { + echo "FAIL: empty generated resolver path must stay unsupported" >&2 + exit 1 +} + +echo "IDE reference summary generation test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-ir-schemas.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-ir-schemas.sh new file mode 100644 index 000000000..8293be6e1 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-ir-schemas.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +python3 - "$SCRIPT_DIR" <<'PYEOF' +import dataclasses +import sys +from pathlib import Path + +script_dir = Path(sys.argv[1]) +sys.path.insert(0, str(script_dir)) + +from migration_core import ( # noqa: E402 + AgentIR, + CommandIR, + HookIR, + MCPServerIR, + PromptIR, +) + + +def _roundtrip(cls, instance): + d = dataclasses.asdict(instance) + rebuilt = cls(**d) + assert rebuilt == instance, (cls, d) + + +# MCPServerIR: extra MCP transport fields. +mcp = MCPServerIR( + name="linear", + transport="stdio", + command="npx", + args=["-y", "@modelcontextprotocol/server-linear"], + env={"LINEAR_API_KEY": "${LINEAR_API_KEY}"}, + cwd="/srv/linear", + timeout_seconds=30, + startup_timeout_seconds=10, + enabled=True, + tool_allowlist=["create_issue"], + auth={"type": "oauth", "scopes": ["read", "write"]}, + mtls={"enabled": False}, + target_schema_version="2026-08", +) +_roundtrip(MCPServerIR, mcp) +assert mcp.tool_allowlist == ["create_issue"] +assert mcp.auth["type"] == "oauth" +print(f"OK MCPServerIR with cwd={mcp.cwd}, oauth={mcp.auth['type']}") + + +# PromptIR +prompt = PromptIR( + name="review", + description="Code review", + arguments=[{"name": "focus", "required": False}], + body="Review the staged diff.", + model="claude-sonnet", + agent="code-reviewer", + file_references=["CHANGELOG.md"], + scope="project", + auto_invocation=True, +) +_roundtrip(PromptIR, prompt) +assert prompt.auto_invocation is True +print(f"OK PromptIR with model={prompt.model}, agent={prompt.agent}") + + +# CommandIR +cmd = CommandIR( + name="lint", + description="Run linters", + invocation="/lint", + body="cargo clippy --workspace --all-targets", + tool_blocks=[{"name": "Bash", "input": {"command": "cargo clippy"}}], + file_references=["Cargo.toml"], + scope="user", +) +_roundtrip(CommandIR, cmd) +assert cmd.invocation == "/lint" +print(f"OK CommandIR with invocation={cmd.invocation}") + + +# AgentIR +agent = AgentIR( + name="reviewer", + description="Reviews staged changes", + system_prompt="You are a careful reviewer.", + tools=["Read", "Grep", "Bash"], + model="claude-opus", + permissions=["fs:read"], + mcp=["linear"], + subagents=["linter"], + handoffs=["reviewer->fixer"], + isolation="worktree", + worktree=True, + memory_policy="ephemeral", + hooks=[{"event": "PreToolUse", "matcher": "Bash"}], +) +_roundtrip(AgentIR, agent) +assert agent.worktree is True +assert "linear" in agent.mcp +print(f"OK AgentIR with {len(agent.tools)} tools, worktree={agent.worktree}") + + +# HookIR +hook = HookIR( + event="PreToolUse", + matcher="Bash", + command="/hooks/pre-bash.sh", + cwd="${workspace}", + env={"HOOK_NAME": "pre-bash"}, + stdin_schema="PreToolUseRequest", + stdout_schema="PreToolUseResponse", + blocking=True, + exit_code=0, + timeout_seconds=10, + async_run=False, + os_overrides={"windows": {"command": "C:\\hooks\\pre-bash.cmd"}}, + target_script_references=["hooks/pre-bash.sh"], +) +_roundtrip(HookIR, hook) +assert hook.event == "PreToolUse" +assert "windows" in hook.os_overrides +print(f"OK HookIR with os_overrides keys: {list(hook.os_overrides)}") + +print() +print("Object IR schema tests passed") +PYEOF \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-jetbrains-junie-mapping.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-jetbrains-junie-mapping.sh new file mode 100644 index 000000000..8fdf634a3 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-jetbrains-junie-mapping.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATION_SCRIPT="${SCRIPT_DIR}/smart-ide-migration.sh" +PATHS_FILE="${SCRIPT_DIR}/../references/ide-paths.json" +IDE_REFERENCE="${SCRIPT_DIR}/../references/ides/jetbrains.md" +TMP_ROOT="$(mktemp -d /tmp/agent-skills-jetbrains-fixture.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +assert_path() { + local object="$1" + local expected="$2" + local actual + actual="$(HOME="$TMP_ROOT/home" bash "$MIGRATION_SCRIPT" legacy --print-path jetbrains "$object")" + [[ "$actual" == "$expected" ]] || { + echo "FAIL: jetbrains/${object}: expected ${expected}, got ${actual}" >&2 + exit 1 + } +} + +assert_unsupported() { + local object="$1" + if HOME="$TMP_ROOT/home" bash "$MIGRATION_SCRIPT" legacy --print-path jetbrains "$object" >/dev/null 2>&1; then + echo "FAIL: jetbrains/${object} unexpectedly has a portable path" >&2 + exit 1 + fi +} + +assert_path global "~/.junie/skills" +assert_path project ".junie" +assert_path project-skills ".junie/skills" +assert_path rules ".junie/AGENTS.md" +assert_path mcp "~/.junie/mcp/mcp.json" +assert_path project-mcp ".junie/mcp/mcp.json" +assert_unsupported config + +python3 - "$PATHS_FILE" <<'PYEOF' +import json +import sys + +entry = json.load(open(sys.argv[1], encoding="utf-8"))["jetbrains"] +expected = { + "global_skills": "~/.junie/skills", + "project_skills": ".junie/skills", + "rules": ".junie/AGENTS.md", + "mcp": "~/.junie/mcp/mcp.json", + "project_mcp": ".junie/mcp/mcp.json", + "config": "", +} +assert entry == expected, (entry, expected) +PYEOF + +for path in '~/.junie/skills' '.junie/skills' '.junie/AGENTS.md' '~/.junie/mcp/mcp.json' '.junie/mcp/mcp.json'; do + grep -Fq "$path" "$IDE_REFERENCE" +done + +if grep -Fq -- '- **rules**: project `.junie/guidelines.md`' "$IDE_REFERENCE"; then + echo "FAIL: stale JetBrains IDE guidelines mapping remains" >&2 + exit 1 +fi + +echo "JetBrains Junie mapping fixture passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-legacy-registry-gate.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-legacy-registry-gate.sh new file mode 100644 index 000000000..6dbeb6679 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-legacy-registry-gate.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +CLI="$SCRIPT_DIR/smart-ide-migration.sh" +LEGACY="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +WORKSPACE="$TMP_ROOT/workspace" +mkdir -p \ + "$TEST_HOME/.cline/skills/demo" \ + "$WORKSPACE/.cursor" \ + "$WORKSPACE/.cline/skills/project-demo" +printf '%s\n' '---' 'name: demo' 'description: Gate fixture.' '---' '# Demo' \ + > "$TEST_HOME/.cline/skills/demo/SKILL.md" +printf '%s\n' '---' 'name: project-demo' 'description: Gate fixture.' '---' '# Demo' \ + > "$WORKSPACE/.cline/skills/project-demo/SKILL.md" +printf '%s\n' '{"mcpServers":{"demo":{"command":"demo"}}}' \ + > "$WORKSPACE/.cursor/mcp.json" + +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" --print-path cline mcp \ + > "$TMP_ROOT/implicit.out" 2>"$TMP_ROOT/implicit.err"; then + echo "FAIL: implicit legacy flags were accepted" >&2 + exit 1 +fi +grep -Fq "implicit legacy flags are disabled" "$TMP_ROOT/implicit.err" + +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" legacy \ + --source cline --target windsurf --objects skills --dry-run --yes \ + > "$TMP_ROOT/mixed.out" 2>"$TMP_ROOT/mixed.err"; then + echo "FAIL: legacy --yes was accepted when combined with --dry-run" >&2 + exit 1 +fi +grep -Fq 'legacy writes are disabled' "$TMP_ROOT/mixed.err" + +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" \ + legacy --source cline --target windsurf --objects skills --yes --strategy overwrite \ + > "$TMP_ROOT/skills.out" 2>"$TMP_ROOT/skills.err"; then + echo "FAIL: public legacy write reached the compatibility engine" >&2 + exit 1 +fi +grep -Fq 'legacy writes are disabled' "$TMP_ROOT/skills.err" +[[ ! -e "$TEST_HOME/.codeium/windsurf/skills/demo/SKILL.md" ]] + +mkdir -p "$WORKSPACE/.cline/rules" +printf '%s\n' '# reviewed project rule' > "$WORKSPACE/.cline/rules/reviewed.md" +HOME="$(native_path "$TEST_HOME")" bash "$CLI" \ + legacy --source cline --target windsurf --workspace "$WORKSPACE" \ + --objects rules --strategy overwrite --dry-run >"$TMP_ROOT/rules.log" +grep -Fq 'Windsurf rules use scoped files' "$TMP_ROOT/rules.log" +[[ ! -e "$WORKSPACE/.windsurf/rules/reviewed.md" ]] + +mkdir -p "$WORKSPACE/.cline" +printf '%s\n' '{"mcpServers":{"demo":{"command":"demo"}}}' \ + > "$WORKSPACE/.cline/mcp.json" +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" \ + legacy --source cline --target windsurf --workspace "$WORKSPACE" \ + --objects project-mcp --scope project --strategy overwrite --yes \ + >"$TMP_ROOT/project-mcp.out" 2>"$TMP_ROOT/project-mcp.err"; then + echo "FAIL: project MCP was authorized from unrelated user-scope surfaces" >&2 + exit 1 +fi +grep -Fq 'legacy writes are disabled' "$TMP_ROOT/project-mcp.err" + +for target in codely roo-code bolt-new pieces emacs codeium; do + if HOME="$(native_path "$TEST_HOME")" bash "$CLI" \ + legacy --source cline --target "$target" --workspace "$WORKSPACE" \ + --objects skills --scope project --yes --strategy overwrite \ + > "$TMP_ROOT/$target.log" 2>&1; then + echo "FAIL: Registry-restricted legacy target was writable: $target" >&2 + exit 1 + fi + grep -Fq 'legacy writes are disabled' "$TMP_ROOT/$target.log" +done + +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" \ + legacy --source cursor --target codely --workspace "$WORKSPACE" \ + --objects project-mcp --scope project --yes --strategy overwrite \ + > "$TMP_ROOT/codely-mcp.log" 2>&1; then + echo "FAIL: unverified Codely MCP target bypassed Registry v2" >&2 + exit 1 +fi +[[ ! -e "$WORKSPACE/.codely-cli/settings.json" ]] + +if bash "$LEGACY" --help > "$TMP_ROOT/direct.log" 2>&1; then + echo "FAIL: internal legacy engine was directly executable" >&2 + exit 1 +fi +grep -Fq 'is internal' "$TMP_ROOT/direct.log" + +echo "Legacy Registry authorization test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-mcp-adapters.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-mcp-adapters.sh new file mode 100644 index 000000000..1b18a0aa3 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-mcp-adapters.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +python3 - "$SCRIPT_DIR" "$SKILL_DIR/references/registry-v2.json" "$TMP_ROOT" <<'PY' +import json +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from migration_core import ( + Registry, + build_plan, + build_plan_document, + emit_mcp_document, + mcp_adapter, + parse_mcp_document, +) + +jsonc = r''' +{ + // a comment outside strings + "mcpServers": { + "demo": { + "command": "demo//literal", + "args": ["--safe",], + }, + }, +} +''' +servers = parse_mcp_document(jsonc, "jsonc:mcpServers") +assert len(servers) == 1 +assert servers[0].command == "demo//literal" +assert servers[0].args == ["--safe"] +rendered_jsonc, _ = emit_mcp_document(servers, "jsonc:servers") +assert set(json.loads(rendered_jsonc)) == {"servers"} + +for source_format in ( + "json5:mcpServers", + "toml:mcp_servers", + "yaml:mcpServers", + "xml:mcpServers", + "lua:mcpServers", +): + adapter = mcp_adapter(source_format) + assert adapter["automatic"] is False + try: + parse_mcp_document("{}", source_format) + except ValueError as error: + assert "dedicated reviewed reconstruction adapter" in str(error) + else: + raise AssertionError(f"{source_format} used a generic JSON fallback") + +workspace = Path(sys.argv[3]) / "workspace" +home = Path(sys.argv[3]) / "home" +workspace.mkdir() +home.mkdir() +(workspace / ".cline").mkdir() +(workspace / ".cline/skills").mkdir() +(workspace / ".cline/skills/fixture-skill").mkdir() +(workspace / ".cline/skills/fixture-skill/SKILL.md").write_text( + "---\nname: fixture-skill\ndescription: Test skill\nmetadata:\n version: '1'\n---\n# fixture\n", + encoding="utf-8", +) +(workspace / ".cline/rules").mkdir() +(workspace / ".cline/rules/rule.md").write_text( + "# Rule\n", + encoding="utf-8", +) +(workspace / ".cline/mcp.json").write_text( + '{"mcpServers":{"demo":{"command":"demo"}}}\n', + encoding="utf-8", +) +registry = Registry(Path(sys.argv[2]), workspace, home) + +plan, _ = build_plan( + registry, + "cline/ide", + "codex/cli", + ["mcp"], + "project", +) +assert plan[0].status == "manual-rebuild" +assert "manual-template" in plan[0].reason +assert plan[0].manual_actions + +cloud = build_plan_document( + registry, + "cline/ide", + "trae/ide", + ["skills", "instructions", "mcp"], + "project", +) +# trae/ide: skills -> ready, instructions -> manual-rebuild (format mismatch), +# mcp -> manual-rebuild (not mapped) +statuses = {item["status"] for item in cloud["items"]} +assert "ready" in statuses, statuses +assert "manual-rebuild" in statuses, statuses +assert "invalid" not in statuses, statuses +# Rebuild manifest includes manual-rebuild items (may have empty actions for unmapped) +assert len(cloud["rebuild_manifest"]["items"]) >= 1 +assert "literal-secret" not in json.dumps(cloud) + +(workspace / ".cline/mcp.json").write_text( + '{"mcpServers":{"remote":{"type":"streamableHttp","url":"https://example.test/mcp","headers":{"Authorization":"Bearer literal-secret"}}}}\n', + encoding="utf-8", +) +remote = build_plan_document( + registry, + "cline/ide", + "forge/cli", + ["mcp"], + "project", +) +assert remote["items"][0]["status"] == "manual-rebuild" +assert "dedicated target-profile transport adapter" in remote["items"][0]["reason"] +assert "literal-secret" not in json.dumps(remote) +PY + +echo "MCP adapter and cloud rebuild tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-mcp-secret-redaction.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-mcp-secret-redaction.sh new file mode 100644 index 000000000..3b78abc77 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-mcp-secret-redaction.sh @@ -0,0 +1,774 @@ +#!/usr/bin/env bash + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MIG="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +LEGACY_MIG="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 + +TMP_ROOT="$(mktemp -d /tmp/agent-skills-redact-test.XXXXXX)" +export HOME="$TMP_ROOT/home" +mkdir -p "$HOME" + +OUT_FILE="$TMP_ROOT/last.out" +cleanup() { rm -rf "$TMP_ROOT"; } +trap cleanup EXIT + +CHECKS=0 +FAIL=0 +check_pass() { CHECKS=$((CHECKS + 1)); echo "PASS: $1"; } +check_fail() { CHECKS=$((CHECKS + 1)); FAIL=$((FAIL + 1)); echo "FAIL: $1" >&2; } + +run() { "$@" > "$OUT_FILE" 2>&1; LAST_RC=$?; } + +run_goose_manual_mcp() { + local label="$1" + rm -f "$HOME/.cursor/mcp.json" + run bash "$MIG" --source goose-cli --target cursor --objects mcp --strategy overwrite --yes + if [[ $LAST_RC -eq 0 && ! -e "$HOME/.cursor/mcp.json" ]] && \ + grep -Fq 'Goose config.yaml uses YAML extensions; automatic MCP migration is unsupported' "$OUT_FILE"; then + check_pass "$label: Goose YAML MCP fails closed without a JSON target" + else + check_fail "$label: Goose YAML MCP boundary was not fail-closed" + fi +} + +assert_valid_json() { + local f="$1" d="$2" + if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$f" 2>/dev/null; then + check_pass "$d (valid JSON)" + else + check_fail "$d (invalid JSON): $(cat "$f" 2>/dev/null | head -3)" + fi +} + +assert_json_val() { + local f="$1" server="$2" keypath="$3" expected="$4" d="$5" + local got + got=$(python3 - "$f" "$server" "$keypath" "$expected" <<'PY' +import json, sys +f, server, keypath, expected = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +node = json.load(open(f))["mcpServers"][server] +for part in keypath.split("."): + node = node[part] +got = "" if node is None else str(node) +print("OK" if got == expected else "MISMATCH got=%r want=%r" % (got, expected)) +PY +) + if [[ "$got" == "OK" ]]; then check_pass "$d"; else check_fail "$d ($got)"; fi +} + +echo "" +echo "== 1. JSON -> JSON redaction (claude -> cursor, mcp) ==" +S1="$HOME/.claude.json" +cat > "$S1" <<'EOF' +{ + "mcpServers": { + "secret-env": { + "command": "npx", + "env": { + "API_KEY": "EXAMPLE_API_KEY_VALUE", + "GITHUB_TOKEN": "EXAMPLE_GITHUB_TOKEN_VALUE", + "NORMAL_VAR": "just-a-normal-value", + "DATABASE_URL": "postgres://user:pass@localhost:5432/db" + } + }, + "bearer": { + "url": "https://mcp.example.com/sse", + "headers": { "Authorization": "Bearer eyJhbGc.secretpart" } + }, + "urlcred": { "url": "https://user:password@api.example.com/mcp" }, + "querycred": { "url": "https://api.example.com/mcp?key=TOKENABCDEF123456&other=keep" } + } +} +EOF + +run bash "$MIG" --source claude --target cursor --objects mcp --strategy overwrite --yes +assert_valid_json "$HOME/.cursor/mcp.json" "1: destination is valid JSON" +assert_json_val "$HOME/.cursor/mcp.json" secret-env "env.API_KEY" "" "1: API_KEY blanked" +assert_json_val "$HOME/.cursor/mcp.json" secret-env "env.GITHUB_TOKEN" "" "1: GITHUB_TOKEN blanked" +assert_json_val "$HOME/.cursor/mcp.json" secret-env "env.NORMAL_VAR" "just-a-normal-value" "1: NORMAL_VAR preserved" +assert_json_val "$HOME/.cursor/mcp.json" secret-env "env.DATABASE_URL" "" "1: DATABASE_URL (postgres cred) blanked" +assert_json_val "$HOME/.cursor/mcp.json" bearer "headers.Authorization" "" "1: Authorization bearer blanked" +assert_json_val "$HOME/.cursor/mcp.json" bearer "url" "https://mcp.example.com/sse" "1: benign bearer url kept" +assert_json_val "$HOME/.cursor/mcp.json" urlcred "url" "" "1: user:pass@ url blanked" +assert_json_val "$HOME/.cursor/mcp.json" querycred "url" "" "1: ?key= query-string cred blanked" +if grep -Fq "[SECURITY]" "$OUT_FILE"; then check_pass "1: [SECURITY] warning printed when secrets redacted"; else check_fail "1: [SECURITY] warning missing despite redaction"; fi +[[ $LAST_RC -eq 0 ]] && check_pass "1: migration exited rc=0" || check_fail "1: migration exited rc=$LAST_RC (expected 0)" + +echo "" +echo "== 2. Honest count: secret-free mcp config -> NO [SECURITY] warning ==" +S2="$HOME/.claude.json" +cat > "$S2" <<'EOF' +{ "mcpServers": { "demo-server": { "command": "echo", "args": [] } } } +EOF +run bash "$MIG" --source claude --target cursor --objects mcp --strategy overwrite --yes +assert_valid_json "$HOME/.cursor/mcp.json" "2: destination is valid JSON" +if grep -Fq "demo-server" "$HOME/.cursor/mcp.json"; then check_pass "2: demo-server migrated"; else check_fail "2: demo-server missing"; fi +if grep -Fq "[SECURITY]" "$OUT_FILE"; then check_fail "2: [SECURITY] should NOT print for secret-free config"; else check_pass "2: no false [SECURITY] warning"; fi + +echo "" +echo "== 3. Goose YAML MCP is unsupported/fail-closed ==" +mkdir -p "$HOME/.config/goose" +cat > "$HOME/.config/goose/config.yaml" <<'EOF' +extensions: + mcp: + servers: + secret-server: + command: npx + env: + API_KEY: "EXAMPLE_API_KEY_VALUE" + NORMAL_VAR: "keep-this-value" + DB_URL: "postgres://u:p@localhost/db" +EOF +run_goose_manual_mcp "3" +if grep -Fq 'EXAMPLE_API_KEY_VALUE' "$HOME/.config/goose/config.yaml"; then + check_pass "3: Goose source YAML remains untouched" +else + check_fail "3: Goose source YAML was modified" +fi + +echo "" +echo "== 4. Default scope excludes mcp (audit hardening) ==" +S4="$HOME/.claude.json" +cat > "$S4" <<'EOF' +{ "mcpServers": { "secret-env": { "env": { "API_KEY": "EXAMPLE_API_KEY_VALUE" } } } } +EOF +mkdir -p "$HOME/.claude/skills/demo-skill" +printf '%s\n' '---' 'name: demo-skill' 'description: fixture' '---' > "$HOME/.claude/skills/demo-skill/SKILL.md" + +run bash "$MIG" --source claude --target cursor --yes +if [[ -f "$HOME/.cursor/skills/demo-skill/SKILL.md" ]]; then check_pass "4: low-risk skill migrated by default"; else check_fail "4: default migration did not move skills"; fi +if [[ -e "$HOME/.cursor/mcp.json" ]]; then + if grep -Fq "EXAMPLE_API_KEY_VALUE" "$HOME/.cursor/mcp.json"; then + check_fail "4: DEFAULT scope copied a live secret (mcp must be opt-in)" + else + check_pass "4: secret mcp not copied by default (no live secret present)" + fi +else + check_pass "4: secret mcp NOT migrated by default (file absent)" +fi +if grep -Fq "global migrations default to skills" "$OUT_FILE"; then check_pass "4: global default is reported"; else check_fail "4: global default notice missing"; fi + +echo "" +echo "== 5. Array secrets: secret-named key with LIST value (JSON path) ==" +S5="$HOME/.claude.json" +cat > "$S5" <<'EOF' +{ + "mcpServers": { + "arr-server": { + "command": "npx", + "env": { "NORMAL_VAR": "keep-me" }, + "API_KEYS": ["EXAMPLE_ARRAY_KEY_1", "EXAMPLE_ARRAY_KEY_2"], + "args": ["--port", "8080", "--token", "EXAMPLE_ARGV_TOKEN", "--api-key=EXAMPLE_EQ_TOKEN"] + } + } +} +EOF +run bash "$MIG" --source claude --target cursor --objects mcp --strategy overwrite --yes +assert_valid_json "$HOME/.cursor/mcp.json" "5: destination is valid JSON" +if grep -Fq "EXAMPLE_ARRAY_KEY_1" "$HOME/.cursor/mcp.json"; then check_fail "5: API_KEYS[0] leaked"; else check_pass "5: API_KEYS[0] blanked"; fi +if grep -Fq "EXAMPLE_ARRAY_KEY_2" "$HOME/.cursor/mcp.json"; then check_fail "5: API_KEYS[1] leaked"; else check_pass "5: API_KEYS[1] blanked"; fi +if grep -Fq "EXAMPLE_ARGV_TOKEN" "$HOME/.cursor/mcp.json"; then check_fail "5: --token argv value leaked"; else check_pass "5: --token argv value blanked"; fi +if grep -Fq "EXAMPLE_EQ_TOKEN" "$HOME/.cursor/mcp.json"; then check_fail "5: --api-key=... value leaked"; else check_pass "5: --api-key=... value blanked"; fi +if grep -Fq '"--token"' "$HOME/.cursor/mcp.json"; then check_pass "5: --token flag itself preserved"; else check_fail "5: --token flag lost"; fi +if grep -Fq '"8080"' "$HOME/.cursor/mcp.json"; then check_pass "5: benign argv (8080) preserved"; else check_fail "5: benign argv lost"; fi + +echo "" +echo "== 6. Goose YAML arrays remain behind the fail-closed boundary ==" +mkdir -p "$HOME/.config/goose" +cat > "$HOME/.config/goose/config.yaml" <<'EOF' +extensions: + arr: + command: npx + api_keys: ["EXAMPLE_YAML_ARR_KEY_1", "EXAMPLE_YAML_ARR_KEY_2"] + args: ["--token", "EXAMPLE_YAML_ARGV_TOKEN"] + keep: ["normal-item"] +EOF +run_goose_manual_mcp "6" +if grep -Fq 'EXAMPLE_YAML_ARGV_TOKEN' "$HOME/.config/goose/config.yaml"; then + check_pass "6: Goose YAML array source remains untouched" +else + check_fail "6: Goose YAML array source was modified" +fi + +echo "" +echo "== 7. Whole-config migration is a manual boundary ==" +mkdir -p "$HOME/.claude" +cat > "$HOME/.claude/settings.json" <<'EOF' +{ + "editor.fontSize": 14, + "apiKey": "EXAMPLE_SETTINGS_API_KEY", + "telemetry": "off" +} +EOF +run bash "$MIG" --source claude --target openclaw --objects config --strategy overwrite --yes +if [[ ! -e "$HOME/.openclaw/openclaw.json" ]]; then + check_pass "7: config boundary creates no target file" +else + check_fail "7: config boundary unexpectedly wrote a target file" +fi +if grep -Fq "automatic whole-IDE config migration is unsupported" "$OUT_FILE"; then check_pass "7: config boundary explains manual review"; else check_fail "7: config boundary message missing"; fi +[[ $LAST_RC -eq 0 ]] && check_pass "7: boundary exits rc=0" || check_fail "7: boundary exited rc=$LAST_RC (expected 0)" + +echo "" +echo "== 8. copilot/vscode MCP paths wired (no silent skip) ==" +S8="$HOME/.claude.json" +cat > "$S8" <<'EOF' +{ "mcpServers": { "demo-server": { "command": "echo", "args": [], "tools": ["*"] } } } +EOF +run bash "$MIG" --source claude --target copilot --objects mcp --strategy overwrite --yes +if [[ -f "$HOME/.copilot/mcp-config.json" ]] && grep -Fq "demo-server" "$HOME/.copilot/mcp-config.json"; then + check_pass "8: claude -> copilot mcp migrated to ~/.copilot/mcp-config.json" +else + check_fail "8: claude -> copilot mcp still skipped" +fi +VSCODE_WORKSPACE="$TMP_ROOT/vscode-workspace" +rm -rf "$VSCODE_WORKSPACE" +mkdir -p "$VSCODE_WORKSPACE" +cp "$S8" "$VSCODE_WORKSPACE/.mcp.json" +run bash "$MIG" --source claude --target vscode --workspace "$VSCODE_WORKSPACE" --objects mcp --scope project --strategy overwrite --yes +VSCODE_MCP="$VSCODE_WORKSPACE/.vscode/mcp.json" +if [[ -f "$VSCODE_MCP" ]]; then + if python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert "demo-server" in d.get("servers", {})' "$VSCODE_MCP" 2>/dev/null; then + check_pass "8: claude -> vscode mcp under root key servers" + else + check_fail "8: vscode mcp.json missing servers.demo-server" + fi +else + check_fail "8: claude -> vscode mcp produced no file" +fi + +echo "" +echo "== 9. Goose YAML short-secret flags remain behind the fail-closed boundary ==" +mkdir -p "$HOME/.config/goose" +cat > "$HOME/.config/goose/config.yaml" <<'EOF' +extensions: + mcp: + servers: + shortflag-inline: + command: npx + args: ["-p", "SHORT_P_VAL", "-t", "SHORT_T_VAL", "-k", "SHORT_K_VAL"] + shortflag-cross: + command: npx + args: + - -p + - CROSS_P_VAL + - -t + - CROSS_T_VAL +EOF +run_goose_manual_mcp "9" + +echo "" +echo "== 10. Vector ②: StopIteration crash on quoted-key inline array ==" +S10="$HOME/.claude.json" +cat > "$S10" <<'EOF' +{ + "mcpServers": { + "stopiter-server": { + "command": "npx", + "args": ["-p", "STOPITER_PWD", "--token", "STOPITER_TOK", "benign-arg"] + } + } +} +EOF +run bash "$MIG" --source claude --target cursor --objects mcp --strategy overwrite --yes +assert_valid_json "$HOME/.cursor/mcp.json" "10: destination valid JSON after line redaction" +if [[ $LAST_RC -eq 0 ]]; then check_pass "10: migration did NOT crash on quoted-key inline array (rc=0)"; else check_fail "10: migration aborted/crashed on quoted-key inline array (rc=$LAST_RC)"; fi +if grep -Fq "STOPITER_PWD" "$HOME/.cursor/mcp.json"; then check_fail "10: -p value leaked"; else check_pass "10: -p value blanked"; fi +if grep -Fq "STOPITER_TOK" "$HOME/.cursor/mcp.json"; then check_fail "10: --token value leaked"; else check_pass "10: --token value blanked"; fi +if grep -Fq '"benign-arg"' "$HOME/.cursor/mcp.json"; then check_pass "10: benign argv preserved"; else check_fail "10: benign argv lost"; fi + +echo "" +echo "== 11. Goose YAML list-item secrets remain behind the fail-closed boundary ==" +mkdir -p "$HOME/.config/goose" +cat > "$HOME/.config/goose/config.yaml" <<'EOF' +extensions: + mcp: + servers: + yaml-list-server: + command: npx + env: + - api_key: "secret123" + - token: "tok-xyz-789" + - normal_var: "keep-this" +EOF +run_goose_manual_mcp "11" + +echo "" +echo "== 12. Goose YAML multi-line args remain behind the fail-closed boundary ==" +mkdir -p "$HOME/.config/goose" +cat > "$HOME/.config/goose/config.yaml" <<'EOF' +extensions: + mcp: + servers: + multiline-args-server: + command: npx + args: + - --token + - TOKVALUE-abcdef123456 + - -p + - mypassword + - normal-arg +EOF +run_goose_manual_mcp "12" + +echo "" +echo "== 13. Compact config remains behind the manual boundary ==" +mkdir -p "$HOME/.claude" +cat > "$HOME/.claude/settings.json" <<'EOF' +{ + "apiKey": "AK_SL", "token": "TOK_SL", "password": "PW_SL", + "normalField": "keep-this-too", + "nested": { "secret": "SEC_NESTED" } +} +EOF +run bash "$MIG" --source claude --target openclaw --objects config --strategy overwrite --yes +D13="$HOME/.openclaw/openclaw.json" +if [[ ! -e "$D13" ]]; then + check_pass "13: compact config boundary creates no target file" +else + check_fail "13: compact config boundary unexpectedly wrote a target file" +fi +if grep -Fq "automatic whole-IDE config migration is unsupported" "$OUT_FILE"; then check_pass "13: compact config boundary explains manual review"; else check_fail "13: compact config boundary message missing"; fi + +echo "" +echo "== 14. Goose YAML keyed secrets remain behind the fail-closed boundary ==" +mkdir -p "$HOME/.config/goose" +cat > "$HOME/.config/goose/config.yaml" <<'EOF' +extensions: + mcp: + servers: + keyedline-server: + command: npx + token: "tok-xyz-789" + apiKey: bare-val-42 + timeout: "30s" +EOF +run_goose_manual_mcp "14" + +echo "" +echo "== 15. Goose YAML consecutive secret flags remain behind the fail-closed boundary ==" +mkdir -p "$HOME/.config/goose" +cat > "$HOME/.config/goose/config.yaml" <<'EOF' +extensions: + mcp: + servers: + consecutive-flags-server: + command: npx + args: + - -p + - -t + - CONSEC_SECRET_VAL + - --verbose +EOF +run_goose_manual_mcp "15" + +echo "" +echo "== 16. Review-fix: fail-closed on redaction failure (vector ② hardening) ==" +D16_DIR=$(mktemp -d "$TMP_ROOT/failclosed.XXXXXX") +D16="$D16_DIR/copy.json" +printf '{"apiKey": "FAILCLOSED_SECRET"}\n' > "$D16" +chmod 000 "$D16" +if [[ -r "$D16" ]]; then + # Windows without POSIX semantics ignores permission bits; the + # unreadable-input fail-closed path cannot be exercised here. + echo "SKIP: 16 (permission bits not enforced on this host)" + D16_SKIPPED=1 +else + D16_SKIPPED=0 +fi +if [[ "$D16_SKIPPED" == "1" ]]; then + chmod 644 "$D16" 2>/dev/null || true +else + set +e + D16_OUT=$(bash -c ' + eval "$(sed -n "/^REDACTOR_PY=/,/^}/p" "$1")" + eval "$(sed -n "/^redact_secrets_in_file()/,/^}/p" "$1")" + redact_secrets_in_file "$2" + ' _ "$LEGACY_MIG" "$D16" 2>/dev/null) + D16_RC=$? + set -e + if [[ $D16_RC -ne 0 ]]; then check_pass "16: fail-closed returns non-zero rc"; else check_fail "16: fail-closed returned rc=0"; fi + if [[ "$D16_OUT" == "-1" ]]; then check_pass "16: fail-closed emits -1 sentinel"; else check_fail "16: fail-closed emitted '$D16_OUT' (expected -1)"; fi + if [[ ! -e "$D16" ]]; then check_pass "16: secret-bearing copy deleted (fail closed)"; else check_fail "16: secret-bearing copy left on disk"; chmod 644 "$D16" 2>/dev/null || true; fi +fi + +echo "" +echo "== 17. CR-001: provider-key VALUE formats redacted under non-secret key names ==" +S17="$HOME/.claude.json" +cat > "$S17" <<'EOF' +{ + "mcpServers": { + "provider-vals": { + "command": "npx", + "env": { + "MY_KEY": "sk-ant-abcdefghijklmnopqrstuvw", + "WEBHOOK_URL": "xoxb-1234567890-abcdefghij", + "NORMAL_VAR": "keep-this-value" + } + } + } +} +EOF +run bash "$MIG" --source claude --target cursor --objects mcp --strategy overwrite --yes +assert_valid_json "$HOME/.cursor/mcp.json" "17: mcp destination is valid JSON" +if grep -Fq "sk-ant-abcdefghijklmnopqrstuvw" "$HOME/.cursor/mcp.json"; then check_fail "17: provider value (sk-) under MY_KEY leaked"; else check_pass "17: sk- provider value blanked under non-secret key"; fi +if grep -Fq "xoxb-1234567890-abcdefghij" "$HOME/.cursor/mcp.json"; then check_fail "17: provider value (xoxb) under WEBHOOK_URL leaked"; else check_pass "17: xoxb provider value blanked under non-secret key"; fi +if grep -Fq "keep-this-value" "$HOME/.cursor/mcp.json"; then check_pass "17: non-secret value preserved"; else check_fail "17: non-secret value lost"; fi +[[ $LAST_RC -eq 0 ]] && check_pass "17: mcp migration exited rc=0" || check_fail "17: mcp migration exited rc=$LAST_RC (expected 0)" + +mkdir -p "$HOME/.claude" +cat > "$HOME/.claude/settings.json" <<'EOF' +{ + "editor.fontSize": 14, + "modelKey": "ghp_abcdefghijklmnopqrst", + "svcAccount": "AKIAIOSFODNN7EXAMPLE", + "telemetry": "off" +} +EOF +run bash "$MIG" --source claude --target openclaw --objects config --strategy overwrite --yes +D17="$HOME/.openclaw/openclaw.json" +if [[ ! -e "$D17" ]]; then + check_pass "17: provider-bearing config boundary creates no target file" +else + check_fail "17: provider-bearing config boundary unexpectedly wrote a target file" +fi +if grep -Fq "automatic whole-IDE config migration is unsupported" "$OUT_FILE"; then check_pass "17: provider-bearing config boundary explains manual review"; else check_fail "17: provider-bearing config boundary message missing"; fi + +echo "" +echo "== 18. CR-002: fail-closed when python3 is unavailable (no silent leak) ==" +T18_BIN="$(mktemp -d "$TMP_ROOT/no-py.XXXXXX")" +for b in /bin/* /usr/bin/*; do + bn="$(basename "$b")" + case "$bn" in python*) continue ;; esac + ln -s "$b" "$T18_BIN/$bn" 2>/dev/null || true +done +run_no_python3() { PATH="$T18_BIN" "$@" > "$OUT_FILE" 2>&1; LAST_RC=$?; } + +S18="$HOME/.claude.json" +cat > "$S18" <<'EOF' +{ "mcpServers": { "leak-test": { "env": { "API_KEY": "sk-ant-TOTALLYSECRETMUSTNOTLEAK" } } } } +EOF +run_no_python3 bash "$MIG" --source claude --target cursor --objects mcp --strategy overwrite --yes +if [[ ! -e "$HOME/.cursor/mcp.json" ]]; then + check_pass "18a: MCP copy absent (fail-closed removed the un-redacted file)" +else + if grep -Fq "sk-ant-TOTALLYSECRETMUSTNOTLEAK" "$HOME/.cursor/mcp.json"; then + check_fail "18a: secret LEAKED despite missing python3" + else + check_fail "18a: copy left on disk without secret (should have been deleted)" + fi +fi +if grep -Fq "[SECURITY]" "$OUT_FILE"; then check_pass "18a: [SECURITY] warning emitted when python3 missing"; else check_fail "18a: [SECURITY] warning missing for no-python3 path"; fi + +mkdir -p "$HOME/.claude" +cat > "$HOME/.claude/settings.json" <<'EOF' +{ "apiKey": "sk-ant-CONFIGSECRETMUSTNOTLEAK", "telemetry": "off" } +EOF +run_no_python3 bash "$MIG" --source claude --target openclaw --objects config --strategy overwrite --yes +if [[ ! -e "$HOME/.openclaw/openclaw.json" ]]; then + check_pass "18b: config boundary creates no file without python3" +else + check_fail "18b: config boundary unexpectedly wrote a file without python3" +fi + +echo "" +echo "== 19. MED-T3: malformed source JSON must not mutate the source ==" +S19="$HOME/.claude.json" +printf '{ "mcpServers": ' > "$S19" +ORIG="$(cat "$S19")" +run bash "$MIG" --source claude --target cursor --objects mcp --strategy overwrite --yes +if [[ "$(cat "$S19")" == "$ORIG" ]]; then + check_pass "19: malformed source config left UNCHANGED (fail-open-safe, recoverable)" +else + check_fail "19: malformed source config was MUTATED by migration" +fi +if [[ "$LAST_RC" =~ ^[0-9]+$ ]]; then + check_pass "19: migration returned a clean exit code (rc=$LAST_RC) on malformed input" +else + check_fail "19: migration produced a non-numeric exit status on malformed input" +fi + +echo "" +echo "== 20. Non-canonical MCP source file + safe environment references ==" +S20_DIR="$TMP_ROOT/custom-source" +S20="$S20_DIR/cursor-export.json" +W20="$TMP_ROOT/custom-source-workspace" +mkdir -p "$S20_DIR" "$W20" +S20_RESOLVED="$(cd "$S20_DIR" && pwd -P)/$(basename "$S20")" +cat > "$S20" <<'EOF' +{ + "mcpServers": { + "local-search": { + "command": "npx", + "args": ["-y", "@acme/search-mcp"], + "env": { + "ACME_API_KEY": "${env:ACME_API_KEY}", + "UNSUPPORTED_TOKEN": "${UNSUPPORTED_TOKEN}", + "LITERAL_TOKEN": "__literal_secret_should_blank__", + "LOG_LEVEL": "info" + } + }, + "remote-docs": { + "url": "https://mcp.acme.example/rpc?api_key=${env:ACME_QUERY_KEY}", + "headers": { + "Authorization": "Bearer ${env:ACME_BEARER_TOKEN}", + "X-Workspace": "acme" + } + } + } +} +EOF +S20_ORIG="$(cat "$S20")" + +set +e +# Native Windows Python treats MSYS-style values as relative paths, so the +# explicit file/workspace arguments must cross into the engine natively. +if command -v cygpath >/dev/null 2>&1; then + W20_ARG="$(cygpath -w "$W20")"; S20_ARG="$(cygpath -w "$S20")" +else + W20_ARG="$W20"; S20_ARG="$S20" +fi +run bash "$MIG" --source cursor --target opencode --workspace "$W20_ARG" \ + --objects project-mcp --source-mcp-file "$S20_ARG" --dry-run +set -e +if [[ $LAST_RC -eq 0 ]]; then check_pass "20a: custom-source dry-run exits 0"; else check_fail "20a: custom-source dry-run exits rc=$LAST_RC"; fi +# The engine echoes the source path in its native view; under MSYS that is +# a drive-qualified Windows path while S20_RESOLVED is the POSIX view. The +# engine may print either separator, so accept all three spellings. +if command -v cygpath >/dev/null 2>&1; then + S20_RESOLVED_MIXED="$(cygpath -m "$S20_RESOLVED")" + S20_RESOLVED_WIN="$(cygpath -w "$S20_RESOLVED")" +else + S20_RESOLVED_MIXED="$S20_RESOLVED" + S20_RESOLVED_WIN="$S20_RESOLVED" +fi +if grep -Fq "source: $S20_RESOLVED" "$OUT_FILE" \ + || grep -Fq "source: $S20_RESOLVED_MIXED" "$OUT_FILE" \ + || grep -Fq "source: $S20_RESOLVED_WIN" "$OUT_FILE"; then + if grep -Fq "validated MCP source" "$OUT_FILE"; then + check_pass "20a: dry-run reads and validates the selected source file" + else + check_fail "20a: dry-run did not validate the selected source file" + fi +else + check_fail "20a: dry-run did not consume the selected source file" +fi +if [[ ! -e "$W20/opencode.json" ]]; then check_pass "20a: dry-run leaves target absent"; else check_fail "20a: dry-run wrote target config"; fi + +run bash "$MIG" --source cursor --target opencode --workspace "$(cygpath -w "$W20" 2>/dev/null || printf '%s' "$W20")" \ + --objects project-mcp --source-mcp-file "$(cygpath -w "$S20" 2>/dev/null || printf '%s' "$S20")" --strategy overwrite --yes +D20="$W20/opencode.json" +if [[ $LAST_RC -eq 0 ]]; then check_pass "20b: custom-source apply exits 0"; else check_fail "20b: custom-source apply exits rc=$LAST_RC"; fi +if [[ ! -e "$D20" ]]; then + # Diagnostic evidence for host-specific target resolution failures. + echo "DIAG 20b: workspace contents:" >&2 + ls -la "$W20" >&2 || true + echo "DIAG 20b: engine output tail:" >&2 + tail -5 "$OUT_FILE" >&2 || true +fi +assert_valid_json "$D20" "20b: custom-source destination is valid JSON" +S20_CHECK=$(python3 - "$D20" <<'PY' +import json, sys +d = json.load(open(sys.argv[1]))["mcp"] +assert d["local-search"]["type"] == "local" +assert d["local-search"]["command"] == ["npx", "-y", "@acme/search-mcp"] +assert d["local-search"]["environment"]["ACME_API_KEY"] == "{env:ACME_API_KEY}" +assert d["local-search"]["environment"]["UNSUPPORTED_TOKEN"] == "" +assert d["local-search"]["environment"]["LITERAL_TOKEN"] == "" +assert d["local-search"]["environment"]["LOG_LEVEL"] == "info" +assert d["remote-docs"]["type"] == "remote" +assert d["remote-docs"]["url"] == "https://mcp.acme.example/rpc?api_key={env:ACME_QUERY_KEY}" +assert d["remote-docs"]["headers"]["Authorization"] == "Bearer {env:ACME_BEARER_TOKEN}" +assert d["remote-docs"]["headers"]["X-Workspace"] == "acme" +print("OK") +PY +) +if [[ "$S20_CHECK" == "OK" ]]; then + check_pass "20b: conversion preserves safe references and blanks literal credentials" +else + check_fail "20b: converted MCP semantics are incorrect" +fi +if [[ "$(cat "$S20")" == "$S20_ORIG" ]]; then check_pass "20b: selected source file remains unchanged"; else check_fail "20b: selected source file was mutated"; fi + +echo "" +echo "== 21. Explicit MCP source rejects ambiguous or foreign inputs ==" +S21="$S20_DIR/foreign-schema.json" +W21="$TMP_ROOT/invalid-source-workspace" +mkdir -p "$W21" +cat > "$S21" <<'EOF' +{ "servers": { "wrong-root": { "command": "echo" } } } +EOF + +set +e +run bash "$MIG" --source cursor --target opencode --workspace "$W21" \ + --objects project-mcp --source-mcp-file "$S21" --strategy overwrite --yes +set -e +if [[ $LAST_RC -ne 0 ]] && grep -Fq "failed strict schema validation" "$OUT_FILE"; then + check_pass "21a: foreign source schema is reported as failed" +else + check_fail "21a: foreign source schema was accepted" +fi +if [[ ! -e "$W21/opencode.json" ]]; then check_pass "21a: foreign schema leaves target absent"; else check_fail "21a: foreign schema wrote a target"; fi + +set +e +run bash "$MIG" --source cursor --target opencode --workspace "$W21" \ + --objects rules --source-mcp-file "$S21" --dry-run +set -e +if [[ $LAST_RC -ne 0 ]] && grep -Fq "requires --objects mcp or project-mcp" "$OUT_FILE"; then + check_pass "21b: override is rejected outside MCP objects" +else + check_fail "21b: override was accepted outside MCP objects" +fi + +set +e +run bash "$MIG" --source cursor --target opencode --workspace "$W21" \ + --objects mcp --scope both --source-mcp-file "$S21" --dry-run +set -e +if [[ $LAST_RC -ne 0 ]] && grep -Fq "cannot represent both global and project MCP scopes" "$OUT_FILE"; then + check_pass "21c: override rejects ambiguous both-scope input" +else + check_fail "21c: override accepted ambiguous both-scope input" +fi + +S21_YAML="$S20_DIR/continue.yaml" +cat > "$S21_YAML" <<'EOF' +mcpServers: [] +EOF +set +e +run bash "$MIG" --source continue --target opencode --workspace "$W21" \ + --objects project-mcp --source-mcp-file "$S21_YAML" --dry-run +set -e +if [[ $LAST_RC -ne 0 ]] && grep -Fq "accepts JSON or JSONC only" "$OUT_FILE"; then + check_pass "21d: override rejects YAML/TOML format boundaries explicitly" +else + check_fail "21d: override did not clearly reject a YAML input" +fi + +echo "" +echo "== 22. Explicit-source JSONC parsing and symlink identity safety ==" +S22_JSONC="$S20_DIR/cursor-export.jsonc" +cat > "$S22_JSONC" <<'EOF' +{ + // A real JSONC comment. + "mcpServers": { + "jsonc-local": { + "command": "echo", + "args": ["https://example.test/a/*literal*/", "literal // text /* x */"], + }, + }, +} +EOF +set +e +run bash "$MIG" --source cursor --target opencode --workspace "$W21" \ + --objects project-mcp --source-mcp-file "$S22_JSONC" --dry-run +set -e +if [[ $LAST_RC -eq 0 ]] && grep -Fq "validated MCP source: 1 server entries" "$OUT_FILE"; then + check_pass "22a: JSONC comments/trailing commas do not corrupt string contents" +else + check_fail "22a: valid JSONC source was rejected" +fi + +W22_LINK="$TMP_ROOT/symlink-source-workspace" +mkdir -p "$W22_LINK" +D22_LINK="$W22_LINK/opencode.json" +cat > "$D22_LINK" <<'EOF' +{ "mcpServers": { "only-copy": { "command": "echo", "args": [] } } } +EOF +S22_LINK="$S20_DIR/link-to-target.json" +ln -s "$D22_LINK" "$S22_LINK" +if [[ ! -L "$S22_LINK" ]]; then + echo "SKIP: 22b (symlinks unavailable on this host)" +else + D22_ORIG="$(cat "$D22_LINK")" + run bash "$MIG" --source cursor --target opencode --workspace "$W22_LINK" \ + --objects project-mcp --source-mcp-file "$S22_LINK" --strategy overwrite --yes + if grep -Fq "source and target resolve to the same file" "$OUT_FILE"; then check_pass "22b: symlinked self-target is refused"; else check_fail "22b: symlinked self-target was not detected"; fi + if [[ "$(cat "$D22_LINK")" == "$D22_ORIG" ]]; then check_pass "22b: symlink identity guard preserves the only copy"; else check_fail "22b: symlink identity guard allowed mutation"; fi +fi + +echo "" +echo "== 23. Safe-reference URLs cannot hide a second literal credential ==" +S23="$S20_DIR/mixed-url.json" +W23="$TMP_ROOT/mixed-url-workspace" +mkdir -p "$W23" +PROVIDER_PREFIX="sk-" +PROVIDER_BODY="ABCDEFGHIJKLMNOPQRSTUV" +cat > "$S23" < "$HOME/.claude/settings.json" < "$S24" <<'EOF' +{ "mcpServers": { "source-entry": { "command": "echo", "args": [] } } } +EOF +cat > "$D24_REAL" <<'EOF' +{ "sentinel": "must remain unchanged" } +EOF +ln -s "$D24_REAL" "$W24/opencode.json" +if [[ ! -L "$W24/opencode.json" ]]; then + # Windows without Developer Mode silently degrades ln -s to a copy; + # the symlink-rejection guarantee is untestable on such hosts. + echo "SKIP: 24 (symlinks unavailable on this host)" + rm -f "$W24/opencode.json" +else + D24_ORIG="$(cat "$D24_REAL")" + set +e + run bash "$MIG" --source cursor --target opencode --workspace "$W24" \ + --objects project-mcp --source-mcp-file "$S24" --strategy overwrite --yes + set -e +fi +if [[ -L "$W24/opencode.json" ]]; then + if [[ $LAST_RC -ne 0 ]] && grep -Fq "target is a symbolic link" "$OUT_FILE"; then + check_pass "24: symlinked MCP target is rejected" + else + check_fail "24: symlinked MCP target was accepted" + fi + if [[ "$(cat "$D24_REAL")" == "$D24_ORIG" ]]; then + check_pass "24: rejected symlink target and referent remain unchanged" + else + check_fail "24: symlink target rejection allowed mutation" + fi +fi + +echo "" +if [[ $FAIL -eq 0 ]]; then + echo "ALL $CHECKS MCP SECRET-REDACTION CHECKS PASSED" + exit 0 +else + echo "$FAIL / $CHECKS MCP SECRET-REDACTION CHECKS FAILED" >&2 + exit 1 +fi diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-migrate-command.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migrate-command.sh new file mode 100644 index 000000000..959b3d173 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migrate-command.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +WRAPPER="${SCRIPT_DIR}/smart-ide-migration.sh" + +WS="$(mktemp -d /tmp/migrate-cmd-ws.XXXXXX)" +HOME_DIR="$(mktemp -d /tmp/migrate-cmd-home.XXXXXX)" +trap 'rm -rf "$WS" "$HOME_DIR"' EXIT + +# Stage a cline skill in HOME so the user-scope surface resolves. +mkdir -p "$HOME_DIR/.cline/skills/fixture-skill" +cat > "$HOME_DIR/.cline/skills/fixture-skill/SKILL.md" <<'SKILL' +--- +name: fixture-skill +description: Test skill for migrate command. +metadata: + version: '1' +--- +# fixture +SKILL + +# Stage an instructions source in HOME. +mkdir -p "$HOME_DIR/.cline/rules" +cat > "$HOME_DIR/.cline/rules/review.md" <<'INSTR' +--- +paths: + - 'src/**/*.ts' +description: Review +--- +Review. +INSTR + +# Stage an MCP source in HOME. +cat > "$HOME_DIR/.cline/mcp.json" <<'MCP' +{"mcpServers":{"demo":{"command":"demo","args":["--safe"]}}} +MCP + +# --plan-only writes the plan but does NOT touch the target tree. +HOME="$(native_path "$HOME_DIR")" "${WRAPPER}" migrate \ + --source cline/ide \ + --target forge/cli \ + --workspace "$WS" \ + --scope user \ + --objects skills,instructions \ + --plan-only \ + --yes >/dev/null + +PLAN="$WS/.migration/migrate-plan.json" +[[ -f "$PLAN" ]] || { echo "FAIL: plan not written"; exit 1; } +echo "OK --plan-only wrote plan to $PLAN" + +# Confirm plan-only mode did NOT call apply: no manifest under .migration. +[[ ! -f "$WS/.migration/migrate-manifest.json" ]] || { + echo "FAIL: plan-only should not have written a manifest"; exit 1; +} +echo "OK --plan-only did not invoke apply" + +# Confirm no target files were written. +[[ ! -d "$HOME_DIR/forge" ]] || { + echo "FAIL: plan-only wrote target files"; exit 1; +} +echo "OK --plan-only performed zero writes" + +# Default --objects is all-portable. Confirm plan documents default. +PLAN_OBJECTS="$(python3 -c "import json,sys; print(','.join(json.load(open(sys.argv[1]))['objects']))" "$PLAN")" +echo "OK plan objects: $PLAN_OBJECTS" + +# Full pipeline (plan + apply + verify). +OUT="$(HOME="$(native_path "$HOME_DIR")" "${WRAPPER}" migrate \ + --source cline/ide \ + --target forge/cli \ + --workspace "$WS" \ + --scope user \ + --objects skills,instructions,mcp \ + --yes --json 2>&1)" +MIGRATE_RC=$? +if [[ $MIGRATE_RC -ne 0 ]]; then + echo "MIGRATE FAILED with output:" + echo "$OUT" | head -50 + exit 1 +fi +echo "$OUT" > "$WS/migrate-out.json" +python3 - "$(native_path "$WS/migrate-out.json")" <<'PY' +import json, re, sys +text = open(sys.argv[1]).read() +m = re.search(r'\{.*\}', text, re.DOTALL) +if not m: + print('FAIL: no JSON in migrate output:', text[:200]) + raise SystemExit(1) +out = json.loads(m.group(0)) +assert out['ok'] is True, out +assert out['stage'] == 'verify', out +assert 'manifest' in out and out['manifest'].endswith('migrate-manifest.json'), out +assert 'verify' in out and out['verify'].endswith('migrate-verify.json'), out +assert out['summary'], out['summary'] +assert out['summary'].get('applied', 0) >= 1, out['summary'] +print('OK full migrate pipeline summary:', out['summary']) +PY + +# Verify the ready Skill landed on the forge target tree. +SKILL_DST="$HOME_DIR/forge/skills/fixture-skill" +[[ -f "$SKILL_DST/SKILL.md" ]] || { + echo "FAIL: ready skill did not land at $SKILL_DST"; exit 1; +} +echo "OK ready skill landed at $SKILL_DST" + +# Verify the verify artifact says OK. +VERIFY="$WS/.migration/migrate-verify.json" +VERIFY_OK="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['ok'])" "$VERIFY")" +[[ "$VERIFY_OK" == "True" ]] || { echo "FAIL: verify reported errors: $VERIFY_OK"; exit 1; } +echo "OK verify artifact reports ok=true" + +# Re-run should be idempotent at the target tree level. +HOME="$(native_path "$HOME_DIR")" "${WRAPPER}" migrate \ + --source cline/ide \ + --target forge/cli \ + --workspace "$WS" \ + --scope user \ + --objects skills,instructions,mcp \ + --yes >/dev/null 2>&1 || true +echo "OK second migrate invocation completed (idempotency not strictly asserted without fixtures)" + +# --strict with mixed-status plan should fail. +set +e +HOME="$(native_path "$HOME_DIR")" "${WRAPPER}" migrate \ + --source cline/ide \ + --target forge/cli \ + --workspace "$WS" \ + --scope user \ + --objects skills,instructions,mcp \ + --strict \ + --yes >/dev/null 2>&1 +STRICT_RC=$? +set -e +if [[ $STRICT_RC -eq 0 ]]; then + echo "FAIL: --strict should have rejected a mixed plan" + exit 1 +fi +echo "OK --strict mode rejected mixed plan (rc=$STRICT_RC)" + +echo +echo "Migrate command tests passed" \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-core.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-core.sh new file mode 100644 index 000000000..2059bd423 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-core.sh @@ -0,0 +1,581 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so Path.home()/os.environ["HOME"] resolution sees a real dir. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +CLI="$SCRIPT_DIR/smart-ide-migration.sh" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +WORKSPACE="$TMP_ROOT/workspace" +TEST_HOME="$TMP_ROOT/home" +mkdir -p \ + "$WORKSPACE/.cline/skills/demo" \ + "$WORKSPACE/.cline/rules" \ + "$TEST_HOME" + +bash "$CLI" > "$TMP_ROOT/help.txt" +grep -E '\{.*detect.*inventory.*plan.*apply.*verify.*rollback.*legacy.*\}' "$TMP_ROOT/help.txt" >/dev/null + +cp "$SKILL_DIR/evals/files/instruction-cline-source.md" \ + "$WORKSPACE/.cline/rules/source.mdc" +printf '%s\n' '---' 'name: demo' 'description: Test fixture.' '---' '# Demo' \ + > "$WORKSPACE/.cline/skills/demo/SKILL.md" +printf '%s\n' 'NON_SECRET_FIXTURE=kept-out-of-target' \ + > "$WORKSPACE/.cline/skills/demo/.env.local" +printf '%s\n' \ + '{' \ + ' "mcpServers": {' \ + ' "example": {' \ + ' "command": "example-server",' \ + ' "args": ["--token", "literal-arg-secret"],' \ + ' "env": {"API_TOKEN": "literal-secret", "MY_VALUE": "sk-ant-abcdefghijklmnopqrstuvw", "REDIRECT_URL": "https://nested:password@example.test/callback", "MODE": "safe"}' \ + ' }' \ + ' }' \ + '}' > "$WORKSPACE/.cline/mcp.json" + +cp "$WORKSPACE/.cline/mcp.json" "$TMP_ROOT/mcp-before-plan-overlap.json" +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects mcp \ + --scope project \ + --output "$WORKSPACE/.cline/mcp.json" \ + --json > "$TMP_ROOT/plan-overlap.log" 2>&1; then + echo "FAIL: plan output overwrote a migration surface" >&2 + exit 1 +fi +grep -Fq 'plan output overlaps' "$TMP_ROOT/plan-overlap.log" +cmp "$TMP_ROOT/mcp-before-plan-overlap.json" "$WORKSPACE/.cline/mcp.json" + +HOME="$(native_path "$TEST_HOME")" bash "$CLI" inventory \ + --workspace "$WORKSPACE" --product cline --json > "$TMP_ROOT/inventory.json" +python3 - "$TMP_ROOT/inventory.json" <<'PY' +import json +import sys +from pathlib import Path + +rows = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +def _hit(t): + hits = [r.get("resolved_path") for r in rows if r.get("object_type") == t and r["exists"]] + assert hits, f"no existing {t} rows: " + json.dumps( + [(r.get("object_type"), r.get("scope"), r.get("resolved_path")) for r in rows][:12] + ) +_hit("mcp") +_hit("skills") +PY + +HOME="$(native_path "$TEST_HOME")" bash "$CLI" detect \ + --workspace "$WORKSPACE" --product cline --json > "$TMP_ROOT/detect.json" +python3 - "$TMP_ROOT/detect.json" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +rows = data.get("detections", data) if isinstance(data, dict) else data +assert any(r.get("product") == "cline" and r.get("state") == "installed" for r in rows) +PY + +PLAN_FILE="$TMP_ROOT/plan.json" +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects skills,instructions,mcp \ + --scope project \ + --output "$PLAN_FILE" \ + --json > "$TMP_ROOT/plan-output.json" +python3 - "$PLAN_FILE" <<'PY' +import json +import sys +from pathlib import Path + +plan = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert plan["schema_version"] == 1 +assert len(plan["plan_sha256"]) == 64 +assert [item["status"] for item in plan["items"]] == ["ready", "ready", "ready"] +assert all(item["review_preview"] for item in plan["items"]) +assert {item["field"] for item in plan["loss_report"]["items"]} >= { + "example.env.API_TOKEN", + "example.args[1]", +} +PY +if grep -E 'literal-(secret|arg-secret)|sk-ant-abcdefghijklmnopqrstuvw|nested:password@' "$PLAN_FILE" >/dev/null; then + echo "FAIL: a literal MCP credential reached the saved plan" >&2 + exit 1 +fi + +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" apply "$PLAN_FILE" \ + > "$TMP_ROOT/no-confirm.log" 2>&1; then + echo "FAIL: apply succeeded without --yes" >&2 + exit 1 +fi + +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" apply "$PLAN_FILE" \ + --manifest "$WORKSPACE/AGENTS.md" \ + --yes > "$TMP_ROOT/manifest-overlap.log" 2>&1; then + echo "FAIL: manifest path overlapped a migration surface" >&2 + exit 1 +fi +grep -Fq 'manifest path overlaps' "$TMP_ROOT/manifest-overlap.log" +[[ ! -e "$WORKSPACE/.forge/skills/demo" ]] +[[ ! -e "$WORKSPACE/AGENTS.md" ]] +[[ ! -e "$WORKSPACE/.mcp.json" ]] + +MANIFEST="$TMP_ROOT/manifest.json" +HOME="$(native_path "$TEST_HOME")" bash "$CLI" apply \ + "$PLAN_FILE" \ + --manifest "$MANIFEST" \ + --yes \ + --json > "$TMP_ROOT/apply.json" + +[[ -f "$WORKSPACE/.forge/skills/demo/SKILL.md" ]] +[[ ! -e "$WORKSPACE/.forge/skills/demo/.env.local" ]] +[[ -f "$WORKSPACE/AGENTS.md" ]] +[[ -f "$WORKSPACE/.mcp.json" ]] +grep -F '"API_TOKEN": "${API_TOKEN}"' "$WORKSPACE/.mcp.json" >/dev/null +grep -F '"--token",' "$WORKSPACE/.mcp.json" >/dev/null +grep -F '"${TOKEN}"' "$WORKSPACE/.mcp.json" >/dev/null +if grep -E 'literal-(secret|arg-secret|query-secret)|sk-ant-abcdefghijklmnopqrstuvw|user:pass@|nested:password@' "$WORKSPACE/.mcp.json" >/dev/null; then + echo "FAIL: literal MCP secret reached the target" >&2 + exit 1 +fi + +bash "$CLI" verify --manifest "$MANIFEST" --json > "$TMP_ROOT/verify.json" +python3 - "$TMP_ROOT/verify.json" "$MANIFEST" <<'PY' +import json +import sys +from pathlib import Path + +assert json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["ok"] is True +manifest = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) +assert manifest["schema_version"] == 2 +assert len(manifest["manifest_sha256"]) == 64 +assert len(manifest["provenance"]["plan_sha256"]) == 64 +PY + +cp "$WORKSPACE/AGENTS.md" "$TMP_ROOT/agents-before-tamper.md" +printf '%s\n' '# changed after apply' >> "$WORKSPACE/AGENTS.md" +if bash "$CLI" rollback --manifest "$MANIFEST" --yes \ + > "$TMP_ROOT/changed-rollback.log" 2>&1; then + echo "FAIL: rollback overwrote a target changed after apply" >&2 + exit 1 +fi +grep -Fq 'changed after apply' "$TMP_ROOT/changed-rollback.log" +cp "$TMP_ROOT/agents-before-tamper.md" "$WORKSPACE/AGENTS.md" + +bash "$CLI" rollback --manifest "$MANIFEST" --yes --json > "$TMP_ROOT/rollback.json" +[[ ! -e "$WORKSPACE/.forge/skills/demo" ]] +[[ ! -e "$WORKSPACE/AGENTS.md" ]] +[[ ! -e "$WORKSPACE/.mcp.json" ]] + +mkdir -p \ + "$WORKSPACE/.cline/skills/leaky" \ + "$WORKSPACE/.forge/skills/leaky" +cp "$SKILL_DIR/SKILL.md" "$WORKSPACE/.cline/skills/leaky/SKILL.md" +cp "$SCRIPT_DIR/test-migration-core.sh" \ + "$WORKSPACE/.cline/skills/leaky/payload.sh" +printf '%s\n' 'preserve-existing-target' \ + > "$WORKSPACE/.forge/skills/leaky/sentinel.txt" + +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects skills \ + --scope project \ + --json > "$TMP_ROOT/secret-preflight-plan.json" +python3 - "$TMP_ROOT/secret-preflight-plan.json" <<'PY' +import json +import sys +from pathlib import Path + +plan = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert plan["items"][0]["status"] == "invalid" +assert "source credential preflight failed" in plan["items"][0]["reason"] +assert "payload.sh: provider credential pattern" in plan["items"][0]["reason"] +PY + +mv "$WORKSPACE/.cline/skills/leaky/payload.sh" \ + "$TMP_ROOT/leaky-payload.sh" +python3 - \ + "$SCRIPT_DIR" \ + "$SKILL_DIR/references/registry-v2.json" \ + "$WORKSPACE" \ + "$TEST_HOME" \ + "$TMP_ROOT/leaky-payload.sh" <<'PY' +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from migration_core import Registry, apply_plan, build_plan + +registry = Registry(Path(sys.argv[2]), Path(sys.argv[3]), Path(sys.argv[4])) +plan, _ = build_plan( + registry, + "cline/ide", + "forge/cli", + ["skills"], + "project", +) +assert plan[0].status == "ready" +shutil.copy2( + Path(sys.argv[5]), + Path(sys.argv[3], ".cline/skills/leaky/payload.sh"), +) +try: + apply_plan(plan, Path(sys.argv[3])) +except ValueError as error: + assert "source credential preflight failed" in str(error) +else: + raise AssertionError("apply did not rescan a changed Skill source") +PY + +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects skills \ + --scope project \ + --output "$TMP_ROOT/blocked-plan.json" \ + --json >/dev/null +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" apply "$TMP_ROOT/blocked-plan.json" \ + --strict --yes > "$TMP_ROOT/secret-preflight-apply.log" 2>&1; then + echo "FAIL: profile-aware apply copied a Skill with a literal credential" >&2 + exit 1 +fi +grep -Fq 'preserve-existing-target' \ + "$WORKSPACE/.forge/skills/leaky/sentinel.txt" +[[ ! -e "$WORKSPACE/.forge/skills/leaky/payload.sh" ]] +mv "$WORKSPACE/.cline/skills/leaky" "$TMP_ROOT/leaky-source" +mv "$WORKSPACE/.forge/skills/leaky" "$TMP_ROOT/leaky-target" + +mkdir -p "$WORKSPACE/.cline/skills/drift" +printf '%s\n' '---' 'name: drift' 'description: Drift fixture.' '---' '# Before' \ + > "$WORKSPACE/.cline/skills/drift/SKILL.md" +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects skills \ + --scope project \ + --output "$TMP_ROOT/drift-plan.json" \ + --json >/dev/null +printf '%s\n' '# Changed after review' >> "$WORKSPACE/.cline/skills/drift/SKILL.md" +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" apply "$TMP_ROOT/drift-plan.json" --yes \ + > "$TMP_ROOT/drift-apply.log" 2>&1; then + echo "FAIL: apply accepted a source changed after plan review" >&2 + exit 1 +fi +grep -Fq 'source changed after plan review' "$TMP_ROOT/drift-apply.log" +[[ ! -e "$WORKSPACE/.forge/skills/drift" ]] +mv "$WORKSPACE/.cline/skills/drift" "$TMP_ROOT/drift-source" + +GIT_WORKSPACE="$TMP_ROOT/git-workspace" +mkdir -p "$GIT_WORKSPACE/.cline/skills/git-drift" +printf '%s\n' '---' 'name: git-drift' 'description: Git drift fixture.' '---' '# Before' \ + > "$GIT_WORKSPACE/.cline/skills/git-drift/SKILL.md" +git -C "$GIT_WORKSPACE" init -q +git -C "$GIT_WORKSPACE" config user.name 'Migration Test' +git -C "$GIT_WORKSPACE" config user.email 'migration-test@example.invalid' +git -C "$GIT_WORKSPACE" add . +git -C "$GIT_WORKSPACE" commit -qm 'fixture: initial state' +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$GIT_WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects skills \ + --scope project \ + --output "$TMP_ROOT/git-drift-plan.json" \ + --json >/dev/null +printf '%s\n' '# unrelated reviewed-context change' > "$GIT_WORKSPACE/README.md" +git -C "$GIT_WORKSPACE" add README.md +git -C "$GIT_WORKSPACE" commit -qm 'fixture: advance head' +if HOME="$(native_path "$TEST_HOME")" bash "$CLI" apply "$TMP_ROOT/git-drift-plan.json" --yes \ + > "$TMP_ROOT/git-drift-apply.log" 2>&1; then + echo "FAIL: apply accepted a changed Git HEAD after plan review" >&2 + exit 1 +fi +grep -Fq 'Git head changed after plan review' "$TMP_ROOT/git-drift-apply.log" +[[ ! -e "$GIT_WORKSPACE/.forge/skills/git-drift" ]] + +mkdir -p \ + "$WORKSPACE/.cline/skills/symlink-race" \ + "$WORKSPACE/.forge/skills/symlink-race" +cp "$SKILL_DIR/SKILL.md" \ + "$WORKSPACE/.cline/skills/symlink-race/SKILL.md" +printf '%s\n' 'safe-before-copy' \ + > "$WORKSPACE/.cline/skills/symlink-race/payload.txt" +printf '%s\n' 'outside-source-boundary' \ + > "$TMP_ROOT/symlink-race-external.txt" +printf '%s\n' 'preserve-symlink-race-target' \ + > "$WORKSPACE/.forge/skills/symlink-race/sentinel.txt" +python3 - \ + "$SCRIPT_DIR" \ + "$SKILL_DIR/references/registry-v2.json" \ + "$WORKSPACE" \ + "$TEST_HOME" \ + "$TMP_ROOT/symlink-race-external.txt" <<'PY' +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from migration_core import Registry, apply_plan, build_plan + +workspace = Path(sys.argv[3]).resolve() +source_skill = workspace / ".cline/skills/symlink-race" +payload = source_skill / "payload.txt" +external = Path(sys.argv[5]) +registry = Registry(Path(sys.argv[2]), workspace, Path(sys.argv[4])) +plan, _ = build_plan( + registry, + "cline/ide", + "forge/cli", + ["skills"], + "project", +) +assert plan[0].status == "ready" +# Arming the race requires real symlink privileges; unprivileged Windows +# hosts reject symlink_to() outright, so skip instead of erroring. +_probe = source_skill / ".symlink-probe" +try: + _probe.symlink_to(external) + _probe.unlink() +except OSError: + print("SKIP: symlink race case (symlinks unavailable on this host)") + raise SystemExit(0) +original_copytree = shutil.copytree + + +def injecting_copytree(src, dst, *args, **kwargs): + if Path(src) == source_skill: + payload.unlink() + payload.symlink_to(external) + return original_copytree(src, dst, *args, **kwargs) + + +shutil.copytree = injecting_copytree +try: + apply_plan(plan, workspace) +except ValueError as error: + assert "symbolic links are not allowed" in str(error) +else: + raise AssertionError("apply followed a Skill symlink introduced during copy") +PY +grep -Fq 'preserve-symlink-race-target' \ + "$WORKSPACE/.forge/skills/symlink-race/sentinel.txt" +[[ ! -e "$WORKSPACE/.forge/skills/symlink-race/payload.txt" ]] +mv "$WORKSPACE/.cline/skills/symlink-race" \ + "$TMP_ROOT/symlink-race-source" +mv "$WORKSPACE/.forge/skills/symlink-race" \ + "$TMP_ROOT/symlink-race-target" + +mkdir -p "$TMP_ROOT/external-qwen" +ln -s "$TMP_ROOT/external-qwen" "$WORKSPACE/.qwen" +if [[ ! -L "$WORKSPACE/.qwen" ]]; then + echo "SKIP: qwen symlink-boundary case (symlinks unavailable on this host)" + rm -rf "$WORKSPACE/.qwen" +else + HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$WORKSPACE" \ + --source cline/ide \ + --target qwen-code/cli \ + --objects skills \ + --scope project \ + --json > "$TMP_ROOT/symlink-plan.json" + python3 - "$TMP_ROOT/symlink-plan.json" <<'PY' +import json +import sys +from pathlib import Path + +plan = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert plan["items"][0]["status"] == "invalid" +assert "symbolic links are not allowed" in plan["items"][0]["reason"] +PY + rm -f "$WORKSPACE/.qwen" +fi + +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$WORKSPACE" \ + --source cline/ide \ + --target firebase-studio/legacy-workspace \ + --objects instructions \ + --scope project \ + --json > "$TMP_ROOT/source-only.json" +python3 - "$TMP_ROOT/source-only.json" <<'PY' +import json +import sys +from pathlib import Path + +plan = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert plan["items"][0]["status"] == "invalid" +assert "source-only" in plan["items"][0]["reason"] +PY + +HOME="$(native_path "$TEST_HOME")" bash "$CLI" legacy --print-path cline mcp \ + | grep -F '~/.cline/data/settings/cline_mcp_settings.json' >/dev/null + +python3 - "$SCRIPT_DIR" "$SKILL_DIR/evals/files" <<'PY' +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from migration_core import emit_instruction, parse_instruction + +fixtures = Path(sys.argv[2]) +source = (fixtures / "instruction-ir-source.mdc").read_text(encoding="utf-8") +golden = (fixtures / "instruction-ir-cursor.golden.mdc").read_text(encoding="utf-8") +instruction = parse_instruction(source, "cursor-mdc") +rendered, loss = emit_instruction(instruction, "cursor-mdc") +assert rendered == golden +assert loss.lossy is False +assert instruction.activation == "glob" +assert instruction.globs == ["src/**/*.ts", "tests/**/*.ts"] +try: + emit_instruction(instruction, "agents-md") +except ValueError as error: + assert "cannot safely represent glob activation" in str(error) +else: + raise AssertionError("conditional Cursor rule was flattened into AGENTS.md") + +plain = parse_instruction("# Always\n", "agents-md") +rendered, loss = emit_instruction(plain, "agents-md") +assert rendered == "# Always\n" +assert not rendered.startswith("---") +assert loss.lossy is False +PY + +INSTRUCTION_SECRET_WORKSPACE="$TMP_ROOT/instruction-secret-workspace" +mkdir -p "$INSTRUCTION_SECRET_WORKSPACE/.cline/rules" +printf '%s%s\n' 'token=fixtureliteral' 'value12345' \ + > "$INSTRUCTION_SECRET_WORKSPACE/.cline/rules/leak.md" +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$INSTRUCTION_SECRET_WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects instructions \ + --scope project \ + --json > "$TMP_ROOT/instruction-secret-plan.json" +python3 - "$TMP_ROOT/instruction-secret-plan.json" <<'PY' +import json +import sys +from pathlib import Path + +plan = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert plan["items"][0]["status"] == "invalid" +assert "instruction credential preflight failed" in plan["items"][0]["reason"] +assert "fixtureliteralvalue12345" not in json.dumps(plan) +PY + +ALIAS_WORKSPACE="$TMP_ROOT/alias-workspace" +mkdir -p "$ALIAS_WORKSPACE/.cline/rules" +printf '%s\n' '# Canonical' > "$ALIAS_WORKSPACE/.cline/rules/rule.md" +printf '%s\n' '# Compatibility' > "$ALIAS_WORKSPACE/.clinerules" +HOME="$(native_path "$TEST_HOME")" bash "$CLI" inventory \ + --workspace "$ALIAS_WORKSPACE" \ + --product cline/ide \ + --json > "$TMP_ROOT/alias-inventory.json" +HOME="$(native_path "$TEST_HOME")" bash "$CLI" plan \ + --workspace "$ALIAS_WORKSPACE" \ + --source cline/ide \ + --target forge/cli \ + --objects instructions \ + --scope project \ + --json > "$TMP_ROOT/alias-plan.json" +python3 - "$TMP_ROOT/alias-inventory.json" "$TMP_ROOT/alias-plan.json" <<'PY' +import json +import sys +from pathlib import Path + +inventory = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +instruction_rows = [ + row + for row in inventory + if row.get("object_type") == "instructions" and row.get("scope") == "project" +] +assert sum(bool(row["exists"]) for row in instruction_rows) == 2 +assert all(row["alias_conflict"] for row in instruction_rows) +plan = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) +assert plan["items"][0]["status"] == "manual-rebuild" +assert "alias conflict" in plan["items"][0]["reason"] +PY + +TRANSACTION_WORKSPACE="$TMP_ROOT/transaction-workspace" +mkdir -p \ + "$TRANSACTION_WORKSPACE/.cline/skills/one" \ + "$TRANSACTION_WORKSPACE/.cline/skills/two" \ + "$TRANSACTION_WORKSPACE/.forge/skills/one" \ + "$TRANSACTION_WORKSPACE/.forge/skills/two" +for skill_name in one two; do + printf '%s\n' '---' "name: $skill_name" 'description: Transaction fixture.' '---' \ + > "$TRANSACTION_WORKSPACE/.cline/skills/$skill_name/SKILL.md" + printf '%s\n' "original-$skill_name" \ + > "$TRANSACTION_WORKSPACE/.forge/skills/$skill_name/sentinel.txt" +done +python3 - \ + "$SCRIPT_DIR" \ + "$SKILL_DIR/references/registry-v2.json" \ + "$TRANSACTION_WORKSPACE" \ + "$TEST_HOME" <<'PY' +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +import migration_core + +workspace = Path(sys.argv[3]).resolve() +registry = migration_core.Registry(Path(sys.argv[2]), workspace, Path(sys.argv[4])) +plan, _ = migration_core.build_plan( + registry, + "cline/ide", + "forge/cli", + ["skills"], + "project", +) +assert plan[0].status == "ready" +original_finish = migration_core.finish_change +calls = 0 + + +def fail_after_second_write(change, target): + global calls + calls += 1 + original_finish(change, target) + if calls == 2: + raise RuntimeError("injected second-write failure") + + +migration_core.finish_change = fail_after_second_write +try: + migration_core.apply_plan(plan, workspace) +except RuntimeError as error: + assert "injected second-write failure" in str(error) +else: + raise AssertionError("injected transaction failure was not raised") +for skill_name in ("one", "two"): + target = workspace / ".forge/skills" / skill_name + assert (target / "sentinel.txt").read_text(encoding="utf-8").strip() == ( + f"original-{skill_name}" + ) + assert not (target / "SKILL.md").exists() +manifest_dir = workspace / ".agent-context-migration/manifests" +assert not manifest_dir.exists() or not list(manifest_dir.glob("*.json")) +PY + +echo "Migration core test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-default-scope.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-default-scope.sh new file mode 100644 index 000000000..ebc82ba26 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-default-scope.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +TMP_ROOT="$(mktemp -d /tmp/agent-skills-default-scope.XXXXXX)" +TEST_HOME="$TMP_ROOT/home" +WORKSPACE="$TMP_ROOT/workspace" + +cleanup() { + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +mkdir -p "$TEST_HOME" "$WORKSPACE" + +DEFAULT_OUTPUT="$( + cd "$WORKSPACE" + HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source claude --target codex --dry-run 2>&1 +)" +if ! grep -Fq 'migration content: skills' <<<"$DEFAULT_OUTPUT"; then + echo "FAIL: a global migration without --objects must default to skills only" >&2 + exit 1 +fi +if grep -Fq 'migration content: skills,rules' <<<"$DEFAULT_OUTPUT"; then + echo "FAIL: a global migration unexpectedly included project objects" >&2 + exit 1 +fi + +if ( + cd "$WORKSPACE" + HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source claude --target codex --objects rules --dry-run +) >"$TMP_ROOT/implicit-workspace.log" 2>&1; then + echo "FAIL: project-backed objects must require an explicit --workspace" >&2 + exit 1 +fi +grep -Fq 'explicit --workspace' "$TMP_ROOT/implicit-workspace.log" || { + echo "FAIL: missing actionable error for an implicit project workspace" >&2 + exit 1 +} + +HOME="$(native_path "$TEST_HOME")" bash "$SCRIPT_DIR/smart-ide-migration.sh" legacy \ + --source claude --target codex --workspace "$WORKSPACE" \ + --objects rules --dry-run >/dev/null + +echo "Migration default scope test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-evidence.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-evidence.sh new file mode 100644 index 000000000..54efa8d60 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-evidence.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +MIGRATION_SCRIPT="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 +TMP_ROOT="$(mktemp -d /tmp/migration-evidence-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +WORKSPACE="$TMP_ROOT/workspace" +SOURCE_FILE="$TMP_ROOT/cursor-mcp.json" +DRY_REPORT="$TMP_ROOT/dry-run.json" +APPLY_REPORT="$TMP_ROOT/apply.json" +mkdir -p "$TEST_HOME" "$WORKSPACE" + +printf '%s\n' '{"mcpServers":{"fixture":{"command":"node","args":["server.js"]}}}' > "$SOURCE_FILE" + +# Explicit file/workspace arguments must cross into the engine natively; +# MSYS does not translate values after unknown options. +if command -v cygpath >/dev/null 2>&1; then + WS_ARG="$(cygpath -w "$WORKSPACE")"; SRC_ARG="$(cygpath -w "$SOURCE_FILE")" +else + WS_ARG="$WORKSPACE"; SRC_ARG="$SOURCE_FILE" +fi + +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target opencode --workspace "$WS_ARG" \ + --objects project-mcp --source-mcp-file "$SRC_ARG" \ + --strategy backup --dry-run --json > "$DRY_REPORT" 2>/dev/null + +python3 - "$DRY_REPORT" "$SOURCE_FILE" "$WORKSPACE/opencode.json" <<'PYEOF' +import json +import pathlib +import sys + +report = json.load(open(sys.argv[1])) +source = str(pathlib.Path(sys.argv[2]).resolve()) +target = str(pathlib.Path(sys.argv[3]).resolve()) +assert report["mode"] == "dry-run" +assert report["scope"] == "project" +assert report["objects"] == ["project-mcp"] +evidence = report["evidence"]["mcp"][0] +assert evidence["scope"] == "project" +if evidence["source_path"] != source: + raise SystemExit( + f"source_path mismatch:\n evidence={evidence['source_path']!r}\n" + f" expected={source!r}\n target={target!r} evidence_target={evidence.get('target_path')!r}" + ) +assert evidence["target_path"] == target +assert evidence["source_unchanged"] is True +assert evidence["target_exists"] is False +assert evidence["target_validation"] == "absent" +if len(evidence["source_sha256_before"]) != 64: + raise SystemExit(f"unexpected evidence record: {json.dumps(evidence)}") +assert len(evidence["source_sha256_before"]) == 64 +if evidence["source_sha256_before"] != evidence["source_sha256_after"]: + raise SystemExit( + f"source hash drifted: before={evidence['source_sha256_before']!r} " + f"after={evidence['source_sha256_after']!r} " + f"unchanged_flag={evidence.get('source_unchanged')!r}" + ) +PYEOF + +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target opencode --workspace "$WS_ARG" \ + --objects project-mcp --source-mcp-file "$SRC_ARG" \ + --strategy backup --yes --json > "$APPLY_REPORT" 2>/dev/null + +python3 - "$APPLY_REPORT" <<'PYEOF' +import json +import sys + +report = json.load(open(sys.argv[1])) +assert report["mode"] == "apply" +evidence = report["evidence"]["mcp"][0] +assert evidence["source_unchanged"] is True +assert evidence["target_exists"] is True +assert evidence["target_validation"] == "valid-json" +assert len(evidence["target_sha256"]) == 64 +PYEOF + +echo "PASS: JSON report contains deterministic MCP migration evidence" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-handoff-branch.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-handoff-branch.sh new file mode 100644 index 000000000..cb36ac83d --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration-handoff-branch.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_PATH="${SCRIPT_DIR}/../references/registry-v2.json" + +WS="$(mktemp -d /tmp/handoff-branch-ws.XXXXXX)" +trap 'rm -rf "$WS"' EXIT + +# Establish a git workspace on a real, named branch (not detached HEAD). +git -C "$WS" init -q +git -C "$WS" config user.email "test@example.com" +git -C "$WS" config user.name "ACB Test" +git -C "$WS" checkout -q -b "release/0.8.21" +printf 'seed\n' > "$WS/seed.txt" +git -C "$WS" add -A +git -C "$WS" commit -qm "seed" + +# Handoff source contains a private field that must be stripped on export. +mkdir -p "$WS/.agent" +printf '{"summary": "Reviewed handoff snapshot", "raw": "machine-specific-path-should-be-stripped"}\n' \ + > "$WS/.agent/handoff.json" + +cd "$SCRIPT_DIR" + +python3 - "$REGISTRY_PATH" "$WS" <<'PYEOF' +import json +import re +import sys +from pathlib import Path + +registry_path = Path(sys.argv[1]) +workspace = Path(sys.argv[2]) +sys.path.insert(0, str(registry_path.parent.parent / "scripts")) + +from migration_core import ( # noqa: E402 + ItemStatus, + PlanItem, + SurfacePath, + apply_plan, +) + + +def make_surface(resolved: Path, boundary: Path) -> SurfacePath: + return SurfacePath( + product="cline", + profile="ide", + object_type="handoff", + scope="user", + storage="home", + path=".agent/handoff.json", + resolved_path=resolved, + boundary=boundary, + source_format="json", + policy="manual-rebuild", + location_role="source", + canonical_path=".agent/handoff.json", + precedence=0, + ) + + +source = make_surface(workspace / ".agent" / "handoff.json", workspace) +dest = workspace / ".cursor" / "handoff" / "session.json" +item = PlanItem( + object_type="handoff", + status=ItemStatus.READY.value, + reason="handoff branch whitelist fixture", + source=source, + target=make_surface(dest, workspace), +) + +# Audit SDI-2: session transfer is opt-in; the default apply must refuse. +try: + apply_plan([item], workspace, workspace / "manifest-refused.json") +except ValueError as exc: + assert "--include-session" in str(exc), exc + print(f"OK default apply refuses handoff without opt-in: {exc}") +else: + raise AssertionError("apply_plan accepted a handoff item without opt-in") +assert not dest.exists() + +manifest, _ = apply_plan( + [item], + workspace, + workspace / "manifest.json", + allow_session_handoff=True, +) + +assert manifest["summary"].get("applied", 0) >= 1, manifest["summary"] +assert dest.is_file(), f"handoff not written to {dest}" + +rendered = json.loads(dest.read_text(encoding="utf-8")) +print("rendered handoff:", json.dumps(rendered)) + +# Audit #8: git_branch must be the human-readable branch name, never a SHA. +git_branch = rendered.get("git_branch") +assert git_branch is not None, "git_branch missing from portable handoff" +sha_pattern = re.compile(r"^[0-9a-f]{40}$") +assert not sha_pattern.fullmatch(git_branch), ( + f"git_branch leaked a commit SHA instead of a branch name: {git_branch!r}" +) +assert git_branch == "release/0.8.21", ( + f"git_branch should be the checked-out branch: {git_branch!r}" +) + +# Privacy: raw machine-specific content must not travel in the bundle. +assert "raw" not in rendered, "raw field leaked into portable handoff" + +print(f"OK handoff git_branch whitelists branch name: {git_branch}") +PYEOF + +echo "Handoff branch whitelist test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration.sh new file mode 100644 index 000000000..146c77d06 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-migration.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LEGACY_SCRIPT="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 + +TMP_ROOT="$(mktemp -d /tmp/agent-skills-migration-test.XXXXXX)" +export HOME="$TMP_ROOT/home" +mkdir -p "$HOME" + +OUT_FILE="$TMP_ROOT/last.out" + +safe_remove_fixture_path() { + local candidate="$1" + + case "$candidate" in + "$TMP_ROOT"/*) ;; + *) return 1 ;; + esac + [[ -e "$candidate" || -L "$candidate" ]] || return 0 + find "$candidate" -depth -delete +} + +cleanup() { + [[ -d "$TMP_ROOT" && "$TMP_ROOT" == /tmp/agent-skills-migration-test.* ]] || return 0 + find "$TMP_ROOT" -depth -delete +} +trap cleanup EXIT + +CHECKS=0 +FAIL=0 + +check_pass() { CHECKS=$((CHECKS + 1)); echo "PASS: $1"; } +check_fail() { CHECKS=$((CHECKS + 1)); FAIL=$((FAIL + 1)); echo "FAIL: $1" >&2; } + +assert_file() { + local p="$1" d="$2" + if [[ -e "$p" ]]; then check_pass "$d"; else check_fail "$d (missing: $p)"; fi +} +assert_dir() { + local p="$1" d="$2" + if [[ -d "$p" ]]; then check_pass "$d"; else check_fail "$d (missing dir: $p)"; fi +} +assert_not_exists() { + local p="$1" d="$2" + if [[ ! -e "$p" ]]; then check_pass "$d"; else check_fail "$d (unexpected path exists: $p)"; fi +} +assert_contains() { + local f="$1" pat="$2" d="$3" + if grep -Fq "$pat" "$f"; then check_pass "$d"; else check_fail "$d (no '$pat' in $f)"; fi +} +assert_eq() { + local a="$1" b="$2" d="$3" + if [[ "$a" == "$b" ]]; then check_pass "$d"; else check_fail "$d (got '$a', want '$b')"; fi +} +assert_not_contains() { + local f="$1" pat="$2" d="$3" + if grep -Fq "$pat" "$f"; then check_fail "$d (unexpected '$pat' in $f)"; else check_pass "$d"; fi +} + +run() { + "$@" > "$OUT_FILE" 2>&1 + LAST_RC=$? +} + + +SRC_SKILL="$HOME/.claude/skills/demo-skill" +mkdir -p "$SRC_SKILL/scripts" "$SRC_SKILL/references" +cat > "$SRC_SKILL/SKILL.md" <<'EOF' +--- +name: demo-skill +description: Fake skill used by migration tests. +--- +EOF +cat > "$SRC_SKILL/scripts/run.sh" <<'EOF' +#!/usr/bin/env bash +echo hi +EOF +cat > "$SRC_SKILL/references/notes.md" <<'EOF' +Reference content. +EOF + +WS="$TMP_ROOT/workspace" +mkdir -p "$WS" +cat > "$WS/CLAUDE.md" <<'EOF' +EOF +cat > "$HOME/.claude/settings.json" <<'EOF' +{ "foo": "bar" } +EOF +cat > "$HOME/.claude.json" <<'EOF' +{ + "mcpServers": { + "demo-server": { + "command": "echo", + "args": [], + "autoApprove": ["shell"], + "enabledTools": ["read"], + "disabledTools": ["write"] + } + } +} +EOF + + +echo "" +echo "== A. smart-ide-migration.sh ==" + +run bash "$LEGACY_SCRIPT" \ + --source claude --target kimiai \ + --workspace "$WS" \ + --objects skills,rules,mcp,config \ + --dry-run +assert_eq "$LAST_RC" "0" "A1: dry-run exits 0" + +assert_contains "$OUT_FILE" ".kimi-code/skills" "A1: dry-run target path is registry-correct (~/.kimi-code/skills)" + +assert_contains "$OUT_FILE" "skills" "A1: plan mentions skills" +assert_contains "$OUT_FILE" "rules" "A1: plan mentions rules" +assert_contains "$OUT_FILE" "mcp" "A1: plan mentions mcp" +assert_contains "$OUT_FILE" "config" "A1: plan mentions config" + +assert_contains "$OUT_FILE" "DRY-RUN: converting MCP config" "A1: mcp plan printed in dry-run" +assert_not_contains "$OUT_FILE" "MCP config converted" "A1: mcp NOT marked success in dry-run (C1)" +assert_contains "$OUT_FILE" "automatic whole-IDE config migration is unsupported" "A1: config manual boundary printed" +assert_not_contains "$OUT_FILE" "config file copied" "A1: config NOT marked success in dry-run (C2)" + +for target in kimiai copilot codex; do + run bash "$LEGACY_SCRIPT" \ + --source claude --target "$target" \ + --workspace "$WS" \ + --objects skills --yes + assert_eq "$LAST_RC" "0" "A2: real migration to $target exits 0" +done + +assert_file "$HOME/.kimi-code/skills/demo-skill/SKILL.md" "A2: kimiai -> ~/.kimi-code/skills/demo-skill/" +assert_file "$HOME/.copilot/skills/demo-skill/SKILL.md" "A2: copilot -> ~/.copilot/skills/demo-skill/" +assert_file "$HOME/.agents/skills/demo-skill/SKILL.md" "A2: codex -> ~/.agents/skills/demo-skill/" + +assert_dir "$HOME/.copilot/skills/demo-skill/scripts" "A3: copilot preserves scripts/ subdir (H4)" +assert_dir "$HOME/.copilot/skills/demo-skill/references" "A3: copilot preserves references/ subdir (H4)" + +run bash "$LEGACY_SCRIPT" \ + --source claude --target kimiai \ + --workspace "$WS" \ + --objects mcp --yes +assert_eq "$LAST_RC" "0" "A4: mcp migration exits 0" + +assert_file "$HOME/.kimi-code/mcp.json" "A4: mcp target file was written" +assert_contains "$HOME/.kimi-code/mcp.json" "demo-server" "A4: mcp server present in target file" +assert_not_contains "$OUT_FILE" "[✗] mcp" "A4: mcp not failed" +assert_contains "$OUT_FILE" "mcp" "A4: mcp reported in output" + +assert_not_contains "$HOME/.kimi-code/mcp.json" "autoApprove" "A4: MCP strips autoApprove grants" +assert_not_contains "$HOME/.kimi-code/mcp.json" "enabledTools" "A4: MCP strips enabledTools grants" +assert_not_contains "$HOME/.kimi-code/mcp.json" "disabledTools" "A4: MCP strips disabledTools grants" + +CONFIG_TGT="$HOME/.cursor/settings.json" +run bash "$LEGACY_SCRIPT" \ + --source claude --target cursor \ + --workspace "$WS" \ + --objects config --yes +assert_eq "$LAST_RC" "0" "A5: config boundary exits 0" +assert_not_exists "$CONFIG_TGT" "A5: generic config boundary creates no target config" +assert_contains "$OUT_FILE" "automatic whole-IDE config migration is unsupported" "A5: generic config boundary explains manual review" + + +echo "" +echo "== C. confirmation gate (--yes) ==" + +GATE_TGT="$HOME/.codeium/windsurf" +safe_remove_fixture_path "$GATE_TGT" +run bash "$LEGACY_SCRIPT" \ + --source claude --target windsurf \ + --workspace "$WS" \ + --objects skills "$SRC_PROJ/.env" +printf '{ "token": "example-token-value-1234567890", "name": "ok" }\n' > "$SRC_PROJ/svc.json" +printf 'name: demo\n' > "$SRC_PROJ/notes.yaml" + +D_TGT="$WS/.agents" +safe_remove_fixture_path "$D_TGT" +for backup_path in "$WS"/.agents.bak.*; do + [[ -e "$backup_path" || -L "$backup_path" ]] || continue + safe_remove_fixture_path "$backup_path" +done + +run bash "$LEGACY_SCRIPT" \ + --source claude --target codex \ + --workspace "$WS" \ + --objects project --dry-run +assert_eq "$LAST_RC" "0" "D1: project dry-run exits 0" +assert_not_exists "$D_TGT" "D1: project dry-run performs ZERO writes" +assert_contains "$OUT_FILE" "automatic whole-project configuration migration is unsupported" "D1: project dry-run explains manual boundary" + +run bash "$LEGACY_SCRIPT" \ + --source claude --target codex \ + --workspace "$WS" \ + --objects project --yes +assert_eq "$LAST_RC" "0" "D2: project migration exits 0" +assert_not_exists "$D_TGT" "D2: project boundary creates no target tree" +assert_contains "$OUT_FILE" "automatic whole-project configuration migration is unsupported" "D2: project boundary explains manual review" +assert_contains "$SRC_PROJ/.env" "EXAMPLE_SECRET_VALUE_1234567890" "D2: SOURCE secret untouched" +assert_contains "$SRC_PROJ/svc.json" "example-token-value-1234567890" "D2: SOURCE json secret untouched" + +if grep -Eq 'rm[[:space:]]+-r[f][[:space:]]+"\$D_TGT"[[:space:]]+"\$WS"/.agents.bak.\*' "$0"; then + check_fail "D3: project-fixture cleanup avoids recursive variable/glob deletion" +else + check_pass "D3: project-fixture cleanup avoids recursive variable/glob deletion" +fi + +if grep -Eq 'rm -rf "\$\{target_(global|path):\?\}/\$\{skill_name:\?\}"' "$SCRIPT_DIR/smart-ide-migration.sh"; then + check_fail "D6: redaction failure cleanup bypasses safe_remove_skill_dir" +else + check_pass "D6: redaction failure cleanup uses guarded deletion" +fi +if grep -Fq 'rm -rf "$target_path"' "$SCRIPT_DIR/smart-ide-migration.sh"; then + check_fail "D7: project overwrite or failure cleanup bypasses containment guard" +else + check_pass "D7: project tree deletion uses containment guard" +fi +if grep -Fq 'read -r _confirm_reply' "$SCRIPT_DIR/smart-ide-migration.sh"; then + check_fail "D8: agent entry point must not block on interactive confirmation" +else + check_pass "D8: agent entry point is non-interactive and uses --yes as the write gate" +fi + +echo "" +if [[ $FAIL -eq 0 ]]; then + echo "ALL CHECKS PASSED ($CHECKS checks)" + exit 0 +else + echo "$FAIL / $CHECKS checks FAILED" >&2 + exit 1 +fi diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-modern-ide-mappings.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-modern-ide-mappings.sh new file mode 100644 index 000000000..904aebca2 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-modern-ide-mappings.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +MIGRATION_SCRIPT="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 +TMP_ROOT="$(mktemp -d /tmp/modern-ide-mappings.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +WORKSPACE="$TMP_ROOT/workspace" +mkdir -p "$TEST_HOME" "$WORKSPACE" + +assert_path() { + local ide="$1" object="$2" expected="$3" actual + actual="$(HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" --print-path "$ide" "$object" 2>/dev/null || true)" + [[ "$actual" == "$expected" ]] || { + echo "FAIL: $ide/$object expected '$expected', got '$actual'" >&2 + exit 1 + } +} + +assert_path visual-studio global "~/.copilot/skills" +assert_path visual-studio project-skills ".github/skills" +assert_path visual-studio rules ".github/copilot-instructions.md" +assert_path visual-studio project-mcp ".mcp.json" +assert_path jetbrains-ai project-skills ".agents/skills" +assert_path android-studio global "~/.agents/skills" +assert_path android-studio project-skills ".agents/skills" +assert_path android-studio rules "AGENTS.md" +assert_path firebase-studio rules ".idx/airules.md" + +mkdir -p "$WORKSPACE/.cursor" +printf '%s\n' '{"mcpServers":{"fixture":{"command":"node","args":["server.js"]}}}' > "$WORKSPACE/.cursor/mcp.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target visual-studio --workspace "$WORKSPACE" \ + --objects project-mcp --dry-run >/dev/null 2>&1 +[[ ! -e "$WORKSPACE/.mcp.json" ]] || { + echo "FAIL: Visual Studio MCP dry-run wrote the target" >&2 + exit 1 +} +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target visual-studio --workspace "$WORKSPACE" \ + --objects project-mcp --yes --strategy overwrite >/dev/null 2>&1 +python3 - "$WORKSPACE/.mcp.json" <<'PY' +import json +import sys + +data = json.load(open(sys.argv[1], encoding="utf-8")) +assert data == {"servers": {"fixture": {"command": "node", "args": ["server.js"]}}} +PY + +MCP_FAIL_WORKSPACE="$TMP_ROOT/mcp-failure" +mkdir -p "$MCP_FAIL_WORKSPACE" +printf '%s\n' '{not-json' > "$MCP_FAIL_WORKSPACE/.mcp.json" +if HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target visual-studio --workspace "$MCP_FAIL_WORKSPACE" \ + --source-mcp-file "$WORKSPACE/.cursor/mcp.json" \ + --objects project-mcp --yes --strategy overwrite >/dev/null 2>&1; then + echo "FAIL: Visual Studio accepted an invalid existing MCP target" >&2 + exit 1 +fi +grep -Fxq '{not-json' "$MCP_FAIL_WORKSPACE/.mcp.json" || { + echo "FAIL: Visual Studio failure path modified the invalid target" >&2 + exit 1 +} + +mkdir -p "$WORKSPACE/.cursor/skills/demo" +printf '%s\n' '---' 'name: demo' 'description: fixture' '---' 'Use it.' > "$WORKSPACE/.cursor/skills/demo/SKILL.md" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target android-studio --workspace "$WORKSPACE" \ + --objects skills --scope project --dry-run >/dev/null 2>&1 +[[ ! -e "$WORKSPACE/.agents/skills/demo/SKILL.md" ]] || { + echo "FAIL: Android Studio skill dry-run wrote the target" >&2 + exit 1 +} +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target android-studio --workspace "$WORKSPACE" \ + --objects skills --scope project --yes --strategy overwrite >/dev/null 2>&1 +[[ -f "$WORKSPACE/.agents/skills/demo/SKILL.md" ]] || { + echo "FAIL: Android Studio project skill was not migrated" >&2 + exit 1 +} + +FIREBASE_WORKSPACE="$TMP_ROOT/firebase-workspace" +mkdir -p "$FIREBASE_WORKSPACE/.idx" +printf '%s\n' '# legacy Firebase fixture' > "$FIREBASE_WORKSPACE/.idx/airules.md" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source firebase-studio --target codex --workspace "$FIREBASE_WORKSPACE" \ + --objects rules --dry-run >/dev/null 2>&1 +[[ ! -e "$FIREBASE_WORKSPACE/AGENTS.md" ]] || { + echo "FAIL: Firebase Studio source dry-run wrote the target" >&2 + exit 1 +} +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source firebase-studio --target codex --workspace "$FIREBASE_WORKSPACE" \ + --objects rules --yes --strategy overwrite >/dev/null 2>&1 +grep -Fq 'legacy Firebase fixture' "$FIREBASE_WORKSPACE/AGENTS.md" + +FIREBASE_TARGET_WORKSPACE="$TMP_ROOT/firebase-target" +mkdir -p "$FIREBASE_TARGET_WORKSPACE" +printf '%s\n' '# maintained fixture' > "$FIREBASE_TARGET_WORKSPACE/AGENTS.md" +if HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source codex --target firebase-studio --workspace "$FIREBASE_TARGET_WORKSPACE" \ + --objects rules --yes --strategy overwrite >/dev/null 2>&1; then + echo "FAIL: Firebase Studio was accepted as a migration target" >&2 + exit 1 +fi +[[ ! -e "$FIREBASE_TARGET_WORKSPACE/.idx/airules.md" ]] || { + echo "FAIL: rejected Firebase Studio target was written" >&2 + exit 1 +} + +[[ -f "$SCRIPT_DIR/../references/ides/xcode.md" ]] || { + echo "FAIL: missing manual Xcode reference" >&2 + exit 1 +} + +echo "Modern IDE mapping tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-opencode-v2-mapping.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-opencode-v2-mapping.sh new file mode 100644 index 000000000..b6dea855c --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-opencode-v2-mapping.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +MIGRATION_SCRIPT="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 +TMP_ROOT="$(mktemp -d /tmp/opencode-v2-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +WORKSPACE="$TMP_ROOT/workspace" +SOURCE_FILE="$TMP_ROOT/cursor-mcp.json" +mkdir -p "$TEST_HOME" "$WORKSPACE" + +printf '%s\n' '{"mcpServers":{"local":{"command":"node","args":["server.js"],"env":{"LOG_LEVEL":"info"},"enabled":true,"timeout":30000},"remote":{"url":"https://example.invalid/mcp","oauth":{"clientId":"client-id","callbackPort":19876}}}}' > "$SOURCE_FILE" + +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target opencode --opencode-version v2 \ + --workspace "$WORKSPACE" --objects project-mcp \ + --source-mcp-file "$SOURCE_FILE" --strategy overwrite --yes >/dev/null + +python3 - "$WORKSPACE/opencode.json" <<'PYEOF' +import json +import sys + +data = json.load(open(sys.argv[1])) +assert set(data["mcp"]) == {"servers"} +servers = data["mcp"]["servers"] +assert servers["local"]["command"] == ["node", "server.js"] +assert servers["local"]["environment"] == {"LOG_LEVEL": "info"} +assert "enabled" not in servers["local"] +assert servers["local"]["disabled"] is False +assert servers["local"]["timeout"] == {"catalog": 30000, "execution": 30000} +assert servers["remote"]["oauth"]["client_id"] == "client-id" +assert servers["remote"]["oauth"]["callback_port"] == 19876 +assert "clientId" not in servers["remote"]["oauth"] +PYEOF + +MIGRATION_WORKSPACE="$TMP_ROOT/version-migration" +mkdir -p "$MIGRATION_WORKSPACE" +printf '%s\n' '{"theme":"dark","mcp":{"legacy":{"type":"local","command":["legacy-server"]}}}' \ + > "$MIGRATION_WORKSPACE/opencode.json" + +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target opencode --opencode-version v2 \ + --workspace "$MIGRATION_WORKSPACE" --objects project-mcp \ + --source-mcp-file "$SOURCE_FILE" --strategy backup --yes >/dev/null + +python3 - "$MIGRATION_WORKSPACE/opencode.json" <<'PYEOF' +import json +import sys + +data = json.load(open(sys.argv[1])) +assert data["theme"] == "dark" +assert set(data["mcp"]) == {"servers"}, "V1 and V2 MCP roots were mixed" +assert "local" in data["mcp"]["servers"] +PYEOF + +VERSION_BACKUP="$(find "$MIGRATION_WORKSPACE" -maxdepth 1 -name 'opencode.json.bak.*' -print -quit)" +python3 - "$VERSION_BACKUP" <<'PYEOF' +import json +import sys + +data = json.load(open(sys.argv[1])) +assert "legacy" in data["mcp"] +PYEOF + +if HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source cursor --target claude --opencode-version v2 \ + --objects mcp --dry-run >/dev/null 2>&1; then + echo "FAIL: --opencode-version was accepted for a non-OpenCode target" >&2 + exit 1 +fi + +echo "PASS: OpenCode V2 target emits the native mcp.servers schema" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-partial-safe-apply.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-partial-safe-apply.sh new file mode 100644 index 000000000..07daa3cf9 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-partial-safe-apply.sh @@ -0,0 +1,446 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_PATH="${SCRIPT_DIR}/../references/registry-v2.json" + +WS="$(mktemp -d /tmp/partial-safe-apply-ws.XXXXXX)" +HOME_DIR="$(mktemp -d /tmp/partial-safe-apply-home.XXXXXX)" +trap 'rm -rf "$WS" "$HOME_DIR"' EXIT + +cd "$SCRIPT_DIR" + +python3 - "$REGISTRY_PATH" "$WS" "$HOME_DIR" <<'PYEOF' +import json +import sys +from pathlib import Path + +registry_path = Path(sys.argv[1]) +workspace = Path(sys.argv[2]) +home = Path(sys.argv[3]) +sys.path.insert(0, str(registry_path.parent.parent / "scripts")) + +from migration_core import ( # noqa: E402 + ItemStatus, + PlanItem, + Registry, + SurfacePath, + apply_plan, + normalize_status, +) + +# Build a fixture plan covering every status state. +plan = [ + PlanItem( + object_type="skills", + status=ItemStatus.READY.value, + reason="ready fixture", + source=SurfacePath( + product="cline", + profile="ide", + object_type="skills", + scope="user", + storage="directory", + path="~/.cline/skills", + resolved_path=home / ".cline" / "skills", + boundary=home, + source_format="agent-skill", + policy="validate-then-atomic-copy", + location_role="canonical", + canonical_path="~/.cline/skills", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="skills", + scope="user", + storage="directory", + path="~/.cursor/skills", + resolved_path=home / ".cursor" / "skills", + boundary=home, + source_format="agent-skill", + policy="validate-then-atomic-copy", + location_role="canonical", + canonical_path="~/.cursor/skills", + precedence=0, + ), + ), + PlanItem( + object_type="hooks", + status=ItemStatus.DRAFT_DISABLED.value, + reason="hooks are draft-only", + source=SurfacePath( + product="cline", + profile="ide", + object_type="hooks", + scope="user", + storage="directory", + path="~/.cline/hooks", + resolved_path=home / ".cline" / "hooks", + boundary=home, + source_format="cline-hook", + policy="disabled-draft-only", + location_role="canonical", + canonical_path="~/.cline/hooks", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="hooks", + scope="user", + storage="directory", + path="~/.cursor/hooks", + resolved_path=home / ".cursor" / "hooks", + boundary=home, + source_format="cursor-hook", + policy="disabled-draft-only", + location_role="canonical", + canonical_path="~/.cursor/hooks", + precedence=0, + ), + ), + PlanItem( + object_type="mcp", + status=ItemStatus.MANUAL_REBUILD.value, + reason="OAuth MCP requires re-auth on target", + source=SurfacePath( + product="cline", + profile="ide", + object_type="mcp", + scope="user", + storage="file", + path="~/.cline/data/settings/cline_mcp_settings.json", + resolved_path=home / ".cline" / "mcp.json", + boundary=home, + source_format="json:mcpServers", + policy="profile-version-adapter", + location_role="canonical", + canonical_path="~/.cline/mcp.json", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="mcp", + scope="user", + storage="file", + path="~/.cursor/mcp.json", + resolved_path=home / ".cursor" / "mcp.json", + boundary=home, + source_format="json:mcpServers", + policy="profile-version-adapter", + location_role="canonical", + canonical_path="~/.cursor/mcp.json", + precedence=0, + ), + ), + PlanItem( + object_type="instructions", + status=ItemStatus.READY_LOSSY.value, + reason="instructions carry some loss", + source=SurfacePath( + product="cline", + profile="ide", + object_type="instructions", + scope="user", + storage="directory", + path="~/.cline/rules", + resolved_path=home / ".cline" / "rules", + boundary=home, + source_format="cline-rule", + policy="semantic-ir-with-loss-report", + location_role="canonical", + canonical_path="~/.cline/rules", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="instructions", + scope="user", + storage="directory", + path="~/.cursor/rules", + resolved_path=home / ".cursor" / "rules", + boundary=home, + source_format="cursor-mdc", + policy="semantic-ir-with-loss-report", + location_role="canonical", + canonical_path="~/.cursor/rules", + precedence=0, + ), + ), + PlanItem( + object_type="skills", + status=ItemStatus.CONFLICT.value, + reason="skill name collision", + source=SurfacePath( + product="cline", + profile="ide", + object_type="skills", + scope="user", + storage="directory", + path="~/.cline/skills", + resolved_path=home / ".cline" / "skills", + boundary=home, + source_format="agent-skill", + policy="validate-then-atomic-copy", + location_role="canonical", + canonical_path="~/.cline/skills", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="skills", + scope="user", + storage="directory", + path="~/.cursor/skills-conflict", + resolved_path=home / ".cursor" / "skills-conflict", + boundary=home, + source_format="agent-skill", + policy="validate-then-atomic-copy", + location_role="canonical", + canonical_path="~/.cursor/skills-conflict", + precedence=0, + ), + ), + PlanItem( + object_type="cloud-knowledge", + status=ItemStatus.FORBIDDEN.value, + reason="generated memory; never copied", + source=SurfacePath( + product="cline", + profile="ide", + object_type="cloud-knowledge", + scope="user", + storage="directory", + path="~/.cline/memory", + resolved_path=home / ".cline" / "memory", + boundary=home, + source_format="cline-memory", + policy="forbidden-regenerate", + location_role="canonical", + canonical_path="~/.cline/memory", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="cloud-knowledge", + scope="user", + storage="directory", + path="~/.cursor/memory", + resolved_path=home / ".cursor" / "memory", + boundary=home, + source_format="cursor-memory", + policy="forbidden-regenerate", + location_role="canonical", + canonical_path="~/.cursor/memory", + precedence=0, + ), + ), +] + + +# Sanity: status_enum property normalizes legacy strings. +legacy = PlanItem( + object_type="instructions", + status="manual", + reason="legacy string", +) +assert legacy.status_enum is ItemStatus.MANUAL_REBUILD +legacy2 = PlanItem( + object_type="instructions", + status="blocked", + reason="legacy string", +) +assert legacy2.status_enum is ItemStatus.INVALID +assert normalize_status("ready-lossy") is ItemStatus.READY_LOSSY +assert normalize_status("draft-disabled") is ItemStatus.DRAFT_DISABLED +print("OK normalize_status accepts legacy and modern strings") + +# Create a real on-disk Skill source so the ready item can land. +skill_src = home / ".cline" / "skills" / "fixture-skill" +skill_src.mkdir(parents=True, exist_ok=True) +(skill_src / "SKILL.md").write_text( + "---\nname: fixture-skill\ndescription: Test skill.\nmetadata:\n version: '1'\n---\n# fixture\n", + encoding="utf-8", +) + +# Create a lossy instruction source so include_lossy=True can write it. +rules_src = home / ".cline" / "rules" +rules_src.mkdir(parents=True, exist_ok=True) +(rules_src / "review.md").write_text( + "---\npaths:\n - 'src/**/*.ts'\ndescription: Review\n---\nReview.\n", + encoding="utf-8", +) + +# Default safe apply: ready skills + draft-disabled hooks eligible; lossy skipped. +manifest_path = workspace / "manifest-default.json" +manifest, mp = apply_plan(plan, workspace, manifest_path) + +assert manifest["apply_safe"] is True +assert manifest["include_lossy"] is False +assert manifest["strict"] is False +assert manifest["summary"].get("applied", 0) >= 1, manifest["summary"] +assert manifest["summary"].get("lossy-skipped", 0) >= 1 +assert manifest["summary"].get("manual-rebuild", 0) >= 1 +assert manifest["summary"].get("forbidden", 0) >= 1 +assert manifest["summary"].get("conflict", 0) >= 1 +# Hooks draft with unknown object_type stays as draft-only (no eligible hook +# adapter yet). Future PRs will add a hook staging path. +assert manifest["summary"].get("draft-only", 0) >= 1 +print(f"OK default safe apply summary: {manifest['summary']}") + +# Verify the skill landed. +skill_dst = home / ".cursor" / "skills" / "fixture-skill" +assert skill_dst.exists(), f"skill not written to {skill_dst}" +assert (skill_dst / "SKILL.md").exists() +print("OK ready skill landed on target tree") + +# Verify the lossy item was NOT written. +assert not (home / ".cursor" / "rules").exists() or not any( + (home / ".cursor" / "rules").iterdir() +), "lossy item should not have written files" +print("OK lossy instructions were skipped under --apply-safe") + +# Forbidden memory target must NOT exist. +assert not (home / ".cursor" / "memory").exists() +print("OK forbidden cloud-knowledge target untouched") + +# Now rerun with include_lossy=True and accept_loss_ids empty. +manifest_path2 = workspace / "manifest-lossy.json" +manifest2, _ = apply_plan( + plan, workspace, manifest_path2, + include_lossy=True, +) +assert manifest2["include_lossy"] is True +assert manifest2["summary"].get("applied-lossy", 0) >= 1 +assert manifest2["summary"].get("lossy-skipped", 0) == 0 +print(f"OK include_lossy=True summary: {manifest2['summary']}") +rules_dst = home / ".cursor" / "rules" +assert rules_dst.exists() +print("OK lossy instructions landed under include_lossy=True") + +# Strict mode rejects non-ready items. +try: + apply_plan( + plan, workspace, workspace / "manifest-strict.json", strict=True, + ) +except ValueError as exc: + assert "non-applicable" in str(exc), exc + print(f"OK strict mode rejects: {exc}") +else: + raise AssertionError("strict mode accepted a mixed-status plan") + +# Verify --strict false again does not require all-ready. +manifest3, _ = apply_plan( + plan, workspace, workspace / "manifest-relaxed.json", strict=False, +) +assert manifest3["summary"] +print("OK strict=False accepts mixed plan") + +# Audit SDI-4 regression: an executable surface (hooks) that somehow +# arrives eligible must fail closed — no write to the live product path +# and no "applied" manifest entry. +hook_src = home / ".cline" / "hooks" / "pre.json" +hook_src.parent.mkdir(parents=True, exist_ok=True) +hook_src.write_text('{"event": "PreToolUse", "command": "echo hi"}\n', encoding="utf-8") +hooks_ready = PlanItem( + object_type="hooks", + status=ItemStatus.READY.value, + reason="replayed-plan fixture for SDI-4", + source=SurfacePath( + product="cline", + profile="ide", + object_type="hooks", + scope="user", + storage="directory", + path="~/.cline/hooks/pre.json", + resolved_path=hook_src, + boundary=home, + source_format="cline-hook", + policy="validate-then-atomic-copy", + location_role="canonical", + canonical_path="~/.cline/hooks/pre.json", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="hooks", + scope="user", + storage="directory", + path="~/.cursor/hooks/pre.json", + resolved_path=home / ".cursor" / "hooks" / "pre.json", + boundary=home, + source_format="cursor-hook", + policy="disabled-draft-only", + location_role="canonical", + canonical_path="~/.cursor/hooks/pre.json", + precedence=0, + ), +) +try: + apply_plan([hooks_ready], workspace, workspace / "manifest-hooks.json") +except ValueError as exc: + assert "no automatic writer" in str(exc), exc + print(f"OK eligible hooks item fails closed: {exc}") +else: + raise AssertionError("apply_plan wrote an executable hook surface") +assert not (home / ".cursor" / "hooks" / "pre.json").exists(), ( + "hook file reached the live target path" +) + +# Same fail-closed contract for agents (never had a writer; previously a +# silent applied-with-no-writes gap). +(home / ".cline" / "agents").mkdir(parents=True, exist_ok=True) +agents_ready = PlanItem( + object_type="agents", + status=ItemStatus.READY.value, + reason="replayed-plan fixture for SDI-1", + source=SurfacePath( + product="cline", + profile="ide", + object_type="agents", + scope="user", + storage="directory", + path="~/.cline/agents", + resolved_path=home / ".cline" / "agents", + boundary=home, + source_format="cline-agent", + policy="manual-template", + location_role="canonical", + canonical_path="~/.cline/agents", + precedence=0, + ), + target=SurfacePath( + product="cursor", + profile="ide", + object_type="agents", + scope="user", + storage="directory", + path="~/.cursor/agents", + resolved_path=home / ".cursor" / "agents", + boundary=home, + source_format="cursor-agent", + policy="manual-template", + location_role="canonical", + canonical_path="~/.cursor/agents", + precedence=0, + ), +) +try: + apply_plan([agents_ready], workspace, workspace / "manifest-agents.json") +except ValueError as exc: + assert "no automatic writer" in str(exc), exc + print(f"OK eligible agents item fails closed: {exc}") +else: + raise AssertionError("apply_plan silently accepted an unwritable agent surface") + +print() +print("Partial safe apply tests passed") +PYEOF \ No newline at end of file diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-profile-contracts.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-profile-contracts.sh new file mode 100644 index 000000000..9e80d6151 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-profile-contracts.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +python3 - \ + "$SCRIPT_DIR" \ + "$SKILL_DIR/references/registry-v2.json" \ + "$SKILL_DIR/evals/profile-contracts.json" <<'PY' +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from migration_core import ( + AUTOMATIC_SURFACE_POLICIES, + FORMAT_FEATURES, + Registry, + emit_instruction, + emit_mcp_document, + mcp_adapter, + parse_instruction, + parse_mcp_document, + preflight_skill_source, +) + +registry = Registry(Path(sys.argv[2]), Path("/tmp/profile-workspace"), Path("/tmp/profile-home")) +contracts = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8")) +assert contracts["schema_version"] == 1 +assert set(contracts["fixture_classes"]) == { + "minimal-valid", + "complete-valid", + "legacy-version", + "invalid", + "literal-secret", + "alias-conflict", +} + +actual = {} +for product_id, product in registry.products.items(): + for profile_id in product.get("profiles", {}): + selector = f"{product_id}/{profile_id}" + _, _, profile = registry.profile(selector) + if profile.get("support_level") != "partial": + continue + surfaces = {} + for object_type, entries in profile.get("surfaces", {}).items(): + formats = [] + for entry in entries: + if entry["policy"] not in AUTOMATIC_SURFACE_POLICIES: + continue + source_format = entry.get("format", "unknown") + if object_type == "mcp" and not mcp_adapter(source_format)["automatic"]: + continue + formats.append(source_format) + if formats: + surfaces[object_type] = sorted(set(formats)) + actual[selector] = surfaces + +assert actual == contracts["profiles"], ( + "profile fixture contract drift:\n" + + json.dumps({"expected": contracts["profiles"], "actual": actual}, indent=2) +) + +instruction_fixtures = { + "agents-md": "# Review\n\nReview every external input.\n", + "amazon-q-rule": "# Review\n\nReview every external input.\n", + "plain-markdown": "# Review\n\nReview every external input.\n", + "augment-rule": ( + "---\ntype: agent_requested\ndescription: Review input boundaries\n---\nReview.\n" + ), + "cline-rule": "---\npaths:\n - 'src/**/*.ts'\n---\nReview.\n", + "claude-rule": "---\npaths: ['src/**/*.ts']\n---\nReview.\n", + "cursor-mdc": ( + "---\ndescription: Review TypeScript\nglobs: 'src/**/*.ts'\n" + "alwaysApply: false # native boolean\n---\nReview.\n" + ), + "continue-rule": ( + "---\nname: TypeScript review\ndescription: Review TypeScript\n" + "globs: ['src/**/*.ts']\nalwaysApply: false\n---\nReview.\n" + ), + "kiro-steering": ( + "---\ninclusion: fileMatch\nfileMatchPattern: 'src/**/*.ts'\n---\n" + "#[[file:SECURITY.md]]\nReview.\n" + ), + "copilot-instructions": ( + "---\napplyTo: 'src/**/*.ts,tests/**/*.ts'\n---\nReview.\n" + ), + "windsurf-rule": ( + "---\ntrigger: glob\nglobs: 'src/**/*.ts,tests/**/*.ts'\n---\nReview.\n" + ), + "trae-rule": "# Review\n\nReview every external input.\n", + "qoder-rule": "# Review\n\nReview every external input.\n", +} +assert set(instruction_fixtures) == set(FORMAT_FEATURES) + +native_markers = { + "augment-rule": "type:", + "cline-rule": "paths:", + "claude-rule": "paths:", + "cursor-mdc": "alwaysApply:", + "continue-rule": "alwaysApply:", + "kiro-steering": "inclusion:", + "copilot-instructions": "applyTo:", + "windsurf-rule": "trigger:", +} +for source_format, fixture in instruction_fixtures.items(): + instruction = parse_instruction(fixture, source_format) + rendered, report = emit_instruction(instruction, source_format) + reparsed = parse_instruction(rendered, source_format) + assert "Review" in rendered + assert reparsed.activation == instruction.activation + assert reparsed.globs == instruction.globs + assert reparsed.imports == instruction.imports + assert report.lossy is False + if source_format in {"agents-md", "amazon-q-rule", "plain-markdown", "trae-rule", "qoder-rule"}: + assert not rendered.startswith("---") + else: + assert native_markers[source_format] in rendered + +conditional = parse_instruction(instruction_fixtures["cline-rule"], "cline-rule") +try: + emit_instruction(conditional, "agents-md") +except ValueError as error: + assert "cannot safely represent glob activation" in str(error) +else: + raise AssertionError("conditional native rule was silently flattened") + +unknown = parse_instruction( + "---\ndescription: Review\nglobs: ''\nalwaysApply: false\nfutureField: value\n---\nReview.\n", + "cursor-mdc", +) +_, unknown_report = emit_instruction(unknown, "cursor-mdc") +assert {item.field for item in unknown_report.items} == {"futureField"} + +for profile in actual.values(): + for source_format in profile.get("instructions", []): + assert source_format in instruction_fixtures + for source_format in profile.get("mcp", []): + minimal = '{"mcpServers":{"demo":{"command":"demo"}}}' + if source_format == "json:mcp": + minimal = '{"mcp":{"demo":{"command":"demo"}}}' + servers = parse_mcp_document(minimal, source_format) + rendered, _ = emit_mcp_document(servers, source_format) + assert parse_mcp_document(rendered, source_format) + +complete = parse_mcp_document( + '{"mcpServers":{"local":{"command":"demo","args":["--token","literal-secret"],"env":{"API_TOKEN":"literal-secret","MODE":"safe"},"autoApprove":["unsafe-tool"]}}}', + "json:mcpServers", +) +rendered, report = emit_mcp_document(complete, "json:mcpServers") +assert report.lossy is True +assert "literal-secret" not in rendered +assert '"MODE": "safe"' in rendered +assert "autoApprove" not in rendered +assert any(item.field == "local.autoApprove" for item in report.items) + +remote = parse_mcp_document( + '{"mcpServers":{"remote":{"type":"sse","url":"https://example.test/mcp"}}}', + "json:mcpServers", +) +try: + emit_mcp_document(remote, "json:mcpServers") +except ValueError as error: + assert "target-profile transport adapter" in str(error) +else: + raise AssertionError("remote MCP used an unreviewed generic transport adapter") + +for invalid in ( + '{"mcpServers":{"bad":{"args":[1]}}}', + '{"mcpServers":{"bad":{}}}', + '{"mcpServers":{"bad":{"command":"demo","url":"https://example.test"}}}', + '{"mcpServers":{},"servers":{}}', + '{"unknown":{}}', +): + try: + parse_mcp_document(invalid, "json:mcpServers") + except ValueError: + pass + else: + raise AssertionError("invalid or alias-conflicting MCP fixture was accepted") + +try: + parse_mcp_document( + '{"mcpServers":{"demo":{"command":"demo"}}}', + "json:servers", + ) +except ValueError as error: + assert "requires root key servers" in str(error) +else: + raise AssertionError("profile-specific MCP root key was not enforced") + +with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + valid = root / "valid" + valid.mkdir() + (valid / "SKILL.md").write_text( + "---\nname: valid\ndescription: Valid fixture.\nmetadata:\n version: '1'\n---\n", + encoding="utf-8", + ) + preflight_skill_source(valid) + invalid = root / "invalid" + invalid.mkdir() + (invalid / "SKILL.md").write_text("# Missing metadata\n", encoding="utf-8") + try: + preflight_skill_source(invalid) + except ValueError as error: + assert "missing frontmatter" in str(error) + else: + raise AssertionError("invalid Skill fixture was accepted") + +assert not any( + profile.get("support_level") == "full" + for product in registry.products.values() + for profile in product.get("profiles", {}).values() +) +PY + +echo "Profile adapter contract tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-progressive-disclosure.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-progressive-disclosure.sh new file mode 100644 index 000000000..284d0a2ce --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-progressive-disclosure.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SKILL_FILE="$SKILL_ROOT/SKILL.md" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[[ $(wc -l < "$SKILL_FILE") -le 100 ]] || \ + fail "SKILL.md must keep its always-loaded workflow to 100 lines or fewer" +[[ $(wc -w < "$SKILL_FILE") -le 600 ]] || \ + fail "SKILL.md must keep its always-loaded workflow to 600 words or fewer" + +for reference in \ + references/migration-safety.md \ + references/mcp-migration.md \ + references/object-migration.md \ + references/verification.md; do + [[ -f "$SKILL_ROOT/$reference" ]] || \ + fail "missing progressively loaded reference: $reference" + grep -F "$reference" "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md does not state when to load $reference" +done + +grep -F 'references/ides/.md' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must route source reads to one IDE reference" +grep -F 'references/ides/.md' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must route target reads to one IDE reference" +grep -F 'Save the plan, review its diff/rebuild manifest' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must retain saved-plan review before apply" +grep -F 'legacy writes are disabled' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must retain the legacy-write boundary" +grep -F 'apply that exact file' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must require replay of the reviewed plan" +grep -F -- '--yes' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must retain the explicit approval gate" +grep -F 'generic migration request authorizes planning only' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must make generic migration requests plan-only" +grep -F 'separate explicit user approval' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must require separate user approval for writes" +grep -F 'explicit `legacy` subcommand' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must require explicit legacy routing" +grep -F 'network access is forbidden' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md must disclose its no-network capability boundary" + +echo "Progressive disclosure test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-reference-composition.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-reference-composition.sh new file mode 100644 index 000000000..e2e191b18 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-reference-composition.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +GENERATOR="$SCRIPT_DIR/sync-ide-reference-summaries.py" +TMP_ROOT="$(mktemp -d /tmp/reference-composition-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +mkdir -p "$TMP_ROOT/ides" +printf '%s\n' '{"demo":{"global_skills":"~/.demo"}}' > "$TMP_ROOT/paths.json" +printf '%s\n' '# demo' '- manual note' > "$TMP_ROOT/ides/demo.md" + +python3 "$GENERATOR" \ + --paths "$TMP_ROOT/paths.json" \ + --references "$TMP_ROOT/ides" >/dev/null + +python3 - "$TMP_ROOT/ides/demo.md" <<'PY' +from pathlib import Path +import sys + +text = Path(sys.argv[1]).read_text(encoding="utf-8") +assert "" in text +assert "| Global skills | `~/.demo` |" in text +assert "- manual note" in text +assert "## Generated path summary" not in text +assert "This table is generated from" not in text +PY + +mkdir -p "$TMP_ROOT/empty-ides" +printf '%s\n' '{"empty":{}}' > "$TMP_ROOT/empty-paths.json" +printf '%s\n' '# empty' '- manual note' > "$TMP_ROOT/empty-ides/empty.md" + +python3 "$GENERATOR" \ + --paths "$TMP_ROOT/empty-paths.json" \ + --references "$TMP_ROOT/empty-ides" >/dev/null + +python3 - "$TMP_ROOT/empty-ides/empty.md" <<'PY' +from pathlib import Path +import sys + +text = Path(sys.argv[1]).read_text(encoding="utf-8") +assert "All automatic paths are unsupported." in text +assert "| Object | Documented path |" not in text +assert "- manual note" in text +PY + +[[ -f "$SKILL_ROOT/references/mcp-transport.md" ]] || { + echo "FAIL: missing conditional MCP transport reference" >&2 + exit 1 +} +grep -F 'mcp-transport.md' "$SKILL_ROOT/references/mcp-migration.md" >/dev/null || { + echo "FAIL: MCP migration reference does not route transport-specific work" >&2 + exit 1 +} + +python3 - "$SKILL_ROOT" <<'PY' +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +registry = (root / "references/ide-registry.md").read_text(encoding="utf-8") +shared = root / "references/ides/ui-only-mcp.md" +assert shared.is_file() +assert "[`iflycode`](ides/ui-only-mcp.md)" in registry +assert "[`raccoon-ai`](ides/ui-only-mcp.md)" in registry +assert not (root / "references/ides/iflycode.md").exists() +assert not (root / "references/ides/raccoon-ai.md").exists() + +skill = (root / "SKILL.md").read_text(encoding="utf-8") +assert "ide-registry.md" in skill +assert "Resolve both product profiles" in skill +PY + +echo "Reference composition test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-reference-layout.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-reference-layout.sh new file mode 100644 index 000000000..2faf5c174 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-reference-layout.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SKILL_FILE="$SKILL_ROOT/SKILL.md" +IDE_PATHS="$SKILL_ROOT/references/ide-paths.json" +IDE_REFS="$SKILL_ROOT/references/ides" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[[ -d "$IDE_REFS" ]] || fail "missing per-IDE reference directory" +[[ -f "$SKILL_ROOT/scripts/README.md" ]] || \ + fail "missing script guide that distinguishes agent entry points from regressions" + +python3 - "$IDE_PATHS" "$IDE_REFS" <<'PY' +import json +from pathlib import Path +import sys + +paths = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +references = Path(sys.argv[2]) +missing = sorted(name for name in paths if not (references / f"{name}.md").is_file()) +if missing: + raise SystemExit("missing IDE references: " + ", ".join(missing)) +PY + +grep -F 'references/ides/.md' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md does not route source IDE reads to a single reference" +grep -F 'references/ides/.md' "$SKILL_FILE" >/dev/null || \ + fail "SKILL.md does not route target IDE reads to a single reference" +echo "Per-IDE reference layout test passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-registry-v2.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-registry-v2.sh new file mode 100644 index 000000000..acc847105 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-registry-v2.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REFERENCES_DIR="$SKILL_DIR/references" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +python3 "$SCRIPT_DIR/validate-registry-v2.py" \ + --registry "$REFERENCES_DIR/registry-v2.json" \ + --index "$REFERENCES_DIR/ide-registry.md" \ + --references "$REFERENCES_DIR/ides" \ + --today 2026-08-17 + +python3 - "$REFERENCES_DIR/registry-v2.json" <<'PY' +import json +import sys +from pathlib import Path + +registry = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +products = registry["products"] + +assert products["cline"]["profiles"]["ide"]["surfaces"]["mcp"][0]["path"] == "~/.cline/data/settings/cline_mcp_settings.json" +assert products["cline"]["profiles"]["ide"]["surfaces"]["mcp"][0]["override_relative_path"] == "settings/cline_mcp_settings.json" +cline_rules = products["cline"]["profiles"]["ide"]["surfaces"]["instructions"] +assert cline_rules[0]["compatibility_paths"] == ["~/Documents/Cline/Rules"] +assert cline_rules[1]["compatibility_paths"] == [".clinerules"] +amazon_q_mcp = products["amazon-q"]["profiles"]["ide"]["surfaces"]["mcp"][0] +assert "~/.aws/amazonq/agents/default.json" in amazon_q_mcp["compatibility_paths"] +augment_mcp = products["augment-code"]["profiles"]["cli-ide"]["surfaces"]["mcp"] +assert {(entry["scope"], entry["path"]) for entry in augment_mcp} >= { + ("project", ".augment/settings.json"), + ("local", ".augment/settings.local.json"), +} +codex_surfaces = products["codex"]["profiles"]["cli"]["surfaces"] +assert codex_surfaces["instructions"][1]["path"] == "AGENTS.md" +assert codex_surfaces["instructions"][1]["compatibility_behavior"] == "precedence" +assert {(entry["scope"], entry["path"]) for entry in codex_surfaces["mcp"]} == { + ("user", "~/.codex/config.toml"), + ("project", ".codex/config.toml"), +} +factory_surfaces = products["factory-droid"]["profiles"]["cli"]["surfaces"] +assert {(entry["scope"], entry["path"]) for entry in factory_surfaces["agents"]} == { + ("user", "~/.factory/droids"), + ("project", ".factory/droids"), +} +assert {entry["scope"] for entry in factory_surfaces["hooks"]} >= { + "user", "project" +} +assert {entry["scope"] for entry in products["qoder"]["profiles"]["cli"]["surfaces"]["hooks"]} == { + "user", "project", "local" +} +assert {entry["scope"] for entry in products["mistral-vibe"]["profiles"]["cli"]["surfaces"]["mcp"]} == { + "user", "project" +} +assert {entry["scope"] for entry in products["pi"]["profiles"]["cli"]["surfaces"]["config"]} == { + "user", "project" +} +assert {entry["scope"] for entry in products["crush"]["profiles"]["cli"]["surfaces"]["mcp"]} == { + "user", "project" +} +assert products["firebase-studio"]["profiles"]["legacy-workspace"]["migration_policy"] == "source-only" +assert products["forge"]["profiles"]["cli"]["surfaces"]["config"][0]["path"] == ".forge.toml" +assert products["qoder"]["default_profile"] == "cli" +assert registry["profile_templates"]["manual-reference"]["support_level"] == "unverified" +assert registry["support_contract"]["bidirectional-reviewed"]["support_level"] == "partial" +assert products["monkeycode"]["template"] == "cloud-ui" +assert ".windsurf/rules" in { + entry["path"] + for entry in products["windsurf"]["profiles"]["ide"]["surfaces"]["instructions"] +} +PY + +if python3 "$SCRIPT_DIR/validate-registry-v2.py" \ + --registry "$REFERENCES_DIR/registry-v2.json" \ + --index "$REFERENCES_DIR/ide-registry.md" \ + --references "$REFERENCES_DIR/ides" \ + --today 2027-08-13 >"$TMP_ROOT/stale.log" 2>&1; then + echo "FAIL: stale registry passed the freshness gate" >&2 + exit 1 +fi +grep -Fq 'registry is stale' "$TMP_ROOT/stale.log" || { + echo "FAIL: stale registry failure did not explain freshness" >&2 + exit 1 +} + +echo "Registry v2 test passed" + +# Negative gate: a stray top-level product duplicate (the #9 pollution class) +# must be rejected by the JSON Schema enforcement, not silently accepted. +POLLUTED="$TMP_ROOT/registry-polluted.json" +python3 - "$REFERENCES_DIR/registry-v2.json" "$POLLUTED" "$REFERENCES_DIR/registry-v2.schema.json" <<'PY' +import json +import shutil +import sys +from pathlib import Path + +registry = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +# Re-introduce the pollution the audit removed: a product object duplicated at +# the root level instead of living only under "products". +registry["letta"] = registry["products"]["letta"] +Path(sys.argv[2]).write_text(json.dumps(registry, indent=2), encoding="utf-8") +# Stage the schema next to the polluted copy so the relative $schema resolves. +shutil.copyfile(Path(sys.argv[3]), Path(sys.argv[2]).parent / "registry-v2.schema.json") +PY + +if python3 "$SCRIPT_DIR/validate-registry-v2.py" \ + --registry "$POLLUTED" \ + --index "$REFERENCES_DIR/ide-registry.md" \ + --references "$REFERENCES_DIR/ides" \ + --today 2026-08-17 >"$TMP_ROOT/polluted.log" 2>&1; then + echo "FAIL: polluted registry (stray top-level product) passed validation" >&2 + exit 1 +fi +grep -Fq "letta' was unexpected" "$TMP_ROOT/polluted.log" || { + echo "FAIL: schema gate did not flag the stray top-level product" >&2 + exit 1 +} + +echo "Registry pollution gate test passed" + diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-remaining-ide-mappings.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-remaining-ide-mappings.sh new file mode 100644 index 000000000..671ee42fb --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-remaining-ide-mappings.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Native Windows Python ignores MSYS-style env values; convert HOME +# fixtures so $HOME resolution sees a real directory on every platform. + +# Pin surface resolution to the POSIX layout the fixtures create; +# otherwise windows-latest would resolve $APPDATA-style overrides. +export AGENT_SKILLS_PLATFORM=linux + +native_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +MIGRATION_SCRIPT="$SCRIPT_DIR/legacy-smart-ide-migration.sh" +export AGENT_SKILLS_SETUP_INTERNAL_LEGACY=1 +TMP_ROOT="$(mktemp -d /tmp/agent-skills-remaining-ide-test.XXXXXX)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +TEST_HOME="$TMP_ROOT/home" +PROJECT="$TMP_ROOT/project" +OUTPUT="$TMP_ROOT/output.txt" +mkdir -p "$TEST_HOME" "$PROJECT" + +assert_path() { + local ide="$1" + local object="$2" + local expected="$3" + local actual + actual="$(HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" --print-path "$ide" "$object" 2>/dev/null || true)" + [[ "$actual" == "$expected" ]] || { + echo "FAIL: ${ide}/${object} expected '${expected}', got '${actual}'" >&2 + exit 1 + } +} + +assert_contains() { + local file="$1" + local text="$2" + grep -Fq "$text" "$file" || { + echo "FAIL: expected '$text' in $file" >&2 + exit 1 + } +} + +assert_not_contains() { + local file="$1" + local text="$2" + if grep -Fq "$text" "$file"; then + echo "FAIL: did not expect '$text' in $file" >&2 + exit 1 + fi +} + +assert_path opencode global "~/.config/opencode/skills" +assert_path opencode project-skills ".opencode/skills" +assert_path opencode rules "AGENTS.md" +assert_path opencode mcp "~/.config/opencode/opencode.json" +assert_path opencode project-mcp "opencode.json" +assert_path opencode project-config "opencode.json" +assert_path opencode config "~/.config/opencode/opencode.json" + +assert_path kilocode project ".kilo" +assert_path kilocode global "~/.kilo/skills" +assert_path kilocode project-skills ".kilo/skills" +assert_path kilocode rules "AGENTS.md" +assert_path kilocode mcp "~/.config/kilo/kilo.jsonc" +assert_path kilocode project-mcp ".kilo/kilo.jsonc" +assert_path kilocode project-config ".kilo/kilo.jsonc" +assert_path kilocode config "~/.config/kilo/kilo.jsonc" + +assert_path kimiai global "~/.kimi-code/skills" +assert_path kimiai project-skills ".kimi-code/skills" +assert_path kimiai rules "AGENTS.md" +assert_path kimiai mcp "~/.kimi-code/mcp.json" +assert_path kimiai project-mcp ".kimi-code/mcp.json" +assert_path kimiai config "~/.kimi-code/config.toml" + +assert_path jetbrains global "~/.junie/skills" +assert_path jetbrains project-skills ".junie/skills" +assert_path jetbrains rules ".junie/AGENTS.md" +assert_path jetbrains mcp "~/.junie/mcp/mcp.json" +assert_path jetbrains project-mcp ".junie/mcp/mcp.json" +assert_path jetbrains config "" + +assert_path workbuddy project ".workbuddy" +assert_path workbuddy global "" +assert_path workbuddy project-skills "" +assert_path workbuddy rules "" +assert_path workbuddy mcp "~/.workbuddy/mcp.json" +assert_path workbuddy project-mcp ".workbuddy/mcp.json" +assert_path workbuddy project-config "" +assert_path workbuddy config "" + +assert_path kiro global "~/.kiro/skills" +assert_path kiro project-skills ".kiro/skills" +assert_path kiro rules ".kiro/steering" +assert_path kiro mcp "~/.kiro/settings/mcp.json" +assert_path kiro project-mcp ".kiro/settings/mcp.json" +assert_path kiro config "" + +assert_path augment-code global "~/.augment/skills" +assert_path augment-code project-skills ".augment/skills" +assert_path augment-code rules ".augment/rules" +assert_path augment-code mcp "~/.augment/settings.json" +assert_path augment-code project-mcp ".augment/settings.json" +assert_path augment-code project-config ".augment/settings.json" +assert_path augment-code config "~/.augment/settings.json" + +assert_path void-editor global "" +assert_path void-editor project "" +assert_path void-editor project-skills "" +assert_path void-editor rules ".voidrules" +assert_path void-editor mcp "~/.void-editor/mcp.json" +assert_path void-editor project-mcp ".vscode/mcp.json" +assert_path void-editor config "" + +VOID_SKILLS_OUTPUT="$(HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source void-editor --target claude --workspace "$PROJECT" \ + --objects skills --dry-run 2>&1)" +grep -Fq 'Void: `.voidrules` is a rules file, not Agent Skills' <<< "$VOID_SKILLS_OUTPUT" + +assert_path baidu-comate global "~/.comate/skills" +assert_path baidu-comate project-skills ".comate/skills" +assert_path baidu-comate rules "" +assert_path baidu-comate mcp "~/.comate/mcp.json" +assert_path baidu-comate project-mcp ".comate/mcp.json" +assert_path baidu-comate config "" + +assert_path tencent-codebuddy global "~/.codebuddy/skills" +assert_path tencent-codebuddy project-skills ".codebuddy/skills" +assert_path tencent-codebuddy rules "CODEBUDDY.md" +assert_path tencent-codebuddy mcp "~/.codebuddy/.mcp.json" +assert_path tencent-codebuddy project-mcp ".mcp.json" +assert_path tencent-codebuddy project-config ".codebuddy/settings.json" +assert_path tencent-codebuddy config "~/.codebuddy/settings.json" + +assert_path zcode global "~/.zcode/skills" +assert_path zcode project-skills "" +assert_path zcode rules "AGENTS.md" +assert_path zcode mcp "~/.zcode/cli/config.json" +assert_path zcode project-mcp ".zcode/config.json" +assert_path zcode project-config ".zcode/config.json" +assert_path zcode config "~/.zcode/cli/config.json" +assert_path roo-code project-mcp ".roo/mcp.json" +assert_path roo-code mcp "" + +write_claude_fixture() { + printf '%s\n' '{"mcpServers":{"local":{"command":"node","args":["server.js","--token","live-token"],"env":{"API_KEY":"live-api-key"}},"remote":{"type":"sse","url":"https://example.invalid/mcp","headers":{"Authorization":"Bearer live-bearer"}}}}' > "$TEST_HOME/.claude.json" +} + +write_workbuddy_fixture() { + printf '%s\n' '{"mcpServers":{"local":{"command":"node","args":["server.js","--token","workbuddy-token"],"env":{"API_KEY":"workbuddy-api-key"}}}}' > "$TEST_HOME/.claude.json" +} + +mcp_target_path() { + case "$1" in + opencode) echo "$TEST_HOME/.config/opencode/opencode.json" ;; + kilocode) echo "$TEST_HOME/.config/kilo/kilo.jsonc" ;; + kimiai) echo "$TEST_HOME/.kimi-code/mcp.json" ;; + jetbrains) echo "$TEST_HOME/.junie/mcp/mcp.json" ;; + workbuddy) echo "$TEST_HOME/.workbuddy/mcp.json" ;; + void-editor) echo "$TEST_HOME/.void-editor/mcp.json" ;; + kiro) echo "$TEST_HOME/.kiro/settings/mcp.json" ;; + augment-code) echo "$TEST_HOME/.augment/settings.json" ;; + zcode) echo "$TEST_HOME/.zcode/cli/config.json" ;; + roo-code) echo "$PROJECT/.roo/mcp.json" ;; + baidu-comate) echo "$TEST_HOME/.comate/mcp.json" ;; + tencent-codebuddy) echo "$TEST_HOME/.codebuddy/.mcp.json" ;; + esac +} + +run_mcp() { + local target="$1" + if [[ "$target" == "workbuddy" || "$target" == "void-editor" || "$target" == "jetbrains" ]]; then + write_workbuddy_fixture + else + write_claude_fixture + fi + HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target "$target" --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 + assert_contains "$OUTPUT" "MCP config" +} + +for target in opencode kilocode kimiai jetbrains workbuddy void-editor kiro augment-code zcode tencent-codebuddy; do + run_mcp "$target" + target_file="$(mcp_target_path "$target")" + [[ -s "$target_file" ]] || { + echo "FAIL: ${target} MCP target was not written" >&2 + exit 1 + } + python3 -m json.tool "$target_file" >/dev/null + assert_not_contains "$target_file" "live-token" + assert_not_contains "$target_file" "live-api-key" + assert_not_contains "$target_file" "live-bearer" +done + +printf '%s\n' '{"mcpServers":{"roo-project":{"command":"node","args":["server.js"],"env":{"API_KEY":"__roo_project_inert_fixture__"}}}}' > "$PROJECT/.mcp.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target roo-code --workspace "$PROJECT" \ + --scope project --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 +assert_contains "$OUTPUT" "MCP config" +python3 -m json.tool "$PROJECT/.roo/mcp.json" >/dev/null +assert_contains "$PROJECT/.roo/mcp.json" '"roo-project"' +assert_not_contains "$PROJECT/.roo/mcp.json" "__roo_project_inert_fixture__" +ROO_GLOBAL_OUTPUT="$(HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target roo-code --objects mcp --dry-run 2>&1)" +grep -Fq 'Roo Code global MCP is extension-storage/UI managed' <<< "$ROO_GLOBAL_OUTPUT" + +assert_contains "$(mcp_target_path opencode)" '"mcp"' +assert_contains "$(mcp_target_path opencode)" '"type": "local"' +assert_contains "$(mcp_target_path opencode)" '"command": [' +assert_contains "$(mcp_target_path opencode)" '"environment"' +assert_contains "$(mcp_target_path kilocode)" '"mcp"' +assert_contains "$(mcp_target_path kilocode)" '"type": "remote"' +assert_contains "$(mcp_target_path kimiai)" '"mcpServers"' +assert_contains "$(mcp_target_path kimiai)" '"transport": "sse"' +assert_contains "$(mcp_target_path jetbrains)" '"mcpServers"' +assert_contains "$(mcp_target_path workbuddy)" '"mcpServers"' +assert_contains "$(mcp_target_path void-editor)" '"mcpServers"' +assert_contains "$(mcp_target_path kiro)" '"mcpServers"' +assert_contains "$(mcp_target_path augment-code)" '"mcpServers"' +assert_contains "$(mcp_target_path augment-code)" '"type": "sse"' +assert_contains "$(mcp_target_path zcode)" '"mcp"' +assert_contains "$(mcp_target_path zcode)" '"servers"' +assert_contains "$(mcp_target_path tencent-codebuddy)" '"mcpServers"' + +rm -f "$(mcp_target_path workbuddy)" +printf '%s\n' '{"mcpServers":{"remote":{"type":"sse","url":"https://example.invalid/mcp","headers":{"Authorization":"Bearer __workbuddy_inert_fixture__"}}}}' > "$TEST_HOME/.claude.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target workbuddy --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 +assert_contains "$OUTPUT" "WorkBuddy desktop MCP schema" +[[ ! -e "$(mcp_target_path workbuddy)" ]] || { + echo "FAIL: WorkBuddy wrote a target for undocumented remote MCP" >&2 + exit 1 +} + +rm -f "$(mcp_target_path void-editor)" +printf '%s\n' '{"mcpServers":{"remote":{"url":"https://example.invalid/mcp","headers":{"Authorization":"Bearer __void_inert_fixture__"}}}}' > "$TEST_HOME/.claude.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target void-editor --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 +assert_contains "$OUTPUT" "Void MCP schema" +[[ ! -e "$(mcp_target_path void-editor)" ]] || { + echo "FAIL: Void wrote a target for header-bearing remote MCP" >&2 + exit 1 +} + +printf '%s\n' '{"mcpServers":{"remote":{"url":"https://example.invalid/mcp"}}}' > "$TEST_HOME/.claude.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target void-editor --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 +python3 -m json.tool "$(mcp_target_path void-editor)" >/dev/null +assert_contains "$(mcp_target_path void-editor)" '"url": "https://example.invalid/mcp"' + +rm -f "$(mcp_target_path jetbrains)" +printf '%s\n' '{"mcpServers":{"remote":{"type":"sse","url":"https://example.invalid/mcp","headers":{"Authorization":"Bearer __junie_inert_fixture__"}}}}' > "$TEST_HOME/.claude.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target jetbrains --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 +assert_contains "$OUTPUT" "Junie MCP schema" +[[ ! -e "$(mcp_target_path jetbrains)" ]] || { + echo "FAIL: Junie wrote a target for undocumented remote MCP" >&2 + exit 1 +} + +printf '%s\n' '{"mcpServers":{"local":{"type":"stdio","command":"node","args":["server.js"],"env":{"API_KEY":"__comate_inert_fixture__"}},"remote":{"type":"sse","url":"https://example.invalid/mcp","headers":{"Authorization":"Bearer __comate_inert_fixture__"}}}}' > "$TEST_HOME/.claude.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target baidu-comate --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 +python3 -m json.tool "$TEST_HOME/.comate/mcp.json" >/dev/null +assert_contains "$TEST_HOME/.comate/mcp.json" '"type": "stdio"' +assert_contains "$TEST_HOME/.comate/mcp.json" '"type": "sse"' +assert_not_contains "$TEST_HOME/.comate/mcp.json" "__comate_inert_fixture__" + +mkdir -p "$TEST_HOME/.config/kilo" +printf '%s\n' '// Kilo JSONC fixture' '{' ' "mcp": {' ' "fixture": {' ' "type": "local",' ' "command": ["node", "server.js"],' ' "environment": {"API_KEY": ""},' ' },' ' },' '}' > "$TEST_HOME/.config/kilo/kilo.jsonc" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source kilocode --target opencode --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 +python3 -m json.tool "$TEST_HOME/.config/opencode/opencode.json" >/dev/null +assert_contains "$TEST_HOME/.config/opencode/opencode.json" '"fixture"' + +COMATE_BEFORE="$TMP_ROOT/comate-before.json" +cp "$TEST_HOME/.comate/mcp.json" "$COMATE_BEFORE" +printf '%s\n' '{"mcpServers":{"ambiguous":{"command":"node"}}}' > "$TEST_HOME/.claude.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target baidu-comate --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 || true +assert_contains "$OUTPUT" 'Comate MCP schema is invalid' +cmp -s "$COMATE_BEFORE" "$TEST_HOME/.comate/mcp.json" || { + echo "FAIL: invalid Comate MCP fixture mutated the existing target" >&2 + exit 1 +} + +ZCODE_BEFORE="$TMP_ROOT/zcode-before.json" +cp "$TEST_HOME/.zcode/cli/config.json" "$ZCODE_BEFORE" +printf '%s\n' '{"mcpServers":{"ambiguous":{"command":["node","server.js"],"args":"not-an-array"}}}' > "$TEST_HOME/.claude.json" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target zcode --workspace "$PROJECT" \ + --objects mcp --yes --strategy overwrite > "$OUTPUT" 2>&1 || true +assert_contains "$OUTPUT" 'MCP mcpServers/schema is invalid' +cmp -s "$ZCODE_BEFORE" "$TEST_HOME/.zcode/cli/config.json" || { + echo "FAIL: invalid args fixture mutated the existing ZCode target" >&2 + exit 1 +} + +mkdir -p "$TEST_HOME/.claude/skills/code-review/scripts" +printf '%s\n' '---' 'name: code-review' 'description: Review code' '---' 'Review the code.' > "$TEST_HOME/.claude/skills/code-review/SKILL.md" +printf '%s\n' '#!/usr/bin/env bash' 'echo review' > "$TEST_HOME/.claude/skills/code-review/scripts/review.sh" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target tencent-codebuddy --workspace "$PROJECT" \ + --objects skills --yes --strategy overwrite > "$OUTPUT" 2>&1 +[[ -f "$TEST_HOME/.codebuddy/skills/code-review/SKILL.md" ]] || { + echo "FAIL: CodeBuddy global Skill was not migrated" >&2 + exit 1 +} +[[ -f "$TEST_HOME/.codebuddy/skills/code-review/scripts/review.sh" ]] || { + echo "FAIL: CodeBuddy Skill resources were not migrated" >&2 + exit 1 +} + +printf '%s\n' 'Junie project instruction' > "$PROJECT/CLAUDE.md" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target jetbrains --workspace "$PROJECT" \ + --objects rules --yes --strategy overwrite > "$OUTPUT" 2>&1 +assert_contains "$PROJECT/.junie/AGENTS.md" "Junie project instruction" + +rm -rf "$TEST_HOME/.workbuddy/skills" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target workbuddy --workspace "$PROJECT" \ + --objects skills --yes --strategy overwrite > "$OUTPUT" 2>&1 +assert_contains "$OUTPUT" "WorkBuddy" +[[ ! -e "$TEST_HOME/.workbuddy/skills" ]] || { + echo "FAIL: WorkBuddy created an undocumented filesystem Skills target" >&2 + exit 1 +} + +printf '%s\n' 'Void workspace instruction' > "$PROJECT/CLAUDE.md" +HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source claude --target void-editor --workspace "$PROJECT" \ + --objects rules --yes --strategy overwrite > "$OUTPUT" 2>&1 +assert_contains "$PROJECT/.voidrules" "Void workspace instruction" + +for source in roo-code void-editor trae trae-cn jetbrains opencode kilocode kimiai workbuddy kiro augment-code baidu-comate tencent-codebuddy zcode; do + PROJECT_OUTPUT="$(HOME="$(native_path "$TEST_HOME")" bash "$MIGRATION_SCRIPT" \ + --source "$source" --target claude --workspace "$PROJECT" \ + --objects project --dry-run 2>&1)" + grep -Fq 'automatic whole-project configuration migration is unsupported' <<< "$PROJECT_OUTPUT" +done + +echo "Remaining IDE mapping tests passed" diff --git a/skills/agent-skills-setup/agent-skills-setup/scripts/test-root-mirror-links.sh b/skills/agent-skills-setup/agent-skills-setup/scripts/test-root-mirror-links.sh new file mode 100644 index 000000000..4f8b0fac0 --- /dev/null +++ b/skills/agent-skills-setup/agent-skills-setup/scripts/test-root-mirror-links.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +MIRROR="$REPO_ROOT/SKILL.md" +TEMP_MIRROR="$(mktemp)" +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$1" + fi +} +trap 'rm -f "$TEMP_MIRROR"' EXIT +MIRROR_HASH_BEFORE="$(sha256_of "$MIRROR")" + +bash "$REPO_ROOT/scripts/sync-root-mirror.sh" --output "$TEMP_MIRROR" + +[[ "$(head -n 1 "$TEMP_MIRROR")" == "