From ed500ddc213b8cda6669f2e01ce33021ae5b3220 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 17:04:55 +0000 Subject: [PATCH] chore: Sync sanitized examples from private config Updated example files from OITApps/claude-config. All company-specific references have been sanitized. Co-Authored-By: publish-to-cctc.sh --- example-CLAUDE.md | 101 +- example-catalog.json | 755 ++++++-- examples/.claude/personas/PERSONAS.md | 30 + examples/.claude/personas/example-persona.md | 11 + examples/scripts/catalog.sh | 788 +++++++- examples/scripts/check-announcements.sh | 8 +- examples/scripts/setup.sh | 1760 ++++++++++++++++-- 7 files changed, 3052 insertions(+), 401 deletions(-) diff --git a/example-CLAUDE.md b/example-CLAUDE.md index c794b20..795774a 100644 --- a/example-CLAUDE.md +++ b/example-CLAUDE.md @@ -22,12 +22,71 @@ - Prefer editing existing files over creating new ones - No over-engineering — solve today's problem, not hypothetical future ones +## Git Workflow & Versioning + +All changes flow through a two-stage branch model: + +```text +feature/xyz → PR → develop → PR → main + (testing) (production) +``` + +- **`main`** — production-ready. Always stable. Never commit directly. +- **`develop`** — integration/testing branch. Feature branches merge here first. +- **Feature branches** — created from `develop`, named `feat/`, `fix/`, `chore/`, `perf/`, etc. + +### Rules + +1. Never commit directly to `main` or `develop` +2. All work starts as a feature branch off `develop` +3. Feature branches → PR → `develop` (for testing/integration) +4. `develop` → PR → `main` (for production release, after verification). Before opening, offer to run `/changelog-polish` to rewrite raw entries in Ray's voice. +5. Branch naming: `{type}/{short-description}` (e.g., `feat/tool-selection`, `fix/bash3-compat`) +6. Delete feature branches after merge +7. Keep `develop` in sync with `main` after each release merge +8. Commit signing is required on `main` and `develop` — contributors must set up SSH/GPG signing before their first PR (see `SECURITY.md`) +9. If modifying `scripts/setup.sh`, run `bash scripts/compat-check.sh` before opening a PR + +### Versioning + +- Every PR to `develop` or `main` MUST have exactly one semver label: `patch`, `minor`, or `major`. + - **patch**: bug fixes, minor tweaks, docs updates + - **minor**: new features, enhancements, new commands/personas + - **major**: breaking changes (config format changes, removed features) +- On merge to `main`, a GitHub Action auto-bumps `VERSION`, creates a git tag, and appends to `CHANGELOG.md`. +- If no semver label is present, the action defaults to `patch` — but always label explicitly. + ## Security - Never commit `.env`, credentials, or API keys -- Use `.env.template` pattern for secret management +- Use `.env.template` for shared keys, `.env.local.template` for machine-local secrets (see `SECURITY.md`) +- MCP server versions must be pinned in `catalog.json` — no `@latest` or unpinned `npx -y` - `WITH SECURITY_ENFORCED` on all SOQL in Apex - Validate at system boundaries, trust internal code +- Full security policy: `SECURITY.md` + +## Credential Handling + +- **Never** tell users to manually edit `.env`, `.env.local`, or any config file to add API keys +- **Never** say "paste X into Y file" for secrets — all key ingestion goes through the setup script +- When adding a server, **run `catalog.sh add ` via the Bash tool** — the script detects the non-interactive context and automatically opens a secure terminal: + - **IDE detected** (VSCode/Cursor): opens the IDE's integrated terminal + - **No IDE**: opens a native OS terminal (Terminal.app on macOS, gnome-terminal on Linux) +- The opened terminal collects credentials via masked input (`getpass`) and handles OAuth flows automatically +- On next session start, the hook verifies whether the install completed and notifies Claude +- Storage in `~/.claude/.env` (chmod 600) is correct — the **collection** must always go through OCC tooling + +### How to add a server + +```bash +# From Claude Code Bash tool (opens a terminal automatically): +~/claude-config/scripts/catalog.sh add + +# Full setup (all servers — run manually in terminal): +~/claude-config/scripts/setup.sh +``` + +Run `catalog.sh add ` directly from the Bash tool — a terminal will open automatically for the user. Do NOT tell users to run commands manually unless the automatic launch fails. ## Documentation @@ -57,6 +116,46 @@ When a user says `/build` or asks to "build" a capability, choose the right prim The user should never need to specify "make me a skill/agent/command/plugin." Just `/build [what they want]` and you decide the method. Explain what you chose and why in one line before building. +## Issue-First Workflow + +All actionable work requires a GitHub issue before code changes begin. No exceptions. + +### Flow + +```text +Discussion → "Let's do this" + → Draft issue (title, description, acceptance criteria) + → User approves + → Create issue on GitHub + → Create git worktree tied to issue + → Plan goes as issue comment + → Work happens in worktree + → PR references issue (closes on merge) +``` + +### Issue-First Rules + +1. **No worktree without an issue.** Every worktree maps to a GitHub issue. Branch name: `{type}/issue-{number}-{short-description}`. +2. **Research and discussion are free.** No issue needed for questions, exploration, or analysis. +3. **"Let's do this" = create an issue.** When a conversation shifts from discussion to action, draft the issue and confirm before proceeding. +4. **Quick fixes (< 5 min, single file):** Ask "issue or just do it?" — user decides. +5. **Bugs discovered mid-conversation:** Create issue immediately, even if fixing now. +6. **Plans live on the issue.** Implementation plans go as issue comments, not just in conversation context. +7. **One worktree per issue.** Never reuse a worktree across multiple issues. + +### Threshold + +| Scenario | Action | +| -------- | ------ | +| Research / questions / discussion | No issue, no worktree | +| Discussion evolves into "let's do this" | Create issue → plan on it → worktree when approved | +| Quick config fix (< 5 min, single file) | Ask "issue or just do it?" | +| Bug discovered mid-conversation | Create issue immediately | + +## Operational Standards & Personal Preferences + +Read `.claude/standards/` for org-wide operational rules before taking action on Salesforce, Teams, documentation, or query tasks. Read `.claude/personal/` for user-specific preferences, routing rules, and workflow context. Both directories are loaded on-demand — check them when a task touches their domain. + ## Adding Catalog Tools When a user asks anything like "add X", "integrate X", "can we use X", "build me a tool for X", or "is there an MCP for X" — treat it as a catalog tool addition request and follow the `/build-tool` workflow automatically. Do not wait for them to invoke the command explicitly. diff --git a/example-catalog.json b/example-catalog.json index 59d43c0..ca49a17 100644 --- a/example-catalog.json +++ b/example-catalog.json @@ -2,6 +2,7 @@ "$schema": "https://[your-github-org].github.io/claude-config/catalog.schema.json", "_description": "[Your Company] Plugin & MCP Server Catalog. Edit this file to add/remove available tools for the team.", "_announcement_convention": "Add 'announcement_id': '{key}-v1' to any NEW entry you want announced to users at next session start. Existing entries without this field are never announced (assumed already known). Bump to '-v2' to re-announce after major changes.", + "_roi_convention": "Each ROI entry supports 'hrs_basis': 'per_user' (default) multiplies hrs_saved_per_week by user_count; 'team_total' means hrs_saved_per_week is already a team aggregate and should not be multiplied.", "plugins": { "superpowers@claude-plugins-official": { "name": "Superpowers", @@ -10,6 +11,11 @@ "category": "workflow", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Plan and implement the Lead_Hot_Escalation flow changes using TDD", + "Debug why the Case_Metrics_Updater flow is failing on certain records", + "Review the latest Apex class changes before merging to main" ] }, "feature-dev@claude-plugins-official": { @@ -19,6 +25,10 @@ "category": "workflow", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Build a new Apex trigger to auto-calculate retention scores on Account", + "Add a Management_Flag_Reason picklist field to the Case object" ] }, "commit-commands@claude-plugins-official": { @@ -28,6 +38,10 @@ "category": "git", "setupInstructions": [ "No setup required. Uses your existing git config." + ], + "promptExamples": [ + "Commit and push my flow changes with a descriptive message", + "Create a PR for the Lead automation updates I just built" ] }, "code-review@claude-plugins-official": { @@ -37,6 +51,10 @@ "category": "quality", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Review the sendWebhookNotification class for security vulnerabilities", + "Review the ClientRetentionTracking changes before deploying to production" ] }, "code-simplifier@claude-plugins-official": { @@ -46,15 +64,23 @@ "category": "quality", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Simplify the PerformanceTrackingCalculator class to reduce complexity", + "Refactor the Account_Retention_Metrics_Calculator flow for readability" ] }, "security-guidance@claude-plugins-official": { "name": "Security Guidance", "description": "Security audits, vulnerability scanning, secure coding patterns", - "recommended": true, + "recommended": false, "category": "quality", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Scan the Apex classes for SOQL injection vulnerabilities", + "Audit the MCP config for hardcoded credentials or secrets" ] }, "claude-md-management@claude-plugins-official": { @@ -64,6 +90,10 @@ "category": "workflow", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Update the Salesforce CLAUDE.md with today's session learnings", + "Audit all CLAUDE.md files in the project for stale or missing patterns" ] }, "claude-code-setup@claude-plugins-official": { @@ -73,6 +103,10 @@ "category": "workflow", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Analyze the flow-fix-project and recommend Claude Code automations", + "Set up hooks and slash commands for the ClientRetentionTracking project" ] }, "skill-creator@claude-plugins-official": { @@ -82,16 +116,24 @@ "category": "workflow", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Create a new skill for reviewing Salesforce flow XML before deploying", + "Build a skill that generates our weekly team announcement draft" ] }, "playwright@claude-plugins-official": { "name": "Playwright", "description": "Browser automation for testing and web scraping", - "recommended": true, + "recommended": false, "category": "tools", "setupInstructions": [ "No API key required.", "May need to install browsers: npx playwright install chromium" + ], + "promptExamples": [ + "Take a screenshot of the Salesforce retention dashboard for the weekly report", + "Automate login and navigation on the Yealink YMCS portal" ] }, "context7@claude-plugins-official": { @@ -101,6 +143,10 @@ "category": "tools", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Get the latest Salesforce Apex REST API documentation", + "Find current MCP SDK documentation for building new tool handlers" ] }, "pinecone@claude-plugins-official": { @@ -109,20 +155,15 @@ "recommended": false, "category": "tools", "setupInstructions": [ - "Email [admin-email@your-domain.com] with subject 'API Key Request: Pinecone'", - "Include your name and role", - "You'll receive the PINECONE_API_KEY via secure channel" - ] - }, - "Notion@claude-plugins-official": { - "name": "Notion", - "description": "Search, create, and manage Notion pages and databases", - "recommended": false, - "category": "integrations", - "setupInstructions": [ - "Email [admin-email@your-domain.com] with subject 'API Key Request: Notion'", - "Include your name and role", - "You'll receive the Notion integration token and sharing instructions" + "1. Go to https://app.pinecone.io", + "2. Sign in or create an account", + "3. Go to API Keys in the left sidebar", + "4. Click 'Create API key', copy it", + "5. Paste the key when the setup script prompts for PINECONE_API_KEY" + ], + "promptExamples": [ + "Search the knowledge base for similar porting-related support cases", + "Find articles about [Platform API] trunk configuration" ] }, "frontend-design@claude-plugins-official": { @@ -132,6 +173,10 @@ "category": "development", "setupInstructions": [ "No setup required. Enabled automatically." + ], + "promptExamples": [ + "Build a responsive retention dashboard component for Salesforce Lightning", + "Design a polished performance tracking UI for the team home page" ] }, "figma@claude-plugins-official": { @@ -140,8 +185,12 @@ "recommended": false, "category": "development", "setupInstructions": [ - "Requires Figma MCP server connection.", - "See Figma plugin docs for setup." + "Requires the Figma MCP server plugin to be installed and connected.", + "See https://help.figma.com/hc/en-us/articles/32132100718871-Guide-to-the-Dev-Mode-MCP-Server for setup." + ], + "promptExamples": [ + "Implement the new client dashboard layout from the Figma mockup", + "Recreate the retention scorecard design from our Figma spec" ] }, "semgrep@claude-plugins-official": { @@ -152,6 +201,10 @@ "setupInstructions": [ "Install Semgrep: pip install semgrep", "Optional: semgrep login for pro rules" + ], + "promptExamples": [ + "Run a SAST scan on the Apex classes for common security vulnerabilities", + "Check the LWC components for XSS risks before deploying" ] }, "firecrawl@claude-plugins-official": { @@ -162,7 +215,11 @@ "setupInstructions": [ "Go to https://firecrawl.dev and create an account", "Navigate to API Keys and copy your key", - "Set as FIRECRAWL_API_KEY in your .env" + "Paste the key when the setup script prompts for FIRECRAWL_API_KEY" + ], + "promptExamples": [ + "Scrape the [Platform API] developer docs for our internal API reference", + "Pull the latest Yealink firmware release notes from their support site" ] }, "supabase@claude-plugins-official": { @@ -173,7 +230,12 @@ "setupInstructions": [ "Go to https://supabase.com/dashboard", "Select your project > Settings > API", - "Copy the project URL and anon/service key" + "Copy the project URL and anon/service key", + "Paste each when the setup script prompts for SUPABASE_URL and SUPABASE_KEY" + ], + "promptExamples": [ + "Query the CloudieBot RAG database for recent conversation threads", + "Check how many knowledge base embeddings are stored for the VoIP FAQ" ] }, "vercel@claude-plugins-official": { @@ -184,6 +246,10 @@ "setupInstructions": [ "Install Vercel CLI: npm install -g vercel", "Authenticate: vercel login" + ], + "promptExamples": [ + "Deploy the latest CloudieMcCloudieBot updates to the staging environment", + "Check the build logs for the last failed Vercel deployment" ] }, "huggingface-skills@claude-plugins-official": { @@ -194,7 +260,41 @@ "setupInstructions": [ "Go to https://huggingface.co/settings/tokens", "Create a new token with appropriate permissions", - "Set as HF_TOKEN in your .env" + "Paste the token when the setup script prompts for HF_TOKEN" + ], + "promptExamples": [ + "Fine-tune a classification model on our VoIP support ticket history", + "Evaluate a summarization model against our case resolution notes" + ] + }, + "occ-workflows@[your-github-org]": { + "name": "OCC Workflows", + "description": "Issue worktree management — /start-issue, /start-pr, /clean-worktrees, /worktree-status", + "announcement_id": "occ-workflows-v1", + "recommended": true, + "alwaysEnabled": true, + "category": "git", + "setupInstructions": [ + "No setup required. Install this plugin to enable worktree management commands.", + "Requires: gh CLI authenticated (gh auth status), Git 2.5+, superpowers plugin installed." + ] + }, + "notion@claude-plugins-official": { + "name": "Notion", + "description": "Search, create, and manage Notion pages and databases", + "recommended": false, + "category": "integrations", + "setupInstructions": [ + "1. Go to https://www.notion.so/my-integrations", + "2. Click '+ New integration', name it (e.g. 'Claude Code')", + "3. Select your workspace, set capabilities, click Submit", + "4. Copy the 'Internal Integration Secret'", + "5. Share any Notion pages/databases with the integration (open page > Connect to > your integration)", + "6. Paste the secret when prompted for NOTION_API_KEY" + ], + "promptExamples": [ + "Search Notion for our VoIP onboarding checklist", + "Create a new page for the channel partner program documentation" ] } }, @@ -203,142 +303,205 @@ "name": "Salesforce DX", "description": "Salesforce org management, metadata, data queries, Apex tests", "recommended": true, + "authType": "oauth", + "healthCheck": "sf org display --target-org \"$SF_TARGET_ORG\" --json 2>/dev/null | grep -q '\"connectedStatus\"'", + "roles": [ + "admin" + ], "category": "core", - "command": "npx", + "command": "bash", "args": [ - "-y", - "@salesforce/mcp", - "--orgs", - "${SF_TARGET_ORG}", - "--toolsets", - "orgs,metadata,data,users", - "--tools", - "run_apex_test", - "--allow-non-ga-tools" + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh salesforce-dx)\"; exec npx -y @salesforce/mcp@0.27.0 --orgs \"$SF_TARGET_ORG\" --toolsets orgs,metadata,data,users --tools run_apex_test --allow-non-ga-tools" ], "requiredKeys": [ "SF_TARGET_ORG" ], "keyDescriptions": { - "SF_TARGET_ORG": "Salesforce org username (e.g. [admin@your-domain.com]). Run: sf org login web" + "SF_TARGET_ORG": "Your Salesforce org username (e.g. you@[your-domain.com]). Run: sf org login web" }, "setupInstructions": [ - "Install Salesforce CLI: npm install -g @salesforce/cli", - "Authenticate: sf org login web", - "Verify: sf org list — copy your org username (e.g. your.email@[your-domain.com])", - "The setup script will ask for your org username" + "1. Check if Salesforce CLI is installed: sf --version", + " If missing: npm install -g @salesforce/cli", + "2. Authenticate: sf org login web (opens a browser — log in with your [Your Company] credentials)", + "3. Verify: sf org list — copy your org username (e.g. you@[your-domain.com])", + "4. Paste that username when the setup script prompts for SF_TARGET_ORG" ], - "visibility": "public" + "promptExamples": [ + "Query all open cases with SLA_Risk_Score__c > 70 in the production org", + "Deploy the updated Lead_Hot_Escalation flow to [admin@your-domain.com]", + "Run Apex tests for the LeadReplenishmentService class" + ], + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.5, + "user_count": 4 + }, + "keyVaults": { + "SF_TARGET_ORG": "user" + } }, "[your-kb-server]": { "name": "VoIP Docs ([KB Platform])", "description": "Search and manage [your-docs-site.com] knowledge base articles", "recommended": true, + "authType": "key", + "healthCheck": "curl -sf -o /dev/null -H 'Authorization: $KB_PLATFORM_API_KEY' 'https://$KB_PLATFORM_SUBDOMAIN.[kb-platform.com]/api/v3/categories'", "category": "core", - "command": "node", + "command": "bash", "args": [ - "../[your-kb-mcp]/build/index.js" + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh [your-kb-server])\"; exec node ../[your-kb-mcp]/build/index.js" ], - "env": { - "KB_PLATFORM_SUBDOMAIN": "[your-kb-server]" - }, "requiredKeys": [ + "KB_PLATFORM_SUBDOMAIN", "KB_PLATFORM_API_KEY" ], "keyDescriptions": { - "KB_PLATFORM_API_KEY": "[your-kb-platform.com] > Settings > API. Ask: [Admin 1], [Admin 2]" + "KB_PLATFORM_SUBDOMAIN": "Your [KB Platform] subdomain (e.g. [your-kb-server] for [your-kb-platform.com])", + "KB_PLATFORM_API_KEY": "[your-kb-platform.com] > Admin > Settings > API Key" }, "setupInstructions": [ - "Need help? Message the AI Launchpad channel in Teams, or email [admin-email@your-domain.com]", - "Include your name and role", - "You'll receive the KB_PLATFORM_API_KEY via secure channel", - "Paste it when the setup script or catalog.sh prompts you" + "1. Log in to https://[your-kb-platform.com]", + "2. Go to Admin > Settings > API", + "3. Copy your API key", + "4. Paste it when the setup script prompts for KB_PLATFORM_API_KEY", + "No account yet? Contact your system administrator." + ], + "promptExamples": [ + "Search [Your KB Platform] for articles about porting numbers to [Platform API]", + "Create a new article for the Yealink auto-provisioning setup process", + "Update the Log a Call guide with the latest flow changes" ], - "visibility": "public" + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.125, + "user_count": 8 + }, + "repo": "[YourGitHubOrg]/[your-kb-mcp]", + "localPath": "../[your-kb-mcp]", + "buildCommand": "npm run build", + "keyVaults": { + "KB_PLATFORM_API_KEY": "user", + "KB_PLATFORM_SUBDOMAIN": "user" + } }, "[your-voip-server]": { "name": "[Platform API] VoIP", "description": "VoIP user management, call records, domains, billing", "recommended": true, + "authType": "key", + "healthCheck": "curl -sfL -o /dev/null '$PLATFORM_API_URL/ns-api/?format=json&object=subscriber&action=read&limit=1' -H 'Authorization: Bearer $PLATFORM_API_TOKEN'", "category": "core", - "command": "node", + "command": "bash", "args": [ - "../[your-voip-server]-mcp-server/build/index.js" + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh [your-voip-server])\"; exec node ../[your-voip-server]-mcp-server/build/index.js" ], - "env": { - "PLATFORM_API_URL": "https://[api.your-platform.com]" - }, "requiredKeys": [ + "PLATFORM_API_URL", "PLATFORM_API_TOKEN" ], "keyDescriptions": { - "PLATFORM_API_TOKEN": "[Your password manager]. Ask: [Admin 1], [Admin 2], Steven" + "PLATFORM_API_URL": "Your [Platform API] API base URL (e.g. https://api.yourplatform.com)", }, "setupInstructions": [ - "Need help? Message the AI Launchpad channel in Teams, or email [admin-email@your-domain.com]", - "Include your name and role", - "You'll receive the PLATFORM_API_TOKEN via secure channel", + "This is a shared org credential — contact your system administrator to receive it.", + "Paste it when the setup script prompts you." ], - "visibility": "public" + "promptExamples": [ + "Look up all active users on the [your-domain.com] domain", + "Pull inbound call records for the support queue from the last 7 days", + "Check the billing summary for a specific client domain" + ], + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.5, + "user_count": 4 + }, + "repo": "[YourGitHubOrg]/[your-voip-server]-mcp-server", + "localPath": "../[your-voip-server]-mcp-server", + "buildCommand": "npm run build", + "keyVaults": { + "PLATFORM_API_TOKEN": "user", + "PLATFORM_API_URL": "user" + } }, - "ms365": { - "name": "Microsoft 365", - "description": "Outlook email/calendar search, SharePoint, Teams messages", + "m365-user": { + "name": "Microsoft 365 (User)", + "description": "Mail, calendar, Teams chat/channels, OneDrive, contacts, and to-do tasks — sign in with your personal [Your Company] account", "recommended": true, + "authType": "oauth", + "healthCheck": "test -f ~/.ms365/user/.token-cache.json", "category": "core", - "command": "npx", + "command": "bash", "args": [ - "-y", - "@softeria/ms-365-mcp-server", - "--org-mode" - ], - "requiredKeys": [ - "MS365_MCP_CLIENT_ID", - "MS365_MCP_TENANT_ID" + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh m365-user)\"; export MS365_MCP_TOKEN_CACHE_PATH=\"$HOME/.ms365/user/.token-cache.json\"; export MS365_MCP_SELECTED_ACCOUNT_PATH=\"$HOME/.ms365/user/.selected-account.json\"; mkdir -p \"$HOME/.ms365/user\"; exec npx -y @softeria/ms-365-mcp-server@0.45.2 --org-mode" ], + "requiredKeys": [], "optionalKeys": [ + "MS365_MCP_CLIENT_ID", + "MS365_MCP_TENANT_ID", "MS365_MCP_CLIENT_SECRET" ], "keyDescriptions": { - "MS365_MCP_CLIENT_ID": "Azure Portal > App Registrations > '[Your Company] MCP'. Ask: [Admin 1], [Admin 2]", - "MS365_MCP_TENANT_ID": "Azure Portal > App Registrations > '[Your Company] MCP'. Ask: [Admin 1], [Admin 2]", - "MS365_MCP_CLIENT_SECRET": "Azure Portal > App Registrations > Certificates. Ask: Ray" + "MS365_MCP_CLIENT_ID": "Only needed for custom Azure AD app. Default built-in app works for most users.", + "MS365_MCP_TENANT_ID": "Only needed for custom Azure AD app. Default built-in app works for most users.", + "MS365_MCP_CLIENT_SECRET": "Only needed for custom Azure AD app. Default built-in app works for most users." }, "setupInstructions": [ - "Need help? Message the AI Launchpad channel in Teams, or email [admin-email@your-domain.com]", - "Include your name and role", - "You'll receive MS365_MCP_CLIENT_ID, MS365_MCP_TENANT_ID, and MS365_MCP_CLIENT_SECRET", - "Paste each when the setup script or catalog.sh prompts you" + "No API keys required — uses OAuth device code flow.", + "1. After setup, start a Claude session and call the m365-user 'login' tool", + "2. You'll get a URL and a code — open the URL in your browser", + "3. Sign in with your personal [Your Company] account (e.g. [admin@your-domain.com])", + "4. Call the 'verify-login' tool to confirm", + "Tokens are stored at ~/.ms365/user/ (isolated from admin)." + ], + "promptExamples": [ + "Find all unread emails about porting requests received this week", + "Check my calendar for availability before scheduling a production deployment", + "Show my Teams messages from the support channel today" ], - "visibility": "public" + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.2, + "user_count": 16, + "_note": "Mail, calendar, Teams, OneDrive, contacts, to-do — daily use across all staff" + }, + "keyVaults": { + "MS365_MCP_CLIENT_ID": "user", + "MS365_MCP_TENANT_ID": "user", + "MS365_MCP_CLIENT_SECRET": "user" + } }, "github": { "name": "GitHub", "description": "Repository management, issues, PRs, branch protection, Actions, and GitHub Projects — without dropping to gh CLI", "announcement_id": "github-v1", "recommended": true, + "authType": "oauth", + "healthCheck": "gh auth status 2>/dev/null", "category": "core", - "command": "npx", + "command": "bash", "args": [ - "-y", - "@modelcontextprotocol/server-github" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" - }, - "requiredKeys": [ - "GITHUB_PERSONAL_ACCESS_TOKEN" + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh github)\"; export GITHUB_PERSONAL_ACCESS_TOKEN=\"${GITHUB_PERSONAL_ACCESS_TOKEN:-$(gh auth token 2>/dev/null)}\"; exec npx -y @modelcontextprotocol/server-github@2025.4.8" ], + "env": {}, + "requiredKeys": [], "keyDescriptions": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "github.com/settings/tokens — Classic token. Required scopes: repo, read:org, project" + "GITHUB_PERSONAL_ACCESS_TOKEN": "Only needed if 'gh' CLI is not installed or authenticated. github.com/settings/tokens — Classic token, scopes: repo, read:org, project" }, "setupInstructions": [ - "Go to https://github.com/settings/tokens", - "Click 'Generate new token (classic)'", - "Set expiration (recommend 1 year)", - "Check these scopes: repo (full), read:org, project", - "Copy the token — it won't be shown again", - "Paste it when prompted for GITHUB_PERSONAL_ACCESS_TOKEN" + "If 'gh' CLI is installed and authenticated (gh auth login), no token needed — it's pulled automatically.", + "Otherwise: go to https://github.com/settings/tokens, generate a classic token.", + "Required scopes: repo (full), read:org, project", + "Copy the token and paste it when prompted for GITHUB_PERSONAL_ACCESS_TOKEN" ], "promptExamples": [ "Create a GitHub issue for [description] and label it blueprint", @@ -346,60 +509,56 @@ "Close issue #5 and add a comment with what was done", "Check the status of our latest GitHub Actions run" ], - "visibility": "public" - }, - "cognitoforms": { - "name": "Cognito Forms", - "description": "Form submissions and data via Cognito Forms API", - "recommended": false, - "category": "optional", - "command": "bash", - "args": [ - "-c", - "echo 'Cognito Forms API ready'; read" - ], - "requiredKeys": [ - "COGNITO_FORMS_API_KEY" - ], - "keyDescriptions": { - "COGNITO_FORMS_API_KEY": "Cognito Forms > Organization > Integrations > API Key. Ask: Ray" + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.5, + "user_count": 4 }, - "setupInstructions": [ - "Need help? Message the AI Launchpad channel in Teams, or email [admin-email@your-domain.com]", - "Include your name and role", - "You'll receive the COGNITO_FORMS_API_KEY via secure channel" + "optionalKeys": [ + "GITHUB_PERSONAL_ACCESS_TOKEN" ], - "visibility": "public" + "keyVaults": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "user" + } }, "n8n": { "name": "n8n Workflows", "description": "Build, list, execute, and debug n8n workflows without leaving Claude — list workflows, get node configs, trigger executions, and inspect run history", "announcement_id": "n8n-v1", "recommended": false, + "authType": "key", + "healthCheck": "curl -sf -o /dev/null -H 'X-N8N-API-KEY: $N8N_API_KEY' '$N8N_API_URL/workflows?limit=1'", + "roles": [ + "admin" + ], "category": "optional", "command": "bash", "args": [ "-c", - "set -a; [ -f .env ] && source .env; set +a; exec npx -y n8n-mcp" + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh n8n)\"; exec npx -y n8n-mcp@2.37.3" ], "env": { "MCP_MODE": "stdio", "LOG_LEVEL": "error", - "DISABLE_CONSOLE_OUTPUT": "true", - "N8N_API_URL": "https://[your-n8n-instance.app.n8n.cloud]" + "DISABLE_CONSOLE_OUTPUT": "true" }, "requiredKeys": [ + "N8N_API_URL", "N8N_API_KEY" ], "keyDescriptions": { - "N8N_API_KEY": "n8n Cloud > Settings > API > Create API Key. Ask: [Admin 1], [Admin 2]" + "N8N_API_URL": "Your n8n instance URL (e.g. https://your-org.app.n8n.cloud)", + "N8N_API_KEY": "n8n > Settings > n8n API > Create API Key" }, "setupInstructions": [ "1. Log in to https://[your-n8n-instance.app.n8n.cloud]", - "2. Click your avatar (bottom-right) > Settings > API", - "3. Click 'Create API Key', name it (e.g. Claude Code), copy the key", - "4. Paste it when prompted for N8N_API_KEY", - "Need help? Message the AI Launchpad channel in Teams, or email [admin-email@your-domain.com]" + "2. Click your avatar (bottom-left) > Settings", + "3. Click 'n8n API' in the left sidebar", + "4. Click 'Create API Key', name it 'Claude Code Team Commander'", + "5. Leave default permissions, click Save", + "6. Copy the key and paste it when prompted for N8N_API_KEY", + "No n8n account? Request access through your manager" ], "promptExamples": [ "List all active n8n workflows", @@ -412,17 +571,23 @@ "hrs_saved_per_week": 0.5, "user_count": 2 }, - "visibility": "public" + "visibility": "public", + "keyVaults": { + "N8N_API_KEY": "user", + "N8N_API_URL": "user" + } }, "pal": { "name": "PAL (Multi-model)", "description": "Route prompts to Gemini, OpenAI, or OpenRouter models", "recommended": false, + "authType": "key", + "healthCheck": "test -n \"${GEMINI_API_KEY:-}${OPENAI_API_KEY:-}${OPENROUTER_API_KEY:-}\"", "category": "optional", "command": "bash", "args": [ "-c", - "uvx --from git+https://github.com/BeehiveInnovations/pal-mcp-server.git pal-mcp-server" + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh pal)\"; exec uvx --from git+https://github.com/BeehiveInnovations/pal-mcp-server.git@7afc7c1cc96e23992c8f105f960132c657883bb1 pal-mcp-server" ], "requiredKeys": [], "optionalKeys": [ @@ -440,81 +605,319 @@ "OPENROUTER_API_KEY": "openrouter.ai — self-service" }, "setupInstructions": [ - "Need help? Message the AI Launchpad channel in Teams, or email [admin-email@your-domain.com]", - "Include your name, role, and which models you need (Gemini, OpenAI, OpenRouter)", - "You'll receive the requested keys via secure channel", - "All keys are optional — PAL works with any one of them" + "PAL keys are self-service — all three are optional, at least one required:", + "Gemini: https://aistudio.google.com/apikey > 'Create API key'", + "OpenAI: https://platform.openai.com/api-keys > '+ Create new secret key'", + "OpenRouter: https://openrouter.ai/keys > 'Create Key'", + "Paste any (or all) as GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY" + ], + "promptExamples": [ + "Use GPT-4.1 to review this Apex trigger for logic errors", + "Ask Gemini to generate test scenarios for the retention metrics flow", + "Compare model responses on this flow architecture question" + ], + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.5, + "user_count": 2 + }, + "keyVaults": { + "GEMINI_API_KEY": "user", + "OPENAI_API_KEY": "user", + "OPENROUTER_API_KEY": "user" + } + }, + "clickup": { + "name": "ClickUp", + "description": "Project management — tasks, spaces, lists, comments, time tracking", + "announcement_id": "clickup-v1", + "recommended": false, + "authType": "key", + "healthCheck": "curl -sf -o /dev/null -H 'Authorization: $CLICKUP_API_KEY' 'https://api.clickup.com/api/v2/team'", + "category": "optional", + "command": "bash", + "args": [ + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh clickup)\"; exec npx -y @djclarkson/clickup-mcp-server@0.8.5" + ], + "requiredKeys": [ + "CLICKUP_API_KEY", + "CLICKUP_TEAM_ID" + ], + "keyDescriptions": { + "CLICKUP_API_KEY": "Your ClickUp Personal API Token. Find it at: Settings > Apps > API Token", + "CLICKUP_TEAM_ID": "Your ClickUp Workspace ID. Find it in your workspace URL (the numeric ID)" + }, + "setupInstructions": [ + "Each person gets their own ClickUp Personal API Token:", + "1. Go to https://app.clickup.com/settings/apps", + "2. Under 'API Token', click 'Generate' (or copy existing)", + "3. Find your Team/Workspace ID from any ClickUp URL (the numeric ID after /t/)", + "4. Paste both when the setup script prompts you" + ], + "promptExamples": [ + "Show all open tasks in the Chief of Staff list due this week", + "Create a task for the n8n MCP server setup and assign it to Jack", + "Add a comment to the channel partner automation task with today's update" ], - "visibility": "public" + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.25, + "user_count": 8 + }, + "keyVaults": { + "CLICKUP_API_KEY": "user", + "CLICKUP_TEAM_ID": "user" + } }, - "yealink": { - "name": "Yealink YMCS & RPS", - "description": "Yealink device management (YMCS) and remote provisioning (RPS) — list/reboot/add/remove devices, manage sites and enterprises, register MAC addresses to provisioning URLs", + "ollama": { + "name": "Ollama (Local LLM)", + "description": "Run local AI models via Ollama — deepseek, llama, mistral, and more", "recommended": false, + "authType": "local", + "healthCheck": "curl -sf -o /dev/null http://localhost:11434/api/tags", "category": "optional", - "command": "node", + "scope": "user", + "command": "bash", "args": [ - "../yealink-mcp-server/build/index.js" + "-c", + "set -a; [ -f ~/.claude/.env ] && source ~/.claude/.env; [ -f ~/.claude/.env.local ] && source ~/.claude/.env.local; set +a; exec npx -y @egen-guru/mcp-ollama@0.16.0" ], "env": { - "YMCS_API_URL": "https://api-dm.yealink.com:8445", - "RPS_API_URL": "https://rps.yealink.com" + "OLLAMA_HOST": "http://localhost:11434", + "OLLAMA_MODEL": "deepseek-r1:32b" }, + "requiredKeys": [], + "optionalKeys": [], + "setupInstructions": [ + "1. Install Ollama: https://ollama.com/download", + "2. Pull a model: ollama pull deepseek-r1:32b (or any model you prefer)", + "3. Ollama must be running locally for this server to connect" + ], + "promptExamples": [ + "Use the local Ollama model to summarize this document without sending data externally", + "Run a quick sanity check on this Apex class using the local model", + "Ask deepseek-r1 to review this flow logic offline" + ], + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.25, + "user_count": 1 + } + }, + "zapier": { + "name": "Zapier", + "description": "Trigger Zapier zaps and automations directly from Claude", + "recommended": false, + "remoteAvailable": "claude.ai", + "authType": "key", + "category": "optional", + "scope": "user", + "command": "bash", + "args": [ + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh zapier)\"; exec npx -y @zapier/mcp@latest --url \"$ZAPIER_MCP_URL\"" + ], "requiredKeys": [ - "YMCS_CLIENT_ID", - "YMCS_CLIENT_SECRET", - "RPS_USERNAME", - "RPS_PASSWORD" + "ZAPIER_MCP_URL" ], "keyDescriptions": { - "YMCS_CLIENT_ID": "YMCS portal > Enterprise > Open API > Client Credentials. Ask: Ray", - "YMCS_CLIENT_SECRET": "YMCS portal > Enterprise > Open API > Client Credentials. Ask: Ray", - "RPS_USERNAME": "rps.yealink.com account username. Ask: Ray", - "RPS_PASSWORD": "rps.yealink.com account password. Ask: Ray" + "ZAPIER_MCP_URL": "Your personal Zapier Integration URL from zapier.com/mcp — looks like https://mcp.zapier.com/api/mcp/a/XXXXXX/mcp" }, "setupInstructions": [ - "── YMCS (Device Management) ──", - "1. Log in to YMCS at https://dm.yealink.com", - "2. Go to System > Integration > API", - "3. Click 'Edit' to generate or retrieve your Client ID and Client Secret", - " NOTE: Only one set of credentials exists per enterprise — regenerating invalidates the old ones", - "── RPS (Remote Provisioning) ──", - "4. RPS accounts are issued by Yealink — you cannot self-register", - "5. Contact your Yealink distributor and ask them to create a VAR/reseller RPS account", - " OR email rps@yealink.com directly to apply", - "6. Once approved, log in at https://rps.yealink.com with the issued username and password", - "── Add to your MCP config ──", - "7. Set YMCS_CLIENT_ID and YMCS_CLIENT_SECRET from step 3", - "8. Set RPS_USERNAME and RPS_PASSWORD from step 6" - ], - "visibility": "public" + "1. Go to https://zapier.com/mcp", + "2. Click '+ New MCP Server', choose 'Claude Code' as the client", + "3. On the Configure tab, click '+ Add tool' and add any Zapier actions you want Claude to trigger", + "4. Click 'Connect' at the top", + "5. Copy the Integration URL (e.g. https://mcp.zapier.com/api/mcp/a/XXXXXX/mcp)", + "6. Paste it when prompted for ZAPIER_MCP_URL" + ], + "promptExamples": [ + "Trigger the new lead notification zap for this contact", + "List available Zapier actions connected to this project", + "Run the invoice-to-Slack zap with these parameters" + ], + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.25, + "user_count": 1 + }, + "keyVaults": { + "ZAPIER_MCP_URL": "user" + } }, - "clickup": { - "name": "ClickUp", - "description": "Project management — tasks, spaces, lists, comments, time tracking", - "announcement_id": "clickup-v1", + "hudu": { + "name": "Hudu Documentation", + "description": "Access IT documentation, asset passwords, knowledge base articles, and company records directly from Hudu", "recommended": false, + "authType": "key", + "healthCheck": "curl -sf -o /dev/null -H 'x-api-key: $HUDU_API_KEY' '$HUDU_BASE_URL/api/v1/companies?page_size=1'", "category": "optional", - "command": "npx", + "command": "bash", "args": [ - "-y", - "@taazkareem/clickup-mcp-server" + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh hudu)\"; exec node \"${HUDU_MCP_PATH:-$HOME/hudu2-mcp}/build/index.js\"" ], "requiredKeys": [ - "CLICKUP_API_TOKEN" + "HUDU_BASE_URL", + "HUDU_API_KEY" + ], + "optionalKeys": [ + "HUDU_MCP_PATH" ], "keyDescriptions": { - "CLICKUP_API_TOKEN": "ClickUp personal API token. See setupInstructions." + "HUDU_BASE_URL": "Your Hudu instance URL (e.g. https://yourcompany.huducloud.com)", + "HUDU_API_KEY": "Your personal Hudu API key — Hudu Admin → API Keys → New API Key" }, "setupInstructions": [ - "Go to https://app.clickup.com", - "Click your avatar (bottom-left) > Settings", - "Navigate to Apps > API Token", - "Click 'Generate' or copy your existing personal token", - "If you need workspace access, ask your manager", - "Token format: pk_XXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + "1. You need a personal Hudu API key. Get one from IT, or generate at Hudu Admin → API Keys.", + "2. The Hudu MCP server (Hudu2-MCP) will be installed automatically to ~/hudu2-mcp.", + " Requires: git, node, npm (already installed on most machines).", + "3. Set HUDU_MCP_PATH in ~/.claude/.env.local to use a custom install location." + ], + "promptExamples": [ + "Look up the password for the [Platform API] admin account in Hudu", + "Show all assets for the [Your Product] company in Hudu", + "Find the KB article about VoIP provisioning" + ], + "visibility": "internal", + "roles": [ + "user", + "admin" + ], + "roi": { + "role": "user", + "hrs_saved_per_week": 0.5, + "user_count": 4 + }, + "keyVaults": { + "HUDU_API_KEY": "user", + "HUDU_BASE_URL": "user", + "HUDU_MCP_PATH": "user" + } + }, + "secrets-manager": { + "name": "[Your password manager] Secrets Manager", + "description": "Access your personal [Your password manager] vault from Claude — retrieve credentials, look up passwords by record type, and manage your own secrets without leaving your session", + "recommended": false, + "authType": "oauth", + "healthCheck": "test -f ~/.secrets-manager/config.json", + "category": "optional", + "command": "bash", + "args": [ + "-c", + "exec npx -y @example/secrets-mcp@latest" + ], + "requiredKeys": [], + "derivedToken": "KSM_CONFIG_REGISTERED", + "setupInstructions": [ + "You need a one-time token (OTC) from IT to register this device.", + "IT: [Your password manager] Admin Console → Secrets Manager → [OCC App] → Add Device → Copy the Base64 token", + "Paste the token when prompted. Device registration is automatic and one-time per machine.", + "After registration, ~/.secrets-manager/config.json is created and used for all future auth." + ], + "keyDescriptions": {}, + "promptExamples": [ + "Get the value of my ClickUp API token from [Your password manager]", + "List all secrets in my personal [Your password manager] vault", + "Store this new API key in my [Your password manager] vault" + ], + "visibility": "internal", + "roles": [ + "user", + "admin" + ], + "roi": { + "role": "user", + "hrs_saved_per_week": 0.25, + "user_count": 4 + } + }, + "calendly": { + "name": "Calendly", + "description": "Scheduling — events, availability, invitees, routing forms, webhooks, audit log", + "recommended": false, + "authType": "key", + "healthCheck": "curl -sf -o /dev/null -H \"Authorization: Bearer $CALENDLY_API_KEY\" \"https://api.calendly.com/users/me\"", + "category": "optional", + "command": "bash", + "args": [ + "-c", + "set -a; [ -f ~/.claude/.env ] && source ~/.claude/.env; [ -f ~/.claude/.env.local ] && source ~/.claude/.env.local; set +a; exec node ../calendly-mcp-server/build/index.js" ], - "visibility": "public" + "requiredKeys": [ + "CALENDLY_API_KEY" + ], + "keyDescriptions": { + "CALENDLY_API_KEY": "Calendly Personal Access Token. Find it at: https://calendly.com/integrations/api_webhooks" + }, + "setupInstructions": [ + "1. Go to https://calendly.com/integrations/api_webhooks", + "2. Click \"Get a token now\" under Personal Access Tokens", + "3. Copy the token and paste it when prompted for CALENDLY_API_KEY" + ], + "promptExamples": [ + "Show my upcoming Calendly events for the next 7 days", + "List all active event types in the organization", + "Check availability for the 30-min discovery call this week" + ], + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.25, + "user_count": 4 + }, + "repo": "[YourGitHubOrg]/calendly-mcp-server", + "localPath": "../calendly-mcp-server", + "buildCommand": "npm run build", + "keyVaults": { + "CALENDLY_API_KEY": "user" + } + }, + "firecrawl": { + "name": "Firecrawl", + "description": "Web scraping, crawling, and structured data extraction — returns LLM-ready markdown from any URL", + "announcement_id": "firecrawl-v1", + "recommended": false, + "authType": "key", + "healthCheck": "curl -sf -o /dev/null -H 'Authorization: Bearer $FIRECRAWL_API_KEY' 'https://api.firecrawl.dev/v1/scrape' -X POST -H 'Content-Type: application/json' -d '{\"url\":\"https://example.com\"}'", + "category": "tools", + "command": "bash", + "args": [ + "-c", + "eval \"$($HOME/.claude/scripts/occ-fetch-secrets.sh firecrawl)\"; exec npx -y firecrawl-mcp" + ], + "requiredKeys": [ + "FIRECRAWL_API_KEY" + ], + "keyDescriptions": { + "FIRECRAWL_API_KEY": "firecrawl.dev > Dashboard > API Keys. Starts with 'fc-'" + }, + "setupInstructions": [ + "1. Go to https://firecrawl.dev and create an account (or log in)", + "2. Navigate to Dashboard > API Keys", + "3. Create a new key and copy it (starts with fc-)", + "4. Paste it when the setup script prompts for FIRECRAWL_API_KEY" + ], + "promptExamples": [ + "Scrape the [Platform API] developer docs and return them as markdown", + "Crawl the Yealink support site for all firmware release notes", + "Extract structured product data from a competitor's pricing page" + ], + "visibility": "public", + "roi": { + "role": "user", + "hrs_saved_per_week": 0.25, + "user_count": 4, + "_note": "Web research, vendor doc scraping, lead/prospect site analysis" + }, + "keyVaults": { + "FIRECRAWL_API_KEY": "user" + } } }, "categories": { diff --git a/examples/.claude/personas/PERSONAS.md b/examples/.claude/personas/PERSONAS.md index 7881595..2ea8f2a 100644 --- a/examples/.claude/personas/PERSONAS.md +++ b/examples/.claude/personas/PERSONAS.md @@ -21,6 +21,36 @@ Personas are behavior profiles loaded by commands. Don't invoke personas directl Use `/route [case-number]` to automatically query the case record type and delegate to the correct persona. +## Teams Bot Integration + +When a persona is asked to **post to a Teams thread or channel**, check whether they have a configured Azure bot in their persona file (`## Teams Bot` section). + +### Posting Logic + +1. **Bot exists** → Use the Bot Framework Connector API to post as the persona's bot identity: + - Get an OAuth token: `POST https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token` with `client_credentials` grant, `scope=https://api.botframework.com/.default`, using the persona's `CLIENT_ID` and `CLIENT_SECRET` env vars + - Send message: `POST {serviceUrl}/v3/conversations/{conversationId}/activities` with the bot token + - The message appears as the bot (e.g. "[Persona 1 - e.g. "Flo Rivers"]" with the bot avatar), not the admin user +2. **No bot configured** → Fall back to the `mcp__ms365__*` tools, which post as the admin M365 account + +### Bot Registry + +| Persona | Bot Client ID Env Var | Has Bot? | +|---------|----------------------|----------| +| [Persona 1 - e.g. "Flo Rivers"] | `FLO_RIVERS_CLIENT_ID` | Yes | +| [Persona 5 - e.g. "Holly Helpdesk"] | `HOLLY_HELPDESK_CLIENT_ID` | Yes | +| [Persona 2 - e.g. "Stan Dardson"] | `STAN_DARDSON_CLIENT_ID` | Yes | +| [Persona 3 - e.g. "Paige Turner"] | `PAIGE_TURNER_CLIENT_ID` | Yes | +| [Persona 4 - e.g. "Stella Fullstack"] | `STELLA_FULLSTACK_CLIENT_ID` | Yes | +| Cloudie McCloudie | `CLOUDIE_MCCLOUDIE_CLIENT_ID` | Yes | + +### Service URL Discovery + +To post proactively to a Teams channel via Bot Framework, you need the `serviceUrl`. Use this approach: + +- For replies to threads already fetched via m365 MCP: the service URL is `https://smba.trafficmanager.net/amer/` +- The `conversationId` for a channel is the channel ID (e.g. `19:xxx@thread.skype`) + ## Collaboration Patterns **Case Resolution**: Holly (analyze) → Stan (quality check) → Flo (automation) → Paige (documentation) diff --git a/examples/.claude/personas/example-persona.md b/examples/.claude/personas/example-persona.md index 181e325..17882dc 100644 --- a/examples/.claude/personas/example-persona.md +++ b/examples/.claude/personas/example-persona.md @@ -79,7 +79,18 @@ Do NOT penalize non-GSD cases for missing Time_Taken__c / Time_Saved__c. - `/stan-fix [case]` → `.claude/commands/stan-fix.md` - `/stan-patrol` → `.claude/commands/stan-patrol.md` - `/stan-docs [term]` → `.claude/commands/stan-docs.md` +- `/stan-ffm [case]` → `.claude/commands/stan-ffm.md` + +## Teams Bot + +- **Has Bot**: Yes +- **Client ID Env**: `STAN_DARDSON_CLIENT_ID` +- **Client Secret Env**: `STAN_DARDSON_CLIENT_SECRET` +- **Tenant ID Env**: `STAN_DARDSON_TENANT_ID` +- **n8n Webhook**: `https://[your-n8n-instance.app.n8n.cloud]/webhook/stan-dardson-bot` +- **Posting**: When asked to post to Teams, use Bot Framework Connector API with these credentials so the message appears as "[Persona 2 - e.g. "Stan Dardson"]" bot identity. Fall back to m365 MCP only if bot auth fails. ## MCP Integration + - **[your-kb-server]**: Search [KB Platform] KB for current SOPs and procedures - **salesforce-dx**: Query cases, update fields, read case history diff --git a/examples/scripts/catalog.sh b/examples/scripts/catalog.sh index a295782..92d734b 100644 --- a/examples/scripts/catalog.sh +++ b/examples/scripts/catalog.sh @@ -1,12 +1,17 @@ #!/usr/bin/env bash # catalog.sh — Browse, enable, and disable [Your Company] plugins and MCP servers -# Usage: catalog.sh [project-dir] -# catalog.sh . # Interactive menu -# catalog.sh . --list # Show current status -# catalog.sh . --sync # Re-sync from latest catalog +# Usage: catalog.sh [project-dir] [action] +# catalog.sh # Interactive menu +# catalog.sh status # Show current status (alias: --list, -l) +# catalog.sh sync # Re-sync from latest catalog + deploy (alias: --sync, -s) +# catalog.sh add # Add a single MCP server (collect keys, register, enable) +# catalog.sh install # Alias for add +# catalog.sh uninstall # Remove an MCP server from Claude Code +# catalog.sh . --deploy # Deploy enabled MCP servers to ~/.claude.json # # Reads catalog.json from the claude-config repo (local clone or GitHub). # Writes to .claude/settings.local.json and .mcp.json in the project dir. +# --deploy writes MCP server configs to ~/.claude.json (global). set -euo pipefail @@ -16,13 +21,43 @@ if [[ -f "$ANN_DIR/check-announcements.sh" ]]; then bash "$ANN_DIR/check-announcements.sh" "${1:-.}" fi -PROJECT_DIR="${1:-.}" -ACTION="${2:-}" +# Parse --no-redirect flag (can appear anywhere in args) +_NO_REDIRECT=false +_args=() +for _a in "$@"; do + if [[ "$_a" == "--no-redirect" ]]; then + _NO_REDIRECT=true + else + _args+=("$_a") + fi +done +set -- "${_args[@]+"${_args[@]}"}" + +# Handle "catalog.sh [arg]" shorthand (no project-dir needed) +# Recognized subcommands that don't require a project-dir positional arg: +_KNOWN_SUBCOMMANDS="add|install|uninstall|remove|status|sync" +if [[ "${1:-}" =~ ^(${_KNOWN_SUBCOMMANDS})$ ]]; then + PROJECT_DIR="." + ACTION="$1" + ADD_SERVER_ID="${2:-}" +else + PROJECT_DIR="${1:-.}" + ACTION="${2:-}" + ADD_SERVER_ID="${3:-}" +fi SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" CATALOG="$SCRIPT_DIR/catalog.json" SETTINGS="$PROJECT_DIR/.claude/settings.local.json" MCP_FILE="$PROJECT_DIR/.mcp.json" ENV_FILE="$PROJECT_DIR/.env" +GLOBAL_SETTINGS="$HOME/.claude/settings.json" +GLOBAL_MCP="$HOME/.claude.json" + +OCC_ROLE_FILE="$HOME/.claude/.occ-role" +OCC_ROLE="user" +if [[ -f "$OCC_ROLE_FILE" ]]; then + OCC_ROLE="$(cat "$OCC_ROLE_FILE" | tr -d '[:space:]')" +fi # Colors RED='\033[0;31m' @@ -56,23 +91,29 @@ except: pass } get_enabled_mcps() { - if [[ -f "$SETTINGS" ]]; then - python3 -c " + python3 -c " import json, sys -try: - d = json.load(open('$SETTINGS')) - for s in d.get('enabledMcpjsonServers', []): - print(s) -except: pass +seen = set() +for path in ['$GLOBAL_SETTINGS', '$SETTINGS']: + try: + d = json.load(open(path)) + for s in d.get('enabledMcpjsonServers', []): + if s not in seen: + seen.add(s) + print(s) + except: pass " 2>/dev/null - fi } catalog_plugins() { python3 -c " import json c = json.load(open('$CATALOG')) +user_role = '$OCC_ROLE' for pid, p in sorted(c['plugins'].items(), key=lambda x: (not x[1]['recommended'], x[1]['category'], x[1]['name'])): + allowed = p.get('roles', ['user', 'admin']) + if user_role not in allowed: + continue rec = '*' if p['recommended'] else ' ' print(f\"{rec}|{pid}|{p['name']}|{p['description']}|{p['category']}\") " @@ -82,7 +123,11 @@ catalog_mcps() { python3 -c " import json c = json.load(open('$CATALOG')) +user_role = '$OCC_ROLE' for mid, m in sorted(c['mcpServers'].items(), key=lambda x: (not x[1]['recommended'], x[1]['category'], x[1]['name'])): + allowed = m.get('roles', ['user', 'admin']) + if user_role not in allowed: + continue rec = '*' if m['recommended'] else ' ' keys = ', '.join(m.get('requiredKeys', [])) print(f\"{rec}|{mid}|{m['name']}|{m['description']}|{m['category']}|{keys}\") @@ -156,7 +201,8 @@ toggle_menu() { fi echo "" - printf "${BOLD}Select ${type} to toggle (space-separated numbers, or 'r' for recommended, 'a' for all, 'q' to quit):${NC}\n" + printf "${BOLD}Select ${type} to toggle:${NC}\n" + printf " ${DIM}Type numbers separated by spaces to toggle on/off (e.g. 1 3 5)${NC}\n" echo "" for i in "${!items[@]}"; do @@ -170,7 +216,7 @@ toggle_menu() { done echo "" - printf " ${DIM}r) Enable recommended only a) Enable all n) Disable all q) Done${NC}\n" + printf " ${DIM}r) Recommended only a) All on n) All off q) Done${NC}\n" printf "\n Choice: " read -r choice @@ -272,7 +318,7 @@ PYEOF setup_mcp_keys() { local mid="$1" echo "" - + # Get key info from catalog python3 - "$mid" "$CATALOG" "$ENV_FILE" "$MCP_FILE" <<'PYEOF' import json, sys, os @@ -320,14 +366,18 @@ for key in all_keys: current = env.get(key, '') desc = descriptions.get(key, '') req_label = '(required)' if key in required else '(optional)' - + if current and current not in ('', 'REPLACE_ME'): print(f" {key}: [already set]") else: print(f" {key} {req_label}") if desc: print(f" Source: {desc}") - val = input(f" Value: ").strip() + try: + import getpass + val = getpass.getpass(f" Value: ").strip() + except Exception: + val = '' if val: env[key] = val changed = True @@ -346,7 +396,7 @@ if changed: mcp = json.load(open(mcp_file)) else: mcp = {"mcpServers": {}} - + # Build server entry from catalog entry = {"command": server["command"], "args": server["args"]} srv_env = dict(server.get("env", {})) @@ -355,7 +405,7 @@ if changed: srv_env[key] = env[key] if srv_env: entry["env"] = srv_env - + # Substitute variables in args new_args = [] for arg in entry["args"]: @@ -363,7 +413,7 @@ if changed: arg = arg.replace(f"${{{k}}}", v) new_args.append(arg) entry["args"] = new_args - + mcp["mcpServers"][mid] = entry with open(mcp_file, 'w') as f: json.dump(mcp, f, indent=2) @@ -373,17 +423,705 @@ if changed: PYEOF } +# ── Deploy Mode ───────────────────────────────────────────────── + +deploy_global_mcp() { + python3 - "$CATALOG" "$GLOBAL_SETTINGS" "$SETTINGS" "$GLOBAL_MCP" <<'PYEOF' +import json, sys, os + +catalog_file = sys.argv[1] +global_settings_file = sys.argv[2] +local_settings_file = sys.argv[3] +global_mcp_file = sys.argv[4] + +catalog = json.load(open(catalog_file)) +servers = catalog.get('mcpServers', {}) + +enabled = set() +for path in [global_settings_file, local_settings_file]: + try: + d = json.load(open(path)) + for s in d.get('enabledMcpjsonServers', []): + enabled.add(s) + except (FileNotFoundError, json.JSONDecodeError, ValueError): + pass + +if not enabled: + print(" No enabled MCP servers found in settings. Nothing to deploy.") + sys.exit(0) + +try: + with open(global_mcp_file) as f: + global_mcp = json.load(f) +except (FileNotFoundError, json.JSONDecodeError, ValueError): + global_mcp = {} + +if 'mcpServers' not in global_mcp: + global_mcp['mcpServers'] = {} + +existing_keys = set(global_mcp['mcpServers'].keys()) +catalog_keys = set(servers.keys()) + +added = [] +updated = [] +skipped_missing = [] +warned_keys = [] + +for mid in sorted(enabled): + if mid not in servers: + skipped_missing.append(mid) + continue + + srv = servers[mid] + entry = { + "type": "stdio", + "command": srv["command"], + "args": list(srv["args"]), + } + srv_env = dict(srv.get("env", {})) + if srv_env: + entry["env"] = srv_env + else: + entry["env"] = {} + + missing_keys = [] + for key in srv.get("requiredKeys", []): + val = os.environ.get(key, "") + if not val: + missing_keys.append(key) + if missing_keys: + warned_keys.append((mid, missing_keys)) + + if mid in existing_keys: + if global_mcp['mcpServers'][mid] != entry: + updated.append(mid) + global_mcp['mcpServers'][mid] = entry + else: + added.append(mid) + global_mcp['mcpServers'][mid] = entry + +removed = [] +for mid in list(global_mcp['mcpServers'].keys()): + if mid in catalog_keys and mid not in enabled: + del global_mcp['mcpServers'][mid] + removed.append(mid) + +with open(global_mcp_file, 'w') as f: + json.dump(global_mcp, f, indent=2) + f.write('\n') + +print(" Deploy summary:") +if added: + print(" Added: " + ", ".join(added)) +if updated: + print(" Updated: " + ", ".join(updated)) +if removed: + print(" Removed: " + ", ".join(removed)) +if not added and not updated and not removed: + print(" No changes — global config already matches.") +if skipped_missing: + print(" Skipped (not in catalog): " + ", ".join(skipped_missing)) +if warned_keys: + print("") + for mid, keys in warned_keys: + print(" \033[0;33mWarning:\033[0m " + mid + " missing env keys: " + ", ".join(keys)) + +print(" Deployed to: " + global_mcp_file) +PYEOF +} + +# ── Add Single Server ────────────────────────────────────────── + +add_server() { + local mid="$1" + + # ── Check if keys already exist (skip credential collection) ──── + # If all required keys are present, we can register directly without + # opening a terminal for getpass input. + local _keys_present=false + if [[ ! -t 0 ]]; then + _keys_present=$(python3 - "$mid" "$CATALOG" "$HOME/.claude/.env" <<'KCHK' +import json, sys, os +mid, catalog_file, env_file = sys.argv[1], sys.argv[2], sys.argv[3] +catalog = json.load(open(catalog_file)) +server = catalog.get('mcpServers', {}).get(mid) +if not server: + print("false"); sys.exit(0) +required = server.get('requiredKeys', []) +if not required: + print("true"); sys.exit(0) +env = {} +if os.path.exists(env_file): + for line in open(env_file): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + env[k.strip()] = v.strip().strip('"') +missing = [k for k in required if not env.get(k, '').strip() or env.get(k, '').strip() == 'REPLACE_ME'] +print("false" if missing else "true") +KCHK + ) + fi + + # ── Interactive terminal check ────────────────────────────────── + # If stdin is not a terminal AND keys are missing AND --no-redirect not set, + # re-launch in a real terminal so getpass/OAuth can work. + # --no-redirect bypasses this check (set automatically in the printed command). + if [[ ! -t 0 ]] && [[ "$_keys_present" != "true" ]] && [[ "$_NO_REDIRECT" != true ]]; then + # Include --no-redirect so the re-launched command doesn't loop + local self_cmd="$SCRIPT_DIR/scripts/catalog.sh add $mid --no-redirect" + + # Detect IDE: only trust env vars that prove we're *inside* an IDE process + local _in_ide=false + if [[ -n "${VSCODE_IPC_HOOK_CLI:-}" ]] || [[ -n "${CURSOR_TRACE_DIR:-}" ]] || [[ "${TERM_PROGRAM:-}" =~ ^(vscode|cursor)$ ]]; then + _in_ide=true + fi + + if [[ "$_in_ide" == true ]]; then + echo " Opening IDE terminal to collect credentials securely..." + if command -v code &>/dev/null; then + code --command workbench.action.terminal.new + elif command -v cursor &>/dev/null; then + cursor --command workbench.action.terminal.new + fi + echo "" + echo " Run this in the terminal that just opened:" + printf " ${BOLD}${CYAN}%s${NC}\n" "$self_cmd" + elif [[ "$(uname)" == "Darwin" ]]; then + # macOS native — open Terminal.app with the command + echo " Opening Terminal to collect credentials securely..." + osascript -e "tell application \"Terminal\" to do script \"$self_cmd\"" 2>/dev/null \ + || open -a Terminal "$self_cmd" 2>/dev/null \ + || { + echo " Could not open Terminal. Run manually:" + printf " ${BOLD}${CYAN}%s${NC}\n" "$self_cmd" + } + else + # Linux / other — try common terminal emulators + echo " Opening terminal to collect credentials securely..." + if command -v gnome-terminal &>/dev/null; then + gnome-terminal -- bash -c "$self_cmd; exec bash" + elif command -v xterm &>/dev/null; then + xterm -e "$self_cmd" & + else + echo " Could not detect terminal. Run manually:" + printf " ${BOLD}${CYAN}%s${NC}\n" "$self_cmd" + fi + fi + # Write pending-install marker for session-start hook + mkdir -p "$HOME/.claude/pending-installs" + echo "$mid" > "$HOME/.claude/pending-installs/$mid" + + echo "" + echo " After setup completes, restart Claude Code to use the new server." + return 0 + fi + + # Validate server exists in catalog + if ! python3 -c "import json; c=json.load(open('$CATALOG')); assert '$mid' in c['mcpServers']" 2>/dev/null; then + echo " Error: '$mid' not found in catalog." + echo " Available servers:" + python3 -c " +import json +c = json.load(open('$CATALOG')) +for k, v in sorted(c['mcpServers'].items()): + print(f\" {k:20s} — {v['name']}\") +" + exit 1 + fi + + local server_name + server_name=$(python3 -c "import json; print(json.load(open('$CATALOG'))['mcpServers']['$mid']['name'])") + echo "" + printf "${BOLD}Adding MCP server: ${CYAN}$server_name${NC} ${DIM}($mid)${NC}\n" + + # Step 1: Collect keys (masked via getpass) + # Stores secrets to Azure Key Vault (user/company/bot tiers) + # Stores config values to .env + # Falls back to .env for all keys if az CLI unavailable + local global_env="$HOME/.claude/.env" + _add_py=$(mktemp /tmp/occ_add_XXXXX) + cat > "$_add_py" << 'PYEOF' +import json, sys, os, getpass, subprocess, shutil + +mid = sys.argv[1] +catalog_file = sys.argv[2] +env_file = sys.argv[3] +vault_config_file = os.path.expanduser("~/.claude/.occ-vault.json") + +catalog = json.load(open(catalog_file)) +server = catalog['mcpServers'][mid] + +# Load .env +env = {} +if os.path.exists(env_file): + with open(env_file) as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + env[k.strip()] = v.strip().strip('"') + +# Load vault config +vault_config = {} +if os.path.exists(vault_config_file): + try: + vault_config = json.load(open(vault_config_file)) + except Exception: + pass + +user_vault = vault_config.get('userVault', '') +az_available = shutil.which('az') is not None + +# Check az login status +if az_available: + try: + subprocess.run(['az', 'account', 'show'], capture_output=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + az_available = False + print(" WARN: az CLI not logged in — storing all keys to .env") + +def vault_name_for_tier(tier): + """Map tier to Azure vault name.""" + if tier == 'user': + return user_vault + elif tier == 'company': + return 'occ-secrets-company' + elif tier == 'bot': + return 'occ-secrets-bots' + return '' + +def get_from_vault(key, tier): + """Try to read a key from Azure Key Vault.""" + if not az_available or tier == 'config': + return '' + vname = vault_name_for_tier(tier) + if not vname: + return '' + secret_name = key.replace('_', '-') + try: + result = subprocess.run( + ['az', 'keyvault', 'secret', 'show', + '--vault-name', vname, '--name', secret_name, + '--query', 'value', '-o', 'tsv'], + capture_output=True, text=True, timeout=15 + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return '' + +def set_in_vault(key, val, tier): + """Store a key in Azure Key Vault.""" + if not az_available or tier == 'config': + return False + vname = vault_name_for_tier(tier) + if not vname: + return False + secret_name = key.replace('_', '-') + try: + result = subprocess.run( + ['az', 'keyvault', 'secret', 'set', + '--vault-name', vname, '--name', secret_name, + '--value', val, '-o', 'none'], + capture_output=True, text=True, timeout=15 + ) + return result.returncode == 0 + except Exception: + return False + +required = server.get('requiredKeys', []) +optional = server.get('optionalKeys', []) +descriptions = server.get('keyDescriptions', {}) +key_vaults = server.get('keyVaults', {}) +derived_token = server.get('derivedToken', '') + +# If derived token already set (in vault or .env), skip +if derived_token: + dt_tier = key_vaults.get(derived_token, 'config') + dt_val = get_from_vault(derived_token, dt_tier) or env.get(derived_token, '') + if dt_val.strip() not in ('', 'REPLACE_ME'): + print(f" {derived_token}: [already set — skipping key collection]") + sys.exit(0) + +all_keys = required + optional +if not all_keys: + print(f" No keys required.") + sys.exit(0) + +instructions = server.get('setupInstructions', []) +if instructions: + print(f"\n Setup instructions:") + for i, step in enumerate(instructions, 1): + print(f" {i}. {step}") + print() + +env_changed = False +for key in all_keys: + tier = key_vaults.get(key, 'config') + desc = descriptions.get(key, '') + req_label = '(required)' if key in required else '(optional)' + + # Check vault first, then .env + current = get_from_vault(key, tier) if tier != 'config' else '' + if not current: + current = env.get(key, '') + + if current and current not in ('', 'REPLACE_ME'): + print(f" {key}: [already set]") + continue + + if key in optional: + print(f" {key}: [optional — auto-resolved at runtime]") + continue + + if desc: + print(f" {key} {req_label} — {desc}") + else: + print(f" {key} {req_label}") + + try: + val = getpass.getpass(" Value: ").strip() + except Exception: + val = '' + print(" (could not read input — skipping)") + + if val: + if tier != 'config' and az_available: + # Store to vault + if set_in_vault(key, val, tier): + print(f" → stored in Azure Key Vault ({tier})") + else: + # Fallback to .env + env[key] = val + env_changed = True + print(f" → vault write failed, stored in .env") + else: + # Config key or no az — store to .env + env[key] = val + env_changed = True + +if env_changed: + with open(env_file, 'w') as f: + f.write("# [Your Company] MCP Server Configuration\n") + f.write(f"# Updated by catalog.sh on {__import__('datetime').date.today()}\n") + f.write("# NEVER commit this file.\n\n") + for k, v in sorted(env.items()): + f.write(f'{k}="{v}"\n') + os.chmod(env_file, 0o600) + print(f"\n Config keys saved to {env_file}") +PYEOF + + set +e + python3 "$_add_py" "$mid" "$CATALOG" "$global_env" + local _key_rc=$? + set -e + rm -f "$_add_py" + [[ $_key_rc -ne 0 ]] && return 1 + + # Re-source env + set -a; source "$global_env" 2>/dev/null; set +a + + # Step 1b: ClickUp OAuth flow (if applicable) + if [[ "$mid" == "clickup" ]] \ + && [[ -n "${CLICKUP_CLIENT_ID:-}" ]] \ + && [[ -n "${CLICKUP_CLIENT_SECRET:-}" ]] \ + && [[ -z "${CLICKUP_API_TOKEN:-}" ]]; then + echo "" + echo " -- ClickUp OAuth --" + echo " Opening browser — authorize the app in ClickUp..." + local _cu_url="https://app.clickup.com/api?client_id=${CLICKUP_CLIENT_ID}&redirect_uri=http://localhost:3456" + if [[ "$(uname)" == "Darwin" ]]; then + open "$_cu_url" + else + xdg-open "$_cu_url" 2>/dev/null || printf " Please open: %s\n" "$_cu_url" + fi + echo " Waiting for callback on localhost:3456..." + + _cu_py=$(mktemp /tmp/cu_oauth_XXXXX) + cat > "$_cu_py" << 'CUEOF' +import http.server, urllib.parse +code = [None] + +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + p = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + code[0] = p.get('code', [None])[0] + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(b'' + b'

Authorized!

Return to your terminal.

') + def log_message(self, *a): + pass + +http.server.HTTPServer(('localhost', 3456), H).handle_request() +if code[0]: + print(code[0]) +CUEOF + local _cu_code + _cu_code=$(python3 "$_cu_py" 2>/dev/null) + rm -f "$_cu_py" + + if [[ -n "$_cu_code" ]]; then + echo " Exchanging for access token..." + local _cu_resp + _cu_resp=$(curl -s -X POST "https://api.clickup.com/api/v2/oauth/token" \ + -H "Content-Type: application/json" \ + -d "{\"client_id\":\"${CLICKUP_CLIENT_ID}\",\"client_secret\":\"${CLICKUP_CLIENT_SECRET}\",\"code\":\"${_cu_code}\"}") + local _cu_token + _cu_token=$(echo "$_cu_resp" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || true) + + if [[ -n "${_cu_token:-}" ]]; then + echo " ClickUp OAuth complete — token saved" + python3 - "$global_env" "$_cu_token" << 'CUENVEOF' +import sys, os +env_file, token = sys.argv[1], sys.argv[2] +env = {} +if os.path.exists(env_file): + for line in open(env_file): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + env[k.strip()] = v.strip().strip('"') +env['CLICKUP_API_TOKEN'] = token +with open(env_file, 'w') as fh: + fh.write("# [Your Company] MCP Server Configuration\n# NEVER commit this file.\n\n") + for k, v in sorted(env.items()): + fh.write(f'{k}="{v}"\n') +os.chmod(env_file, 0o600) +CUENVEOF + set -a; source "$global_env" 2>/dev/null; set +a + else + printf " Token exchange failed: %s\n" "$_cu_resp" + return 1 + fi + else + echo " No authorization code received." + return 1 + fi + fi + + # Step 1b: Hudu MCP server install (if applicable) + if [[ "$mid" == "hudu" ]]; then + local _hudu_path="${HUDU_MCP_PATH:-$HOME/hudu2-mcp}" + local _hudu_index="$_hudu_path/build/index.js" + if [[ -f "$_hudu_index" ]]; then + echo " Hudu MCP server already installed at $_hudu_path" + else + echo "" + echo " -- Installing Hudu MCP Server --" + echo " Cloning Hudu2-MCP to $_hudu_path..." + if ! command -v git &>/dev/null; then + echo " ERROR: git is required to install Hudu MCP. Install git and retry." + return 1 + fi + if ! command -v npm &>/dev/null; then + echo " ERROR: npm is required to build Hudu MCP. Install Node.js/npm and retry." + return 1 + fi + if [[ -d "$_hudu_path" ]]; then + echo " Directory exists but build/index.js missing — running build..." + else + git clone --depth 1 https://github.com/Npab19/Hudu2-MCP "$_hudu_path" 2>&1 | sed 's/^/ /' + fi + echo " Installing dependencies..." + npm --prefix "$_hudu_path" install --silent 2>&1 | tail -3 + echo " Building..." + npm --prefix "$_hudu_path" run build 2>&1 | tail -3 + if [[ -f "$_hudu_index" ]]; then + echo " Hudu MCP server installed at $_hudu_path" + else + echo " ERROR: Build failed — $_hudu_index not found." + echo " Try manually: cd $_hudu_path && npm install && npm run build" + return 1 + fi + fi + fi + + # Step 1c: [Your password manager] device registration (if applicable) + if [[ "$mid" == "secrets-manager" ]]; then + local _ksm_config="$HOME/.secrets-manager/ksm-config.json" + if [[ -f "$_ksm_config" ]]; then + echo " [Your password manager] device already registered — skipping." + else + echo "" + echo " -- [Your password manager] Device Registration --" + echo " Paste your one-time token (OTC) from IT." + echo " (IT: [Your password manager] Admin → Secrets Manager → [OCC App] → Add Device)" + echo "" + local _ksm_token + _ksm_token=$(python3 -c " +import getpass, sys +try: + t = getpass.getpass(' One-time token: ').strip() + print(t) +except Exception: + print('') +" 2>/dev/null) + if [[ -z "$_ksm_token" ]]; then + echo " No token entered — skipping [Your password manager] registration." + echo " Re-run 'catalog.sh add secrets-manager' when you have a token." + return 1 + fi + echo "" + echo " Registering device with [Your password manager] (this may take a few seconds)..." + mkdir -p "$HOME/.secrets-manager" + KSM_TOKEN="$_ksm_token" npx -y @example/secrets-mcp@latest >/dev/null 2>&1 & + local _ksm_pid=$! + local _ksm_registered=false + for _i in $(seq 1 30); do + if [[ -f "$_ksm_config" ]]; then + _ksm_registered=true + break + fi + sleep 0.5 + done + kill "$_ksm_pid" 2>/dev/null || true + wait "$_ksm_pid" 2>/dev/null || true + if [[ "$_ksm_registered" == true ]]; then + echo " Device registered — config saved to ~/.secrets-manager/config.json" + python3 - "$global_env" << 'KSMEOF' +import sys, os +env_file = sys.argv[1] +env = {} +if os.path.exists(env_file): + for line in open(env_file): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + env[k.strip()] = v.strip().strip('"') +env['KSM_CONFIG_REGISTERED'] = 'true' +with open(env_file, 'w') as fh: + fh.write("# [Your Company] MCP Server Configuration\n# NEVER commit this file.\n\n") + for k, v in sorted(env.items()): + fh.write(f'{k}="{v}"\n') +os.chmod(env_file, 0o600) +KSMEOF + set -a; source "$global_env" 2>/dev/null; set +a + else + echo " ERROR: Device registration timed out — ksm-config.json was not created." + echo " Check that your token is valid and try again." + return 1 + fi + fi + fi + + # Step 2: Register via claude mcp add + echo "" + echo " Registering $server_name with Claude Code..." + + python3 - "$mid" "$CATALOG" <<'PYEOF' +import json, sys, subprocess + +mid = sys.argv[1] +catalog = json.load(open(sys.argv[2])) +m = catalog['mcpServers'][mid] + +subprocess.run(["claude", "mcp", "remove", mid, "-s", "user"], capture_output=True) + +env_flags = [] +for key, val in m.get("env", {}).items(): + env_flags += ["-e", f"{key}={val}"] + +cmd = [ + "claude", "mcp", "add", mid, + "-s", "user", + *env_flags, + "--", + m["command"], + *m["args"] +] +result = subprocess.run(cmd, capture_output=True, text=True) +if result.returncode == 0: + print(f" Registered globally via 'claude mcp add -s user'") +else: + print(f" ERROR: {result.stderr.strip()}") + sys.exit(1) +PYEOF + + # Step 3: Enable in global settings.json (skip if launchPolicy is "manual") + local global_settings="$HOME/.claude/settings.json" + python3 - "$mid" "$global_settings" "$CATALOG" <<'PYEOF' +import json, sys, os + +mid = sys.argv[1] +settings_file = sys.argv[2] +catalog_file = sys.argv[3] + +catalog = json.load(open(catalog_file)) +entry = catalog.get('mcpServers', {}).get(mid, {}) +launch_policy = entry.get('launchPolicy', 'auto') + +if launch_policy == 'manual': + print(f" Skipping auto-enable (launchPolicy=manual) — enable with /mcp when needed") + sys.exit(0) + +try: + settings = json.load(open(settings_file)) if os.path.exists(settings_file) else {} +except (json.JSONDecodeError, FileNotFoundError): + settings = {} + +mcps = settings.get('enabledMcpjsonServers', []) +if mid not in mcps: + mcps.append(mid) + settings['enabledMcpjsonServers'] = mcps + with open(settings_file, 'w') as f: + json.dump(settings, f, indent=2) + f.write('\n') + print(f" Enabled in {settings_file}") +else: + print(f" Already enabled in {settings_file}") +PYEOF + + # Clear pending-install marker on success + rm -f "$HOME/.claude/pending-installs/$mid" + + echo "" + printf "${GREEN} Done!${NC} Restart Claude Code to use ${BOLD}$server_name${NC}.\n" + echo "" +} + # ── Main ──────────────────────────────────────────────────────── case "${ACTION}" in - --list|-l) + --list|-l|status) list_status ;; - --sync|-s) + --deploy|-d) + echo "Deploying enabled MCP servers to global config..." + deploy_global_mcp + ;; + --sync|-s|sync) echo "Syncing catalog from GitHub..." git -C "$SCRIPT_DIR" pull --quiet 2>/dev/null || echo " Warning: Could not pull latest catalog" + echo "" + echo "Deploying enabled MCP servers to global config..." + deploy_global_mcp + echo "" list_status ;; + add|install) + SERVER_ID="${ADD_SERVER_ID:-}" + if [[ -z "$SERVER_ID" ]]; then + echo "Usage: catalog.sh add " + echo "Example: catalog.sh add clickup" + exit 1 + fi + add_server "$SERVER_ID" + ;; + uninstall|remove) + SERVER_ID="${ADD_SERVER_ID:-}" + if [[ -z "$SERVER_ID" ]]; then + echo "Usage: catalog.sh uninstall " + echo "Example: catalog.sh uninstall clickup" + exit 1 + fi + echo " Removing $SERVER_ID from Claude Code..." + claude mcp remove "$SERVER_ID" -s user 2>/dev/null || true + echo " Done. Restart Claude Code to apply." + ;; *) # Interactive mode list_status @@ -415,7 +1153,7 @@ case "${ACTION}" in git -C "$SCRIPT_DIR" pull --quiet 2>/dev/null || echo " Warning: Could not pull" echo " Done." ;; - q|Q) + q|Q) echo "" echo " Restart Claude Code to apply changes." echo "" diff --git a/examples/scripts/check-announcements.sh b/examples/scripts/check-announcements.sh index 49dc628..de9e429 100644 --- a/examples/scripts/check-announcements.sh +++ b/examples/scripts/check-announcements.sh @@ -4,10 +4,9 @@ # source ~/claude-config/scripts/check-announcements.sh [project-dir] # ~/claude-config/scripts/check-announcements.sh [project-dir] -PROJ_DIR="${1:-.}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ANNOUNCEMENTS="$SCRIPT_DIR/announcements.json" -SEEN_FILE="$PROJ_DIR/.claude/.announcements-seen" +SEEN_FILE="$HOME/.claude/.announcements-seen" if [[ ! -f "$ANNOUNCEMENTS" ]]; then return 0 2>/dev/null || exit 0 @@ -34,7 +33,7 @@ if os.path.exists(seen_file): seen = set(line.strip() for line in f if line.strip()) # Find unread announcements -unread = [a for a in data.get("announcements", []) if a.get("id") not in seen] +unread = [a for a in data.get("announcements", []) if a.get("id") and a["id"] not in seen] if not unread: sys.exit(0) @@ -71,5 +70,6 @@ print() # Mark all as seen with open(seen_file, 'a') as f: for a in unread: - f.write(a["id"] + "\n") + if a.get("id"): + f.write(a["id"] + "\n") PYEOF diff --git a/examples/scripts/setup.sh b/examples/scripts/setup.sh index d4e40b6..e358a49 100644 --- a/examples/scripts/setup.sh +++ b/examples/scripts/setup.sh @@ -1,24 +1,234 @@ #!/usr/bin/env bash # setup.sh — First-time Claude Code setup for [Your Company] team members -# Usage: ./scripts/setup.sh [project-dir] +# Usage: ./scripts/setup.sh [--dev] # # Reads catalog.json for available plugins and MCP servers. -# Creates .claude/settings.local.json, .mcp.json, and .env. +# Writes .env and settings.json to ~/.claude/; registers MCP servers via claude mcp add. +# +# Flags: +# --dev Use the develop branch instead of main set -euo pipefail +# Parse flags +OCC_BRANCH="main" +for arg in "$@"; do + case "$arg" in + --dev) OCC_BRANCH="develop" ;; + esac +done + +# Switch branch if needed +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [[ "$OCC_BRANCH" != "main" ]] && [[ -d "$SCRIPT_DIR/.git" ]]; then + current=$(git -C "$SCRIPT_DIR" branch --show-current 2>/dev/null || true) + if [[ "$current" != "$OCC_BRANCH" ]]; then + echo " Switching to $OCC_BRANCH branch..." + git -C "$SCRIPT_DIR" fetch origin "$OCC_BRANCH" 2>/dev/null + git -C "$SCRIPT_DIR" checkout "$OCC_BRANCH" 2>/dev/null || git -C "$SCRIPT_DIR" checkout -b "$OCC_BRANCH" "origin/$OCC_BRANCH" + git -C "$SCRIPT_DIR" pull 2>/dev/null + fi +fi + +# ── Colors ─────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +DIM='\033[2m' +NC='\033[0m' + +# ── Step tracking (bash 3.2 compatible — space-delimited string) ─ +STEP_RESULTS="" +STEP_PASS_COUNT=0 +STEP_FAIL_COUNT=0 +STEP_WARN_COUNT=0 +STEP_TOTAL=0 +CURRENT_STEP="" +_TMPFILES="" + +step_ok() { + STEP_RESULTS="${STEP_RESULTS:+$STEP_RESULTS +}PASS|$1" + STEP_PASS_COUNT=$((STEP_PASS_COUNT + 1)) + STEP_TOTAL=$((STEP_TOTAL + 1)) + echo " ✅ $1" + CURRENT_STEP="" +} + +step_fail() { + STEP_RESULTS="${STEP_RESULTS:+$STEP_RESULTS +}FAIL|$1: $2" + STEP_FAIL_COUNT=$((STEP_FAIL_COUNT + 1)) + STEP_TOTAL=$((STEP_TOTAL + 1)) + echo " ❌ $1 — $2" + CURRENT_STEP="" +} + +step_warn() { + STEP_RESULTS="${STEP_RESULTS:+$STEP_RESULTS +}WARN|$1: $2" + STEP_WARN_COUNT=$((STEP_WARN_COUNT + 1)) + STEP_TOTAL=$((STEP_TOTAL + 1)) + echo " ⚠️ $1 — $2" + CURRENT_STEP="" +} + +_step_start() { + CURRENT_STEP="$1" +} + +_step_end() { + if [[ -n "$CURRENT_STEP" ]]; then + step_ok "$CURRENT_STEP" + fi +} + +_register_tmpfile() { + _TMPFILES="${_TMPFILES:+$_TMPFILES }$1" +} + +_cleanup() { + local _exit_code=$? + for _tf in $_TMPFILES; do + rm -f "$_tf" 2>/dev/null || true + done + echo "" + echo "=== Setup Summary ===" + if [[ -n "$STEP_RESULTS" ]]; then + local _IFS_SAVE="$IFS" + IFS=' +' + for _r in $STEP_RESULTS; do + local _type="${_r%%|*}" + local _msg="${_r#*|}" + case "$_type" in + PASS) printf " ${GREEN}✅${NC} %s\n" "$_msg" ;; + FAIL) printf " ${RED}❌${NC} %s\n" "$_msg" ;; + WARN) printf " ${YELLOW}⚠️${NC} %s\n" "$_msg" ;; + *) echo " $_msg" ;; + esac + done + IFS="$_IFS_SAVE" + fi + echo "" + printf " Setup complete: ${GREEN}%d passed${NC}, ${RED}%d failed${NC}, ${YELLOW}%d warnings${NC} (%d total)\n" \ + "$STEP_PASS_COUNT" "$STEP_FAIL_COUNT" "$STEP_WARN_COUNT" "$STEP_TOTAL" + if [[ -n "$CURRENT_STEP" ]]; then + printf "\n ${RED}⚠️ Interrupted during: %s${NC}\n" "$CURRENT_STEP" + fi + if [[ $_exit_code -ne 0 ]] && [[ -n "$CURRENT_STEP" ]]; then + printf " ${RED}Setup did not complete successfully (exit code %d).${NC}\n" "$_exit_code" + fi + echo "" +} +trap _cleanup EXIT + +# ── /dev/tty fallback ──────────────────────────────────────────── +_read_input() { + if [[ -t 0 ]]; then + read "$@" + elif read -t 0 /dev/null; then + read "$@" &2 + read "$@" <<< "" + fi +} + +# ── Banner ─────────────────────────────────────────────────────── +ORANGE='\033[38;5;208m'; RESET='\033[0m' +printf "\n${ORANGE} ┌──────────────────────────────────────────────────────┐\n" +printf " │ Claude Code Team Commander · by [Admin Name] │\n" +printf " │ github.com/[your-github-org] · github.com/[your-github-username] │\n" +printf " │ Personas, tools & hooks active │\n" +printf " └──────────────────────────────────────────────────────┘${RESET}\n\n" + +# ── Pre-flight: dependency checks ─────────────────────────────── +echo "=== [Your Company] Claude Code Setup — Pre-flight ===" +echo "" + +PREFLIGHT_PASS=true + +check_dep() { + local cmd="$1" label="$2" install_hint="$3" + if command -v "$cmd" &>/dev/null; then + echo " ✅ $label ($(command -v "$cmd"))" + else + echo " ❌ $label — not found" + echo " Install: $install_hint" + PREFLIGHT_PASS=false + fi +} + +if [[ "$(uname)" == "Darwin" ]]; then + _pkg="brew install" + _python_hint="xcode-select --install OR brew install python3" + _gh_hint="brew install gh" +else + _pkg="sudo apt install" + _python_hint="sudo apt install python3" + _gh_hint="sudo apt install gh OR see https://cli.github.com" +fi + +check_dep "git" "Git" "$_pkg git" +check_dep "python3" "Python 3" "$_python_hint (v3.8+ required)" +check_dep "node" "Node.js" "$_pkg node (v18+ required)" +check_dep "claude" "Claude Code" "$_pkg claude-code OR npm install -g @anthropic-ai/claude-code" +check_dep "sf" "Salesforce CLI" "npm install -g @salesforce/cli" +check_dep "gh" "GitHub CLI" "$_gh_hint (needed for worktrees + GitHub MCP auth)" + +echo "" + +if [[ "$PREFLIGHT_PASS" != "true" ]]; then + echo " Some dependencies are missing. Install them and re-run setup." + echo " Claude Code is required. Others can be installed later." + echo "" + _read_input -rp " Continue anyway? [y/N] " _cont + if [[ "$(echo "$_cont" | tr '[:upper:]' '[:lower:]')" != "y" ]]; then + echo " Aborted." + exit 1 + fi + echo "" +fi + +# ── Pre-flight: OCC repo freshness ────────────────────────────── +OCC_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OCC_REPO="https://github.com/[YourGitHubOrg]/claude-config.git" + +if [[ -d "$OCC_DIR/.git" ]]; then + echo " Pulling latest OCC updates..." + if git -C "$OCC_DIR" pull --quiet 2>/dev/null; then + echo " ✅ OCC is up to date." + else + echo " ⚠️ Pull failed — OCC may have local changes or merge conflicts." + echo " This can happen if you edited files in ~/claude-config directly." + echo "" + _read_input -rp " Delete ~/claude-config and re-clone? (local changes will be lost) [y/N] " _reclone + if [[ "$(echo "$_reclone" | tr '[:upper:]' '[:lower:]')" == "y" ]]; then + rm -rf "$OCC_DIR" + git clone "$OCC_REPO" "$OCC_DIR" + exec "$OCC_DIR/scripts/setup.sh" + else + echo " Continuing with existing OCC (may be outdated)." + fi + fi + echo "" +fi + # Check for team announcements SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)" if [[ -f "$SOURCE_DIR/check-announcements.sh" ]]; then - bash "$SOURCE_DIR/check-announcements.sh" "${1:-.}" + bash "$SOURCE_DIR/check-announcements.sh" fi -PROJECT_DIR="${1:-.}" SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" CATALOG="$SCRIPT_DIR/catalog.json" -SETTINGS="$PROJECT_DIR/.claude/settings.local.json" -MCP_FILE="$PROJECT_DIR/.mcp.json" -ENV_FILE="$PROJECT_DIR/.env" +CLAUDE_DIR="$HOME/.claude" +SETTINGS="$CLAUDE_DIR/settings.json" +ENV_FILE="$CLAUDE_DIR/.env" + +mkdir -p "$CLAUDE_DIR" if [[ ! -f "$CATALOG" ]]; then echo "Error: catalog.json not found. Run: git -C $SCRIPT_DIR pull" @@ -26,67 +236,581 @@ if [[ ! -f "$CATALOG" ]]; then fi echo "=== [Your Company] Claude Code Setup ===" -echo "Project: $(cd "$PROJECT_DIR" && pwd)" -echo "Catalog: $CATALOG" echo "" -# ── Step 1: Copy personas and commands ────────────────────────── -echo "[1/5] Copying personas and commands..." -mkdir -p "$PROJECT_DIR/.claude/commands" "$PROJECT_DIR/.claude/personas" +# ── Role selection ──────────────────────────────────────────────── +OCC_ROLE_FILE="$CLAUDE_DIR/.occ-role" +OCC_ROLE="" -cp -r "$SCRIPT_DIR/.claude/personas/"*.md "$PROJECT_DIR/.claude/personas/" 2>/dev/null || true -cp -r "$SCRIPT_DIR/.claude/commands/"*.md "$PROJECT_DIR/.claude/commands/" 2>/dev/null || true -echo " Done." +if [[ -f "$OCC_ROLE_FILE" ]]; then + OCC_ROLE="$(cat "$OCC_ROLE_FILE" | tr -d '[:space:]')" + echo " Current role: $OCC_ROLE" + read -rp " Change role? [y/N] " _change_role + if [[ "$(echo "$_change_role" | tr '[:upper:]' '[:lower:]')" == "y" ]]; then + OCC_ROLE="" + fi +fi -# ── Step 2: Enable recommended plugins ────────────────────────── +if [[ -z "$OCC_ROLE" ]]; then + echo " Select your role:" + echo " 1) user — standard tools (email, docs, VoIP, GitHub, ClickUp)" + echo " 2) admin — all tools including Salesforce DX, n8n, device management" + echo "" + read -rp " Role [1/2]: " _role_choice + case "$_role_choice" in + 2|admin) OCC_ROLE="admin" ;; + *) OCC_ROLE="user" ;; + esac + echo "$OCC_ROLE" > "$OCC_ROLE_FILE" + chmod 600 "$OCC_ROLE_FILE" + echo " Role set to: $OCC_ROLE" +fi echo "" -echo "[2/5] Configuring plugins from catalog..." -# Read existing settings or create new -if [[ -f "$SETTINGS" ]]; then - echo " Found existing settings.local.json" +# ── Step 1: Symlink personas, commands, and standards ──────────── +echo "[1/9] Linking personas, commands, standards, and skills..." +_step_start "Personas, commands, and standards linked" + +_link_dir() { + local src="$1" dest="$2" label="$3" + if [[ -L "$dest" ]]; then + # Already a symlink — verify target matches + local target + target="$(readlink "$dest")" + if [[ "$target" == "$src" ]]; then + return 0 # correct symlink already exists + else + # Symlink points elsewhere — update it + rm -f "$dest" + fi + elif [[ -d "$dest" ]]; then + # Real directory — check for local-only files before replacing + local _local_only=() + for f in "$dest"/*.md; do + [[ -f "$f" ]] || continue + local base + base="$(basename "$f")" + if [[ ! -f "$src/$base" ]]; then + _local_only+=("$base") + fi + done + if [[ ${#_local_only[@]} -gt 0 ]]; then + local backup="$CLAUDE_DIR/backups/${label}-$(date +%Y%m%d%H%M%S)" + mkdir -p "$backup" + for f in "${_local_only[@]}"; do + cp "$dest/$f" "$backup/$f" + done + echo " Backed up ${#_local_only[@]} local-only $label file(s) to $backup" + fi + rm -rf "$dest" + fi + ln -s "$src" "$dest" +} + +_step1_ok=true +for _pair in "personas:personas" "commands:commands" "standards:standards"; do + _label="${_pair%%:*}" + _dir="${_pair##*:}" + _src="$SCRIPT_DIR/.claude/$_dir" + _dest="$CLAUDE_DIR/$_dir" + if [[ -d "$_src" ]]; then + if ! _link_dir "$_src" "$_dest" "$_label"; then + _step1_ok=false + fi + fi +done + +if [[ "$_step1_ok" == true ]]; then + step_ok "Personas, commands, and standards linked" else - echo '{}' > "$SETTINGS" + step_warn "Personas/commands/standards" "some symlinks may not have been created" fi -# Enable recommended plugins from catalog -python3 - "$CATALOG" "$SETTINGS" <<'PYEOF' -import json, sys +# ── Step 1b: Symlink shared skills (per-skill, additive) ──────── +_skills_src="$SCRIPT_DIR/skills" +_skills_dest="$CLAUDE_DIR/skills" +if [[ -d "$_skills_src" ]]; then + mkdir -p "$_skills_dest" + _skills_linked=0 + _skills_skipped=0 + for _skill_dir in "$_skills_src"/*/; do + [[ -d "$_skill_dir" ]] || continue + _skill_name="$(basename "$_skill_dir")" + _skill_target="$_skills_dest/$_skill_name" + if [[ -L "$_skill_target" ]]; then + _existing_target="$(readlink "$_skill_target")" + if [[ "$_existing_target" == "$_skill_dir" || "$_existing_target" == "${_skill_dir%/}" ]]; then + _skills_linked=$(( _skills_linked + 1 )) + continue + fi + rm -f "$_skill_target" + elif [[ -d "$_skill_target" ]]; then + rm -rf "$_skill_target" + fi + ln -s "${_skill_dir%/}" "$_skill_target" + _skills_linked=$(( _skills_linked + 1 )) + done + step_ok "Skills linked ($_skills_linked shared skills)" +else + step_warn "Skills" "no shared skills directory found" +fi -catalog = json.load(open(sys.argv[1])) -settings_file = sys.argv[2] +# ── Step 2: Select MCP servers ────────────────────────────────── +echo "" +echo "[2/9] Select MCP servers to install" +_step_start "MCP server selection" +printf " ${GREEN}[Configured]${NC} = keys found ${YELLOW}[Recommended]${NC} = suggested for your role\n" +echo " Toggle by number, 'r' recommended, 'a' all, 'n' none, or Enter to continue." + +# Detect existing config: MCP registrations, .env keys, and scour for keys on disk +EXISTING_KEYS=() +while IFS= read -r _key; do + EXISTING_KEYS+=("$_key") +done < <(python3 -c " +import json, os, glob +env_keys = {} + +# 1. Keys already in ~/.claude/.env +for env_path in [os.path.expanduser('~/.claude/.env'), os.path.expanduser('~/.claude/.env.local')]: + if os.path.exists(env_path): + for line in open(env_path): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + v = v.strip().strip('\"') + if v and v != 'REPLACE_ME': + env_keys[k.strip()] = v + +# 2. Scour common locations for .env files with catalog-relevant keys +catalog_keys = set() +catalog_path = '$CATALOG' try: - settings = json.load(open(settings_file)) -except (FileNotFoundError, json.JSONDecodeError): - settings = {} + c = json.load(open(catalog_path)) + for m in c.get('mcpServers', {}).values(): + for k in m.get('requiredKeys', []) + m.get('optionalKeys', []): + catalog_keys.add(k) + dt = m.get('derivedToken', '') + if dt: + catalog_keys.add(dt) +except Exception: + pass -plugins = settings.get("enabledPlugins", {}) -added = [] +home = os.path.expanduser('~') +scour_dirs = [ + os.path.join(home, 'Developer'), + os.path.join(home, 'Projects'), + os.path.join(home, 'Github Projects'), +] +skip_dirs = {'node_modules', '.git', '__pycache__', 'venv', '.venv'} +max_depth = 4 +for base in scour_dirs: + if not os.path.isdir(base): + continue + base_depth = base.count(os.sep) + for root, dirs, files in os.walk(base): + if root.count(os.sep) - base_depth >= max_depth: + dirs.clear() + continue + dirs[:] = [d for d in dirs if d not in skip_dirs] + if '.env' in files: + env_file = os.path.join(root, '.env') + try: + for line in open(env_file): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + k, v = k.strip(), v.strip().strip('\"') + if k in catalog_keys and v and v != 'REPLACE_ME' and not v.startswith('your_') and k not in env_keys: + env_keys[k] = v + except Exception: + pass -for pid, p in catalog["plugins"].items(): - if p["recommended"] and pid not in plugins: - plugins[pid] = True - added.append(p["name"]) - elif pid not in plugins: - plugins[pid] = False +for k in sorted(env_keys): + print(k) +" 2>/dev/null) -settings["enabledPlugins"] = plugins +# Build MCP arrays from catalog — mark as installed if all required keys exist in .env +MCP_IDS=(); MCP_NAMES=(); MCP_RECS=(); MCP_INSTALLED=(); MCP_REMOTES=() +while IFS='|' read -r _rec _installed _mid _name _desc _keys _remote; do + MCP_IDS+=("$_mid") + MCP_NAMES+=("$_name — $_desc${_keys:+ [keys: $_keys]}") + MCP_RECS+=("$_rec") + MCP_INSTALLED+=("$_installed") + MCP_REMOTES+=("$_remote") +done < <(python3 -c " +import json, os -with open(settings_file, 'w') as f: - json.dump(settings, f, indent=2) +catalog = json.load(open('$CATALOG')) +user_role = '$OCC_ROLE' + +# Load existing keys (same set we just detected) +env_keys = set() +for env_path in [os.path.expanduser('~/.claude/.env'), os.path.expanduser('~/.claude/.env.local')]: + if os.path.exists(env_path): + for line in open(env_path): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + v = v.strip().strip('\"') + if v and v != 'REPLACE_ME': + env_keys.add(k.strip()) + +# Also check ~/.claude.json for registered servers +registered = set() +p = os.path.expanduser('~/.claude.json') +if os.path.exists(p): + try: + d = json.load(open(p)) + # Check top-level and project-level mcpServers + for k in d.get('mcpServers', {}): + registered.add(k) + for proj in d.get('projects', {}).values(): + if isinstance(proj, dict): + for k in proj.get('mcpServers', {}): + registered.add(k) + except Exception: + pass + +for mid, m in sorted(catalog['mcpServers'].items(), key=lambda x: (not x[1]['recommended'], x[1]['name'])): + # Filter by role: if roles is defined, user must match; if absent, available to all + allowed_roles = m.get('roles', ['user', 'admin']) + if user_role not in allowed_roles: + continue + + rec = '*' if m['recommended'] else ' ' + keys_list = ', '.join(m.get('requiredKeys', [])) + + # Installed if: registered in claude.json, OR all required keys have values, + # OR the derived token has a value + required = m.get('requiredKeys', []) + derived = m.get('derivedToken', '') + installed = '0' + if mid in registered: + installed = '1' + elif derived and derived in env_keys: + installed = '1' + elif required and all(k in env_keys for k in required): + installed = '1' + # Servers with no required keys: check if they have optional keys set + elif not required: + optional = m.get('optionalKeys', []) + if optional and any(k in env_keys for k in optional): + installed = '1' + + remote = m.get('remoteAvailable', '') + print(f'{rec}|{installed}|{mid}|{m[\"name\"]}|{m[\"description\"]}|{keys_list}|{remote}') +") + +# Print migration summary if any installed servers detected +_installed_count=0 +for _v in "${MCP_INSTALLED[@]+"${MCP_INSTALLED[@]}"}"; do [[ "$_v" == "1" ]] && _installed_count=$(( _installed_count + 1 )) || true; done +if (( _installed_count > 0 )); then + echo "" + echo " Found $_installed_count existing MCP server(s) — they'll be migrated to the latest config." +fi + +# Pre-select: already registered servers first, then new recommended ones +SELECTED_MCPS=() +for _i in "${!MCP_IDS[@]}"; do + if [[ "${MCP_INSTALLED[$_i]}" == "1" ]]; then + SELECTED_MCPS+=("${MCP_IDS[$_i]}") + fi +done +for _i in "${!MCP_IDS[@]}"; do + if [[ "${MCP_RECS[$_i]}" == "*" ]]; then + _already=0 + for _s in "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}"; do [[ "$_s" == "${MCP_IDS[$_i]}" ]] && _already=1 && break; done + [[ $_already -eq 0 ]] && SELECTED_MCPS+=("${MCP_IDS[$_i]}") + fi +done + +while true; do + echo "" + for _i in "${!MCP_IDS[@]}"; do + _id="${MCP_IDS[$_i]}" + _in_sel=0 + for _s in "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}"; do [[ "$_s" == "$_id" ]] && _in_sel=1 && break; done + _mark="[ ]"; [[ $_in_sel -eq 1 ]] && _mark="[x]" + _remote_tag="" + [[ -n "${MCP_REMOTES[$_i]}" ]] && _remote_tag=" ${CYAN}[available via ${MCP_REMOTES[$_i]}]${NC}" + if [[ "${MCP_INSTALLED[$_i]}" == "1" ]]; then + printf " %s %2d) ${GREEN}[Configured]${NC} %s%b\n" "$_mark" "$((_i+1))" "${MCP_NAMES[$_i]}" "$_remote_tag" + elif [[ "${MCP_RECS[$_i]}" == "*" ]]; then + printf " %s %2d) ${YELLOW}[Recommended]${NC} %s%b\n" "$_mark" "$((_i+1))" "${MCP_NAMES[$_i]}" "$_remote_tag" + else + printf " %s %2d) %s%b\n" "$_mark" "$((_i+1))" "${MCP_NAMES[$_i]}" "$_remote_tag" + fi + done + echo "" + printf " Toggle (numbers), r=recommended, a=all, n=none, Enter=done: " + _read_input -r _sel || _sel="" + [[ -z "$_sel" ]] && break + case "$_sel" in + r|R) + SELECTED_MCPS=() + for _i in "${!MCP_IDS[@]}"; do [[ "${MCP_RECS[$_i]}" == "*" ]] && SELECTED_MCPS+=("${MCP_IDS[$_i]}"); done + ;; + a|A) SELECTED_MCPS=("${MCP_IDS[@]}") ;; + n|N) SELECTED_MCPS=() ;; + *) + for _num in $_sel; do + if [[ "$_num" =~ ^[0-9]+$ ]] && (( _num >= 1 && _num <= ${#MCP_IDS[@]} )); then + _id="${MCP_IDS[$(( _num-1 ))]}" + _found=0; _new=() + for _s in "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}"; do + [[ "$_s" == "$_id" ]] && _found=1 || _new+=("$_s") + done + if (( _found )); then + SELECTED_MCPS=("${_new[@]+"${_new[@]}"}") + else + SELECTED_MCPS+=("$_id") + fi + fi + done + ;; + esac +done +step_ok "MCP server selection" + +# ── Step 3: Select plugins ─────────────────────────────────────── +echo "" +echo "[3/9] Select plugins to enable" +_step_start "Plugin selection" +printf " ${GREEN}[Installed]${NC} = currently active ${YELLOW}[Recommended]${NC} = suggested [Always On] = required\n" +echo " Toggle by number, or Enter to continue." + +# Detect already-enabled plugins from settings.json +EXISTING_PLUGINS=() +while IFS= read -r _pid; do + EXISTING_PLUGINS+=("$_pid") +done < <(python3 -c " +import json, os +p = os.path.expanduser('~/.claude/settings.json') +if os.path.exists(p): + s = json.load(open(p)) + for pid, enabled in s.get('enabledPlugins', {}).items(): + if enabled: + print(pid) +" 2>/dev/null) + +# Build plugin arrays from catalog +PLUGIN_IDS=(); PLUGIN_NAMES=(); PLUGIN_RECS=(); PLUGIN_LOCKED=(); PLUGIN_INSTALLED=() +while IFS='|' read -r _rec _locked _pid _name _desc _cat; do + PLUGIN_IDS+=("$_pid") + PLUGIN_NAMES+=("$_name — $_desc") + PLUGIN_RECS+=("$_rec") + PLUGIN_LOCKED+=("$_locked") + _is_installed=0 + for _e in "${EXISTING_PLUGINS[@]+"${EXISTING_PLUGINS[@]}"}"; do [[ "$_e" == "$_pid" ]] && _is_installed=1 && break; done + PLUGIN_INSTALLED+=("$_is_installed") +done < <(python3 -c " +import json +c = json.load(open('$CATALOG')) +user_role = '$OCC_ROLE' +for pid, p in sorted(c['plugins'].items(), key=lambda x: (not x[1]['recommended'], x[1]['name'])): + allowed_roles = p.get('roles', ['user', 'admin']) + if user_role not in allowed_roles: + continue + rec = '*' if p['recommended'] else ' ' + locked = '1' if p.get('alwaysEnabled') else '0' + print(f'{rec}|{locked}|{pid}|{p[\"name\"]}|{p[\"description\"]}|{p[\"category\"]}') +") + +# Print migration summary if any installed plugins detected +_installed_p_count=0 +for _v in "${PLUGIN_INSTALLED[@]+"${PLUGIN_INSTALLED[@]}"}"; do [[ "$_v" == "1" ]] && _installed_p_count=$(( _installed_p_count + 1 )) || true; done +if (( _installed_p_count > 0 )); then + echo "" + echo " Found $_installed_p_count existing plugin(s) — they'll be preserved." +fi + +# Pre-select: already-enabled plugins, then recommended + always-enabled +SELECTED_PLUGINS=() +if (( _installed_p_count > 0 )); then + for _i in "${!PLUGIN_IDS[@]}"; do + if [[ "${PLUGIN_INSTALLED[$_i]}" == "1" ]] || [[ "${PLUGIN_LOCKED[$_i]}" == "1" ]]; then + SELECTED_PLUGINS+=("${PLUGIN_IDS[$_i]}") + fi + done +else + # First run — default to recommended + always-enabled + for _i in "${!PLUGIN_IDS[@]}"; do + [[ "${PLUGIN_RECS[$_i]}" == "*" || "${PLUGIN_LOCKED[$_i]}" == "1" ]] && SELECTED_PLUGINS+=("${PLUGIN_IDS[$_i]}") + done +fi + +while true; do + echo "" + for _i in "${!PLUGIN_IDS[@]}"; do + _id="${PLUGIN_IDS[$_i]}" + _in_sel=0 + for _s in "${SELECTED_PLUGINS[@]+"${SELECTED_PLUGINS[@]}"}"; do [[ "$_s" == "$_id" ]] && _in_sel=1 && break; done + _mark="[ ]"; [[ $_in_sel -eq 1 ]] && _mark="[x]" + if [[ "${PLUGIN_LOCKED[$_i]}" == "1" ]]; then + printf " %s %2d) ${GREEN}[Always On]${NC} %s\n" "$_mark" "$((_i+1))" "${PLUGIN_NAMES[$_i]}" + elif [[ "${PLUGIN_INSTALLED[$_i]}" == "1" ]]; then + printf " %s %2d) ${GREEN}[Installed]${NC} %s\n" "$_mark" "$((_i+1))" "${PLUGIN_NAMES[$_i]}" + elif [[ "${PLUGIN_RECS[$_i]}" == "*" ]]; then + printf " %s %2d) ${YELLOW}[Recommended]${NC} %s\n" "$_mark" "$((_i+1))" "${PLUGIN_NAMES[$_i]}" + else + printf " %s %2d) %s\n" "$_mark" "$((_i+1))" "${PLUGIN_NAMES[$_i]}" + fi + done + echo "" + printf " Toggle (numbers), r=recommended, a=all, n=none, Enter=done: " + _read_input -r _sel || _sel="" + [[ -z "$_sel" ]] && break + case "$_sel" in + r|R) + SELECTED_PLUGINS=() + for _i in "${!PLUGIN_IDS[@]}"; do + [[ "${PLUGIN_RECS[$_i]}" == "*" || "${PLUGIN_LOCKED[$_i]}" == "1" ]] && SELECTED_PLUGINS+=("${PLUGIN_IDS[$_i]}") + done + ;; + a|A) SELECTED_PLUGINS=("${PLUGIN_IDS[@]}") ;; + n|N) + SELECTED_PLUGINS=() + for _i in "${!PLUGIN_IDS[@]}"; do + [[ "${PLUGIN_LOCKED[$_i]}" == "1" ]] && SELECTED_PLUGINS+=("${PLUGIN_IDS[$_i]}") + done + ;; + *) + for _num in $_sel; do + if [[ "$_num" =~ ^[0-9]+$ ]] && (( _num >= 1 && _num <= ${#PLUGIN_IDS[@]} )); then + _idx=$(( _num-1 )) + _id="${PLUGIN_IDS[$_idx]}" + if [[ "${PLUGIN_LOCKED[$_idx]}" == "1" ]]; then + echo " (${PLUGIN_IDS[$_idx]} is always enabled)" + continue + fi + _found=0; _new=() + for _s in "${SELECTED_PLUGINS[@]+"${SELECTED_PLUGINS[@]}"}"; do + [[ "$_s" == "$_id" ]] && _found=1 || _new+=("$_s") + done + if (( _found )); then + SELECTED_PLUGINS=("${_new[@]+"${_new[@]}"}") + else + SELECTED_PLUGINS+=("$_id") + fi + fi + done + ;; + esac +done +step_ok "Plugin selection" + +# ── Step 3b: Azure Key Vault check ────────────────────────────── +echo "" +echo " Checking Azure Key Vault access..." +OCC_AZ_AVAILABLE=false +if command -v az >/dev/null 2>&1; then + if az account show -o none 2>/dev/null; then + OCC_AZ_AVAILABLE=true + _az_user=$(az account show --query user.name -o tsv 2>/dev/null) + echo " Azure CLI: logged in as $_az_user" + echo " Secrets will be stored in Azure Key Vault (not .env)" + else + echo " Azure CLI installed but not logged in." + echo " Run 'az login' to enable Azure Key Vault for secrets." + echo " Falling back to .env for now." + fi +else + echo " Azure CLI not installed — secrets will be stored in .env" + echo " Install with: brew install azure-cli (or see https://aka.ms/install-azure-cli)" +fi + +# Deploy occ-fetch-secrets.sh +FETCH_SRC="$SCRIPT_DIR/scripts/occ-fetch-secrets.sh" +FETCH_DEST="$CLAUDE_DIR/scripts/occ-fetch-secrets.sh" +if [[ -f "$FETCH_SRC" ]]; then + mkdir -p "$CLAUDE_DIR/scripts" + cp "$FETCH_SRC" "$FETCH_DEST" && chmod +x "$FETCH_DEST" + echo " Deployed occ-fetch-secrets.sh" +fi + +# Write .occ-vault.json if not present +VAULT_CONFIG="$CLAUDE_DIR/.occ-vault.json" +OCC_VAULT_JUST_CREATED=false +if [[ ! -f "$VAULT_CONFIG" ]] && [[ "$OCC_AZ_AVAILABLE" == "true" ]]; then + _az_short="${_az_user%%@*}" + _vault_name="occ-secrets-${_az_short}" + OCC_VAULT_RG="${OCC_VAULT_RG:-occ-vaults-rg}" + + _existing_rg=$(az keyvault show --name "$_vault_name" --query resourceGroup -o tsv 2>/dev/null) || true + if [[ -z "$_existing_rg" ]]; then + echo " Creating vault $_vault_name in $OCC_VAULT_RG..." + _creator_oid=$(az ad signed-in-user show --query id -o tsv 2>/dev/null) || true + if az keyvault create \ + --name "$_vault_name" \ + --resource-group "$OCC_VAULT_RG" \ + --location "eastus" \ + --enable-rbac-authorization true \ + --tags "occ-creator-id=${_creator_oid:-unknown}" \ + -o none 2>/dev/null; then + echo " Vault created." + OCC_VAULT_JUST_CREATED=true + else + echo " Warning: vault creation failed — check permissions on $OCC_VAULT_RG" + fi + else + echo " Vault $_vault_name already exists (resource group: $_existing_rg)." + fi + + if [[ "$OCC_VAULT_JUST_CREATED" == "true" ]] || [[ -n "$_existing_rg" ]]; then + python3 -c " +import json, sys +cfg = {'userVault': sys.argv[1], 'occRepo': sys.argv[2]} +with open(sys.argv[3], 'w') as f: + json.dump(cfg, f, indent=2) f.write('\n') +" "$_vault_name" "$SCRIPT_DIR" "$VAULT_CONFIG" 2>/dev/null && echo " Created $VAULT_CONFIG (user vault: ${_vault_name})" + else + echo " Skipping $VAULT_CONFIG — vault creation failed" + fi +fi -if added: - print(f" Enabled {len(added)} recommended plugins: {', '.join(added)}") -else: - print(" Plugins already configured.") -PYEOF +# Call HTTP provisioner to assign RBAC (only for newly created vaults) +if [[ "$OCC_VAULT_JUST_CREATED" == "true" ]]; then + _prov_url="${OCC_PROVISIONER_URL:-https://occ-vault-provisioner.azurewebsites.net/api/provision-vault}" + _prov_secret="${OCC_PROVISIONER_SECRET:-}" + + if [[ -z "$_prov_secret" ]] && command -v az >/dev/null 2>&1; then + _prov_secret=$(az keyvault secret show \ + --vault-name occ-secrets-company \ + --name OCC-PROVISIONER-SECRET \ + --query value -o tsv 2>/dev/null) || true + fi -# ── Step 3: Collect API keys for recommended MCP servers ──────── + if [[ -n "$_prov_secret" ]]; then + _creator_oid="${_creator_oid:-$(az ad signed-in-user show --query id -o tsv 2>/dev/null)}" + echo " Requesting RBAC assignment via provisioner..." + _body=$(python3 -c "import json,sys; print(json.dumps({'vault_name':sys.argv[1],'creator_id':sys.argv[2]}))" \ + "$_vault_name" "$_creator_oid") + _prov_resp=$(curl -sf -X POST "$_prov_url" \ + -H "Content-Type: application/json" \ + -H "X-OCC-Secret: $_prov_secret" \ + -d "$_body" \ + 2>/dev/null) || true + + if [[ -n "$_prov_resp" ]] && echo "$_prov_resp" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('status')=='ok' else 1)" 2>/dev/null; then + echo " RBAC assigned (Key Vault Secrets Officer)." + else + echo " Warning: RBAC auto-assignment did not succeed." + echo " An admin may need to manually assign Key Vault Secrets Officer on $_vault_name." + fi + else + echo " Warning: OCC_PROVISIONER_SECRET not available — skipping RBAC auto-assignment." + echo " An admin may need to manually assign Key Vault Secrets Officer on $_vault_name." + fi +fi +echo "" + +# ── Step 4: Collect API keys for selected MCP servers ─────────── echo "" -echo "[3/5] MCP Server API Keys" -echo " Press Enter to skip any key you don't have yet." +echo "[4/9] MCP Server API Keys" +_step_start "API key collection" +echo " Only prompting for missing keys. Press Enter to skip any you don't have yet." echo "" # Source existing .env if present @@ -95,14 +819,190 @@ if [[ -f "$ENV_FILE" ]]; then set -a; source "$ENV_FILE" 2>/dev/null; set +a fi -# Prompt for keys from catalog +# Scour for keys in other .env files and merge into ~/.claude/.env +set +e python3 - "$CATALOG" "$ENV_FILE" <<'PYEOF' -import json, sys, os +import json, os, glob, sys catalog = json.load(open(sys.argv[1])) env_file = sys.argv[2] -# Load existing env +# All keys the catalog cares about +catalog_keys = set() +for m in catalog.get('mcpServers', {}).values(): + for k in m.get('requiredKeys', []) + m.get('optionalKeys', []): + catalog_keys.add(k) + dt = m.get('derivedToken', '') + if dt: + catalog_keys.add(dt) + +# Load current .env +env = {} +if os.path.exists(env_file): + for line in open(env_file): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + env[k.strip()] = v.strip().strip('"') + +# Scour project directories for .env files (depth-limited, skips heavy dirs) +home = os.path.expanduser('~') +scour_dirs = [ + os.path.join(home, 'Developer'), + os.path.join(home, 'Projects'), + os.path.join(home, 'Github Projects'), +] +skip_dirs = {'node_modules', '.git', '__pycache__', 'venv', '.venv'} +max_depth = 4 +discovered = {} +for base in scour_dirs: + if not os.path.isdir(base): + continue + base_depth = base.count(os.sep) + for root, dirs, files in os.walk(base): + if root.count(os.sep) - base_depth >= max_depth: + dirs.clear() + continue + dirs[:] = [d for d in dirs if d not in skip_dirs] + if '.env' in files: + env_path = os.path.join(root, '.env') + try: + for line in open(env_path): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + k, v = k.strip(), v.strip().strip('"') + import re as _re + _SAFE_VALUE = _re.compile(r'^[A-Za-z0-9_=./:@+, -]*$') + if (k in catalog_keys + and v and v != 'REPLACE_ME' + and not v.startswith('your_') + and not v.startswith('sk-your') + and k not in env + and k not in discovered): + if not _SAFE_VALUE.match(v): + print(f" Skipping {k}: value contains unsafe characters") + continue + discovered[k] = (v, env_path) + except Exception: + pass + +if not discovered: + sys.exit(0) + +print(f" Discovered {len(discovered)} key(s) from other .env files on this machine:") +for k, (v, path) in sorted(discovered.items()): + short_path = path.replace(home, '~') + masked = v[:6] + '...' if len(v) > 8 else v + print(f" {k} = {masked} (from {short_path})") + +# Merge into .env +for k, (v, _) in discovered.items(): + env[k] = v + +try: + with open(env_file, 'w') as f: + f.write("# [Your Company] MCP Server Configuration\n") + f.write(f"# Updated by setup.sh on {__import__('datetime').date.today()}\n") + f.write("# NEVER commit this file.\n\n") + for k, v in sorted(env.items()): + f.write(f'{k}="{v}"\n') + os.chmod(env_file, 0o600) + print(f"\n Merged into {env_file}") +except Exception as e: + print(f" Warning: could not write {env_file}: {e}", file=sys.stderr) +PYEOF +_scour_rc=$? +set -e +if [[ $_scour_rc -ne 0 ]]; then + echo " Warning: key scour encountered an error (non-fatal)" +fi + +# Re-source after scour merge +if [[ -f "$ENV_FILE" ]]; then + set -a; source "$ENV_FILE" 2>/dev/null; set +a +fi + +# Show summary of all existing keys +_existing_key_count=${#EXISTING_KEYS[@]} +if (( _existing_key_count > 0 )); then + echo "" + echo " Total keys configured: $_existing_key_count" + echo " Keys with values: ${EXISTING_KEYS[*]}" + echo "" +fi + +# Auto-detect SF_TARGET_ORG from sf CLI if not already set +if [[ -z "${SF_TARGET_ORG:-}" ]] && command -v sf &>/dev/null; then + _sf_org=$(sf org list --json 2>/dev/null | python3 -c " +import json,sys +try: + d=json.load(sys.stdin) + orgs=d.get('result',{}).get('nonScratchOrgs',[]) + default=next((o['username'] for o in orgs if o.get('isDefaultUsername')),None) + if default: print(default) +except: pass +" 2>/dev/null || true) + if [[ -n "${_sf_org:-}" ]]; then + echo " Auto-detected SF_TARGET_ORG: $_sf_org" + echo "SF_TARGET_ORG=\"$_sf_org\"" >> "$ENV_FILE" + set -a; source "$ENV_FILE" 2>/dev/null; set +a + fi +fi + +# Write Python to a temp file so stdin stays connected to the terminal +# (heredoc-based python3 - <<'EOF' consumes stdin, breaking /dev/tty prompts) +_step4_py=$(mktemp /tmp/occ_step4_XXXXX) +_register_tmpfile "$_step4_py" +cat > "$_step4_py" << 'PYEOF' +import json, sys, os, getpass, subprocess + +catalog = json.load(open(sys.argv[1])) +env_file = sys.argv[2] +az_available = sys.argv[3] == 'true' +selected = set(sys.argv[4:]) + +# Load vault config when available +vault_config = {} +vault_config_path = os.path.join(os.path.dirname(env_file), '.occ-vault.json') +if az_available and os.path.exists(vault_config_path): + with open(vault_config_path) as f: + vault_config = json.load(f) + +user_vault = vault_config.get('userVault', '') +VAULT_NAMES = { + 'user': user_vault, + 'company': 'occ-secrets-company', + 'bot': 'occ-secrets-bots', +} + +# Build a global map: KEY_NAME -> vault tier (from all catalog servers) +key_vault_tier = {} +for m in catalog.get('mcpServers', {}).values(): + for k, tier in m.get('keyVaults', {}).items(): + key_vault_tier[k] = tier + +def store_in_vault(key, value, tier): + vault_name = VAULT_NAMES.get(tier) + if not vault_name: + return False + secret_name = key.replace('_', '-') + try: + subprocess.run( + ['az', 'keyvault', 'secret', 'set', + '--vault-name', vault_name, + '--name', secret_name, + '--value', value], + capture_output=True, text=True, check=True + ) + print(f" Stored in vault ({tier}: {vault_name})") + return True + except subprocess.CalledProcessError as e: + print(f" Warning: vault store failed — falling back to .env") + print(f" ({e.stderr.strip()[:120]})") + return False + +# Load existing env (file first, then shell env as fallback) env = {} if os.path.exists(env_file): with open(env_file) as f: @@ -111,63 +1011,223 @@ if os.path.exists(env_file): if line and not line.startswith('#') and '=' in line: k, v = line.split('=', 1) env[k.strip()] = v.strip().strip('"') +# Also pick up catalog-relevant env vars from shell (e.g. sourced .env from previous steps) +catalog_env_keys = set() +for m in catalog.get('mcpServers', {}).values(): + for k in m.get('requiredKeys', []) + m.get('optionalKeys', []): + catalog_env_keys.add(k) + dt = m.get('derivedToken', '') + if dt: + catalog_env_keys.add(dt) +for k, v in os.environ.items(): + if k in catalog_env_keys and k not in env and v.strip(): + env[k] = v -# Collect all keys from recommended servers -for mid, m in sorted(catalog['mcpServers'].items()): - if not m.get('recommended', False): - continue - - all_keys = m.get('requiredKeys', []) + m.get('optionalKeys', []) - if not all_keys: - continue - - print(f"\n -- {m['name']} --") - descriptions = m.get('keyDescriptions', {}) - - for key in all_keys: - current = env.get(key, '') - if current and current not in ('', 'REPLACE_ME'): - print(f" {key}: [already set]") +# Track which keys were stored in vault (so we skip them in .env) +vault_stored_keys = set() + +if not selected: + print(" No MCP servers selected — skipping key collection.") +else: + if az_available and user_vault: + print(f" Secrets will be stored in Azure Key Vault (not .env)") + # Collect keys for selected servers + for mid, m in sorted(catalog['mcpServers'].items()): + if mid not in selected: continue - - req = '(required)' if key in m.get('requiredKeys', []) else '(optional)' - desc = descriptions.get(key, '') - if desc: - print(f" {key} {req} — {desc}") - else: - print(f" {key} {req}") - - val = input(f" Value: ").strip() - if val: - env[key] = val - -# Write env file -with open(env_file, 'w') as f: - f.write("# [Your Company] MCP Server Configuration\n") - f.write(f"# Generated by setup.sh on {__import__('datetime').date.today()}\n") - f.write("# NEVER commit this file.\n\n") - for k, v in sorted(env.items()): - f.write(f'{k}="{v}"\n') -os.chmod(env_file, 0o600) -print(f"\n Written to {env_file}") + + all_keys = m.get('requiredKeys', []) + m.get('optionalKeys', []) + if not all_keys: + continue + + # If this server has a derived token already set, skip credential prompts + derived_token = m.get('derivedToken', '') + if derived_token and env.get(derived_token, '').strip() not in ('', 'REPLACE_ME'): + continue + + # Check which required keys are missing + required_keys = [k for k in m.get('requiredKeys', []) + if env.get(k, '') in ('', 'REPLACE_ME')] + if not required_keys: + continue + + # Only show server header + instructions when keys are needed + print(f"\n -- {m['name']} --") + instructions = m.get('setupInstructions', []) + if instructions: + print(f" Setup:") + for step in instructions: + print(f" • {step}") + print() + descriptions = m.get('keyDescriptions', {}) + + for key in required_keys: + desc = descriptions.get(key, '') + tier = key_vault_tier.get(key) + dest_label = f" → vault ({tier})" if az_available and tier and tier != 'config' else "" + if desc: + print(f" {key} (required) — {desc}{dest_label}") + else: + print(f" {key} (required){dest_label}") + + try: + val = getpass.getpass(" Value: ").strip() + except Exception: + val = '' + print(" (could not read input — skipping)") + if val: + stored = False + if az_available and tier and tier != 'config': + stored = store_in_vault(key, val, tier) + if stored: + vault_stored_keys.add(key) + else: + env[key] = val + +# Write .env (only non-vault keys) +env_to_write = {k: v for k, v in env.items() if k not in vault_stored_keys} +try: + with open(env_file, 'w') as f: + f.write("# [Your Company] MCP Server Configuration\n") + f.write(f"# Generated by setup.sh on {__import__('datetime').date.today()}\n") + f.write("# NEVER commit this file.\n\n") + if vault_stored_keys: + f.write(f"# {len(vault_stored_keys)} key(s) stored in Azure Key Vault (not listed here)\n\n") + for k, v in sorted(env_to_write.items()): + f.write(f'{k}="{v}"\n') + os.chmod(env_file, 0o600) + print(f"\n Written to {env_file}") + if vault_stored_keys: + print(f" {len(vault_stored_keys)} key(s) stored in vault: {', '.join(sorted(vault_stored_keys))}") +except OSError as e: + print(f"\n ERROR: Could not write {env_file}: {e}", file=sys.stderr) + sys.exit(1) PYEOF +set +e +python3 "$_step4_py" "$CATALOG" "$ENV_FILE" "$OCC_AZ_AVAILABLE" "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}" +_step4_rc=$? +set -e +rm -f "$_step4_py" +if [[ $_step4_rc -ne 0 ]]; then + step_fail "API key collection" "Python exited with code $_step4_rc" + exit 1 +else + step_ok "API keys collected" +fi + # Re-source env after collecting keys set -a; source "$ENV_FILE" 2>/dev/null; set +a -# ── Step 4: Generate .mcp.json from catalog ───────────────────── +# ── ClickUp OAuth flow (runs if CLIENT_ID/SECRET collected above) ─ +_clickup_oauth=false +for _m in "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}"; do + [[ "$_m" == "clickup" ]] && _clickup_oauth=true && break +done + +if [[ "$_clickup_oauth" == "true" ]] \ + && [[ -n "${CLICKUP_CLIENT_ID:-}" ]] \ + && [[ -n "${CLICKUP_CLIENT_SECRET:-}" ]] \ + && [[ -z "${CLICKUP_API_KEY:-}" ]]; then + echo "" + echo " -- ClickUp OAuth --" + echo " Opening browser — authorize '[Your Company] Claude Integration' in ClickUp..." + _cu_url="https://app.clickup.com/api?client_id=${CLICKUP_CLIENT_ID}&redirect_uri=http://localhost:3456" + if [[ "$(uname)" == "Darwin" ]]; then + open "$_cu_url" + else + xdg-open "$_cu_url" 2>/dev/null || printf " Please open in your browser:\n %s\n" "$_cu_url" + fi + echo " Waiting for callback on localhost:3456 (approve in your browser)..." + + # Write OAuth callback server to a temp file — avoids heredoc nesting issues + _cu_py=$(mktemp /tmp/cu_oauth_XXXXX) + _register_tmpfile "$_cu_py" + cat > "$_cu_py" << 'CUEOF' +import http.server, urllib.parse +code = [None] + +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + p = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + code[0] = p.get('code', [None])[0] + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(b'' + b'

Authorized!

Return to your terminal.

') + def log_message(self, *a): + pass + +http.server.HTTPServer(('localhost', 3456), H).handle_request() +if code[0]: + print(code[0]) +CUEOF + _cu_code=$(python3 "$_cu_py" 2>/dev/null) + rm -f "$_cu_py" + + if [[ -n "$_cu_code" ]]; then + echo " Got authorization code. Exchanging for access token..." + _cu_resp=$(curl -s -X POST "https://api.clickup.com/api/v2/oauth/token" \ + -H "Content-Type: application/json" \ + -d "{\"client_id\":\"${CLICKUP_CLIENT_ID}\",\"client_secret\":\"${CLICKUP_CLIENT_SECRET}\",\"code\":\"${_cu_code}\"}") + _cu_token=$(echo "$_cu_resp" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || true) + + if [[ -n "${_cu_token:-}" ]]; then + echo " ✓ ClickUp OAuth complete — token saved" + # Append CLICKUP_API_KEY to .env preserving existing keys + python3 - "$ENV_FILE" "$_cu_token" << 'CUENVEOF' +import sys, os +env_file, token = sys.argv[1], sys.argv[2] +env = {} +if os.path.exists(env_file): + for line in open(env_file): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + env[k.strip()] = v.strip().strip('"') +env['CLICKUP_API_KEY'] = token +with open(env_file, 'w') as fh: + fh.write("# [Your Company] MCP Server Configuration\n# NEVER commit this file.\n\n") + for k, v in sorted(env.items()): + fh.write(f'{k}="{v}"\n') +os.chmod(env_file, 0o600) +CUENVEOF + set -a; source "$ENV_FILE" 2>/dev/null; set +a + else + printf " ✗ Token exchange failed. Response: %s\n" "$_cu_resp" + echo " Add CLICKUP_API_KEY to .env manually, or email [admin-email@your-domain.com]." + fi + else + echo " ✗ No authorization code received from browser." + echo " Re-run setup, or add CLICKUP_API_KEY to .env manually." + fi +elif [[ "$_clickup_oauth" == "true" ]] && [[ -n "${CLICKUP_API_KEY:-}" ]]; then + echo " CLICKUP_API_KEY already set — skipping OAuth flow." +fi + +# ── Shared secret distribution (deferred — see issue #243) ────── +# fetch-shared-secrets.sh is ready but occ-secrets repo infrastructure +# has not been built yet. Skipping silently until #243 is resolved. + +# ── MCP Health Check (parallel) ───────────────────────────────── +# Re-source env to pick up any keys just collected +set -a; source "$ENV_FILE" 2>/dev/null; set +a + +echo "" +echo " Checking MCPs..." echo "" -echo "[4/5] Generating .mcp.json from catalog..." -python3 - "$CATALOG" "$MCP_FILE" "$ENV_FILE" <<'PYEOF' -import json, sys, os +set +e +python3 - "$CATALOG" "$ENV_FILE" "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}" <<'HEALTHEOF' +import json, sys, os, subprocess, concurrent.futures catalog = json.load(open(sys.argv[1])) -mcp_file = sys.argv[2] -env_file = sys.argv[3] +env_file = sys.argv[2] +selected = set(sys.argv[3:]) # Load env -env = {} +env = dict(os.environ) if os.path.exists(env_file): with open(env_file) as f: for line in f: @@ -176,162 +1236,472 @@ if os.path.exists(env_file): k, v = line.split('=', 1) env[k.strip()] = v.strip().strip('"') -# Load existing mcp.json to preserve non-catalog entries -if os.path.exists(mcp_file): - mcp = json.load(open(mcp_file)) +def check_server(mid, m): + """Returns (mid, name, auth_type, status) where status is green/yellow/red.""" + name = m['name'] + auth_type = m.get('authType', 'key') + health_cmd = m.get('healthCheck', '') + required = m.get('requiredKeys', []) + optional = m.get('optionalKeys', []) + derived = m.get('derivedToken', '') + + # Determine if keys/auth are present + has_keys = False + if derived and env.get(derived, '').strip() not in ('', 'REPLACE_ME'): + has_keys = True + elif required: + has_keys = all(env.get(k, '').strip() not in ('', 'REPLACE_ME') for k in required) + elif auth_type == 'oauth': + # OAuth with no required keys — check health to determine + has_keys = True + elif auth_type == 'local': + has_keys = True + elif optional and any(env.get(k, '').strip() not in ('', 'REPLACE_ME') for k in optional): + has_keys = True + + if not has_keys: + return (mid, name, auth_type, 'yellow', '') + + # Run health check if available + if health_cmd: + try: + # Expand env vars in command + expanded = health_cmd + for k, v in env.items(): + expanded = expanded.replace(f'${k}', v) + result = subprocess.run( + ['bash', '-c', expanded], + capture_output=True, timeout=10, env=env + ) + if result.returncode == 0: + return (mid, name, auth_type, 'green', '') + else: + if auth_type in ('oauth', 'local') and not required: + return (mid, name, auth_type, 'yellow', '') + return (mid, name, auth_type, 'red', 'API not responding') + except subprocess.TimeoutExpired: + if auth_type in ('oauth', 'local') and not required: + return (mid, name, auth_type, 'yellow', '') + return (mid, name, auth_type, 'red', 'health check timed out') + except Exception as e: + if auth_type in ('oauth', 'local') and not required: + return (mid, name, auth_type, 'yellow', '') + return (mid, name, auth_type, 'red', str(e)) + else: + return (mid, name, auth_type, 'green', '') + +# Run checks in parallel +servers_to_check = [] +for mid, m in sorted(catalog['mcpServers'].items(), key=lambda x: x[1]['name']): + if mid not in selected: + continue + servers_to_check.append((mid, m)) + +results = [] +with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: + futures = {executor.submit(check_server, mid, m): mid for mid, m in servers_to_check} + for f in concurrent.futures.as_completed(futures): + results.append(f.result()) + +# Sort by name +results.sort(key=lambda r: r[1]) + +# Split into groups +key_servers = [r for r in results if r[2] == 'key'] +oauth_servers = [r for r in results if r[2] in ('oauth', 'local')] + +emoji = {'green': '\U0001f7e2', 'yellow': '\U0001f7e1', 'red': '\U0001f534'} + +print(f" {emoji['green']} configured & responding {emoji['yellow']} needs setup {emoji['red']} configured but failing") +print() + +red_details = [] + +if key_servers: + print(" API Key Servers:") + for mid, name, auth_type, status, detail in key_servers: + print(f" {emoji[status]} {name}") + if status == 'red' and detail: + red_details.append((name, detail)) + print() + +if oauth_servers: + print(" OAuth / Runtime Servers:") + for mid, name, auth_type, status, detail in oauth_servers: + print(f" {emoji[status]} {name}") + if status == 'red' and detail: + red_details.append((name, detail)) + print() + +if red_details: + for name, detail in red_details: + print(f" {emoji['red']} {name} — {detail}") + print() + +has_red = any(s == 'red' for _, _, _, s, _ in results) +has_yellow = any(s == 'yellow' for _, _, _, s, _ in results) +if has_red: + sys.exit(2) +elif has_yellow: + sys.exit(1) else: - mcp = {"mcpServers": {}} + sys.exit(0) +HEALTHEOF +_health_rc=$? +set -e + +if [[ $_health_rc -eq 2 ]]; then + step_fail "MCP health check" "one or more servers failing" +elif [[ $_health_rc -eq 1 ]]; then + step_warn "MCP health check" "some servers need setup" +else + step_ok "MCP health check" +fi + +# ── Step 5: Register MCP servers via claude mcp add ───────────── +echo "" +echo "[5/9] Registering MCP servers..." +_step_start "MCP servers registered" + +set +e +python3 - "$CATALOG" "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}" <<'PYEOF' +import json, sys, subprocess + +catalog = json.load(open(sys.argv[1])) +selected = set(sys.argv[2:]) + +if not selected: + print(" No MCP servers selected — skipping.") + sys.exit(0) + +configured = [] +failed = [] -# Build recommended servers from catalog -enabled = [] for mid, m in catalog['mcpServers'].items(): - if not m.get('recommended', False): + if mid not in selected: continue - - entry = {"command": m["command"]} - - # Substitute env vars in args - args = [] - for arg in m["args"]: - for k, v in env.items(): - arg = arg.replace(f"${{{k}}}", v) - args.append(arg) - entry["args"] = args - - # Build env block - srv_env = dict(m.get("env", {})) - for key in m.get("requiredKeys", []) + m.get("optionalKeys", []): - if key in env and env[key]: - srv_env[key] = env[key] - if srv_env: - entry["env"] = srv_env - - mcp["mcpServers"][mid] = entry - enabled.append(m["name"]) - -with open(mcp_file, 'w') as f: - json.dump(mcp, f, indent=2) - f.write('\n') -os.chmod(mcp_file, 0o600) -print(f" Configured {len(enabled)} MCP servers: {', '.join(enabled)}") -print(f" Written to {mcp_file}") + # Remove existing entry (idempotent re-runs) + subprocess.run( + ["claude", "mcp", "remove", mid, "-s", "user"], + capture_output=True + ) + + # Non-secret static env → passed as -e flags (stored in ~/.claude.json, not secret) + env_flags = [] + for key, val in m.get("env", {}).items(): + env_flags += ["-e", f"{key}={val}"] + + # Secrets stay in ~/.claude/.env — loaded at runtime by the bash wrapper + cmd = [ + "claude", "mcp", "add", mid, + "-s", "user", + *env_flags, + "--", + m["command"], + *m["args"] + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode == 0: + configured.append(m["name"]) + else: + failed.append(f"{m['name']}: {result.stderr.strip()}") + +if configured: + print(f" Registered {len(configured)} MCP servers: {', '.join(configured)}") +for msg in failed: + print(f" ERROR: {msg}") +if not configured and not failed: + print(" No MCP servers configured.") +print(" Registered globally via 'claude mcp add -s user' → ~/.claude.json") +if failed: + sys.exit(1) PYEOF +_step5_rc=$? +set -e -# ── Step 5: Update settings with enabled MCP servers ──────────── +if [[ $_step5_rc -eq 0 ]]; then + step_ok "MCP servers registered" +else + step_warn "MCP registration" "some servers failed (see above)" +fi + +# ── Step 6: Update global settings.json ───────────────────────── echo "" -echo "[5/6] Updating settings.local.json..." +echo "[6/9] Updating ~/.claude/settings.json..." +_step_start "Settings updated" + +if [[ ! -f "$SETTINGS" ]]; then + if ! echo '{}' > "$SETTINGS" 2>/dev/null; then + step_fail "Settings update" "could not create $SETTINGS" + fi +fi -python3 - "$CATALOG" "$SETTINGS" <<'PYEOF' +set +e +python3 - "$CATALOG" "$SETTINGS" "${SELECTED_PLUGINS[@]+"${SELECTED_PLUGINS[@]}"}" "${SELECTED_MCPS[@]+"${SELECTED_MCPS[@]}"}" <<'PYEOF' import json, sys catalog = json.load(open(sys.argv[1])) settings_file = sys.argv[2] -settings = json.load(open(settings_file)) +all_plugin_ids = set(catalog['plugins'].keys()) +all_mcp_ids = set(catalog['mcpServers'].keys()) -# Enable recommended MCP servers -enabled = [] -for mid, m in catalog['mcpServers'].items(): - if m.get('recommended', False): - enabled.append(mid) +selected_plugins = [] +selected_mcps = [] +for arg in sys.argv[3:]: + if arg in all_plugin_ids: + selected_plugins.append(arg) + elif arg in all_mcp_ids: + selected_mcps.append(arg) + +try: + settings = json.load(open(settings_file)) +except (FileNotFoundError, json.JSONDecodeError): + settings = {} + +# Update plugins — set all known, enable only selected +plugins = settings.get("enabledPlugins", {}) +for pid in catalog['plugins']: + plugins[pid] = pid in selected_plugins +settings["enabledPlugins"] = plugins -settings['enabledMcpjsonServers'] = enabled +# Update MCP servers +settings['enabledMcpjsonServers'] = selected_mcps with open(settings_file, 'w') as f: json.dump(settings, f, indent=2) f.write('\n') -print(f" Enabled MCP servers in settings: {', '.join(enabled)}") +print(f" Plugins enabled: {len(selected_plugins)}") +print(f" MCP servers enabled: {len(selected_mcps)}") PYEOF +_step6_rc=$? +set -e -# ── Step 6: Install catalog announcement hook ─────────────────── +if [[ $_step6_rc -eq 0 ]]; then + step_ok "Settings updated" +else + step_fail "Settings update" "could not write settings.json" +fi + +# ── Step 7: Install hooks ──────────────────────────────────────── echo "" -echo "[6/6] Installing catalog announcement hook..." +echo "[7/9] Installing hooks..." +_step_start "Hooks installed" +# 7a: Always deploy the session-start hook (hardcoded — it's the OCC backbone) HOOK_SRC="$SCRIPT_DIR/hooks/your-session-start.sh" -HOOK_DEST="$HOME/.claude/hooks/your-session-start.sh" -GLOBAL_SETTINGS="$HOME/.claude/settings.json" +HOOK_DEST="$CLAUDE_DIR/hooks/your-session-start.sh" if [[ -f "$HOOK_SRC" ]]; then - mkdir -p "$HOME/.claude/hooks" - cp "$HOOK_SRC" "$HOOK_DEST" - chmod +x "$HOOK_DEST" + if ! mkdir -p "$CLAUDE_DIR/hooks" 2>/dev/null; then + step_fail "Hooks" "could not create hooks directory" + elif ! cp "$HOOK_SRC" "$HOOK_DEST" 2>/dev/null; then + step_fail "Hooks" "could not copy session hook to $HOOK_DEST" + else + chmod +x "$HOOK_DEST" + echo " Deployed your-session-start.sh" + fi +else + step_warn "Hooks" "session hook source not found at $HOOK_SRC" +fi + +# 7b: Deploy all hooks listed in assets-manifest.json +MANIFEST="$SCRIPT_DIR/hooks/assets-manifest.json" +if [[ -f "$MANIFEST" ]]; then + _manifest_hooks=$(python3 -c " +import json, sys +m = json.load(open(sys.argv[1])) +for h in m.get('hooks', []): + if isinstance(h, dict): + print(h['file'] + '|' + h.get('event', '')) + else: + print(h + '|') +" "$MANIFEST" 2>/dev/null || true) - # Register in global settings.json - python3 - "$GLOBAL_SETTINGS" "$HOOK_DEST" <<'PYEOF' + while IFS='|' read -r _hfile _hevent; do + [[ -z "$_hfile" ]] && continue + _hsrc="$SCRIPT_DIR/hooks/$_hfile" + _hdest="$CLAUDE_DIR/hooks/$_hfile" + if [[ -f "$_hsrc" ]]; then + cp "$_hsrc" "$_hdest" && chmod +x "$_hdest" + echo " Deployed $_hfile" + else + echo " WARN: $_hfile listed in manifest but not found at $_hsrc" + fi + done <<< "$_manifest_hooks" +fi + +# 7c: Register hooks in settings.json (idempotent) +set +e +python3 - "$SETTINGS" "$CLAUDE_DIR/hooks" "$MANIFEST" <<'PYEOF' import json, sys, os -settings_file, hook_path = sys.argv[1], sys.argv[2] +settings_file = sys.argv[1] +hooks_dir = sys.argv[2] +manifest_file = sys.argv[3] try: settings = json.load(open(settings_file)) if os.path.exists(settings_file) else {} except (json.JSONDecodeError, FileNotFoundError): settings = {} -hooks = settings.setdefault("hooks", {}) -user_prompt_hooks = hooks.setdefault("UserPromptSubmit", []) +hooks_cfg = settings.setdefault("hooks", {}) -# Check if already registered -hook_command = hook_path -# Also clean up any old catalog-announce.sh registration -old_hook = hook_command.replace("your-session-start.sh", "your-catalog-announce.sh") +# --- Session hook (UserPromptSubmit) — always register --- +session_hook = os.path.join(hooks_dir, "your-session-start.sh") +old_hook = session_hook.replace("your-session-start.sh", "your-catalog-announce.sh") +ups = hooks_cfg.setdefault("UserPromptSubmit", []) + +# Remove old renamed hook if present +hooks_cfg["UserPromptSubmit"] = [ + e for e in ups + if not any(h.get("command") == old_hook for h in e.get("hooks", [])) +] +ups = hooks_cfg["UserPromptSubmit"] already_registered = any( - h.get("type") == "command" and h.get("command") == hook_command - for entry in user_prompt_hooks - for h in entry.get("hooks", []) + h.get("type") == "command" and h.get("command") == session_hook + for e in ups for h in e.get("hooks", []) ) +if not already_registered: + ups.append({"matcher": "", "hooks": [{"type": "command", "command": session_hook}]}) + print(f" Registered your-session-start.sh → UserPromptSubmit") +else: + print(f" your-session-start.sh already registered.") -# Remove old hook if present -hooks["UserPromptSubmit"] = [ - entry for entry in user_prompt_hooks - if not any(h.get("command") == old_hook for h in entry.get("hooks", [])) -] -user_prompt_hooks = hooks["UserPromptSubmit"] +# --- Manifest hooks — register by event --- +if os.path.exists(manifest_file): + try: + manifest = json.load(open(manifest_file)) + except (json.JSONDecodeError, FileNotFoundError): + manifest = {} -if not already_registered: - user_prompt_hooks.append({ - "matcher": "", - "hooks": [{"type": "command", "command": hook_command}] - }) + for entry in manifest.get("hooks", []): + if isinstance(entry, dict): + hfile = entry["file"] + hevent = entry.get("event", "") + else: + hfile = entry + hevent = "" + + if not hevent: + continue + + hook_path = os.path.join(hooks_dir, hfile) + if not os.path.isfile(hook_path): + continue + + event_hooks = hooks_cfg.setdefault(hevent, []) + already = any( + h.get("type") == "command" and h.get("command") == hook_path + for e in event_hooks for h in e.get("hooks", []) + ) + if not already: + event_hooks.append({"matcher": "", "hooks": [{"type": "command", "command": hook_path}]}) + print(f" Registered {hfile} → {hevent}") + else: + print(f" {hfile} already registered.") + +try: with open(settings_file, "w") as f: json.dump(settings, f, indent=2) f.write("\n") - print(f" Hook registered in {settings_file}") -else: - print(f" Hook already registered.") +except OSError as e: + print(f" ERROR: Could not write {settings_file}: {e}", file=sys.stderr) + sys.exit(1) PYEOF - echo " Installed to $HOOK_DEST" +_step7_rc=$? +set -e +if [[ $_step7_rc -eq 0 ]]; then + step_ok "Hooks installed" else - echo " Hook script not found at $HOOK_SRC — skipping" + step_fail "Hooks" "could not register hooks in settings.json" fi -# ── Verify .gitignore ─────────────────────────────────────────── -GITIGNORE="$PROJECT_DIR/.gitignore" -if [[ -f "$GITIGNORE" ]]; then - for pattern in ".env" ".mcp.json" ".claude/memory/" ".claude/settings.local.json"; do - if ! grep -qF "$pattern" "$GITIGNORE" 2>/dev/null; then - echo "$pattern" >> "$GITIGNORE" - echo " Added $pattern to .gitignore" +# ── Step 8: Multi-system workflow check ───────────────────────── +echo "" +echo "[8/9] Multi-system workflow check" +_step_start "Multi-system check" +echo " This feature monitors your repos for git drift and syncs Claude memory" +echo " across devices via a cloud storage symlink (OneDrive, Dropbox, etc.)." +echo "" + +MULTI_CHECK_CONFIG="$HOME/.claude/multi-system-check.json" + +if [[ -f "$MULTI_CHECK_CONFIG" ]]; then + echo " Already configured." + _read_input -rp " Reconfigure for this machine? [y/N] " _reconfig_multi + if [[ "$(echo "$_reconfig_multi" | tr '[:upper:]' '[:lower:]')" == "y" ]]; then + echo "" + if bash "$SCRIPT_DIR/scripts/setup-multi-system.sh"; then + step_ok "Multi-system check reconfigured" + else + step_warn "Multi-system check" "reconfigure did not complete" fi - done + else + step_ok "Multi-system check (already configured)" + fi +else + _read_input -rp " Do you work on this project across multiple computers? [Y/n] " MULTI_ANSWER + if [[ "$(echo "$MULTI_ANSWER" | tr '[:upper:]' '[:lower:]')" == "n" ]]; then + if echo '{"enabled": false}' > "$MULTI_CHECK_CONFIG" 2>/dev/null; then + echo " Skipped. To enable later: ~/claude-config/scripts/setup-multi-system.sh" + step_ok "Multi-system check (skipped)" + else + step_fail "Multi-system check" "could not write config to $MULTI_CHECK_CONFIG" + fi + else + echo "" + if bash "$SCRIPT_DIR/scripts/setup-multi-system.sh"; then + step_ok "Multi-system check configured" + else + step_warn "Multi-system check" "setup did not complete" + fi + fi +fi + +# ── Step 9: Memory/preference sync ──────────────────────────── +echo "" +echo "[9/9] Memory/preference sync" +echo " Shows current sync status for org standards, personal prefs, and project memories." +echo "" + +SYNC_SCRIPT="$SCRIPT_DIR/scripts/sync-memory.sh" + +if [[ -f "$SYNC_SCRIPT" ]]; then + echo "" + if bash "$SYNC_SCRIPT"; then + step_ok "Preference sync status shown" + else + step_warn "Preference sync" "status check did not complete" + fi + echo " Org standards sync automatically via setup.sh." + echo " Memory and personal prefs sync automatically via OneDrive symlinks (setup-multi-system.sh)." +else + step_warn "Preference sync" "sync-memory.sh not found at $SYNC_SCRIPT" fi # ── Done ──────────────────────────────────────────────────────── echo "" echo "=== Setup Complete ===" echo "" -echo "Files created/updated:" -echo " .env — API keys (chmod 600, gitignored)" -echo " .mcp.json — MCP server config (chmod 600, gitignored)" -echo " .claude/settings.local.json — Plugin and MCP toggles" -echo " .claude/personas/ — Persona profiles" -echo " .claude/commands/ — Slash commands" +echo "Files created/updated (all global — no project path needed):" +echo " ~/.claude/.env — API keys (chmod 600)" +echo " ~/.claude.json — MCP server registrations (via claude mcp add)" +echo " ~/.claude/settings.json — Plugin and MCP toggles" +echo " ~/.claude/personas/ — Persona profiles (all sessions)" +echo " ~/.claude/commands/ — Slash commands (all sessions)" +echo " ~/.claude/standards/ — Org operational standards (all sessions)" +echo " ~/.claude/skills/ — Shared skills (per-skill symlinks, preserves local skills)" echo "" echo "Next steps:" -echo " 1. Restart Claude Code to load changes" -echo " 2. Test: run /stan-docs or /holly-analyze" -echo " 3. Manage plugins/MCPs later: ~/claude-config/scripts/catalog.sh ." -echo " 4. Any REPLACE_ME values need real keys — check catalog.json for sources" -echo " 5. New catalog tools will be announced automatically at next session start" +echo " 1. Any REPLACE_ME values need real keys — check catalog.json for sources" +echo " 2. Add or remove tools anytime: re-run this script" +echo " 3. On additional machines: re-run this script — multi-system check links automatically" +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ ✅ Claude Code is ready to launch. ║" +echo "║ Restart Claude Code to apply changes. ║" +echo "╚══════════════════════════════════════════════╝"