├── .gitignore ├── bin └── droid-factory.js ├── templates ├── droids │ ├── test-coverage-reviewer.md │ ├── documentation-accuracy-reviewer.md │ ├── performance-reviewer.md │ ├── code-quality-reviewer.md │ ├── security-code-reviewer.md │ ├── todo-fixme-scanner.md │ ├── test-plan-writer.md │ ├── pr-readiness-reviewer.md │ ├── release-notes-writer.md │ └── git-summarizer.md └── commands │ ├── todo-scan.md │ ├── test-plan.md │ ├── code-review.md │ ├── pr-ready.md │ └── release-notes.md ├── package.json ├── lib ├── spinner.js ├── planner.js ├── marketplace-planner.js ├── fs-utils.js ├── command-convert.js ├── args.js ├── output.js ├── agent-convert.js ├── marketplace-ui.js ├── ui.js ├── cli.js └── marketplace.js ├── .github └── workflows │ └── publish.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.tgz 3 | package-lock.json 4 | *.log 5 | .factory/ 6 | -------------------------------------------------------------------------------- /bin/droid-factory.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | 4 | require('../lib/cli').run(process.argv).catch((err) => { 5 | console.error('Installation failed:', err?.message || err); 6 | process.exit(1); 7 | }); 8 | -------------------------------------------------------------------------------- /templates/droids/test-coverage-reviewer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: test-coverage-reviewer 3 | description: Reviews testing implementation and coverage; identifies gaps and brittle tests 4 | model: inherit 5 | tools: read-only 6 | version: v1 7 | --- 8 | Assess tests impacted by the diff: 9 | - Untested code paths, branches, error handling, boundary conditions 10 | - Test quality (AAA structure, specificity, determinism), proper use of doubles 11 | 12 | Respond with: 13 | Coverage Analysis: 14 | - 15 | Missing Scenarios: 16 | - 17 | Recommendations: 18 | - 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "droid-factory", 3 | "version": "0.0.4", 4 | "description": "Install custom Factory Droid subagents and delegate work using custom slash commands.", 5 | "bin": { 6 | "droid-factory": "./bin/droid-factory.js" 7 | }, 8 | "files": [ 9 | "bin", 10 | "templates", 11 | "lib" 12 | ], 13 | "license": "MIT", 14 | "private": false, 15 | "repository": "https://github.com/iannuttall/droid-factory", 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "engines": { 20 | "node": ">=16" 21 | }, 22 | "dependencies": { 23 | "enquirer": "^2.3.6", 24 | "gray-matter": "^4.0.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /templates/droids/documentation-accuracy-reviewer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: documentation-accuracy-reviewer 3 | description: Verifies code documentation, README/API accuracy against implementation changes 4 | model: inherit 5 | tools: read-only 6 | version: v1 7 | --- 8 | Compare documentation against the diff: 9 | - Public interfaces documented, parameters/returns accurate 10 | - Examples reflect current behavior; outdated comments removed 11 | - README/API sections match actual functionality and error responses 12 | 13 | Respond with: 14 | Summary: 15 | Issues: 16 | - — Current: — Fix: 17 | Priorities: 18 | - 19 | -------------------------------------------------------------------------------- /templates/droids/performance-reviewer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: performance-reviewer 3 | description: Identifies performance bottlenecks (algorithmic complexity, N+1, caching, memory/IO) 4 | model: inherit 5 | tools: read-only 6 | version: v1 7 | --- 8 | Analyze the diff for performance risks: 9 | - Inefficient complexity (nested loops, repeated work), blocking ops 10 | - N+1 DB/API calls, missing pagination/projection, caching/memoization ops 11 | - Memory/IO patterns (large allocations in loops, unclosed handles) 12 | 13 | Respond with: 14 | Critical Issues: 15 | - : — Impact: 16 | Optimization Opportunities: 17 | - 18 | Best Practices: 19 | - 20 | -------------------------------------------------------------------------------- /templates/droids/code-quality-reviewer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: code-quality-reviewer 3 | description: Reviews code quality and maintainability (naming, complexity, duplication, error handling, style) 4 | model: inherit 5 | tools: read-only 6 | version: v1 7 | --- 8 | You are an expert code quality reviewer. Given the diff and repo context, assess: 9 | - Naming clarity, single-responsibility, complexity, duplication (DRY) 10 | - Error handling and input validation 11 | - Readability, magic numbers/strings, consistent style/format 12 | 13 | Respond with: 14 | Summary: 15 | Findings: 16 | - severity: : 17 | Fix: 18 | Positives: 19 | - 20 | -------------------------------------------------------------------------------- /lib/spinner.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const SPIN_FRAMES = ['/', '-', '\\', '|']; 4 | 5 | function start(label) { 6 | if (!process.stdout.isTTY) return null; 7 | let frame = 0; 8 | const timer = setInterval(() => { 9 | const glyph = SPIN_FRAMES[frame = (frame + 1) % SPIN_FRAMES.length]; 10 | process.stdout.write(`\r${glyph} ${label}`); 11 | }, 80); 12 | return timer; 13 | } 14 | 15 | function stop(timer) { 16 | if (!timer) return; 17 | clearInterval(timer); 18 | if (process.stdout.isTTY) { 19 | try { 20 | process.stdout.clearLine(0); 21 | process.stdout.cursorTo(0); 22 | } catch { 23 | process.stdout.write('\r'); 24 | } 25 | } 26 | } 27 | 28 | module.exports = { start, stop }; 29 | -------------------------------------------------------------------------------- /templates/droids/security-code-reviewer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: security-code-reviewer 3 | description: Reviews diffs for security issues (OWASP Top 10, secrets, authn/z, input handling, configs) 4 | model: inherit 5 | tools: read-only 6 | version: v1 7 | --- 8 | Perform a security review. Focus on: 9 | - Injection (SQL/NoSQL/command/path), XSS, CSRF, IDOR/access control 10 | - Secrets exposure (keys/tokens/private keys/JWTs), weak crypto, hardcoded secrets 11 | - Session/authn/authz correctness, input validation/encoding 12 | - Risky infra configs (Docker root/latest/exposed ports, CI secrets scope, CORS/debug flags) 13 | 14 | Report by severity with: Description, Location (file:line), Impact, Remediation, References (CWE/OWASP). If no issues, state "No security issues found" and what was verified. 15 | -------------------------------------------------------------------------------- /templates/commands/todo-scan.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Find TODO and FIXME markers in the current branch 3 | argument-hint: 4 | --- 5 | 6 | You are a code maintenance reviewer. Locate outstanding TODO/FIXME markers so the team can resolve or ticket them before shipping. 7 | 8 | Workflow: 9 | 1. Invoke the Task tool with `subagent_type: todo-fixme-scanner`, passing `$ARGUMENTS` as an optional glob/filter (e.g., `src/**`). Ask for grouped results plus severity hints. 10 | 2. If the subagent fails, run `git grep -n "TODO\|FIXME"` (respecting the optional glob) and summarise manually. 11 | 12 | Respond with Markdown containing: 13 | - `Summary` 14 | - `Markers` — list each file with line numbers and snippet 15 | - `Recommended Actions` — assign follow-up owners or tickets 16 | - `Notes` — e.g., false positives or items to keep 17 | 18 | If no markers are found, state so explicitly. 19 | -------------------------------------------------------------------------------- /templates/droids/todo-fixme-scanner.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: todo-fixme-scanner 3 | description: Scan the repo for TODO and FIXME markers and propose follow-up actions 4 | model: inherit 5 | tools: 6 | - Execute 7 | - Read 8 | version: v1 9 | --- 10 | You are a productivity assistant. Report outstanding TODO/FIXME markers so the team can resolve them or turn them into tracked work. 11 | 12 | Instructions: 13 | 1. Accept input containing git-summarizer output and an optional glob filter (e.g., `src/**`). 14 | 2. Run `git grep -n "TODO\|FIXME" `; when no glob is provided, search the entire repository excluding vendor/build artifacts (`-- "*"` already respects `.gitignore`). 15 | 3. For each match, capture file path, line number, and the exact marker text (trimmed). 16 | 4. Categorise markers: 17 | - `Blocking` — appears in staged changes or critical paths (e.g., security, auth, payment code) 18 | - `Important` — should be ticketed soon but not blocking 19 | - `Informational` — notes or long-term backlog items 20 | 5. Suggest next steps (resolve now, create ticket, document why it can remain). 21 | 22 | Output format: 23 | - `Summary` — count of markers by category 24 | - `Markers` — bullets (`- [Blocking] path:line — snippet`) 25 | - `Recommended Actions` — bullet list of owners/actions 26 | - `Notes` — e.g., tests that deliberately include TODO markers 27 | 28 | If no markers are found, return `Summary: No TODO/FIXME markers detected.` and `Markers: - None`. 29 | -------------------------------------------------------------------------------- /templates/commands/test-plan.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Generate a targeted test plan for recent changes 3 | argument-hint: 4 | --- 5 | 6 | You are a QA lead. Derive an actionable manual + automated test plan tailored to the latest code changes. Operate in READ-ONLY mode. 7 | 8 | Workflow: 9 | 1. Invoke the Task tool with `subagent_type: git-summarizer` to capture repository context (diffs, file list, commits). Retain the Markdown output for downstream use. 10 | 2. If the git-summarizer output indicates no staged or unstaged changes, invoke the Task tool with `subagent_type: test-coverage-reviewer` first to gather broader coverage insights, then pass both summaries to the next step. 11 | 3. Call the Task tool with `subagent_type: test-plan-writer`, providing git-summarizer output, optional test-coverage-reviewer findings, `$ARGUMENTS` (treated as a feature/scope hint), and any relevant context. Request a concise yet thorough test matrix. 12 | 4. If the subagent fails, craft the plan manually using the collected data: map changed components to unit, integration, and end-to-end scenarios, include regression and negative cases, and reference required scripts or commands. 13 | 14 | Respond with Markdown containing the following sections: 15 | - `Summary` 16 | - `Automated Tests` 17 | - `Manual Scenarios` 18 | - `Regression / Guardrails` 19 | - `Open Questions` 20 | 21 | Each bullet should include pass criteria and any commands or data needed. Note `- None` when a section does not apply. 22 | -------------------------------------------------------------------------------- /templates/droids/test-plan-writer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: test-plan-writer 3 | description: Produce focused automated and manual test plans for a set of code changes 4 | model: inherit 5 | tools: 6 | - Execute 7 | - Read 8 | version: v1 9 | --- 10 | You are a QA lead tasked with turning git-summarizer output into a concrete test plan. 11 | 12 | Input includes: 13 | - Repository summary (branch status, staged/untracked files) 14 | - Diff excerpts and impacted files 15 | - Commit messages and optional feature hints from the caller 16 | - (Optional) Coverage notes from `test-coverage-reviewer` 17 | 18 | Deliverables: 19 | - Summary of test scope 20 | - Automated test recommendations with exact commands or scripts to run 21 | - Manual/Exploratory scenarios (step-by-step with expected outcome) 22 | - Regression guardrails covering adjacent systems, feature flags, rollbacks 23 | - Open questions or follow-ups (e.g., data seeds, staging env access) 24 | 25 | Guidelines: 26 | 1. Map each changed component to relevant test layers (unit, integration, e2e). Reference existing test suites or filenames when possible. 27 | 2. Highlight critical paths, edge cases, and negative scenarios. Include accessibility, performance, or security checks if applicable. 28 | 3. Use Markdown headings provided by the caller (`Summary`, `Automated Tests`, etc.). Under each, list bullets as `- — command/criteria`. 29 | 4. Flag missing coverage explicitly and suggest new tests when required. 30 | 5. Keep language concise and directive so engineers can run the plan immediately. 31 | 32 | If information is insufficient to produce a plan, state what additional context is needed. 33 | -------------------------------------------------------------------------------- /templates/commands/code-review.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Run a precise, thorough code review (git status, bug risks, merge readiness, security) 3 | argument-hint: 4 | --- 5 | 6 | You are a senior code reviewer. Perform a READ-ONLY code review; never commit/push/modify state. 7 | 8 | Workflow: 9 | 1. Collect repository context by invoking the Task tool with `subagent_type: git-summarizer`. Supply `$ARGUMENTS` (if provided) for additional hints (e.g., target path or branch). Store the returned Markdown for downstream droids and include key highlights in your final response. 10 | 2. Delegate focused passes using the gathered summary: 11 | - `code-quality-reviewer` 12 | - `security-code-reviewer` 13 | - `performance-reviewer` 14 | - `test-coverage-reviewer` 15 | - `documentation-accuracy-reviewer` 16 | Provide each subagent with the git-summarizer output plus any relevant context from the session. Ask them to return only high-signal findings. 17 | 3. If any subagent fails or is unavailable, cover its checklist yourself using the git-summarizer data. 18 | 4. Consolidate results, deduplicate overlapping issues, and prioritise by severity. Be explicit when no blockers are found but justify why. 19 | 20 | Focus areas for the final review: 21 | - Correctness risks (logic, null safety, error handling, race conditions) 22 | - Security issues (secrets, authn/z, injection, dependency risks) 23 | - Merge readiness (branch divergence, conflicts, missing reviews/tests) 24 | - Test coverage gaps and concrete follow-up actions 25 | 26 | Respond with: 27 | Summary: 28 | Blockers: 29 | Security: 30 | Correctness/Bug Risks: 31 | Merge Readiness: 32 | Tests & Coverage: 33 | Recommendations: 34 | -------------------------------------------------------------------------------- /templates/commands/pr-ready.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Validate branch readiness before opening a pull request 3 | --- 4 | 5 | You are a PR readiness reviewer. Gather data in read-only mode and determine whether the current branch is ready to submit for review. Only write to disk if the user explicitly asks you to save the final report. 6 | 7 | Workflow: 8 | 1. Invoke the Task tool with `subagent_type: git-summarizer` to gather repository context. Save the Markdown output; it will be supplied to downstream droids. 9 | 2. Call the Task tool with `subagent_type: pr-readiness-reviewer`, providing the git-summarizer output, `$ARGUMENTS` (if supplied), and any relevant session context. Ask for a concise readiness verdict plus required follow-ups. 10 | 3. If the subagent fails, manually evaluate readiness using the git-summarizer data: confirm clean status, required tests, docs updates, changelog entries, dependency or migration impacts, and outstanding TODO/FIXME markers. 11 | 4. Produce an actionable summary highlighting blockers, recommended actions, owners, and whether the branch is PR-ready. 12 | 13 | Saving the report (interactive): after you present the readiness report inline, ask the user if they want it saved to Markdown. If yes, suggest a default path like `reports/pr-ready-YYYYMMDD.md` (relative to the repo root), create parent directories if needed, write the file, and confirm the saved location. If the user declines, do not write anything. When saving, write the output verbatim, exactly as presented (no extra headers or formatting changes). 14 | 15 | Respond with: 16 | Summary: 17 | Status: 18 | Required Actions: 19 | - 20 | Tests: 21 | - 22 | Documentation & Notes: 23 | - 24 | -------------------------------------------------------------------------------- /lib/planner.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { getTemplateDescription } = require('./fs-utils'); 6 | 7 | function resolveSelection(request, available, kind) { 8 | if (!request) return null; // caller will use defaults 9 | if (request === 'all') return [...available]; 10 | const wanted = request.split(',').map((s) => s.trim()).filter(Boolean); 11 | const result = []; 12 | const missing = []; 13 | for (const name of wanted) { 14 | const normalized = name.replace(/\.md$/, ''); 15 | if (available.includes(normalized)) result.push(normalized); 16 | else missing.push(name); 17 | } 18 | if (missing.length) { 19 | console.warn(`Warning: Unknown ${kind} template(s): ${missing.join(', ')}`); 20 | } 21 | return result; 22 | } 23 | 24 | function computePlan({ selectedCommands, selectedDroids, templateCommandsDir, templateDroidsDir, destCommandsDir, destDroidsDir }) { 25 | const commands = selectedCommands.map((name) => { 26 | const src = path.join(templateCommandsDir, `${name}.md`); 27 | const dest = path.join(destCommandsDir, `${name}.md`); 28 | return { 29 | name, 30 | src, 31 | dest, 32 | description: getTemplateDescription(src), 33 | exists: fs.existsSync(dest), 34 | }; 35 | }); 36 | 37 | const droids = selectedDroids.map((name) => { 38 | const src = path.join(templateDroidsDir, `${name}.md`); 39 | const dest = path.join(destDroidsDir, `${name}.md`); 40 | return { 41 | name, 42 | src, 43 | dest, 44 | description: getTemplateDescription(src), 45 | exists: fs.existsSync(dest), 46 | }; 47 | }); 48 | 49 | return { commands, droids }; 50 | } 51 | 52 | module.exports = { resolveSelection, computePlan }; 53 | -------------------------------------------------------------------------------- /templates/droids/pr-readiness-reviewer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: pr-readiness-reviewer 3 | description: Assess branch readiness for pull request submission (tests, docs, blockers) 4 | model: inherit 5 | tools: 6 | - Execute 7 | - Read 8 | version: v1 9 | --- 10 | You are a senior engineer verifying that a branch is ready for pull request submission. You will receive git-summarizer output and optional notes from the caller. 11 | 12 | Delivereables: 13 | - A readiness verdict: `Ready` or `Needs Work` 14 | - Required actions grouped by priority with suggested owners 15 | - Clear callouts for missing tests, documentation, changelog entries, and dependency risks 16 | 17 | Checklist: 18 | 1. **Repository hygiene** — Ensure there are no unstaged or untracked files, and note any merge conflicts or divergence from the base branch. 19 | 2. **TODO/FIXME scan** — Highlight outstanding TODO/FIXME markers in staged files. Use `git grep -n "TODO\|FIXME"` when permitted; otherwise request that the caller run it. 20 | 3. **Tests & validation** — Confirm relevant automated tests (unit/integration/e2e) have been run or specify which commands must be executed before opening the PR. 21 | 4. **Documentation & changelog** — Flag required README/API docs, release notes, or migration guides that must be updated. Mention if nothing is needed. 22 | 5. **Dependencies & migrations** — Note any dependency bumps, config changes, schema migrations, or feature flags that require special handling. 23 | 24 | Formatting: 25 | - Start with `Status: Ready` or `Status: Needs Work`. 26 | - Under `Required Actions`, list items as `- ` sorted by severity. 27 | - Provide sections for `Tests`, `Documentation`, and `Risks`. Use `- None` when empty. 28 | - Keep responses concise but specific, referencing file paths or commit SHAs when applicable. 29 | 30 | If information is missing to make a judgment, clearly state what additional data is required. 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "release: npm publish" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - package.json 8 | workflow_dispatch: 9 | 10 | jobs: 11 | publish: 12 | if: github.repository_owner == 'iannuttall' 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | id-token: write 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | registry-url: https://registry.npmjs.org 26 | 27 | - name: Install deps (no lockfile) 28 | run: npm install --ignore-scripts --no-audit --no-fund 29 | 30 | - name: Determine if publish is needed 31 | id: check 32 | run: | 33 | PKG_NAME=$(node -p "require('./package.json').name") 34 | PKG_VERSION=$(node -p "require('./package.json').version") 35 | NPM_VERSION=$(npm view "$PKG_NAME" version || echo '0.0.0') 36 | echo "pkg_version=$PKG_VERSION" >> $GITHUB_OUTPUT 37 | echo "npm_version=$NPM_VERSION" >> $GITHUB_OUTPUT 38 | if [ "$PKG_VERSION" = "$NPM_VERSION" ]; then 39 | echo "should_publish=false" >> $GITHUB_OUTPUT 40 | else 41 | echo "should_publish=true" >> $GITHUB_OUTPUT 42 | fi 43 | 44 | - name: Mask token 45 | if: steps.check.outputs.should_publish == 'true' 46 | run: echo "::add-mask::${{ secrets.NPM_TOKEN }}" 47 | 48 | - name: Publish to npm 49 | if: steps.check.outputs.should_publish == 'true' 50 | run: npm publish --access public --provenance 51 | env: 52 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 53 | 54 | - name: Skip publish (version already on npm) 55 | if: steps.check.outputs.should_publish != 'true' 56 | run: echo "Version ${{ steps.check.outputs.pkg_version }} already published as ${{ steps.check.outputs.npm_version }}; skipping." 57 | -------------------------------------------------------------------------------- /templates/droids/release-notes-writer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: release-notes-writer 3 | description: Analyse commit history to produce structured release notes ordered by impact 4 | model: inherit 5 | tools: 6 | - Execute 7 | - Read 8 | version: v1 9 | --- 10 | You are a release notes writer. Given commit metadata, diffs, and contextual notes from the parent agent, produce a crisp Markdown summary for engineers and stakeholders. 11 | 12 | Expect input containing: 13 | - Markdown output from `git-summarizer` (repository status, staged/unstaged diffs, commit table, range notes) 14 | - Additional guidance such as target release tag, PR highlights, or areas of concern 15 | 16 | Responsibilities: 17 | 1. Classify entries into the following buckets (in this exact order): 18 | - **New Features** – feature additions, enhancements, performance improvements 19 | - **Security Fixes** – vulnerabilities, auth/authz hardening, dependency security patches 20 | - **Bug Fixes** – non-security defect resolutions, stability improvements 21 | - **Other Changes** – documentation, chores, refactors, tooling adjustments 22 | 2. Within each bucket order items chronologically (oldest first) unless a critical fix should be surfaced sooner. 23 | 3. Each bullet should contain: short description, reference (PR number or short SHA with link), author(s), and any required follow-up/testing notes. 24 | 4. Capture breaking changes or migrations with an explicit **⚠ Breaking change** prefix. 25 | 5. Produce a final `Release Summary` section covering: 26 | - Range (e.g. `v1.2.3…HEAD`) 27 | - High level impact 28 | - Outstanding risks, TODOs, or verification gaps 29 | 30 | Formatting rules: 31 | - Use Markdown headings for each section, even if a section is empty (write `- None` when applicable). 32 | - Reference PRs or commits as `[Description](link)` when URLs are provided; otherwise show short SHA in backticks. 33 | - Keep wording concise (<2 sentences per entry) but include essential context, especially for security fixes. 34 | 35 | If the input does not provide sufficient data, request the missing information explicitly. Otherwise return only the final Markdown. 36 | -------------------------------------------------------------------------------- /lib/marketplace-planner.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const path = require('path'); 4 | 5 | function computeMarketplacePlan({ selectedPlugins, discovered, destCommandsDir, destDroidsDir }) { 6 | const selected = (selectedPlugins === 'all') 7 | ? discovered 8 | : discovered.filter((p) => selectedPlugins.includes(p.name)); 9 | 10 | const unresolved = []; 11 | const commands = []; 12 | const droids = []; 13 | 14 | for (const p of selected) { 15 | const res = p.resolved || { kind: 'unsupported', reason: 'Unknown' }; 16 | const errors = Array.isArray(p.errors) ? p.errors.filter(Boolean) : []; 17 | const hasCommands = Array.isArray(p.commands) && p.commands.length > 0; 18 | const hasAgents = Array.isArray(p.agents) && p.agents.length > 0; 19 | const hookCount = Array.isArray(p.hooks) ? p.hooks.length : 0; 20 | 21 | if (errors.length) { 22 | for (const err of errors) { 23 | unresolved.push({ plugin: p.name, reason: err }); 24 | } 25 | } 26 | 27 | if (hookCount) { 28 | const label = hookCount === 1 ? 'hook' : 'hooks'; 29 | unresolved.push({ plugin: p.name, reason: `${hookCount} ${label} not installed (unsupported)` }); 30 | } 31 | 32 | if (!hasCommands && !hasAgents) { 33 | if (!errors.length && !hookCount) { 34 | unresolved.push({ plugin: p.name, reason: res.reason || 'No components found' }); 35 | } 36 | continue; 37 | } 38 | 39 | // Commands 40 | for (const c of (p.commands || [])) { 41 | const isUrl = /^https?:\/\//i.test(c); 42 | const name = isUrl ? path.basename(c).replace(/\.md$/i, '') : path.basename(c).replace(/\.md$/i, ''); 43 | const dest = path.join(destCommandsDir, `${name}.md`); 44 | commands.push({ plugin: p.name, name, src: c, srcType: isUrl ? 'remote' : 'local', dest }); 45 | } 46 | 47 | // Agents → droids 48 | for (const a of (p.agents || [])) { 49 | const isUrl = /^https?:\/\//i.test(a); 50 | const name = isUrl ? path.basename(a).replace(/\.md$/i, '') : path.basename(a).replace(/\.md$/i, ''); 51 | const dest = path.join(destDroidsDir, `${name}.md`); 52 | droids.push({ plugin: p.name, name, src: a, srcType: isUrl ? 'remote' : 'local', dest }); 53 | } 54 | } 55 | 56 | return { commands, droids, unresolved }; 57 | } 58 | 59 | module.exports = { computeMarketplacePlan }; 60 | -------------------------------------------------------------------------------- /templates/droids/git-summarizer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: git-summarizer 3 | description: Collects detailed repository context (status, diffs, commit range) for downstream reviewers 4 | model: inherit 5 | tools: 6 | - Execute 7 | - Read 8 | version: v1 9 | --- 10 | You are a release engineer tasked with gathering a comprehensive yet digestible snapshot of the current git repository state. Act in READ-ONLY mode—never stage, commit, or mutate files. 11 | 12 | Required data to capture: 13 | 1. **Repository identity** 14 | - Branch info: `git status --porcelain -b` 15 | - Upstream tracking, ahead/behind counts 16 | - `git rev-parse HEAD` for the current commit 17 | - `git remote -v` (dedupe identical URLs) 18 | 2. **Tag & range discovery** 19 | - Previous annotated or lightweight tag: `git describe --tags --abbrev=0` (handle failure gracefully) 20 | - Candidate comparison ranges: previous tag → HEAD; otherwise last 20 commits (`HEAD~20..HEAD`) 21 | 3. **Status overview** 22 | - Staged files: `git diff --cached --name-status` 23 | - Unstaged files: `git diff --name-status` 24 | - Untracked files: `git ls-files --others --exclude-standard` 25 | 4. **Diff excerpts** 26 | - Provide unified diffs for staged changes (`git diff --cached --unified=3`) 27 | - If unstaged changes exist, also include `git diff --unified=3` 28 | - For large diffs, note truncation and provide file-level summaries 29 | 5. **Commit summary** 30 | - `git log --no-merges --date=short --pretty=format:"%H%x09%an%x09%ad%x09%s" ` using the determined comparison range 31 | - Include top-level stats (`git diff --stat` for the range) 32 | 33 | Output format: 34 | - Return Markdown with the following headings in order: 35 | 1. `## Repository` 36 | 2. `## Status` 37 | 3. `## Staged Diff` 38 | 4. `## Unstaged Diff` 39 | 5. `## Commit Summary` 40 | 6. `## Range Details` 41 | - Use fenced code blocks for raw command output (label with the command that produced it). 42 | - Highlight potential issues (merge conflicts, detached HEAD, missing upstream) with bold callouts. 43 | - If a section has no data, write `- None` so downstream agents can rely on structure. 44 | 45 | Additional guidance: 46 | - Keep command output complete but concise; prefer unified context of 3 lines. 47 | - Annotate any failures (e.g., “No previous tag found”). 48 | - Do not execute commands outside git or inspect sensitive files. 49 | - Finish with a short summary paragraph highlighting: 50 | - Branch and range used 51 | - Count of staged vs unstaged files 52 | - Estimated risk factors (large diffs, security-related keywords spotted) 53 | 54 | Return only the Markdown summary. Downstream agents will consume this verbatim. 55 | -------------------------------------------------------------------------------- /templates/commands/release-notes.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Generate structured release notes (features, security fixes, bug fixes) 3 | --- 4 | 5 | You are a release manager. Produce concise, high-signal release notes from recent changes. Gather data in read-only mode; only write to disk if the user explicitly confirms a save location after you present the notes. 6 | 7 | Workflow: 8 | 1. Invoke the Task tool with `subagent_type: git-summarizer` to gather repository context (branch, tags, diffs, commit log). Preserve the returned Markdown; it will be forwarded to downstream subagents. 9 | 2. Call the Task tool with `subagent_type: release-notes-writer`, passing the git-summarizer output and any additional guidance (e.g., known release scope, notable PRs). If the subagent fails, use the summary to craft the notes yourself following steps 3–5. 10 | 3. Structure the release notes in Markdown with sections ordered by priority: 11 | - `New Features` 12 | - `Security Fixes` 13 | - `Bug Fixes` 14 | - `Other Changes` (include documentation, tooling, chores only if relevant) 15 | 4. For each entry include: concise description, PR/commit reference (link when possible), author(s), and follow-up/testing notes. Highlight breaking changes explicitly. 16 | 5. End with a `Release Summary` section listing: 17 | - Tag or range covered 18 | - High-level impact 19 | - Known risks or follow-up tasks 20 | - Testing/verification status (note gaps explicitly) 21 | 22 | Saving the report (interactive): 23 | - After you present the release notes inline, ask the user if they want them saved to Markdown. If yes, propose a default path such as `notes/release-notes-YYYYMMDD.md` or `notes/vX.Y.Z.md` (relative to the repo root). 24 | - Validate parent directories exist; create them if necessary. 25 | - Only then write the file and confirm the saved location. If the user declines, do not write anything. 26 | - When saving, write the output verbatim, exactly as presented in your final response (no additional headers, timestamps, or formatting changes). 27 | 28 | Respond with the Markdown release notes inline using the template below. Reference git-summarizer highlights when relevant. If a file path was supplied and the write succeeded, confirm the saved location. 29 | 30 | ### Example layout 31 | 32 | ``` 33 | ## New Features 34 | - Feature title — [PR #123](https://example.com/pr/123) (Author) 35 | 36 | ## Security Fixes 37 | - None 38 | 39 | ## Bug Fixes 40 | - Fix null handling in payment flow — [commit abc123](https://example.com/commit/abc123) (Author) — Tests: `npm test -- payment` 41 | 42 | ## Other Changes 43 | - Docs: Update onboarding guide — [PR #124](https://example.com/pr/124) 44 | 45 | ## Release Summary 46 | - Range: v1.2.0…HEAD 47 | - Impact: Improved checkout resilience and onboarding docs 48 | - Risks: Monitor payment service latency (retry strategy updated) 49 | - Testing: `npm test`, `npm run integration:payments` 50 | ``` 51 | -------------------------------------------------------------------------------- /lib/fs-utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | const https = require('https'); 7 | 8 | function listBasenames(dir) { 9 | if (!fs.existsSync(dir)) return []; 10 | return fs.readdirSync(dir) 11 | .filter((f) => f.endsWith('.md')) 12 | .map((f) => f.replace(/\.md$/, '')) 13 | .sort(); 14 | } 15 | 16 | function ensureDir(dir) { 17 | fs.mkdirSync(dir, { recursive: true }); 18 | } 19 | 20 | function copyFile(src, dest, force) { 21 | if (fs.existsSync(dest) && !force) return 'skipped'; 22 | ensureDir(path.dirname(dest)); 23 | fs.copyFileSync(src, dest); 24 | return 'written'; 25 | } 26 | 27 | function downloadToFile(url, dest, force) { 28 | if (fs.existsSync(dest) && !force) return Promise.resolve('skipped'); 29 | ensureDir(path.dirname(dest)); 30 | return new Promise((resolve, reject) => { 31 | const file = fs.createWriteStream(dest); 32 | const req = https.get(url, { headers: { 'User-Agent': 'droid-factory' } }, (res) => { 33 | if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { 34 | res.pipe(file); 35 | file.on('finish', () => file.close(() => resolve('written'))); 36 | } else { 37 | file.close(() => fs.unlink(dest, () => resolve('skipped'))); 38 | } 39 | }); 40 | req.on('error', (err) => { file.close(() => fs.unlink(dest, () => reject(err))); }); 41 | }); 42 | } 43 | 44 | function getTemplateDescription(filePath) { 45 | try { 46 | const content = fs.readFileSync(filePath, 'utf8'); 47 | if (!content.startsWith('---')) return null; 48 | const end = content.indexOf('\n---', 3); 49 | if (end === -1) return null; 50 | const frontMatter = content.slice(3, end).split('\n'); 51 | for (const rawLine of frontMatter) { 52 | const line = rawLine.trim(); 53 | if (line.toLowerCase().startsWith('description:')) { 54 | const value = line.slice('description:'.length).trim(); 55 | return value.replace(/^['"]|['"]$/g, ''); 56 | } 57 | } 58 | } catch { /* ignore */ } 59 | return null; 60 | } 61 | 62 | function readCustomDroidsSetting() { 63 | const settingsPath = path.join(os.homedir(), '.factory', 'settings.json'); 64 | try { 65 | const raw = fs.readFileSync(settingsPath, 'utf8'); 66 | const stripped = raw 67 | .replace(/\/\*[\s\S]*?\*\//g, '') 68 | .replace(/(^|\n)\s*\/\/.*?(?=\n|$)/g, '$1'); 69 | const data = JSON.parse(stripped); 70 | return { enabled: data?.enableCustomDroids === true, path: settingsPath }; 71 | } catch (err) { 72 | if (err.code === 'ENOENT') return { enabled: false, missing: true, path: settingsPath }; 73 | return { enabled: false, error: err, path: settingsPath }; 74 | } 75 | } 76 | 77 | module.exports = { 78 | listBasenames, 79 | ensureDir, 80 | copyFile, 81 | getTemplateDescription, 82 | readCustomDroidsSetting, 83 | downloadToFile, 84 | }; 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Droid Factory install 2 | 3 | Install custom Factory Droid subagents and delegate work using custom slash commands with a single `npx droid-factory` (or `bunx droid-factory`) call. By default it launches a guided, step‑by‑step installer where you pick the install location (personal or project) and choose which commands and droids to install; flags are available for non‑interactive “install everything” runs. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | npx droid-factory 9 | ``` 10 | ```bash 11 | bunx droid-factory 12 | ``` 13 | 14 | The guided flow highlights existing files, lets you decide on overwrites, and ends with a concise summary. 15 | 16 | Ensure **Custom Droids** are enabled in Factory (`/settings` → Experimental → Custom Droids) or by adding `"enableCustomDroids": true` to `~/.factory/settings.json`; otherwise the installed commands cannot launch their helper agents. 17 | 18 | ### Guided flow 19 | 20 | - Step 1/6 — Choose install location (Personal `~/.factory` or Project `./.factory`) 21 | - Step 2/6 — Choose source (Templates or Marketplace) 22 | 23 | Templates (bundled): 24 | - Step 3/6 — Install all commands and droids? 25 | - Step 4/6 — Select commands to install 26 | - Step 5/6 — Select droids to install 27 | - Step 6/6 — Overwrite existing files if found? 28 | - Summary shows what was created/overwritten/skipped and next steps 29 | 30 | Marketplace: 31 | - Step 3/6 — Enter marketplace (path/url/owner/repo) 32 | - Step 4/6 — Install all plugins? 33 | - Step 5/6 — Select plugins to install 34 | - Step 6/6 — Overwrite existing files if found? 35 | - Summary shows what was created/overwritten/skipped and next steps 36 | 37 | ### Optional flags (non-interactive) 38 | 39 | - `--yes` — run without interactive confirmations 40 | - `--dry-run` — preview actions and summary without writing files 41 | - `--scope personal|project` and `--path ` — target install location 42 | - `--commands all|name1,name2` and `--droids all|name1,name2` — select what to install 43 | - `--only-commands`, `--only-droids` — limit to one type 44 | - `--force` — overwrite existing files 45 | - `--list` — list available templates 46 | - `--verbose` — print the detailed plan 47 | - Marketplace: `--marketplace `, `--plugins all|name1,name2`, `--import marketplace|templates`, `--ref `, `--debug` 48 | 49 | ## Contributing commands or droids 50 | 51 | 1. Fork this repository. 52 | 2. Add or modify Markdown templates in `templates/commands/` or `templates/droids/`. 53 | 3. Run `npm test` or `npm run lint` if scripts are provided (future roadmap). 54 | 4. Verify your changes locally (see "Local development" below). 55 | 5. Commit with clear messages and open a pull request against the main branch. 56 | 6. Include before/after installer output as needed to demonstrate your change. 57 | 58 | ## Local development 59 | 60 | ```bash 61 | git clone https://github.com/iannuttall/droid-factory.git 62 | cd droid-factory 63 | npm install 64 | 65 | node bin/droid-factory.js --dry-run --yes 66 | node bin/droid-factory.js --yes 67 | ``` 68 | 69 | Use `npm pack` to sanity-check the tarball before publishing, and remember to bump `package.json` versions per semantic versioning guidelines. 70 | 71 | 72 | -------------------------------------------------------------------------------- /lib/command-convert.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const matter = require('gray-matter'); 4 | 5 | function normalizeSingleLine(s) { 6 | return String(s).replace(/\s*\n\s*/g, ' ').replace(/\s+/g, ' ').trim(); 7 | } 8 | 9 | function deriveDescriptionFromBody(content) { 10 | const lines = String(content || '').split(/\r?\n/); 11 | for (let line of lines) { 12 | line = (line || '').trim(); 13 | if (!line) continue; 14 | line = line.replace(/^#+\s*/, '').replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, ''); 15 | line = normalizeSingleLine(line); 16 | if (line) { 17 | if (line.length > 200) { 18 | const cut = line.slice(0, 200); 19 | return cut.replace(/\s+\S*$/, '').trim(); 20 | } 21 | return line; 22 | } 23 | } 24 | return ''; 25 | } 26 | 27 | function isPlainYamlSafe(s) { 28 | if (typeof s !== 'string') return false; 29 | if (!s.length) return true; 30 | if (/\r|\n/.test(s)) return false; 31 | if (/^\s/.test(s)) return false; 32 | if (/\s$/.test(s)) return false; 33 | if (/^[-?:,\[\]{}#&*!|>'"%@`]/.test(s)) return false; 34 | if (/:\s/.test(s)) return false; 35 | if (/#/.test(s)) return false; 36 | return true; 37 | } 38 | 39 | const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 40 | 41 | function emitYamlKV(key, value, { alwaysQuote = false } = {}) { 42 | if (value === undefined || value === null) return null; 43 | const v = String(value); 44 | if (!v.length) return `${key}:`; 45 | if (!alwaysQuote && isPlainYamlSafe(v)) return `${key}: ${v}`; 46 | return `${key}: "${esc(v)}"`; 47 | } 48 | 49 | function convertCommandMarkdownToFactory(mdText) { 50 | try { 51 | const parsed = matter(mdText || '', { language: 'yaml' }); 52 | const src = parsed.data || {}; 53 | const body = parsed.content || ''; 54 | 55 | let description = src.description; 56 | if (typeof description === 'string' && description.trim().length) { 57 | description = normalizeSingleLine(description); 58 | } else { 59 | const derived = deriveDescriptionFromBody(body); 60 | description = derived || undefined; 61 | } 62 | 63 | let argHint = src['argument-hint']; 64 | if (Array.isArray(argHint)) { 65 | const items = argHint.map((x) => String(x).trim()).filter(Boolean); 66 | argHint = items.length ? items.map((x) => `[${x}]`).join(' ') : undefined; 67 | } else if (argHint !== undefined && argHint !== null) { 68 | argHint = normalizeSingleLine(String(argHint)); 69 | if (argHint === '') argHint = undefined; 70 | } 71 | 72 | let allowedTools = src['allowed-tools']; 73 | if (Array.isArray(allowedTools)) { 74 | allowedTools = allowedTools.map((x) => String(x).trim()).filter(Boolean).join(', '); 75 | } else if (allowedTools !== undefined && allowedTools !== null) { 76 | allowedTools = String(allowedTools).trim(); 77 | if (allowedTools === '') allowedTools = undefined; 78 | } 79 | 80 | const lines = ['---']; 81 | const addLine = (str) => { if (str) lines.push(str); }; 82 | 83 | if (description !== undefined) addLine(emitYamlKV('description', description, { alwaysQuote: false })); 84 | if (argHint !== undefined) addLine(emitYamlKV('argument-hint', argHint, { alwaysQuote: true })); 85 | if (allowedTools !== undefined) addLine(emitYamlKV('allowed-tools', allowedTools, { alwaysQuote: true })); 86 | 87 | lines.push('---'); 88 | 89 | return lines.join('\n') + '\n\n' + body; 90 | } catch (e) { 91 | return mdText || ''; 92 | } 93 | } 94 | 95 | module.exports = { convertCommandMarkdownToFactory }; 96 | -------------------------------------------------------------------------------- /lib/args.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const path = require('path'); 4 | 5 | function parseArgs(argv) { 6 | const args = { 7 | scope: 'personal', 8 | path: '', 9 | force: false, 10 | verbose: false, 11 | help: false, 12 | yes: false, 13 | dryRun: false, 14 | noCommands: false, 15 | noDroids: false, 16 | onlyCommands: false, 17 | onlyDroids: false, 18 | commands: undefined, 19 | droids: undefined, 20 | list: false, 21 | // Marketplace-related 22 | marketplace: undefined, 23 | plugins: undefined, 24 | import: undefined, 25 | ref: undefined, 26 | debug: false 27 | }; 28 | 29 | for (let i = 2; i < argv.length; i++) { 30 | const a = argv[i]; 31 | if (a === '--scope' && i + 1 < argv.length) { args.scope = argv[++i]; } 32 | else if (a === '--path' && i + 1 < argv.length) { args.path = argv[++i]; } 33 | else if (a === '--force') { args.force = true; } 34 | else if (a === '--yes' || a === '-y') { args.yes = true; } 35 | else if (a === '--dry-run') { args.dryRun = true; } 36 | else if (a === '--no-commands') { args.noCommands = true; } 37 | else if (a === '--no-droids') { args.noDroids = true; } 38 | else if (a === '--only-commands') { args.onlyCommands = true; } 39 | else if (a === '--only-droids') { args.onlyDroids = true; } 40 | else if (a === '--commands' && i + 1 < argv.length) { args.commands = argv[++i]; } 41 | else if (a === '--droids' && i + 1 < argv.length) { args.droids = argv[++i]; } 42 | else if (a === '--list') { args.list = true; } 43 | else if (a === '--verbose') { args.verbose = true; } 44 | else if (a === '--debug') { args.debug = true; } 45 | // Marketplace additions 46 | else if (a === '--marketplace' && i + 1 < argv.length) { args.marketplace = argv[++i]; } 47 | else if (a === '--plugins' && i + 1 < argv.length) { args.plugins = argv[++i]; } 48 | else if (a === '--import' && i + 1 < argv.length) { args.import = argv[++i]; } 49 | else if (a === '--ref' && i + 1 < argv.length) { args.ref = argv[++i]; } 50 | else if (a === '-h' || a === '--help') { args.help = true; } 51 | else { 52 | // ignore unknown arguments for forward compatibility 53 | } 54 | } 55 | return args; 56 | } 57 | 58 | function usage(invokedPath) { 59 | const invoked = path.basename(invokedPath || process.argv[1] || 'droid-factory'); 60 | return `\nUsage: ${invoked} [options]\n\nTargets:\n --scope personal|project Install to ~/.factory (default) or /.factory\n --path Required when --scope=project\n\nTemplate selection (defaults: commands=all, droids=all):\n --commands all|name1,name2 Install all or specific commands\n --droids all|name1,name2 Install all or specific droids\n --no-commands Skip installing commands\n --no-droids Skip installing droids\n --only-commands Commands only (implies --no-droids)\n --only-droids Droids only (implies --no-commands)\n --list List available templates then exit\n\nMarketplace import:\n --marketplace Load a Claude Code marketplace\n --plugins all|name1,name2 Select plugins to install (agents→droids, commands)\n --import templates|marketplace Choose import source non-interactively\n --ref GitHub ref for marketplace/plugins (default: main|master)\n\nOther:\n --force Overwrite existing files\n --yes, -y Skip confirmation prompt\n --dry-run Show plan only (no writes)\n --verbose Verbose logging\n -h, --help Show this help\n\nNotes:\n- Names refer to template basenames (e.g. code-review, security-code-reviewer).\n- Defaults install to personal scope → ~/.factory/{commands,droids}.\n- When --scope=project, pass --path pointing at the repo root.\n`; 61 | } 62 | 63 | module.exports = { parseArgs, usage }; 64 | -------------------------------------------------------------------------------- /lib/output.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function dim(str) { return `\x1b[2m${str}\x1b[0m`; } 4 | function green(str) { return `\x1b[32m${str}\x1b[0m`; } 5 | function cyan(str) { return `\x1b[36m${str}\x1b[0m`; } 6 | function bold(str) { return `\x1b[1m${str}\x1b[0m`; } 7 | 8 | const symbols = { CHECK: '*', ARROW: '>' }; 9 | 10 | function plural(n, one, many) { return n === 1 ? one : (many || one + 's'); } 11 | 12 | function termWidth() { return (process.stdout && process.stdout.columns) ? process.stdout.columns : 80; } 13 | function truncate(str, max) { if (!str) return ''; if (str.length <= max) return str; return str.slice(0, Math.max(0, max - 1)) + '…'; } 14 | 15 | function printPlan(plan, args, destCommands, destDroids) { 16 | console.log('Install plan:'); 17 | if (!args.noCommands) { 18 | console.log(' Commands:'); 19 | if (!plan.commands.length) console.log(' (none)'); 20 | else for (const item of plan.commands) console.log(` - ${item.name}${item.exists ? ' (exists)' : ''}`); 21 | } 22 | if (!args.noDroids) { 23 | console.log(' Droids:'); 24 | if (!plan.droids.length) console.log(' (none)'); 25 | else for (const item of plan.droids) console.log(` - ${item.name}${item.exists ? ' (exists)' : ''}`); 26 | } 27 | console.log('\nInstalling to:'); 28 | if (!args.noCommands) console.log(` ${destCommands}`); 29 | if (!args.noDroids) console.log(` ${destDroids}`); 30 | const existsCount = plan.commands.filter((c) => c.exists).length + plan.droids.filter((d) => d.exists).length; 31 | const newCount = plan.commands.length + plan.droids.length - existsCount; 32 | console.log(`\nSummary: ${newCount} new, ${existsCount} existing${existsCount && !args.force ? ' (will skip unless --force)' : ''}`); 33 | } 34 | 35 | function printSummary({ guided, args, basePath, created, overwritten, skipped, customDroidsEnabled, plan }) { 36 | const { CHECK, ARROW } = symbols; 37 | if (guided) { 38 | const allSelected = (args.commands === 'all' && args.droids === 'all'); 39 | if (allSelected) { 40 | console.log(`${green(CHECK)} ${bold('Step 3/4 — Select commands to install')} ${dim('·')} ${cyan('(skipped)')}`); 41 | console.log(`${green(CHECK)} ${bold('Step 4/4 — Select droids to install')} ${dim('·')} ${cyan('(skipped)')}`); 42 | } else { 43 | if (args.noCommands) console.log(`${green(CHECK)} ${bold('Step 3/4 — Select commands to install')} ${dim('·')} ${cyan('(skipped)')}`); 44 | if (args.noDroids) console.log(`${green(CHECK)} ${bold('Step 4/4 — Select droids to install')} ${dim('·')} ${cyan('(skipped)')}`); 45 | } 46 | console.log(`${ARROW} Installing to: ${cyan(basePath)}`); 47 | if (customDroidsEnabled) { 48 | console.log(`${green(CHECK)} Custom droids are enabled in your settings.`); 49 | } else { 50 | console.log(`${ARROW} Custom droids need to be enabled in settings.`); 51 | console.log(`${ARROW} Open /settings → Experimental → Custom Droids, or set enableCustomDroids: true in ~/.factory/settings.json`); 52 | } 53 | console.log(`${green(CHECK)} Completed — ${created} created, ${overwritten} overwritten, ${skipped} skipped.`); 54 | if (!customDroidsEnabled) console.log(`${ARROW} Next: Enable Custom Droids as described above.`); 55 | console.log(`${ARROW} Next: Restart Droid (Ctrl+C then relaunch) or run /commands and press R to reload.`); 56 | } else { 57 | console.log(`${green(CHECK)} Completed — ${created} created, ${overwritten} overwritten, ${skipped} skipped.`); 58 | console.log(`${ARROW} Next: Restart Droid (Ctrl+C then relaunch) or run /commands and press R to reload.`); 59 | } 60 | } 61 | 62 | function printMarketplacePlan(plan, args, destCommands, destDroids) { 63 | console.log('Install plan (marketplace):'); 64 | console.log(' Commands:'); 65 | if (!plan.commands.length) console.log(' (none)'); 66 | else for (const item of plan.commands) console.log(` - ${item.name} [${item.plugin}]${args.verbose ? ` ← ${item.srcType}` : ''}`); 67 | console.log(' Droids:'); 68 | if (!plan.droids.length) console.log(' (none)'); 69 | else for (const item of plan.droids) console.log(` - ${item.name} [${item.plugin}]${args.verbose ? ` ← ${item.srcType}` : ''}`); 70 | if (plan.unresolved?.length) { 71 | console.log(' Unresolved plugins:'); 72 | for (const u of plan.unresolved) console.log(` - ${u.plugin}${u.reason ? ` (${u.reason})` : ''}`); 73 | } 74 | console.log('\nInstalling to:'); 75 | if (plan.commands.length) console.log(` ${destCommands}`); 76 | if (plan.droids.length) console.log(` ${destDroids}`); 77 | } 78 | 79 | module.exports = { 80 | colors: { dim, green, cyan, bold }, 81 | symbols, 82 | helpers: { plural, termWidth, truncate }, 83 | printPlan, 84 | printSummary, 85 | printMarketplacePlan, 86 | }; 87 | -------------------------------------------------------------------------------- /lib/agent-convert.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const matter = require('gray-matter'); 4 | // no extra YAML deps; we build minimal frontmatter manually 5 | 6 | function toArray(val) { 7 | if (!val && val !== 0) return null; 8 | if (Array.isArray(val)) return [...new Set(val.map(String))]; 9 | const parts = String(val).split(',').map((s) => s.trim()).filter(Boolean); 10 | return parts.length ? [...new Set(parts)] : null; 11 | } 12 | 13 | function convertAgentMarkdownToDroid(mdText, { fallbackName } = {}) { 14 | let parsed; 15 | let src = {}; 16 | let body = ''; 17 | try { 18 | parsed = matter(mdText || '', { language: 'yaml' }); 19 | src = parsed.data || {}; 20 | body = parsed.content || ''; 21 | } catch (e) { 22 | // Fallback: salvage minimal fields from malformed frontmatter 23 | const text = String(mdText || ''); 24 | const fmStart = text.indexOf('---'); 25 | let fmEnd = -1; 26 | if (fmStart === 0) { 27 | fmEnd = text.indexOf('\n---', 3); 28 | } 29 | if (fmStart === 0 && fmEnd !== -1) { 30 | const fmBlock = text.slice(3, fmEnd).split(/\r?\n/); 31 | // Extract simple key: value on a single line 32 | for (const line of fmBlock) { 33 | const m = /^([A-Za-z0-9_\-]+):\s*(.*)$/.exec(line.trim()); 34 | if (!m) continue; 35 | const key = m[1].toLowerCase(); 36 | const val = m[2]; 37 | if (key === 'name' && !src.name) src.name = val; 38 | else if (key === 'description' && !src.description) src.description = val; 39 | else if (key === 'tools' && !src.tools) src.tools = val; 40 | // ignore model and others in fallback 41 | } 42 | // Preserve meaningful lines (example-like keys allowed), drop other yaml-ish keys (color, etc.) 43 | const filtered = fmBlock.filter((line) => { 44 | const ln = (line || '').trim(); 45 | if (/^\s*(name|description|tools|model|color|createdat|updatedat|author|homepage|category|keywords|license|version):\s*/i.test(ln)) return false; 46 | const yamlish = /^[A-Za-z0-9_\-]+:\s*/.test(ln); 47 | const allowed = /^(context|user|assistant|commentary):\s*/i.test(ln); 48 | if (yamlish && !allowed) return false; 49 | return true; 50 | }); 51 | // If examples were embedded in frontmatter, capture them to augment description 52 | const fmRaw = fmBlock.join('\n'); 53 | const exampleMatches = fmRaw.match(/[\s\S]*?<\/example>/gi) || []; 54 | if (exampleMatches.length) { 55 | const examplesInline = exampleMatches.join(' ').replace(/\s+/g, ' ').trim(); 56 | if (src.description) src.description = String(src.description) + ' ' + examplesInline; 57 | else src.description = examplesInline; 58 | } 59 | body = filtered.join('\n') + '\n' + text.slice(fmEnd + 4); 60 | } else { 61 | body = text; 62 | } 63 | } 64 | 65 | const toolsList = toArray(src.tools); 66 | // Normalize description to single-line and strip escaped newlines/tags 67 | function sanitizeDescription(input) { 68 | if (typeof input !== 'string') return undefined; 69 | let s = String(input); 70 | // Convert literal escape sequences first 71 | s = s.replace(/\\[nrt]/gi, ' '); 72 | // Normalize common HTML line/paragraph breaks but preserve XML-ish tags like 73 | s = s.replace(//gi, ' '); 74 | s = s.replace(/<\/?:?p\b[^>]*>/gi, ' '); 75 | // Collapse actual newlines and excessive whitespace 76 | s = s.replace(/\s*\n\s*/g, ' '); 77 | s = s.replace(/\s+/g, ' ').trim(); 78 | // Replace display-style role labels like "Context:" "Assistant:" etc. with hyphen form to avoid YAML quoting while keeping text clean 79 | // Avoid URLs ("://") and emoji/colon code; only replace when colon follows an alnum and a space 80 | s = s.replace(/([A-Za-z0-9])\s*:\s+(?!\/)/g, '$1 - '); 81 | return s || undefined; 82 | } 83 | 84 | let description = sanitizeDescription(src.description); 85 | 86 | const name = src.name || fallbackName || ''; 87 | const tools = Array.isArray(toolsList) && toolsList.length ? toolsList : null; 88 | const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 89 | function isPlainYamlSafe(s) { 90 | if (typeof s !== 'string') return false; 91 | if (!s.length) return true; 92 | if (/[\r\n]/.test(s)) return false; 93 | if (/^\s/.test(s)) return false; 94 | if (/\s$/.test(s)) return false; 95 | if (/^[-?:,\[\]{}#&*!|>'"%@`]/.test(s)) return false; 96 | // allow colon+space inside plain scalars; YAML parsers accept this in values 97 | if (/#/.test(s)) return false; // '#' would start a comment if unquoted 98 | return true; 99 | } 100 | const lines = []; 101 | lines.push('---'); 102 | lines.push(`name: ${name}`); 103 | if (typeof description === 'string' && description.length) { 104 | // Prefer plain scalar when possible; minimally transform to avoid YAML pitfalls 105 | let plainCandidate = description.indexOf('#') !== -1 ? description.replace(/#/g, '#') : description; 106 | if (isPlainYamlSafe(plainCandidate)) lines.push(`description: ${plainCandidate}`); 107 | else lines.push(`description: "${esc(description)}"`); 108 | } 109 | lines.push('model: inherit'); 110 | if (tools) { 111 | lines.push('tools:'); 112 | for (const t of tools) lines.push(` - ${t}`); 113 | } 114 | lines.push('---'); 115 | return lines.join('\n') + '\n\n' + (body || ''); 116 | } 117 | 118 | module.exports = { convertAgentMarkdownToDroid }; 119 | -------------------------------------------------------------------------------- /lib/marketplace-ui.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const path = require('path'); 4 | const { colors, helpers } = require('./output'); 5 | const spinner = require('./spinner'); 6 | const { createMultiSelectWithFooter, runWithReset, RESET } = require('./ui'); 7 | const { loadMarketplace, discoverPlugins } = require('./marketplace'); 8 | const { configureAsciiTheme } = require('./ui'); 9 | 10 | const { dim, green } = colors; 11 | const { truncate, termWidth, plural } = helpers; 12 | 13 | function formatFooter(p) { 14 | const parts = []; 15 | const cmdCount = (p.commands || []).length; 16 | const agentCount = (p.agents || []).length; 17 | const hookCount = (p.hooks || []).length; 18 | if (cmdCount) parts.push(`${cmdCount} ${plural(cmdCount, 'command')}`); 19 | if (agentCount) parts.push(`${agentCount} ${plural(agentCount, 'agent')}`); 20 | if (hookCount) parts.push(`${hookCount} ${plural(hookCount, 'hook')} (not installed)`); 21 | const counts = parts.length ? parts.join(', ') : 'No installable templates'; 22 | const desc = p.description ? ` — ${p.description}` : ''; 23 | const error = Array.isArray(p.errors) && p.errors.length ? ` — Error: ${p.errors[0]}` : ''; 24 | return truncate(`${counts}${desc}${error}`, termWidth() - 2); 25 | } 26 | 27 | async function guidedMarketplaceFlowFromScope({ scope, initialInput, debug = false, ref }) { 28 | const enq = require('enquirer'); 29 | configureAsciiTheme(enq); 30 | const { Input, Confirm, MultiSelect } = enq; 31 | 32 | // State machine for nested navigation 33 | let step = 'marketplace'; 34 | let marketplace = initialInput || ''; 35 | let loaded = null; 36 | let discovered = null; 37 | let warningsShown = false; 38 | let installAll = null; 39 | let chosenPlugins = []; 40 | let force = false; 41 | 42 | while (true) { 43 | if (step === 'marketplace') { 44 | const marketplacePrompt = new Input({ 45 | name: 'marketplace', 46 | message: 'Step 3/6 — Enter marketplace:', 47 | initial: marketplace, 48 | hint: dim('Accepts: path, GitHub owner/repo, or git URL.'), 49 | prefix: green('*'), 50 | symbols: { check: '*', cross: 'x', pointer: '>' }, 51 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 52 | }); 53 | const mktRes = await runWithReset(marketplacePrompt); 54 | if (mktRes === RESET) return { args: RESET }; 55 | marketplace = mktRes; 56 | 57 | // Load + discover 58 | try { 59 | let sp = null; 60 | const spinEnabled = process.stdout.isTTY && !debug; 61 | if (spinEnabled) sp = spinner.start('Discovering plugins...'); 62 | loaded = await loadMarketplace(marketplace, ref, { debug }); 63 | discovered = await discoverPlugins(loaded.json, loaded.context, { debug }); 64 | spinner.stop(sp); 65 | } catch (e) { 66 | spinner.stop(); 67 | console.error('Failed to load marketplace:', e?.message || e); 68 | process.exit(1); 69 | } 70 | 71 | const erroredPlugins = discovered.filter((p) => Array.isArray(p.errors) && p.errors.length); 72 | warningsShown = !debug && erroredPlugins.length > 0; 73 | if (warningsShown) { 74 | console.log('\nWarning: Some plugins could not be fully discovered:'); 75 | for (const plugin of erroredPlugins) { 76 | console.log(` - ${plugin.name}: ${plugin.errors[0]}`); 77 | } 78 | console.log(' Consider setting GITHUB_TOKEN to increase GitHub API limits.'); 79 | } 80 | step = 'installAll'; 81 | } else if (step === 'installAll') { 82 | const totalCounts = discovered.reduce((acc, p) => { 83 | acc.commands += (p.commands || []).length; 84 | acc.agents += (p.agents || []).length; 85 | acc.hooks += (p.hooks || []).length; 86 | return acc; 87 | }, { commands: 0, agents: 0, hooks: 0 }); 88 | const sections = [`${discovered.length} ${plural(discovered.length, 'plugin')}`]; 89 | if (totalCounts.commands) sections.push(`${totalCounts.commands} ${plural(totalCounts.commands, 'command')}`); 90 | if (totalCounts.agents) sections.push(`${totalCounts.agents} ${plural(totalCounts.agents, 'agent')}`); 91 | if (totalCounts.hooks) sections.push(`${totalCounts.hooks} ${plural(totalCounts.hooks, 'hook')}`); 92 | const installAllPrompt = new Confirm({ 93 | name: 'all', 94 | message: 'Step 4/6 — Install all plugins?', 95 | initial: installAll !== null ? installAll : true, 96 | hint: dim(`Discovered: ${sections.join(' · ')}`), 97 | prefix: green('*'), 98 | symbols: { check: '*', cross: 'x', pointer: '>' }, 99 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 100 | }); 101 | const allRes = await runWithReset(installAllPrompt); 102 | if (allRes === RESET) return { args: RESET }; 103 | installAll = allRes; 104 | step = installAll ? 'force' : 'plugins'; 105 | } else if (step === 'plugins') { 106 | const choices = discovered.map((p) => ({ name: p.name, value: p.name, message: p.name, data: { desc: formatFooter(p) } })); 107 | const ms = createMultiSelectWithFooter(MultiSelect, { 108 | title: 'Step 5/6 — Select plugins to install', 109 | choices, 110 | initial: chosenPlugins 111 | }); 112 | const plugRes = await runWithReset(ms); 113 | if (plugRes === RESET) return { args: RESET }; 114 | chosenPlugins = plugRes; 115 | step = 'force'; 116 | } else if (step === 'force') { 117 | const forcePrompt = new Confirm({ 118 | name: 'force', 119 | message: 'Step 6/6 — Overwrite existing files if found?', 120 | initial: force, 121 | hint: dim('Choosing No will skip pre-existing files'), 122 | prefix: green('*'), 123 | symbols: { check: '*', cross: 'x', pointer: '>' }, 124 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 125 | }); 126 | const forceRes = await runWithReset(forcePrompt); 127 | if (forceRes === RESET) return { args: RESET }; 128 | force = forceRes; 129 | break; // Exit loop when force confirmation is done 130 | } 131 | } 132 | 133 | const args = { scope, marketplace, yes: true, force, debug }; 134 | if (ref) args.ref = ref; 135 | args.plugins = installAll ? 'all' : (chosenPlugins.join(',')); 136 | return { args, loaded, discovered, warningsShown }; 137 | } 138 | 139 | module.exports = { guidedMarketplaceFlowFromScope }; 140 | -------------------------------------------------------------------------------- /lib/ui.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const os = require('os'); 4 | const path = require('path'); 5 | const { colors, symbols, helpers } = require('./output'); 6 | const { listBasenames, getTemplateDescription } = require('./fs-utils'); 7 | 8 | const { dim, green, cyan } = colors; 9 | const { CHECK } = symbols; 10 | const { plural, termWidth, truncate } = helpers; 11 | 12 | // Back marker (kept for compatibility) and Reset marker 13 | const BACK = Symbol('BACK'); 14 | const RESET = Symbol('RESET'); 15 | 16 | // Helper to clear lines from terminal (moves cursor up and clears) 17 | function clearLines(count) { 18 | if (!process.stdout.isTTY) return; 19 | for (let i = 0; i < count; i++) { 20 | process.stdout.moveCursor(0, -1); // Move cursor up one line 21 | process.stdout.clearLine(0); // Clear the line 22 | process.stdout.cursorTo(0); // Move cursor to start 23 | } 24 | } 25 | 26 | function configureAsciiTheme(enquirer) { 27 | if (enquirer && enquirer.symbols) { 28 | enquirer.symbols.check = '*'; 29 | enquirer.symbols.cross = 'x'; 30 | enquirer.symbols.question = '?'; 31 | enquirer.symbols.pointer = '>'; 32 | enquirer.symbols.ellipsis = '...'; 33 | } 34 | } 35 | 36 | function createMultiSelectWithFooter(EnquirerMultiSelect, { title, choices, initial, allowBack = false }) { 37 | const prompt = new EnquirerMultiSelect({ 38 | name: 'items', 39 | message: title, 40 | choices, 41 | initial, 42 | hint: dim('Space to toggle, Enter to confirm · q to quit · esc to restart'), 43 | prefix: green(CHECK), 44 | symbols: { check: '*', cross: 'x', pointer: '>' }, 45 | indicator(state, choice) { 46 | const mark = choice.enabled ? '*' : ' '; 47 | const ptr = state.index === choice.index ? '>' : ' '; 48 | return `${ptr} ${mark}`; 49 | }, 50 | footer() { 51 | const idx = this.state?.index ?? 0; 52 | const current = this.choices?.[idx]; 53 | const key = current ? (current.value ?? current.name) : ''; 54 | const desc = current && current.data && current.data.desc ? current.data.desc : ''; 55 | return desc ? dim(truncate(desc, termWidth() - 2)) : ''; 56 | }, 57 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 58 | }); 59 | 60 | return prompt; 61 | } 62 | 63 | function attachEscReset(prompt) { 64 | if (!prompt) return prompt; 65 | prompt.__reset = false; 66 | prompt.on('keypress', (ch, key) => { 67 | if (key && key.name === 'escape') { 68 | prompt.__reset = true; 69 | try { prompt.cancel(); } catch {} 70 | } 71 | // 'q' quits (cancel) without reset 72 | if ((ch === 'q' || ch === 'Q') || (key && key.name && key.name.toLowerCase() === 'q')) { 73 | try { prompt.cancel(); } catch {} 74 | } 75 | }); 76 | return prompt; 77 | } 78 | 79 | async function runWithReset(prompt) { 80 | attachEscReset(prompt); 81 | try { 82 | return await prompt.run(); 83 | } catch (e) { 84 | if (prompt.__reset) return RESET; 85 | throw e; 86 | } 87 | } 88 | 89 | async function selectScope(messageLabel = 'Choose install location') { 90 | const { Select } = require('enquirer'); 91 | configureAsciiTheme(require('enquirer')); 92 | const workspaceDir = path.join(os.homedir(), '.factory'); 93 | const projectDir = path.join(process.cwd(), '.factory'); 94 | const prompt = new Select({ 95 | name: 'scope', 96 | message: messageLabel, 97 | choices: [ 98 | { name: 'personal', message: `Personal workspace (${workspaceDir})` }, 99 | { name: 'project', message: `This project (${projectDir})` } 100 | ], 101 | hint: dim('Use ↑/↓ then Enter · q to quit · esc to restart'), 102 | prefix: green(CHECK), 103 | symbols: { check: '*', cross: 'x', pointer: '>' }, 104 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 105 | }); 106 | 107 | const result = await runWithReset(prompt); 108 | 109 | return result; 110 | } 111 | 112 | async function chooseImportSource(allowBack = false) { 113 | const { Select } = require('enquirer'); 114 | configureAsciiTheme(require('enquirer')); 115 | const prompt = new Select({ 116 | name: 'import', 117 | message: 'Step 2/6 — Choose import source', 118 | choices: [ 119 | { name: 'templates', message: 'Built-in templates (Droid Factory)', value: 'templates' }, 120 | { name: 'marketplace', message: 'Claude Code marketplace', value: 'marketplace' } 121 | ], 122 | hint: dim('Use ↑/↓ then Enter · q to quit · esc to restart'), 123 | prefix: green(CHECK), 124 | symbols: { check: '*', cross: 'x', pointer: '>' }, 125 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 126 | }); 127 | const result = await runWithReset(prompt); 128 | return result; 129 | } 130 | 131 | async function guidedFlow({ availableCommands, availableDroids, templateCommandsDir, templateDroidsDir }) { 132 | const { MultiSelect, Confirm } = require('enquirer'); 133 | configureAsciiTheme(require('enquirer')); 134 | 135 | const scope = await selectScope('Step 1/4 — Choose install location'); 136 | 137 | // Correctly resolve base directories 138 | const workspaceDir = path.join(os.homedir(), '.factory'); 139 | const projectDir = path.join(process.cwd(), '.factory'); 140 | const baseDir = scope === 'personal' ? workspaceDir : projectDir; 141 | const destCommands = path.join(baseDir, 'commands'); 142 | const destDroids = path.join(baseDir, 'droids'); 143 | const installedCommands = new Set(listBasenames(destCommands)); 144 | const installedDroids = new Set(listBasenames(destDroids)); 145 | 146 | const installAll = await new Confirm({ 147 | name: 'all', 148 | message: 'Step 2/4 — Install all commands and droids?', 149 | initial: true, 150 | hint: dim(`Currently installed: ${installedCommands.size} ${plural(installedCommands.size, 'command')}, ${installedDroids.size} ${plural(installedDroids.size, 'droid')}`), 151 | prefix: green(CHECK), 152 | onCancel: () => { console.log('Cancelled.'); process.exit(0); } 153 | }).run(); 154 | 155 | let chosenCommands = []; 156 | let chosenDroids = []; 157 | if (!installAll) { 158 | if (availableCommands.length) { 159 | const cmdChoices = availableCommands.map((name) => { 160 | const label = `/${name}${installedCommands.has(name) ? ' (installed)' : ''}`; 161 | const desc = templateCommandsDir ? getTemplateDescription(path.join(templateCommandsDir, `${name}.md`)) : null; 162 | return { name, value: name, message: truncate(label, termWidth() - 6), data: { desc } }; 163 | }); 164 | // Fill desc lazily on render via data; simpler: resolve from template during build in caller if needed 165 | const cmdPrompt = createMultiSelectWithFooter(MultiSelect, { 166 | title: 'Step 3/4 — Select commands to install', 167 | choices: cmdChoices, 168 | initial: availableCommands.filter((n) => installedCommands.has(n)) 169 | }); 170 | chosenCommands = await cmdPrompt.run(); 171 | } 172 | 173 | if (availableDroids.length) { 174 | const drChoices = availableDroids.map((name) => { 175 | const label = `${name}${installedDroids.has(name) ? ' (installed)' : ''}`; 176 | const desc = templateDroidsDir ? getTemplateDescription(path.join(templateDroidsDir, `${name}.md`)) : null; 177 | return { name, value: name, message: truncate(label, termWidth() - 6), data: { desc } }; 178 | }); 179 | const drPrompt = createMultiSelectWithFooter(MultiSelect, { 180 | title: 'Step 4/4 — Select droids to install', 181 | choices: drChoices, 182 | initial: availableDroids.filter((n) => installedDroids.has(n)) 183 | }); 184 | chosenDroids = await drPrompt.run(); 185 | } 186 | } 187 | 188 | const force = await new Confirm({ 189 | name: 'force', 190 | message: 'Overwrite existing files if found?', 191 | initial: false, 192 | hint: dim('Choosing No will skip pre-existing files'), 193 | prefix: green(CHECK), 194 | onCancel: () => { console.log('Cancelled.'); process.exit(0); } 195 | }).run(); 196 | 197 | const args = { scope, path: scope === 'project' ? process.cwd() : '', force, yes: true }; 198 | if (installAll) { 199 | args.commands = 'all'; 200 | args.droids = 'all'; 201 | args.noCommands = false; 202 | args.noDroids = false; 203 | } else { 204 | if (chosenCommands.length) args.commands = chosenCommands.join(','); else args.noCommands = true; 205 | if (chosenDroids.length) args.droids = chosenDroids.join(','); else args.noDroids = true; 206 | } 207 | return args; 208 | } 209 | 210 | async function guidedTemplatesFlowFromScope({ scope, availableCommands, availableDroids, templateCommandsDir, templateDroidsDir }) { 211 | configureAsciiTheme(require('enquirer')); 212 | 213 | const baseDir = scope === 'personal' ? path.join(os.homedir(), '.factory') : path.join(process.cwd(), '.factory'); 214 | const destCommands = path.join(baseDir, 'commands'); 215 | const destDroids = path.join(baseDir, 'droids'); 216 | const installedCommands = new Set(listBasenames(destCommands)); 217 | const installedDroids = new Set(listBasenames(destDroids)); 218 | 219 | // State machine for nested navigation 220 | let step = 'installAll'; 221 | let installAll = null; 222 | let chosenCommands = []; 223 | let chosenDroids = []; 224 | let force = false; 225 | 226 | while (true) { 227 | if (step === 'installAll') { 228 | const { Confirm } = require('enquirer'); 229 | const installAllPrompt = new Confirm({ 230 | name: 'all', 231 | message: 'Step 3/6 — Install all commands and droids?', 232 | initial: installAll !== null ? installAll : true, 233 | hint: dim(`Currently installed: ${installedCommands.size} ${plural(installedCommands.size, 'command')}, ${installedDroids.size} ${plural(installedDroids.size, 'droid')}`), 234 | prefix: green(CHECK), 235 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 236 | }); 237 | const installAllRes = await runWithReset(installAllPrompt); 238 | if (installAllRes === RESET) return RESET; 239 | installAll = installAllRes; 240 | step = installAll ? 'force' : (availableCommands.length ? 'commands' : (availableDroids.length ? 'droids' : 'force')); 241 | } else if (step === 'commands') { 242 | const cmdChoices = availableCommands.map((name) => { 243 | const label = `/${name}${installedCommands.has(name) ? ' (installed)' : ''}`; 244 | const desc = templateCommandsDir ? getTemplateDescription(path.join(templateCommandsDir, `${name}.md`)) : null; 245 | return { name, value: name, message: truncate(label, termWidth() - 6), data: { desc } }; 246 | }); 247 | const { MultiSelect } = require('enquirer'); 248 | const ms = createMultiSelectWithFooter(MultiSelect, { 249 | title: 'Step 4/6 — Select commands to install', 250 | choices: cmdChoices, 251 | initial: chosenCommands.length ? availableCommands.filter((n) => chosenCommands.includes(n)) : availableCommands.filter((n) => installedCommands.has(n)) 252 | }); 253 | const cmdRes = await runWithReset(ms); 254 | if (cmdRes === RESET) return RESET; 255 | chosenCommands = cmdRes; 256 | step = availableDroids.length ? 'droids' : 'force'; 257 | } else if (step === 'droids') { 258 | const drChoices = availableDroids.map((name) => { 259 | const label = `${name}${installedDroids.has(name) ? ' (installed)' : ''}`; 260 | const desc = templateDroidsDir ? getTemplateDescription(path.join(templateDroidsDir, `${name}.md`)) : null; 261 | return { name, value: name, message: truncate(label, termWidth() - 6), data: { desc } }; 262 | }); 263 | const { MultiSelect } = require('enquirer'); 264 | const ms = createMultiSelectWithFooter(MultiSelect, { 265 | title: 'Step 5/6 — Select droids to install', 266 | choices: drChoices, 267 | initial: chosenDroids.length ? availableDroids.filter((n) => chosenDroids.includes(n)) : availableDroids.filter((n) => installedDroids.has(n)) 268 | }); 269 | const drRes = await runWithReset(ms); 270 | if (drRes === RESET) return RESET; 271 | chosenDroids = drRes; 272 | step = 'force'; 273 | } else if (step === 'force') { 274 | const { Confirm } = require('enquirer'); 275 | const forcePrompt = new Confirm({ 276 | name: 'force', 277 | message: 'Step 6/6 — Overwrite existing files if found?', 278 | initial: force, 279 | hint: dim('Choosing No will skip pre-existing files'), 280 | prefix: green(CHECK), 281 | onCancel() { if (this.__reset) return; console.log('Cancelled.'); process.exit(0); } 282 | }); 283 | const forceRes = await runWithReset(forcePrompt); 284 | if (forceRes === RESET) return RESET; 285 | force = forceRes; 286 | break; 287 | } 288 | } 289 | 290 | const args = { scope, path: scope === 'project' ? process.cwd() : '', force, yes: true }; 291 | if (installAll) { 292 | args.commands = 'all'; 293 | args.droids = 'all'; 294 | args.noCommands = false; 295 | args.noDroids = false; 296 | } else { 297 | if (chosenCommands.length) args.commands = chosenCommands.join(','); else args.noCommands = true; 298 | if (chosenDroids.length) args.droids = chosenDroids.join(','); else args.noDroids = true; 299 | } 300 | return args; 301 | } 302 | 303 | module.exports = { configureAsciiTheme, guidedFlow, selectScope, chooseImportSource, guidedTemplatesFlowFromScope, createMultiSelectWithFooter, BACK, RESET, runWithReset, attachEscReset }; 304 | 305 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | const readline = require('readline'); 7 | 8 | const { parseArgs, usage } = require('./args'); 9 | const output = require('./output'); 10 | const { listBasenames, ensureDir, copyFile, readCustomDroidsSetting, downloadToFile } = require('./fs-utils'); 11 | const { resolveSelection, computePlan } = require('./planner'); 12 | const spinner = require('./spinner'); 13 | const { guidedFlow, configureAsciiTheme, selectScope, chooseImportSource, guidedTemplatesFlowFromScope, BACK, RESET } = require('./ui'); 14 | const { guidedMarketplaceFlowFromScope } = require('./marketplace-ui'); 15 | const { loadMarketplace, discoverPlugins, getLastRateLimit, httpGetText } = require('./marketplace'); 16 | const { convertAgentMarkdownToDroid } = require('./agent-convert'); 17 | const { convertCommandMarkdownToFactory } = require('./command-convert'); 18 | 19 | function canPrompt() { return process.stdin.isTTY && process.stdout.isTTY; } 20 | 21 | function logMarketplaceDiscoveryWarnings(discovered, debug) { 22 | if (debug) return false; 23 | const errored = (discovered || []).filter((p) => Array.isArray(p.errors) && p.errors.length); 24 | if (!errored.length) return false; 25 | console.log('\nWarning: Some plugins could not be fully discovered:'); 26 | for (const plugin of errored) { 27 | console.log(` - ${plugin.name}: ${plugin.errors[0]}`); 28 | } 29 | console.log(' Consider setting GITHUB_TOKEN to increase GitHub API limits.'); 30 | return true; 31 | } 32 | 33 | function logRateLimitIfLow(debug) { 34 | if (debug) return; 35 | const info = getLastRateLimit && getLastRateLimit(); 36 | if (!info) return; 37 | const { remaining, limit, reset } = info; 38 | if (typeof remaining !== 'number' || remaining > 5) return; 39 | const resetIn = typeof reset === 'number' ? Math.max(0, Math.round((reset * 1000 - Date.now()) / 1000)) : null; 40 | console.log('\nWarning: GitHub API rate limit nearly exhausted.'); 41 | if (typeof remaining === 'number' && typeof limit === 'number') { 42 | console.log(` Remaining ${remaining}/${limit} requests for this hour.`); 43 | } else if (typeof remaining === 'number') { 44 | console.log(` Remaining requests: ${remaining}.`); 45 | } 46 | if (resetIn !== null) { 47 | const mins = Math.floor(resetIn / 60); 48 | const secs = resetIn % 60; 49 | console.log(` Resets in ~${mins}m ${secs}s.`); 50 | } 51 | console.log(' Set GITHUB_TOKEN to increase limits.'); 52 | } 53 | 54 | async function confirmIfNeeded(args) { 55 | const interactive = canPrompt() && !args.yes && !args.dryRun; 56 | if (!interactive) return args; // unchanged 57 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 58 | const answer = await new Promise((resolve) => { rl.question('\nProceed? [y] Yes / [f] Force overwrite / [n] Cancel: ', resolve); }); 59 | rl.close(); 60 | const normalized = (answer || '').trim().toLowerCase(); 61 | if (normalized === 'n' || normalized === 'no' || normalized === 'q' || normalized === 'quit' || normalized === 'exit') { 62 | console.log('Cancelled.'); 63 | process.exit(0); 64 | } 65 | if (normalized === 'f' || normalized === 'force') return { ...args, force: true }; 66 | return args; 67 | } 68 | 69 | async function run(argv) { 70 | const args = parseArgs(argv); 71 | if (args.help) { console.log(usage(argv[1])); return; } 72 | 73 | const templateDir = path.join(__dirname, '..', 'templates'); 74 | const templateCommands = path.join(templateDir, 'commands'); 75 | const templateDroids = path.join(templateDir, 'droids'); 76 | 77 | const availableCommands = listBasenames(templateCommands); 78 | const availableDroids = listBasenames(templateDroids); 79 | 80 | const isGuidedCandidate = canPrompt() 81 | && !args.list && !args.help 82 | && !args.onlyCommands && !args.onlyDroids; 83 | 84 | // Guided entry with source selection if no explicit mode provided 85 | if (isGuidedCandidate && !args.marketplace && !args.import && args.commands === undefined && args.droids === undefined) { 86 | try { 87 | configureAsciiTheme(require('enquirer')); 88 | 89 | // State machine for navigation with back support 90 | let step = 'scope'; 91 | let scopeChoice = null; 92 | let srcChoice = null; 93 | 94 | while (true) { 95 | if (step === 'scope') { 96 | scopeChoice = await selectScope('Step 1/6 — Choose install location'); 97 | if (scopeChoice === RESET) { scopeChoice = null; srcChoice = null; step = 'scope'; if (process.stdout.isTTY) { try { console.clear(); } catch {} } continue; } 98 | step = 'source'; 99 | } else if (step === 'source') { 100 | srcChoice = await chooseImportSource(false); 101 | if (srcChoice === RESET) { scopeChoice = null; srcChoice = null; step = 'scope'; if (process.stdout.isTTY) { try { console.clear(); } catch {} } continue; } 102 | step = 'flow'; 103 | } else if (step === 'flow') { 104 | if (srcChoice === 'marketplace') { 105 | const result = await guidedMarketplaceFlowFromScope({ scope: scopeChoice, debug: args.debug, ref: args.ref }); 106 | if (result.args === RESET) { scopeChoice = null; srcChoice = null; step = 'scope'; if (process.stdout.isTTY) { try { console.clear(); } catch {} } continue; } 107 | Object.assign(args, result.args); 108 | args.yes = true; 109 | args.__loadedMarketplace = { loaded: result.loaded, discovered: result.discovered, warningsShown: result.warningsShown }; 110 | } else { 111 | const guided = await guidedTemplatesFlowFromScope({ 112 | scope: scopeChoice, 113 | availableCommands, 114 | availableDroids, 115 | templateCommandsDir: templateCommands, 116 | templateDroidsDir: templateDroids, 117 | }); 118 | if (guided === RESET) { scopeChoice = null; srcChoice = null; step = 'scope'; if (process.stdout.isTTY) { try { console.clear(); } catch {} } continue; } 119 | Object.assign(args, guided); 120 | args.yes = true; 121 | } 122 | break; // Exit the loop when flow completes successfully 123 | } 124 | } 125 | } catch (e) { 126 | // fall back to flags/defaults 127 | } 128 | } 129 | 130 | if (args.list) { 131 | console.log('Available command templates:'); 132 | console.log(availableCommands.length ? ' - ' + availableCommands.join('\n - ') : ' (none)'); 133 | console.log('\nAvailable droid templates:'); 134 | console.log(availableDroids.length ? ' - ' + availableDroids.join('\n - ') : ' (none)'); 135 | return; 136 | } 137 | 138 | if (args.onlyCommands) args.noDroids = true; 139 | if (args.onlyDroids) args.noCommands = true; 140 | 141 | let baseDir = ''; 142 | if (args.scope === 'personal' || !args.scope) baseDir = path.join(os.homedir(), '.factory'); 143 | else if (args.scope === 'project') baseDir = path.join(path.resolve(args.path || process.cwd()), '.factory'); 144 | else { console.error(`Error: unknown --scope '${args.scope}'. Use 'personal' or 'project'.`); process.exit(2); } 145 | 146 | const destCommands = path.join(baseDir, 'commands'); 147 | const destDroids = path.join(baseDir, 'droids'); 148 | 149 | // Marketplace branch 150 | if (args.marketplace || args.import === 'marketplace' || args.__loadedMarketplace) { 151 | // Load marketplace and discover plugins 152 | let loaded = null; 153 | let discovered = null; 154 | let warningsShown = false; 155 | if (args.__loadedMarketplace) { 156 | loaded = args.__loadedMarketplace.loaded; 157 | discovered = args.__loadedMarketplace.discovered; 158 | warningsShown = !!args.__loadedMarketplace.warningsShown; 159 | } else { 160 | try { 161 | let fetchSpin = null; 162 | const spinEnabled = process.stdout.isTTY && !args.verbose && !args.debug; 163 | if (spinEnabled) fetchSpin = spinner.start('Fetching marketplace...'); 164 | loaded = await loadMarketplace(args.marketplace || '', args.ref, { debug: args.debug }); 165 | discovered = await discoverPlugins(loaded.json, loaded.context, { debug: args.debug }); 166 | spinner.stop(fetchSpin); 167 | warningsShown = logMarketplaceDiscoveryWarnings(discovered, args.debug); 168 | if (!args.debug) logRateLimitIfLow(args.debug); 169 | } catch (e) { 170 | console.error('Failed to load marketplace:', e?.message || e); 171 | process.exit(1); 172 | } 173 | } 174 | 175 | if (!warningsShown) warningsShown = logMarketplaceDiscoveryWarnings(discovered, args.debug); 176 | if (!args.debug) logRateLimitIfLow(args.debug); 177 | if (args.__loadedMarketplace) args.__loadedMarketplace.warningsShown = warningsShown; 178 | 179 | // Selection 180 | let selectedPlugins; 181 | if (args.plugins === undefined) { 182 | // Non-interactive without explicit selection → all 183 | selectedPlugins = 'all'; 184 | } else if (args.plugins === 'all') { 185 | selectedPlugins = 'all'; 186 | } else if (typeof args.plugins === 'string') { 187 | const arr = args.plugins.split(',').map((s) => s.trim()).filter(Boolean); 188 | selectedPlugins = arr.length ? arr : []; 189 | } else { 190 | selectedPlugins = []; 191 | } 192 | const { computeMarketplacePlan } = require('./marketplace-planner'); 193 | const plan = computeMarketplacePlan({ 194 | selectedPlugins, 195 | discovered, 196 | destCommandsDir: destCommands, 197 | destDroidsDir: destDroids, 198 | }); 199 | 200 | if (args.verbose) output.printMarketplacePlan(plan, args, destCommands, destDroids); 201 | if (args.dryRun) { console.log('\nDry run: no files were written.'); return; } 202 | 203 | if (!plan.commands.length && !plan.droids.length) { 204 | console.log('Nothing to install (no plugins or components selected).'); 205 | return; 206 | } 207 | 208 | const confirmedArgs = await confirmIfNeeded(args); 209 | if (plan.commands.length) ensureDir(destCommands); 210 | if (plan.droids.length) ensureDir(destDroids); 211 | 212 | let spinnerTimer = null; 213 | const spinEnabled = process.stdout.isTTY && !confirmedArgs.verbose && !confirmedArgs.debug; 214 | const sigintHandler = () => { spinner.stop(spinnerTimer); console.log('\nCancelled.'); process.exit(130); }; 215 | process.on('SIGINT', sigintHandler); 216 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 217 | 218 | const copyResults = { commands: new Map(), droids: new Map() }; 219 | try { 220 | // Commands (convert Claude commands → Factory commands) 221 | for (const item of plan.commands) { 222 | const existed = fs.existsSync(item.dest); 223 | let result = 'skipped'; 224 | if (existed && !confirmedArgs.force) { 225 | copyResults.commands.set(item.name, { result, existed }); 226 | if (confirmedArgs.verbose) { 227 | spinner.stop(spinnerTimer); spinnerTimer = null; 228 | console.log(`skip ${item.dest}`); 229 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 230 | } 231 | continue; 232 | } 233 | try { 234 | let srcText = ''; 235 | if (item.srcType === 'local') { 236 | if (!fs.existsSync(item.src)) { if (confirmedArgs.verbose) console.log(`skip ${item.name} (source not found)`); copyResults.commands.set(item.name, { result, existed }); continue; } 237 | srcText = fs.readFileSync(item.src, 'utf8'); 238 | } else { 239 | srcText = await httpGetText(item.src, undefined, undefined, { debug: confirmedArgs.debug }); 240 | } 241 | const converted = convertCommandMarkdownToFactory(srcText); 242 | ensureDir(path.dirname(item.dest)); 243 | fs.writeFileSync(item.dest, converted, 'utf8'); 244 | result = 'written'; 245 | } catch (e) { 246 | result = 'skipped'; 247 | } 248 | copyResults.commands.set(item.name, { result, existed }); 249 | if (confirmedArgs.verbose) { 250 | spinner.stop(spinnerTimer); spinnerTimer = null; 251 | const label = result === 'skipped' ? 'skip ' : 'wrote '; 252 | console.log(`${label}${item.dest}`); 253 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 254 | } 255 | } 256 | // Droids (convert Claude agents → Factory droids) 257 | for (const item of plan.droids) { 258 | const existed = fs.existsSync(item.dest); 259 | let result = 'skipped'; 260 | if (existed && !confirmedArgs.force) { 261 | copyResults.droids.set(item.name, { result, existed }); 262 | if (confirmedArgs.verbose) { 263 | spinner.stop(spinnerTimer); spinnerTimer = null; 264 | console.log(`skip ${item.dest}`); 265 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 266 | } 267 | continue; 268 | } 269 | try { 270 | let srcText = ''; 271 | if (item.srcType === 'local') { 272 | if (!fs.existsSync(item.src)) { if (confirmedArgs.verbose) console.log(`skip ${item.name} (source not found)`); copyResults.droids.set(item.name, { result, existed }); continue; } 273 | srcText = fs.readFileSync(item.src, 'utf8'); 274 | } else { 275 | srcText = await httpGetText(item.src, undefined, undefined, { debug: confirmedArgs.debug }); 276 | } 277 | const converted = convertAgentMarkdownToDroid(srcText, { fallbackName: item.name }); 278 | ensureDir(path.dirname(item.dest)); 279 | fs.writeFileSync(item.dest, converted, 'utf8'); 280 | result = 'written'; 281 | } catch (e) { 282 | result = 'skipped'; 283 | } 284 | copyResults.droids.set(item.name, { result, existed }); 285 | if (confirmedArgs.verbose) { 286 | spinner.stop(spinnerTimer); spinnerTimer = null; 287 | const label = result === 'skipped' ? 'skip ' : 'wrote '; 288 | console.log(`${label}${item.dest}`); 289 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 290 | } 291 | } 292 | } finally { 293 | spinner.stop(spinnerTimer); spinnerTimer = null; process.off('SIGINT', sigintHandler); 294 | } 295 | 296 | // Count results 297 | const writtenCmds = plan.commands.filter(it => (copyResults.commands.get(it.name)?.result) === 'written'); 298 | const writtenDrs = plan.droids.filter(it => (copyResults.droids.get(it.name)?.result) === 'written'); 299 | const overwritten = writtenCmds.filter(it => copyResults.commands.get(it.name)?.existed).length + writtenDrs.filter(it => copyResults.droids.get(it.name)?.existed).length; 300 | const created = writtenCmds.filter(it => !copyResults.commands.get(it.name)?.existed).length + writtenDrs.filter(it => !copyResults.droids.get(it.name)?.existed).length; 301 | const skipped = Array.from(copyResults.commands.values()).filter(v => v.result === 'skipped').length + Array.from(copyResults.droids.values()).filter(v => v.result === 'skipped').length; 302 | 303 | const basePath = (args.scope === 'personal') ? path.join(os.homedir(), '.factory') : path.join(process.cwd(), '.factory'); 304 | const custom = readCustomDroidsSetting(); 305 | const enabled = !!custom.enabled && !custom.error && !custom.missing; 306 | 307 | output.printSummary({ guided: false, args, basePath, created, overwritten, skipped, customDroidsEnabled: enabled, plan }); 308 | return; 309 | } 310 | 311 | const selectedCommands = args.noCommands ? [] : (resolveSelection(args.commands, availableCommands, 'command') || [...availableCommands]); 312 | const selectedDroids = args.noDroids ? [] : (resolveSelection(args.droids, availableDroids, 'droid') || [...availableDroids]); 313 | if (!selectedCommands.length && !selectedDroids.length) { console.log('Nothing to install (no commands or droids selected).'); return; } 314 | 315 | const plan = computePlan({ 316 | selectedCommands, 317 | selectedDroids, 318 | templateCommandsDir: templateCommands, 319 | templateDroidsDir: templateDroids, 320 | destCommandsDir: destCommands, 321 | destDroidsDir: destDroids, 322 | }); 323 | 324 | const guidedMode = isGuidedCandidate && args.yes; 325 | if (!guidedMode) { 326 | if (args.verbose) output.printPlan(plan, args, destCommands, destDroids); 327 | } 328 | 329 | if (args.dryRun) { console.log('\nDry run: no files were written.'); return; } 330 | 331 | const confirmedArgs = await confirmIfNeeded(args); 332 | 333 | if (!confirmedArgs.noCommands) ensureDir(destCommands); 334 | if (!confirmedArgs.noDroids) ensureDir(destDroids); 335 | 336 | let spinnerTimer = null; 337 | const spinEnabled = process.stdout.isTTY && !confirmedArgs.verbose; 338 | const sigintHandler = () => { spinner.stop(spinnerTimer); console.log('\nCancelled.'); process.exit(130); }; 339 | process.on('SIGINT', sigintHandler); 340 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 341 | 342 | const copyResults = { commands: new Map(), droids: new Map() }; 343 | try { 344 | if (!confirmedArgs.noCommands) { 345 | for (const item of plan.commands) { 346 | if (!fs.existsSync(item.src)) { if (confirmedArgs.verbose) console.log(`skip ${item.name} (template not found)`); continue; } 347 | const result = copyFile(item.src, item.dest, confirmedArgs.force); 348 | copyResults.commands.set(item.name, result); 349 | if (confirmedArgs.verbose) { 350 | spinner.stop(spinnerTimer); spinnerTimer = null; 351 | const label = result === 'skipped' ? 'skip ' : 'wrote '; 352 | console.log(`${label}${item.dest}`); 353 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 354 | } 355 | } 356 | } 357 | if (!confirmedArgs.noDroids) { 358 | for (const item of plan.droids) { 359 | if (!fs.existsSync(item.src)) { if (confirmedArgs.verbose) console.log(`skip ${item.name} (template not found)`); continue; } 360 | const result = copyFile(item.src, item.dest, confirmedArgs.force); 361 | copyResults.droids.set(item.name, result); 362 | if (confirmedArgs.verbose) { 363 | spinner.stop(spinnerTimer); spinnerTimer = null; 364 | const label = result === 'skipped' ? 'skip ' : 'wrote '; 365 | console.log(`${label}${item.dest}`); 366 | if (spinEnabled) spinnerTimer = spinner.start('Installing...'); 367 | } 368 | } 369 | } 370 | } finally { 371 | spinner.stop(spinnerTimer); spinnerTimer = null; process.off('SIGINT', sigintHandler); 372 | } 373 | 374 | const writtenCmds = plan.commands.filter(it => copyResults.commands.get(it.name) === 'written'); 375 | const writtenDrs = plan.droids.filter(it => copyResults.droids.get(it.name) === 'written'); 376 | const overwritten = writtenCmds.filter(it => it.exists).length + writtenDrs.filter(it => it.exists).length; 377 | const created = writtenCmds.filter(it => !it.exists).length + writtenDrs.filter(it => !it.exists).length; 378 | const skipped = Array.from(copyResults.commands.values()).filter(v => v === 'skipped').length + Array.from(copyResults.droids.values()).filter(v => v === 'skipped').length; 379 | 380 | const basePath = (args.scope === 'personal') ? path.join(os.homedir(), '.factory') : path.join(process.cwd(), '.factory'); 381 | const custom = readCustomDroidsSetting(); 382 | const enabled = !!custom.enabled && !custom.error && !custom.missing; 383 | 384 | output.printSummary({ guided: guidedMode, args, basePath, created, overwritten, skipped, customDroidsEnabled: enabled, plan }); 385 | } 386 | 387 | module.exports = { run }; 388 | -------------------------------------------------------------------------------- /lib/marketplace.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const https = require('https'); 6 | 7 | const githubTreeCache = new Map(); 8 | let lastRateLimit = null; 9 | 10 | function updateRateLimit(headers) { 11 | if (!headers) return; 12 | const limit = headers['x-ratelimit-limit']; 13 | const remaining = headers['x-ratelimit-remaining']; 14 | const reset = headers['x-ratelimit-reset']; 15 | if (limit === undefined && remaining === undefined && reset === undefined) return; 16 | lastRateLimit = { 17 | limit: limit !== undefined ? Number(limit) : undefined, 18 | remaining: remaining !== undefined ? Number(remaining) : undefined, 19 | reset: reset !== undefined ? Number(reset) : undefined, 20 | fetchedAt: Date.now(), 21 | }; 22 | } 23 | 24 | function debugLog(debug, ...args) { 25 | if (debug) console.log('[debug]', ...args); 26 | } 27 | 28 | function isUrl(input) { 29 | return /^https?:\/\//i.test(input || ''); 30 | } 31 | 32 | function isOwnerRepoShorthand(input) { 33 | return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(input || ''); 34 | } 35 | 36 | function httpGetText(url, headers = {}, maxRedirects = 5, opts = {}) { 37 | const debug = !!opts.debug; 38 | debugLog(debug, 'httpGetText →', url); 39 | return new Promise((resolve, reject) => { 40 | const req = https.get(url, { headers: { 'User-Agent': 'droid-factory', ...headers } }, (res) => { 41 | debugLog(debug, 'httpGetText status', res.statusCode, url); 42 | updateRateLimit(res.headers || {}); 43 | // Handle redirects 44 | if ([301, 302, 303, 307, 308].includes(res.statusCode)) { 45 | const location = res.headers.location; 46 | if (location && maxRedirects > 0) { 47 | const nextUrl = new URL(location, url).toString(); 48 | res.resume(); // discard 49 | debugLog(debug, 'httpGetText redirect →', nextUrl); 50 | httpGetText(nextUrl, headers, maxRedirects - 1, opts).then(resolve, reject); 51 | return; 52 | } 53 | } 54 | let data = ''; 55 | res.on('data', (d) => (data += d)); 56 | res.on('end', () => { 57 | if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { 58 | debugLog(debug, 'httpGetText success', url); 59 | resolve(data); 60 | } else { 61 | debugLog(debug, 'httpGetText error', res.statusCode, url); 62 | const err = new Error(`HTTP ${res.statusCode} for ${url}`); 63 | err.statusCode = res.statusCode; 64 | err.url = url; 65 | err.headers = res.headers; 66 | reject(err); 67 | } 68 | }); 69 | }); 70 | req.on('error', (err) => { 71 | debugLog(debug, 'httpGetText request error', err?.message || err); 72 | reject(err); 73 | }); 74 | }); 75 | } 76 | 77 | async function httpGetJson(url, headers = {}, opts = {}) { 78 | const text = await httpGetText(url, headers, undefined, opts); 79 | return JSON.parse(text); 80 | } 81 | 82 | async function githubGetRepoTree(owner, repo, ref, token, opts = {}) { 83 | const key = `${owner}/${repo}@${ref}`; 84 | if (githubTreeCache.has(key)) { 85 | return githubTreeCache.get(key); 86 | } 87 | const headers = token ? { Authorization: `Bearer ${token}` } : {}; 88 | const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`; 89 | const json = await httpGetJson(url, headers, opts); 90 | if (!json || !Array.isArray(json.tree)) { 91 | const err = new Error(`GitHub tree response malformed for ${owner}/${repo}@${ref}`); 92 | err.response = json; 93 | throw err; 94 | } 95 | githubTreeCache.set(key, json.tree); 96 | return json.tree; 97 | } 98 | 99 | function readLocalJson(filePath) { 100 | const raw = fs.readFileSync(filePath, 'utf8'); 101 | return JSON.parse(raw); 102 | } 103 | 104 | function parseGitHubRawUrl(u) { 105 | try { 106 | const url = new URL(u); 107 | if (url.hostname !== 'raw.githubusercontent.com') return null; 108 | const parts = url.pathname.split('/').filter(Boolean); 109 | if (parts.length < 3) return null; 110 | const owner = parts[0]; 111 | const repo = parts[1]; 112 | const ref = parts[2]; 113 | const filePath = parts.slice(3).join('/'); 114 | return { owner, repo, ref, filePath }; 115 | } catch { return null; } 116 | } 117 | 118 | function parseGitRepoUrl(u) { 119 | try { 120 | const url = new URL(u); 121 | const host = url.hostname.toLowerCase(); 122 | let pathname = url.pathname.replace(/\.git$/i, ''); 123 | // Remove trailing slash 124 | pathname = pathname.replace(/\/$/, ''); 125 | const parts = pathname.split('/').filter(Boolean); 126 | if (!parts.length) return null; 127 | if (host === 'github.com') { 128 | const owner = parts[0]; 129 | const repo = parts[1]; 130 | if (!owner || !repo) return null; 131 | return { provider: 'github', owner, repo }; 132 | } 133 | if (host === 'gitlab.com') { 134 | if (parts.length < 2) return null; 135 | const namespacePath = parts.join('/'); 136 | const repo = parts[parts.length - 1]; 137 | return { provider: 'gitlab', namespacePath, repo }; 138 | } 139 | return null; 140 | } catch { return null; } 141 | } 142 | 143 | async function loadMarketplace(input, ref, opts = {}) { 144 | const debug = !!opts.debug; 145 | debugLog(debug, 'loadMarketplace input', input, 'ref', ref); 146 | // Returns: { json, context } 147 | // context: { kind: 'local'|'github-raw'|'github-shorthand'|'url', baseDir?, gh?: {owner,repo,ref,basePath} } 148 | if (!input) throw new Error('No marketplace input provided'); 149 | 150 | // Local directory or file 151 | if (!isUrl(input) && !isOwnerRepoShorthand(input)) { 152 | let file = input; 153 | if (fs.existsSync(input) && fs.statSync(input).isDirectory()) { 154 | const candidate = path.join(input, '.claude-plugin', 'marketplace.json'); 155 | if (!fs.existsSync(candidate)) throw new Error(`marketplace.json not found in ${input}`); 156 | file = candidate; 157 | } 158 | debugLog(debug, 'Loading local marketplace file', file); 159 | const json = readLocalJson(file); 160 | const baseDir = path.dirname(file); 161 | return { json, context: { kind: 'local', baseDir } }; 162 | } 163 | 164 | // GitHub shorthand owner/repo 165 | if (isOwnerRepoShorthand(input)) { 166 | const [owner, repo] = input.split('/'); 167 | const refsToTry = [ref, 'main', 'master'].filter(Boolean); 168 | let lastErr = null; 169 | for (const r of refsToTry) { 170 | const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${r}/.claude-plugin/marketplace.json`; 171 | debugLog(debug, 'Attempt GitHub shorthand fetch', rawUrl); 172 | try { 173 | const text = await httpGetText(rawUrl, undefined, undefined, { debug }); 174 | const json = JSON.parse(text); 175 | return { json, context: { kind: 'github', gh: { owner, repo, ref: r, basePath: '' } } }; 176 | } catch (e) { lastErr = e; } 177 | } 178 | throw lastErr || new Error('Failed to load marketplace from GitHub shorthand'); 179 | } 180 | 181 | // URL 182 | if (isUrl(input)) { 183 | const ghParsed = parseGitHubRawUrl(input); 184 | if (ghParsed) { 185 | debugLog(debug, 'Detected GitHub raw URL', input, ghParsed); 186 | const text = await httpGetText(input, undefined, undefined, { debug }); 187 | const json = JSON.parse(text); 188 | // base path is directory of filePath 189 | const basePath = ghParsed.filePath ? path.posix.dirname(ghParsed.filePath) : ''; 190 | return { json, context: { kind: 'github', gh: { owner: ghParsed.owner, repo: ghParsed.repo, ref: ghParsed.ref, basePath } } }; 191 | } 192 | const gitRepo = parseGitRepoUrl(input); 193 | if (gitRepo && gitRepo.provider === 'github') { 194 | const refsToTry = [ref, 'main', 'master'].filter(Boolean); 195 | let lastErr = null; 196 | for (const r of refsToTry) { 197 | const rawUrl = `https://raw.githubusercontent.com/${gitRepo.owner}/${gitRepo.repo}/${r}/.claude-plugin/marketplace.json`; 198 | debugLog(debug, 'Attempt GitHub git URL fetch', rawUrl); 199 | try { 200 | const text = await httpGetText(rawUrl, undefined, undefined, { debug }); 201 | const json = JSON.parse(text); 202 | return { json, context: { kind: 'github', gh: { owner: gitRepo.owner, repo: gitRepo.repo, ref: r, basePath: '' } } }; 203 | } catch (e) { lastErr = e; } 204 | } 205 | throw lastErr || new Error('Failed to load marketplace from GitHub git URL'); 206 | } 207 | if (gitRepo && gitRepo.provider === 'gitlab') { 208 | const refsToTry = [ref, 'main', 'master'].filter(Boolean); 209 | let lastErr = null; 210 | for (const r of refsToTry) { 211 | const rawUrl = `https://gitlab.com/${gitRepo.namespacePath}/-/raw/${encodeURIComponent(r)}/.claude-plugin/marketplace.json`; 212 | debugLog(debug, 'Attempt GitLab git URL fetch', rawUrl); 213 | try { 214 | const text = await httpGetText(rawUrl, undefined, undefined, { debug }); 215 | const json = JSON.parse(text); 216 | return { json, context: { kind: 'gitlab', gl: { namespacePath: gitRepo.namespacePath, repo: gitRepo.repo, ref: r, basePath: '' } } }; 217 | } catch (e) { lastErr = e; } 218 | } 219 | throw lastErr || new Error('Failed to load marketplace from GitLab git URL'); 220 | } 221 | // Generic URL to marketplace.json 222 | const text = await httpGetText(input, undefined, undefined, { debug }); 223 | const json = JSON.parse(text); 224 | return { json, context: { kind: 'url', baseUrl: input.replace(/\/marketplace\.json$/,'') } }; 225 | } 226 | 227 | throw new Error('Unsupported marketplace input'); 228 | } 229 | 230 | function normalizePlugins(json) { 231 | const plugins = Array.isArray(json?.plugins) ? json.plugins : []; 232 | return plugins.map((p) => ({ 233 | name: p.name, 234 | description: p.description || '', 235 | version: p.version || '', 236 | author: p.author || {}, 237 | category: p.category || p.tags || '', 238 | keywords: p.keywords || p.tags || [], 239 | license: p.license || '', 240 | homepage: p.homepage || '', 241 | repository: p.repository || '', 242 | strict: p.strict !== undefined ? !!p.strict : true, 243 | pluginRoot: json.pluginRoot || json.metadata?.pluginRoot || '', 244 | source: p.source, 245 | overrides: { commands: p.commands, agents: p.agents, hooks: p.hooks, mcpServers: p.mcpServers } 246 | })).filter((p) => !!p.name); 247 | } 248 | 249 | function resolvePluginSource(plugin, context) { 250 | // Returns { kind, localDir?, github?, reason?, overrides } 251 | const overrides = plugin.overrides || {}; 252 | const src = plugin.source; 253 | const pluginRoot = plugin.pluginRoot || ''; 254 | if (typeof src === 'string') { 255 | // Relative path 256 | if (context.kind === 'local') { 257 | const base = path.resolve(path.join(context.baseDir, pluginRoot || '')); 258 | return { kind: 'local', localDir: path.resolve(base, src), overrides }; 259 | } 260 | if (context.kind === 'github' && context.gh) { 261 | const basePath = path.posix.join(context.gh.basePath || '', pluginRoot || ''); 262 | const full = path.posix.join(basePath, src); 263 | return { kind: 'github', github: { ...context.gh, path: full }, overrides }; 264 | } 265 | if (context.kind === 'gitlab' && context.gl) { 266 | const basePath = path.posix.join(context.gl.basePath || '', pluginRoot || ''); 267 | const full = path.posix.join(basePath, src); 268 | return { kind: 'gitlab', gitlab: { ...context.gl, path: full }, overrides }; 269 | } 270 | // Generic URL base not supported for directory enumeration 271 | return { kind: 'unsupported', reason: 'Non-GitHub remote source path', overrides }; 272 | } 273 | if (src && typeof src === 'object') { 274 | const type = (src.source || src.type || '').toLowerCase(); 275 | if (type === 'github') { 276 | const repo = src.repo || src.repository; 277 | if (!repo) return { kind: 'unsupported', reason: 'Missing GitHub repo', overrides }; 278 | const [owner, repoName] = repo.split('/'); 279 | const ref = src.ref || context.gh?.ref || 'main'; 280 | const basePath = src.path || ''; 281 | return { kind: 'github', github: { owner, repo: repoName, ref, path: basePath }, overrides }; 282 | } 283 | if (type === 'git' || type === 'url') { 284 | const url = src.url || src.href || ''; 285 | const repo = parseGitRepoUrl(url); 286 | if (repo && repo.provider === 'github') { 287 | const ref = src.ref || context.gh?.ref || 'main'; 288 | const basePath = src.path || ''; 289 | return { kind: 'github', github: { owner: repo.owner, repo: repo.repo, ref, path: basePath }, overrides }; 290 | } 291 | if (repo && repo.provider === 'gitlab') { 292 | const ref = src.ref || context.gl?.ref || 'main'; 293 | const basePath = src.path || ''; 294 | return { kind: 'gitlab', gitlab: { namespacePath: repo.namespacePath, repo: repo.repo, ref, path: basePath }, overrides }; 295 | } 296 | return { kind: 'unsupported', reason: 'Unsupported git/url provider', overrides }; 297 | } 298 | } 299 | return { kind: 'unsupported', reason: 'Unknown source type', overrides }; 300 | } 301 | 302 | function listMarkdownFilesLocal(dir) { 303 | try { 304 | if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; 305 | return fs.readdirSync(dir) 306 | .filter((f) => f.endsWith('.md')) 307 | .map((f) => path.join(dir, f)) 308 | .sort(); 309 | } catch { return []; } 310 | } 311 | 312 | function listFilesLocal(dir) { 313 | try { 314 | if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; 315 | return fs.readdirSync(dir) 316 | .map((f) => path.join(dir, f)) 317 | .filter((p) => fs.existsSync(p) && fs.statSync(p).isFile()) 318 | .sort(); 319 | } catch { return []; } 320 | } 321 | 322 | async function githubListDir(owner, repo, ref, repoPath, token, opts = {}) { 323 | const headers = token ? { Authorization: `Bearer ${token}` } : {}; 324 | const encPath = encodeURIComponent(repoPath).replace(/%2F/g, '/'); 325 | const url = `https://api.github.com/repos/${owner}/${repo}/contents/${encPath}?ref=${encodeURIComponent(ref)}`; 326 | const result = await httpGetJson(url, headers, opts); 327 | return result; 328 | } 329 | 330 | function toRawUrl(owner, repo, ref, repoPath) { 331 | const safe = repoPath.replace(/^\//, ''); 332 | return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${safe}`; 333 | } 334 | 335 | async function gitlabListDir(namespacePath, ref, repoPath, opts = {}) { 336 | // GitLab repository tree API (public repos) 337 | const project = encodeURIComponent(namespacePath); 338 | const encPath = encodeURIComponent(repoPath); 339 | const url = `https://gitlab.com/api/v4/projects/${project}/repository/tree?path=${encPath}&ref=${encodeURIComponent(ref)}&per_page=100`; 340 | const result = await httpGetJson(url, undefined, opts); 341 | return result; 342 | } 343 | 344 | function toGitlabRawUrl(namespacePath, ref, repoPath) { 345 | const safe = repoPath.replace(/^\//, ''); 346 | return `https://gitlab.com/${namespacePath}/-/raw/${ref}/${safe}`; 347 | } 348 | 349 | async function scanPluginLocal(localDir, overrides, opts = {}) { 350 | const debug = !!opts.debug; 351 | debugLog(debug, 'scanPluginLocal', localDir); 352 | const errors = []; 353 | if (!fs.existsSync(localDir) || !fs.statSync(localDir).isDirectory()) { 354 | errors.push(`Local source not found: ${localDir}`); 355 | debugLog(debug, 'scanPluginLocal missing directory', localDir); 356 | return { commands: [], agents: [], hooks: [], errors }; 357 | } 358 | const commandsDir = overrides?.commands && typeof overrides.commands === 'string' ? path.resolve(localDir, overrides.commands) : path.join(localDir, 'commands'); 359 | const agentsDir = overrides?.agents && typeof overrides.agents === 'string' ? path.resolve(localDir, overrides.agents) : path.join(localDir, 'agents'); 360 | const hooksDir = overrides?.hooks && typeof overrides.hooks === 'string' ? path.resolve(localDir, overrides.hooks) : path.join(localDir, 'hooks'); 361 | 362 | const commands = Array.isArray(overrides?.commands) 363 | ? overrides.commands.map((p) => path.resolve(localDir, p)).filter((p) => p.endsWith('.md') && fs.existsSync(p)) 364 | : listMarkdownFilesLocal(commandsDir); 365 | const agents = Array.isArray(overrides?.agents) 366 | ? overrides.agents.map((p) => path.resolve(localDir, p)).filter((p) => p.endsWith('.md') && fs.existsSync(p)) 367 | : listMarkdownFilesLocal(agentsDir); 368 | const hooks = Array.isArray(overrides?.hooks) 369 | ? overrides.hooks.map((p) => path.resolve(localDir, p)).filter((p) => fs.existsSync(p) && fs.statSync(p).isFile()) 370 | : listFilesLocal(hooksDir); 371 | 372 | debugLog(debug, 'scanPluginLocal results', { commands, agents, hooks }); 373 | 374 | return { commands, agents, hooks, errors }; 375 | } 376 | 377 | async function scanPluginGithub(gh, overrides, opts = {}) { 378 | const debug = !!opts.debug; 379 | debugLog(debug, 'scanPluginGithub', gh); 380 | const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; 381 | // Build candidate paths 382 | const base = gh.path || ''; 383 | const commandsPath = typeof overrides?.commands === 'string' ? path.posix.join(base, overrides.commands) : path.posix.join(base, 'commands'); 384 | const agentsPath = typeof overrides?.agents === 'string' ? path.posix.join(base, overrides.agents) : path.posix.join(base, 'agents'); 385 | const hooksPath = typeof overrides?.hooks === 'string' ? path.posix.join(base, overrides.hooks) : path.posix.join(base, 'hooks'); 386 | 387 | const errors = []; 388 | let tree = null; 389 | let treeFailed = false; 390 | try { 391 | tree = await githubGetRepoTree(gh.owner, gh.repo, gh.ref, token, opts); 392 | debugLog(debug, `Loaded GitHub tree for ${gh.owner}/${gh.repo}@${gh.ref}`, { size: tree.length }); 393 | } catch (e) { 394 | treeFailed = true; 395 | debugLog(debug, `GitHub tree fetch failed for ${gh.owner}/${gh.repo}@${gh.ref}`, e?.message || e); 396 | } 397 | 398 | const isMarkdown = (repoPath) => /\.md$/i.test(repoPath); 399 | 400 | function listFromTree(pathInRepo, filterFn = isMarkdown) { 401 | if (!tree || !Array.isArray(tree)) return null; 402 | const normalized = pathInRepo.replace(/^\/+/, '').replace(/\/+/g, '/').replace(/\/+$/, ''); 403 | if (!normalized) return []; 404 | const prefix = normalized + '/'; 405 | const results = []; 406 | for (const entry of tree) { 407 | if (!entry || entry.type !== 'blob' || typeof entry.path !== 'string') continue; 408 | if (!entry.path.startsWith(prefix)) continue; 409 | const remainder = entry.path.slice(prefix.length); 410 | if (!remainder || remainder.includes('/')) continue; // direct children only 411 | if (filterFn && !filterFn(entry.path)) continue; 412 | results.push(toRawUrl(gh.owner, gh.repo, gh.ref, entry.path)); 413 | } 414 | return results; 415 | } 416 | 417 | async function listViaApi(pathInRepo, label, filterFn = isMarkdown) { 418 | try { 419 | const entries = await githubListDir(gh.owner, gh.repo, gh.ref, pathInRepo, token, opts); 420 | if (!Array.isArray(entries)) { 421 | const msg = `GitHub API unexpected response for ${label}`; 422 | errors.push(msg); 423 | debugLog(debug, msg); 424 | return []; 425 | } 426 | return entries 427 | .filter((e) => e && e.type === 'file' && (!filterFn || filterFn(e.path || e.download_url || e.name))) 428 | .map((e) => e.download_url || toRawUrl(gh.owner, gh.repo, gh.ref, path.posix.join(pathInRepo, e.name))); 429 | } catch (e) { 430 | if (e && e.statusCode === 404) { 431 | debugLog(debug, `GitHub path not found (${label}) — treating as empty`); 432 | return []; 433 | } 434 | const msg = `GitHub API error (${label}): ${e?.message || e}`; 435 | errors.push(msg); 436 | debugLog(debug, msg); 437 | return []; 438 | } 439 | } 440 | 441 | async function resolveSection(pathInRepo, overrideValue, label, filterFn = isMarkdown) { 442 | if (Array.isArray(overrideValue)) { 443 | return overrideValue 444 | .map((p) => path.posix.join(base, p)) 445 | .filter((repoPath) => !filterFn || filterFn(repoPath)) 446 | .map((repoPath) => toRawUrl(gh.owner, gh.repo, gh.ref, repoPath)); 447 | } 448 | let urls = listFromTree(pathInRepo, filterFn); 449 | if (urls === null || (urls && urls.length === 0 && treeFailed)) { 450 | urls = await listViaApi(pathInRepo, label, filterFn); 451 | } 452 | if (urls === null) urls = await listViaApi(pathInRepo, label, filterFn); 453 | return urls || []; 454 | } 455 | 456 | const commands = await resolveSection(commandsPath, overrides?.commands, `${gh.repo}/${commandsPath}`); 457 | const agents = await resolveSection(agentsPath, overrides?.agents, `${gh.repo}/${agentsPath}`); 458 | const hooks = await resolveSection(hooksPath, overrides?.hooks, `${gh.repo}/${hooksPath}`, null); 459 | 460 | debugLog(debug, 'scanPluginGithub discovered', { commands, agents, hooks, errors }); 461 | return { commands, agents, hooks, errors }; 462 | } 463 | 464 | async function scanPluginGitlab(gl, overrides, opts = {}) { 465 | const debug = !!opts.debug; 466 | debugLog(debug, 'scanPluginGitlab', gl); 467 | const base = gl.path || ''; 468 | const commandsPath = typeof overrides?.commands === 'string' ? path.posix.join(base, overrides.commands) : path.posix.join(base, 'commands'); 469 | const agentsPath = typeof overrides?.agents === 'string' ? path.posix.join(base, overrides.agents) : path.posix.join(base, 'agents'); 470 | const hooksPath = typeof overrides?.hooks === 'string' ? path.posix.join(base, overrides.hooks) : path.posix.join(base, 'hooks'); 471 | 472 | const errors = []; 473 | 474 | async function resolveSection(pathInRepo, overrideValue, label, filterFn = (name) => /\.md$/i.test(name)) { 475 | if (Array.isArray(overrideValue)) { 476 | return overrideValue 477 | .map((p) => path.posix.join(base, p)) 478 | .filter((repoPath) => !filterFn || filterFn(repoPath)) 479 | .map((repoPath) => toGitlabRawUrl(gl.namespacePath, gl.ref, repoPath)); 480 | } 481 | try { 482 | const entries = await gitlabListDir(gl.namespacePath, gl.ref, pathInRepo, opts); 483 | if (!Array.isArray(entries)) { 484 | const msg = `GitLab API unexpected response for ${label}`; 485 | errors.push(msg); 486 | debugLog(debug, msg); 487 | return []; 488 | } 489 | return entries 490 | .filter((e) => e && e.type === 'blob' && (!filterFn || filterFn(e.path || e.name))) 491 | .map((e) => toGitlabRawUrl(gl.namespacePath, gl.ref, e.path || (pathInRepo + '/' + e.name))); 492 | } catch (e) { 493 | if (e && e.statusCode === 404) { 494 | debugLog(debug, `GitLab path not found (${label}) — treating as empty`); 495 | return []; 496 | } 497 | const msg = `GitLab API error (${label}): ${e?.message || e}`; 498 | errors.push(msg); 499 | debugLog(debug, msg); 500 | return []; 501 | } 502 | } 503 | 504 | const commands = await resolveSection(commandsPath, overrides?.commands, `${gl.repo}/${commandsPath}`); 505 | const agents = await resolveSection(agentsPath, overrides?.agents, `${gl.repo}/${agentsPath}`); 506 | const hooks = await resolveSection(hooksPath, overrides?.hooks, `${gl.repo}/${hooksPath}`, null); 507 | 508 | debugLog(debug, 'scanPluginGitlab discovered', { commands, agents, hooks, errors }); 509 | return { commands, agents, hooks, errors }; 510 | } 511 | 512 | async function discoverPlugins(marketplaceJson, context, opts = {}) { 513 | const debug = !!opts.debug; 514 | debugLog(debug, 'discoverPlugins start', { contextKind: context?.kind }); 515 | const normalized = normalizePlugins(marketplaceJson); 516 | const results = []; 517 | for (const p of normalized) { 518 | const resolved = resolvePluginSource(p, context); 519 | debugLog(debug, `Plugin ${p.name} resolved`, resolved); 520 | let scan = { commands: [], agents: [], hooks: [], errors: [] }; 521 | try { 522 | if (resolved.kind === 'local') { 523 | scan = await scanPluginLocal(resolved.localDir, resolved.overrides, opts); 524 | } else if (resolved.kind === 'github') { 525 | scan = await scanPluginGithub(resolved.github, resolved.overrides, opts); 526 | } else if (resolved.kind === 'gitlab') { 527 | scan = await scanPluginGitlab(resolved.gitlab, resolved.overrides, opts); 528 | } 529 | } catch (e) { 530 | debugLog(debug, `Plugin ${p.name} scan error`, e?.message || e); 531 | } 532 | debugLog(debug, `Plugin ${p.name} discovered counts`, { commands: scan.commands.length, agents: scan.agents.length, hooks: scan.hooks?.length || 0, errors: scan.errors?.length || 0 }); 533 | results.push({ 534 | name: p.name, 535 | description: p.description, 536 | resolved, 537 | commands: scan.commands, 538 | agents: scan.agents, 539 | hooks: scan.hooks, 540 | errors: scan.errors || [], 541 | }); 542 | } 543 | return results; 544 | } 545 | 546 | function basenameNoExt(p) { return path.basename(p).replace(/\.md$/i, ''); } 547 | 548 | module.exports = { 549 | loadMarketplace, 550 | discoverPlugins, 551 | basenameNoExt, 552 | httpGetText, 553 | getLastRateLimit: () => lastRateLimit, 554 | }; 555 | --------------------------------------------------------------------------------