diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ae6c2bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + python: + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install --upgrade build + - run: PYTHONPATH=src python -m unittest discover -s tests -v + - run: PYTHONPATH=src python -m unittest discover -s tests/integration -v + - run: python -m build + - run: python -m pip install --force-reinstall dist/*.whl + - run: agent-switch --version + + macos-app: + runs-on: macos-14 + steps: + - uses: actions/checkout@v7 + - run: swift build --package-path macos-app/AgentSwitch + - run: >- + xcodebuild + -project macos-app/AgentSwitch/AgentSwitch.xcodeproj + -scheme AgentSwitch + -configuration Release + -derivedDataPath "$RUNNER_TEMP/AgentSwitchDerivedData" + CODE_SIGNING_ALLOWED=NO + build + + privacy: + runs-on: macos-14 + steps: + - uses: actions/checkout@v7 + - name: Reject common credential and private-path shapes + run: python3 scripts/privacy_scan.py diff --git a/.gitignore b/.gitignore index d280bcb..09b3491 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,11 @@ build/ .firecrawl/ *.egg-info/ .coverage + +# Local credentials and runtime state must never enter the product repository. +.env +.env.* +*.env +!*.env.example +secrets.env +backups/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6cd2765 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to Agent Switch are documented here. + +## 0.2.0 - Unreleased + +### Added + +- Neutral empty registry for new installations. +- Unified MCP `list`, `add`, `set`, `enable`, `disable`, `remove`, and native + `import`/`--adopt` commands. +- macOS MCP editor, target selection, import/adoption, enable/disable, and + removal actions. +- Native import preview showing MCP IDs and secret names before adoption. +- Credential-to-MCP consumer mapping in status and the Secret UI. +- Detected-agent-only reconciliation for clean first-run behavior. + +### Security + +- Generated wrappers no longer source or export the complete secret store. +- Each MCP receives only its declared credentials; inherited sensitive + variables and unrelated stored credentials are removed. +- Native-config adoption backs up files privately before removing inline MCPs. +- Backup names include both target-path and content digests to prevent collisions. +- Credential-shaped stdio arguments are migrated to declared secret placeholders. +- Positional secret values are rejected. +- Doctor warns about unpinned `npx` packages. + +### Changed + +- The macOS app prefers an installed Agent Switch CLI and no longer requires a + source checkout when that CLI is available. +- `doctor --strict` now fails for drift or missing required credentials as well + as blocked targets. + +## 0.1.3 - 2026-07-13 + +- Initial public repository preparation with CLI, native dashboard, secret + operations, MCP projections, Skill inventory, and CC Switch compatibility. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7d2e447 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +Agent Switch manages security-sensitive local configuration. Changes should be +small, testable, and preserve unrelated native configuration. + +## Development + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -e . +PYTHONPATH=src python -m unittest discover -s tests +PYTHONPATH=src python -m unittest discover -s tests/integration +swift build --package-path macos-app/AgentSwitch +python3 scripts/privacy_scan.py +``` + +## Required boundaries + +- Never commit or print real credential values. +- MCPs may receive only the credentials they explicitly declare. +- Manage only the `agent-*` namespace and marked instruction blocks. +- Import/adoption changes require preview, validation, atomic writes, and + private backups. +- A clean user home must remain neutral until the user explicitly acts. + +Open an issue before adding a new agent adapter or changing a native config +ownership boundary. diff --git a/README.md b/README.md index fdeb9c8..cf1a18b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Agent Switch -**One local control plane for AI coding agents, MCP servers, Skills, CLI tools, and secrets.** +**One local control plane for MCP servers and MCP credentials across your AI coding agents.** [](https://www.apple.com/macos/) [](https://www.python.org/) @@ -21,8 +21,8 @@ Requirements: 2. If the canonical checkout does not exist, clone the repository there. If it exists and is clean, update it with git pull --ff-only. Never overwrite uncommitted user work. 3. Install or refresh the Python CLI from that checkout with pipx, and make sure `agent-switch` is on PATH. 4. Run the complete Python test suite. Then build and install the native app by running `macos-app/AgentSwitch/install.sh` from the checkout. -5. Run `agent-switch write-default-config`, then `agent-switch doctor`. Explain the planned managed changes briefly and run `agent-switch reconcile` only if Doctor reports no blocked target. -6. Verify `agent-switch doctor --strict`, `agent-switch agents`, `agent-switch clis`, and `agent-switch skills` all run. Confirm that `~/Applications/Agent Switch.app` opens. +5. Run `agent-switch write-default-config`, then `agent-switch mcp import --dry-run --json` to inventory existing MCPs without changing native configs. Do not adopt existing MCPs until the planned MCP IDs, target apps, and secret NAMES have been shown to me. +6. Run `agent-switch doctor`. Explain the planned managed changes briefly and run `agent-switch reconcile` only if Doctor reports no blocked target. Verify `agent-switch doctor --strict`, `agent-switch agents`, `agent-switch mcp list`, and `agent-switch secret list` all run. Confirm that `~/Applications/Agent Switch.app` opens. 7. Never ask me to paste an API key or token into chat. Never pass a secret as a command argument, print it, or write it to a project .env file. If a secret is needed, tell me to enter it in the Agent Switch app, or pipe it to `agent-switch secret set --stdin NAME` locally. 8. Report exactly what was installed, which agents were enrolled, any missing secret NAMES only, and any action I still need to take. Do not report secret values. ``` @@ -46,6 +46,7 @@ PYTHONPATH=src python3 -m unittest discover -s tests/integration macos-app/AgentSwitch/install.sh agent-switch write-default-config +agent-switch mcp import --dry-run --json agent-switch doctor agent-switch reconcile agent-switch doctor --strict @@ -59,15 +60,17 @@ agent-switch doctor --strict AI tools usually keep separate copies of the same environment: one MCP list for Codex, another for Claude Code, another for Hermes, different instruction files, and secrets scattered across app configs. Those copies drift. -**Agent Switch gives them one managed local environment.** You define a tool once, store each secret once, and reconcile the safe projection into every supported agent. +**Agent Switch gives them one managed local environment.** You register an MCP once, store each credential once, choose its target agents, and reconcile a secret-free projection into every supported agent. + +New installations start with an empty registry. Agent Switch never assumes that every user wants the maintainer's preferred MCPs or credentials. ### Core features | Area | What Agent Switch manages | | --- | --- | | **Agents** | Detects and enrolls Codex, Claude Code, and Hermes; keeps one policy synchronized through their native instruction entry points. | -| **MCP servers** | Defines `agent-*` MCP tools centrally, generates secret-loading wrappers, repairs drift, and preserves every unrelated MCP entry. | -| **Secrets** | Stores tool credentials in one local mode-`0600` file; the macOS UI can add, update, reveal, hide, copy, and delete values. | +| **MCP servers** | Imports existing MCPs; adds, edits, enables, disables, removes, and targets them centrally; then repairs drift while preserving unrelated entries. | +| **Secrets** | Stores credentials once in a local mode-`0600` store and records which MCPs may consume each name. Every MCP receives only its declared credentials. | | **Skills** | Reads an optional Skill Hub warehouse and separates dormant, project-active, global, and missing Skills. Downloading or updating a Skill never activates it. | | **CLI tools** | Shows installed agent/tool CLIs, versions, package managers, executable paths, and Finder/file actions. | | **Health and sync** | `doctor` previews drift; `reconcile` applies atomic, backed-up changes; the app exposes the same check-and-sync workflow. | @@ -120,6 +123,21 @@ agent-switch doctor --json # Repair managed MCP entries, wrappers, and shared instructions agent-switch reconcile +# Inventory supported user-level stdio MCPs currently configured in supported agents +agent-switch mcp import --dry-run --json + +# Back up native configs, migrate inline credentials, and adopt existing MCPs +agent-switch mcp import --adopt + +# Manage the central registry +agent-switch mcp list +agent-switch mcp add filesystem --command npx \ + --arg=-y --arg=@modelcontextprotocol/server-filesystem@1.0.0 \ + --app codex --app claude +agent-switch mcp disable filesystem +agent-switch mcp enable filesystem +agent-switch mcp remove filesystem + # Inspect local integrations agent-switch agents agent-switch clis @@ -142,14 +160,22 @@ Never substitute a literal secret for `secret-producing-command` in a recorded c Agent Switch is local-first, but it is not a password vault: - secret values are stored in `~/.config/agent-switch/secrets.env`, not in this repository, Git, app preferences, or generated agent configs; +- generated wrappers parse the store as data rather than sourcing it as shell code; +- a wrapper removes inherited sensitive variables and injects only the secret names declared by that MCP; - the secrets file and instruction files use restrictive local permissions; - secret writes use stdin or inherited file descriptors instead of process arguments; - secret reads refuse stdout, stderr, terminal, and aliased descriptors; - the macOS app reveals a value only after an explicit eye-button click, with no Touch ID dialog; - diagnostics and audits report secret **names**, never secret values; - wrappers fail closed when a required secret name is missing. +- `doctor` warns when an `npx` MCP package is not pinned to a version. + +Read [MCP registry](docs/mcp-registry.md), [Secrets and wrappers](docs/secrets-and-wrappers.md), [Recovery](docs/recovery.md), and [Security Policy](SECURITY.md) before production use. -Read [Secrets and wrappers](docs/secrets-and-wrappers.md), [Recovery](docs/recovery.md), and [Security Policy](SECURITY.md) before production use. +Version 0.2 manages user-level, command/stdio MCP definitions. Native remote +HTTP/SSE and OAuth-session migration, project-scoped MCP discovery, additional +agent adapters, and signed distribution are tracked in the [roadmap](docs/roadmap.md) +rather than being presented as finished features. ## Skill Hub: warehouse first, activation second @@ -214,6 +240,11 @@ python -m unittest discover -s tests/integration The native application lives in [`macos-app/AgentSwitch`](macos-app/AgentSwitch) and targets macOS 14+. +The app uses the installed `agent-switch` CLI by default, exactly like an end +user installation. To run the app against a source checkout during development, +launch it with `AGENT_SWITCH_DEV_ROOT=/absolute/path/to/agent-switch`; source +code is never selected implicitly. + ## License [MIT](LICENSE) © 2026 JNHFlow21 diff --git a/SECURITY.md b/SECURITY.md index 703c7dd..7973e26 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,9 +7,15 @@ Please use [GitHub private vulnerability reporting](https://github.com/JNHFlow21 ## Secret handling contract - Never commit `~/.config/agent-switch/secrets.env` or copy it into a repository. +- The repository ignores `.env`, `*.env`, `secrets.env`, and `backups/`, but + ignore rules are only a last line of defense; credential values belong only + in the Agent Switch private store. - Never include secret values in issues, screenshots, logs, or diagnostic output. - Use the macOS app or `agent-switch secret set --stdin NAME` to write a value. - Use `agent-switch secret list` for audits; it returns names only. +- Declare credential names on the MCP and keep their values out of the MCP's + ordinary `env` mapping. Generated wrappers grant only declared names. +- Pin package versions for MCPs launched through package runners such as `npx`. - Rotate an affected provider credential immediately if exposure is suspected. ## Scope @@ -17,3 +23,5 @@ Please use [GitHub private vulnerability reporting](https://github.com/JNHFlow21 Security reports should cover Agent Switch itself: secret storage and transport, generated wrappers, MCP config projection, instruction management, the native macOS app, and CC Switch compatibility behavior. Third-party MCP servers, CLI tools, Skill sources, and providers keep their own security boundaries. +An MCP process can read the credentials explicitly granted to it; do not grant a +credential to code whose source or package version you do not trust. diff --git a/VERSION b/VERSION index b1e80bb..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.2.0 diff --git a/docs/mcp-registry.md b/docs/mcp-registry.md new file mode 100644 index 0000000..4e38a8b --- /dev/null +++ b/docs/mcp-registry.md @@ -0,0 +1,68 @@ +# Unified MCP Registry + +Agent Switch treats `~/.config/agent-switch/config.json` as the source of truth +for MCP definitions and `~/.config/agent-switch/secrets.env` as the source of +truth for credential values. + +## Neutral first run + +A new registry is empty. Agent Switch does not preinstall search, social, or +provider-specific MCPs and does not request unrelated credentials. + +```bash +agent-switch write-default-config +agent-switch mcp list +``` + +## Import and adoption + +Preview MCPs found in Claude Code, Claude Desktop, Codex, and Hermes: + +```bash +agent-switch mcp import --dry-run --json +``` + +Adoption is explicit: + +```bash +agent-switch mcp import --adopt +``` + +The adopt flow validates every source first, classifies credential-shaped +environment names, stores their values without printing them, backs up the +native files, removes the imported source entries, and reconciles wrapper-backed +`agent-*` projections. Non-secret environment values stay in central config. +The native app performs this same dry-run first and shows the MCP IDs and secret +names before the user can confirm adoption. + +Version 0.2 imports user-level command/stdio entries. It deliberately does not +rewrite native HTTP/SSE or OAuth definitions into an unverified transport +bridge. Those transports need a dedicated adapter that preserves each client's +authentication semantics; see the roadmap. Discovery reports those entries as +`skipped` by app and ID, and adoption leaves them untouched. + +## Registry lifecycle + +```bash +agent-switch mcp add NAME --command COMMAND [--arg ARG] [--secret NAME] [--app APP] +agent-switch mcp set NAME --command COMMAND [options] +agent-switch mcp enable NAME +agent-switch mcp disable NAME +agent-switch mcp remove NAME +agent-switch mcp list --json +``` + +Supported app IDs are `claude`, `claude_desktop`, `codex`, and `hermes`. +Disabling an MCP removes its managed projections and stale wrapper on the next +reconcile without deleting stored credential values. + +`--env NAME=VALUE` is only for non-secret settings. Names that look like keys, +tokens, passwords, credentials, or authentication values are rejected and must +be declared with `--secret NAME`; their values are written separately through +the Secret UI or `agent-switch secret set --stdin NAME`. + +## Ownership + +Agent Switch owns only `agent-*` entries and its marked instruction blocks. +Unrelated native configuration is preserved. Existing files are written +atomically and backups are kept in the private mode-`0700` backup directory. diff --git a/docs/recovery.md b/docs/recovery.md index 5590161..18e0b9c 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -1,6 +1,8 @@ # Recovery Agent Switch writes atomically and stores backups under the Agent Switch backup directory when an existing file changes. +The backup directory is mode `0700` and backup files are mode `0600` because an +adopted native MCP config may have contained an inline credential. ## Failed Native Config Write @@ -39,3 +41,17 @@ files are written atomically and backed up under `~/.config/agent-switch/backups If enrollment does not converge, run `agent-switch agents --json` followed by `agent-switch doctor --json` and restore only the affected target backup. + +## MCP Import Or Adoption + +Always preview first: + +```bash +agent-switch mcp import --dry-run --json +``` + +`mcp import --adopt` validates all detected source formats before removing any +source entry. It then backs up each changed native file, migrates credential +values to the private store without printing them, and reconciles managed +projections. If adoption is interrupted, run `agent-switch doctor --json` and +restore only the affected private backup before retrying. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..47e221d --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,30 @@ +# Roadmap + +Agent Switch's product goal is one control plane for MCP definitions and static +credential values across local AI agents. The project documents incomplete +boundaries explicitly instead of treating a successful demo as a finished +distribution. + +## 0.2 foundation + +- Neutral first run with no maintainer-specific MCPs or credentials. +- Central lifecycle for user-level command/stdio MCPs. +- Explicit import preview and backed-up adoption from supported agents. +- One private credential store with per-MCP least-privilege wrappers. +- Detected-target-only reconciliation, strict health checks, CI, and package + build validation. + +## Before stable 1.0 + +1. Normalize native Streamable HTTP and SSE MCP definitions without losing + client-specific OAuth behavior; migrate static header credentials without + printing or duplicating their values. +2. Discover project/local MCP scopes separately from user scope and require an + explicit ownership choice before adoption. +3. Add adapters through a documented contract and fixture suite rather than + guessing unknown agent configuration formats. +4. Ship a signed and notarized macOS release, a verified installer, published + Python packages, and a Homebrew path. +5. Add complete English and Chinese localization plus a first-run wizard that + previews targets, MCP IDs, and secret names before any write. +6. Add a first-class backup browser and guided restore flow. diff --git a/docs/secrets-and-wrappers.md b/docs/secrets-and-wrappers.md index e19166b..5445442 100644 --- a/docs/secrets-and-wrappers.md +++ b/docs/secrets-and-wrappers.md @@ -22,16 +22,11 @@ Default path: ~/.config/agent-switch/secrets.env ``` -Example shape: +Example shape (names vary with the MCP registry): ```bash -TAVILY_API_KEY=... -XCRAWL_API_KEY=... -BIRDREAD_API_KEY=... -X_API_KEY=... -X_API_SECRET=... -X_ACCESS_TOKEN=... -X_ACCESS_TOKEN_SECRET=... +FILESYSTEM_TOKEN=... +SEARCH_API_KEY=... ``` The status output reports missing secret names only. It never prints values. @@ -48,9 +43,8 @@ agent-switch secret delete FIRECRAWL_API_KEY `--stdin` and `--fd` keep the value out of the Agent Switch process arguments. Replace `secret-producing-command` with a program that emits the value; never -replace that placeholder with the value itself. The legacy -`secret set NAME VALUE` form is deprecated in 0.1.3 and will be removed after -this compatibility release. +replace that placeholder with the value itself. Positional secret values are +rejected so they cannot enter process arguments or shell history. Secret input must be non-empty, single-line UTF-8 no larger than 64 KiB. The CLI removes one final LF or CRLF from a producer. `--stdin` rejects interactive @@ -109,9 +103,19 @@ manage it safely. Each wrapper: -1. loads the secrets file if present; -2. validates required secret names; -3. prints missing names only; -4. executes the configured MCP command. +1. parses the secrets file as data; it never shell-sources the file; +2. removes inherited sensitive environment variables; +3. validates the secret names declared by that MCP; +4. injects only those declared values into the child environment; +5. expands declared `${SECRET_NAME}` placeholders in command arguments without + writing the value into the wrapper or native app config; +6. prints missing names only; +7. executes the configured MCP command. + +During native stdio import, credential-shaped environment values and values +following flags such as `--api-key` or `--token` are moved into the private +store. Their generated argument placeholder is kept in central config. Direct +configuration rejects recognized credential-shaped literals in commands, +arguments, or ordinary environment values. Wrappers are deterministic so `reconcile` can skip unchanged writes. diff --git a/llms.txt b/llms.txt index c472198..3870709 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # Agent Switch -> Agent Switch is a local control plane for Codex, Claude Code, Claude Desktop, Hermes, MCP servers, Skills, CLI tools, and API secrets on macOS. +> Agent Switch is a local control plane for registering supported MCPs once and storing their static credentials once across Codex, Claude Code, Claude Desktop, and Hermes on macOS. Canonical repository: https://github.com/JNHFlow21/agent-switch License: MIT @@ -15,6 +15,9 @@ Agent Switch prevents configuration drift across local AI coding agents. It cent - It manages only MCP IDs in the `agent-*` namespace. - It preserves unrelated MCP entries and provider settings. - Secret values remain in `~/.config/agent-switch/secrets.env` and are not embedded in generated app configuration. +- Each MCP wrapper receives only the credential names declared by that MCP. +- New installations start with an empty MCP registry; adoption of existing native MCP entries is explicit and backed up. +- Version 0.2 imports user-level command/stdio MCPs; remote HTTP/SSE/OAuth definitions are reported and preserved until a dedicated adapter is available. - Diagnostics list secret names only. - Downloaded Skills are dormant until explicitly activated for a project or the global profile. - Skill source updates never activate Skills. @@ -33,6 +36,12 @@ Agent Switch prevents configuration drift across local AI coding agents. It cent Give a local coding agent the installation prompt at the top of README.md, or follow the manual installation section. +## MCP management + +- `agent-switch mcp import --dry-run --json`: inventory native MCPs without mutation +- `agent-switch mcp import --adopt`: migrate and centrally manage existing MCPs +- `agent-switch mcp add|set|enable|disable|remove|list`: manage the registry lifecycle + ## Documentation - README: https://github.com/JNHFlow21/agent-switch#readme diff --git a/macos-app/AgentSwitch/AgentSwitch.xcodeproj/project.pbxproj b/macos-app/AgentSwitch/AgentSwitch.xcodeproj/project.pbxproj index 3acb245..50ac0a5 100644 --- a/macos-app/AgentSwitch/AgentSwitch.xcodeproj/project.pbxproj +++ b/macos-app/AgentSwitch/AgentSwitch.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.3; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.jnhflow.AgentSwitch; PRODUCT_NAME = "Agent Switch"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -383,7 +383,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.3; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.jnhflow.AgentSwitch; PRODUCT_NAME = "Agent Switch"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/macos-app/AgentSwitch/AgentSwitch/Features/Secrets/SecretsView.swift b/macos-app/AgentSwitch/AgentSwitch/Features/Secrets/SecretsView.swift index 4bd34e3..30ecfd2 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Features/Secrets/SecretsView.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Features/Secrets/SecretsView.swift @@ -162,6 +162,12 @@ struct SecretsView: View { .font(DSTypography.caption) .foregroundStyle(DSColor.textMuted) } + if let consumers = info.consumers?[name], !consumers.isEmpty { + Text("\(L10n.usedByMCP): \(consumers.joined(separator: ", "))") + .font(DSTypography.caption) + .foregroundStyle(DSColor.textMuted) + .lineLimit(2) + } } Spacer() diff --git a/macos-app/AgentSwitch/AgentSwitch/Features/Settings/SettingsView.swift b/macos-app/AgentSwitch/AgentSwitch/Features/Settings/SettingsView.swift index 304d15a..e0532c9 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Features/Settings/SettingsView.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Features/Settings/SettingsView.swift @@ -7,7 +7,6 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState - @AppStorage("autoRefreshEnabled") private var autoRefreshEnabled = true @AppStorage("includeCCSwitch") private var includeCCSwitch = false var body: some View { @@ -28,7 +27,7 @@ struct SettingsView: View { private var pageBadges: [DSPageBadge] { [ DSPageBadge(text: L10n.ready, tone: .good), - DSPageBadge(text: "v0.1.3", tone: .neutral), + DSPageBadge(text: "v\(AppVersion.current)", tone: .neutral), ] } @@ -51,7 +50,7 @@ struct SettingsView: View { ) DSMetricCard( label: L10n.version, - value: "0.1.3", + value: AppVersion.current, note: "github.com/JNHFlow21/agent-switch" ) } @@ -63,8 +62,6 @@ struct SettingsView: View { Text(L10n.general) .font(DSTypography.heading) - Toggle(L10n.autoRefreshOnLaunch, isOn: $autoRefreshEnabled) - .toggleStyle(.switch) Toggle(L10n.includeCCSwitch, isOn: $includeCCSwitch) .toggleStyle(.switch) } @@ -114,7 +111,7 @@ struct SettingsView: View { Text(L10n.about) .font(DSTypography.heading) - DSInfoRow(label: L10n.version, value: "0.1.3") + DSInfoRow(label: L10n.version, value: AppVersion.current) Divider() HStack(alignment: .firstTextBaseline, spacing: DSSpacing.lg) { Text(L10n.repository) diff --git a/macos-app/AgentSwitch/AgentSwitch/Features/Tools/ToolsView.swift b/macos-app/AgentSwitch/AgentSwitch/Features/Tools/ToolsView.swift index 09c6ab6..b5d3396 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Features/Tools/ToolsView.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Features/Tools/ToolsView.swift @@ -7,6 +7,10 @@ import SwiftUI struct ToolsView: View { @EnvironmentObject var appState: AppState + @State private var showingEditor = false + @State private var editingToolID: String? + @State private var pendingDelete: String? + @State private var showingImportConfirmation = false private var tools: [ToolDisplayItem] { guard let report = appState.report, let config = appState.config else { return [] } @@ -22,11 +26,16 @@ struct ToolsView: View { requiredSecrets: tool.requiredSecrets, missingSecrets: missingSecrets, secretsOk: hasAllSecrets, - hasDrift: report.changes.contains { $0.target == "wrappers" && $0.detail.contains(tool.id) } + hasDrift: report.changes.contains { $0.target == "wrappers" && $0.detail.contains(tool.id) }, + enabled: tool.enabled ) } } + private var editingTool: ToolInfo? { + appState.config?.tools.first { $0.id == editingToolID } + } + var body: some View { DSPage(title: L10n.managedMCPTools, subtitle: pageSubtitle, badges: pageBadges) { if tools.isEmpty && appState.isLoading { @@ -37,13 +46,99 @@ struct ToolsView: View { LazyVStack(spacing: DSSpacing.lg) { ForEach(tools) { tool in - ToolRow(tool: tool) + ToolRow( + tool: tool, + onEdit: { + editingToolID = tool.id + showingEditor = true + }, + onToggle: { + Task { _ = await appState.setMCPEnabled(id: tool.id, enabled: !tool.enabled) } + }, + onDelete: { pendingDelete = tool.id } + ) + } + } + } + } + .toolbar { + ToolbarItemGroup { + Button { + Task { + if await appState.previewMCPImport() { + showingImportConfirmation = true + } } + } label: { + Label(L10n.importMCPs, systemImage: "square.and.arrow.down") + } + .disabled(appState.isLoading) + + Button { + editingToolID = nil + showingEditor = true + } label: { + Label(L10n.addMCP, systemImage: "plus") + } + } + } + .sheet(isPresented: $showingEditor) { + MCPEditorSheet(existing: editingTool) { draft in + await appState.saveMCP( + id: draft.id, + name: draft.name, + command: draft.command, + args: draft.args, + secrets: draft.secrets, + env: draft.env, + apps: draft.apps, + description: draft.description, + enabled: draft.enabled + ) + } + } + .confirmationDialog( + L10n.removeMCP, + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + presenting: pendingDelete + ) { id in + Button(L10n.removeMCP, role: .destructive) { + Task { + _ = await appState.removeMCP(id: id) + pendingDelete = nil } } + Button(L10n.cancel, role: .cancel) { pendingDelete = nil } + } message: { id in + Text("\(L10n.removeMCPConfirmation) \(id)") + } + .confirmationDialog( + L10n.importMCPs, + isPresented: $showingImportConfirmation + ) { + Button(L10n.importAndAdoptMCPs) { + Task { _ = await appState.importMCPs() } + } + Button(L10n.cancel, role: .cancel) {} + } message: { + Text(importPreviewMessage) } } + private var importPreviewMessage: String { + guard let preview = appState.importPreview else { return L10n.importMCPConfirmation } + let ids = Array(Set(preview.imported + preview.merged)).sorted() + let idText = ids.isEmpty ? L10n.none : ids.joined(separator: ", ") + let secretText = preview.secretNames.isEmpty ? L10n.none : preview.secretNames.joined(separator: ", ") + let skippedText = preview.skipped.isEmpty + ? L10n.none + : preview.skipped.map { "\($0.app):\($0.id)" }.joined(separator: ", ") + return "\(L10n.importMCPConfirmation)\n\n\(L10n.discoveredMCPs): \(preview.discovered)\n\(L10n.supportedMCPs): \(preview.supported)\nMCP IDs: \(idText)\n\(L10n.secretNamesOnly): \(secretText)\n\(L10n.skippedMCPs): \(skippedText)" + } + private var pageSubtitle: String? { appState.lastRefresh.map { "\(L10n.lastRefreshed)\($0.formatted(date: .omitted, time: .shortened))" } } @@ -96,10 +191,14 @@ struct ToolDisplayItem: Identifiable { let missingSecrets: [String] let secretsOk: Bool let hasDrift: Bool + let enabled: Bool } struct ToolRow: View { let tool: ToolDisplayItem + let onEdit: () -> Void + let onToggle: () -> Void + let onDelete: () -> Void var body: some View { DSCard { @@ -112,6 +211,9 @@ struct ToolRow: View { if tool.hasDrift { DSBadge(text: L10n.driftState, tone: .warn) } + if !tool.enabled { + DSBadge(text: L10n.disabledMCP, tone: .neutral) + } } Text(tool.id) .font(DSTypography.mono) @@ -119,8 +221,8 @@ struct ToolRow: View { } Spacer() DSBadge( - text: tool.secretsOk ? L10n.readyState : L10n.missingSecrets, - tone: tool.secretsOk ? .good : .bad + text: !tool.enabled ? L10n.disabledMCP : tool.secretsOk ? L10n.readyState : L10n.missingSecrets, + tone: !tool.enabled ? .neutral : tool.secretsOk ? .good : .bad ) } @@ -174,9 +276,152 @@ struct ToolRow: View { .font(DSTypography.mono) .foregroundStyle(DSColor.textPrimary) .lineLimit(2) + Spacer() + Button(tool.enabled ? L10n.disableMCP : L10n.enableMCP, action: onToggle) + .buttonStyle(.bordered) + DSIconButton(systemName: "pencil", help: L10n.editMCP, action: onEdit) + DSIconButton(systemName: "trash", help: L10n.removeMCP, action: onDelete) } } .frame(maxWidth: .infinity, alignment: .leading) } } } + +private struct MCPDraft { + let id: String + let name: String + let command: String + let args: [String] + let secrets: [String] + let env: [String: String] + let apps: AppFlags + let description: String? + let enabled: Bool +} + +private struct MCPEditorSheet: View { + @Environment(\.dismiss) private var dismiss + let existing: ToolInfo? + let onSave: (MCPDraft) async -> Bool + + @State private var id: String + @State private var name: String + @State private var command: String + @State private var argsText: String + @State private var secretsText: String + @State private var envText: String + @State private var descriptionText: String + @State private var claude: Bool + @State private var claudeDesktop: Bool + @State private var codex: Bool + @State private var hermes: Bool + @State private var enabled: Bool + @State private var saving = false + + init(existing: ToolInfo?, onSave: @escaping (MCPDraft) async -> Bool) { + self.existing = existing + self.onSave = onSave + _id = State(initialValue: existing?.id ?? "") + _name = State(initialValue: existing?.name ?? "") + _command = State(initialValue: existing?.command ?? "") + _argsText = State(initialValue: existing?.args.joined(separator: "\n") ?? "") + _secretsText = State(initialValue: existing?.requiredSecrets.joined(separator: "\n") ?? "") + _envText = State(initialValue: existing?.env.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" }.joined(separator: "\n") ?? "") + _descriptionText = State(initialValue: existing?.description ?? "") + _claude = State(initialValue: existing?.apps.claude ?? true) + _claudeDesktop = State(initialValue: existing?.apps.claude_desktop ?? true) + _codex = State(initialValue: existing?.apps.codex ?? true) + _hermes = State(initialValue: existing?.apps.hermes ?? true) + _enabled = State(initialValue: existing?.enabled ?? true) + } + + private var normalizedID: String { + let value = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value.hasPrefix("agent-") ? value : "agent-\(value)" + } + + private var valid: Bool { + normalizedID.range(of: "^agent-[a-z0-9][a-z0-9-]*$", options: .regularExpression) != nil + && !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (claude || claudeDesktop || codex || hermes) + } + + var body: some View { + VStack(alignment: .leading, spacing: DSSpacing.xl) { + Text(existing == nil ? L10n.addMCP : L10n.editMCP) + .font(DSTypography.title) + Text(L10n.mcpEditorNote) + .font(DSTypography.caption) + .foregroundStyle(DSColor.textSecondary) + + Form { + TextField(L10n.mcpID, text: $id) + .disabled(existing != nil) + TextField(L10n.mcpName, text: $name) + TextField(L10n.mcpCommand, text: $command) + TextField(L10n.mcpDescription, text: $descriptionText) + VStack(alignment: .leading) { + Text(L10n.mcpArguments).font(DSTypography.caption) + TextEditor(text: $argsText).font(DSTypography.mono).frame(minHeight: 72) + } + VStack(alignment: .leading) { + Text(L10n.mcpRequiredSecrets).font(DSTypography.caption) + TextEditor(text: $secretsText).font(DSTypography.mono).frame(minHeight: 64) + } + VStack(alignment: .leading) { + Text(L10n.mcpEnvironment).font(DSTypography.caption) + TextEditor(text: $envText).font(DSTypography.mono).frame(minHeight: 64) + } + Section(L10n.targets) { + Toggle("Claude Code", isOn: $claude) + Toggle("Claude Desktop", isOn: $claudeDesktop) + Toggle("Codex", isOn: $codex) + Toggle("Hermes", isOn: $hermes) + } + Toggle(L10n.enableMCP, isOn: $enabled) + } + .formStyle(.grouped) + + HStack { + Button(L10n.cancel) { dismiss() } + .buttonStyle(.plain) + Spacer() + Button(existing == nil ? L10n.addMCP : L10n.saveMCP) { + saving = true + let args = argsText.split(whereSeparator: \.isNewline).map(String.init).filter { !$0.isEmpty } + let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",")) + let secrets = secretsText.components(separatedBy: separators).filter { !$0.isEmpty }.map { $0.uppercased() } + var environment: [String: String] = [:] + for line in envText.split(whereSeparator: \.isNewline) { + let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, !parts[0].isEmpty { + environment[String(parts[0])] = String(parts[1]) + } + } + let draft = MCPDraft( + id: normalizedID, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + command: command.trimmingCharacters(in: .whitespacesAndNewlines), + args: args, + secrets: Array(Set(secrets)).sorted(), + env: environment, + apps: AppFlags(claude: claude, claude_desktop: claudeDesktop, codex: codex, hermes: hermes), + description: descriptionText.trimmingCharacters(in: .whitespacesAndNewlines), + enabled: enabled + ) + Task { + if await onSave(draft) { dismiss() } + saving = false + } + } + .buttonStyle(.bordered) + .disabled(!valid || saving) + } + } + .padding(DSSpacing.xxl) + .frame(width: 640, height: 720) + .background(DSColor.background) + } +} diff --git a/macos-app/AgentSwitch/AgentSwitch/Models/AgentModels.swift b/macos-app/AgentSwitch/AgentSwitch/Models/AgentModels.swift index 7339109..2899655 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Models/AgentModels.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Models/AgentModels.swift @@ -37,6 +37,7 @@ struct SecretInfo: Codable { let missing: [String] let presentNames: [String] let storedNames: [String] + let consumers: [String: [String]]? } struct AgentReport: Codable { @@ -115,7 +116,9 @@ struct ToolInfo: Codable, Identifiable { let requiredSecrets: [String] let apps: AppFlags let envNames: [String] + let env: [String: String] let description: String? + let enabled: Bool enum CodingKeys: String, CodingKey { case id @@ -125,7 +128,9 @@ struct ToolInfo: Codable, Identifiable { case requiredSecrets case apps case envNames + case env case description + case enabled } init(from decoder: Decoder) throws { @@ -136,8 +141,10 @@ struct ToolInfo: Codable, Identifiable { args = try container.decodeIfPresent([String].self, forKey: .args) ?? [] requiredSecrets = try container.decodeIfPresent([String].self, forKey: .requiredSecrets) ?? [] apps = try container.decodeIfPresent(AppFlags.self, forKey: .apps) ?? AppFlags() - envNames = try container.decodeIfPresent([String].self, forKey: .envNames) ?? [] + env = try container.decodeIfPresent([String: String].self, forKey: .env) ?? [:] + envNames = try container.decodeIfPresent([String].self, forKey: .envNames) ?? env.keys.sorted() description = try container.decodeIfPresent(String.self, forKey: .description) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true } var displayCommand: String { @@ -200,3 +207,19 @@ struct ReconcileSummary: Codable { let unchanged: Int let blocked: Int } + +struct MCPImportPreview: Codable { + let dryRun: Bool + let discovered: Int + let supported: Int + let imported: [String] + let merged: [String] + let secretNames: [String] + let skipped: [SkippedMCPImport] +} + +struct SkippedMCPImport: Codable { + let app: String + let id: String + let reason: String +} diff --git a/macos-app/AgentSwitch/AgentSwitch/Navigation/SidebarView.swift b/macos-app/AgentSwitch/AgentSwitch/Navigation/SidebarView.swift index 4c972a2..1493f15 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Navigation/SidebarView.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Navigation/SidebarView.swift @@ -74,7 +74,7 @@ struct SidebarView: View { .font(DSTypography.caption) .foregroundStyle(DSColor.textSecondary) Spacer() - Text("v0.1.3") + Text("v\(AppVersion.current)") .font(DSTypography.caption) .foregroundStyle(DSColor.textMuted) } diff --git a/macos-app/AgentSwitch/AgentSwitch/Resources/L10n.swift b/macos-app/AgentSwitch/AgentSwitch/Resources/L10n.swift index 1fbd3cd..df18410 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Resources/L10n.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Resources/L10n.swift @@ -5,6 +5,10 @@ import Foundation +enum AppVersion { + static let current = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.2.0" +} + enum L10n { // MARK: - Sidebar static let appName = String(localized: "Agent Switch", comment: "App name") @@ -59,6 +63,30 @@ enum L10n { static let readyState = String(localized: "就绪", comment: "Tool badge") static let missingSecrets = String(localized: "缺少密钥", comment: "Tool badge") static let driftState = String(localized: "漂移", comment: "Tool badge: drift") + static let addMCP = String(localized: "添加 MCP", comment: "Add MCP") + static let editMCP = String(localized: "编辑 MCP", comment: "Edit MCP") + static let removeMCP = String(localized: "删除 MCP", comment: "Remove MCP") + static let removeMCPConfirmation = String(localized: "确认从统一注册表删除:", comment: "Remove MCP confirmation") + static let enableMCP = String(localized: "启用 MCP", comment: "Enable MCP") + static let disableMCP = String(localized: "停用 MCP", comment: "Disable MCP") + static let disabledMCP = String(localized: "已停用", comment: "Disabled MCP") + static let importMCPs = String(localized: "导入已有 MCP", comment: "Import MCPs") + static let importAndAdoptMCPs = String(localized: "备份并统一接管", comment: "Import and adopt MCPs") + static let importMCPConfirmation = String(localized: "Agent Switch 会扫描已安装 Agent 的 MCP,迁移内嵌密钥,备份原配置,再用统一注册表生成托管项。", comment: "Import MCP confirmation") + static let discoveredMCPs = String(localized: "发现 MCP", comment: "Import preview discovered count") + static let supportedMCPs = String(localized: "可接管 MCP", comment: "Import preview supported count") + static let skippedMCPs = String(localized: "保留未接管", comment: "Import preview skipped MCPs") + static let secretNamesOnly = String(localized: "密钥名称(不含值)", comment: "Import preview secret names") + static let none = String(localized: "无", comment: "Empty value") + static let saveMCP = String(localized: "保存 MCP", comment: "Save MCP") + static let mcpID = String(localized: "MCP ID,例如 filesystem", comment: "MCP id") + static let mcpName = String(localized: "显示名称", comment: "MCP name") + static let mcpCommand = String(localized: "启动命令", comment: "MCP command") + static let mcpDescription = String(localized: "说明(可选)", comment: "MCP description") + static let mcpArguments = String(localized: "参数(每行一个)", comment: "MCP arguments") + static let mcpRequiredSecrets = String(localized: "所需密钥名称(每行一个)", comment: "MCP secrets") + static let mcpEnvironment = String(localized: "普通环境变量(每行 NAME=VALUE;密钥不要填这里)", comment: "MCP environment") + static let mcpEditorNote = String(localized: "密钥值只在密钥页面保存;每个 MCP 运行时只能获得这里声明的密钥。", comment: "MCP editor note") // MARK: - Secrets static let secretsInventory = String(localized: "密钥清单", comment: "Page: Secrets") @@ -95,6 +123,7 @@ enum L10n { static let notConfigured = String(localized: "尚未配置", comment: "Secret missing") static let requiredBadge = String(localized: "必需", comment: "Required badge") static let secretEditorNote = String(localized: "密钥值通过安全管道写入,不会进入命令参数或日志。", comment: "Secret editor note") + static let usedByMCP = String(localized: "授权给", comment: "Secret consumers") static let secretName = String(localized: "密钥名称,例如 FIRECRAWL_API_KEY", comment: "Secret name field") static let secretNameLabel = String(localized: "密钥名称", comment: "Existing secret name label") static let secretValue = String(localized: "密钥值", comment: "Secret value field") diff --git a/macos-app/AgentSwitch/AgentSwitch/Services/AgentSwitchService.swift b/macos-app/AgentSwitch/AgentSwitch/Services/AgentSwitchService.swift index a837efb..11a9a0e 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Services/AgentSwitchService.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Services/AgentSwitchService.swift @@ -8,24 +8,35 @@ import Darwin actor AgentSwitchService { private let pythonPath: String - private let projectRoot: String + private let projectRoot: String? + private let cliPath: String? init() { self.projectRoot = Self.findProjectRoot() self.pythonPath = Self.findPythonPath() + self.cliPath = Self.findCLIPath() } - private static func findProjectRoot() -> String { - let candidates = [ - NSHomeDirectory() + "/Agent-Workspace/agent-switch", - "/usr/local/share/agent-switch", - ] - for path in candidates { - if FileManager.default.fileExists(atPath: path + "/src/agent_switch/cli.py") { - return path - } + private static func findProjectRoot() -> String? { + guard let path = ProcessInfo.processInfo.environment["AGENT_SWITCH_DEV_ROOT"], + !path.isEmpty, + FileManager.default.fileExists(atPath: path + "/src/agent_switch/cli.py") + else { + return nil } - return candidates[0] + return path + } + + private static func findCLIPath() -> String? { + let environment = ProcessInfo.processInfo.environment + let home = NSHomeDirectory() + let candidates = [ + environment["AGENT_SWITCH_CLI"], + home + "/.local/bin/agent-switch", + "/opt/homebrew/bin/agent-switch", + "/usr/local/bin/agent-switch", + ].compactMap { $0 } + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } } private static func findPythonPath() -> String { @@ -43,17 +54,24 @@ actor AgentSwitchService { return "/usr/bin/python3" } - func runDoctor() async throws -> DoctorReport { - let output = try await runCLI(["doctor", "--json", "--no-ccswitch"]) + func runDoctor(includeCCSwitch: Bool = false) async throws -> DoctorReport { + var arguments = ["doctor", "--json"] + if !includeCCSwitch { arguments.append("--no-ccswitch") } + let output = try await runCLI(arguments) return try JSONDecoder().decode(DoctorReport.self, from: Data(output.utf8)) } - func runReconcile() async throws -> String { - return try await runCLI(["reconcile", "--json", "--no-ccswitch"]) + func runReconcile(includeCCSwitch: Bool = false) async throws -> String { + var arguments = ["reconcile", "--json"] + if !includeCCSwitch { arguments.append("--no-ccswitch") } + return try await runCLI(arguments) } func getConfig() async throws -> ConfigInfo { let configPath = NSHomeDirectory() + "/.config/agent-switch/config.json" + if !FileManager.default.fileExists(atPath: configPath) { + _ = try await runCLI(["write-default-config"]) + } if let data = FileManager.default.contents(atPath: configPath) { return try JSONDecoder().decode(ConfigInfo.self, from: data) } @@ -80,6 +98,50 @@ actor AgentSwitchService { return try await getSkills() } + func saveMCP( + id: String, + name: String, + command: String, + args: [String], + secrets: [String], + env: [String: String], + apps: AppFlags, + description: String?, + enabled: Bool + ) async throws { + var arguments = ["mcp", "set", id, "--name", name, "--command", command] + arguments += args.map { "--arg=\($0)" } + arguments += secrets.map { "--secret=\($0)" } + arguments += env.sorted { $0.key < $1.key }.map { "--env=\($0.key)=\($0.value)" } + if apps.claude { arguments += ["--app", "claude"] } + if apps.claude_desktop { arguments += ["--app", "claude_desktop"] } + if apps.codex { arguments += ["--app", "codex"] } + if apps.hermes { arguments += ["--app", "hermes"] } + if let description, !description.isEmpty { arguments += ["--description", description] } + if !enabled { arguments.append("--disabled") } + arguments.append("--json") + _ = try await runCLI(arguments) + } + + func removeMCP(id: String) async throws { + _ = try await runCLI(["mcp", "remove", id, "--json"]) + } + + func setMCPEnabled(id: String, enabled: Bool) async throws { + _ = try await runCLI(["mcp", enabled ? "enable" : "disable", id, "--json"]) + } + + func importMCPs(includeCCSwitch: Bool = false) async throws { + var arguments = ["mcp", "import", "--adopt", "--json"] + if !includeCCSwitch { arguments.append("--no-ccswitch") } + _ = try await runCLI(arguments) + } + + func previewMCPImport() async throws -> MCPImportPreview { + let output = try await runCLI(["mcp", "import", "--dry-run", "--json"]) + return try JSONDecoder().decode(MCPImportPreview.self, from: Data(output.utf8)) + } + func setSecret(name: String, value: String) async throws { _ = try await runCLI(["secret", "set", "--stdin", name], stdin: Data(value.utf8)) } @@ -113,10 +175,20 @@ actor AgentSwitchService { return try handle.readToEnd() ?? Data() } do { - let command = "exec \"$1\" -m agent_switch secret get --fd 3 \"$2\" 3>\"$3\"" + let useInstalledCLI = projectRoot == nil && cliPath != nil + let executable = useInstalledCLI ? cliPath! : pythonPath + let command: String + let arguments: [String] + if useInstalledCLI { + command = "exec \"$1\" secret get --fd 3 \"$2\" 3>\"$3\"" + arguments = ["-c", command, "agent-switch-secret", executable, name, fifo.path] + } else { + command = "exec \"$1\" -m agent_switch secret get --fd 3 \"$2\" 3>\"$3\"" + arguments = ["-c", command, "agent-switch-secret", executable, name, fifo.path] + } _ = try await runProcess( executable: "/bin/zsh", - arguments: ["-c", command, "agent-switch-secret", pythonPath, name, fifo.path] + arguments: arguments ) let data = try await readTask.value guard let value = String(data: data, encoding: .utf8), !value.isEmpty else { @@ -130,11 +202,10 @@ actor AgentSwitchService { } private func runCLI(_ arguments: [String], stdin: Data? = nil) async throws -> String { - try await runProcess( - executable: pythonPath, - arguments: ["-m", "agent_switch"] + arguments, - stdin: stdin - ) + if projectRoot == nil, let cliPath { + return try await runProcess(executable: cliPath, arguments: arguments, stdin: stdin) + } + return try await runProcess(executable: pythonPath, arguments: ["-m", "agent_switch"] + arguments, stdin: stdin) } private func runProcess(executable: String, arguments: [String], stdin: Data? = nil) async throws -> String { @@ -145,10 +216,12 @@ actor AgentSwitchService { process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments - process.currentDirectoryURL = URL(fileURLWithPath: projectRoot) + process.currentDirectoryURL = URL(fileURLWithPath: projectRoot ?? NSHomeDirectory()) var environment = ProcessInfo.processInfo.environment - environment["PYTHONPATH"] = projectRoot + "/src" + if let projectRoot { + environment["PYTHONPATH"] = projectRoot + "/src" + } let home = NSHomeDirectory() environment["PATH"] = [ home + "/.local/bin", diff --git a/macos-app/AgentSwitch/AgentSwitch/Services/AppState.swift b/macos-app/AgentSwitch/AgentSwitch/Services/AppState.swift index 7a3c4b2..9e1a01e 100644 --- a/macos-app/AgentSwitch/AgentSwitch/Services/AppState.swift +++ b/macos-app/AgentSwitch/AgentSwitch/Services/AppState.swift @@ -12,12 +12,17 @@ class AppState: ObservableObject { @Published var agents: [AgentInfo] = [] @Published var clis: [CLIInfo] = [] @Published var skillReport: SkillReport? + @Published var importPreview: MCPImportPreview? @Published var isLoading = false @Published var lastError: String? @Published var lastRefresh: Date? private let service = AgentSwitchService() + private var includeCCSwitch: Bool { + UserDefaults.standard.bool(forKey: "includeCCSwitch") + } + var isHealthy: Bool { if lastError != nil { return false @@ -42,7 +47,7 @@ class AppState: ObservableObject { lastError = nil do { config = try await service.getConfig() - report = try await service.runDoctor() + report = try await service.runDoctor(includeCCSwitch: includeCCSwitch) agents = try await service.getAgents() clis = try await service.getCLIs() skillReport = try await service.getSkills() @@ -61,9 +66,9 @@ class AppState: ObservableObject { isLoading = true lastError = nil do { - _ = try await service.runReconcile() + _ = try await service.runReconcile(includeCCSwitch: includeCCSwitch) config = try await service.getConfig() - report = try await service.runDoctor() + report = try await service.runDoctor(includeCCSwitch: includeCCSwitch) agents = try await service.getAgents() clis = try await service.getCLIs() skillReport = try await service.getSkills() @@ -79,7 +84,7 @@ class AppState: ObservableObject { lastError = nil do { try await service.setSecret(name: name, value: value) - report = try await service.runDoctor() + report = try await service.runDoctor(includeCCSwitch: includeCCSwitch) lastRefresh = Date() isLoading = false return true @@ -105,7 +110,7 @@ class AppState: ObservableObject { lastError = nil do { try await service.deleteSecret(name: name) - report = try await service.runDoctor() + report = try await service.runDoctor(includeCCSwitch: includeCCSwitch) lastRefresh = Date() isLoading = false return true @@ -130,4 +135,99 @@ class AppState: ObservableObject { return false } } + + func saveMCP( + id: String, + name: String, + command: String, + args: [String], + secrets: [String], + env: [String: String], + apps: AppFlags, + description: String?, + enabled: Bool + ) async -> Bool { + isLoading = true + lastError = nil + do { + try await service.saveMCP( + id: id, + name: name, + command: command, + args: args, + secrets: secrets, + env: env, + apps: apps, + description: description, + enabled: enabled + ) + isLoading = false + await refresh() + return true + } catch { + lastError = error.localizedDescription + isLoading = false + return false + } + } + + func removeMCP(id: String) async -> Bool { + isLoading = true + lastError = nil + do { + try await service.removeMCP(id: id) + isLoading = false + await refresh() + return true + } catch { + lastError = error.localizedDescription + isLoading = false + return false + } + } + + func setMCPEnabled(id: String, enabled: Bool) async -> Bool { + isLoading = true + lastError = nil + do { + try await service.setMCPEnabled(id: id, enabled: enabled) + isLoading = false + await refresh() + return true + } catch { + lastError = error.localizedDescription + isLoading = false + return false + } + } + + func importMCPs() async -> Bool { + isLoading = true + lastError = nil + do { + try await service.importMCPs(includeCCSwitch: includeCCSwitch) + isLoading = false + await refresh() + return true + } catch { + lastError = error.localizedDescription + isLoading = false + return false + } + } + + func previewMCPImport() async -> Bool { + isLoading = true + lastError = nil + do { + importPreview = try await service.previewMCPImport() + isLoading = false + return true + } catch { + importPreview = nil + lastError = error.localizedDescription + isLoading = false + return false + } + } } diff --git a/pyproject.toml b/pyproject.toml index 4ebe50f..4f86e78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-switch" -version = "0.1.3" +version = "0.2.0" description = "Local control plane for AI agents, MCP servers, Skills, CLI tools, and secrets." readme = "README.md" license = {file = "LICENSE"} diff --git a/scripts/privacy_scan.py b/scripts/privacy_scan.py new file mode 100644 index 0000000..9468371 --- /dev/null +++ b/scripts/privacy_scan.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Fail closed on common private-path and credential shapes without echoing values.""" + +from __future__ import annotations + +from pathlib import Path +import re +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[1] +PATTERNS = ( + re.compile("/" + r"Users/[^/\s]+"), + re.compile("AK" + r"IA[0-9A-Z]{16}"), + re.compile("gh" + r"[pousr]_[A-Za-z0-9]{20,}"), + re.compile("sk" + r"-[A-Za-z0-9_-]{12,}"), + re.compile("-----BEGIN " + r"(?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), +) + + +def candidate_paths() -> tuple[Path, ...]: + result = subprocess.run( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + cwd=ROOT, + check=True, + capture_output=True, + ) + paths = [] + for raw in result.stdout.split(b"\0"): + if not raw: + continue + relative = Path(raw.decode("utf-8", errors="strict")) + if relative.parts and relative.parts[0] == "tests": + continue + paths.append(relative) + return tuple(paths) + + +def main() -> int: + findings: list[tuple[Path, int]] = [] + for relative in candidate_paths(): + path = ROOT / relative + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + for line_number, line in enumerate(text.splitlines(), start=1): + if any(pattern.search(line) for pattern in PATTERNS): + findings.append((relative, line_number)) + if findings: + for path, line_number in findings: + # Report location only. Never repeat the matched value into CI logs. + print(f"potential private data at {path}:{line_number}", file=sys.stderr) + return 1 + print("privacy scan passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agent_switch/__init__.py b/src/agent_switch/__init__.py index fdb4cf7..326f284 100644 --- a/src/agent_switch/__init__.py +++ b/src/agent_switch/__init__.py @@ -1,3 +1,3 @@ """Agent Switch runtime package.""" -__version__ = "0.1.3" +__version__ = "0.2.0" diff --git a/src/agent_switch/agents.py b/src/agent_switch/agents.py index 42c80aa..2485d0a 100644 --- a/src/agent_switch/agents.py +++ b/src/agent_switch/agents.py @@ -9,6 +9,12 @@ from agent_switch.instructions import MANAGED_END, MANAGED_START from agent_switch.paths import AgentPaths from agent_switch.reconcile.doctor import run_doctor +from agent_switch.targets import detected_apps + + +def _command_detected(paths: AgentPaths, command: str) -> bool: + target_user_home = paths.codex_config.parent.parent.resolve() + return target_user_home == Path.home().resolve() and shutil.which(command) is not None @dataclass(frozen=True) @@ -50,19 +56,15 @@ def _codex_managed(paths: AgentPaths) -> bool: return data.get("model_instructions_file") == str(paths.codex_instructions) -def _command_detected(paths: AgentPaths, command: str) -> bool: - target_user_home = paths.codex_config.parent.parent.resolve() - return target_user_home == Path.home().resolve() and shutil.which(command) is not None - - def agent_statuses(config: AgentConfig, paths: AgentPaths) -> tuple[AgentStatus, ...]: report = run_doctor(config, paths, include_ccswitch=False) drift_targets = {change.target for change in report.changes} + detected_targets = detected_apps(paths) definitions = ( ( "codex", "Codex", - paths.codex_config.parent.exists() or paths.codex_config.exists() or _command_detected(paths, "codex"), + "codex" in detected_targets, _codex_managed(paths), paths.codex_config, paths.codex_instructions, @@ -71,7 +73,7 @@ def agent_statuses(config: AgentConfig, paths: AgentPaths) -> tuple[AgentStatus, ( "claude", "Claude Code", - paths.claude_global_instructions.parent.exists() or paths.claude_config.exists() or _command_detected(paths, "claude"), + "claude" in detected_targets, _contains_managed_block(paths.claude_global_instructions), paths.claude_config, paths.claude_global_instructions, @@ -80,10 +82,7 @@ def agent_statuses(config: AgentConfig, paths: AgentPaths) -> tuple[AgentStatus, ( "hermes", "Hermes", - paths.hermes_config.parent.exists() - or paths.hermes_config.exists() - or paths.hermes_soul.exists() - or _command_detected(paths, "hermes"), + "hermes" in detected_targets, _contains_managed_block(paths.hermes_soul), paths.hermes_config, paths.hermes_soul, diff --git a/src/agent_switch/atomic.py b/src/agent_switch/atomic.py index 565ed04..d08aebe 100644 --- a/src/agent_switch/atomic.py +++ b/src/agent_switch/atomic.py @@ -30,9 +30,9 @@ def sha256_text(text: str) -> str: def backup_path_for(path: Path, backup_dir: Path, source_digest: str | None = None) -> Path: - digest = sha256_bytes(str(path).encode("utf-8"))[:12] - if source_digest: - digest = source_digest[:12] + path_digest = sha256_bytes(str(path.resolve(strict=False)).encode("utf-8"))[:10] + content_digest = (source_digest or "new")[:12] + digest = f"{path_digest}-{content_digest}" name = f"{path.name}.{digest}.bak" return backup_dir / name @@ -58,9 +58,11 @@ def write_if_changed( backup_path = None if target.exists() and backup_dir is not None: backup_root = Path(backup_dir) - backup_root.mkdir(parents=True, exist_ok=True) + backup_root.mkdir(mode=0o700, parents=True, exist_ok=True) + backup_root.chmod(0o700) backup_path = backup_path_for(target, backup_root, sha256_bytes(existing_data or b"")) shutil.copy2(target, backup_path) + backup_path.chmod(0o600) fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=str(target.parent)) try: diff --git a/src/agent_switch/ccswitch/db.py b/src/agent_switch/ccswitch/db.py index 1442a66..8f2fca5 100644 --- a/src/agent_switch/ccswitch/db.py +++ b/src/agent_switch/ccswitch/db.py @@ -143,3 +143,10 @@ def upsert_agent_mcp_server( bool(apps.hermes), ), ) + + def delete_agent_mcp_server(self, server_id: str) -> None: + if not server_id.startswith("agent-"): + raise ValueError(f"refusing to delete non-agent MCP id: {server_id}") + self.ensure_schema() + with self._connection() as conn: + conn.execute("DELETE FROM mcp_servers WHERE id = ?", (server_id,)) diff --git a/src/agent_switch/cli.py b/src/agent_switch/cli.py index 799e136..9d8734f 100644 --- a/src/agent_switch/cli.py +++ b/src/agent_switch/cli.py @@ -5,6 +5,7 @@ import os import stat import sys +import re from pathlib import Path from typing import BinaryIO @@ -14,10 +15,22 @@ from agent_switch.ccswitch.imports import preview_deeplink from agent_switch.cli_inventory import cli_inventory from agent_switch.config.loader import load_config, render_default_config -from agent_switch.paths import paths_for +from agent_switch.config.model import ManagedApps, ToolSpec +from agent_switch.config.store import update_config +from agent_switch.mcp.registry import find_tool, normalize_tool_id, put_tool, remove_tool, set_tool_enabled +from agent_switch.mcp.imports import SENSITIVE_NAME_RE, adopt_native_mcps, discover_native_mcps, plan_import +from agent_switch.paths import ensure_private_dir, paths_for from agent_switch.reconcile.apply import apply_reconcile from agent_switch.reconcile.doctor import run_doctor -from agent_switch.security.secrets import MAX_SECRET_BYTES, delete_secret, get_secret, list_secret_names, set_secret +from agent_switch.security.secrets import ( + MAX_SECRET_BYTES, + delete_secret, + get_secret, + list_secret_names, + read_env_file, + set_secret, + validate_secret, +) from agent_switch.skill_inventory import load_skill_report, update_git_skill_sources from agent_switch.status.dashboard import render_dashboard from agent_switch.status.report import human_report @@ -34,7 +47,8 @@ def cmd_doctor(args: argparse.Namespace) -> int: paths, config = _load(args) report = run_doctor(config, paths, include_ccswitch=not args.no_ccswitch) sys.stdout.write(report.to_json() if args.json else human_report(report)) - return 1 if report.blocked and args.strict else 0 + strict_failure = report.blocked or report.drift_count > 0 or bool(report.secret_report.missing) + return 1 if args.strict and strict_failure else 0 def cmd_reconcile(args: argparse.Namespace) -> int: @@ -42,7 +56,7 @@ def cmd_reconcile(args: argparse.Namespace) -> int: if args.dry_run: report = run_doctor(config, paths, include_ccswitch=not args.no_ccswitch) sys.stdout.write(report.to_json() if args.json else human_report(report)) - return 0 + return 1 if report.blocked else 0 summary, report = apply_reconcile(config, paths, include_ccswitch=not args.no_ccswitch) payload = {"summary": summary.to_dict(), "post": report.to_dict()} if args.json: @@ -77,6 +91,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int: def cmd_write_default_config(args: argparse.Namespace) -> int: paths, config = _load(args) + ensure_private_dir(paths.agent_home) result = write_if_changed(paths.config_file, render_default_config(config), backup_dir=paths.backup_dir) sys.stdout.write(f"{'wrote' if result.changed else 'unchanged'} {result.path}\n") return 0 @@ -127,6 +142,194 @@ def cmd_skills_update(args: argparse.Namespace) -> int: return 0 +def _config_path(args: argparse.Namespace, paths) -> Path: + return Path(args.config).expanduser() if args.config else paths.config_file + + +def _json_or_line(args: argparse.Namespace, payload: dict[str, object], line: str) -> None: + if args.json: + sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n") + else: + sys.stdout.write(line + "\n") + + +def cmd_mcp_list(args: argparse.Namespace) -> int: + _paths, config = _load(args) + tools = [tool.to_public_dict() for tool in config.tools] + if args.json: + sys.stdout.write(json.dumps({"mcps": tools}, ensure_ascii=False, indent=2, sort_keys=True) + "\n") + else: + for tool in config.tools: + state = "enabled" if tool.enabled else "disabled" + apps = ",".join(name for name, enabled in tool.apps.to_dict().items() if enabled) or "none" + sys.stdout.write(f"{tool.id}: {state} [{apps}] {tool.command} {' '.join(tool.args)}\n") + return 0 + + +def _parse_env(entries: list[str]) -> dict[str, str]: + result: dict[str, str] = {} + for entry in entries: + if "=" not in entry: + raise ValueError(f"--env must use NAME=VALUE: {entry}") + name, value = entry.split("=", 1) + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + raise ValueError(f"invalid environment name: {name}") + if SENSITIVE_NAME_RE.search(name): + raise ValueError(f"sensitive environment name must be declared with --secret, not --env: {name}") + result[name] = value + return result + + +def _apps_from_names(names: list[str] | None) -> ManagedApps: + selected = set(names or ("claude", "claude_desktop", "codex", "hermes")) + return ManagedApps( + claude="claude" in selected, + claude_desktop="claude_desktop" in selected, + codex="codex" in selected, + hermes="hermes" in selected, + ) + + +def _tool_from_args(args: argparse.Namespace) -> ToolSpec: + return ToolSpec( + id=normalize_tool_id(args.id), + name=args.name or normalize_tool_id(args.id).removeprefix("agent-"), + command=args.command, + args=tuple(args.arg or ()), + required_secrets=tuple(sorted(set(args.secret or ()))), + apps=_apps_from_names(args.app), + env=_parse_env(args.env or []), + description=args.description, + enabled=not args.disabled, + ) + + +def _store_mcp(args: argparse.Namespace, *, require_new: bool) -> int: + paths, _config = _load(args) + tool = _tool_from_args(args) + updated, result = update_config( + _config_path(args, paths), + paths.secrets_file, + paths.backup_dir, + lambda config: put_tool(config, tool, require_new=require_new), + ) + stored = find_tool(updated, tool.id) + _json_or_line( + args, + {"changed": result.changed, "mcp": stored.to_public_dict()}, + f"{'added' if require_new else 'saved'} {stored.id}", + ) + return 0 + + +def cmd_mcp_add(args: argparse.Namespace) -> int: + return _store_mcp(args, require_new=True) + + +def cmd_mcp_set(args: argparse.Namespace) -> int: + return _store_mcp(args, require_new=False) + + +def cmd_mcp_remove(args: argparse.Namespace) -> int: + paths, _config = _load(args) + normalized = normalize_tool_id(args.id) + _updated, result = update_config( + _config_path(args, paths), + paths.secrets_file, + paths.backup_dir, + lambda config: remove_tool(config, normalized), + ) + _json_or_line(args, {"changed": result.changed, "id": normalized}, f"removed {normalized}") + return 0 + + +def _toggle_mcp(args: argparse.Namespace, enabled: bool) -> int: + paths, _config = _load(args) + normalized = normalize_tool_id(args.id) + updated, result = update_config( + _config_path(args, paths), + paths.secrets_file, + paths.backup_dir, + lambda config: set_tool_enabled(config, normalized, enabled), + ) + tool = find_tool(updated, normalized) + state = "enabled" if enabled else "disabled" + _json_or_line(args, {"changed": result.changed, "mcp": tool.to_public_dict()}, f"{state} {normalized}") + return 0 + + +def cmd_mcp_enable(args: argparse.Namespace) -> int: + return _toggle_mcp(args, True) + + +def cmd_mcp_disable(args: argparse.Namespace) -> int: + return _toggle_mcp(args, False) + + +def cmd_mcp_import(args: argparse.Namespace) -> int: + paths, config = _load(args) + apps = args.app or ["claude", "claude_desktop", "codex", "hermes"] + discovery = discover_native_mcps(paths, apps) + native = discovery.mcps + preview = plan_import(config, native) + discovery_payload = { + "discovered": len(native) + len(discovery.skipped), + "supported": len(native), + "skipped": [item.to_public_dict() for item in discovery.skipped], + } + if args.dry_run: + payload = {"dryRun": True, **discovery_payload, **preview.to_public_dict()} + _json_or_line( + args, + payload, + f"would import {len(preview.imported)} MCP(s), merge {len(preview.merged)}, " + f"and leave {len(discovery.skipped)} unsupported MCP(s) untouched", + ) + return 0 + + # Validate the complete import before moving any inline values into the + # central store. Output and errors expose secret names only. + stored = read_env_file(paths.secrets_file) + conflicts = sorted(name for name, value in preview.secrets.items() if name in stored and stored[name] != value) + if conflicts: + raise ValueError("import would overwrite existing secret name(s): " + ", ".join(conflicts)) + for name, value in preview.secrets.items(): + validate_secret(name, value) + for name, value in preview.secrets.items(): + set_secret(paths.secrets_file, name, value) + updated, result = update_config( + _config_path(args, paths), + paths.secrets_file, + paths.backup_dir, + lambda current: plan_import(current, native).config, + ) + adopted = () + sync_summary = None + if args.adopt: + preflight = run_doctor(updated, paths, include_ccswitch=not args.no_ccswitch) + if preflight.blocked: + raise RuntimeError("MCPs were imported but adoption is blocked; no source entry was removed") + adopted = adopt_native_mcps(paths, native) + sync_summary, post = apply_reconcile(updated, paths, include_ccswitch=not args.no_ccswitch) + if post.blocked: + raise RuntimeError("MCPs were imported but reconciliation is blocked; run agent-switch doctor") + payload = { + "changed": result.changed, + **discovery_payload, + **preview.to_public_dict(), + "adoptedSourceFiles": len(adopted), + "reconcile": sync_summary.to_dict() if sync_summary else None, + "note": "source entries were backed up and replaced by managed projections" if args.adopt else "source entries are preserved until --adopt is explicitly requested", + } + _json_or_line( + args, + payload, + f"imported {len(preview.imported)} MCP(s), merged {len(preview.merged)}, " + f"stored {len(preview.secrets)} secret name(s), and left {len(discovery.skipped)} unsupported MCP(s) untouched", + ) + return 0 + + def _read_secret_stream(stream: BinaryIO, source: str) -> str: if stream.isatty(): raise ValueError(f"refusing to read a secret from TTY {source}; use a pipe or inherited file descriptor") @@ -166,17 +369,11 @@ def _read_secret_fd(fd: int) -> str: def _secret_value_from_args(args: argparse.Namespace) -> str: - has_legacy_value = args.value is not None - source_count = int(has_legacy_value) + int(args.read_stdin) + int(args.fd is not None) + if args.value is not None: + raise ValueError("positional secret values are not supported; use --stdin NAME or --fd N NAME") + source_count = int(args.read_stdin) + int(args.fd is not None) if source_count != 1: - raise ValueError("choose exactly one secret source: positional VALUE, --stdin, or --fd N") - - if has_legacy_value: - sys.stderr.write( - "agent-switch: warning: positional secret VALUE is deprecated and will be removed after 0.1.3; " - "use --stdin NAME or --fd N NAME\n" - ) - return args.value + raise ValueError("choose exactly one secret source: --stdin or --fd N") if args.read_stdin: stream = getattr(sys.stdin, "buffer", None) if stream is None: @@ -325,6 +522,57 @@ def build_parser() -> argparse.ArgumentParser: skills_update.add_argument("--json", action="store_true") skills_update.set_defaults(func=cmd_skills_update) + mcp = sub.add_parser("mcp", help="manage the central MCP registry") + mcp_sub = mcp.add_subparsers(dest="mcp_command", required=True) + mcp_list = mcp_sub.add_parser("list") + mcp_list.add_argument("--json", action="store_true") + mcp_list.set_defaults(func=cmd_mcp_list) + + def add_tool_arguments(command_parser: argparse.ArgumentParser) -> None: + command_parser.add_argument("id", help="MCP id; agent- is added automatically") + command_parser.add_argument("--name") + command_parser.add_argument("--command", required=True) + command_parser.add_argument("--arg", action="append", default=[]) + command_parser.add_argument("--secret", action="append", default=[]) + command_parser.add_argument("--env", action="append", default=[], help="non-secret NAME=VALUE environment entry") + command_parser.add_argument( + "--app", + action="append", + choices=("claude", "claude_desktop", "codex", "hermes"), + help="target app; repeat as needed (defaults to all)", + ) + command_parser.add_argument("--description") + command_parser.add_argument("--disabled", action="store_true") + command_parser.add_argument("--json", action="store_true") + + mcp_add = mcp_sub.add_parser("add") + add_tool_arguments(mcp_add) + mcp_add.set_defaults(func=cmd_mcp_add) + mcp_set = mcp_sub.add_parser("set") + add_tool_arguments(mcp_set) + mcp_set.set_defaults(func=cmd_mcp_set) + mcp_remove = mcp_sub.add_parser("remove") + mcp_remove.add_argument("id") + mcp_remove.add_argument("--json", action="store_true") + mcp_remove.set_defaults(func=cmd_mcp_remove) + mcp_import = mcp_sub.add_parser("import", help="import MCPs from installed agent configs") + mcp_import.add_argument( + "--app", + action="append", + choices=("claude", "claude_desktop", "codex", "hermes"), + help="source app; repeat as needed (defaults to all)", + ) + mcp_import.add_argument("--dry-run", action="store_true") + mcp_import.add_argument("--adopt", action="store_true", help="back up source configs, remove imported entries, and reconcile managed projections") + mcp_import.add_argument("--no-ccswitch", action="store_true") + mcp_import.add_argument("--json", action="store_true") + mcp_import.set_defaults(func=cmd_mcp_import) + for name, handler in (("enable", cmd_mcp_enable), ("disable", cmd_mcp_disable)): + toggle = mcp_sub.add_parser(name) + toggle.add_argument("id") + toggle.add_argument("--json", action="store_true") + toggle.set_defaults(func=handler) + secret = sub.add_parser("secret") secret_sub = secret.add_subparsers(dest="secret_command", required=True) secret_set = secret_sub.add_parser("set") diff --git a/src/agent_switch/config/loader.py b/src/agent_switch/config/loader.py index b365a96..8903f82 100644 --- a/src/agent_switch/config/loader.py +++ b/src/agent_switch/config/loader.py @@ -22,7 +22,7 @@ def load_config(config_file: str | Path, default_secret_file: str | Path) -> Age secret_file = (path.parent / secret_file).resolve() tools = tuple(ToolSpec.from_mapping(item) for item in raw.get("tools", [])) config = AgentConfig( - tools=tools or default_config(secret_file).tools, + tools=tools, routes=RouteConfig.from_mapping(raw.get("routes")), secret_file=secret_file, ) @@ -31,5 +31,4 @@ def load_config(config_file: str | Path, default_secret_file: str | Path) -> Age def render_default_config(config: AgentConfig) -> str: - return json.dumps(config.to_public_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n" - + return json.dumps(config.to_config_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n" diff --git a/src/agent_switch/config/model.py b/src/agent_switch/config/model.py index 2314785..1b03758 100644 --- a/src/agent_switch/config/model.py +++ b/src/agent_switch/config/model.py @@ -2,10 +2,23 @@ from dataclasses import dataclass, field from pathlib import Path +import re from typing import Any APP_NAMES = ("claude", "claude_desktop", "codex", "hermes") +ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") +SENSITIVE_NAME_RE = re.compile(r"(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)", re.IGNORECASE) +SECRET_REFERENCE_RE = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}") +SENSITIVE_ARG_FLAG_RE = re.compile( + r"^--?(?:api[-_]?key|token|secret|password|passwd|credential|authorization|auth-token)(?:=|$)", + re.IGNORECASE, +) +TOKEN_VALUE_RE = re.compile( + r"(?ix)(sk-[A-Za-z0-9_\-]{8,}|xai-[A-Za-z0-9_\-]{8,}|tvly-[A-Za-z0-9_\-]{8,}|" + r"AIza[A-Za-z0-9_\-]{12,}|[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,})" +) class ConfigError(ValueError): @@ -43,6 +56,7 @@ class ToolSpec: apps: ManagedApps = field(default_factory=ManagedApps) env: dict[str, str] = field(default_factory=dict) description: str | None = None + enabled: bool = True @classmethod def from_mapping(cls, value: dict[str, Any]) -> "ToolSpec": @@ -55,6 +69,7 @@ def from_mapping(cls, value: dict[str, Any]) -> "ToolSpec": apps=ManagedApps.from_mapping(value.get("apps")), env={str(k): str(v) for k, v in value.get("env", {}).items()}, description=value.get("description"), + enabled=bool(value.get("enabled", True)), ) @property @@ -71,14 +86,21 @@ def to_public_dict(self) -> dict[str, Any]: "apps": self.apps.to_dict(), "envNames": sorted(self.env.keys()), "description": self.description, + "enabled": self.enabled, } + def to_config_dict(self) -> dict[str, Any]: + payload = self.to_public_dict() + payload.pop("envNames", None) + payload["env"] = dict(sorted(self.env.items())) + return payload + @dataclass(frozen=True) class RouteConfig: - search_default: str = "agent-xcrawl" - x_read_default: str = "agent-birdread" - x_read_fallback: str = "agent-xurl-fallback" + search_default: str = "" + x_read_default: str = "" + x_read_fallback: str = "" @classmethod def from_mapping(cls, value: dict[str, Any] | None) -> "RouteConfig": @@ -110,7 +132,7 @@ def tool_ids(self) -> set[str]: def tools_for_app(self, app: str) -> tuple[ToolSpec, ...]: if app not in APP_NAMES: raise ConfigError(f"unknown app: {app}") - return tuple(tool for tool in self.tools if getattr(tool.apps, app)) + return tuple(tool for tool in self.tools if tool.enabled and getattr(tool.apps, app)) def to_public_dict(self) -> dict[str, Any]: return { @@ -119,54 +141,18 @@ def to_public_dict(self) -> dict[str, Any]: "secretFile": str(self.secret_file), } + def to_config_dict(self) -> dict[str, Any]: + return { + "tools": [tool.to_config_dict() for tool in self.tools], + "routes": self.routes.to_dict(), + "secretFile": str(self.secret_file), + } + def default_tools() -> tuple[ToolSpec, ...]: - apps = ManagedApps() - return ( - ToolSpec( - id="agent-tavily", - name="Tavily Search", - command="npx", - args=("-y", "tavily-mcp"), - required_secrets=("TAVILY_API_KEY",), - apps=apps, - description="Search fallback when explicitly requested.", - ), - ToolSpec( - id="agent-xcrawl", - name="Xcrawl", - command="npx", - args=("-y", "xcrawl-mcp"), - required_secrets=("XCRAWL_API_KEY",), - apps=apps, - description="Default public web research and scraping route.", - ), - ToolSpec( - id="agent-firecrawl", - name="Firecrawl", - command="npx", - args=("-y", "firecrawl-mcp"), - required_secrets=("FIRECRAWL_API_KEY",), - apps=apps, - description="Firecrawl search, scrape, crawl, parse, and browser interaction MCP route.", - ), - ToolSpec( - id="agent-birdread", - name="Birdread", - command="agent-switch-mcp-bird", - required_secrets=(), - apps=apps, - description="Default X/Twitter single-post and thread reader backed by the local bird CLI.", - ), - ToolSpec( - id="agent-xurl-fallback", - name="Official X API fallback", - command="agent-switch-mcp-xcli", - required_secrets=("X_API_KEY", "X_API_SECRET", "X_BEARER_TOKEN", "X_ACCESS_TOKEN", "X_ACCESS_TOKEN_SECRET"), - apps=apps, - description="Fallback X/Twitter reader route backed by the local x-cli-auth official API wrapper.", - ), - ) + # A public control plane must start neutral. Recommended MCPs belong in an + # explicit catalog/import flow, never in every user's first configuration. + return () def default_config(secret_file: Path) -> AgentConfig: @@ -185,10 +171,49 @@ def validate_config(config: AgentConfig) -> None: seen.add(tool.id) if not tool.command: raise ConfigError(f"tool command is required: {tool.id}") + if TOKEN_VALUE_RE.search(tool.command): + raise ConfigError(f"credential-shaped value must not be embedded in MCP command: {tool.id}") + invalid_secrets = sorted(name for name in tool.required_secrets if not SECRET_NAME_RE.fullmatch(name)) + if invalid_secrets: + raise ConfigError(f"invalid required secret name(s) for {tool.id}: {', '.join(invalid_secrets)}") + invalid_env = sorted(name for name in tool.env if not ENV_NAME_RE.fullmatch(name)) + if invalid_env: + raise ConfigError(f"invalid MCP environment name(s) for {tool.id}: {', '.join(invalid_env)}") + sensitive_env = sorted(name for name in tool.env if SENSITIVE_NAME_RE.search(name)) + if sensitive_env: + raise ConfigError( + f"sensitive MCP environment name(s) must use requiredSecrets for {tool.id}: " + + ", ".join(sensitive_env) + ) + secret_shaped_env = sorted(name for name, value in tool.env.items() if TOKEN_VALUE_RE.search(value)) + if secret_shaped_env: + raise ConfigError( + f"credential-shaped MCP environment value(s) must use requiredSecrets for {tool.id}: " + + ", ".join(secret_shaped_env) + ) + previous_arg = "" + for index, argument in enumerate(tool.args): + references = set(SECRET_REFERENCE_RE.findall(argument)) + undeclared = sorted(name for name in references if SENSITIVE_NAME_RE.search(name) and name not in tool.required_secrets) + if undeclared: + raise ConfigError( + f"MCP argument references undeclared secret name(s) for {tool.id}: {', '.join(undeclared)}" + ) + assignment_secret = bool(SENSITIVE_ARG_FLAG_RE.match(argument) and "=" in argument and not references) + follows_secret_flag = bool(SENSITIVE_ARG_FLAG_RE.match(previous_arg) and "=" not in previous_arg and not references) + if TOKEN_VALUE_RE.search(argument) or assignment_secret or follows_secret_flag: + raise ConfigError( + f"credential-shaped MCP argument {index + 1} must use a declared ${{SECRET_NAME}} placeholder: {tool.id}" + ) + previous_arg = argument + overlap = set(tool.required_secrets) & set(tool.env) + if overlap: + raise ConfigError(f"MCP env must not override required secret name(s): {', '.join(sorted(overlap))}") + enabled_ids = {tool.id for tool in config.tools if tool.enabled} missing_routes = [ route for route in (config.routes.search_default, config.routes.x_read_default, config.routes.x_read_fallback) - if route not in seen + if route and route not in enabled_ids ] if missing_routes: - raise ConfigError(f"route references unknown tool id(s): {', '.join(sorted(missing_routes))}") + raise ConfigError(f"route references unknown or disabled tool id(s): {', '.join(sorted(missing_routes))}") diff --git a/src/agent_switch/config/routes.py b/src/agent_switch/config/routes.py index 44c2264..920cbd0 100644 --- a/src/agent_switch/config/routes.py +++ b/src/agent_switch/config/routes.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from .model import AgentConfig, ToolSpec +from .model import ConfigError @dataclass(frozen=True) @@ -21,10 +22,13 @@ def to_dict(self) -> dict[str, str | None]: def select_search_tool(config: AgentConfig) -> RouteSelection: tools = {tool.id: tool for tool in config.tools} + if not config.routes.search_default: + raise ConfigError("no default search MCP is configured") return RouteSelection("search", tools[config.routes.search_default]) def select_x_reader(config: AgentConfig) -> RouteSelection: tools = {tool.id: tool for tool in config.tools} + if not config.routes.x_read_default or not config.routes.x_read_fallback: + raise ConfigError("no X reader route is configured") return RouteSelection("x-read", tools[config.routes.x_read_default], tools[config.routes.x_read_fallback]) - diff --git a/src/agent_switch/config/store.py b/src/agent_switch/config/store.py new file mode 100644 index 0000000..27eaa1f --- /dev/null +++ b/src/agent_switch/config/store.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from contextlib import contextmanager +import fcntl +import os +from pathlib import Path +from typing import Callable, Iterator + +from agent_switch.atomic import WriteResult, write_if_changed +from agent_switch.config.loader import load_config, render_default_config +from agent_switch.config.model import AgentConfig, validate_config + + +@contextmanager +def _config_lock(config_path: Path) -> Iterator[None]: + parent_existed = config_path.parent.exists() + config_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + # The default control-plane directory must be private, but a caller using + # --config inside an existing project must not have that directory's mode + # changed as a side effect. + if not parent_existed: + config_path.parent.chmod(0o700) + lock_path = config_path.with_name(f".{config_path.name}.lock") + lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + os.fchmod(lock_fd, 0o600) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + yield + finally: + try: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + finally: + os.close(lock_fd) + + +def update_config( + config_path: str | Path, + default_secret_file: str | Path, + backup_dir: str | Path, + transform: Callable[[AgentConfig], AgentConfig], +) -> tuple[AgentConfig, WriteResult]: + path = Path(config_path) + with _config_lock(path): + current = load_config(path, default_secret_file) + updated = transform(current) + validate_config(updated) + result = write_if_changed(path, render_default_config(updated), mode=0o600, backup_dir=backup_dir) + return updated, result diff --git a/src/agent_switch/instructions.py b/src/agent_switch/instructions.py index a8c9ed5..a79566c 100644 --- a/src/agent_switch/instructions.py +++ b/src/agent_switch/instructions.py @@ -114,30 +114,35 @@ def desired_instruction_targets(paths: AgentPaths) -> InstructionTargets: ) -def write_instructions(paths: AgentPaths) -> list[WriteResult]: +def write_instructions(paths: AgentPaths, apps: set[str] | None = None) -> list[WriteResult]: + selected = apps or set() targets = desired_instruction_targets(paths) - results = [ - write_if_changed(targets.codex, codex_instructions(paths), mode=0o600, backup_dir=paths.backup_dir), - write_if_changed(targets.claude, claude_instructions(paths), mode=0o600, backup_dir=paths.backup_dir), - write_if_changed(targets.hermes, hermes_instructions(paths), mode=0o600, backup_dir=paths.backup_dir), - ] - - claude_current = targets.claude_global.read_text(encoding="utf-8") if targets.claude_global.exists() else "" - results.append( - write_if_changed( - targets.claude_global, - merge_managed_block(claude_current, claude_instructions(paths)), - backup_dir=paths.backup_dir, + results: list[WriteResult] = [] + if "codex" in selected: + results.append(write_if_changed(targets.codex, codex_instructions(paths), mode=0o600, backup_dir=paths.backup_dir)) + if "claude" in selected: + results.append(write_if_changed(targets.claude, claude_instructions(paths), mode=0o600, backup_dir=paths.backup_dir)) + if "hermes" in selected: + results.append(write_if_changed(targets.hermes, hermes_instructions(paths), mode=0o600, backup_dir=paths.backup_dir)) + + if "claude" in selected: + claude_current = targets.claude_global.read_text(encoding="utf-8") if targets.claude_global.exists() else "" + results.append( + write_if_changed( + targets.claude_global, + merge_managed_block(claude_current, claude_instructions(paths)), + backup_dir=paths.backup_dir, + ) ) - ) - hermes_current = targets.hermes_soul.read_text(encoding="utf-8") if targets.hermes_soul.exists() else "" - results.append( - write_if_changed( - targets.hermes_soul, - merge_managed_block(hermes_current, hermes_instructions(paths)), - mode=0o600, - backup_dir=paths.backup_dir, + if "hermes" in selected: + hermes_current = targets.hermes_soul.read_text(encoding="utf-8") if targets.hermes_soul.exists() else "" + results.append( + write_if_changed( + targets.hermes_soul, + merge_managed_block(hermes_current, hermes_instructions(paths)), + mode=0o600, + backup_dir=paths.backup_dir, + ) ) - ) return results diff --git a/src/agent_switch/mcp/bird_server.py b/src/agent_switch/mcp/bird_server.py index 817d5c1..85fee7d 100644 --- a/src/agent_switch/mcp/bird_server.py +++ b/src/agent_switch/mcp/bird_server.py @@ -53,7 +53,7 @@ def build_server() -> StdioMCPServer: string_arg = {"type": "string"} return StdioMCPServer( name="agent-birdread", - version="0.1.3", + version="0.2.0", tools=[ Tool( name="read_x_post", diff --git a/src/agent_switch/mcp/imports.py b/src/agent_switch/mcp/imports.py new file mode 100644 index 0000000..9132cd0 --- /dev/null +++ b/src/agent_switch/mcp/imports.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +import json +from pathlib import Path +import re +import tomllib +from typing import Any, Iterable + +from agent_switch.atomic import WriteResult, write_if_changed +from agent_switch.config.model import AgentConfig, ConfigError, ManagedApps, ToolSpec, validate_config +from agent_switch.mcp.registry import normalize_tool_id, put_tool +from agent_switch.paths import AgentPaths +from agent_switch.renderers.hermes import _entry_key, _find_mcp_section, _split_yaml_entries +from agent_switch.security.redaction import TOKEN_VALUE_RE + + +SENSITIVE_NAME_RE = re.compile(r"(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)", re.IGNORECASE) +SECRET_REFERENCE_RE = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}") +SENSITIVE_ARG_FLAG_RE = re.compile( + r"^--?(?:api[-_]?key|token|secret|password|passwd|credential|authorization|auth-token)(?:=|$)", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class NativeMcp: + app: str + source_id: str + command: str + args: tuple[str, ...] + env: dict[str, str] + required_secrets: tuple[str, ...] = () + + +@dataclass(frozen=True) +class SkippedNativeMcp: + app: str + source_id: str + reason: str + + def to_public_dict(self) -> dict[str, str]: + return {"app": self.app, "id": self.source_id, "reason": self.reason} + + +@dataclass(frozen=True) +class NativeMcpDiscovery: + mcps: tuple[NativeMcp, ...] + skipped: tuple[SkippedNativeMcp, ...] + + +@dataclass(frozen=True) +class ImportPlan: + config: AgentConfig + secrets: dict[str, str] + imported: tuple[str, ...] + merged: tuple[str, ...] + + def to_public_dict(self) -> dict[str, object]: + return { + "imported": list(self.imported), + "merged": list(self.merged), + "secretNames": sorted(self.secrets), + } + + +def _derived_arg_secret_name(server_id: str, index: int) -> str: + slug = re.sub(r"[^A-Z0-9]+", "_", server_id.upper()).strip("_") or "MCP" + return f"MCP_{slug}_SECRET_ARG_{index + 1}" + + +def _sanitize_args(server_id: str, raw_args: list[object]) -> tuple[tuple[str, ...], dict[str, str], tuple[str, ...]]: + args = [str(item) for item in raw_args] + values: dict[str, str] = {} + required: set[str] = set() + rendered: list[str] = [] + previous = "" + for index, arg in enumerate(args): + references = set(SECRET_REFERENCE_RE.findall(arg)) + required.update(name for name in references if SENSITIVE_NAME_RE.search(name)) + sensitive_assignment = SENSITIVE_ARG_FLAG_RE.match(arg) + sensitive_after_flag = bool(SENSITIVE_ARG_FLAG_RE.match(previous) and "=" not in previous) + sensitive_header = previous in {"--header", "-H"} and bool(SENSITIVE_NAME_RE.search(arg)) + literal_credential = bool(TOKEN_VALUE_RE.search(arg)) + needs_migration = not references and (sensitive_after_flag or sensitive_header or literal_credential) + if sensitive_assignment and "=" in arg and not references: + prefix, secret_value = arg.split("=", 1) + if secret_value: + name = _derived_arg_secret_name(server_id, index) + values[name] = secret_value + required.add(name) + rendered.append(f"{prefix}=${{{name}}}") + previous = arg + continue + if needs_migration: + name = _derived_arg_secret_name(server_id, index) + values[name] = arg + required.add(name) + rendered.append(f"${{{name}}}") + else: + rendered.append(arg) + previous = arg + return tuple(rendered), values, tuple(sorted(required)) + + +def _spec_from_mapping(app: str, server_id: str, value: Any) -> NativeMcp: + if not isinstance(value, dict): + raise ConfigError(f"{app} MCP {server_id} must be an object") + command = value.get("command") + if not isinstance(command, str) or not command: + raise ConfigError(f"{app} MCP {server_id} has no command") + raw_args = value.get("args", []) + raw_env = value.get("env", {}) + if not isinstance(raw_args, list) or not all(isinstance(item, (str, int, float, bool)) for item in raw_args): + raise ConfigError(f"{app} MCP {server_id} args must be a list") + if not isinstance(raw_env, dict): + raise ConfigError(f"{app} MCP {server_id} env must be an object") + args, arg_secrets, referenced_secrets = _sanitize_args(server_id, raw_args) + environment = {str(name): str(secret) for name, secret in raw_env.items()} + for name, secret in arg_secrets.items(): + if name in environment and environment[name] != secret: + raise ConfigError(f"{app} MCP {server_id} has a derived argument secret conflict") + environment[name] = secret + return NativeMcp( + app=app, + source_id=server_id, + command=command, + args=args, + env=environment, + required_secrets=referenced_secrets, + ) + + +def _scan_spec(app: str, server_id: str, value: Any) -> tuple[NativeMcp | None, SkippedNativeMcp | None]: + if isinstance(value, dict) and not value.get("command") and value.get("url"): + raw_transport = str(value.get("type") or "remote").lower() + transport = raw_transport if raw_transport in {"http", "sse", "streamable-http"} else "remote" + return None, SkippedNativeMcp( + app=app, + source_id=server_id, + reason=f"{transport} transport requires the remote MCP adapter", + ) + return _spec_from_mapping(app, server_id, value), None + + +def _json_mcp_discovery(path: Path, app: str) -> NativeMcpDiscovery: + if not path.exists(): + return NativeMcpDiscovery((), ()) + try: + root = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ConfigError(f"invalid {app} JSON at {path}: {exc}") from exc + servers = root.get("mcpServers", {}) if isinstance(root, dict) else {} + if not isinstance(servers, dict): + raise ConfigError(f"{app} mcpServers must be an object") + mcps: list[NativeMcp] = [] + skipped: list[SkippedNativeMcp] = [] + for server_id, spec in servers.items(): + source_id = str(server_id) + if source_id.startswith("agent-"): + continue + native, unsupported = _scan_spec(app, source_id, spec) + if native is not None: + mcps.append(native) + if unsupported is not None: + skipped.append(unsupported) + return NativeMcpDiscovery(tuple(mcps), tuple(skipped)) + + +def _codex_mcp_discovery(path: Path) -> NativeMcpDiscovery: + if not path.exists(): + return NativeMcpDiscovery((), ()) + try: + root = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"invalid codex TOML at {path}: {exc}") from exc + servers = root.get("mcp_servers", {}) if isinstance(root, dict) else {} + if not isinstance(servers, dict): + raise ConfigError("codex mcp_servers must be a table") + mcps: list[NativeMcp] = [] + skipped: list[SkippedNativeMcp] = [] + for server_id, spec in servers.items(): + source_id = str(server_id) + if source_id.startswith("agent-"): + continue + native, unsupported = _scan_spec("codex", source_id, spec) + if native is not None: + mcps.append(native) + if unsupported is not None: + skipped.append(unsupported) + return NativeMcpDiscovery(tuple(mcps), tuple(skipped)) + + +def _yaml_scalar(raw: str) -> Any: + value = raw.strip() + if value == "[]": + return [] + try: + return json.loads(value) + except json.JSONDecodeError: + return value.strip("'\"") + + +def _hermes_chunk_spec(chunk: list[str]) -> dict[str, object]: + result: dict[str, object] = {} + index = 1 + while index < len(chunk): + line = chunk[index] + match = re.match(r"^ ([A-Za-z0-9_-]+):(?:\s*(.*))?$", line) + if not match: + index += 1 + continue + key, raw_value = match.group(1), match.group(2) or "" + if raw_value: + result[key] = _yaml_scalar(raw_value) + index += 1 + continue + if key == "args": + values: list[str] = [] + index += 1 + while index < len(chunk) and (item := re.match(r"^ -\s*(.*)$", chunk[index])): + values.append(str(_yaml_scalar(item.group(1)))) + index += 1 + result[key] = values + continue + if key == "env": + values_dict: dict[str, str] = {} + index += 1 + while index < len(chunk) and (item := re.match(r"^ ([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", chunk[index])): + values_dict[item.group(1)] = str(_yaml_scalar(item.group(2))) + index += 1 + result[key] = values_dict + continue + index += 1 + return result + + +def _hermes_mcp_discovery(path: Path) -> NativeMcpDiscovery: + if not path.exists(): + return NativeMcpDiscovery((), ()) + lines = path.read_text(encoding="utf-8").splitlines() + section = _find_mcp_section(lines) + if section is None: + return NativeMcpDiscovery((), ()) + start, end = section + items: list[NativeMcp] = [] + skipped: list[SkippedNativeMcp] = [] + for chunk in _split_yaml_entries(lines[start + 1 : end]): + server_id = _entry_key(chunk) + if server_id is None or server_id.startswith("agent-"): + continue + native, unsupported = _scan_spec("hermes", server_id, _hermes_chunk_spec(chunk)) + if native is not None: + items.append(native) + if unsupported is not None: + skipped.append(unsupported) + return NativeMcpDiscovery(tuple(items), tuple(skipped)) + + +def discover_native_mcps(paths: AgentPaths, apps: Iterable[str] | None = None) -> NativeMcpDiscovery: + selected = set(apps or ("claude", "claude_desktop", "codex", "hermes")) + scans: list[NativeMcpDiscovery] = [] + if "claude" in selected: + scans.append(_json_mcp_discovery(paths.claude_config, "claude")) + if "claude_desktop" in selected: + scans.append(_json_mcp_discovery(paths.claude_desktop_config, "claude_desktop")) + if "codex" in selected: + scans.append(_codex_mcp_discovery(paths.codex_config)) + if "hermes" in selected: + scans.append(_hermes_mcp_discovery(paths.hermes_config)) + return NativeMcpDiscovery( + tuple(mcp for scan in scans for mcp in scan.mcps), + tuple(item for scan in scans for item in scan.skipped), + ) + + +def load_native_mcps(paths: AgentPaths, apps: Iterable[str] | None = None) -> tuple[NativeMcp, ...]: + return discover_native_mcps(paths, apps).mcps + + +def _apps_with(apps: ManagedApps, app: str) -> ManagedApps: + values = apps.to_dict() + values[app] = True + return ManagedApps(**values) + + +def plan_import(config: AgentConfig, native_mcps: Iterable[NativeMcp]) -> ImportPlan: + updated = config + secret_values: dict[str, str] = {} + imported: list[str] = [] + merged: list[str] = [] + for native in native_mcps: + tool_id = normalize_tool_id(native.source_id) + private_env = { + name: value + for name, value in native.env.items() + if SENSITIVE_NAME_RE.search(name) or TOKEN_VALUE_RE.search(value) + } + public_env = {name: value for name, value in native.env.items() if name not in private_env} + for name, value in private_env.items(): + if name in secret_values and secret_values[name] != value: + raise ConfigError(f"conflicting values found for secret name {name}") + secret_values[name] = value + apps = ManagedApps(claude=False, claude_desktop=False, codex=False, hermes=False) + candidate = ToolSpec( + id=tool_id, + name=native.source_id, + command=native.command, + args=native.args, + required_secrets=tuple(sorted(set(private_env) | set(native.required_secrets))), + apps=_apps_with(apps, native.app), + env=public_env, + description=f"Imported from {native.app}", + ) + existing = next((tool for tool in updated.tools if tool.id == tool_id), None) + if existing is None: + updated = put_tool(updated, candidate, require_new=True) + imported.append(tool_id) + continue + public_conflicts = { + name + for name, value in candidate.env.items() + if name in existing.env and existing.env[name] != value + } + compatible = existing.command == candidate.command and existing.args == candidate.args and not public_conflicts + if not compatible: + raise ConfigError(f"MCP import conflict for {tool_id}; commands or environments differ between sources") + updated = put_tool( + updated, + replace( + existing, + apps=_apps_with(existing.apps, native.app), + env={**existing.env, **candidate.env}, + required_secrets=tuple(sorted(set(existing.required_secrets) | set(candidate.required_secrets))), + ), + ) + merged.append(tool_id) + validate_config(updated) + return ImportPlan(updated, secret_values, tuple(sorted(set(imported))), tuple(sorted(set(merged)))) + + +def _remove_json_entries(path: Path, ids: set[str], backup_dir: Path) -> WriteResult | None: + if not path.exists() or not ids: + return None + root = json.loads(path.read_text(encoding="utf-8")) + servers = root.get("mcpServers") if isinstance(root, dict) else None + if not isinstance(servers, dict): + return None + changed = False + for server_id in ids: + changed = servers.pop(server_id, None) is not None or changed + if not changed: + return None + return write_if_changed(path, json.dumps(root, ensure_ascii=False, indent=2, sort_keys=True) + "\n", backup_dir=backup_dir) + + +def _remove_codex_entries(path: Path, ids: set[str], backup_dir: Path) -> WriteResult | None: + if not path.exists() or not ids: + return None + original = path.read_text(encoding="utf-8") + rendered = original + for server_id in ids: + quoted = re.escape(server_id) + subtable = re.compile( + rf'(?ms)^\[mcp_servers\.(?:"{quoted}"|{quoted})\]\s*\n.*?(?=^\[|\Z)' + ) + rendered = subtable.sub("", rendered) + lines = rendered.splitlines() + in_mcp_table = False + kept: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_mcp_table = stripped == "[mcp_servers]" + if in_mcp_table and re.match(rf'^(?:"{quoted}"|{quoted})\s*=', stripped): + continue + kept.append(line) + rendered = "\n".join(kept).rstrip() + ("\n" if kept else "") + if rendered == original: + return None + try: + tomllib.loads(rendered) + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"refusing to adopt Codex MCP because cleanup would produce invalid TOML: {exc}") from exc + return write_if_changed(path, rendered, backup_dir=backup_dir) + + +def _remove_hermes_entries(path: Path, ids: set[str], backup_dir: Path) -> WriteResult | None: + if not path.exists() or not ids: + return None + original = path.read_text(encoding="utf-8") + lines = original.splitlines() + section = _find_mcp_section(lines) + if section is None: + return None + start, end = section + preserved: list[str] = [] + for chunk in _split_yaml_entries(lines[start + 1 : end]): + if _entry_key(chunk) not in ids: + preserved.extend(chunk) + rendered = "\n".join(lines[: start + 1] + preserved + lines[end:]).rstrip() + "\n" + if rendered == original: + return None + return write_if_changed(path, rendered, backup_dir=backup_dir) + + +def adopt_native_mcps(paths: AgentPaths, native_mcps: Iterable[NativeMcp]) -> tuple[WriteResult, ...]: + ids_by_app: dict[str, set[str]] = {} + for native in native_mcps: + ids_by_app.setdefault(native.app, set()).add(native.source_id) + results = [ + _remove_json_entries(paths.claude_config, ids_by_app.get("claude", set()), paths.backup_dir), + _remove_json_entries( + paths.claude_desktop_config, + ids_by_app.get("claude_desktop", set()), + paths.backup_dir, + ), + _remove_codex_entries(paths.codex_config, ids_by_app.get("codex", set()), paths.backup_dir), + _remove_hermes_entries(paths.hermes_config, ids_by_app.get("hermes", set()), paths.backup_dir), + ] + return tuple(result for result in results if result is not None) diff --git a/src/agent_switch/mcp/registry.py b/src/agent_switch/mcp/registry.py new file mode 100644 index 0000000..af33048 --- /dev/null +++ b/src/agent_switch/mcp/registry.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import replace +import re + +from agent_switch.config.model import AgentConfig, ConfigError, RouteConfig, ToolSpec + + +def normalize_tool_id(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-") + if slug.startswith("agent-"): + slug = slug.removeprefix("agent-") + if not slug: + raise ConfigError("MCP id must contain at least one letter or number") + return f"agent-{slug}" + + +def find_tool(config: AgentConfig, tool_id: str) -> ToolSpec: + normalized = normalize_tool_id(tool_id) + for tool in config.tools: + if tool.id == normalized: + return tool + raise ConfigError(f"unknown MCP: {normalized}") + + +def put_tool(config: AgentConfig, tool: ToolSpec, *, require_new: bool = False) -> AgentConfig: + normalized = normalize_tool_id(tool.id) + normalized_tool = replace(tool, id=normalized) + existing = {item.id: item for item in config.tools} + if require_new and normalized in existing: + raise ConfigError(f"MCP already exists: {normalized}") + existing[normalized] = normalized_tool + return replace(config, tools=tuple(existing[key] for key in sorted(existing))) + + +def set_tool_enabled(config: AgentConfig, tool_id: str, enabled: bool) -> AgentConfig: + current = find_tool(config, tool_id) + updated = put_tool(config, replace(current, enabled=enabled)) + if enabled: + return updated + routes = updated.routes + return replace( + updated, + routes=RouteConfig( + search_default="" if routes.search_default == current.id else routes.search_default, + x_read_default="" if routes.x_read_default == current.id else routes.x_read_default, + x_read_fallback="" if routes.x_read_fallback == current.id else routes.x_read_fallback, + ), + ) + + +def remove_tool(config: AgentConfig, tool_id: str) -> AgentConfig: + normalized = normalize_tool_id(tool_id) + if normalized not in config.tool_ids(): + raise ConfigError(f"unknown MCP: {normalized}") + routes = config.routes + next_routes = RouteConfig( + search_default="" if routes.search_default == normalized else routes.search_default, + x_read_default="" if routes.x_read_default == normalized else routes.x_read_default, + x_read_fallback="" if routes.x_read_fallback == normalized else routes.x_read_fallback, + ) + return replace(config, tools=tuple(tool for tool in config.tools if tool.id != normalized), routes=next_routes) diff --git a/src/agent_switch/mcp/templates.py b/src/agent_switch/mcp/templates.py index ae2413b..4f38bc6 100644 --- a/src/agent_switch/mcp/templates.py +++ b/src/agent_switch/mcp/templates.py @@ -1,43 +1,114 @@ from __future__ import annotations -import shlex +import json from agent_switch.config.model import ToolSpec +def _literal(value: object) -> str: + # JSON literals are deterministic and valid Python for strings, lists, and + # mappings used by the generated wrapper. + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + def render_wrapper_script(tool: ToolSpec, secret_file: str) -> str: - required = " ".join(shlex.quote(name) for name in tool.required_secrets) - command = " ".join(shlex.quote(part) for part in (tool.command, *tool.args)) - env_lines = "\n".join(f"export {shlex.quote(key)}={shlex.quote(value)}" for key, value in sorted(tool.env.items())) - if env_lines: - env_lines += "\n" - if tool.required_secrets: - secret_check = f"""declare -a required=({required}) -missing=() -for name in "${{required[@]}}"; do - if [ -z "${{!name:-}}" ]; then - missing+=("$name") - fi -done -""" - else: - secret_check = "missing=()\n" - return f"""#!/usr/bin/env bash -set -euo pipefail - -SECRET_FILE="${{AGENT_SWITCH_SECRETS:-{shlex.quote(secret_file)}}}" -if [ -f "$SECRET_FILE" ]; then - set -a - # shellcheck disable=SC1090 - . "$SECRET_FILE" - set +a -fi - -{env_lines}{secret_check} -if [ "${{#missing[@]}}" -gt 0 ]; then - printf 'Agent Switch wrapper {tool.id} missing required secret(s): %s\\n' "${{missing[*]}}" >&2 - exit 78 -fi - -exec {command} "$@" -""" + """Render a standalone wrapper that grants only declared secrets. + + The previous shell wrapper sourced the complete secrets file with `set -a`, + which exposed every stored credential to every MCP. This generated Python + launcher parses the file as data, strips sensitive inherited variables, and + adds back only the names explicitly declared by this tool. + """ + + return f'''#!/usr/bin/env python3 +# Generated by Agent Switch. Do not edit; run `agent-switch reconcile`. +import os +import re +import sys + +SECRET_FILE = {_literal(secret_file)} +REQUIRED = tuple({_literal(list(tool.required_secrets))}) +TOOL_ENV = {_literal(dict(sorted(tool.env.items())))} +COMMAND = {_literal([tool.command, *tool.args])} +SECRET_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$") +SENSITIVE_NAME = re.compile(r"(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)", re.IGNORECASE) +SECRET_REFERENCE = re.compile(r"\\$\\{{([A-Z][A-Z0-9_]*)\\}}") + + +def decode_double_quoted(value): + output = [] + index = 0 + while index < len(value): + character = value[index] + if character == "\\\\" and index + 1 < len(value) and value[index + 1] in ('\\\\', '"', '$', '`'): + output.append(value[index + 1]) + index += 2 + continue + output.append(character) + index += 1 + return "".join(output) + + +def decode_value(raw): + value = raw.strip() + if value.startswith(("'", '"')): + if len(value) < 2 or value[-1] != value[0]: + raise ValueError("malformed quoted secret value") + return value[1:-1] if value[0] == "'" else decode_double_quoted(value[1:-1]) + return value + + +def read_secrets(path): + values = {{}} + try: + with open(path, "r", encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export "):].strip() + if "=" not in line: + continue + name, raw_value = line.split("=", 1) + name = name.strip() + if SECRET_NAME.fullmatch(name): + values[name] = decode_value(raw_value) + except FileNotFoundError: + pass + return values + + +try: + stored = read_secrets(SECRET_FILE) +except (OSError, UnicodeError, ValueError) as error: + print("Agent Switch wrapper {tool.id} could not read the secret store: " + str(error), file=sys.stderr) + raise SystemExit(78) + +missing = [name for name in REQUIRED if not stored.get(name)] +if missing: + print("Agent Switch wrapper {tool.id} missing required secret(s): " + " ".join(missing), file=sys.stderr) + raise SystemExit(78) + +environment = {{ + name: value + for name, value in os.environ.items() + if name not in stored and not SENSITIVE_NAME.search(name) +}} +for name in REQUIRED: + environment[name] = stored[name] +environment.update(TOOL_ENV) + + +def expand_parameter(value): + return SECRET_REFERENCE.sub(lambda match: environment.get(match.group(1), match.group(0)), value) + + +expanded_command = [expand_parameter(value) for value in COMMAND] + +try: + os.execvpe(expanded_command[0], expanded_command + sys.argv[1:], environment) +except FileNotFoundError: + print("Agent Switch wrapper {tool.id} command not found: " + expanded_command[0], file=sys.stderr) + raise SystemExit(127) +''' diff --git a/src/agent_switch/mcp/wrappers.py b/src/agent_switch/mcp/wrappers.py index ec13938..9b6e746 100644 --- a/src/agent_switch/mcp/wrappers.py +++ b/src/agent_switch/mcp/wrappers.py @@ -8,7 +8,7 @@ from agent_switch.config.model import AgentConfig, ToolSpec from agent_switch.mcp.specs import wrapper_path_for from agent_switch.mcp.templates import render_wrapper_script -from agent_switch.paths import AgentPaths +from agent_switch.paths import AgentPaths, ensure_private_dir @dataclass(frozen=True) @@ -36,8 +36,11 @@ def render_wrapper(tool: ToolSpec, secret_file: Path) -> str: def write_wrappers(config: AgentConfig, paths: AgentPaths) -> list[WriteResult]: results: list[WriteResult] = [] - paths.wrapper_dir.mkdir(parents=True, exist_ok=True) - for tool in config.tools: + ensure_private_dir(paths.agent_home) + ensure_private_dir(paths.wrapper_dir) + enabled_tools = tuple(tool for tool in config.tools if tool.enabled) + desired_paths = {wrapper_path_for(tool, paths.wrapper_dir) for tool in enabled_tools} + for tool in enabled_tools: result = write_if_changed( wrapper_path_for(tool, paths.wrapper_dir), render_wrapper(tool, config.secret_file), @@ -45,13 +48,18 @@ def write_wrappers(config: AgentConfig, paths: AgentPaths) -> list[WriteResult]: backup_dir=paths.backup_dir, ) results.append(result) + for stale in sorted(paths.wrapper_dir.glob("mcp-*")): + if stale not in desired_paths and stale.is_file(): + stale.unlink() + results.append(WriteResult(stale, True, None, "")) return results def wrapper_health(config: AgentConfig, wrapper_dir: Path) -> list[WrapperHealth]: health: list[WrapperHealth] = [] for tool in config.tools: + if not tool.enabled: + continue path = wrapper_path_for(tool, wrapper_dir) health.append(WrapperHealth(tool.id, path, path.exists(), os.access(path, os.X_OK))) return health - diff --git a/src/agent_switch/mcp/xcli_server.py b/src/agent_switch/mcp/xcli_server.py index 929b7ba..9d8869e 100644 --- a/src/agent_switch/mcp/xcli_server.py +++ b/src/agent_switch/mcp/xcli_server.py @@ -47,7 +47,7 @@ def build_server() -> StdioMCPServer: string_arg = {"type": "string"} return StdioMCPServer( name="agent-xurl-fallback", - version="0.1.3", + version="0.2.0", tools=[ Tool( name="x_api_get_post", diff --git a/src/agent_switch/reconcile/apply.py b/src/agent_switch/reconcile/apply.py index 31be028..b3cc4a1 100644 --- a/src/agent_switch/reconcile/apply.py +++ b/src/agent_switch/reconcile/apply.py @@ -13,6 +13,7 @@ from agent_switch.renderers.claude_desktop import render_claude_desktop_config from agent_switch.renderers.codex import render_codex_config from agent_switch.renderers.hermes import render_hermes_config +from agent_switch.targets import detected_apps def _ccswitch_apps_match(observed, desired) -> bool: @@ -34,7 +35,8 @@ def apply_reconcile(config: AgentConfig, paths: AgentPaths, *, include_ccswitch: changed = 0 unchanged = 0 - for result in write_instructions(paths): + active_apps = detected_apps(paths) + for result in write_instructions(paths, active_apps): changed += int(result.changed) unchanged += int(not result.changed) @@ -49,6 +51,8 @@ def apply_reconcile(config: AgentConfig, paths: AgentPaths, *, include_ccswitch: ("hermes", paths.hermes_config, render_hermes_config), ) for app, path, renderer in targets: + if app not in active_apps: + continue desired = desired_specs_for_app(config, app, paths.wrapper_dir) rendered = renderer(_read(path), desired) result = write_if_changed(path, rendered, backup_dir=paths.backup_dir) @@ -58,7 +62,17 @@ def apply_reconcile(config: AgentConfig, paths: AgentPaths, *, include_ccswitch: if include_ccswitch and paths.ccswitch_db.exists(): db = CcSwitchDb(paths.ccswitch_db) rows = db.list_mcp_servers() + enabled_ids = { + tool.id + for tool in config.tools + if tool.enabled and (tool.apps.claude or tool.apps.codex or tool.apps.hermes) + } + for server_id in sorted(row_id for row_id in rows if row_id.startswith("agent-") and row_id not in enabled_ids): + db.delete_agent_mcp_server(server_id) + changed += 1 for tool in config.tools: + if not tool.enabled: + continue if not (tool.apps.claude or tool.apps.codex or tool.apps.hermes): continue desired = mcp_spec_for_tool(tool, paths.wrapper_dir) diff --git a/src/agent_switch/reconcile/doctor.py b/src/agent_switch/reconcile/doctor.py index 11581eb..a71037b 100644 --- a/src/agent_switch/reconcile/doctor.py +++ b/src/agent_switch/reconcile/doctor.py @@ -24,11 +24,23 @@ from agent_switch.renderers.hermes import render_hermes_config from agent_switch.security.redaction import redact_mapping, redact_text from agent_switch.security.secrets import SecretReport, check_secrets +from agent_switch.targets import detected_apps Renderer = Callable[[str, dict[str, dict[str, object]]], str] +def _unpinned_npx_package(command: str, args: tuple[str, ...]) -> str | None: + if Path(command).name not in {"npx", "npx.cmd"}: + return None + package = next((arg for arg in args if not arg.startswith("-")), None) + if package is None: + return None + if package.startswith("@"): + return package if "@" not in package[1:] else None + return package if "@" not in package else None + + def _ccswitch_apps_match(observed, desired) -> bool: return ( observed.claude == desired.claude @@ -109,6 +121,7 @@ def _check_text_target(target: str, path: Path, desired: str, changes: list[Plan def run_doctor(config: AgentConfig, paths: AgentPaths, *, include_ccswitch: bool = True) -> DoctorReport: findings: list[DoctorFinding] = [] changes: list[PlanChange] = [] + active_apps = detected_apps(paths) secret_report = check_secrets(config) if secret_report.missing: @@ -120,25 +133,46 @@ def run_doctor(config: AgentConfig, paths: AgentPaths, *, include_ccswitch: bool ) ) + for tool in config.tools: + unpinned = _unpinned_npx_package(tool.command, tool.args) + if unpinned: + findings.append( + DoctorFinding( + "warning", + tool.id, + f"npx package is not version-pinned: {unpinned}", + ) + ) + for item in wrapper_health(config, paths.wrapper_dir): if not item.ok(): changes.append(PlanChange("wrappers", item.path, "write", f"wrapper for {item.tool_id} is missing or not executable")) - - _check_text_target("instructions.codex", paths.codex_instructions, codex_instructions(paths), changes) - _check_text_target("instructions.claude", paths.claude_instructions, claude_instructions(paths), changes) - _check_text_target("instructions.hermes", paths.hermes_instructions, hermes_instructions(paths), changes) - _check_text_target( - "instructions.claude_global", - paths.claude_global_instructions, - merge_managed_block(_read_text(paths.claude_global_instructions), claude_instructions(paths)), - changes, - ) - _check_text_target( - "instructions.hermes_soul", - paths.hermes_soul, - merge_managed_block(_read_text(paths.hermes_soul), hermes_instructions(paths)), - changes, - ) + desired_wrapper_paths = { + paths.wrapper_dir / tool.wrapper_name for tool in config.tools if tool.enabled + } + if paths.wrapper_dir.exists(): + for stale in sorted(paths.wrapper_dir.glob("mcp-*")): + if stale.is_file() and stale not in desired_wrapper_paths: + changes.append(PlanChange("wrappers", stale, "delete", "stale or disabled MCP wrapper")) + + if "codex" in active_apps: + _check_text_target("instructions.codex", paths.codex_instructions, codex_instructions(paths), changes) + if "claude" in active_apps: + _check_text_target("instructions.claude", paths.claude_instructions, claude_instructions(paths), changes) + _check_text_target( + "instructions.claude_global", + paths.claude_global_instructions, + merge_managed_block(_read_text(paths.claude_global_instructions), claude_instructions(paths)), + changes, + ) + if "hermes" in active_apps: + _check_text_target("instructions.hermes", paths.hermes_instructions, hermes_instructions(paths), changes) + _check_text_target( + "instructions.hermes_soul", + paths.hermes_soul, + merge_managed_block(_read_text(paths.hermes_soul), hermes_instructions(paths)), + changes, + ) target_specs = { "claude": (paths.claude_config, render_claude_config), @@ -147,6 +181,8 @@ def run_doctor(config: AgentConfig, paths: AgentPaths, *, include_ccswitch: bool "hermes": (paths.hermes_config, render_hermes_config), } for app, (path, renderer) in target_specs.items(): + if app not in active_apps: + continue _check_target(app, path, renderer, desired_specs_for_app(config, app, paths.wrapper_dir), findings, changes) if include_ccswitch: @@ -154,7 +190,16 @@ def run_doctor(config: AgentConfig, paths: AgentPaths, *, include_ccswitch: bool if paths.ccswitch_db.exists(): try: rows = db.list_mcp_servers() + enabled_ids = { + tool.id + for tool in config.tools + if tool.enabled and (tool.apps.claude or tool.apps.codex or tool.apps.hermes) + } + for server_id in sorted(row_id for row_id in rows if row_id.startswith("agent-") and row_id not in enabled_ids): + changes.append(PlanChange("ccswitch", paths.ccswitch_db, "delete", f"remove stale MCP row {server_id}")) for tool in config.tools: + if not tool.enabled: + continue if not (tool.apps.claude or tool.apps.codex or tool.apps.hermes): continue desired = mcp_spec_for_tool(tool, paths.wrapper_dir) diff --git a/src/agent_switch/security/secrets.py b/src/agent_switch/security/secrets.py index 0effd97..50fb958 100644 --- a/src/agent_switch/security/secrets.py +++ b/src/agent_switch/security/secrets.py @@ -1,7 +1,7 @@ from __future__ import annotations from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field import fcntl import os from pathlib import Path @@ -23,6 +23,7 @@ class SecretReport: missing: tuple[str, ...] present_names: tuple[str, ...] stored_names: tuple[str, ...] = () + consumers: dict[str, tuple[str, ...]] = field(default_factory=dict) def to_dict(self) -> dict[str, object]: return { @@ -32,6 +33,7 @@ def to_dict(self) -> dict[str, object]: "missing": list(self.missing), "presentNames": list(self.present_names), "storedNames": list(self.stored_names), + "consumers": {name: list(tool_ids) for name, tool_ids in sorted(self.consumers.items())}, } @@ -75,7 +77,7 @@ def _read_env_file_unlocked(env_path: Path) -> dict[str, str]: key, raw_value = line.split("=", 1) key = key.strip() value = _decode_env_value(raw_value) - if key: + if SECRET_NAME_RE.fullmatch(key): values[key] = value return values @@ -122,6 +124,12 @@ def _validate_secret_value(value: str) -> None: raise ValueError(f"secret value exceeds the {MAX_SECRET_BYTES}-byte limit") +def validate_secret(name: str, value: str) -> None: + if not SECRET_NAME_RE.fullmatch(name): + raise ValueError(f"invalid secret name: {name}") + _validate_secret_value(value) + + def _ensure_private_directory(path: Path) -> None: path.mkdir(mode=0o700, parents=True, exist_ok=True) path.chmod(0o700) @@ -146,9 +154,7 @@ def _secret_lock(secret_path: Path, *, exclusive: bool = True) -> Iterator[None] def set_secret(path: str | Path, name: str, value: str) -> None: - if not SECRET_NAME_RE.fullmatch(name): - raise ValueError(f"invalid secret name: {name}") - _validate_secret_value(value) + validate_secret(name, value) secret_path = Path(path) _ensure_private_directory(secret_path.parent) with _secret_lock(secret_path): @@ -209,10 +215,14 @@ def list_secret_names(path: str | Path) -> tuple[str, ...]: def check_secrets(config: AgentConfig) -> SecretReport: - required = tuple(sorted({name for tool in config.tools for name in tool.required_secrets})) + required = tuple(sorted({name for tool in config.tools if tool.enabled for name in tool.required_secrets})) values = read_env_file(config.secret_file) missing = tuple(name for name in required if not values.get(name)) present = tuple(sorted(name for name in required if values.get(name))) + consumers = { + name: tuple(sorted(tool.id for tool in config.tools if tool.enabled and name in tool.required_secrets)) + for name in required + } return SecretReport( path=config.secret_file, exists=config.secret_file.exists(), @@ -220,4 +230,5 @@ def check_secrets(config: AgentConfig) -> SecretReport: missing=missing, present_names=present, stored_names=tuple(sorted(name for name, value in values.items() if value)), + consumers=consumers, ) diff --git a/src/agent_switch/status/dashboard.py b/src/agent_switch/status/dashboard.py index 00ae837..51ff402 100644 --- a/src/agent_switch/status/dashboard.py +++ b/src/agent_switch/status/dashboard.py @@ -29,14 +29,19 @@ def _badge(label: str, tone: str = "neutral") -> str: def _tool_enabled_apps(tool: ToolSpec) -> list[str]: + if not tool.enabled: + return [] return [APP_LABELS[app] for app in APP_NAMES if getattr(tool.apps, app)] def _route_roles(config: AgentConfig) -> dict[str, list[str]]: roles: dict[str, list[str]] = {tool.id: [] for tool in config.tools} - roles.setdefault(config.routes.search_default, []).append("Search default") - roles.setdefault(config.routes.x_read_default, []).append("X reader") - roles.setdefault(config.routes.x_read_fallback, []).append("X fallback") + if config.routes.search_default: + roles.setdefault(config.routes.search_default, []).append("Search default") + if config.routes.x_read_default: + roles.setdefault(config.routes.x_read_default, []).append("X reader") + if config.routes.x_read_fallback: + roles.setdefault(config.routes.x_read_fallback, []).append("X fallback") return roles @@ -103,11 +108,12 @@ def _app_rows(config: AgentConfig, paths: AgentPaths, report: DoctorReport, incl for app in APP_NAMES: state, tone = _target_state(report, app) tools = [tool.id for tool in config.tools_for_app(app)] + tool_details = ", ".join(_e(tool) for tool in tools) if tools else 'none' rows.append( "
{_badge('Search default', 'route')} {_e(config.routes.search_default)}
-{_badge('X reader', 'route')} {_e(config.routes.x_read_default)} fallback {_e(config.routes.x_read_fallback)}
+{_badge('Search default', 'route')} {_e(config.routes.search_default or 'Not configured')}
+{_badge('X reader', 'route')} {_e(config.routes.x_read_default or 'Not configured')} fallback {_e(config.routes.x_read_fallback or 'Not configured')}