From 3772c5fe1e3a9ca523925bb774cbaf29313e2c06 Mon Sep 17 00:00:00 2001 From: Karsten Samaschke Date: Sun, 15 Feb 2026 12:49:12 +0100 Subject: [PATCH 1/2] feat: add unified skill publishing pipeline Implement source-configured skill publishing flows across core, CLI, and dashboard, including personal publish modes, official contribution handling, bundle validation, and recursive resource support.\n\nAdd per-run advanced publish overrides (mode/base branch), official-source guardrails in UI/API, updated docs and schemas, and focused installer/dashboard/publish tests.\n\nStabilize the pre-commit test gate by cleaning stale compiled dist tests before build:quick so npm test executes only current compiled test sources. --- CONTRIBUTING.md | 21 +- README.md | 114 +- docs/README.md | 1 + docs/architecture.md | 2 + docs/configuration-guide.md | 40 + docs/index.md | 5 +- docs/installation-guide.md | 31 + docs/skill-publishing-guide.md | 159 ++ docs/skills-reference.md | 21 + ica.config.default.json | 2 + package.json | 3 +- schemas/skill-catalog.schema.json | 29 +- src/catalog/skills.catalog.json | 1040 +----------- src/installer-cli/index.ts | 989 ++++-------- src/installer-core/catalog.ts | 271 +--- src/installer-core/catalogMultiSource.ts | 290 ++-- src/installer-core/claudeIntegration.ts | 4 +- src/installer-core/repositories.ts | 13 + src/installer-core/skillPublish.ts | 742 +++++++++ src/installer-core/sources.ts | 78 +- src/installer-core/types.ts | 38 +- src/installer-dashboard/server/index.ts | 1141 ++++++++++++- .../web/src/InstallerDashboard.tsx | 1423 ++++++++++++----- src/installer-dashboard/web/src/styles.css | 551 ++++--- src/schemas/ica.config.schema.json | 9 + tests/installer/claude-integration.test.ts | 119 ++ tests/installer/cli-serve.test.ts | 21 + .../dashboard-skill-publish-ux.test.ts | 60 + .../dashboard-source-actions-style.test.ts | 34 + tests/installer/skill-publish.test.ts | 257 +++ tests/installer/sources.test.ts | 12 + 31 files changed, 4695 insertions(+), 2825 deletions(-) create mode 100644 docs/skill-publishing-guide.md create mode 100644 src/installer-core/skillPublish.ts create mode 100644 tests/installer/claude-integration.test.ts create mode 100644 tests/installer/cli-serve.test.ts create mode 100644 tests/installer/dashboard-skill-publish-ux.test.ts create mode 100644 tests/installer/dashboard-source-actions-style.test.ts create mode 100644 tests/installer/skill-publish.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60aaf5c..fff05ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,25 @@ We welcome contributions in many forms: 4. Test your changes thoroughly 5. Submit a pull request to the `dev` branch +### Contributing Skills to Official Source + +If you are contributing a skill bundle, you can validate and propose via ICA: + +```bash +node dist/src/installer-cli/index.js skills validate --path=/path/to/skill --profile=official +node dist/src/installer-cli/index.js skills contribute-official --path=/path/to/skill --message="Add my-skill" +``` + +Expected skill structure: +- required: `SKILL.md` +- optional: `scripts/`, `references/`, `assets/`, and other files needed by the skill + +Official contribution validation requires `SKILL.md` frontmatter fields: +- `name` +- `description` +- `category` +- `version` + ## Branching Strategy ### Branch Structure @@ -175,4 +194,4 @@ If you have questions about contributing: 2. Ask in GitHub Discussions 3. Create an issue with the `question` label -We appreciate all contributions, big and small. Thank you for helping make Intelligent Code Agents better! \ No newline at end of file +We appreciate all contributions, big and small. Thank you for helping make Intelligent Code Agents better! diff --git a/README.md b/README.md index 799f207..f532e0e 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,15 @@ # Intelligent Code Agents (ICA) -ICA is the fastest path from "nothing installed" to a working, managed agent stack. +ICA is a skills installer and manager for coding agents. -Use one CLI and one local dashboard to install, sync, audit, and update skills across runtimes without hand-wired setup. - -What ICA gives you: -- one command surface for install/uninstall/sync/audit -- source-aware skill management with explicit pinning (`/`) -- CLI + local dashboard workflows -- verified bootstrap installers and reproducible releases - -## Skills Repository + Contribution - -Official skills live in the standalone Skills repository: -- [intelligentcode-ai/skills](https://github.com/intelligentcode-ai/skills) - -Want to add a custom skill? -- Open a PR in the Skills repo: [Create Pull Request](https://github.com/intelligentcode-ai/skills/pulls) -- Follow contribution requirements in: [How to Contribute](https://github.com/intelligentcode-ai/skills#how-to-contribute) - -## Agent Bootstrap Prompt (IDE/Local Agent) - -Use this when you want your local agent to do setup for you end-to-end. - -Paste this into your coding agent: - -```text -Bootstrap ICA for this local environment. - -Your task: -1) Download and install this skill file into your own native skill storage (using your runtime's normal skill conventions): - https://raw.githubusercontent.com/intelligentcode-ai/skills/master/skills/ica-bootstrap/SKILL.md -2) Immediately execute the installed `ica-bootstrap` skill. -3) Let `ica-bootstrap` complete end-to-end setup, including: - - ICA CLI installation - - skills source registration/refresh - - installation of `ica-cli` - - verification (`ica doctor`, `ica list`) - -Behavior requirements: -- Do the work directly on this machine. -- Do not ask me where skill folders are. -- Do not ask me to confirm routine steps. -- Ask only if credentials, permissions, or security policy block execution. -- If a command fails, diagnose and retry with the safest fallback automatically. - -Final output: -- What you installed -- What you executed -- Current ICA health/status -- Exact blocker only if setup is still not complete -``` - -Canonical prompt source: -- [skills/ica-bootstrap/assets/INITIAL_PROMPT.md](https://github.com/intelligentcode-ai/skills/blob/master/skills/ica-bootstrap/assets/INITIAL_PROMPT.md) +It gives you one clean control plane for: +- installing, uninstalling, syncing, and auditing skills +- managing multiple skill sources with explicit source pinning (`/`) +- running from CLI or a local-first dashboard +- using verified bootstrap installers and signed, reproducible releases ## Install First (Verified Bootstrap) -If you're setting up manually, this is the safest and fastest starting point. - -Bootstrap downloads the latest source artifact (`ica--source.tar.gz`), verifies it against `SHA256SUMS.txt`, and installs `ica`. - macOS/Linux: ```bash @@ -73,22 +22,17 @@ Windows PowerShell: iwr https://raw.githubusercontent.com/intelligentcode-ai/intelligent-code-agents/main/scripts/bootstrap/install.ps1 -UseBasicParsing | iex ``` -Then run: - -```bash -ica install -ica serve --open=true -``` - ## Multi-Source Skills (Clear + Explicit) ICA supports multiple skill repositories side-by-side. - Add official and custom repos (HTTPS/SSH) - Keep each source cached locally under `~/.ica//skills` +- Keep each source publish workspace under `~/.ica/source-workspaces//repo` - Select skills explicitly as `/` to avoid ambiguity - Remove a source without deleting already installed skills (they are marked orphaned) - Use the same model in CLI and dashboard +- Configure per-source publishing defaults: `direct-push`, `branch-only`, or `branch-pr` ## Dashboard Preview @@ -115,10 +59,6 @@ Post-install evidence with expanded `Installed State` and `Operation Report`. ![ICA Dashboard Management](docs/assets/dashboard/dashboard-step-05-management.png) Management action example (`Uninstall selected`) with updated state/report. -### 6) Manage hooks -![ICA Dashboard Hooks](docs/assets/dashboard/dashboard-step-06-hooks.png) -Dedicated hooks catalog/actions with source-aware hook install state. - ## Build From Source ```bash @@ -153,14 +93,16 @@ Commands: - `ica list` - `ica doctor` - `ica catalog` -- `ica serve` -- `ica launch` (alias; deprecated) - `ica sources list` - `ica sources add --repo-url=...` (or `--repo-path=...`; defaults to current directory when omitted) - `ica sources remove --id=...` - `ica sources auth --id=... --token=...` - `ica sources refresh [--id=...]` -- `ica sources update --id=... --name=... --repo-url=...` +- `ica sources update --id=... --name=... --repo-url=... --publish-default-mode=branch-pr --default-base-branch=main --provider-hint=github --official-contribution-enabled=false` +- `ica skills validate --path=/path/to/skill --profile=personal` +- `ica skills publish --source= --path=/path/to/skill --message="feat(skill): publish my-skill"` +- `ica skills contribute-official --path=/path/to/skill --message="Add my-skill"` +- `ica container mount-project --project-path=/path --confirm` Source-qualified example: @@ -173,6 +115,17 @@ node dist/src/installer-cli/index.js install --yes \ Legacy `--skills=` is still accepted and resolves against the official source. +## Skill Publishing and Official Contribution + +- `ica skills validate` supports `personal` and `official` profiles +- Personal publishing uses the source's configured default mode: + - `direct-push`: commits to base branch and pushes + - `branch-only`: pushes a feature branch + - `branch-pr`: pushes a feature branch and attempts PR creation when provider integration is available +- Official contribution uses strict validation and PR-oriented flow (defaults to official source base branch `dev`) +- Skill bundles are copied recursively and support `SKILL.md` + additional resources/assets/scripts/other files +- Source settings include `officialContributionEnabled` to mark official contribution targets + Custom repositories are persisted in `~/.ica/sources.json` (or `$ICA_STATE_HOME/sources.json` when set). Downloaded source skills are materialized under `~/.ica//skills` (or `$ICA_STATE_HOME//skills`). @@ -180,19 +133,16 @@ When install mode is `symlink`, ICA links installed skills from that local skill ## Dashboard -Start locally (frontend container + host API control plane): +Start locally (binds to `127.0.0.1`): ```bash -ica serve --open=true +npm ci +npm run build +npm run start:dashboard ``` Open: `http://127.0.0.1:4173` -Architecture note: -- `ica serve` runs the ICA API on localhost (`127.0.0.1`) with an ephemeral per-session API key. -- The dashboard container serves static frontend assets only. -- A host-side BFF proxies `/api/v1/*` and `/ws/events` as same-origin routes for the browser. - ### GHCR Container Dashboard highlights: @@ -200,10 +150,9 @@ Dashboard highlights: - Install, uninstall, and sync skills across multiple targets - Add/remove/auth/refresh skill sources (HTTPS + SSH) - Target discovery plus user/project scope management -- Native project directory picker via localhost CLI API +- Native project directory picker (host helper) plus container mount orchestration endpoint - Skill catalog filtering with bulk selection controls - Installed-state and operation-report inspection in the UI -- Frontend-only container with host-BFF same-origin proxying Container image can be built from `src/installer-dashboard/Dockerfile` and published to GHCR via `.github/workflows/dashboard-ghcr.yml`. @@ -216,11 +165,9 @@ docker build -f src/installer-dashboard/Dockerfile -t ica-dashboard:local . Run: ```bash -docker run --rm -p 4173:80 ica-dashboard:local +docker run --rm -p 4173:4173 ica-dashboard:local ``` -For full installer functionality (API + WS + container lifecycle), use `ica serve --open=true`. - ## Supported Targets - `claude` @@ -262,6 +209,7 @@ Tag releases from `main` (`vX.Y.Z`). The `release-sign` workflow: ## Documentation - [Installation Guide](docs/installation-guide.md) +- [Skill Publishing Guide](docs/skill-publishing-guide.md) - [Configuration Guide](docs/configuration-guide.md) - [Workflow Guide](docs/workflow-guide.md) - [Release Signing](docs/release-signing.md) diff --git a/docs/README.md b/docs/README.md index eb09745..68f89b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,3 +6,4 @@ Deployment documentation in this repo now reflects only: - verified bootstrap installers - `ica` CLI workflows - dashboard workflows +- skill publishing and official contribution workflows diff --git a/docs/architecture.md b/docs/architecture.md index 6ca1496..aaba64b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,6 +22,8 @@ Skills are the primary interface for specialized capabilities. They are: - Local `src/skills/*/SKILL.md` fallback has been removed as part of the repo split - Installed to your agent home `skills/` directory (for example `~/.claude/skills/` or `~/.codex/skills/`) - Invoked by skill name and intent (tool-dependent), with source-qualified IDs available as `/` +- Source publish settings support per-source defaults (`direct-push` | `branch-only` | `branch-pr`) and provider hints +- Write-capable publish workspaces are separated from read-only sync caches under `~/.ica/source-workspaces//repo` If one repository references another inside Git metadata, the precise term is **Git submodule** (not "subrepo"). diff --git a/docs/configuration-guide.md b/docs/configuration-guide.md index 8730cb1..1e1efc5 100644 --- a/docs/configuration-guide.md +++ b/docs/configuration-guide.md @@ -66,6 +66,25 @@ Notes: ## Key Settings +### Autonomy + Work-Item Orchestration +- `autonomy.level` (string) — L1/L2/L3 autonomy mode +- `autonomy.work_item_pipeline_enabled` (bool, default `true`) — auto-run `create-work-items` -> `plan-work-items` -> `run-work-items` when actionable findings/comments are detected +- `autonomy.work_item_pipeline_mode` (string, default `batch_auto`) — confirmation behavior for actionable finding ingestion + - `batch_auto`: no extra confirmation + - `batch_confirm`: one grouped confirmation + - `item_confirm`: per-item confirmation + +Example: + +```json +{ + "autonomy": { + "work_item_pipeline_enabled": true, + "work_item_pipeline_mode": "batch_auto" + } +} +``` + ### Git - `git.privacy` (bool) — strip AI mentions from commits/PRs - `git.privacy_patterns` (array) @@ -88,3 +107,24 @@ Notes: ### Models Model selection is **user‑controlled via Claude Code settings** (`.claude/settings.json` or `~/.claude/settings.json`) or `/model`. + +## Source Registry Publish Settings + +Skill publishing defaults are stored in the source registry (`~/.ica/sources.json` or `$ICA_STATE_HOME/sources.json`), not in `ica.config.json`. + +Per-source publish fields: + +- `publishDefaultMode`: `direct-push` | `branch-only` | `branch-pr` +- `defaultBaseBranch`: target branch for publish operations +- `providerHint`: `github` | `gitlab` | `bitbucket` | `unknown` +- `officialContributionEnabled`: marks a source as eligible for official contribution flow + +Update via CLI: + +```bash +node dist/src/installer-cli/index.js sources update --id=my-source \ + --publish-default-mode=branch-pr \ + --default-base-branch=main \ + --provider-hint=github \ + --official-contribution-enabled=false +``` diff --git a/docs/index.md b/docs/index.md index e0a75bd..c64f20d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,8 +4,9 @@ 1. [Installation Guide](installation-guide.md) 2. [Configuration Guide](configuration-guide.md) 3. [Workflow Guide](workflow-guide.md) -4. [MCP Integration (Claude Code)](mcp-integration.md) -5. [MCP Proxy (ICA-Owned)](mcp-proxy.md) +4. [Skill Publishing Guide](skill-publishing-guide.md) +5. [MCP Integration (Claude Code)](mcp-integration.md) +6. [MCP Proxy (ICA-Owned)](mcp-proxy.md) ## Core Concepts - [Roles and Skills](skills-reference.md) diff --git a/docs/installation-guide.md b/docs/installation-guide.md index 177e646..16bc4d6 100644 --- a/docs/installation-guide.md +++ b/docs/installation-guide.md @@ -49,7 +49,38 @@ node dist/src/installer-cli/index.js serve --open=true node dist/src/installer-cli/index.js sources list node dist/src/installer-cli/index.js sources add --repo-url=https://github.com/intelligentcode-ai/skills.git node dist/src/installer-cli/index.js sources add --repo-path=. # uses current directory as local source +node dist/src/installer-cli/index.js sources update --id=my-source --publish-default-mode=branch-pr --default-base-branch=main --provider-hint=github node dist/src/installer-cli/index.js sources refresh +node dist/src/installer-cli/index.js skills validate --path=/path/to/skill --profile=personal +node dist/src/installer-cli/index.js skills publish --source=my-source --path=/path/to/skill +node dist/src/installer-cli/index.js skills contribute-official --path=/path/to/skill +``` + +## Skill Publishing Quick Start + +1. Add or update a source: + +```bash +node dist/src/installer-cli/index.js sources add --repo-url=https://github.com/your-org/skills.git --name=my-source +node dist/src/installer-cli/index.js sources update --id=my-source \ + --publish-default-mode=branch-pr \ + --default-base-branch=main \ + --provider-hint=github \ + --official-contribution-enabled=false +``` + +2. Validate and publish: + +```bash +node dist/src/installer-cli/index.js skills validate --path=/path/to/skill --profile=personal +node dist/src/installer-cli/index.js skills publish --source=my-source --path=/path/to/skill +``` + +3. Propose to official source: + +```bash +node dist/src/installer-cli/index.js skills validate --path=/path/to/skill --profile=official +node dist/src/installer-cli/index.js skills contribute-official --path=/path/to/skill ``` Non-interactive example: diff --git a/docs/skill-publishing-guide.md b/docs/skill-publishing-guide.md new file mode 100644 index 0000000..ae5e0bb --- /dev/null +++ b/docs/skill-publishing-guide.md @@ -0,0 +1,159 @@ +# Skill Publishing Guide + +This guide covers how to validate and publish local skill bundles to your own repositories, and how to propose skills to the official source. + +## What This Supports + +- Local skill bundles from any directory (existing repo, downloaded folder, dedicated local folder) +- Recursive bundle publishing (`SKILL.md` plus scripts/references/assets/other files) +- Per-source publishing defaults: + - `direct-push` + - `branch-only` + - `branch-pr` +- Official contribution flow with strict validation and PR-oriented publishing + +## Bundle Requirements + +Required: + +- `SKILL.md` + +Recommended: + +- YAML frontmatter in `SKILL.md` with: + - `name` + - `description` + - `category` + - `version` + +Supported additional content: + +- `scripts/` +- `references/` +- `assets/` +- other files/folders needed by the skill + +## Validation Profiles + +### Personal + +- Hard failures: + - missing `SKILL.md` + - invalid skill name + - path/symlink escape + - blocked files/secrets/size limits +- Warnings: + - missing recommended frontmatter fields + - nonstandard top-level entries + +### Official + +- Includes all personal hard failures +- Additional hard failures: + - missing frontmatter block + - missing required fields (`name`, `description`, `category`, `version`) + - broken local links in `SKILL.md` + +## Source Publish Settings + +Configure per source: + +- `publishDefaultMode`: `direct-push` | `branch-only` | `branch-pr` +- `defaultBaseBranch`: typically `main` for personal repos +- `providerHint`: `github` | `gitlab` | `bitbucket` | `unknown` +- `officialContributionEnabled`: enables use as official contribution target + +Examples: + +```bash +node dist/src/installer-cli/index.js sources update \ + --id=my-source \ + --publish-default-mode=branch-pr \ + --default-base-branch=main \ + --provider-hint=github \ + --official-contribution-enabled=false +``` + +```bash +node dist/src/installer-cli/index.js sources update \ + --id=official-skills \ + --publish-default-mode=branch-pr \ + --default-base-branch=dev \ + --provider-hint=github \ + --official-contribution-enabled=true +``` + +## Personal Publishing Flow + +1. Validate bundle: + +```bash +node dist/src/installer-cli/index.js skills validate \ + --path=/path/to/skill \ + --profile=personal +``` + +2. Publish using source defaults: + +```bash +node dist/src/installer-cli/index.js skills publish \ + --source=my-source \ + --path=/path/to/skill \ + --message="feat(skill): publish my-skill" +``` + +Behavior by mode: + +- `direct-push`: commit and push base branch +- `branch-only`: push feature branch only +- `branch-pr`: push feature branch and attempt PR creation when provider integration is available + +## Official Contribution Flow + +1. Validate with strict profile: + +```bash +node dist/src/installer-cli/index.js skills validate \ + --path=/path/to/skill \ + --profile=official +``` + +2. Submit contribution: + +```bash +node dist/src/installer-cli/index.js skills contribute-official \ + --path=/path/to/skill \ + --message="Add my-skill" +``` + +Notes: + +- Default official base branch is `dev` +- When GitHub integration is available, ICA attempts to create PR-ready output +- For provider/API limitations, ICA returns compare/manual-PR details + +## Dashboard Workflow + +In the dashboard `Settings` tab: + +1. Configure source publish settings +2. Open `Skill Publishing` +3. Set local path and optional skill name/message +4. Run `Validate skill` +5. Run `Publish to source` or `Contribute official` +6. Review returned branch/commit/PR or compare URL + +## Storage Paths + +- Source registry: `~/.ica/sources.json` (or `$ICA_STATE_HOME/sources.json`) +- Read-only synced skills cache: `~/.ica//skills` +- Write-capable publish workspace: `~/.ica/source-workspaces//repo` + +## Safety Controls + +Publishing blocks risky content: + +- secret-like tokens in text files +- blocked credential file patterns +- path traversal and symlink escapes +- oversized file/bundle limits diff --git a/docs/skills-reference.md b/docs/skills-reference.md index 4de1035..39f03f6 100644 --- a/docs/skills-reference.md +++ b/docs/skills-reference.md @@ -63,3 +63,24 @@ file-placement, branch-protection, infrastructure-protection - `ica.workflow.json`: workflow automation controls (auto-merge standing approval, optional GitHub approvals gate, release automation) See `docs/configuration-guide.md` for the full hierarchy. + +## Authoring and Publishing Skills + +ICA supports publishing local skill bundles to configured sources. + +- Validate local bundles: + - `ica skills validate --path=/path/to/skill --profile=personal|official` +- Publish to your own source repo: + - `ica skills publish --source= --path=/path/to/skill` +- Contribute to official source: + - `ica skills contribute-official --path=/path/to/skill` + +Per-source publish behavior is configurable via: + +- `publishDefaultMode`: `direct-push`, `branch-only`, `branch-pr` +- `defaultBaseBranch`: e.g. `main` (or `dev` for official contribution workflows) +- `providerHint`: `github`, `gitlab`, `bitbucket`, `unknown` +- `officialContributionEnabled`: marks source as eligible for official contribution flow + +For full command examples and workflow details, see: +- `docs/skill-publishing-guide.md` diff --git a/ica.config.default.json b/ica.config.default.json index 9eaeaf6..e3bd212 100644 --- a/ica.config.default.json +++ b/ica.config.default.json @@ -6,6 +6,8 @@ "autonomy": { "level": "L2", "pm_always_active": true, + "work_item_pipeline_enabled": true, + "work_item_pipeline_mode": "batch_auto", "l3_settings": { "max_parallel": 5, "auto_discover": true, diff --git a/package.json b/package.json index d3bb016..abb1f00 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "ica": "dist/src/installer-cli/index.js" }, "scripts": { + "clean:compiled-tests": "node -e \"const fs=require('fs');fs.rmSync('dist/tests',{recursive:true,force:true});\"", "build": "tsc -p tsconfig.json && node dist/src/installer-core/catalog/generateCatalog.js && npm run build:dashboard:web", "build:dashboard:web": "vite build --config src/installer-dashboard/web/vite.config.ts", "dev:dashboard:web": "vite --config src/installer-dashboard/web/vite.config.ts", "start:dashboard": "node dist/src/installer-dashboard/server/index.js", - "build:quick": "tsc -p tsconfig.json && node dist/src/installer-core/catalog/generateCatalog.js", + "build:quick": "npm run clean:compiled-tests && tsc -p tsconfig.json && node dist/src/installer-core/catalog/generateCatalog.js", "skill:trigger-check": "node scripts/skill-trigger-check.mjs", "test": "npm run build:quick && node --test dist/tests/installer/*.test.js && bash tests/run-tests.sh" }, diff --git a/schemas/skill-catalog.schema.json b/schemas/skill-catalog.schema.json index df478c5..b5f0425 100644 --- a/schemas/skill-catalog.schema.json +++ b/schemas/skill-catalog.schema.json @@ -8,16 +8,23 @@ "generatedAt": { "type": "string", "format": "date-time" }, "source": { "type": "string", "enum": ["local-repo", "github-release", "multi-source"] }, "version": { "type": "string" }, - "stale": { "type": "boolean" }, - "catalogSource": { "type": "string", "enum": ["live", "cache", "snapshot"] }, - "staleReason": { "type": "string" }, - "cacheAgeSeconds": { "type": "number" }, - "nextRefreshAt": { "type": "string", "format": "date-time" }, "sources": { "type": "array", "items": { "type": "object", - "required": ["id", "name", "repoUrl", "transport", "official", "enabled", "skillsRoot", "removable"], + "required": [ + "id", + "name", + "repoUrl", + "transport", + "official", + "enabled", + "skillsRoot", + "publishDefaultMode", + "providerHint", + "officialContributionEnabled", + "removable" + ], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, @@ -26,6 +33,10 @@ "official": { "type": "boolean" }, "enabled": { "type": "boolean" }, "skillsRoot": { "type": "string" }, + "publishDefaultMode": { "type": "string", "enum": ["direct-push", "branch-only", "branch-pr"] }, + "defaultBaseBranch": { "type": "string" }, + "providerHint": { "type": "string", "enum": ["github", "gitlab", "bitbucket", "unknown"] }, + "officialContributionEnabled": { "type": "boolean" }, "credentialRef": { "type": "string" }, "removable": { "type": "boolean" }, "lastSyncAt": { "type": "string", "format": "date-time" }, @@ -64,11 +75,7 @@ "description": { "type": "string" }, "category": { "type": "string" }, "scope": { "type": "string" }, - "subcategory": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, - "author": { "type": "string" }, - "contactEmail": { "type": "string" }, - "website": { "type": "string" }, "dependencies": { "type": "array", "items": { "type": "string" } }, "compatibleTargets": { "type": "array", @@ -83,7 +90,7 @@ "type": "object", "required": ["type", "path"], "properties": { - "type": { "type": "string", "enum": ["references", "scripts", "assets"] }, + "type": { "type": "string", "enum": ["references", "scripts", "assets", "other"] }, "path": { "type": "string" } } } diff --git a/src/catalog/skills.catalog.json b/src/catalog/skills.catalog.json index 49d4397..bac9a68 100644 --- a/src/catalog/skills.catalog.json +++ b/src/catalog/skills.catalog.json @@ -1,7 +1,7 @@ { "generatedAt": "1970-01-01T00:00:00.000Z", "source": "multi-source", - "version": "12.1.0", + "version": "12.0.0", "sources": [ { "id": "official-skills", @@ -11,1040 +11,12 @@ "official": true, "enabled": true, "skillsRoot": "/skills", + "publishDefaultMode": "branch-pr", + "defaultBaseBranch": "dev", + "providerHint": "github", + "officialContributionEnabled": true, "removable": true } ], - "skills": [ - { - "skillId": "official-skills/agent-browser", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "agent-browser", - "name": "agent-browser", - "description": "Use when you need to reproduce or debug web UI flows (especially auth/OIDC) via the Agent Browser CLI, capture snapshots/screenshots, and extract redirect URLs and on-page errors deterministically. Includes install/setup guidance when the CLI is missing.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/agent-browser", - "version": "10.2.14" - }, - { - "skillId": "official-skills/ai-engineer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "ai-engineer", - "name": "ai-engineer", - "description": "Activate when user needs AI/ML work - model integration, behavioral frameworks, intelligent automation. Activate when the ai-engineer skill is requested or work involves machine learning, agentic systems, or AI-driven features.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/ai-engineer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/architect", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "architect", - "name": "architect", - "description": "Activate when user needs architectural decisions, system design, technology selection, or design reviews. Activate when the architect skill is requested or work requires structural decisions. Provides design patterns and architectural guidance.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/architect", - "version": "10.2.14" - }, - { - "skillId": "official-skills/autonomy", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "autonomy", - "name": "autonomy", - "description": "Activate when a subagent completes work and needs continuation check. Activate when a task finishes to determine next steps or when detecting work patterns in user messages. Governs automatic work continuation and queue management.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/autonomy", - "version": "10.2.14" - }, - { - "skillId": "official-skills/backend-tester", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "backend-tester", - "name": "backend-tester", - "description": "Activate when user needs API or backend testing - REST/GraphQL validation, integration tests, database verification. Activate when the backend-tester skill is requested or work requires backend quality assurance.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/backend-tester", - "version": "10.2.14" - }, - { - "skillId": "official-skills/best-practices", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "best-practices", - "name": "best-practices", - "description": "Activate when starting new work to check for established patterns. Activate when ensuring consistency with team standards or when promoting successful memory patterns. Searches and applies best practices before implementation.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/best-practices", - "version": "10.2.14" - }, - { - "skillId": "official-skills/branch-protection", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "branch-protection", - "name": "branch-protection", - "description": "Activate when performing git operations. MANDATORY by default - prevents direct commits to main/master, blocks destructive operations (force push, reset --hard). Enforces dev-first workflow where all changes go to dev before main. Assumes branch protection enabled unless disabled in settings.", - "category": "enforcement", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/branch-protection", - "version": "10.2.14" - }, - { - "skillId": "official-skills/commit-pr", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "commit-pr", - "name": "commit-pr", - "description": "Activate when user asks to commit, push changes, create a PR, open a pull request, or submit changes for review. Activate when process skill reaches commit or PR phase. Provides commit message formatting and PR structure. PRs default to dev branch, not main. Works with git-privacy skill.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/commit-pr", - "version": "10.2.14" - }, - { - "skillId": "official-skills/database-engineer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "database-engineer", - "name": "database-engineer", - "description": "Activate when user needs database work - schema design, query optimization, migrations, data modeling. Activate when the database-engineer skill is requested or work involves database design or performance tuning.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/database-engineer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/developer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "developer", - "name": "developer", - "description": "Activate when user asks to code, build, implement, create, fix bugs, refactor, or write software. Activate when the developer skill is requested. Provides implementation patterns and coding standards for hands-on development work.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/developer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/devops-engineer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "devops-engineer", - "name": "devops-engineer", - "description": "Activate when user needs CI/CD or deployment work - pipeline design, deployment automation, release management. Activate when the devops-engineer skill is requested or work involves build systems or infrastructure automation.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/devops-engineer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/file-placement", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "file-placement", - "name": "file-placement", - "description": "Activate when creating any summary, report, or output file. Ensures files go to correct directories (summaries/, memory/, stories/, bugs/). Mirrors what summary-file-enforcement hook enforces.", - "category": "enforcement", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/file-placement", - "version": "10.2.14" - }, - { - "skillId": "official-skills/git-privacy", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "git-privacy", - "name": "git-privacy", - "description": "Activate when performing git commits, creating pull requests, or any git operation. MANDATORY by default - prevents AI attribution (Co-Authored-By, \"Generated with\" footers). Does NOT block legitimate AI feature descriptions.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/git-privacy", - "version": "10.2.14" - }, - { - "skillId": "official-skills/ica-bootstrap", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "ica-bootstrap", - "name": "ica-bootstrap", - "description": "Activate when users need first-time ICA setup from scratch: install ICA CLI, configure sources, install baseline skills (including `ica-cli`), verify health, and hand off to day-2 CLI usage.", - "category": "command", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/ica-bootstrap", - "version": "10.2.14" - }, - { - "skillId": "official-skills/ica-cli", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "ica-cli", - "name": "ica-cli", - "description": "Activate when users ask how to use, configure, verify, upgrade, or troubleshoot the ICA CLI (`ica`) after initial setup, including source management and day-2 operations.", - "category": "command", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/ica-cli", - "version": "10.2.14" - }, - { - "skillId": "official-skills/infrastructure-protection", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "infrastructure-protection", - "name": "infrastructure-protection", - "description": "Activate when performing infrastructure, VM, container, or cloud operations. Ensures safety protocols are followed and blocks destructive operations by default. Mirrors agent-infrastructure-protection hook.", - "category": "enforcement", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/infrastructure-protection", - "version": "10.2.14" - }, - { - "skillId": "official-skills/mcp-client", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "mcp-client", - "name": "mcp-client", - "description": "Universal MCP client for connecting to MCP servers with progressive disclosure. Use when you need to list MCP servers/tools or call an MCP tool against a server that is not already wired into the current agent runtime.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/mcp-client", - "version": "10.2.14" - }, - { - "skillId": "official-skills/mcp-common", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "mcp-common", - "name": "mcp-common", - "description": "Internal shared helpers for ICA MCP tooling (client/proxy). Not intended to be invoked directly by users.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/mcp-common", - "version": "10.2.14" - }, - { - "skillId": "official-skills/mcp-config", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "mcp-config", - "name": "mcp-config", - "description": "Activate when setting up MCP servers, resolving MCP tool availability, or configuring fallbacks for MCP-dependent features. Configures and troubleshoots MCP (Model Context Protocol) integrations.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/mcp-config", - "version": "10.2.14" - }, - { - "skillId": "official-skills/mcp-proxy", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "mcp-proxy", - "name": "mcp-proxy", - "description": "Local stdio MCP proxy server that mirrors upstream MCP servers/tools and centralizes authentication (OAuth, headers/env). Register one server in your agent runtime, manage upstreams via .mcp.json and/or $ICA_HOME/mcp-servers.json.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/mcp-proxy", - "version": "10.2.14" - }, - { - "skillId": "official-skills/memory", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "memory", - "name": "memory", - "description": "Activate when user wants to save knowledge, search past decisions, or manage persistent memories. Handles architecture patterns, implementation logic, issues/fixes, and past implementations. Uses local SQLite + FTS5 + vector embeddings for fast hybrid search. Supports write, search, update, archive, and list operations.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/memory", - "version": "10.2.14" - }, - { - "skillId": "official-skills/parallel-execution", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "parallel-execution", - "name": "parallel-execution", - "description": "Activate when multiple independent work items can execute concurrently. Activate when coordinating non-blocking task patterns in L3 autonomy mode. Manages parallel execution from .agent/queue/.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/parallel-execution", - "version": "10.2.14" - }, - { - "skillId": "official-skills/pm", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "pm", - "name": "pm", - "description": "Activate when user needs coordination, story breakdown, task delegation, or progress tracking. Activate when the pm skill is requested or work requires planning before implementation. PM coordinates specialists but does not implement.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/pm", - "version": "10.2.14" - }, - { - "skillId": "official-skills/pr-automerge", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "pr-automerge", - "name": "pr-automerge", - "description": "Activate when asked to auto-review and merge a PR. Runs a closed-loop workflow: subagent Stage 3 review -> fix findings -> re-review -> post ICA-REVIEW receipt -> merge (optional via workflow.auto_merge).", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/pr-automerge", - "version": "10.2.14" - }, - { - "skillId": "official-skills/pr-comments", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "pr-comments", - "name": "pr-comments", - "description": "Ensures pull request descriptions and commit messages are written for human reviewers — clear, professional, and without any AI attribution. This skill is automatically applied when creating PRs or commits. Use when the user says \"review my PR description\", \"improve PR description\", or \"check commit message\".", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/pr-comments" - }, - { - "skillId": "official-skills/process", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "process", - "name": "process", - "description": "Activate when user explicitly requests the development workflow process, asks about workflow phases, or says \"start work\", \"begin development\", \"follow the process\". Activate when creating PRs or deploying to production. NOT for simple questions or minor fixes. Enforces TDD by default for implementation work and executes AUTONOMOUSLY - only pauses when human decision is genuinely required.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/process", - "version": "10.2.14" - }, - { - "skillId": "official-skills/qa-engineer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "qa-engineer", - "name": "qa-engineer", - "description": "Activate when user needs test planning or QA strategy - test frameworks, quality metrics, test automation. Activate when the qa-engineer skill is requested or work requires a comprehensive quality assurance approach.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/qa-engineer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/release", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "release", - "name": "release", - "description": "Activate when user asks to release, bump version, cut a release, merge to main, or tag a version. Handles version bumping (semver), CHANGELOG updates, PR merging, git tagging, and GitHub release creation.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/release", - "version": "10.2.14" - }, - { - "skillId": "official-skills/requirements-engineer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "requirements-engineer", - "name": "requirements-engineer", - "description": "Activate when user needs requirements gathering - business analysis, specification development, user stories. Activate when the requirements-engineer skill is requested or work requires bridging business and technical understanding.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/requirements-engineer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/reviewer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "reviewer", - "name": "reviewer", - "description": "Activate when reviewing code, before committing, after committing, or before merging a PR. Activate when user asks to review, audit, check for security issues, or find regressions. Analyzes code for logic errors, regressions, edge cases, security issues, and test gaps. Fixes findings AUTOMATICALLY. Required at process skill quality gates.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/reviewer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/security-engineer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "security-engineer", - "name": "security-engineer", - "description": "Activate when user needs security work - vulnerability assessment, security architecture, compliance audits, penetration testing. Activate when the security-engineer skill is requested or work requires security review.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/security-engineer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/skill-creator", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "skill-creator", - "name": "skill-creator", - "description": "Activate when user wants to create a new skill or update an existing skill. Activate when extending capabilities with specialized knowledge, workflows, or tool integrations. Provides guidance for effective skill design.", - "category": "meta", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/skill-creator", - "version": "10.2.14" - }, - { - "skillId": "official-skills/skill-writer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "skill-writer", - "name": "skill-writer", - "description": "Activate when users want to create or update an Agent Skill and want a Test-Driven Development approach. Defines a red-green-refactor workflow for SKILL.md authoring, trigger quality, validation, and iterative refinement.", - "category": "meta", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/skill-writer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/story-breakdown", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "story-breakdown", - "name": "story-breakdown", - "description": "Activate when user presents a large story or epic that needs decomposition. Activate when a task spans multiple components or requires coordination across specialists. Creates work items in .agent/queue/ for execution.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/story-breakdown", - "version": "10.2.14" - }, - { - "skillId": "official-skills/suggest", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "suggest", - "name": "suggest", - "description": "Activate when user asks for improvement suggestions, refactoring ideas, or \"what could be better\". Analyzes code and provides realistic, context-aware proposals. Implements safe improvements AUTOMATICALLY. Separate from reviewer (which finds problems).", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/suggest", - "version": "10.2.14" - }, - { - "skillId": "official-skills/system-engineer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "system-engineer", - "name": "system-engineer", - "description": "Activate when user needs infrastructure or system operations work - system reliability, monitoring, capacity planning. Activate when the system-engineer skill is requested or work involves configuration management or operational excellence.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/system-engineer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/tdd", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "tdd", - "name": "tdd", - "description": "Activate when user asks for Test-Driven Development, test-first implementation, red-green-refactor, or enforcing tests before code. Use for feature work, bug fixes, and refactors; treat TDD as the default rule unless the user explicitly waives it.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/tdd", - "version": "10.2.14" - }, - { - "skillId": "official-skills/thinking", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "thinking", - "name": "thinking", - "description": "Activate when facing complex problems, multi-step decisions, high-risk changes, complex debugging, or architectural decisions. Activate when careful analysis is needed before taking action. Provides explicit step-by-step validation.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/thinking", - "version": "10.2.14" - }, - { - "skillId": "official-skills/user-tester", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "user-tester", - "name": "user-tester", - "description": "Activate when user needs E2E or user journey testing - browser automation, Puppeteer/Playwright, cross-browser testing. Activate when the user-tester skill is requested or work requires user flow validation or experience verification.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/user-tester", - "version": "10.2.14" - }, - { - "skillId": "official-skills/validate", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "validate", - "name": "validate", - "description": "Activate when checking if work meets requirements, verifying completion criteria, validating file placement, or ensuring quality standards. Use before marking work complete to verify success criteria are met.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/validate", - "version": "10.2.14" - }, - { - "skillId": "official-skills/web-designer", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "web-designer", - "name": "web-designer", - "description": "Activate when user needs UI/UX design work - interface design, user research, design systems, accessibility. Activate when the web-designer skill is requested or work requires visual design or user experience expertise.", - "category": "role", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/web-designer", - "version": "10.2.14" - }, - { - "skillId": "official-skills/work-queue", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "work-queue", - "name": "work-queue", - "description": "Activate when user has a large task to break into smaller work items. Activate when user asks about work queue status or what remains to do. Activate when managing sequential or parallel execution. Creates and manages .agent/queue/ for cross-platform work tracking.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/work-queue", - "version": "10.2.14" - }, - { - "skillId": "official-skills/workflow", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "workflow", - "name": "workflow", - "description": "Activate when checking workflow step requirements, resolving workflow conflicts, or ensuring proper execution sequence. Applies workflow enforcement patterns and validates compliance.", - "category": "process", - "author": "Karsten Samaschke", - "contactEmail": "karsten@vanillacore.net", - "website": "https://vanillacore.net", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "skills/workflow", - "version": "10.2.14" - } - ] + "skills": [] } diff --git a/src/installer-cli/index.ts b/src/installer-cli/index.ts index 41a9ac8..0b78a53 100644 --- a/src/installer-cli/index.ts +++ b/src/installer-cli/index.ts @@ -3,9 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; -import net from "node:net"; -import { spawn, execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { spawn, ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { executeOperation } from "../installer-core/executor"; @@ -20,12 +18,11 @@ import { loadHookCatalogFromSources, HookInstallSelection } from "../installer-c import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from "../installer-core/hookExecutor"; import { loadHookInstallState } from "../installer-core/hookState"; import { registerRepository } from "../installer-core/repositories"; -import { refreshSourcesAndHooks } from "../installer-core/sourceRefresh"; +import { contributeOfficialSkillBundle, publishSkillBundle, validateSkillBundle } from "../installer-core/skillPublish"; import { loadInstallState } from "../installer-core/state"; import { parseTargets, resolveTargetPaths } from "../installer-core/targets"; -import { checkForAppUpdate } from "../installer-core/updateCheck"; import { findRepoRoot } from "../installer-core/repo"; -import { InstallMode, InstallRequest, InstallScope, InstallSelection, OperationKind, TargetPlatform } from "../installer-core/types"; +import { InstallMode, InstallRequest, InstallScope, InstallSelection, OperationKind, PublishMode, TargetPlatform, ValidationProfile } from "../installer-core/types"; interface ParsedArgs { command: string; @@ -33,18 +30,10 @@ interface ParsedArgs { positionals: string[]; } -const execFileAsync = promisify(execFile); -const DEFAULT_DASHBOARD_IMAGE = "ghcr.io/intelligentcode-ai/ica-installer-dashboard:main"; - -export type ServeImageBuildMode = "auto" | "always" | "never"; -export type ServeReusePortsMode = boolean; - -export function redactCliErrorMessage(message: string): string { - return message - .replace(/(ICA_(?:UI_)?API_KEY=)[^\s]+/g, "$1[REDACTED]") - .replace(/(--(?:token|api-key)=)[^\s]+/g, "$1[REDACTED]") - .replace(/(x-ica-api-key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, "$1[REDACTED]"); -} +const HELPER_HOST = "127.0.0.1"; +const HELPER_PORT = Number(process.env.ICA_HELPER_PORT || "4174"); +const HELPER_TOKEN = process.env.ICA_HELPER_TOKEN || crypto.randomBytes(24).toString("hex"); +let helperProcess: ChildProcessWithoutNullStreams | null = null; function parseArgv(argv: string[]): ParsedArgs { const [command = "help", ...rest] = argv; @@ -167,269 +156,105 @@ function parseHookTargetsStrict(rawValue: string): HookTargetPlatform[] { return ["claude"]; } -async function commandExists(command: string): Promise { - try { - await execFileAsync(command, ["--version"], { maxBuffer: 1024 * 1024 }); - return true; - } catch { - return false; - } -} - -async function isLoopbackPortAvailable(port: number): Promise { - return await new Promise((resolve) => { - const server = net.createServer(); - server.unref(); - server.once("error", () => { - resolve(false); - }); - server.once("listening", () => { - server.close(() => resolve(true)); - }); - server.listen(port, "127.0.0.1"); +async function helperRequest(pathname: string, body: Record): Promise> { + const response = await fetch(`http://${HELPER_HOST}:${HELPER_PORT}${pathname}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-ica-helper-token": HELPER_TOKEN, + }, + body: JSON.stringify(body), }); -} - -async function getListeningPids(port: number): Promise { - const pids = new Set(); - - try { - if (process.platform === "win32") { - const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { maxBuffer: 4 * 1024 * 1024 }); - const lines = stdout.split(/\r?\n/); - const matcher = new RegExp(`:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`, "i"); - for (const line of lines) { - const match = line.match(matcher); - if (!match) continue; - const pid = Number.parseInt(match[1], 10); - if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) { - pids.add(pid); - } - } - return Array.from(pids); - } - - const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { maxBuffer: 1024 * 1024 }); - for (const line of stdout.split(/\r?\n/)) { - const pid = Number.parseInt(line.trim(), 10); - if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) { - pids.add(pid); - } - } - } catch { - return []; - } - - return Array.from(pids); -} - -function canSignalPid(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; + const payload = (await response.json()) as Record; + if (!response.ok) { + throw new Error(typeof payload.error === "string" ? payload.error : "Helper request failed."); } + return payload; } -async function isIcaOwnedServePid(pid: number): Promise { - if (process.platform === "win32") { - // Keep previous behavior on Windows where lightweight commandline checks are less portable. - return true; - } - try { - const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="], { maxBuffer: 1024 * 1024 }); - const command = (stdout || "").toLowerCase(); - return ( - command.includes("installer-api/server/index.js") || - command.includes("installer-bff/server/index.js") || - command.includes("dist/src/installer-api/server/index.js") || - command.includes("dist/src/installer-bff/server/index.js") - ); - } catch { - return false; - } -} - -async function waitForPortAvailable(port: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await isLoopbackPortAvailable(port)) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return await isLoopbackPortAvailable(port); -} - -async function reclaimLoopbackPort(port: number, flagName: "ui-port" | "api-port"): Promise { - if (await isLoopbackPortAvailable(port)) { - return; - } - - const pids = await getListeningPids(port); - if (pids.length === 0) { - throw new Error( - `Requested --${flagName}=${port} is in use, but ICA could not identify the owning process to stop it automatically.`, - ); - } - - for (const pid of pids) { - if (!(await isIcaOwnedServePid(pid))) { - throw new Error( - `Requested --${flagName}=${port} is in use by non-ICA process (pid ${pid}). Stop it manually or choose a different port.`, - ); - } - } - - output.write(`Notice: ${flagName} ${port} is busy; stopping existing process on that port.\n`); - for (const pid of pids) { +async function waitForHelperReady(retries = 30): Promise { + for (let attempt = 0; attempt < retries; attempt += 1) { try { - process.kill(pid, "SIGTERM"); + const response = await fetch(`http://${HELPER_HOST}:${HELPER_PORT}/health`, { + headers: { + "x-ica-helper-token": HELPER_TOKEN, + }, + }); + if (response.ok) return; } catch { - // ignore individual process signal failures + // retry } + await new Promise((resolve) => setTimeout(resolve, 150)); } + throw new Error("ICA helper did not become ready in time."); +} - if (await waitForPortAvailable(port, 2000)) { - return; - } - - for (const pid of pids) { - if (!canSignalPid(pid)) continue; +async function ensureHelperRunning(repoRoot: string): Promise { + if (helperProcess && !helperProcess.killed) { try { - process.kill(pid, "SIGKILL"); + await waitForHelperReady(1); + return; } catch { - // ignore individual process signal failures + // respawn below } } - if (await waitForPortAvailable(port, 1500)) { - return; + const helperScript = path.join(repoRoot, "dist", "src", "installer-helper", "server.js"); + if (!fs.existsSync(helperScript)) { + throw new Error("Native helper is not built. Run: npm run build"); } - throw new Error(`Requested --${flagName}=${port} is still in use after attempting to stop the existing process.`); -} - -export function parseServeImageBuildMode(rawValue: string): ServeImageBuildMode { - const normalized = rawValue.trim().toLowerCase(); - if (normalized === "auto" || normalized === "always" || normalized === "never") { - return normalized; - } - throw new Error(`Invalid --build-image value '${rawValue}'. Supported: auto|always|never`); -} - -export function parseServeReusePorts(rawValue: string): ServeReusePortsMode { - const normalized = rawValue.trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - throw new Error(`Invalid --reuse-ports value '${rawValue}'. Supported: true|false`); + helperProcess = spawn(process.execPath, [helperScript], { + env: { + ...process.env, + ICA_HELPER_PORT: String(HELPER_PORT), + ICA_HELPER_TOKEN: HELPER_TOKEN, + }, + stdio: "pipe", + }); + helperProcess.stderr.on("data", (chunk) => { + const message = chunk.toString("utf8"); + process.stderr.write(`[ica-helper] ${message}`); + }); + await waitForHelperReady(); } -export function parseServeRefreshMinutes(rawValue: string): number { - const trimmed = rawValue.trim(); - const parsed = Number(trimmed); - if (!Number.isFinite(parsed) || parsed < 0) { - throw new Error(`Invalid --sources-refresh-minutes value '${rawValue}'. Use 0 to disable or a positive number.`); - } - return Math.floor(parsed); -} +function openBrowser(url: string): void { + let command = ""; + let args: string[] = []; -export function shouldBuildDashboardImage(input: { - mode: ServeImageBuildMode; - image: string; - imageExists: boolean; - defaultImage: string; -}): boolean { - if (input.mode === "always") { - return true; - } - if (input.mode === "never") { - return false; - } - if (input.imageExists) { - return false; - } - if (input.image.trim().toLowerCase().startsWith("ghcr.io/")) { - return false; + if (process.platform === "darwin") { + command = "open"; + args = [url]; + } else if (process.platform === "win32") { + command = "cmd"; + args = ["/c", "start", "", url]; + } else { + command = "xdg-open"; + args = [url]; } - return input.image === input.defaultImage; -} - -export function shouldFallbackToSourceBuild(pullErrorMessage: string): boolean { - const normalized = pullErrorMessage.toLowerCase(); - return ( - normalized.includes("no matching manifest") || - normalized.includes("no match for platform in manifest") || - normalized.includes("manifest unknown") || - normalized.includes("manifest not found") || - normalized.includes("not found: manifest") - ); -} -function toDockerRunArgs(base: { - containerName: string; - image: string; - env?: string[]; - ports?: string[]; -}): string[] { - const args: string[] = ["run", "-d", "--name", base.containerName]; - for (const envItem of base.env || []) { - args.push("-e", envItem); - } - for (const port of base.ports || []) { - args.push("-p", port); + try { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.unref(); + } catch (error) { + process.stderr.write(`Unable to open browser automatically: ${error instanceof Error ? error.message : String(error)}\n`); } - args.push(base.image); - return args; } -async function allocateLoopbackPort(input: { - preferredPort: number; - explicit: boolean; - flagName: "ui-port" | "api-port"; - blockedPorts?: Set; -}): Promise { - const blocked = input.blockedPorts || new Set(); - if (!blocked.has(input.preferredPort) && (await isLoopbackPortAvailable(input.preferredPort))) { - return input.preferredPort; - } - if (input.explicit) { - throw new Error(`Requested --${input.flagName}=${input.preferredPort} is unavailable. Choose a different port.`); +function parseServePort(rawValue: string, flagName: string): number { + const parsed = Number(rawValue.trim()); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + throw new Error(`Invalid --${flagName} value '${rawValue}'.`); } - for (let candidate = input.preferredPort + 1; candidate <= Math.min(65535, input.preferredPort + 100); candidate += 1) { - if (blocked.has(candidate)) { - continue; - } - if (await isLoopbackPortAvailable(candidate)) { - output.write(`Notice: ${input.flagName} ${input.preferredPort} is busy; using ${candidate}.\n`); - return candidate; - } - } - throw new Error(`Unable to find a free localhost port for --${input.flagName} near ${input.preferredPort}.`); + return parsed; } -async function waitForApiReady(port: number, apiKey: string, retries = 40): Promise { +async function waitForDashboardReady(url: string, child: ReturnType, retries = 50): Promise { for (let attempt = 0; attempt < retries; attempt += 1) { - try { - const response = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { - headers: { "x-ica-api-key": apiKey }, - }); - if (response.ok) return; - } catch { - // retry + if (child.exitCode !== null) { + throw new Error(`Dashboard process exited before becoming ready (code ${child.exitCode}).`); } - await new Promise((resolve) => setTimeout(resolve, 150)); - } - throw new Error("ICA API did not become ready in time."); -} - -async function waitForHttpReady(url: string, retries = 40): Promise { - for (let attempt = 0; attempt < retries; attempt += 1) { try { const response = await fetch(url); if (response.ok) return; @@ -438,99 +263,7 @@ async function waitForHttpReady(url: string, retries = 40): Promise { } await new Promise((resolve) => setTimeout(resolve, 150)); } - throw new Error(`Service did not become ready in time: ${url}`); -} - -async function dockerInspect(containerName: string): Promise { - try { - await execFileAsync("docker", ["inspect", containerName], { maxBuffer: 8 * 1024 * 1024 }); - return true; - } catch { - return false; - } -} - -async function reclaimDockerPublishedPort(port: number, expectedContainerName: string): Promise { - try { - const { stdout } = await execFileAsync( - "docker", - ["ps", "--filter", `publish=${port}`, "--format", "{{.ID}} {{.Names}}"], - { - maxBuffer: 4 * 1024 * 1024, - }, - ); - const ids = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - if (ids.length === 0) { - return 0; - } - let removed = 0; - for (const row of ids) { - const [id, name] = row.split(/\s+/, 2); - if (!id || !name || name !== expectedContainerName) { - continue; - } - await execFileAsync("docker", ["rm", "-f", id], { maxBuffer: 8 * 1024 * 1024 }); - removed += 1; - } - return removed; - } catch { - return 0; - } -} - -async function dockerImageExists(image: string): Promise { - try { - await execFileAsync("docker", ["image", "inspect", image], { maxBuffer: 8 * 1024 * 1024 }); - return true; - } catch { - return false; - } -} - -async function ensureDashboardImage(options: { - repoRoot: string; - image: string; - mode: ServeImageBuildMode; - defaultImage: string; -}): Promise { - const dockerfilePath = path.join(options.repoRoot, "src", "installer-dashboard", "Dockerfile"); - const buildFromSource = async (): Promise => { - if (!fs.existsSync(dockerfilePath)) { - throw new Error(`Dashboard Dockerfile not found at ${dockerfilePath}. Provide --image= or run with --build-image=never.`); - } - output.write(`Building dashboard image '${options.image}' from source...\n`); - await execFileAsync("docker", ["build", "-f", dockerfilePath, "-t", options.image, options.repoRoot], { maxBuffer: 16 * 1024 * 1024 }); - output.write(`Built dashboard image '${options.image}'.\n`); - }; - - const imageExists = await dockerImageExists(options.image); - const shouldBuild = shouldBuildDashboardImage({ - mode: options.mode, - image: options.image, - imageExists, - defaultImage: options.defaultImage, - }); - if (!shouldBuild) { - if (!imageExists && options.image.trim().toLowerCase().startsWith("ghcr.io/")) { - output.write(`Pulling dashboard image '${options.image}'...\n`); - try { - await execFileAsync("docker", ["pull", options.image], { maxBuffer: 16 * 1024 * 1024 }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const canFallback = options.mode !== "never" && fs.existsSync(dockerfilePath) && shouldFallbackToSourceBuild(message); - if (!canFallback) { - throw error; - } - output.write("Dashboard image pull failed for this platform; falling back to source build.\n"); - await buildFromSource(); - } - } - return; - } - await buildFromSource(); + throw new Error(`Dashboard did not become ready in time at ${url}.`); } function printHelp(): void { @@ -546,20 +279,24 @@ function printHelp(): void { output.write(` ica sources add [--repo-url= | --repo-path=] [--name=] [--id=] [--transport=https|ssh]\n`); output.write(` ica sources remove --id=\n`); output.write( - ` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--hooks-root=/hooks] [--enabled=true|false]\n`, + ` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--hooks-root=/hooks] [--enabled=true|false] [--publish-default-mode=direct-push|branch-only|branch-pr] [--default-base-branch=main] [--provider-hint=github|gitlab|bitbucket|unknown]\n`, ); output.write(` ica sources auth --id= [--token=]\n`); output.write(` ica sources refresh [--id=]\n\n`); + output.write(` ica skills validate --path= [--profile=personal|official]\n`); + output.write( + ` ica skills publish --source= --path= [--message=] [--override-mode=direct-push|branch-only|branch-pr] [--override-base-branch=]\n`, + ); + output.write(` ica skills contribute-official --path= [--source=] [--message=]\n\n`); output.write(` ica hooks list [--targets=claude,gemini] [--scope=user|project] [--project-path=/path]\n`); output.write(` ica hooks catalog [--json]\n`); output.write(` ica hooks install [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n`); output.write(` ica hooks uninstall [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n`); output.write(` ica hooks sync [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n\n`); - output.write( - ` ica serve [--host=127.0.0.1] [--ui-port=4173] [--api-port=4174] [--reuse-ports=true|false] [--open=true|false] [--image=ghcr.io/intelligentcode-ai/ica-installer-dashboard:main] [--build-image=auto|always|never]\n`, - ); + output.write(` ica serve [--host=127.0.0.1] [--ui-port=4173] [--open=true|false]\n`); output.write(` ica launch (alias for serve; deprecated)\n\n`); output.write(` Note: repository registration is unified. Adding one source auto-registers both skills and hooks mirrors.\n\n`); + output.write(` ica container mount-project --project-path= --confirm [--container-name=] [--image=] [--port=] [--json]\n\n`); output.write(`Common flags:\n`); output.write(` --targets=claude,codex\n`); output.write(` --scope=user|project\n`); @@ -576,79 +313,6 @@ function printHelp(): void { output.write(` --yes\n`); output.write(` --json\n`); output.write(` --refresh (for catalog: force live source refresh)\n`); - output.write(` --sources-refresh-minutes=60 (serve only; set 0 to disable periodic source refresh)\n`); -} - -function resolveInstallerVersion(repoRoot: string): string { - const versionFile = path.join(repoRoot, "VERSION"); - if (!fs.existsSync(versionFile)) { - return "0.0.0"; - } - try { - const value = fs.readFileSync(versionFile, "utf8").trim(); - return value || "0.0.0"; - } catch { - return "0.0.0"; - } -} - -async function maybePrintUpdateNotifier(repoRoot: string, options: Record): Promise { - if (boolOption(options, "json", false)) { - return; - } - const currentVersion = resolveInstallerVersion(repoRoot); - const update = await checkForAppUpdate(currentVersion); - if (!update.updateAvailable || !update.latestVersion) { - return; - } - const targetVersion = update.latestVersion.replace(/^v/i, ""); - const link = update.latestReleaseUrl || "https://github.com/intelligentcode-ai/intelligent-code-agents/releases/latest"; - output.write(`Update available: ICA ${targetVersion} (current ${currentVersion}). ${link}\n`); -} - -async function refreshSourcesOnCliStart(): Promise { - try { - const result = await refreshSourcesAndHooks({ - credentials: createCredentialProvider(), - loadSources, - loadHookSources, - syncSource, - syncHookSource, - }); - const errors = result.refreshed.flatMap((entry) => [entry.skills?.error, entry.hooks?.error]).filter((item): item is string => Boolean(item)); - if (errors.length > 0) { - output.write(`Warning: startup source refresh completed with ${errors.length} error(s).\n`); - } - } catch (error) { - output.write(`Warning: startup source refresh failed: ${error instanceof Error ? error.message : String(error)}\n`); - } -} - -function openBrowser(url: string): void { - let command = ""; - let args: string[] = []; - if (process.platform === "darwin") { - command = "open"; - args = [url]; - } else if (process.platform === "win32") { - command = "cmd"; - args = ["/c", "start", "", url]; - } else { - command = "xdg-open"; - args = [url]; - } - - try { - const child = spawn(command, args, { detached: true, stdio: "ignore" }); - child.unref(); - } catch (error) { - process.stderr.write(`Unable to open browser automatically: ${error instanceof Error ? error.message : String(error)}\n`); - } -} - -function isLoopbackHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "localhost"; } async function promptInteractive(command: OperationKind, options: Record): Promise { @@ -811,8 +475,7 @@ async function runDoctor(options: Record): Promise): Promise { const repoRoot = findRepoRoot(__dirname); - const refresh = boolOption(options, "refresh", false); - const catalog = await loadCatalogFromSources(repoRoot, refresh); + const catalog = await loadCatalogFromSources(repoRoot, false); if (boolOption(options, "json", false)) { output.write(`${JSON.stringify(catalog, null, 2)}\n`); return; @@ -820,21 +483,6 @@ async function runCatalog(options: Record): Promise>[number]; hooks?: Awaited>[number]; }> @@ -901,6 +553,10 @@ async function runSources(positionals: string[], options: Record>[number]; hooks?: Awaited>[number]; } @@ -917,6 +573,10 @@ async function runSources(positionals: string[], options: Record repo.id === sourceId) + : repositories.filter((repo) => repo.skills?.enabled !== false || repo.hooks?.enabled !== false); + if (targets.length === 0) { throw new Error(sourceId ? `Unknown source '${sourceId}'` : "No enabled sources found."); } - const refreshed = result.refreshed.map((item) => ({ - id: item.sourceId, - skills: item.skills, - hooks: item.hooks, - })); + + const refreshed: Array<{ + id: string; + skills?: { revision?: string; localPath?: string; error?: string }; + hooks?: { revision?: string; localPath?: string; error?: string }; + }> = []; + for (const repo of targets) { + const item: { + id: string; + skills?: { revision?: string; localPath?: string; error?: string }; + hooks?: { revision?: string; localPath?: string; error?: string }; + } = { id: repo.id }; + + if (repo.skills) { + try { + const result = await syncSource(repo.skills, credentialProvider); + item.skills = { revision: result.revision, localPath: result.localPath }; + } catch (error) { + item.skills = { error: error instanceof Error ? error.message : String(error) }; + } + } + if (repo.hooks) { + try { + const result = await syncHookSource(repo.hooks, credentialProvider); + item.hooks = { revision: result.revision, localPath: result.localPath }; + } catch (error) { + item.hooks = { error: error instanceof Error ? error.message : String(error) }; + } + } + refreshed.push(item); + } output.write(json ? `${JSON.stringify(refreshed, null, 2)}\n` : `Refreshed ${refreshed.length} repositories.\n`); return; } @@ -1261,220 +963,201 @@ async function runHooks(positionals: string[], options: Record): Promise { const repoRoot = findRepoRoot(__dirname); const host = stringOption(options, "host", "127.0.0.1").trim() || "127.0.0.1"; - if (!isLoopbackHost(host)) { - throw new Error(`Refusing non-loopback host '${host}'. The ICA API is localhost-only.`); - } - const uiPortInput = Number(stringOption(options, "ui-port", stringOption(options, "port", "4173")).trim() || "4173"); - const apiPortInput = Number(stringOption(options, "api-port", "4174").trim() || "4174"); - if (!Number.isInteger(uiPortInput) || uiPortInput <= 0) { - throw new Error("Invalid --ui-port value."); - } - if (!Number.isInteger(apiPortInput) || apiPortInput <= 0) { - throw new Error("Invalid --api-port value."); - } - const uiPortExplicit = options["ui-port"] !== undefined || options.port !== undefined; - const apiPortExplicit = options["api-port"] !== undefined; - const reusePorts = parseServeReusePorts(stringOption(options, "reuse-ports", process.env.ICA_REUSE_PORTS || "true")); - if (uiPortInput === apiPortInput) { - throw new Error("API and UI ports must be different. Choose distinct --api-port and --ui-port values."); - } - const containerName = stringOption(options, "container-name", process.env.ICA_DASHBOARD_CONTAINER_NAME || "ica-dashboard"); - const image = stringOption(options, "image", process.env.ICA_DASHBOARD_IMAGE || DEFAULT_DASHBOARD_IMAGE); - const buildMode = parseServeImageBuildMode(stringOption(options, "build-image", process.env.ICA_DASHBOARD_BUILD_IMAGE || "auto")); - const sourcesRefreshMinutes = parseServeRefreshMinutes( - stringOption(options, "sources-refresh-minutes", process.env.ICA_SOURCE_REFRESH_INTERVAL_MINUTES || "60"), - ); - if (!(await commandExists("docker"))) { - throw new Error("Docker CLI is not available."); - } - if (await dockerInspect(containerName)) { - await execFileAsync("docker", ["rm", "-f", containerName], { maxBuffer: 8 * 1024 * 1024 }); - } + const uiPort = parseServePort(stringOption(options, "ui-port", stringOption(options, "port", "4173")), "ui-port"); - let apiPort = apiPortInput; - let uiPort = uiPortInput; - let uiContainerPort = 0; - if (reusePorts) { - await reclaimLoopbackPort(apiPort, "api-port"); - await reclaimLoopbackPort(uiPort, "ui-port"); - } else { - apiPort = await allocateLoopbackPort({ - preferredPort: apiPortInput, - explicit: apiPortExplicit, - flagName: "api-port", - }); - uiPort = await allocateLoopbackPort({ - preferredPort: uiPortInput, - explicit: uiPortExplicit, - flagName: "ui-port", - blockedPorts: new Set([apiPort]), - }); + const apiPortRaw = stringOption(options, "api-port", "").trim(); + if (apiPortRaw) { + output.write("Notice: --api-port is ignored by the local dashboard server.\n"); } - const preferredInternalUiPort = Math.max(uiPort, apiPort) + 1; - if (reusePorts) { - uiContainerPort = preferredInternalUiPort; - await reclaimDockerPublishedPort(uiContainerPort, containerName); - if (!(await isLoopbackPortAvailable(uiContainerPort))) { - throw new Error( - `Requested internal dashboard port ${uiContainerPort} is in use by another process. Choose a different --ui-port.`, - ); - } - } else { - uiContainerPort = await allocateLoopbackPort({ - preferredPort: preferredInternalUiPort, - explicit: false, - flagName: "ui-port", - blockedPorts: new Set([apiPort, uiPort]), - }); + if (options["image"] !== undefined || options["build-image"] !== undefined) { + output.write("Notice: --image/--build-image are ignored in local serve mode.\n"); } - const apiScript = path.join(repoRoot, "dist", "src", "installer-api", "server", "index.js"); - const bffScript = path.join(repoRoot, "dist", "src", "installer-bff", "server", "index.js"); - if (!fs.existsSync(apiScript)) { - throw new Error("ICA API runtime is not built. Run: npm run build"); - } - if (!fs.existsSync(bffScript)) { - throw new Error("ICA dashboard BFF runtime is not built. Run: npm run build"); + const dashboardScript = path.join(repoRoot, "dist", "src", "installer-dashboard", "server", "index.js"); + if (!fs.existsSync(dashboardScript)) { + throw new Error("Dashboard server is not built. Run: npm run build"); } - const apiKey = crypto.randomBytes(24).toString("hex"); - const hostForUrl = host.includes(":") ? `[${host}]` : host; - const apiBaseUrl = `http://${hostForUrl}:${apiPort}`; - const staticOrigin = `http://${hostForUrl}:${uiContainerPort}`; - const dashboardUrl = `http://${hostForUrl}:${uiPort}`; - const open = boolOption(options, "open", false); - - await ensureDashboardImage({ - repoRoot, - image, - mode: buildMode, - defaultImage: DEFAULT_DASHBOARD_IMAGE, - }); - const apiProcess = spawn(process.execPath, [apiScript], { - stdio: "inherit", + const dashboardUrl = `http://${host}:${uiPort}`; + const child = spawn(process.execPath, [dashboardScript], { + cwd: repoRoot, env: { ...process.env, - ICA_API_HOST: "127.0.0.1", - ICA_API_PORT: String(apiPort), - ICA_API_KEY: apiKey, - ICA_UI_PORT: String(uiPort), - ICA_SOURCE_REFRESH_INTERVAL_MINUTES: String(sourcesRefreshMinutes), + ICA_DASHBOARD_HOST: host, + ICA_DASHBOARD_PORT: String(uiPort), }, - }); - let shutdownRequested = false; - let containerStarted = false; - let bffStarted = false; - let apiProcessError: Error | null = null; - let bffProcessError: Error | null = null; - apiProcess.once("error", (error) => { - apiProcessError = error; - shutdownRequested = true; - }); - const bffProcess = spawn(process.execPath, [bffScript], { stdio: "inherit", - env: { - ...process.env, - ICA_BFF_HOST: "127.0.0.1", - ICA_BFF_PORT: String(uiPort), - ICA_BFF_STATIC_ORIGIN: `http://127.0.0.1:${uiContainerPort}`, - ICA_BFF_API_ORIGIN: `http://127.0.0.1:${apiPort}`, - ICA_BFF_API_KEY: apiKey, - }, - }); - bffProcess.once("error", (error) => { - bffProcessError = error; - shutdownRequested = true; }); - const shutdown = async (): Promise => { - if (!shutdownRequested) { - shutdownRequested = true; - } - if (containerStarted) { - try { - await execFileAsync("docker", ["rm", "-f", containerName], { maxBuffer: 8 * 1024 * 1024 }); - } catch { - // ignore cleanup failure - } - } - if (apiProcess.exitCode === null && !apiProcess.killed) { - apiProcess.kill("SIGTERM"); - } - if (bffProcess.exitCode === null && !bffProcess.killed) { - bffProcess.kill("SIGTERM"); - } - }; + const onSigint = () => child.kill("SIGINT"); + const onSigterm = () => child.kill("SIGTERM"); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); try { - await waitForApiReady(apiPort, apiKey); - const runArgs = toDockerRunArgs({ - containerName, - image, - ports: [`127.0.0.1:${uiContainerPort}:80`], - }); - const dockerRunResult = await execFileAsync("docker", runArgs, { maxBuffer: 8 * 1024 * 1024 }); - containerStarted = true; - await waitForHttpReady(`http://127.0.0.1:${uiContainerPort}/`); - await waitForHttpReady(`http://127.0.0.1:${uiPort}/health`); - bffStarted = true; - - output.write(`ICA dashboard listening at ${dashboardUrl}\n`); - output.write(`Dashboard proxy: http://${hostForUrl}:${uiPort} -> ${staticOrigin} + ${apiBaseUrl}\n`); - output.write(`Container: ${containerName} (${(dockerRunResult.stdout || "").trim()})\n`); - output.write( - sourcesRefreshMinutes > 0 - ? `Source auto-refresh: every ${sourcesRefreshMinutes} minute(s)\n` - : "Source auto-refresh: disabled\n", - ); - if (open) { + await waitForDashboardReady(dashboardUrl, child); + output.write(`Dashboard ready: ${dashboardUrl}\n`); + if (boolOption(options, "open", false)) { openBrowser(dashboardUrl); } - const requestShutdown = (): void => { - shutdownRequested = true; - }; - process.once("SIGINT", requestShutdown); - process.once("SIGTERM", requestShutdown); + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0 || code === null) { + resolve(); + return; + } + reject(new Error(`Dashboard exited with code ${code}.`)); + }); + }); + } finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + } +} + +async function runContainer(positionals: string[], options: Record): Promise { + const action = (positionals[0] || "mount-project").toLowerCase(); + if (action !== "mount-project") { + throw new Error(`Unknown container action '${action}'. Supported: mount-project`); + } + const projectPath = stringOption(options, "project-path", "").trim(); + if (!projectPath) { + throw new Error("Missing required option --project-path"); + } + if (!boolOption(options, "confirm", false)) { + throw new Error("Container mount requires --confirm"); + } - while (!shutdownRequested) { - if (apiProcess.exitCode !== null) { - throw new Error(`ICA API process exited with code ${apiProcess.exitCode}`); + const repoRoot = findRepoRoot(__dirname); + await ensureHelperRunning(repoRoot); + const payload = await helperRequest("/container/mount-project", { + projectPath, + containerName: stringOption(options, "container-name", "") || undefined, + image: stringOption(options, "image", "") || undefined, + port: stringOption(options, "port", "") || undefined, + confirm: true, + }); + if (boolOption(options, "json", false)) { + output.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + output.write(`Mounted project path '${projectPath}' into container '${String(payload.containerName || "")}'.\n`); + if (payload.command) { + output.write(`Command: ${String(payload.command)}\n`); + } +} + +async function runSkills(positionals: string[], options: Record): Promise { + const action = (positionals[0] || "validate").toLowerCase(); + const json = boolOption(options, "json", false); + const credentials = createCredentialProvider(); + + if (action === "validate") { + const skillPath = stringOption(options, "path", "").trim(); + if (!skillPath) { + throw new Error("Missing required option --path"); + } + const profile = (stringOption(options, "profile", "personal").trim() || "personal") as ValidationProfile; + if (profile !== "personal" && profile !== "official") { + throw new Error("Invalid --profile. Supported: personal|official"); + } + + const result = await validateSkillBundle({ localPath: skillPath }, profile); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + output.write(`Profile: ${result.profile}\n`); + output.write(`Detected files: ${result.detectedFiles.length}\n`); + if (result.warnings.length > 0) { + output.write(`Warnings:\n`); + for (const warning of result.warnings) { + output.write(` - ${warning}\n`); } - if (bffProcess.exitCode !== null) { - throw new Error(`ICA dashboard BFF process exited with code ${bffProcess.exitCode}`); + } + if (result.errors.length > 0) { + output.write(`Errors:\n`); + for (const err of result.errors) { + output.write(` - ${err}\n`); } - await new Promise((resolve) => setTimeout(resolve, 250)); + throw new Error("Validation failed."); } - if (apiProcessError) { - throw apiProcessError; + output.write(`Validation passed.\n`); + return; + } + + if (action === "publish") { + const sourceId = stringOption(options, "source", stringOption(options, "id", "")).trim(); + const skillPath = stringOption(options, "path", "").trim(); + const overrideMode = stringOption(options, "override-mode", "").trim(); + const overrideBaseBranch = stringOption(options, "override-base-branch", "").trim(); + if (!sourceId) throw new Error("Missing required option --source"); + if (!skillPath) throw new Error("Missing required option --path"); + if (overrideMode && overrideMode !== "direct-push" && overrideMode !== "branch-only" && overrideMode !== "branch-pr") { + throw new Error("Invalid --override-mode. Supported: direct-push|branch-only|branch-pr"); } - if (bffProcessError) { - throw bffProcessError; + + const result = await publishSkillBundle( + { + sourceId, + bundle: { + localPath: skillPath, + skillName: stringOption(options, "skill-name", "").trim() || undefined, + }, + commitMessage: stringOption(options, "message", "").trim() || undefined, + overrideMode: overrideMode ? (overrideMode as PublishMode) : undefined, + overrideBaseBranch: overrideBaseBranch || undefined, + }, + credentials, + ); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + return; } - if (!bffStarted) { - throw new Error("ICA dashboard BFF did not start correctly."); + output.write(`Published using mode '${result.mode}'.\n`); + output.write(`Branch: ${result.branch}\n`); + output.write(`Commit: ${result.commitSha}\n`); + output.write(`Pushed remote: ${result.pushedRemote}\n`); + if (result.prUrl) output.write(`PR: ${result.prUrl}\n`); + if (result.compareUrl) output.write(`Compare: ${result.compareUrl}\n`); + return; + } + + if (action === "contribute-official") { + const skillPath = stringOption(options, "path", "").trim(); + if (!skillPath) throw new Error("Missing required option --path"); + const result = await contributeOfficialSkillBundle( + { + sourceId: stringOption(options, "source", "").trim() || undefined, + bundle: { + localPath: skillPath, + skillName: stringOption(options, "skill-name", "").trim() || undefined, + }, + commitMessage: stringOption(options, "message", "").trim() || undefined, + }, + credentials, + ); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + return; } - await shutdown(); - } catch (error) { - await shutdown(); - throw error; + output.write(`Official contribution prepared.\n`); + output.write(`Branch: ${result.branch}\n`); + output.write(`Commit: ${result.commitSha}\n`); + output.write(`Pushed remote: ${result.pushedRemote}\n`); + if (result.prUrl) output.write(`PR: ${result.prUrl}\n`); + if (result.compareUrl) output.write(`Compare: ${result.compareUrl}\n`); + return; } -} -async function runLaunch(options: Record): Promise { - output.write("Deprecation notice: `ica launch` is now an alias of `ica serve` and will be removed in a future release.\n"); - await runServe(options); + throw new Error(`Unknown skills action '${action}'. Supported: validate|publish|contribute-official`); } async function main(): Promise { const { command, options, positionals } = parseArgv(process.argv.slice(2)); const normalized = command.toLowerCase(); - const repoRoot = findRepoRoot(__dirname); - - if (normalized !== "help") { - await refreshSourcesOnCliStart(); - await maybePrintUpdateNotifier(repoRoot, options); - } if (["install", "uninstall", "sync"].includes(normalized)) { await runOperation(normalized as OperationKind, options); @@ -1506,23 +1189,37 @@ async function main(): Promise { return; } + if (normalized === "skills") { + await runSkills(positionals, options); + return; + } + if (normalized === "serve") { await runServe(options); return; } if (normalized === "launch") { - await runLaunch(options); + output.write("Deprecation notice: `ica launch` is now an alias of `ica serve` and will be removed in a future release.\n"); + await runServe(options); + return; + } + + if (normalized === "container") { + await runContainer(positionals, options); return; } printHelp(); } -if (require.main === module) { - main().catch((error) => { - const rawMessage = error instanceof Error ? error.message : String(error); - process.stderr.write(`ICA CLI failed: ${redactCliErrorMessage(rawMessage)}\n`); - process.exitCode = 1; - }); -} +main().catch((error) => { + process.stderr.write(`ICA CLI failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); + +process.on("exit", () => { + if (helperProcess && !helperProcess.killed) { + helperProcess.kill(); + } +}); diff --git a/src/installer-core/catalog.ts b/src/installer-core/catalog.ts index 26641cd..898d09a 100644 --- a/src/installer-core/catalog.ts +++ b/src/installer-core/catalog.ts @@ -1,39 +1,39 @@ import fs from "node:fs"; -import fsp from "node:fs/promises"; import path from "node:path"; import { SkillCatalog, SkillCatalogEntry, SkillResource, TargetPlatform } from "./types"; import { buildMultiSourceCatalog } from "./catalogMultiSource"; import { isSkillBlocked } from "./skillBlocklist"; -import { DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL, getIcaStateRoot } from "./sources"; -import { frontmatterList, frontmatterString, parseFrontmatter } from "./skillMetadata"; -import { pathExists, writeText } from "./fs"; -import { computeDirectoryDigest } from "./contentDigest"; +import { DEFAULT_PUBLISH_MODE, DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL } from "./sources"; +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; interface LocalCatalogEntry { name: string; description: string; category: string; - scope?: string; - subcategory?: string; - tags?: string[]; - author?: string; - contactEmail?: string; - website?: string; dependencies: string[]; compatibleTargets: TargetPlatform[]; resources: SkillResource[]; sourcePath: string; - contentDigest?: string; - contentFileCount?: number; } -interface CacheRecord { - catalog: SkillCatalog; - savedAtMs: number; -} +function parseFrontmatter(content: string): Record { + const match = content.match(FRONTMATTER_RE); + if (!match) { + return {}; + } -export const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000; -const CATALOG_CACHE_RELATIVE_PATH = path.join("catalog", "skills.catalog.json"); + const map: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) { + map[key] = value; + } + } + return map; +} function inferCategory(skillName: string): string { const roleSkills = new Set([ @@ -64,22 +64,30 @@ function inferCategory(skillName: string): string { function collectResources(skillDir: string): SkillResource[] { const resources: SkillResource[] = []; - const directories: Array = ["references", "scripts", "assets"]; - - for (const resourceType of directories) { - const location = path.join(skillDir, resourceType); - if (!fs.existsSync(location)) continue; - - for (const file of fs - .readdirSync(location, { withFileTypes: true }) - .filter((entry) => entry.isFile() || entry.isSymbolicLink()) - .sort((a, b) => a.name.localeCompare(b.name))) { + const walk = (current: string): void => { + const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.name === ".git") continue; + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(absolute); + continue; + } + if (!(entry.isFile() || entry.isSymbolicLink())) continue; + if (entry.name === "SKILL.md") continue; + + const relative = path.relative(skillDir, absolute).replace(/\\/g, "/"); + const topLevel = relative.split("/", 1)[0]; + const resourceType: SkillResource["type"] = + topLevel === "references" || topLevel === "scripts" || topLevel === "assets" ? topLevel : "other"; resources.push({ type: resourceType, - path: path.join("skills", path.basename(skillDir), resourceType, file.name), + path: path.join("skills", path.basename(skillDir), relative).replace(/\\/g, "/"), }); } - } + }; + walk(skillDir); + resources.sort((a, b) => a.path.localeCompare(b.path)); return resources; } @@ -92,36 +100,21 @@ function toCatalogEntry(skillDir: string, repoRoot: string): LocalCatalogEntry | const content = fs.readFileSync(skillFile, "utf8"); const frontmatter = parseFrontmatter(content); - const name = frontmatterString(frontmatter, "name") || path.basename(skillDir); + const name = frontmatter.name || path.basename(skillDir); if (isSkillBlocked(name)) { return null; } - const description = frontmatterString(frontmatter, "description") || ""; - const explicitCategory = (frontmatterString(frontmatter, "category") || "").trim().toLowerCase(); - const scope = (frontmatterString(frontmatter, "scope") || "").trim().toLowerCase() || undefined; - const subcategory = (frontmatterString(frontmatter, "subcategory") || "").trim().toLowerCase() || undefined; - const tags = frontmatterList(frontmatter, "tags"); - const author = frontmatterString(frontmatter, "author"); - const contactEmail = frontmatterString(frontmatter, "contact-email") || frontmatterString(frontmatter, "contactEmail"); - const website = frontmatterString(frontmatter, "website"); - const digest = computeDirectoryDigest(skillDir); + const description = frontmatter.description || ""; + const explicitCategory = (frontmatter.category || "").trim().toLowerCase(); return { name, description, category: explicitCategory || inferCategory(name), - scope, - subcategory, - tags: tags.length > 0 ? tags : undefined, - author, - contactEmail, - website, dependencies: [], compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], resources: collectResources(skillDir), sourcePath: path.relative(repoRoot, skillDir).replace(/\\/g, "/"), - contentDigest: digest.digest, - contentFileCount: digest.fileCount, }; } @@ -134,115 +127,6 @@ function resolveGeneratedAt(sourceDateEpoch?: string): string { return "1970-01-01T00:00:00.000Z"; } -function normalizeCatalog(repoRoot: string, catalog: SkillCatalog): SkillCatalog { - return { - ...catalog, - sources: catalog.sources || [], - skills: (catalog.skills || []).map((skill) => ({ - ...skill, - skillId: skill.skillId || `${skill.sourceId || "local"}/${skill.skillName || skill.name}`, - sourceId: skill.sourceId || "local", - sourceName: skill.sourceName || skill.sourceId || "local", - sourceUrl: skill.sourceUrl || "", - skillName: skill.skillName || skill.name, - sourcePath: path.isAbsolute(skill.sourcePath || "") ? (skill.sourcePath || "") : path.resolve(repoRoot, skill.sourcePath || ""), - })), - }; -} - -function withLiveDiagnostics(catalog: SkillCatalog): SkillCatalog { - return { - ...catalog, - stale: false, - catalogSource: "live", - staleReason: undefined, - cacheAgeSeconds: undefined, - nextRefreshAt: undefined, - }; -} - -function withSnapshotDiagnostics(catalog: SkillCatalog, staleReason: string): SkillCatalog { - return { - ...catalog, - stale: true, - catalogSource: "snapshot", - staleReason, - cacheAgeSeconds: undefined, - nextRefreshAt: undefined, - }; -} - -function withCacheDiagnostics(catalog: SkillCatalog, savedAtMs: number, nowMs: number, staleReason?: string): SkillCatalog { - const cacheAgeSeconds = Math.max(0, Math.floor((nowMs - savedAtMs) / 1000)); - const nextRefreshAt = new Date(savedAtMs + CATALOG_CACHE_TTL_MS).toISOString(); - const ttlExpired = nowMs >= savedAtMs + CATALOG_CACHE_TTL_MS; - const stale = Boolean(staleReason) || ttlExpired; - - return { - ...catalog, - stale, - catalogSource: "cache", - staleReason: staleReason || (stale ? "Cached catalog is older than refresh TTL." : undefined), - cacheAgeSeconds, - nextRefreshAt, - }; -} - -function liveUnavailableReason(catalog: SkillCatalog): string { - const failures = catalog.sources.filter((source) => source.enabled !== false && source.lastError).map((source) => `${source.id}: ${source.lastError}`); - if (failures.length > 0) { - return `Live catalog refresh failed (${failures.join("; ")}).`; - } - - const hasEnabledSource = catalog.sources.some((source) => source.enabled !== false); - if (!hasEnabledSource) { - return "No enabled skill sources are configured; serving fallback catalog."; - } - - return "Live catalog returned zero skills; serving fallback catalog."; -} - -function shouldAttemptLiveRefresh(refresh: boolean, cache: CacheRecord | null, nowMs: number): boolean { - if (refresh) return true; - if (!cache) return true; - return nowMs >= cache.savedAtMs + CATALOG_CACHE_TTL_MS; -} - -function cachePath(): string { - return path.join(getIcaStateRoot(), CATALOG_CACHE_RELATIVE_PATH); -} - -async function loadCatalogCache(repoRoot: string): Promise { - const targetPath = cachePath(); - if (!(await pathExists(targetPath))) { - return null; - } - - try { - const raw = await fsp.readFile(targetPath, "utf8"); - const parsed = JSON.parse(raw) as SkillCatalog; - const stat = await fsp.stat(targetPath); - return { - catalog: normalizeCatalog(repoRoot, parsed), - savedAtMs: stat.mtimeMs, - }; - } catch { - return null; - } -} - -async function saveCatalogCache(catalog: SkillCatalog): Promise { - const payload: SkillCatalog = { - ...catalog, - stale: undefined, - catalogSource: undefined, - staleReason: undefined, - cacheAgeSeconds: undefined, - nextRefreshAt: undefined, - }; - await writeText(cachePath(), `${JSON.stringify(payload, null, 2)}\n`); -} - export function buildDefaultSourceCatalog(version: string, sourceDateEpoch?: string): SkillCatalog { return { generatedAt: resolveGeneratedAt(sourceDateEpoch), @@ -257,6 +141,10 @@ export function buildDefaultSourceCatalog(version: string, sourceDateEpoch?: str official: true, enabled: true, skillsRoot: DEFAULT_SKILLS_ROOT, + publishDefaultMode: DEFAULT_PUBLISH_MODE, + defaultBaseBranch: "dev", + providerHint: "github", + officialContributionEnabled: true, removable: true, }, ], @@ -302,7 +190,19 @@ export function loadCatalog(repoRoot: string, fallbackVersion = "0.0.0"): SkillC const catalogPath = path.join(repoRoot, "src", "catalog", "skills.catalog.json"); if (fs.existsSync(catalogPath)) { const catalog = loadCatalogFromFile(catalogPath); - const normalized = normalizeCatalog(repoRoot, catalog); + const normalized: SkillCatalog = { + ...catalog, + sources: catalog.sources || [], + skills: catalog.skills.map((skill) => ({ + ...skill, + skillId: skill.skillId || `${skill.sourceId || "local"}/${skill.skillName || skill.name}`, + sourceId: skill.sourceId || "local", + sourceName: skill.sourceName || skill.sourceId || "local", + sourceUrl: skill.sourceUrl || "", + skillName: skill.skillName || skill.name, + sourcePath: path.isAbsolute(skill.sourcePath) ? skill.sourcePath : path.resolve(repoRoot, skill.sourcePath), + })), + }; if (normalized.skills.length > 0) { return normalized; } @@ -314,52 +214,19 @@ export function loadCatalog(repoRoot: string, fallbackVersion = "0.0.0"): SkillC export async function loadCatalogFromSources(repoRoot: string, refresh = false): Promise { const versionFile = path.join(repoRoot, "VERSION"); const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, "utf8").trim() : "0.0.0"; - const snapshot = loadCatalog(repoRoot, version); - const cache = await loadCatalogCache(repoRoot); - const nowMs = Date.now(); - let liveFailureReason: string | undefined; - - if (!shouldAttemptLiveRefresh(refresh, cache, nowMs) && cache) { - return withCacheDiagnostics(cache.catalog, cache.savedAtMs, nowMs); - } - - let multi: SkillCatalog; - try { - multi = await buildMultiSourceCatalog({ - repoVersion: version, - refresh, - }); - } catch { - liveFailureReason = "Live catalog refresh failed unexpectedly; serving fallback catalog."; - multi = { - generatedAt: new Date().toISOString(), - source: "multi-source", - version, - sources: [], - skills: [], - }; - } + const multi = await buildMultiSourceCatalog({ + repoVersion: version, + refresh, + }); if (multi.skills.length > 0) { - const live = withLiveDiagnostics(multi); - try { - await saveCatalogCache(live); - } catch { - // Cache persistence is best-effort; live catalog should still be returned. - } - return live; + return multi; } - - const reason = liveFailureReason || liveUnavailableReason(multi); - if (cache) { - return withCacheDiagnostics(cache.catalog, cache.savedAtMs, nowMs, reason); - } - - const fallback: SkillCatalog = { - ...snapshot, - source: multi.sources.length > 0 ? "multi-source" : snapshot.source, - sources: multi.sources.length > 0 ? multi.sources : snapshot.sources, + const fallback = loadCatalog(repoRoot, version); + return { + ...fallback, + source: multi.sources.length > 0 ? "multi-source" : fallback.source, + sources: multi.sources.length > 0 ? multi.sources : fallback.sources, }; - return withSnapshotDiagnostics(fallback, `${reason} Serving bundled snapshot catalog.`); } export function findSkill(catalog: SkillCatalog, skillNameOrId: string): SkillCatalogEntry | undefined { diff --git a/src/installer-core/catalogMultiSource.ts b/src/installer-core/catalogMultiSource.ts index 87c8e42..a27e370 100644 --- a/src/installer-core/catalogMultiSource.ts +++ b/src/installer-core/catalogMultiSource.ts @@ -1,32 +1,90 @@ import fs from "node:fs"; import path from "node:path"; import { createCredentialProvider } from "./credentials"; -import { safeErrorMessage } from "./security"; import { setSourceSyncStatus, ensureSourceRegistry, OFFICIAL_SOURCE_ID } from "./sources"; import { syncSource } from "./sourceSync"; import { CatalogSkill, InstallSelection, SkillCatalog, SkillResource, SkillSource, TargetPlatform } from "./types"; import { isSkillBlocked } from "./skillBlocklist"; -import { frontmatterList, frontmatterString, parseFrontmatter } from "./skillMetadata"; -import { computeDirectoryDigest } from "./contentDigest"; + +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; interface CatalogOptions { repoVersion: string; refresh: boolean; } -interface SkillIndexEntry { - skillName?: string; - name?: string; - description?: string; - category?: string; - scope?: string; - subcategory?: string; - tags?: string[] | string; - version?: string; - author?: string; - "contact-email"?: string; - contactEmail?: string; - website?: string; +interface ParsedFrontmatter { + values: Record; + lists: Record; +} + +function cleanFrontmatterValue(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function parseFrontmatter(content: string): ParsedFrontmatter { + const match = content.match(FRONTMATTER_RE); + if (!match) return { values: {}, lists: {} }; + const values: Record = {}; + const lists: Record = {}; + let currentListKey: string | null = null; + + for (const rawLine of match[1].split("\n")) { + const line = rawLine.replace(/\r$/, ""); + const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (keyMatch) { + const key = keyMatch[1].trim(); + const rawValue = keyMatch[2].trim(); + if (!key) { + currentListKey = null; + continue; + } + + if (rawValue.length === 0) { + currentListKey = key; + if (!lists[key]) lists[key] = []; + continue; + } + + if (rawValue.startsWith("[") && rawValue.endsWith("]")) { + const entries = rawValue + .slice(1, -1) + .split(",") + .map((entry) => cleanFrontmatterValue(entry)) + .filter(Boolean); + if (entries.length > 0) { + lists[key] = entries; + } + } else { + values[key] = cleanFrontmatterValue(rawValue); + } + currentListKey = null; + continue; + } + + const listMatch = line.match(/^\s*-\s*(.+)$/); + if (currentListKey && listMatch) { + const value = cleanFrontmatterValue(listMatch[1]); + if (value) { + if (!lists[currentListKey]) lists[currentListKey] = []; + lists[currentListKey].push(value); + } + continue; + } + + if (line.trim()) { + currentListKey = null; + } + } + + return { values, lists }; } function inferCategory(skillName: string): string { @@ -58,23 +116,30 @@ function inferCategory(skillName: string): string { function collectResources(skillDir: string, skillName: string): SkillResource[] { const resources: SkillResource[] = []; - const directories: Array = ["references", "scripts", "assets"]; - for (const type of directories) { - const base = path.join(skillDir, type); - if (!fs.existsSync(base)) continue; - - const files = fs - .readdirSync(base, { withFileTypes: true }) - .filter((entry) => entry.isFile() || entry.isSymbolicLink()) - .sort((a, b) => a.name.localeCompare(b.name)); + const walk = (current: string): void => { + const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.name === ".git") continue; + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(absolute); + continue; + } + if (!(entry.isFile() || entry.isSymbolicLink())) continue; + if (entry.name === "SKILL.md") continue; - for (const file of files) { + const relative = path.relative(skillDir, absolute).replace(/\\/g, "/"); + const topLevel = relative.split("/", 1)[0]; + const type: SkillResource["type"] = + topLevel === "references" || topLevel === "scripts" || topLevel === "assets" ? topLevel : "other"; resources.push({ type, - path: path.join("skills", skillName, type, file.name).replace(/\\/g, "/"), + path: path.join("skills", skillName, relative).replace(/\\/g, "/"), }); } - } + }; + walk(skillDir); + resources.sort((a, b) => a.path.localeCompare(b.path)); return resources; } @@ -86,59 +151,27 @@ function skillRootPath(source: SkillSource, localRepoPath: string): string { return path.join(localRepoPath, relativeRoot); } -function normalizeTags(value: unknown): string[] { - if (Array.isArray(value)) { - return value - .map((item) => String(item).trim()) - .filter(Boolean); - } - if (typeof value === "string") { - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - } - return []; -} - -function loadSkillIndexEntries(localRepoPath: string, root: string): SkillIndexEntry[] | null { - const candidates = [path.join(localRepoPath, "skills.index.json"), path.join(root, "skills.index.json")]; - for (const candidate of candidates) { - if (!fs.existsSync(candidate)) continue; - try { - const raw = JSON.parse(fs.readFileSync(candidate, "utf8")) as { skills?: SkillIndexEntry[] } | SkillIndexEntry[]; - if (Array.isArray(raw)) { - return raw; - } - if (raw && Array.isArray(raw.skills)) { - return raw.skills; - } - } catch { - return null; - } - } - return null; -} - function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | null { const skillFile = path.join(skillDir, "SKILL.md"); if (!fs.existsSync(skillFile)) return null; const content = fs.readFileSync(skillFile, "utf8"); - const frontmatter = parseFrontmatter(content); - const skillName = frontmatterString(frontmatter, "name") || path.basename(skillDir); + const parsedFrontmatter = parseFrontmatter(content); + const frontmatter = parsedFrontmatter.values; + const skillName = frontmatter.name || path.basename(skillDir); if (isSkillBlocked(skillName)) { return null; } const skillId = `${source.id}/${skillName}`; const stat = fs.statSync(skillFile); - const explicitCategory = (frontmatterString(frontmatter, "category") || "").trim().toLowerCase(); - const scope = (frontmatterString(frontmatter, "scope") || "").trim().toLowerCase() || undefined; - const subcategory = (frontmatterString(frontmatter, "subcategory") || "").trim().toLowerCase() || undefined; - const tags = frontmatterList(frontmatter, "tags"); - const author = frontmatterString(frontmatter, "author"); - const contactEmail = frontmatterString(frontmatter, "contact-email") || frontmatterString(frontmatter, "contactEmail"); - const website = frontmatterString(frontmatter, "website"); - const digest = computeDirectoryDigest(skillDir); + const explicitCategory = (frontmatter.category || "").trim().toLowerCase(); + const explicitScope = (frontmatter.scope || "").trim().toLowerCase(); + const tags = Array.from( + new Set( + (parsedFrontmatter.lists.tags || []) + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean), + ), + ); return { skillId, @@ -147,103 +180,16 @@ function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | n sourceUrl: source.repoUrl, skillName, name: skillName, - description: frontmatterString(frontmatter, "description") || "", + description: frontmatter.description || "", category: explicitCategory || inferCategory(skillName), - scope, - subcategory, + scope: explicitScope || undefined, tags: tags.length > 0 ? tags : undefined, - author, - contactEmail, - website, dependencies: [], compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], resources: collectResources(skillDir, skillName), sourcePath: skillDir, - version: frontmatterString(frontmatter, "version"), + version: frontmatter.version, updatedAt: stat.mtime.toISOString(), - contentDigest: digest.digest, - contentFileCount: digest.fileCount, - }; -} - -function toCatalogSkillFromIndex(source: SkillSource, root: string, entry: SkillIndexEntry): CatalogSkill | null { - const skillName = (entry.skillName || entry.name || "").trim(); - if (!skillName || isSkillBlocked(skillName)) { - return null; - } - - const skillDir = path.join(root, skillName); - const skillFile = path.join(skillDir, "SKILL.md"); - if (!fs.existsSync(skillFile)) { - return null; - } - const stat = fs.statSync(skillFile); - const explicitCategory = (entry.category || "").trim().toLowerCase(); - const scope = (entry.scope || "").trim().toLowerCase() || undefined; - const subcategory = (entry.subcategory || "").trim().toLowerCase() || undefined; - const tags = normalizeTags(entry.tags); - const digest = computeDirectoryDigest(skillDir); - - return { - skillId: `${source.id}/${skillName}`, - sourceId: source.id, - sourceName: source.name, - sourceUrl: source.repoUrl, - skillName, - name: skillName, - description: (entry.description || "").trim(), - category: explicitCategory || inferCategory(skillName), - scope, - subcategory, - tags: tags.length > 0 ? tags : undefined, - author: entry.author?.trim() || undefined, - contactEmail: entry.contactEmail?.trim() || entry["contact-email"]?.trim() || undefined, - website: entry.website?.trim() || undefined, - dependencies: [], - compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], - resources: collectResources(skillDir, skillName), - sourcePath: skillDir, - version: entry.version?.trim() || undefined, - updatedAt: stat.mtime.toISOString(), - contentDigest: digest.digest, - contentFileCount: digest.fileCount, - }; -} - -function skillNameFromIndexEntry(entry: SkillIndexEntry): string { - return (entry.skillName || entry.name || "").trim(); -} - -function loadSkillIndexMap(localRepoPath: string, root: string): Map | null { - const entries = loadSkillIndexEntries(localRepoPath, root); - if (!entries) return null; - - const map = new Map(); - for (const entry of entries) { - const name = skillNameFromIndexEntry(entry); - if (!name) continue; - map.set(name, entry); - } - return map; -} - -function applyIndexMetadata(skill: CatalogSkill, entry: SkillIndexEntry): CatalogSkill { - const explicitCategory = (entry.category || "").trim().toLowerCase(); - const scope = (entry.scope || "").trim().toLowerCase() || undefined; - const subcategory = (entry.subcategory || "").trim().toLowerCase() || undefined; - const tags = normalizeTags(entry.tags); - - return { - ...skill, - description: (entry.description || "").trim() || skill.description, - category: explicitCategory || skill.category, - scope: scope || skill.scope, - subcategory: subcategory || skill.subcategory, - tags: tags.length > 0 ? tags : skill.tags, - author: entry.author?.trim() || skill.author, - contactEmail: entry.contactEmail?.trim() || entry["contact-email"]?.trim() || skill.contactEmail, - website: entry.website?.trim() || skill.website, - version: entry.version?.trim() || skill.version, }; } @@ -300,32 +246,18 @@ export async function buildMultiSourceCatalog(options: CatalogOptions): Promise< throw new Error(`Source '${source.id}' is invalid: missing skills root '${hydrated.skillsRoot}'.`); } - const indexMap = loadSkillIndexMap(localRepoPath, root); - const skillDirs = fs .readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => path.join(root, entry.name)) .sort((a, b) => path.basename(a).localeCompare(path.basename(b))); - const seenSkillNames = new Set(); for (const skillDir of skillDirs) { - const discovered = toCatalogSkill(hydrated, skillDir); - if (!discovered) continue; - seenSkillNames.add(discovered.skillName); - const indexEntry = indexMap?.get(discovered.skillName); - catalogSkills.push(indexEntry ? applyIndexMetadata(discovered, indexEntry) : discovered); - } - - if (indexMap) { - for (const [skillName, entry] of indexMap.entries()) { - if (seenSkillNames.has(skillName)) continue; - const fromIndex = toCatalogSkillFromIndex(hydrated, root, entry); - if (fromIndex) catalogSkills.push(fromIndex); - } + const item = toCatalogSkill(hydrated, skillDir); + if (item) catalogSkills.push(item); } } catch (error) { - const message = safeErrorMessage(error, "Source refresh failed."); + const message = error instanceof Error ? error.message : String(error); hydratedSources.push({ ...source, lastError: message, diff --git a/src/installer-core/claudeIntegration.ts b/src/installer-core/claudeIntegration.ts index 7500e88..0ac993f 100644 --- a/src/installer-core/claudeIntegration.ts +++ b/src/installer-core/claudeIntegration.ts @@ -16,7 +16,7 @@ function mergeHooks(settings: Record, installPath: string): Rec filtered.push( { - matcher: { tools: ["BashTool", "Bash"] }, + matcher: "^(BashTool|Bash)$", hooks: [ { type: "command", @@ -26,7 +26,7 @@ function mergeHooks(settings: Record, installPath: string): Rec ], }, { - matcher: { tools: ["FileWriteTool", "FileEditTool", "Write", "Edit"] }, + matcher: "^(FileWriteTool|FileEditTool|Write|Edit)$", hooks: [ { type: "command", diff --git a/src/installer-core/repositories.ts b/src/installer-core/repositories.ts index beeb25d..ab95732 100644 --- a/src/installer-core/repositories.ts +++ b/src/installer-core/repositories.ts @@ -2,6 +2,7 @@ import { CredentialProvider } from "./credentials"; import { addHookSource, HookSource, loadHookSources, updateHookSource } from "./hookSources"; import { syncHookSource } from "./hookSync"; import { addSource, loadSources, updateSource } from "./sources"; +import { GitProvider, PublishMode } from "./types"; import { SkillSource } from "./types"; import { SourceTransport } from "./types"; import { safeErrorMessage } from "./security"; @@ -16,6 +17,10 @@ export interface RepositoryRegistrationInput { removable?: boolean; official?: boolean; skillsRoot?: string; + publishDefaultMode?: PublishMode; + defaultBaseBranch?: string; + providerHint?: GitProvider; + officialContributionEnabled?: boolean; hooksRoot?: string; token?: string; } @@ -55,6 +60,10 @@ async function upsertSkillSource(input: RepositoryRegistrationInput): Promise } { + const match = content.match(FRONTMATTER_RE); + if (!match) return { hasFrontmatter: false, fields: {} }; + + const map: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) map[key] = value; + } + return { hasFrontmatter: true, fields: map }; +} + +function nowStamp(): string { + const now = new Date(); + const parts = [ + now.getUTCFullYear(), + String(now.getUTCMonth() + 1).padStart(2, "0"), + String(now.getUTCDate()).padStart(2, "0"), + String(now.getUTCHours()).padStart(2, "0"), + String(now.getUTCMinutes()).padStart(2, "0"), + String(now.getUTCSeconds()).padStart(2, "0"), + ]; + return parts.join(""); +} + +function normalizeGitError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function looksLikeTextFile(filePath: string): boolean { + const extension = path.extname(filePath).toLowerCase(); + if (!extension) return true; + const binaryExtensions = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".zip", ".gz", ".tgz", ".pdf", ".woff", ".woff2"]); + return !binaryExtensions.has(extension); +} + +function parseMarkdownLinks(markdown: string): string[] { + const links: string[] = []; + const re = /\[[^\]]+]\(([^)]+)\)/g; + let match: RegExpExecArray | null = re.exec(markdown); + while (match) { + links.push(match[1].trim()); + match = re.exec(markdown); + } + return links; +} + +function shouldSkipFromCopy(name: string): boolean { + const lower = name.toLowerCase(); + if (lower === ".git" || lower === ".ds_store" || lower === "thumbs.db") return true; + if (lower === "__pycache__") return true; + if (lower.startsWith(".env")) return true; + if (BLOCKED_FILE_NAMES.has(lower)) return true; + return false; +} + +function isBlockedFile(relativePath: string): boolean { + const base = path.basename(relativePath).toLowerCase(); + if (BLOCKED_FILE_NAMES.has(base)) return true; + if (base.startsWith(".env")) return true; + if (BLOCKED_EXTENSIONS.has(path.extname(base))) return true; + return false; +} + +export function sanitizeSkillName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64); +} + +export function detectGitProvider(repoUrl: string): GitProvider { + return detectProviderFromSource(repoUrl); +} + +interface BundleScanResult { + detectedFiles: string[]; + topLevelDirectories: Set; + totalBytes: number; + errors: string[]; + warnings: string[]; +} + +async function scanBundle(rootPath: string): Promise { + const detectedFiles = new Set(); + const topLevelDirectories = new Set(); + let totalBytes = 0; + const errors: string[] = []; + const warnings: string[] = []; + + const walk = async (current: string): Promise => { + const entries = (await fsp.readdir(current, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const absolute = path.join(current, entry.name); + const relative = path.relative(rootPath, absolute).replace(/\\/g, "/"); + const lowerName = entry.name.toLowerCase(); + if (relative) { + const [first] = relative.split("/", 1); + if (first) topLevelDirectories.add(first); + } + + const stat = await fsp.lstat(absolute); + if (stat.isSymbolicLink()) { + const target = await fsp.realpath(absolute); + try { + assertPathWithin(rootPath, target); + } catch { + errors.push(`Symlink escape blocked: '${relative}' points outside bundle root.`); + continue; + } + warnings.push(`Symlink '${relative}' is ignored during publish for safety.`); + continue; + } + + if (entry.isDirectory()) { + if (shouldSkipFromCopy(lowerName)) continue; + await walk(absolute); + continue; + } + + if (!entry.isFile()) continue; + if (shouldSkipFromCopy(lowerName)) continue; + + detectedFiles.add(relative); + totalBytes += stat.size; + if (stat.size > MAX_FILE_SIZE_BYTES) { + errors.push(`File '${relative}' exceeds max file size (${MAX_FILE_SIZE_BYTES} bytes).`); + } + + if (isBlockedFile(relative)) { + errors.push(`Blocked file pattern detected: '${relative}'.`); + } + + if (looksLikeTextFile(absolute) && stat.size <= 256 * 1024) { + const content = await fsp.readFile(absolute, "utf8").catch(() => ""); + if (content) { + for (const pattern of SECRET_PATTERNS) { + if (pattern.test(content)) { + errors.push(`Potential secret detected in '${relative}'.`); + break; + } + } + } + } + } + }; + + await walk(rootPath); + if (totalBytes > MAX_BUNDLE_SIZE_BYTES) { + errors.push(`Bundle exceeds max total size (${MAX_BUNDLE_SIZE_BYTES} bytes).`); + } + + return { + detectedFiles: Array.from(detectedFiles).sort((a, b) => a.localeCompare(b)), + topLevelDirectories, + totalBytes, + errors, + warnings, + }; +} + +function defaultSkillName(bundlePath: string, frontmatterName?: string): string { + const raw = frontmatterName || path.basename(bundlePath); + return raw.trim(); +} + +function validateRequiredFrontmatter(fields: Record, required: string[]): string[] { + const errors: string[] = []; + for (const key of required) { + if (!fields[key] || !fields[key].trim()) { + errors.push(`Missing required frontmatter field: '${key}'.`); + } + } + return errors; +} + +function extractSkillName(bundle: SkillBundleInput, frontmatterFields: Record): string { + return (bundle.skillName || defaultSkillName(bundle.localPath, frontmatterFields.name)).trim(); +} + +export async function validateSkillBundle(bundle: SkillBundleInput, profile: ValidationProfile): Promise { + const errors: string[] = []; + const warnings: string[] = []; + const localPath = path.resolve(bundle.localPath); + + if (!(await pathExists(localPath))) { + return { + profile, + errors: [`Bundle path not found: '${localPath}'.`], + warnings, + detectedFiles: [], + }; + } + + const stat = await fsp.lstat(localPath); + if (!stat.isDirectory()) { + return { + profile, + errors: [`Bundle path must be a directory: '${localPath}'.`], + warnings, + detectedFiles: [], + }; + } + + const skillFile = path.join(localPath, "SKILL.md"); + if (!(await pathExists(skillFile))) { + errors.push("Missing required file: SKILL.md"); + return { + profile, + errors, + warnings, + detectedFiles: [], + }; + } + + const skillContent = await fsp.readFile(skillFile, "utf8"); + const frontmatter = parseFrontmatter(skillContent); + const candidateName = extractSkillName(bundle, frontmatter.fields); + const sanitized = sanitizeSkillName(candidateName); + if (!sanitized) { + errors.push(`Invalid skill name '${candidateName}'. Use lowercase letters, numbers, and dashes.`); + } else if (candidateName !== sanitized) { + errors.push(`Invalid skill name '${candidateName}'. Suggested normalized name: '${sanitized}'.`); + } + + const scan = await scanBundle(localPath); + errors.push(...scan.errors); + warnings.push(...scan.warnings); + + if (profile === "personal") { + for (const field of ["name", "description", "category", "version"]) { + if (!frontmatter.fields[field] || !frontmatter.fields[field].trim()) { + warnings.push(`Optional frontmatter field '${field}' is missing.`); + } + } + const knownRoots = new Set(["SKILL.md", "scripts", "references", "assets", "README.md"]); + for (const dir of scan.topLevelDirectories) { + if (!knownRoots.has(dir)) { + warnings.push(`Nonstandard top-level entry '${dir}' found in skill bundle.`); + } + } + } + + if (profile === "official") { + if (!frontmatter.hasFrontmatter) { + errors.push("Official contribution requires YAML frontmatter in SKILL.md."); + } + errors.push(...validateRequiredFrontmatter(frontmatter.fields, ["name", "description", "category", "version"])); + + const links = parseMarkdownLinks(skillContent); + for (const target of links) { + const clean = target.split("#", 1)[0].trim(); + if (!clean || clean.startsWith("http://") || clean.startsWith("https://") || clean.startsWith("mailto:")) continue; + if (clean.startsWith("/")) { + errors.push(`Broken SKILL.md link '${target}': absolute paths are not allowed.`); + continue; + } + const resolved = path.resolve(localPath, clean); + try { + assertPathWithin(localPath, resolved); + } catch { + errors.push(`Broken SKILL.md link '${target}': path escapes skill bundle root.`); + continue; + } + if (!(await pathExists(resolved))) { + errors.push(`Broken SKILL.md link '${target}': target does not exist.`); + } + } + } + + return { + profile, + errors: Array.from(new Set(errors)), + warnings: Array.from(new Set(warnings)), + detectedFiles: Array.from(new Set(["SKILL.md", ...scan.detectedFiles])).sort((a, b) => a.localeCompare(b)), + }; +} + +async function runGit(args: string[], cwd?: string): Promise { + const result = await execFileAsync("git", args, { + cwd, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + }, + timeout: 180_000, + maxBuffer: 8 * 1024 * 1024, + }); + return (result.stdout || "").trim(); +} + +async function hasRemoteBranch(repoPath: string, branch: string): Promise { + try { + await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], repoPath); + return true; + } catch { + return false; + } +} + +async function detectDefaultBranch(repoPath: string): Promise { + try { + const value = await runGit(["rev-parse", "--abbrev-ref", "origin/HEAD"], repoPath); + if (value.startsWith("origin/")) { + return value.slice("origin/".length); + } + } catch { + // fall through + } + if (await hasRemoteBranch(repoPath, "main")) return "main"; + return "master"; +} + +function repoSkillsRoot(repoPath: string, skillsRoot: string): string { + return path.join(repoPath, skillsRoot.replace(/^\/+/, "")); +} + +async function copyBundleForPublish(sourceRoot: string, destinationRoot: string): Promise { + await removePath(destinationRoot); + await ensureDir(destinationRoot); + + const walk = async (current: string): Promise => { + const entries = await fsp.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (shouldSkipFromCopy(entry.name)) continue; + const from = path.join(current, entry.name); + const to = path.join(destinationRoot, path.relative(sourceRoot, from)); + const stat = await fsp.lstat(from); + + if (stat.isSymbolicLink()) { + const target = await fsp.realpath(from); + assertPathWithin(sourceRoot, target); + continue; + } + + if (entry.isDirectory()) { + await ensureDir(to); + await walk(from); + continue; + } + if (!entry.isFile()) continue; + if (isBlockedFile(path.relative(sourceRoot, from))) continue; + + await ensureDir(path.dirname(to)); + await fsp.copyFile(from, to); + } + }; + + await walk(sourceRoot); +} + +interface GithubRepoRef { + owner: string; + repo: string; +} + +function parseGithubRepo(repoUrl: string): GithubRepoRef | null { + const trimmed = repoUrl.trim(); + const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + const sshUrlMatch = trimmed.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i); + if (sshUrlMatch) { + return { owner: sshUrlMatch[1], repo: sshUrlMatch[2] }; + } + return null; +} + +async function githubRequest(token: string, method: string, pathname: string, body?: Record): Promise { + const response = await fetch(`https://api.github.com${pathname}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "ica-skill-publisher", + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API ${method} ${pathname} failed (${response.status}): ${text}`); + } + return (await response.json()) as T; +} + +function buildCompareUrl(source: SkillSource, baseBranch: string, branch: string): string | undefined { + const trimmed = source.repoUrl.replace(/\.git$/i, ""); + if (source.providerHint === "github") { + const parsed = parseGithubRepo(source.repoUrl); + if (!parsed) return undefined; + return `https://github.com/${parsed.owner}/${parsed.repo}/compare/${baseBranch}...${branch}?expand=1`; + } + if (source.providerHint === "gitlab") { + const match = trimmed.match(/gitlab\.com\/([^/]+)\/(.+)$/i); + if (!match) return undefined; + return `https://gitlab.com/${match[1]}/${match[2]}/-/compare/${encodeURIComponent(baseBranch)}...${encodeURIComponent(branch)}`; + } + if (source.providerHint === "bitbucket") { + const match = trimmed.match(/bitbucket\.org\/([^/]+)\/(.+)$/i); + if (!match) return undefined; + return `https://bitbucket.org/${match[1]}/${match[2]}/pull-requests/new?source=${encodeURIComponent(branch)}&dest=${encodeURIComponent(baseBranch)}`; + } + return undefined; +} + +async function createGithubPrSameRepo( + source: SkillSource, + token: string, + branch: string, + baseBranch: string, + title: string, + body: string, +): Promise { + const repo = parseGithubRepo(source.repoUrl); + if (!repo) return undefined; + try { + const result = await githubRequest<{ html_url?: string }>(token, "POST", `/repos/${repo.owner}/${repo.repo}/pulls`, { + title, + head: branch, + base: baseBranch, + body, + }); + return result.html_url; + } catch { + return undefined; + } +} + +async function ensureGitRemote(repoPath: string, remoteName: string, remoteUrl: string): Promise { + try { + await runGit(["remote", "set-url", remoteName, remoteUrl], repoPath); + } catch { + await runGit(["remote", "add", remoteName, remoteUrl], repoPath); + } +} + +async function waitForForkReady(forkUrl: string, repoPath: string): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + try { + await runGit(["ls-remote", "--heads", forkUrl], repoPath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } +} + +async function createGithubForkPr( + source: SkillSource, + token: string, + repoPath: string, + branch: string, + baseBranch: string, + title: string, + body: string, +): Promise<{ pushedRemote: string; prUrl?: string; compareUrl?: string }> { + const upstream = parseGithubRepo(source.repoUrl); + if (!upstream) { + return { pushedRemote: "origin", compareUrl: buildCompareUrl(source, baseBranch, branch) }; + } + const me = await githubRequest<{ login: string }>(token, "GET", "/user"); + try { + await githubRequest>(token, "POST", `/repos/${upstream.owner}/${upstream.repo}/forks`); + } catch { + // Existing fork or restricted endpoint; continue. + } + + const forkHttps = `https://github.com/${me.login}/${upstream.repo}.git`; + const forkAuth = withHttpsCredential(forkHttps, token); + await waitForForkReady(forkAuth, repoPath); + await ensureGitRemote(repoPath, "fork", forkAuth); + + await runGit(["push", "-u", "fork", branch], repoPath); + await ensureGitRemote(repoPath, "fork", forkHttps); + + const result = await githubRequest<{ html_url?: string }>(token, "POST", `/repos/${upstream.owner}/${upstream.repo}/pulls`, { + title, + head: `${me.login}:${branch}`, + base: baseBranch, + body, + }); + return { + pushedRemote: "fork", + prUrl: result.html_url, + compareUrl: result.html_url ? undefined : buildCompareUrl(source, baseBranch, branch), + }; +} + +interface WorkspaceResult { + repoPath: string; + baseBranch: string; + authRemoteUrl: string; + plainRemoteUrl: string; +} + +async function prepareWorkspace(source: SkillSource, credentials: CredentialProvider, forceBaseBranch?: string): Promise { + const repoPath = getSourceWorkspaceRepoPath(source.id); + await ensureDir(path.dirname(repoPath)); + const hasRepo = await pathExists(path.join(repoPath, ".git")); + const token = source.transport === "https" ? await credentials.get(source.id) : null; + const authRemoteUrl = source.transport === "https" && token ? withHttpsCredential(source.repoUrl, token) : source.repoUrl; + const plainRemoteUrl = source.repoUrl; + + if (!hasRepo) { + await runGit(["clone", authRemoteUrl, repoPath], path.dirname(repoPath)); + } else { + await ensureGitRemote(repoPath, "origin", authRemoteUrl); + await runGit(["fetch", "--all", "--prune"], repoPath); + } + await ensureGitRemote(repoPath, "origin", plainRemoteUrl); + + const configuredBase = (forceBaseBranch || source.defaultBaseBranch || "").trim(); + const baseBranch = configuredBase || (source.official ? "dev" : "main"); + const checkoutBranch = (await hasRemoteBranch(repoPath, baseBranch)) ? baseBranch : await detectDefaultBranch(repoPath); + await runGit(["checkout", "-f", checkoutBranch], repoPath); + await runGit(["reset", "--hard", `origin/${checkoutBranch}`], repoPath); + + return { + repoPath, + baseBranch: checkoutBranch, + authRemoteUrl, + plainRemoteUrl, + }; +} + +async function resolveSource(sourceId: string): Promise { + const source = (await loadSources()).find((item) => item.id === sourceId); + if (!source) { + throw new Error(`Unknown source '${sourceId}'.`); + } + if (!source.enabled) { + throw new Error(`Source '${sourceId}' is disabled.`); + } + return source; +} + +interface PublishInternalOptions { + validationProfile: ValidationProfile; + forceMode?: PublishMode; + forceBaseBranch?: string; + officialContribution?: boolean; +} + +async function publishInternal( + source: SkillSource, + request: PublishRequest, + credentials: CredentialProvider, + options: PublishInternalOptions, +): Promise { + const validation = await validateSkillBundle(request.bundle, options.validationProfile); + if (validation.errors.length > 0) { + throw new Error(`Bundle validation failed:\n- ${validation.errors.join("\n- ")}`); + } + + const skillFile = path.join(path.resolve(request.bundle.localPath), "SKILL.md"); + const frontmatter = parseFrontmatter(await fsp.readFile(skillFile, "utf8")); + const sourceName = extractSkillName(request.bundle, frontmatter.fields); + const skillName = sanitizeSkillName(sourceName); + const mode = options.forceMode || source.publishDefaultMode; + const workspace = await prepareWorkspace(source, credentials, options.forceBaseBranch); + + try { + const skillRoot = repoSkillsRoot(workspace.repoPath, source.skillsRoot); + const destination = path.join(skillRoot, skillName); + await ensureDir(skillRoot); + + const branch = mode === "direct-push" ? workspace.baseBranch : `skill/${skillName}/${nowStamp()}`; + if (mode === "direct-push") { + await runGit(["checkout", "-f", workspace.baseBranch], workspace.repoPath); + await runGit(["reset", "--hard", `origin/${workspace.baseBranch}`], workspace.repoPath); + } else { + await runGit(["checkout", "-B", branch, workspace.baseBranch], workspace.repoPath); + } + + await copyBundleForPublish(path.resolve(request.bundle.localPath), destination); + await runGit(["add", path.join(source.skillsRoot.replace(/^\/+/, ""), skillName)], workspace.repoPath); + await runGit(["add", "-A"], workspace.repoPath); + + let hasChanges = true; + try { + await runGit(["diff", "--cached", "--quiet"], workspace.repoPath); + hasChanges = false; + } catch { + hasChanges = true; + } + if (!hasChanges) { + throw new Error("No skill changes detected to publish."); + } + + const commitMessage = request.commitMessage?.trim() || `feat(skill): publish ${skillName}`; + await runGit( + ["-c", "user.name=ICA Skill Publisher", "-c", "user.email=ica-skill-publisher@local", "commit", "-m", commitMessage], + workspace.repoPath, + ); + const commitSha = await runGit(["rev-parse", "HEAD"], workspace.repoPath); + + await ensureGitRemote(workspace.repoPath, "origin", workspace.authRemoteUrl); + let pushedRemote = "origin"; + let prUrl: string | undefined; + let compareUrl: string | undefined; + + if (mode === "direct-push") { + await runGit(["push", "origin", workspace.baseBranch], workspace.repoPath); + } else if (mode === "branch-only") { + await runGit(["push", "-u", "origin", branch], workspace.repoPath); + } else if (options.officialContribution) { + const token = source.transport === "https" ? await credentials.get(source.id) : null; + if (source.providerHint === "github" && token) { + const contribution = await createGithubForkPr( + source, + token, + workspace.repoPath, + branch, + workspace.baseBranch, + `Add skill: ${skillName}`, + `Adds the \`${skillName}\` skill bundle via ICA contribution flow.`, + ); + pushedRemote = contribution.pushedRemote; + prUrl = contribution.prUrl; + compareUrl = contribution.compareUrl; + } else { + await runGit(["push", "-u", "origin", branch], workspace.repoPath); + compareUrl = buildCompareUrl(source, workspace.baseBranch, branch); + } + } else { + await runGit(["push", "-u", "origin", branch], workspace.repoPath); + const token = source.transport === "https" ? await credentials.get(source.id) : null; + if (source.providerHint === "github" && token) { + prUrl = await createGithubPrSameRepo( + source, + token, + branch, + workspace.baseBranch, + `Publish skill: ${skillName}`, + `Publishes \`${skillName}\` from ICA skill publishing workflow.`, + ); + } + compareUrl = prUrl ? undefined : buildCompareUrl(source, workspace.baseBranch, branch); + } + + return { + mode, + branch, + commitSha, + pushedRemote, + prUrl, + compareUrl, + }; + } catch (error) { + throw new Error(`Publish failed for source '${source.id}': ${normalizeGitError(error)}`); + } finally { + try { + await ensureGitRemote(workspace.repoPath, "origin", workspace.plainRemoteUrl); + } catch { + // ignore remote reset failures + } + } +} + +export async function publishSkillBundle(request: PublishRequest, credentials: CredentialProvider): Promise { + const source = await resolveSource(request.sourceId); + const overrideMode = request.overrideMode; + const forceMode = + overrideMode === "direct-push" || overrideMode === "branch-only" || overrideMode === "branch-pr" ? overrideMode : undefined; + return publishInternal(source, request, credentials, { + validationProfile: "personal", + forceMode, + forceBaseBranch: request.overrideBaseBranch?.trim() || undefined, + }); +} + +export async function contributeOfficialSkillBundle( + input: { bundle: SkillBundleInput; sourceId?: string; commitMessage?: string }, + credentials: CredentialProvider, +): Promise { + const allSources = await loadSources(); + const source = + (input.sourceId ? allSources.find((item) => item.id === input.sourceId) : undefined) || + allSources.find((item) => item.id === OFFICIAL_SOURCE_ID) || + allSources.find((item) => item.officialContributionEnabled); + if (!source) { + throw new Error("No official contribution source configured."); + } + if (!source.officialContributionEnabled) { + throw new Error(`Source '${source.id}' is not enabled for official contributions.`); + } + + return publishInternal( + source, + { + sourceId: source.id, + bundle: input.bundle, + commitMessage: input.commitMessage, + }, + credentials, + { + validationProfile: "official", + forceMode: "branch-pr", + forceBaseBranch: source.defaultBaseBranch || "dev", + officialContribution: true, + }, + ); +} diff --git a/src/installer-core/sources.ts b/src/installer-core/sources.ts index 9af6ee2..f7c5bd9 100644 --- a/src/installer-core/sources.ts +++ b/src/installer-core/sources.ts @@ -1,13 +1,14 @@ import os from "node:os"; import path from "node:path"; import { ensureDir, pathExists, readText, writeText } from "./fs"; -import { redactSensitive, stripUrlCredentials } from "./security"; -import { SourceTransport, SkillSource } from "./types"; +import { GitProvider, PublishMode, SourceTransport, SkillSource } from "./types"; export const OFFICIAL_SOURCE_ID = "official-skills"; export const OFFICIAL_SOURCE_NAME = "official"; export const OFFICIAL_SOURCE_URL = "https://github.com/intelligentcode-ai/skills.git"; export const DEFAULT_SKILLS_ROOT = "/skills"; +export const DEFAULT_PUBLISH_MODE: PublishMode = "branch-pr"; +export const DEFAULT_BASE_BRANCH = "main"; interface AddOrUpdateSourceInput { id?: string; @@ -17,6 +18,10 @@ interface AddOrUpdateSourceInput { official?: boolean; enabled?: boolean; skillsRoot?: string; + publishDefaultMode?: PublishMode; + defaultBaseBranch?: string; + providerHint?: GitProvider; + officialContributionEnabled?: boolean; credentialRef?: string; removable?: boolean; } @@ -36,6 +41,26 @@ function detectTransport(repoUrl: string): SourceTransport { return "https"; } +function normalizePublishDefaultMode(mode?: string): PublishMode { + if (mode === "direct-push" || mode === "branch-only" || mode === "branch-pr") { + return mode; + } + return DEFAULT_PUBLISH_MODE; +} + +function normalizeDefaultBaseBranch(branch?: string): string | undefined { + const next = (branch || "").trim(); + return next || undefined; +} + +export function detectGitProvider(repoUrl: string): GitProvider { + const normalized = repoUrl.trim().toLowerCase(); + if (normalized.includes("github.com")) return "github"; + if (normalized.includes("gitlab.com")) return "gitlab"; + if (normalized.includes("bitbucket.org")) return "bitbucket"; + return "unknown"; +} + function slug(value: string): string { return value .toLowerCase() @@ -67,14 +92,19 @@ function uniqueSourceId(baseId: string, existing: Set): string { } function defaultSource(source?: Partial): SkillSource { + const repoUrl = source?.repoUrl || OFFICIAL_SOURCE_URL; return { id: source?.id || OFFICIAL_SOURCE_ID, name: source?.name || OFFICIAL_SOURCE_NAME, - repoUrl: source?.repoUrl || OFFICIAL_SOURCE_URL, - transport: source?.transport || detectTransport(source?.repoUrl || OFFICIAL_SOURCE_URL), + repoUrl, + transport: source?.transport || detectTransport(repoUrl), official: source?.official ?? true, enabled: source?.enabled ?? true, skillsRoot: normalizeSkillsRoot(source?.skillsRoot), + publishDefaultMode: normalizePublishDefaultMode(source?.publishDefaultMode), + defaultBaseBranch: normalizeDefaultBaseBranch(source?.defaultBaseBranch) || (source?.official ? "dev" : DEFAULT_BASE_BRANCH), + providerHint: source?.providerHint || detectGitProvider(repoUrl), + officialContributionEnabled: source?.officialContributionEnabled ?? Boolean(source?.official), credentialRef: source?.credentialRef, removable: source?.removable ?? true, lastSyncAt: source?.lastSyncAt, @@ -108,25 +138,34 @@ export function getSourceRepoPath(sourceId: string): string { return path.join(getSourceCacheRoot(), sourceId, "repo"); } +export function getSourceWorkspaceRepoPath(sourceId: string): string { + return path.join(getIcaStateRoot(), "source-workspaces", sourceId, "repo"); +} + export function getSourceSkillsPath(sourceId: string): string { return path.join(getSourceRoot(sourceId), "skills"); } -function normalizeSource(source: SkillSource): SkillSource { - const cleanRepoUrl = stripUrlCredentials(source.repoUrl.trim()); +function normalizeSource(source: Partial & { id: string; repoUrl: string }): SkillSource { + const repoUrl = source.repoUrl.trim(); + const official = Boolean(source.official); return { ...source, id: slug(source.id), name: source.name?.trim() || source.id, - repoUrl: cleanRepoUrl, - transport: source.transport || detectTransport(source.repoUrl), + repoUrl, + transport: source.transport || detectTransport(repoUrl), skillsRoot: normalizeSkillsRoot(source.skillsRoot), - official: Boolean(source.official), + publishDefaultMode: normalizePublishDefaultMode(source.publishDefaultMode), + defaultBaseBranch: normalizeDefaultBaseBranch(source.defaultBaseBranch) || (official ? "dev" : DEFAULT_BASE_BRANCH), + providerHint: source.providerHint || detectGitProvider(repoUrl), + officialContributionEnabled: source.officialContributionEnabled ?? official, + official, enabled: source.enabled !== false, removable: source.removable !== false, credentialRef: source.credentialRef?.trim() || undefined, lastSyncAt: source.lastSyncAt, - lastError: source.lastError ? redactSensitive(source.lastError) : undefined, + lastError: source.lastError, localPath: source.localPath, localSkillsPath: source.localSkillsPath, revision: source.revision, @@ -140,9 +179,13 @@ export async function loadSources(): Promise { } try { - const raw = JSON.parse(await readText(sourcesFile)) as { sources?: SkillSource[] }; + const raw = JSON.parse(await readText(sourcesFile)) as { sources?: Array> }; const parsed = Array.isArray(raw.sources) ? raw.sources : []; - const normalized = parsed.map((source) => normalizeSource(source)); + const normalized = parsed + .filter((source): source is Partial & { id: string; repoUrl: string } => { + return Boolean(source && typeof source.id === "string" && typeof source.repoUrl === "string"); + }) + .map((source) => normalizeSource(source)); if (!normalized.find((source) => source.official)) { normalized.unshift(defaultSource()); @@ -174,6 +217,10 @@ export async function addSource(input: AddOrUpdateSourceInput): Promise(["claude", "gemini"]); + +function parseScope(value?: string): InstallScope { + return value === "project" ? "project" : "user"; +} + +function parseTargets(value?: string): TargetPlatform[] { + if (!value) { + return discoverTargets(); + } + const parsed = value + .split(/[\s,]+/) + .map((item) => item.trim()) + .filter(Boolean) + .filter((item): item is TargetPlatform => SUPPORTED_TARGETS.includes(item as TargetPlatform)); + + return Array.from(new Set(parsed)); +} + +function normalizePathForMatch(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function looksLikeOfficialSkillPath(localPath: string): boolean { + return normalizePathForMatch(localPath).includes("/official-skills/"); +} + +const HELPER_HOST = "127.0.0.1"; +const HELPER_PORT = Number(process.env.ICA_HELPER_PORT || "4174"); +const HELPER_TOKEN = process.env.ICA_HELPER_TOKEN || crypto.randomBytes(24).toString("hex"); +let helperProcess: ChildProcessWithoutNullStreams | null = null; + +async function helperRequest(pathname: string, body: Record): Promise> { + const response = await fetch(`http://${HELPER_HOST}:${HELPER_PORT}${pathname}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-ica-helper-token": HELPER_TOKEN, + }, + body: JSON.stringify(body), + }); + + const payload = (await response.json()) as Record; + if (!response.ok) { + throw new Error(typeof payload.error === "string" ? payload.error : "Helper request failed."); + } + return payload; +} + +async function waitForHelperReady(retries = 30): Promise { + for (let attempt = 0; attempt < retries; attempt += 1) { + try { + const response = await fetch(`http://${HELPER_HOST}:${HELPER_PORT}/health`, { + headers: { + "x-ica-helper-token": HELPER_TOKEN, + }, + }); + if (response.ok) return; + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error("ICA helper did not become ready in time."); +} + +async function ensureHelperRunning(repoRoot: string): Promise { + if (helperProcess && !helperProcess.killed) { + try { + await waitForHelperReady(1); + return; + } catch { + // respawn below + } + } + + const helperScript = path.join(repoRoot, "dist", "src", "installer-helper", "server.js"); + if (!fs.existsSync(helperScript)) { + throw new Error("Native helper is not built. Run: npm run build"); + } + + helperProcess = spawn(process.execPath, [helperScript], { + env: { + ...process.env, + ICA_HELPER_PORT: String(HELPER_PORT), + ICA_HELPER_TOKEN: HELPER_TOKEN, + }, + stdio: "pipe", + }); + helperProcess.stderr.on("data", (chunk) => { + const message = chunk.toString("utf8"); + process.stderr.write(`[ica-helper] ${message}`); + }); + + await waitForHelperReady(); +} + +function asInstallSelection(input: unknown): InstallSelection[] | undefined { + if (!Array.isArray(input)) return undefined; + const parsed = input + .map((item) => (item && typeof item === "object" ? (item as Record) : null)) + .filter((item): item is Record => Boolean(item)) + .map((item) => ({ + sourceId: String(item.sourceId || ""), + skillName: String(item.skillName || ""), + skillId: String(item.skillId || `${String(item.sourceId || "")}/${String(item.skillName || "")}`), + })) + .filter((item) => item.sourceId && item.skillName); + return parsed.length > 0 ? parsed : undefined; +} + +function asHookInstallSelection(input: unknown): HookInstallSelection[] | undefined { + if (!Array.isArray(input)) return undefined; + const parsed = input + .map((item) => (item && typeof item === "object" ? (item as Record) : null)) + .filter((item): item is Record => Boolean(item)) + .map((item) => ({ + sourceId: String(item.sourceId || ""), + hookName: String(item.hookName || ""), + hookId: String(item.hookId || `${String(item.sourceId || "")}/${String(item.hookName || "")}`), + })) + .filter((item) => item.sourceId && item.hookName); + return parsed.length > 0 ? parsed : undefined; +} + +function detectLegacyInstalledSkills(installPath: string, catalogSkillNames: Set): InstallationSkillView[] { + const skillsRoot = path.join(installPath, "skills"); + if (!fs.existsSync(skillsRoot)) { + return []; + } + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(skillsRoot, { withFileTypes: true }); + } catch { + return []; + } + + const detected: InstallationSkillView[] = []; + for (const entry of entries) { + if (!catalogSkillNames.has(entry.name)) { + continue; + } + + const skillPath = path.join(skillsRoot, entry.name); + let looksLikeSkill = false; + try { + const stat = fs.lstatSync(skillPath); + if (stat.isSymbolicLink()) { + const resolved = fs.realpathSync(skillPath); + looksLikeSkill = fs.existsSync(path.join(resolved, "SKILL.md")); + } else if (stat.isDirectory()) { + looksLikeSkill = fs.existsSync(path.join(skillPath, "SKILL.md")); + } + } catch { + looksLikeSkill = false; + } + + if (looksLikeSkill) { + detected.push({ + name: entry.name, + installMode: "unknown", + effectiveMode: "unknown", + }); + } + } + + return detected.sort((a, b) => a.name.localeCompare(b.name)); +} + +function detectLegacyInstalledHooks(installPath: string, catalogHookNames: Set): InstallationHookView[] { + const hooksRoot = path.join(installPath, "hooks"); + if (!fs.existsSync(hooksRoot)) { + return []; + } + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(hooksRoot, { withFileTypes: true }); + } catch { + return []; + } + + const detected: InstallationHookView[] = []; + for (const entry of entries) { + if (!catalogHookNames.has(entry.name)) { + continue; + } + + const hookPath = path.join(hooksRoot, entry.name); + let looksLikeHook = false; + try { + const stat = fs.lstatSync(hookPath); + if (stat.isSymbolicLink()) { + const resolved = fs.realpathSync(hookPath); + looksLikeHook = fs.existsSync(path.join(resolved, "HOOK.md")) || fs.readdirSync(resolved, { withFileTypes: true }).length > 0; + } else if (stat.isDirectory()) { + looksLikeHook = fs.existsSync(path.join(hookPath, "HOOK.md")) || fs.readdirSync(hookPath, { withFileTypes: true }).length > 0; + } + } catch { + looksLikeHook = false; + } + + if (looksLikeHook) { + detected.push({ + name: entry.name, + installMode: "unknown", + effectiveMode: "unknown", + }); + } + } + + return detected.sort((a, b) => a.name.localeCompare(b.name)); } async function main(): Promise { const app = Fastify({ logger: false }); const repoRoot = findRepoRoot(__dirname); + const webBuildPath = path.join(repoRoot, "dist", "installer-dashboard", "web-build"); + if (fs.existsSync(webBuildPath)) { + await app.register(fastifyStatic, { + root: webBuildPath, + prefix: "/", + }); + } + + const pluginRuntime = await loadDashboardServerPlugins({ + app, + enabledPluginIds: parseEnabledDashboardPlugins(process.env.ICA_DASHBOARD_PLUGINS), + registry: dashboardServerPluginRegistry, + pluginConfigs: parseDashboardPluginConfig(process.env.ICA_DASHBOARD_PLUGIN_CONFIG), + }); + + app.get("/api/v1/health", async () => { + return { + ok: true, + service: "ica-installer-dashboard", + timestamp: new Date().toISOString(), + }; + }); + + app.get("/api/v1/capabilities", async () => { + return { capabilities: mergeCapabilities(capabilityRegistry(), pluginRuntime.capabilities) }; + }); + + app.get("/api/v1/plugins", async () => { + return { + loadedPluginIds: pluginRuntime.loadedPluginIds, + capabilities: pluginRuntime.capabilities, + }; + }); + + app.get("/api/v1/catalog/skills", async () => { + const catalog = await loadCatalogFromSources(repoRoot, true); + return { + generatedAt: catalog.generatedAt, + version: catalog.version, + sources: catalog.sources, + skills: catalog.skills, + }; + }); + + app.get("/api/v1/catalog/hooks", async () => { + const catalog = await loadHookCatalogFromSources(repoRoot, true); + return { + generatedAt: catalog.generatedAt, + version: catalog.version, + sources: catalog.sources, + hooks: catalog.hooks, + }; + }); + + app.get("/api/v1/targets/discovered", async () => { + return { + targets: discoverTargets(), + }; + }); + + app.get("/api/v1/installations", async (request) => { + const query = request.query as { scope?: string; projectPath?: string; targets?: string }; + const scope = parseScope(query.scope); + const projectPath = query.projectPath; + const targets = parseTargets(query.targets); + const resolved = resolveTargetPaths(targets, scope, projectPath); + const catalog = await loadCatalogFromSources(repoRoot, false); + const catalogSkillNames = new Set(catalog.skills.map((skill) => skill.skillName)); + const activeSourceIds = new Set(catalog.sources.map((source) => source.id)); - if (!fs.existsSync(path.join(webBuildPath, "index.html"))) { - throw new Error("Dashboard web assets not built. Run: npm run build:dashboard:web"); + const rows = await Promise.all( + resolved.map(async (entry) => { + const state = await loadInstallState(entry.installPath); + const managedSkills: InstallationSkillView[] = + state?.managedSkills.map((skill) => ({ + name: skill.name, + skillId: skill.skillId, + sourceId: skill.sourceId, + installMode: skill.installMode, + effectiveMode: skill.effectiveMode, + orphaned: skill.orphaned || (skill.sourceId ? !activeSourceIds.has(skill.sourceId) : false), + })) || []; + const skillsByName = new Map(managedSkills.map((skill) => [skill.name, skill])); + const detected = detectLegacyInstalledSkills(entry.installPath, catalogSkillNames); + for (const skill of detected) { + if (!skillsByName.has(skill.name)) { + skillsByName.set(skill.name, skill); + } + } + const combinedSkills = Array.from(skillsByName.values()).sort((a, b) => a.name.localeCompare(b.name)); + + return { + target: entry.target, + installPath: entry.installPath, + scope: entry.scope, + projectPath: entry.projectPath, + installed: Boolean(state) || combinedSkills.length > 0, + managedSkills: combinedSkills, + updatedAt: state?.updatedAt, + }; + }), + ); + + return { installations: rows }; + }); + + app.get("/api/v1/hooks/installations", async (request) => { + const query = request.query as { scope?: string; projectPath?: string; targets?: string }; + const scope = parseScope(query.scope); + const projectPath = query.projectPath; + const targets = parseTargets(query.targets).filter((target): target is HookTargetPlatform => HOOK_CAPABLE_TARGETS.has(target as HookTargetPlatform)); + if (targets.length === 0) { + return { installations: [] }; + } + const resolved = resolveTargetPaths(targets, scope, projectPath); + const catalog = await loadHookCatalogFromSources(repoRoot, false); + const catalogHookNames = new Set(catalog.hooks.map((hook) => hook.hookName)); + const activeSourceIds = new Set(catalog.sources.map((source) => source.id)); + + const rows = await Promise.all( + resolved.map(async (entry) => { + const state = await loadHookInstallState(entry.installPath); + const managedHooks: InstallationHookView[] = + state?.managedHooks.map((hook) => ({ + name: hook.name, + hookId: hook.hookId, + sourceId: hook.sourceId, + installMode: hook.installMode, + effectiveMode: hook.effectiveMode, + orphaned: hook.orphaned || (hook.sourceId ? !activeSourceIds.has(hook.sourceId) : false), + })) || []; + const hooksByName = new Map(managedHooks.map((hook) => [hook.name, hook])); + const detected = detectLegacyInstalledHooks(entry.installPath, catalogHookNames); + for (const hook of detected) { + if (!hooksByName.has(hook.name)) { + hooksByName.set(hook.name, hook); + } + } + const combinedHooks = Array.from(hooksByName.values()).sort((a, b) => a.name.localeCompare(b.name)); + + return { + target: entry.target, + installPath: entry.installPath, + scope: entry.scope, + projectPath: entry.projectPath, + installed: Boolean(state) || combinedHooks.length > 0, + managedHooks: combinedHooks, + updatedAt: state?.updatedAt, + }; + }), + ); + + return { installations: rows }; + }); + + app.get("/api/v1/sources", async () => { + const skillSources = await loadSources(); + const hookSources = await loadHookSources(); + const byId = new Map< + string, + { + id: string; + name: string; + repoUrl: string; + transport: "https" | "ssh"; + official: boolean; + enabled: boolean; + skillsRoot?: string; + hooksRoot?: string; + publishDefaultMode?: PublishMode; + defaultBaseBranch?: string; + providerHint?: "github" | "gitlab" | "bitbucket" | "unknown"; + officialContributionEnabled?: boolean; + credentialRef?: string; + removable: boolean; + lastSyncAt?: string; + lastError?: string; + revision?: string; + } + >(); + + for (const source of skillSources) { + byId.set(source.id, { + ...(byId.get(source.id) || { + id: source.id, + name: source.name, + repoUrl: source.repoUrl, + transport: source.transport, + official: source.official, + enabled: source.enabled, + removable: source.removable, + }), + id: source.id, + name: source.name, + repoUrl: source.repoUrl, + transport: source.transport, + official: source.official, + enabled: source.enabled, + skillsRoot: source.skillsRoot, + publishDefaultMode: source.publishDefaultMode, + defaultBaseBranch: source.defaultBaseBranch, + providerHint: source.providerHint, + officialContributionEnabled: source.officialContributionEnabled, + credentialRef: source.credentialRef, + removable: source.removable, + lastSyncAt: source.lastSyncAt || byId.get(source.id)?.lastSyncAt, + lastError: source.lastError || byId.get(source.id)?.lastError, + revision: source.revision || byId.get(source.id)?.revision, + }); + } + + for (const source of hookSources) { + byId.set(source.id, { + ...(byId.get(source.id) || { + id: source.id, + name: source.name, + repoUrl: source.repoUrl, + transport: source.transport, + official: source.official, + enabled: source.enabled, + removable: source.removable, + }), + id: source.id, + name: source.name, + repoUrl: source.repoUrl, + transport: source.transport, + official: source.official, + enabled: (byId.get(source.id)?.enabled ?? false) || source.enabled, + hooksRoot: source.hooksRoot, + publishDefaultMode: byId.get(source.id)?.publishDefaultMode, + defaultBaseBranch: byId.get(source.id)?.defaultBaseBranch, + providerHint: byId.get(source.id)?.providerHint, + officialContributionEnabled: byId.get(source.id)?.officialContributionEnabled, + credentialRef: source.credentialRef || byId.get(source.id)?.credentialRef, + removable: (byId.get(source.id)?.removable ?? true) && source.removable, + lastSyncAt: byId.get(source.id)?.lastSyncAt || source.lastSyncAt, + lastError: byId.get(source.id)?.lastError || source.lastError, + revision: byId.get(source.id)?.revision || source.revision, + }); + } + + return { + sources: Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id)), + }; + }); + + app.post("/api/v1/sources", async (request, reply) => { + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + const repoUrl = String(body.repoUrl || "").trim(); + if (!repoUrl) { + return reply.code(400).send({ error: "repoUrl is required." }); + } + + const credentialProvider = createCredentialProvider(); + const token = typeof body.token === "string" ? body.token.trim() : ""; + const registration = await registerRepository( + { + id: typeof body.id === "string" ? body.id : undefined, + name: typeof body.name === "string" ? body.name : undefined, + repoUrl, + transport: typeof body.transport === "string" && (body.transport === "https" || body.transport === "ssh") ? body.transport : undefined, + skillsRoot: typeof body.skillsRoot === "string" ? body.skillsRoot : undefined, + publishDefaultMode: + typeof body.publishDefaultMode === "string" && + (body.publishDefaultMode === "direct-push" || body.publishDefaultMode === "branch-only" || body.publishDefaultMode === "branch-pr") + ? body.publishDefaultMode + : undefined, + defaultBaseBranch: typeof body.defaultBaseBranch === "string" ? body.defaultBaseBranch : undefined, + providerHint: + typeof body.providerHint === "string" && + (body.providerHint === "github" || body.providerHint === "gitlab" || body.providerHint === "bitbucket" || body.providerHint === "unknown") + ? body.providerHint + : undefined, + officialContributionEnabled: typeof body.officialContributionEnabled === "boolean" ? body.officialContributionEnabled : undefined, + hooksRoot: typeof body.hooksRoot === "string" ? body.hooksRoot : undefined, + enabled: body.enabled !== false, + removable: body.removable !== false, + official: body.official === true, + token, + }, + credentialProvider, + ); + const source = registration.skillSource; + const auth = await checkSourceAuth( + { + id: source.id, + repoUrl: source.repoUrl, + transport: source.transport, + }, + credentialProvider, + ); + if (!auth.ok) { + await setSourceSyncStatus(source.id, { lastError: auth.message }); + return reply.code(400).send({ error: auth.message, source }); + } + + return { + source, + sync: registration.sync, + }; + }); + + app.patch("/api/v1/sources/:id", async (request, reply) => { + const params = request.params as { id: string }; + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + + try { + const source = await updateSource(params.id, { + name: typeof body.name === "string" ? body.name : undefined, + repoUrl: typeof body.repoUrl === "string" ? body.repoUrl : undefined, + transport: typeof body.transport === "string" && (body.transport === "https" || body.transport === "ssh") ? body.transport : undefined, + skillsRoot: typeof body.skillsRoot === "string" ? body.skillsRoot : undefined, + publishDefaultMode: + typeof body.publishDefaultMode === "string" && + (body.publishDefaultMode === "direct-push" || body.publishDefaultMode === "branch-only" || body.publishDefaultMode === "branch-pr") + ? body.publishDefaultMode + : undefined, + defaultBaseBranch: typeof body.defaultBaseBranch === "string" ? body.defaultBaseBranch : undefined, + providerHint: + typeof body.providerHint === "string" && + (body.providerHint === "github" || body.providerHint === "gitlab" || body.providerHint === "bitbucket" || body.providerHint === "unknown") + ? body.providerHint + : undefined, + officialContributionEnabled: typeof body.officialContributionEnabled === "boolean" ? body.officialContributionEnabled : undefined, + enabled: typeof body.enabled === "boolean" ? body.enabled : undefined, + credentialRef: typeof body.credentialRef === "string" ? body.credentialRef : undefined, + removable: typeof body.removable === "boolean" ? body.removable : undefined, + official: typeof body.official === "boolean" ? body.official : undefined, + }); + try { + await updateHookSource(params.id, { + name: typeof body.name === "string" ? body.name : undefined, + repoUrl: typeof body.repoUrl === "string" ? body.repoUrl : undefined, + transport: typeof body.transport === "string" && (body.transport === "https" || body.transport === "ssh") ? body.transport : undefined, + hooksRoot: typeof body.hooksRoot === "string" ? body.hooksRoot : undefined, + enabled: typeof body.enabled === "boolean" ? body.enabled : undefined, + credentialRef: typeof body.credentialRef === "string" ? body.credentialRef : undefined, + removable: typeof body.removable === "boolean" ? body.removable : undefined, + official: typeof body.official === "boolean" ? body.official : undefined, + }); + } catch { + // Older environments may still have only skill sources configured. + } + + const credentialProvider = createCredentialProvider(); + const token = typeof body.token === "string" ? body.token.trim() : ""; + if (token) { + await credentialProvider.store(params.id, token); + await updateSource(params.id, { credentialRef: `${params.id}:stored` }); + await updateHookSource(params.id, { credentialRef: `${params.id}:stored` }); + } + + return { source }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.delete("/api/v1/sources/:id", async (request, reply) => { + const params = request.params as { id: string }; + try { + let removed: Awaited> | null = null; + try { + removed = await removeSource(params.id); + } catch { + // allow hook-only entries + } + try { + await removeHookSource(params.id); + } catch { + // hooks mirror may not exist; ignore. + } + const credentialProvider = createCredentialProvider(); + await credentialProvider.delete(params.id); + return { source: removed || { id: params.id } }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post("/api/v1/sources/:id/auth/check", async (request, reply) => { + const params = request.params as { id: string }; + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + const source = (await loadSources()).find((item) => item.id === params.id) || (await loadHookSources()).find((item) => item.id === params.id); + if (!source) { + return reply.code(404).send({ error: `Unknown source '${params.id}'.` }); + } + + const credentialProvider = createCredentialProvider(); + const token = typeof body.token === "string" ? body.token.trim() : ""; + if (token) { + await credentialProvider.store(source.id, token); + await updateSource(source.id, { credentialRef: `${source.id}:stored` }); + try { + await updateHookSource(source.id, { credentialRef: `${source.id}:stored` }); + } catch { + // ignore missing hook mirror + } + } + const auth = await checkSourceAuth( + { + id: source.id, + repoUrl: source.repoUrl, + transport: source.transport, + }, + credentialProvider, + ); + if (!auth.ok) { + return reply.code(400).send(auth); + } + return auth; + }); + + app.post("/api/v1/sources/:id/refresh", async (request, reply) => { + const params = request.params as { id: string }; + const skillSource = (await loadSources()).find((item) => item.id === params.id); + const hookSource = (await loadHookSources()).find((item) => item.id === params.id); + if (!skillSource && !hookSource) { + return reply.code(404).send({ error: `Unknown source '${params.id}'.` }); + } + const credentialProvider = createCredentialProvider(); + try { + const refreshed: Array<{ type: "skills" | "hooks"; revision?: string; localPath?: string; error?: string }> = []; + if (skillSource) { + try { + const result = await syncSource(skillSource, credentialProvider); + refreshed.push({ type: "skills", revision: result.revision, localPath: result.localPath }); + } catch (error) { + refreshed.push({ type: "skills", error: error instanceof Error ? error.message : String(error) }); + } + } + if (hookSource) { + try { + const result = await syncHookSource(hookSource, credentialProvider); + refreshed.push({ type: "hooks", revision: result.revision, localPath: result.localPath }); + } catch (error) { + refreshed.push({ type: "hooks", error: error instanceof Error ? error.message : String(error) }); + } + } + return { sourceId: params.id, refreshed }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post("/api/v1/sources/refresh-all", async () => { + const credentialProvider = createCredentialProvider(); + const skillSources = (await loadSources()).filter((source) => source.enabled); + const hookSources = (await loadHookSources()).filter((source) => source.enabled); + const byId = new Map(); + for (const source of skillSources) { + byId.set(source.id, { ...(byId.get(source.id) || {}), skills: source }); + } + for (const source of hookSources) { + byId.set(source.id, { ...(byId.get(source.id) || {}), hooks: source }); + } + + const refreshed: Array<{ + sourceId: string; + skills?: { revision?: string; localPath?: string; error?: string }; + hooks?: { revision?: string; localPath?: string; error?: string }; + }> = []; + for (const [sourceId, entry] of byId.entries()) { + const item: { + sourceId: string; + skills?: { revision?: string; localPath?: string; error?: string }; + hooks?: { revision?: string; localPath?: string; error?: string }; + } = { sourceId }; + + if (entry.skills) { + try { + const result = await syncSource(entry.skills, credentialProvider); + item.skills = { revision: result.revision, localPath: result.localPath }; + } catch (error) { + item.skills = { error: error instanceof Error ? error.message : String(error) }; + } + } + if (entry.hooks) { + try { + const result = await syncHookSource(entry.hooks, credentialProvider); + item.hooks = { revision: result.revision, localPath: result.localPath }; + } catch (error) { + item.hooks = { error: error instanceof Error ? error.message : String(error) }; + } + } + refreshed.push(item); + } + return { refreshed }; + }); + + app.post("/api/v1/skills/validate", async (request, reply) => { + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + const localPath = typeof body.path === "string" ? body.path.trim() : ""; + if (!localPath) { + return reply.code(400).send({ error: "path is required." }); + } + const profile = (typeof body.profile === "string" ? body.profile : "personal") as ValidationProfile; + if (profile !== "personal" && profile !== "official") { + return reply.code(400).send({ error: "profile must be 'personal' or 'official'." }); + } + + try { + const validation = await validateSkillBundle( + { + localPath, + skillName: typeof body.skillName === "string" ? body.skillName : undefined, + }, + profile, + ); + return { validation }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post("/api/v1/skills/publish", async (request, reply) => { + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + const sourceId = typeof body.sourceId === "string" ? body.sourceId.trim() : ""; + const localPath = typeof body.path === "string" ? body.path.trim() : ""; + const overrideMode = typeof body.overrideMode === "string" ? body.overrideMode.trim() : ""; + const overrideBaseBranch = typeof body.overrideBaseBranch === "string" ? body.overrideBaseBranch.trim() : ""; + if (!sourceId) { + return reply.code(400).send({ error: "sourceId is required." }); + } + if (!localPath) { + return reply.code(400).send({ error: "path is required." }); + } + if (overrideMode && overrideMode !== "direct-push" && overrideMode !== "branch-only" && overrideMode !== "branch-pr") { + return reply.code(400).send({ error: "overrideMode must be direct-push, branch-only, or branch-pr." }); + } + try { + const sources = await loadSources(); + const targetSource = sources.find((source) => source.id === sourceId); + if (!targetSource) { + return reply.code(404).send({ error: `Unknown source '${sourceId}'.` }); + } + + const catalog = await loadCatalogFromSources(repoRoot, false); + const normalizedLocalPath = normalizePathForMatch(localPath); + const matchedSkill = catalog.skills.find((skill) => normalizePathForMatch(skill.sourcePath || "") === normalizedLocalPath); + const matchedSource = matchedSkill ? sources.find((source) => source.id === matchedSkill.sourceId) : undefined; + const officialBundle = Boolean(matchedSource?.official) || looksLikeOfficialSkillPath(localPath); + if (officialBundle && !targetSource.official) { + return reply.code(400).send({ error: "Official skills can only be published to official sources." }); + } + + const result = await publishSkillBundle( + { + sourceId, + bundle: { + localPath, + skillName: typeof body.skillName === "string" ? body.skillName : undefined, + }, + commitMessage: typeof body.message === "string" ? body.message : undefined, + overrideMode: overrideMode ? (overrideMode as PublishMode) : undefined, + overrideBaseBranch: overrideBaseBranch || undefined, + }, + createCredentialProvider(), + ); + return { result }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post("/api/v1/skills/contribute-official", async (request, reply) => { + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + const localPath = typeof body.path === "string" ? body.path.trim() : ""; + if (!localPath) { + return reply.code(400).send({ error: "path is required." }); + } + try { + const result = await contributeOfficialSkillBundle( + { + sourceId: typeof body.sourceId === "string" ? body.sourceId : undefined, + bundle: { + localPath, + skillName: typeof body.skillName === "string" ? body.skillName : undefined, + }, + commitMessage: typeof body.message === "string" ? body.message : undefined, + }, + createCredentialProvider(), + ); + return { result }; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post("/api/v1/skills/pick", async (request, reply) => { + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + try { + await ensureHelperRunning(repoRoot); + const payload = await helperRequest("/pick-directory", { + initialPath: typeof body.initialPath === "string" ? body.initialPath : process.cwd(), + }); + return payload; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post("/api/v1/projects/pick", async (request, reply) => { + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + try { + await ensureHelperRunning(repoRoot); + const payload = await helperRequest("/pick-directory", { + initialPath: typeof body.initialPath === "string" ? body.initialPath : process.cwd(), + }); + return payload; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post("/api/v1/container/mount-project", async (request, reply) => { + const body = (request.body && typeof request.body === "object" ? (request.body as Record) : {}) as Record< + string, + unknown + >; + try { + await ensureHelperRunning(repoRoot); + const payload = await helperRequest("/container/mount-project", { + projectPath: typeof body.projectPath === "string" ? body.projectPath : "", + containerName: typeof body.containerName === "string" ? body.containerName : undefined, + image: typeof body.image === "string" ? body.image : undefined, + port: typeof body.port === "string" ? body.port : undefined, + confirm: body.confirm === true, + }); + return payload; + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.addHook("preHandler", async (request, reply) => { + if (!["POST", "PATCH", "DELETE"].includes(request.method) || !request.url.startsWith("/api/v1/")) { + return; + } + + const loopbackIps = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); + if (!loopbackIps.has(request.ip)) { + return reply.code(403).send({ error: "Forbidden: dashboard API accepts local loopback requests only." }); + } + + if (request.method !== "DELETE") { + const contentType = String(request.headers["content-type"] || ""); + if (!contentType.toLowerCase().includes("application/json")) { + return reply.code(415).send({ error: "Unsupported media type: expected application/json." }); + } + } + }); + + function normalizeBody(body: unknown): Partial { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return {}; + } + const typed = body as Partial; + return { + ...typed, + skillSelections: asInstallSelection((typed as Record).skillSelections), + }; + } + + function normalizeHookBody(body: unknown): Partial { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return {}; + } + const typed = body as Partial; + return { + ...typed, + hookSelections: asHookInstallSelection((typed as Record).hookSelections), + }; } - await app.register(fastifyStatic, { - root: webBuildPath, - prefix: "/", + function normalizeTargets(value: Partial["targets"]): TargetPlatform[] { + if (!Array.isArray(value)) { + return discoverTargets(); + } + const filtered = value.filter((item): item is TargetPlatform => + typeof item === "string" && SUPPORTED_TARGETS.includes(item as TargetPlatform), + ); + return Array.from(new Set(filtered)); + } + + function normalizeHookTargets(value: Partial["targets"]): HookTargetPlatform[] { + const filtered = normalizeTargets(value as TargetPlatform[]).filter( + (item): item is HookTargetPlatform => HOOK_CAPABLE_TARGETS.has(item as HookTargetPlatform), + ); + return Array.from(new Set(filtered)); + } + + app.post("/api/v1/install/apply", async (request, reply) => { + const body = normalizeBody(request.body); + const targets = normalizeTargets(body.targets); + if (targets.length === 0) { + return reply.code(400).send({ error: "No valid targets selected." }); + } + const installRequest: InstallRequest = { + operation: "install", + targets, + scope: body.scope || "user", + projectPath: body.projectPath, + agentDirName: body.agentDirName, + mode: body.mode || "symlink", + skills: body.skills || [], + skillSelections: body.skillSelections, + removeUnselected: body.removeUnselected || false, + installClaudeIntegration: body.installClaudeIntegration !== false, + force: body.force || false, + configFile: body.configFile, + mcpConfigFile: body.mcpConfigFile, + envFile: body.envFile, + }; + + return executeOperation(repoRoot, installRequest, { hooks: pluginRuntime.installHooks }); + }); + + app.post("/api/v1/uninstall/apply", async (request, reply) => { + const body = normalizeBody(request.body); + const targets = normalizeTargets(body.targets); + if (targets.length === 0) { + return reply.code(400).send({ error: "No valid targets selected." }); + } + const uninstallRequest: InstallRequest = { + operation: "uninstall", + targets, + scope: body.scope || "user", + projectPath: body.projectPath, + agentDirName: body.agentDirName, + mode: body.mode || "symlink", + skills: body.skills || [], + skillSelections: body.skillSelections, + removeUnselected: false, + installClaudeIntegration: body.installClaudeIntegration !== false, + force: body.force || false, + configFile: body.configFile, + mcpConfigFile: body.mcpConfigFile, + envFile: body.envFile, + }; + + return executeOperation(repoRoot, uninstallRequest, { hooks: pluginRuntime.installHooks }); + }); + + app.post("/api/v1/sync/apply", async (request, reply) => { + const body = normalizeBody(request.body); + const targets = normalizeTargets(body.targets); + if (targets.length === 0) { + return reply.code(400).send({ error: "No valid targets selected." }); + } + const syncRequest: InstallRequest = { + operation: "sync", + targets, + scope: body.scope || "user", + projectPath: body.projectPath, + agentDirName: body.agentDirName, + mode: body.mode || "symlink", + skills: body.skills || [], + skillSelections: body.skillSelections, + removeUnselected: true, + installClaudeIntegration: body.installClaudeIntegration !== false, + force: body.force || false, + configFile: body.configFile, + mcpConfigFile: body.mcpConfigFile, + envFile: body.envFile, + }; + + return executeOperation(repoRoot, syncRequest, { hooks: pluginRuntime.installHooks }); }); - app.get("/health", async () => ({ ok: true, service: "ica-dashboard-static" })); + app.post("/api/v1/hooks/install/apply", async (request, reply) => { + const body = normalizeHookBody(request.body); + const targets = normalizeHookTargets(body.targets); + if (targets.length === 0) { + return reply.code(400).send({ error: "No hook-capable targets selected (supported: claude, gemini)." }); + } + const installRequest: HookInstallRequest = { + operation: "install", + targets, + scope: body.scope || "user", + projectPath: body.projectPath, + agentDirName: body.agentDirName, + mode: body.mode || "symlink", + hooks: body.hooks || [], + hookSelections: body.hookSelections, + removeUnselected: body.removeUnselected || false, + force: body.force || false, + }; + + return executeHookOperation(repoRoot, installRequest); + }); + + app.post("/api/v1/hooks/uninstall/apply", async (request, reply) => { + const body = normalizeHookBody(request.body); + const targets = normalizeHookTargets(body.targets); + if (targets.length === 0) { + return reply.code(400).send({ error: "No hook-capable targets selected (supported: claude, gemini)." }); + } + const uninstallRequest: HookInstallRequest = { + operation: "uninstall", + targets, + scope: body.scope || "user", + projectPath: body.projectPath, + agentDirName: body.agentDirName, + mode: body.mode || "symlink", + hooks: body.hooks || [], + hookSelections: body.hookSelections, + removeUnselected: false, + force: body.force || false, + }; + + return executeHookOperation(repoRoot, uninstallRequest); + }); + + app.post("/api/v1/hooks/sync/apply", async (request, reply) => { + const body = normalizeHookBody(request.body); + const targets = normalizeHookTargets(body.targets); + if (targets.length === 0) { + return reply.code(400).send({ error: "No hook-capable targets selected (supported: claude, gemini)." }); + } + const syncRequest: HookInstallRequest = { + operation: "sync", + targets, + scope: body.scope || "user", + projectPath: body.projectPath, + agentDirName: body.agentDirName, + mode: body.mode || "symlink", + hooks: body.hooks || [], + hookSelections: body.hookSelections, + removeUnselected: true, + force: body.force || false, + }; + + return executeHookOperation(repoRoot, syncRequest); + }); app.setNotFoundHandler(async (_request, reply) => { + if (!fs.existsSync(path.join(webBuildPath, "index.html"))) { + return reply.type("text/plain").send("Dashboard web assets not built. Run: npm run build:dashboard:web"); + } return reply.type("text/html").send(fs.readFileSync(path.join(webBuildPath, "index.html"), "utf8")); }); const host = process.env.ICA_DASHBOARD_HOST || "127.0.0.1"; const port = Number(process.env.ICA_DASHBOARD_PORT || "4173"); await app.listen({ host, port }); - process.stdout.write(`ICA static dashboard listening at http://${host}:${port}\n`); + process.stdout.write(`ICA dashboard listening at http://${host}:${port}\n`); } main().catch((error) => { - process.stderr.write(`Dashboard startup failed: ${sanitizeError(error)}\n`); + process.stderr.write(`Dashboard startup failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); diff --git a/src/installer-dashboard/web/src/InstallerDashboard.tsx b/src/installer-dashboard/web/src/InstallerDashboard.tsx index 9c7e74e..b82c0b2 100644 --- a/src/installer-dashboard/web/src/InstallerDashboard.tsx +++ b/src/installer-dashboard/web/src/InstallerDashboard.tsx @@ -1,7 +1,4 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { apiFetch } from "./api-client"; -import { RealtimeEvent, RealtimeStatus, startRealtimeClient } from "./realtime-client"; -import { runStartupTasks } from "./startup"; type Target = "claude" | "codex" | "cursor" | "gemini" | "antigravity"; @@ -14,6 +11,10 @@ type Source = { enabled: boolean; skillsRoot: string; hooksRoot?: string; + publishDefaultMode?: "direct-push" | "branch-only" | "branch-pr"; + defaultBaseBranch?: string; + providerHint?: "github" | "gitlab" | "bitbucket" | "unknown"; + officialContributionEnabled?: boolean; credentialRef?: string; removable: boolean; lastSyncAt?: string; @@ -31,16 +32,11 @@ type Skill = { description: string; category: string; scope?: string; - subcategory?: string; tags?: string[]; - author?: string; - contactEmail?: string; - website?: string; resources: Array<{ type: string; path: string }>; + sourcePath?: string; version?: string; updatedAt?: string; - contentDigest?: string; - contentFileCount?: number; }; type InstallationSkill = { @@ -129,29 +125,29 @@ type HookOperationReport = { targets: HookOperationTargetReport[]; }; +type SkillValidationResult = { + profile: "personal" | "official"; + errors: string[]; + warnings: string[]; + detectedFiles: string[]; +}; + +type SkillPublishResult = { + mode: "direct-push" | "branch-only" | "branch-pr"; + branch: string; + commitSha: string; + pushedRemote: string; + prUrl?: string; + compareUrl?: string; +}; + +type PublishMode = "direct-push" | "branch-only" | "branch-pr"; + type DashboardTab = "skills" | "hooks" | "settings" | "state"; type DashboardMode = "light" | "dark"; type DashboardAccent = "slate" | "blue" | "red" | "green" | "amber"; type DashboardBackground = "slate" | "ocean" | "sand" | "forest" | "wine"; type LegacyDashboardTheme = "light" | "dark" | "blue" | "red" | "green"; -type HealthPayload = { - version?: string; - update?: { - currentVersion?: string; - latestVersion?: string; - latestReleaseUrl?: string; - checkedAt?: string; - updateAvailable?: boolean; - error?: string; - }; - error?: string; -}; -type SkillChangeNotice = { - added: number; - removed: number; - changed: number; - checkedAt: string; -}; const allTargets: Target[] = ["claude", "codex", "cursor", "gemini", "antigravity"]; const modeStorageKey = "ica.dashboard.mode"; @@ -176,9 +172,6 @@ const backgroundOptions: Array<{ id: DashboardBackground; label: string }> = [ { id: "forest", label: "Forest" }, { id: "wine", label: "Wine" }, ]; -const rawUpdateCheckMinutes = - Number((import.meta as { env?: Record }).env?.VITE_ICA_UPDATE_CHECK_MINUTES || "60"); -const updateCheckMinutes = Number.isFinite(rawUpdateCheckMinutes) && rawUpdateCheckMinutes > 0 ? Math.floor(rawUpdateCheckMinutes) : 60; function isDashboardMode(value: string | null): value is DashboardMode { return value === "light" || value === "dark"; @@ -252,13 +245,55 @@ function titleCase(value: string): string { .replace(/\b[a-z]/g, (match) => match.toUpperCase()); } -function buildSkillSnapshot(items: Skill[]): Map { - const snapshot = new Map(); - for (const skill of items) { - const key = `${skill.version || ""}|${skill.contentDigest || ""}|${skill.updatedAt || ""}`; - snapshot.set(skill.skillId, key); +export function computeFilterSourceOptions( + entries: T[], + selectedIds: Set, + resolveEntryId: (entry: T) => string, +): string[] { + const allSourceIds = Array.from(new Set(entries.map((entry) => entry.sourceId))).sort((a, b) => a.localeCompare(b)); + if (allSourceIds.length === 0 || selectedIds.size === 0) { + return allSourceIds; + } + + const selectedSourceIds = new Set(); + for (const entry of entries) { + if (selectedIds.has(resolveEntryId(entry))) { + selectedSourceIds.add(entry.sourceId); + } } - return snapshot; + + if (selectedSourceIds.size === 0) { + return allSourceIds; + } + + return Array.from(selectedSourceIds).sort((a, b) => a.localeCompare(b)); +} + +type SkillPublishCandidate = { + skillId: string; + skillName: string; + sourceId: string; + sourceName: string; + localPath: string; +}; + +function toSkillPublishCandidate(skill: Skill): SkillPublishCandidate { + return { + skillId: skill.skillId, + skillName: skill.skillName, + sourceId: skill.sourceId, + sourceName: skill.sourceName || skill.sourceId, + localPath: skill.sourcePath!.trim(), + }; +} + +export function listSkillPublishCandidates(skills: Skill[], selectedSkillIds: Set): SkillPublishCandidate[] { + const withLocalPath = skills.filter((skill) => typeof skill.sourcePath === "string" && skill.sourcePath.trim().length > 0); + const selected = withLocalPath.filter((skill) => selectedSkillIds.has(skill.skillId)); + const pool = selected.length > 0 ? selected : withLocalPath; + return pool + .map((skill) => toSkillPublishCandidate(skill)) + .sort((a, b) => a.skillName.localeCompare(b.skillName) || a.sourceName.localeCompare(b.sourceName)); } export function InstallerDashboard(): JSX.Element { @@ -279,23 +314,29 @@ export function InstallerDashboard(): JSX.Element { const [hookReport, setHookReport] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [startupWarnings, setStartupWarnings] = useState([]); - const [liveStatus, setLiveStatus] = useState("http-only"); - const [appVersion, setAppVersion] = useState("unknown"); - const [appUpdate, setAppUpdate] = useState(null); - const [updateChecking, setUpdateChecking] = useState(false); - const [skillChangeNotice, setSkillChangeNotice] = useState(null); - const [apiReachable, setApiReachable] = useState(false); - const [startupRunId, setStartupRunId] = useState(0); const [catalogLoading, setCatalogLoading] = useState(false); const [catalogLoadingMessage, setCatalogLoadingMessage] = useState(""); const [catalogLoadingProgress, setCatalogLoadingProgress] = useState(0); - const [catalogWarning, setCatalogWarning] = useState(""); const [selectionCustomized, setSelectionCustomized] = useState(false); const [sourceRepoUrl, setSourceRepoUrl] = useState(""); const [sourceName, setSourceName] = useState(""); const [sourceTransport, setSourceTransport] = useState<"https" | "ssh">("https"); const [sourceToken, setSourceToken] = useState(""); + const [sourcePublishDefaultMode, setSourcePublishDefaultMode] = useState<"direct-push" | "branch-only" | "branch-pr">("branch-pr"); + const [sourceDefaultBaseBranch, setSourceDefaultBaseBranch] = useState("main"); + const [sourceProviderHint, setSourceProviderHint] = useState<"github" | "gitlab" | "bitbucket" | "unknown">("unknown"); + const [sourceOfficialContributionEnabled, setSourceOfficialContributionEnabled] = useState(false); + const [editingSourceId, setEditingSourceId] = useState(""); + const [skillPublishPath, setSkillPublishPath] = useState(""); + const [skillPickerOpen, setSkillPickerOpen] = useState(false); + const [skillPickerQuery, setSkillPickerQuery] = useState(""); + const [skillPublishName, setSkillPublishName] = useState(""); + const [skillPublishMessage, setSkillPublishMessage] = useState(""); + const [skillPublishOverrideMode, setSkillPublishOverrideMode] = useState<"source-default" | PublishMode>("source-default"); + const [skillPublishOverrideBaseBranch, setSkillPublishOverrideBaseBranch] = useState(""); + const [skillValidationProfile, setSkillValidationProfile] = useState<"personal" | "official">("personal"); + const [skillValidationResult, setSkillValidationResult] = useState(null); + const [skillPublishResult, setSkillPublishResult] = useState(null); const [activeTab, setActiveTab] = useState("skills"); const [appearanceMode, setAppearanceMode] = useState(() => readStoredAppearance().mode); const [appearanceAccent, setAppearanceAccent] = useState(() => readStoredAppearance().accent); @@ -309,12 +350,11 @@ export function InstallerDashboard(): JSX.Element { const [hookSourceFilter, setHookSourceFilter] = useState("all"); const [hooksInstalledOnly, setHooksInstalledOnly] = useState(false); const [hookSelectionCustomized, setHookSelectionCustomized] = useState(false); + const [publishComposerOpen, setPublishComposerOpen] = useState(false); + const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); + const [publishOriginSourceId, setPublishOriginSourceId] = useState(undefined); const appearancePanelRef = useRef(null); const appearanceTriggerRef = useRef(null); - const refreshInstallationsRef = useRef<() => Promise>(async () => undefined); - const refreshCatalogRef = useRef<() => Promise>(async () => undefined); - const updateCheckRef = useRef<() => Promise>(async () => undefined); - const skillSnapshotRef = useRef | null>(null); const selectedTargetList = useMemo(() => Array.from(targets).sort(), [targets]); const selectedHookTargetList = useMemo( @@ -326,24 +366,10 @@ export function InstallerDashboard(): JSX.Element { const skillById = useMemo(() => new Map(skills.map((skill) => [skill.skillId, skill])), [skills]); const hookById = useMemo(() => new Map(hooks.map((hook) => [hook.hookId, hook])), [hooks]); const sourceNameById = useMemo(() => new Map(sources.map((source) => [source.id, source.name || source.id])), [sources]); - const liveStatusLabel = liveStatus === "connected" ? "Connected" : liveStatus === "reconnecting" ? "Reconnecting" : "HTTP-only"; - - function addStartupWarning(message: string): void { - setStartupWarnings((current) => (current.includes(message) ? current : [...current, message])); - } - - function jumpToSkillsTab(): void { - setActiveTab("skills"); - if (typeof window !== "undefined") { - window.scrollTo({ top: 0, behavior: "smooth" }); - } - } - - function retryStartup(): void { - setError(""); - setStartupWarnings([]); - setStartupRunId((value) => value + 1); - } + const selectedPublishSource = useMemo( + () => sources.find((source) => source.id === editingSourceId) || null, + [sources, editingSourceId], + ); const installedSkillIds = useMemo(() => { const names = new Set(); @@ -378,47 +404,110 @@ export function InstallerDashboard(): JSX.Element { const normalizedQuery = searchQuery.trim().toLowerCase(); const normalizedHookQuery = hookSearchQuery.trim().toLowerCase(); const sourceFilterOptions = useMemo(() => { - return Array.from(new Set(skills.map((skill) => skill.sourceId))).sort((a, b) => a.localeCompare(b)); - }, [skills]); - const scopeFilterOptions = useMemo(() => { - return Array.from(new Set(skills.map((skill) => (skill.scope || "").trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b)); - }, [skills]); - const categoryFilterOptions = useMemo(() => { - return Array.from(new Set(skills.map((skill) => skill.category).filter(Boolean))).sort((a, b) => a.localeCompare(b)); - }, [skills]); - const tagFilterOptions = useMemo(() => { - return Array.from(new Set(skills.flatMap((skill) => skill.tags || []).filter(Boolean))).sort((a, b) => a.localeCompare(b)); - }, [skills]); + return Array.from(new Set(sources.filter((source) => source.enabled).map((source) => source.id))).sort((a, b) => a.localeCompare(b)); + }, [sources]); const hookSourceFilterOptions = useMemo(() => { - return Array.from(new Set(hooks.map((hook) => hook.sourceId))).sort((a, b) => a.localeCompare(b)); - }, [hooks]); + return Array.from(new Set(sources.filter((source) => source.enabled).map((source) => source.id))).sort((a, b) => a.localeCompare(b)); + }, [sources]); + const skillPublishCandidates = useMemo(() => listSkillPublishCandidates(skills, selectedSkills), [skills, selectedSkills]); + const selectedSkillPublishCandidates = useMemo(() => { + return skills + .filter((skill) => selectedSkills.has(skill.skillId)) + .filter((skill) => typeof skill.sourcePath === "string" && skill.sourcePath.trim().length > 0) + .map((skill) => toSkillPublishCandidate(skill)) + .sort((a, b) => a.skillName.localeCompare(b.skillName) || a.sourceName.localeCompare(b.sourceName)); + }, [skills, selectedSkills]); + const publishBlockReason = useMemo(() => { + if (!editingSourceId || !skillPublishPath.trim()) { + return null; + } + return getOfficialPublishBlockReason(editingSourceId, skillPublishPath.trim(), publishOriginSourceId); + }, [editingSourceId, skillPublishPath, publishOriginSourceId, sources, skills]); + const normalizedSkillPickerQuery = skillPickerQuery.trim().toLowerCase(); + const visibleSkillPublishCandidates = useMemo(() => { + if (!normalizedSkillPickerQuery) return skillPublishCandidates; + return skillPublishCandidates.filter((candidate) => { + const haystack = `${candidate.skillName} ${candidate.sourceName} ${candidate.localPath}`.toLowerCase(); + return haystack.includes(normalizedSkillPickerQuery); + }); + }, [skillPublishCandidates, normalizedSkillPickerQuery]); - const visibleSkills = useMemo(() => { + const sourceScopedSkills = useMemo(() => { return skills.filter((skill) => { if (sourceFilter !== "all" && skill.sourceId !== sourceFilter) { return false; } - if (scopeFilter !== "all" && (skill.scope || "") !== scopeFilter) { + if (installedOnly && !installedSkillIds.has(skill.skillId)) { return false; } - if (categoryFilter !== "all" && skill.category !== categoryFilter) { + return true; + }); + }, [skills, sourceFilter, installedOnly, installedSkillIds]); + + const scopeFilterOptions = useMemo(() => { + return Array.from( + new Set( + sourceScopedSkills + .map((skill) => (skill.scope || "").trim().toLowerCase()) + .filter(Boolean), + ), + ).sort((a, b) => a.localeCompare(b)); + }, [sourceScopedSkills]); + + const categoryFilterOptions = useMemo(() => { + return Array.from( + new Set( + sourceScopedSkills + .map((skill) => (skill.category || "").trim().toLowerCase()) + .filter(Boolean), + ), + ).sort((a, b) => a.localeCompare(b)); + }, [sourceScopedSkills]); + + const tagFilterOptions = useMemo(() => { + const tags: string[] = []; + for (const skill of sourceScopedSkills) { + for (const tag of skill.tags || []) { + const normalized = tag.trim().toLowerCase(); + if (normalized) tags.push(normalized); + } + } + return Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b)); + }, [sourceScopedSkills]); + + const visibleSkills = useMemo(() => { + return sourceScopedSkills.filter((skill) => { + const skillScope = (skill.scope || "").trim().toLowerCase(); + const skillCategory = (skill.category || "").trim().toLowerCase(); + const skillTags = (skill.tags || []).map((tag) => tag.trim().toLowerCase()).filter(Boolean); + + if (scopeFilter !== "all" && skillScope !== scopeFilter) { return false; } - if (tagFilter !== "all" && !(skill.tags || []).includes(tagFilter)) { + if (categoryFilter !== "all" && skillCategory !== categoryFilter) { return false; } - if (installedOnly && !installedSkillIds.has(skill.skillId)) { + if (tagFilter !== "all" && !skillTags.includes(tagFilter)) { return false; } if (!normalizedQuery) { return true; } const resourceText = skill.resources.map((item) => `${item.type} ${item.path}`).join(" "); - const tagText = (skill.tags || []).join(" "); - const haystack = `${skill.skillId} ${skill.description} ${skill.category} ${skill.scope || ""} ${skill.subcategory || ""} ${tagText} ${resourceText}`.toLowerCase(); + const tagsText = skillTags.join(" "); + const haystack = `${skill.skillId} ${skill.description} ${skill.category} ${skill.scope || ""} ${tagsText} ${resourceText}`.toLowerCase(); return haystack.includes(normalizedQuery); }); - }, [skills, sourceFilter, scopeFilter, categoryFilter, tagFilter, installedOnly, installedSkillIds, normalizedQuery]); + }, [sourceScopedSkills, scopeFilter, categoryFilter, tagFilter, normalizedQuery]); + + const selectedVisibleSkillPublishCandidates = useMemo(() => { + return visibleSkills + .filter((skill) => selectedSkills.has(skill.skillId)) + .filter((skill) => typeof skill.sourcePath === "string" && skill.sourcePath.trim().length > 0) + .map((skill) => toSkillPublishCandidate(skill)) + .sort((a, b) => a.skillName.localeCompare(b.skillName) || a.sourceName.localeCompare(b.sourceName)); + }, [visibleSkills, selectedSkills]); + const quickPublishCandidate = selectedVisibleSkillPublishCandidates.length === 1 ? selectedVisibleSkillPublishCandidates[0] : null; const filteredCategorized = useMemo(() => { const byCategory = new Map(); @@ -447,7 +536,7 @@ export function InstallerDashboard(): JSX.Element { }, [hooks, hookSourceFilter, hooksInstalledOnly, installedHookIds, normalizedHookQuery]); async function fetchSources(): Promise { - const res = await apiFetch("/api/v1/sources"); + const res = await fetch("/api/v1/sources"); const payload = (await res.json()) as { sources?: Source[]; error?: string }; if (!res.ok) { throw new Error(asErrorMessage(payload, "Failed to load sources.")); @@ -457,7 +546,7 @@ export function InstallerDashboard(): JSX.Element { async function refreshSources(runRefresh = false): Promise { if (runRefresh) { - await apiFetch("/api/v1/sources/refresh-all", { + await fetch("/api/v1/sources/refresh-all", { method: "POST", headers: { "Content-Type": "application/json", @@ -478,58 +567,13 @@ export function InstallerDashboard(): JSX.Element { setCatalogLoadingProgress(58); setCatalogLoadingMessage("Loading refreshed skills catalog…"); } - const res = await apiFetch(`/api/v1/catalog/skills${runRefresh ? "?refresh=true" : ""}`); - const payload = (await res.json()) as { - skills?: Skill[]; - stale?: boolean; - catalogSource?: "live" | "cache" | "snapshot"; - staleReason?: string; - error?: string; - }; + const res = await fetch("/api/v1/catalog/skills"); + const payload = (await res.json()) as { skills?: Skill[]; error?: string }; if (!res.ok) { throw new Error(asErrorMessage(payload, "Failed to load skills catalog.")); } setCatalogLoadingProgress(88); - const nextSkills = Array.isArray(payload.skills) ? payload.skills : []; - setSkills(nextSkills); - const nextSnapshot = buildSkillSnapshot(nextSkills); - const previousSnapshot = skillSnapshotRef.current; - if (previousSnapshot && previousSnapshot.size > 0) { - let added = 0; - let removed = 0; - let changed = 0; - - for (const skillId of nextSnapshot.keys()) { - if (!previousSnapshot.has(skillId)) { - added += 1; - continue; - } - if (previousSnapshot.get(skillId) !== nextSnapshot.get(skillId)) { - changed += 1; - } - } - for (const skillId of previousSnapshot.keys()) { - if (!nextSnapshot.has(skillId)) { - removed += 1; - } - } - - if (added > 0 || removed > 0 || changed > 0) { - setSkillChangeNotice({ - added, - removed, - changed, - checkedAt: new Date().toISOString(), - }); - } - } - skillSnapshotRef.current = nextSnapshot; - if (payload.stale) { - const source = payload.catalogSource || "fallback"; - setCatalogWarning(payload.staleReason || `Catalog is currently served from ${source}.`); - } else { - setCatalogWarning(""); - } + setSkills(Array.isArray(payload.skills) ? payload.skills : []); setCatalogLoadingProgress(100); setCatalogLoadingMessage("Skills catalog is ready."); } finally { @@ -542,7 +586,7 @@ export function InstallerDashboard(): JSX.Element { } async function fetchHooks(): Promise { - const res = await apiFetch("/api/v1/catalog/hooks"); + const res = await fetch("/api/v1/catalog/hooks"); const payload = (await res.json()) as { hooks?: Hook[]; error?: string }; if (!res.ok) { throw new Error(asErrorMessage(payload, "Failed to load hooks catalog.")); @@ -551,7 +595,7 @@ export function InstallerDashboard(): JSX.Element { } async function fetchDiscoveredTargets(): Promise { - const res = await apiFetch("/api/v1/targets/discovered"); + const res = await fetch("/api/v1/targets/discovered"); const payload = (await res.json()) as { targets?: Target[]; error?: string }; if (!res.ok) { throw new Error(asErrorMessage(payload, "Failed to discover targets.")); @@ -565,19 +609,6 @@ export function InstallerDashboard(): JSX.Element { } } - async function fetchApiVersion(): Promise { - const res = await apiFetch("/api/v1/health"); - const payload = (await res.json()) as HealthPayload; - if (!res.ok) { - setApiReachable(false); - throw new Error(asErrorMessage(payload, "Failed to load API health.")); - } - const version = typeof payload.version === "string" && payload.version.trim() ? payload.version.trim() : "unknown"; - setApiReachable(true); - setAppVersion(version); - setAppUpdate(payload.update && typeof payload.update === "object" ? payload.update : null); - } - async function fetchInstallations(): Promise { if (scope === "project" && !trimmedProjectPath) { setInstallations([]); @@ -590,7 +621,7 @@ export function InstallerDashboard(): JSX.Element { targets: targetKey, }); - const res = await apiFetch(`/api/v1/installations?${query.toString()}`); + const res = await fetch(`/api/v1/installations?${query.toString()}`); const payload = (await res.json()) as { installations?: InstallationRow[]; error?: string }; if (!res.ok) { throw new Error(asErrorMessage(payload, "Failed to load installed state.")); @@ -615,7 +646,7 @@ export function InstallerDashboard(): JSX.Element { targets: selectedHookTargetList.join(","), }); - const res = await apiFetch(`/api/v1/hooks/installations?${query.toString()}`); + const res = await fetch(`/api/v1/hooks/installations?${query.toString()}`); const payload = (await res.json()) as { installations?: HookInstallationRow[]; error?: string }; if (!res.ok) { throw new Error(asErrorMessage(payload, "Failed to load installed hook state.")); @@ -623,19 +654,6 @@ export function InstallerDashboard(): JSX.Element { setHookInstallations(Array.isArray(payload.installations) ? payload.installations : []); } - refreshInstallationsRef.current = async () => { - await Promise.all([fetchInstallations(), fetchHookInstallations()]); - }; - - refreshCatalogRef.current = async () => { - await fetchSources(); - await Promise.all([fetchSkills(), fetchHooks()]); - }; - - updateCheckRef.current = async () => { - await runUpdateCheck(); - }; - function setSkillsSelection(skillIds: string[], shouldSelect: boolean): void { setSelectionCustomized(true); setSelectedSkills((current) => { @@ -724,7 +742,7 @@ export function InstallerDashboard(): JSX.Element { }) .filter((item): item is { sourceId: string; skillName: string; skillId: string } => Boolean(item)); - const res = await apiFetch(`/api/v1/${operation}/apply`, { + const res = await fetch(`/api/v1/${operation}/apply`, { method: "POST", headers: { "Content-Type": "application/json", @@ -784,7 +802,7 @@ export function InstallerDashboard(): JSX.Element { }) .filter((item): item is { sourceId: string; hookName: string; hookId: string } => Boolean(item)); - const res = await apiFetch(`/api/v1/hooks/${operation}/apply`, { + const res = await fetch(`/api/v1/hooks/${operation}/apply`, { method: "POST", headers: { "Content-Type": "application/json", @@ -818,13 +836,17 @@ export function InstallerDashboard(): JSX.Element { setBusy(true); setError(""); try { - const res = await apiFetch("/api/v1/sources", { + const res = await fetch("/api/v1/sources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: sourceName.trim() || undefined, repoUrl: sourceRepoUrl.trim(), transport: sourceTransport, + publishDefaultMode: sourcePublishDefaultMode, + defaultBaseBranch: sourceDefaultBaseBranch.trim() || undefined, + providerHint: sourceProviderHint, + officialContributionEnabled: sourceOfficialContributionEnabled, token: sourceToken.trim() || undefined, }), }); @@ -835,6 +857,10 @@ export function InstallerDashboard(): JSX.Element { setSourceRepoUrl(""); setSourceName(""); setSourceToken(""); + setSourcePublishDefaultMode("branch-pr"); + setSourceDefaultBaseBranch("main"); + setSourceProviderHint("unknown"); + setSourceOfficialContributionEnabled(false); await fetchSources(); await fetchSkills(true); await fetchHooks(); @@ -845,14 +871,12 @@ export function InstallerDashboard(): JSX.Element { } } - async function runUpdateCheck(): Promise { - if (updateChecking) { - return; - } - setUpdateChecking(true); + async function refreshSource(sourceId?: string): Promise { + setBusy(true); setError(""); try { - const res = await apiFetch("/api/v1/sources/refresh-all", { + const endpoint = sourceId ? `/api/v1/sources/${sourceId}/refresh` : "/api/v1/sources/refresh-all"; + const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), @@ -861,35 +885,58 @@ export function InstallerDashboard(): JSX.Element { if (!res.ok) { throw new Error(asErrorMessage(payload, "Source refresh failed.")); } - await Promise.all([fetchApiVersion(), fetchSources(), fetchSkills(), fetchHooks(), fetchInstallations(), fetchHookInstallations()]); + await fetchSources(); + await fetchSkills(); + await fetchHooks(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { - setUpdateChecking(false); + setBusy(false); } } - async function refreshSource(sourceId?: string): Promise { - if (!sourceId) { - await runUpdateCheck(); + async function deleteSource(source: Source): Promise { + setBusy(true); + setError(""); + try { + const res = await fetch(`/api/v1/sources/${source.id}`, { method: "DELETE" }); + const payload = await res.json(); + if (!res.ok) { + throw new Error(asErrorMessage(payload, "Source removal failed.")); + } + await fetchSources(); + await fetchSkills(); + await fetchHooks(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function saveSourcePublishSettings(): Promise { + if (!editingSourceId) { + setError("Select a source to update."); return; } setBusy(true); setError(""); try { - const endpoint = `/api/v1/sources/${sourceId}/refresh`; - const res = await apiFetch(endpoint, { - method: "POST", + const res = await fetch(`/api/v1/sources/${editingSourceId}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ + publishDefaultMode: sourcePublishDefaultMode, + defaultBaseBranch: sourceDefaultBaseBranch.trim() || undefined, + providerHint: sourceProviderHint, + officialContributionEnabled: sourceOfficialContributionEnabled, + }), }); const payload = await res.json(); if (!res.ok) { - throw new Error(asErrorMessage(payload, "Source refresh failed.")); + throw new Error(asErrorMessage(payload, "Failed to update source publish settings.")); } await fetchSources(); - await fetchSkills(); - await fetchHooks(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -897,18 +944,240 @@ export function InstallerDashboard(): JSX.Element { } } - async function deleteSource(source: Source): Promise { + function normalizeLocalPath(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); + } + + function resolveSkillForPath(localPath: string): Skill | undefined { + const normalized = normalizeLocalPath(localPath); + return skills.find((skill) => normalizeLocalPath(skill.sourcePath || "") === normalized); + } + + function isOfficialSkillBundle(localPath: string, originSourceId?: string): boolean { + const normalized = normalizeLocalPath(localPath); + const originSource = originSourceId ? sources.find((source) => source.id === originSourceId) : undefined; + if (originSource?.official) { + return true; + } + + const matchedSkill = resolveSkillForPath(normalized); + if (matchedSkill) { + const matchedSource = sources.find((source) => source.id === matchedSkill.sourceId); + if (matchedSource?.official) { + return true; + } + } + + return normalized.includes("/official-skills/"); + } + + function getOfficialPublishBlockReason(sourceId: string, localPath: string, originSourceId?: string): string | null { + const targetSource = sources.find((source) => source.id === sourceId); + if (!targetSource) { + return "Select a valid target source first."; + } + if (targetSource.official) { + return null; + } + if (isOfficialSkillBundle(localPath, originSourceId)) { + return "Official skills can only be published to official sources."; + } + return null; + } + + function preparePublishOverlay(params: { localPath: string; skillName?: string; originSourceId?: string }): void { + setSkillPublishPath(params.localPath.trim()); + if (params.skillName) { + setSkillPublishName(params.skillName); + } + setPublishOriginSourceId(params.originSourceId); + setPublishComposerOpen(true); + setPublishAdvancedOpen(false); + } + + async function runSkillValidation(): Promise { + if (!skillPublishPath.trim()) { + setError("Set a local skill path first."); + return; + } setBusy(true); setError(""); + setSkillValidationResult(null); try { - const res = await apiFetch(`/api/v1/sources/${source.id}`, { method: "DELETE" }); - const payload = await res.json(); + const res = await fetch("/api/v1/skills/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: skillPublishPath.trim(), + skillName: skillPublishName.trim() || undefined, + profile: skillValidationProfile, + }), + }); + const payload = (await res.json()) as { validation?: SkillValidationResult; error?: string }; if (!res.ok) { - throw new Error(asErrorMessage(payload, "Source removal failed.")); + throw new Error(asErrorMessage(payload, "Skill validation failed.")); + } + if (payload.validation) { + setSkillValidationResult(payload.validation); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function publishSkillBundleRequest(params: { + sourceId: string; + path: string; + originSourceId?: string; + skillName?: string; + message?: string; + overrideMode?: PublishMode; + overrideBaseBranch?: string; + }): Promise { + const blockReason = getOfficialPublishBlockReason(params.sourceId, params.path, params.originSourceId); + if (blockReason) { + throw new Error(blockReason); + } + + const res = await fetch("/api/v1/skills/publish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourceId: params.sourceId, + path: params.path, + skillName: params.skillName, + message: params.message, + overrideMode: params.overrideMode, + overrideBaseBranch: params.overrideBaseBranch, + }), + }); + const payload = (await res.json()) as { result?: SkillPublishResult; error?: string }; + if (!res.ok) { + throw new Error(asErrorMessage(payload, "Skill publish failed.")); + } + if (payload.result) { + setSkillPublishResult(payload.result); + } + await fetchSources(); + await fetchSkills(); + setPublishComposerOpen(false); + setPublishAdvancedOpen(false); + } + + async function runSkillPublish(): Promise { + if (!editingSourceId) { + setError("Select a source for publishing."); + return; + } + if (!skillPublishPath.trim()) { + setError("Set a local skill path first."); + return; + } + setBusy(true); + setError(""); + setSkillPublishResult(null); + try { + await publishSkillBundleRequest({ + sourceId: editingSourceId, + path: skillPublishPath.trim(), + originSourceId: publishOriginSourceId, + skillName: skillPublishName.trim() || undefined, + message: skillPublishMessage.trim() || undefined, + overrideMode: skillPublishOverrideMode !== "source-default" ? skillPublishOverrideMode : undefined, + overrideBaseBranch: skillPublishOverrideBaseBranch.trim() || undefined, + }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function runQuickPublishFromCatalogSelection(): Promise { + if (selectedVisibleSkillPublishCandidates.length === 0) { + setError("Select one visible local catalog skill first."); + return; + } + if (selectedVisibleSkillPublishCandidates.length > 1) { + setError("Select exactly one visible local catalog skill to quick publish."); + return; + } + + const candidate = selectedVisibleSkillPublishCandidates[0]; + setError(""); + setSkillPublishResult(null); + preparePublishOverlay({ + localPath: candidate.localPath, + skillName: candidate.skillName, + originSourceId: candidate.sourceId, + }); + } + + async function runPickFolderAndPublish(): Promise { + setBusy(true); + setError(""); + setSkillPublishResult(null); + try { + const pickerRes = await fetch("/api/v1/skills/pick", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initialPath: skillPublishPath.trim() || undefined, + }), + }); + const pickerPayload = (await pickerRes.json()) as { path?: string; error?: string }; + if (!pickerRes.ok) { + throw new Error(asErrorMessage(pickerPayload, "Skill picker failed.")); + } + const pickedPath = pickerPayload.path?.trim(); + if (!pickedPath) { + return; + } + const pickedSkill = resolveSkillForPath(pickedPath); + preparePublishOverlay({ + localPath: pickedPath, + skillName: pickedSkill?.skillName || skillPublishName.trim() || undefined, + originSourceId: pickedSkill?.sourceId, + }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function runOfficialContribution(): Promise { + if (!skillPublishPath.trim()) { + setError("Set a local skill path first."); + return; + } + setBusy(true); + setError(""); + setSkillPublishResult(null); + try { + const res = await fetch("/api/v1/skills/contribute-official", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourceId: editingSourceId || undefined, + path: skillPublishPath.trim(), + skillName: skillPublishName.trim() || undefined, + message: skillPublishMessage.trim() || undefined, + }), + }); + const payload = (await res.json()) as { result?: SkillPublishResult; error?: string }; + if (!res.ok) { + throw new Error(asErrorMessage(payload, "Official contribution failed.")); + } + if (payload.result) { + setSkillPublishResult(payload.result); } await fetchSources(); await fetchSkills(); - await fetchHooks(); + setPublishComposerOpen(false); + setPublishAdvancedOpen(false); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -916,11 +1185,50 @@ export function InstallerDashboard(): JSX.Element { } } + async function pickSkillPublishPath(): Promise { + setBusy(true); + setError(""); + try { + const res = await fetch("/api/v1/skills/pick", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initialPath: skillPublishPath.trim() || undefined, + }), + }); + const payload = (await res.json()) as { path?: string; error?: string }; + if (!res.ok) { + throw new Error(asErrorMessage(payload, "Skill picker failed.")); + } + if (payload.path) { + const pickedPath = payload.path.trim(); + setSkillPublishPath(pickedPath); + const matched = resolveSkillForPath(pickedPath); + setPublishOriginSourceId(matched?.sourceId); + if (matched?.skillName) { + setSkillPublishName(matched.skillName); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + function applySkillPublishCandidate(candidate: SkillPublishCandidate): void { + setError(""); + setSkillPublishPath(candidate.localPath); + setSkillPublishName(candidate.skillName); + setPublishOriginSourceId(candidate.sourceId); + setSkillPickerOpen(false); + } + async function pickProjectPath(): Promise { setBusy(true); setError(""); try { - const res = await apiFetch("/api/v1/projects/pick", { + const res = await fetch("/api/v1/projects/pick", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -941,94 +1249,68 @@ export function InstallerDashboard(): JSX.Element { } } - useEffect(() => { - let cancelled = false; - setStartupWarnings([]); + async function mountProjectInContainer(): Promise { + if (!trimmedProjectPath) { + setError("Set a project path first."); + return; + } + setBusy(true); setError(""); - setApiReachable(false); - - void runStartupTasks( - [ - { id: "API health", critical: false, run: async () => fetchApiVersion() }, - { id: "Targets discovery", critical: true, run: async () => fetchDiscoveredTargets() }, - { id: "Source loading", critical: true, run: async () => fetchSources() }, - { id: "Skills catalog", critical: false, run: async () => fetchSkills() }, - { id: "Hooks catalog", critical: false, run: async () => fetchHooks() }, - ], - { attempts: 3, initialDelayMs: 200 }, - ) - .then((summary) => { - if (cancelled) { - return; - } - for (const warning of summary.warnings) { - addStartupWarning(warning); - } - if (summary.errors.length > 0) { - setError(summary.errors.join(" | ")); - } - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : String(err)); - } + try { + const res = await fetch("/api/v1/container/mount-project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectPath: trimmedProjectPath, + confirm: true, + }), }); + const payload = await res.json(); + if (!res.ok) { + throw new Error(asErrorMessage(payload, "Container mount failed.")); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } - return () => { - cancelled = true; - }; - }, [startupRunId]); + useEffect(() => { + fetchDiscoveredTargets().catch((err) => setError(err instanceof Error ? err.message : String(err))); + fetchSources() + .then(async () => { + await fetchSkills(true); + await fetchHooks(); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, []); useEffect(() => { - if (!apiReachable) { + if (sources.length === 0) { + setEditingSourceId(""); return; } - setSelectionCustomized(false); - setHookSelectionCustomized(false); - Promise.all([fetchInstallations(), fetchHookInstallations()]).catch((err) => - addStartupWarning(`Installations refresh: ${err instanceof Error ? err.message : String(err)}`), - ); - }, [scope, trimmedProjectPath, targetKey, selectedHookTargetList.join(","), apiReachable]); + if (!editingSourceId || !sources.some((source) => source.id === editingSourceId)) { + setEditingSourceId(sources[0].id); + } + }, [sources, editingSourceId]); useEffect(() => { - const stopRealtime = startRealtimeClient({ - onStatusChange: (status) => setLiveStatus(status), - onEvent: (event: RealtimeEvent) => { - if (event.type === "operation.completed" || event.type === "operation.failed") { - void refreshInstallationsRef.current().catch((err) => - addStartupWarning(`Installations refresh: ${err instanceof Error ? err.message : String(err)}`), - ); - } - if (event.type === "source.refresh.completed" || event.type === "source.refresh.failed") { - void refreshCatalogRef.current().catch((err) => - addStartupWarning(`Catalog refresh: ${err instanceof Error ? err.message : String(err)}`), - ); - void refreshInstallationsRef.current().catch((err) => - addStartupWarning(`Installations refresh: ${err instanceof Error ? err.message : String(err)}`), - ); - } - }, - onError: (message) => addStartupWarning(message), - }); - - return () => { - stopRealtime(); - }; - }, []); + if (!editingSourceId) return; + const selected = sources.find((source) => source.id === editingSourceId); + if (!selected) return; + setSourcePublishDefaultMode(selected.publishDefaultMode || "branch-pr"); + setSourceDefaultBaseBranch(selected.defaultBaseBranch || "main"); + setSourceProviderHint(selected.providerHint || "unknown"); + setSourceOfficialContributionEnabled(Boolean(selected.officialContributionEnabled)); + }, [editingSourceId, sources]); useEffect(() => { - if (!apiReachable || updateCheckMinutes <= 0) { - return; - } - const timer = window.setInterval(() => { - void updateCheckRef.current().catch((err) => - addStartupWarning(`Scheduled update check: ${err instanceof Error ? err.message : String(err)}`), - ); - }, updateCheckMinutes * 60 * 1000); - return () => { - window.clearInterval(timer); - }; - }, [apiReachable]); + setSelectionCustomized(false); + setHookSelectionCustomized(false); + Promise.all([fetchInstallations(), fetchHookInstallations()]).catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, [scope, trimmedProjectPath, targetKey, selectedHookTargetList.join(",")]); useEffect(() => { if (selectionCustomized) return; @@ -1047,6 +1329,27 @@ export function InstallerDashboard(): JSX.Element { } }, [sourceFilter, sourceFilterOptions]); + useEffect(() => { + if (scopeFilter === "all") return; + if (!scopeFilterOptions.includes(scopeFilter)) { + setScopeFilter("all"); + } + }, [scopeFilter, scopeFilterOptions]); + + useEffect(() => { + if (categoryFilter === "all") return; + if (!categoryFilterOptions.includes(categoryFilter)) { + setCategoryFilter("all"); + } + }, [categoryFilter, categoryFilterOptions]); + + useEffect(() => { + if (tagFilter === "all") return; + if (!tagFilterOptions.includes(tagFilter)) { + setTagFilter("all"); + } + }, [tagFilter, tagFilterOptions]); + useEffect(() => { if (hookSourceFilter === "all") return; if (!hookSourceFilterOptions.includes(hookSourceFilter)) { @@ -1054,6 +1357,29 @@ export function InstallerDashboard(): JSX.Element { } }, [hookSourceFilter, hookSourceFilterOptions]); + useEffect(() => { + if (!skillPickerOpen) return; + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + setSkillPickerOpen(false); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [skillPickerOpen]); + + useEffect(() => { + if (!publishComposerOpen && !publishAdvancedOpen) return; + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + setPublishComposerOpen(false); + setPublishAdvancedOpen(false); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [publishComposerOpen, publishAdvancedOpen]); + useEffect(() => { if (catalogLoading || skills.length === 0) return; setSelectedSkills((current) => { @@ -1147,15 +1473,12 @@ export function InstallerDashboard(): JSX.Element { }, [selectedHooks, hookById]); const selectedUnknownHookCount = Math.max(0, selectedHooks.size - selectedKnownHookCount); const installedHookCount = installedHookIds.size; - const apiUnavailable = error.includes("Cannot reach ICA API at"); return (
- +

ICA COMMAND CENTER

Multi-source

Skills & Hooks Dashboard

@@ -1164,69 +1487,14 @@ export function InstallerDashboard(): JSX.Element { {sources.length} sources {installedSkillCount} skills installed {installedHookCount} hooks installed - Live updates: {liveStatusLabel}
- {apiUnavailable && ( -
-

API not reachable

-

{error}

-
- -
-
- )} - {!apiUnavailable && error && ( + {error && (
Action needed: {error}
)} - {!apiUnavailable && startupWarnings.length > 0 && ( -
- Startup warnings: {startupWarnings.join(" | ")} -
- )} - {!apiUnavailable && appUpdate?.updateAvailable && ( -
-
- New ICA app version available - - Current v{appVersion} • Latest v{appUpdate.latestVersion} - -
-
- {appUpdate.latestReleaseUrl && ( - - View release - - )} - -
-
- )} - {!apiUnavailable && skillChangeNotice && ( -
-
- Skills repository changed - - +{skillChangeNotice.added} added • -{skillChangeNotice.removed} removed • {skillChangeNotice.changed} updated - -
-
- - -
-
- )} {catalogLoading && (
@@ -1252,22 +1520,22 @@ export function InstallerDashboard(): JSX.Element { Skills
+ +
+
+
+

Skill Publishing

+

Quick publish from selected skills or picked folders. Target and advanced settings appear only in overlays.

+
+ {skillPublishCandidates.length} local bundles +
+ +
+ + +
+

+ {selectedVisibleSkillPublishCandidates.length === 0 && + "Select one visible local catalog skill, then publish it in one click."} + {selectedVisibleSkillPublishCandidates.length === 1 && + `Ready to publish "${selectedVisibleSkillPublishCandidates[0].skillName}" from selected catalog skill.`} + {selectedVisibleSkillPublishCandidates.length > 1 && + "Multiple visible local catalog skills are selected. Keep one selected to enable one-click publish."} +

+ + {selectedPublishSource && ( +

+ Last target: {selectedPublishSource.name || selectedPublishSource.id} • flow{" "} + {selectedPublishSource.publishDefaultMode || "branch-pr"}. +

+ )} + + {skillValidationResult && ( +
+ Validation Result ({skillValidationResult.profile}) +
{JSON.stringify(skillValidationResult, null, 2)}
+
+ )} + {skillPublishResult && ( +
+ Publish Result +
{JSON.stringify(skillPublishResult, null, 2)}
+
+ )} +
@@ -1412,7 +1729,6 @@ export function InstallerDashboard(): JSX.Element { {!catalogLoading && selectedUnknownSkillCount > 0 ? ` • ${selectedUnknownSkillCount} unavailable` : ""} {!catalogLoading && normalizedQuery ? ` • ${filteredSkillsCount} shown` : ""}

- {catalogWarning &&

{catalogWarning}

}
- {scopeFilterOptions.map((scopeId) => ( + {scopeFilterOptions.map((scopeValue) => ( ))}
@@ -1482,14 +1802,14 @@ export function InstallerDashboard(): JSX.Element { > all - {categoryFilterOptions.map((categoryId) => ( + {categoryFilterOptions.map((categoryValue) => ( ))} @@ -1497,17 +1817,21 @@ export function InstallerDashboard(): JSX.Element {
Tag
- - {tagFilterOptions.map((tagId) => ( + {tagFilterOptions.map((tagValue) => ( ))}
@@ -1556,21 +1880,16 @@ export function InstallerDashboard(): JSX.Element {
{sourceNameById.get(skill.sourceId) || skill.sourceId} - {isInstalled && installed} -
-
-

{skill.description}

- {(skill.scope || skill.subcategory || (skill.tags && skill.tags.length > 0)) && ( -
- {skill.scope && scope: {skill.scope}} - {skill.subcategory && sub: {skill.subcategory}} - {(skill.tags || []).slice(0, 3).map((tag) => ( + {skill.scope && {titleCase(skill.scope)}} + {(skill.tags || []).slice(0, 2).map((tag) => ( #{tag} ))} + {isInstalled && installed}
- )} + +

{skill.description}

{skill.resources.length > 0 && (
Resources ({skill.resources.length}) @@ -1747,9 +2066,15 @@ export function InstallerDashboard(): JSX.Element { roots: {source.skillsRoot || "(no /skills)"} / {source.hooksRoot || "(no /hooks)"} + + publish: {source.publishDefaultMode} / base {source.defaultBaseBranch || "main"} / provider {source.providerHint} + {source.lastSyncAt ? `synced ${new Date(source.lastSyncAt).toLocaleString()}` : "never synced"} {source.lastError && {source.lastError}}
+ @@ -1762,6 +2087,49 @@ export function InstallerDashboard(): JSX.Element { ))}
+ +

Source Publish Settings

+ Selected Source + + Default Publish Mode + + Default Base Branch + setSourceDefaultBaseBranch(event.target.value)} + /> + Provider Hint + + + + +

Add Repository

Source Name )} -
- - -
+ Default Publish Mode (new source) + + Default Base Branch (new source) + setSourceDefaultBaseBranch(event.target.value)} + /> + Provider Hint (new source) + + + +
@@ -1843,6 +2237,9 @@ export function InstallerDashboard(): JSX.Element { + )} @@ -1900,19 +2297,261 @@ export function InstallerDashboard(): JSX.Element { )} -
-
- Intelligent Code Agents - v{appVersion} + {publishComposerOpen && ( +
setPublishComposerOpen(false)}> +
event.stopPropagation()} + > +
+
+

Choose Publish Target

+

Select the source target in this overlay, then publish.

+
+ +
+ +

+ Bundle path: {skillPublishPath || "(not set)"} +

+ {publishBlockReason &&

{publishBlockReason}

} +
+ + + +
+
+
+ )} + + {publishAdvancedOpen && ( +
setPublishAdvancedOpen(false)}> +
event.stopPropagation()} + > +
+
+

Advanced Settings

+

Adjust bundle path, metadata, validation, and contribution options here.

+
+ +
+ +
+ + + + + + + + + + + +
+ + {publishBlockReason &&

{publishBlockReason}

} +
+ + + +
+
+ +
+
+
+ )} + + {skillPickerOpen && ( +
setSkillPickerOpen(false)}> +
event.stopPropagation()} + > +
+
+

Select Local Skill Bundle

+

Pick a bundle discovered in your local catalog, then publish it without retyping paths.

+
+ +
+ setSkillPickerQuery(event.target.value)} + aria-label="Search local skill bundles" + /> +
+ {visibleSkillPublishCandidates.length === 0 ? ( +
No local bundles match this search.
+ ) : ( + visibleSkillPublishCandidates.map((candidate) => ( + + )) + )} +
+
- - View on GitHub - -
+ )} ); } diff --git a/src/installer-dashboard/web/src/styles.css b/src/installer-dashboard/web/src/styles.css index 5307683..e0a94aa 100644 --- a/src/installer-dashboard/web/src/styles.css +++ b/src/installer-dashboard/web/src/styles.css @@ -33,8 +33,6 @@ --radius-lg: 14px; --radius-md: 10px; --shadow: 0 4px 14px rgba(15, 23, 42, 0.05); - --btn-height: 2.75rem; - --btn-inline-height: 2.45rem; --space-1: 0.375rem; --space-2: 0.5rem; --space-3: 0.75rem; @@ -345,7 +343,7 @@ body[data-mode="dark"][data-accent="amber"] { strong, b { - font-weight: 600; + font-weight: 400; } body { @@ -353,8 +351,6 @@ body { color: var(--text); background: radial-gradient(circle at 12% -10%, var(--bg-wash) 0%, transparent 34%), - radial-gradient(circle at 90% 8%, color-mix(in srgb, var(--accent) 8%, transparent) 0%, transparent 28%), - repeating-linear-gradient(135deg, transparent 0 16px, color-mix(in srgb, var(--accent) 2%, transparent) 16px 17px), linear-gradient(180deg, var(--bg-top) 0%, var(--bg) 100%); font-family: "Open Sans", "Segoe UI", sans-serif; font-weight: 300; @@ -373,34 +369,18 @@ input[type="radio"]:focus-visible { } .shell { - max-width: 1500px; + max-width: 1440px; margin: 0 auto; padding: var(--space-8) var(--space-6) var(--space-9); } .hero { background: var(--surface); - border: 1px solid color-mix(in srgb, var(--line) 80%, transparent); - border-radius: 18px; - padding: var(--space-6); - box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + border: 1px solid var(--line); + border-radius: 12px; + padding: var(--space-5) var(--space-6); + box-shadow: none; margin-bottom: var(--space-5); - position: relative; - overflow: hidden; -} - -.hero::after { - content: ""; - position: absolute; - right: -48px; - top: -48px; - width: 190px; - height: 190px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); - background: color-mix(in srgb, var(--accent-soft) 55%, transparent); - opacity: 0.42; - pointer-events: none; } .hero-topline { @@ -412,43 +392,21 @@ input[type="radio"]:focus-visible { .eyebrow { margin: 0; - font-size: 0.76rem; + font-size: 0.72rem; text-transform: uppercase; - letter-spacing: 0.15em; + letter-spacing: 0.12em; color: var(--text-muted); - font-weight: 600; -} - -.eyebrow-link { - border: 0; - background: transparent; - padding: 0; - cursor: pointer; - display: inline-flex; - align-items: center; - text-decoration: none; - transition: color 140ms ease, transform 140ms ease; -} - -.eyebrow-link:hover { - color: var(--accent-ink); - transform: translateY(-1px); -} - -.eyebrow-link:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 3px; - border-radius: 4px; + font-weight: 400; } .stamp { margin: 0; - border-radius: 999px; + border-radius: 8px; border: 1px solid color-mix(in srgb, var(--line) 76%, transparent); color: var(--text-soft); - background: color-mix(in srgb, var(--surface-soft) 50%, transparent); - padding: 0.2rem 0.58rem; - font-size: 0.72rem; + background: transparent; + padding: 0.14rem 0.46rem; + font-size: 0.7rem; } .hero h1 { @@ -483,28 +441,6 @@ input[type="radio"]:focus-visible { font-size: 0.7rem; } -.live-badge { - font-weight: 600; -} - -.live-badge-connected { - border-color: color-mix(in srgb, #28a26f 50%, var(--line)); - color: color-mix(in srgb, #28a26f 72%, var(--text)); - background: color-mix(in srgb, #28a26f 10%, transparent); -} - -.live-badge-reconnecting { - border-color: color-mix(in srgb, #d08c2f 55%, var(--line)); - color: color-mix(in srgb, #d08c2f 72%, var(--text)); - background: color-mix(in srgb, #d08c2f 11%, transparent); -} - -.live-badge-http-only { - border-color: color-mix(in srgb, #7f8a97 58%, var(--line)); - color: color-mix(in srgb, #7f8a97 78%, var(--text)); - background: color-mix(in srgb, #7f8a97 10%, transparent); -} - .status { border-radius: 12px; padding: var(--space-4) var(--space-5); @@ -514,26 +450,6 @@ input[type="radio"]:focus-visible { background: var(--surface); } -.startup-empty-state { - margin: 0 0 var(--space-5); - display: grid; - gap: var(--space-3); - border-color: var(--status-info-border); - background: var(--status-info-bg); -} - -.startup-empty-state h2 { - margin: 0; - font-size: 1.02rem; - font-weight: 400; -} - -.startup-empty-state-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - .status-error { border-color: var(--status-error-border); background: var(--status-error-bg); @@ -573,49 +489,6 @@ input[type="radio"]:focus-visible { transition: width 170ms ease; } -.update-banner { - margin: 0 0 var(--space-5); - border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); - border-radius: 14px; - padding: var(--space-4) var(--space-5); - background: - linear-gradient(110deg, color-mix(in srgb, var(--accent-soft) 88%, transparent) 0%, color-mix(in srgb, var(--surface) 80%, transparent) 100%), - repeating-linear-gradient(135deg, transparent 0 14px, color-mix(in srgb, var(--accent) 6%, transparent) 14px 15px); - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--space-3); - box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 14%, transparent); -} - -.update-banner-skills { - border-color: color-mix(in srgb, var(--accent) 44%, var(--line)); - background: - radial-gradient(circle at 85% 10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 45%), - linear-gradient(125deg, color-mix(in srgb, var(--accent-soft) 90%, transparent) 0%, color-mix(in srgb, var(--surface) 80%, transparent) 100%); -} - -.update-banner-copy { - display: grid; - gap: 0.15rem; -} - -.update-banner-copy strong { - font-weight: 600; - font-size: 0.95rem; -} - -.update-banner-copy span { - color: var(--text-soft); - font-size: 0.86rem; -} - -.update-banner-actions { - display: flex; - align-items: center; - gap: var(--space-2); -} - .tab-nav { display: flex; gap: var(--space-3); @@ -884,7 +757,7 @@ input[type="radio"]:focus-visible { .panel { background: var(--surface); border: 1px solid var(--line); - border-radius: 16px; + border-radius: 12px; padding: var(--space-6); } @@ -960,6 +833,249 @@ input[type="radio"]:focus-visible { gap: var(--space-2); } +.panel-publish { + display: grid; + gap: var(--space-4); + background: var(--surface); +} + +.publish-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.publish-head h2 { + margin-bottom: var(--space-1); +} + +.publish-chip { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: var(--surface-soft); + color: var(--text-soft); + font-size: 0.74rem; + line-height: 1.2; + padding: 0.32rem 0.62rem; + white-space: nowrap; +} + +.publish-field { + display: grid; + gap: var(--space-1); +} + +.publish-field .field-label { + margin-top: 0; +} + +.publish-field .input { + margin-top: 0; +} + +.publish-quick-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +.publish-quick-actions .btn { + min-block-size: 2.7rem; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.publish-quick-hint { + margin-top: calc(var(--space-2) * -1); +} + +.publish-hint { + margin: 0; + border: 1px dashed var(--line); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + font-size: 0.8rem; + color: var(--text-soft); + line-height: 1.5; +} + +.publish-hint code { + font-family: "Open Sans", "Segoe UI", sans-serif; + font-size: 0.74rem; +} + +.publish-advanced { + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: var(--space-3); + background: color-mix(in srgb, var(--surface-soft) 75%, transparent); +} + +.publish-advanced > summary { + list-style: none; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-soft); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.publish-advanced > summary::-webkit-details-marker { + display: none; +} + +.publish-advanced[open] > summary { + margin-bottom: var(--space-3); +} + +.publish-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.publish-field-span { + grid-column: 1 / -1; +} + +.publish-path-row { + display: grid; + gap: var(--space-2); +} + +.publish-path-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +.publish-path-actions .btn { + min-block-size: 2.45rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.publish-actions { + margin-top: var(--space-3); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); +} + +.publish-actions .btn { + min-block-size: 2.6rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.publish-picker-overlay, +.publish-config-overlay { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-5); + background: rgba(8, 13, 22, 0.4); + backdrop-filter: blur(5px); +} + +.publish-picker-modal, +.publish-config-modal { + width: min(44rem, 100%); + max-height: min(78vh, 52rem); + overflow: hidden; + overscroll-behavior: contain; + display: grid; + gap: var(--space-3); + border: 1px solid var(--line-strong); + border-radius: 14px; + background: color-mix(in srgb, var(--surface) 96%, transparent); + box-shadow: 0 18px 42px rgba(8, 17, 32, 0.24); + padding: var(--space-5); +} + +.publish-config-modal { + width: min(40rem, 100%); + max-height: min(82vh, 56rem); + overflow: auto; +} + +.publish-config-modal .publish-actions { + margin-top: 0; +} + +.publish-picker-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.publish-picker-head h3 { + margin: 0 0 var(--space-1); + font-size: 1rem; + font-weight: 400; +} + +.publish-picker-list { + display: grid; + gap: var(--space-2); + overflow: auto; + max-height: 50vh; + padding-right: 2px; +} + +.publish-picker-item { + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-soft); + color: inherit; + text-align: left; + display: grid; + gap: var(--space-1); + padding: var(--space-3); + cursor: pointer; +} + +.publish-picker-item:hover { + border-color: color-mix(in srgb, var(--chip-active-border) 62%, var(--line)); + background: color-mix(in srgb, var(--accent-soft) 62%, var(--surface-soft)); +} + +.publish-picker-item:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.publish-picker-item-name { + color: var(--text); + font-size: 0.92rem; +} + +.publish-picker-item-source { + color: var(--text-muted); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.publish-picker-item code { + font-family: "Open Sans", "Segoe UI", sans-serif; + color: var(--text-soft); + font-size: 0.74rem; + overflow-wrap: anywhere; +} + .catalog-column { display: grid; gap: var(--space-5); @@ -1181,14 +1297,20 @@ input[type="radio"]:focus-visible { } .source-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + display: flex; + flex-wrap: wrap; gap: var(--space-2); } .source-actions .btn-inline { - inline-size: 100%; - min-width: 0; + inline-size: 7.6rem; + min-width: 7.6rem; + block-size: 2.6rem; + min-block-size: 2.6rem; + display: inline-flex; + justify-content: center; + align-items: center; + line-height: 1; } .empty-state { @@ -1361,30 +1483,19 @@ input[type="radio"]:focus-visible { display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; gap: var(--space-4); } .btn { - appearance: none; - -webkit-appearance: none; - border-radius: 12px; + border-radius: 8px; border: 1px solid transparent; - padding: 0.52rem 0.86rem; + padding: 0.48rem 0.72rem; font: inherit; - font-size: 0.88rem; - font-weight: 500; + font-size: 0.84rem; + font-weight: 400; cursor: pointer; - line-height: 1.2; - box-sizing: border-box; - display: inline-flex; - align-items: center; - justify-content: center; - min-height: var(--btn-height); - transition: transform 130ms ease, background-color 130ms ease, border-color 130ms ease; -} - -.btn:not(:disabled):hover { - transform: translateY(-1px); + line-height: 1.35; } .btn:disabled { @@ -1426,19 +1537,7 @@ input[type="radio"]:focus-visible { .btn-inline { font-size: 0.82rem; - min-height: var(--btn-inline-height); - padding: 0.34rem 0.62rem; -} - -.repo-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-2); -} - -.repo-actions .btn { - width: 100%; - min-width: 0; + padding: 0.38rem 0.62rem; } .settings-grid { @@ -1508,10 +1607,6 @@ input[type="radio"]:focus-visible { margin-top: var(--space-2); } -.settings-grid .panel-settings .repo-actions .btn + .btn { - margin-top: 0; -} - .settings-grid .panel-settings h2:not(:first-of-type) { margin-top: var(--space-5); } @@ -1531,53 +1626,6 @@ input[type="radio"]:focus-visible { background: var(--surface-soft); } -.app-footer { - margin-top: var(--space-6); - border-top: 1px solid color-mix(in srgb, var(--line) 76%, transparent); - padding: var(--space-4) var(--space-5); - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - color: var(--text-muted); - font-size: 0.82rem; - border-radius: 14px; - background: color-mix(in srgb, var(--surface) 85%, transparent); -} - -.app-footer-meta { - display: inline-flex; - align-items: center; - gap: var(--space-2); -} - -.app-footer-version { - border: 1px solid color-mix(in srgb, var(--line) 72%, transparent); - border-radius: 999px; - padding: 0.14rem 0.5rem; - color: var(--text-soft); - background: color-mix(in srgb, var(--accent-soft) 66%, transparent); - font-size: 0.75rem; - letter-spacing: 0.03em; -} - -.app-footer a { - color: var(--accent-ink); - text-decoration: none; - border-bottom: 1px solid transparent; -} - -.app-footer a:hover { - border-bottom-color: color-mix(in srgb, var(--accent-ink) 55%, transparent); -} - -.app-footer a:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 2px; - border-radius: 4px; -} - .collapsible summary { list-style: none; cursor: pointer; @@ -1684,6 +1732,21 @@ pre { align-items: stretch; } + .publish-quick-actions, + .publish-grid, + .publish-actions { + grid-template-columns: 1fr; + } + + .publish-path-actions { + grid-template-columns: 1fr 1fr; + } + + .publish-picker-modal, + .publish-config-modal { + width: min(42rem, 100%); + } + .theme-row { grid-template-columns: 1fr; gap: var(--space-4); @@ -1713,11 +1776,6 @@ pre { .appearance-popover { width: min(32rem, calc(100vw - 2rem)); } - - .update-banner { - flex-wrap: wrap; - align-items: flex-start; - } } @media (max-width: 760px) { @@ -1758,11 +1816,6 @@ pre { width: fit-content; } - .update-banner-actions { - width: 100%; - justify-content: flex-start; - } - .theme-group { width: 100%; align-items: flex-start; @@ -1817,11 +1870,6 @@ pre { align-items: flex-start; } - .source-actions, - .repo-actions { - grid-template-columns: 1fr; - } - .chip-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1831,12 +1879,23 @@ pre { min-width: 0; } - .app-footer { - flex-direction: column; - align-items: flex-start; + .publish-path-actions { + grid-template-columns: 1fr; + } + + .publish-picker-overlay, + .publish-config-overlay { + padding: var(--space-3); + } + + .publish-picker-modal, + .publish-config-modal { + max-height: 84vh; + padding: var(--space-4); } - .app-footer-meta { - flex-wrap: wrap; + .publish-picker-head { + flex-direction: column; + align-items: stretch; } } diff --git a/src/schemas/ica.config.schema.json b/src/schemas/ica.config.schema.json index 8568f1b..5d944ed 100644 --- a/src/schemas/ica.config.schema.json +++ b/src/schemas/ica.config.schema.json @@ -26,6 +26,15 @@ "type": "boolean", "description": "Always activate PM role" }, + "work_item_pipeline_enabled": { + "type": "boolean", + "description": "Automatically orchestrate create-work-items -> plan-work-items -> run-work-items for actionable findings/comments" + }, + "work_item_pipeline_mode": { + "type": "string", + "enum": ["batch_auto", "batch_confirm", "item_confirm"], + "description": "Confirmation mode for actionable-finding ingestion when work item pipeline is enabled" + }, "l3_settings": { "type": "object", "description": "L3 autonomous mode settings", diff --git a/tests/installer/claude-integration.test.ts b/tests/installer/claude-integration.test.ts new file mode 100644 index 0000000..d5d9a60 --- /dev/null +++ b/tests/installer/claude-integration.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyClaudeIntegration } from "../../src/installer-core/claudeIntegration"; + +const repoRoot = path.resolve(__dirname, "../../.."); + +test("applyClaudeIntegration writes string matchers for managed PreToolUse hooks", async () => { + const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-integration-")); + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-project-")); + + fs.writeFileSync( + path.join(installPath, "settings.json"), + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: "Read", + hooks: [{ type: "command", command: "node /tmp/existing.js" }], + }, + ], + }, + }, + null, + 2, + ), + "utf8", + ); + + await applyClaudeIntegration({ + repoRoot, + installPath, + scope: "project", + projectPath, + agentDirName: ".claude", + }); + + const settings = JSON.parse(fs.readFileSync(path.join(installPath, "settings.json"), "utf8")) as { + hooks?: { PreToolUse?: Array<{ matcher?: unknown; hooks?: Array<{ command?: string }> }> }; + }; + + const preToolUse = settings.hooks?.PreToolUse ?? []; + const managed = preToolUse.filter((entry) => + entry.hooks?.some((hook) => + (hook.command || "").includes("agent-infrastructure-protection.js") || + (hook.command || "").includes("summary-file-enforcement.js"), + ), + ); + + assert.equal(managed.length, 2); + assert.ok(managed.every((entry) => typeof entry.matcher === "string")); + const matchers = managed.map((entry) => String(entry.matcher)).sort(); + assert.deepEqual(matchers, ["^(BashTool|Bash)$", "^(FileWriteTool|FileEditTool|Write|Edit)$"]); +}); + +test("applyClaudeIntegration keeps unrelated hooks and replaces prior managed entries", async () => { + const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-integration-")); + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-project-")); + + fs.writeFileSync( + path.join(installPath, "settings.json"), + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: "Read", + hooks: [{ type: "command", command: "node /tmp/keep-me.js" }], + }, + { + matcher: "legacy", + hooks: [{ type: "command", command: "node /tmp/hooks/agent-infrastructure-protection.js" }], + }, + { + matcher: "legacy", + hooks: [{ type: "command", command: "node /tmp/hooks/summary-file-enforcement.js" }], + }, + ], + }, + }, + null, + 2, + ), + "utf8", + ); + + await applyClaudeIntegration({ + repoRoot, + installPath, + scope: "project", + projectPath, + agentDirName: ".claude", + }); + + const settings = JSON.parse(fs.readFileSync(path.join(installPath, "settings.json"), "utf8")) as { + hooks?: { PreToolUse?: Array<{ matcher?: unknown; hooks?: Array<{ command?: string }> }> }; + }; + const preToolUse = settings.hooks?.PreToolUse ?? []; + + const userReadHooks = preToolUse.filter((entry) => + entry.hooks?.some((hook) => (hook.command || "").includes("keep-me.js")), + ); + assert.equal(userReadHooks.length, 1); + + const infraHooks = preToolUse.filter((entry) => + entry.hooks?.some((hook) => (hook.command || "").includes("agent-infrastructure-protection.js")), + ); + assert.equal(infraHooks.length, 1); + assert.equal(infraHooks[0].matcher, "^(BashTool|Bash)$"); + + const summaryHooks = preToolUse.filter((entry) => + entry.hooks?.some((hook) => (hook.command || "").includes("summary-file-enforcement.js")), + ); + assert.equal(summaryHooks.length, 1); + assert.equal(summaryHooks[0].matcher, "^(FileWriteTool|FileEditTool|Write|Edit)$"); +}); diff --git a/tests/installer/cli-serve.test.ts b/tests/installer/cli-serve.test.ts new file mode 100644 index 0000000..8e71eaf --- /dev/null +++ b/tests/installer/cli-serve.test.ts @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readFile(relativePath: string): string { + return fs.readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); +} + +test("CLI help advertises serve/launch commands", () => { + const cli = readFile("src/installer-cli/index.ts"); + assert.match(cli, /ica serve \[--host=127\.0\.0\.1\] \[--ui-port=4173\] \[--open=true\|false\]/); + assert.match(cli, /ica launch \(alias for serve; deprecated\)/); +}); + +test("CLI main dispatch handles serve and launch", () => { + const cli = readFile("src/installer-cli/index.ts"); + assert.match(cli, /if \(normalized === "serve"\) \{/); + assert.match(cli, /if \(normalized === "launch"\) \{/); + assert.match(cli, /await runServe\(options\);/); +}); diff --git a/tests/installer/dashboard-skill-publish-ux.test.ts b/tests/installer/dashboard-skill-publish-ux.test.ts new file mode 100644 index 0000000..54a5234 --- /dev/null +++ b/tests/installer/dashboard-skill-publish-ux.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readWorkspaceFile(relativePath: string): string { + return fs.readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); +} + +test("skills UI keeps only two primary publish actions on panel and moves configuration to overlays", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.match(ui, />\s*Publish\s*Scope<\/span>/); + assert.match(ui, /Category<\/span>/); + assert.match(ui, /Tag<\/span>/); + assert.doesNotMatch(ui, /skill-publish-btn/); + assert.doesNotMatch(ui, /runQuickPublishFromSkillCard/); +}); + +test("source filter options include all discovered source ids", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.match(ui, /new Set\(sources\.filter\(\(source\) => source\.enabled\)\.map\(\(source\) => source\.id\)\)/); +}); + +test("quick publish derives candidates from visible selected skills", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.match(ui, /const selectedVisibleSkillPublishCandidates = useMemo/); + assert.match(ui, /selectedVisibleSkillPublishCandidates\.length === 1/); +}); + +test("dashboard server exposes a dedicated skill directory picker endpoint", () => { + const server = readWorkspaceFile("src/installer-dashboard/server/index.ts"); + + assert.match(server, /app\.post\("\/api\/v1\/skills\/pick"/); +}); + +test("dashboard server blocks publishing official skills to non-official sources", () => { + const server = readWorkspaceFile("src/installer-dashboard/server/index.ts"); + + assert.match(server, /Official skills can only be published to official sources/); +}); + +test("advanced publish flow supports per-run override mode and base branch", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + const server = readWorkspaceFile("src/installer-dashboard/server/index.ts"); + + assert.match(ui, /Publish Mode Override \(optional\)/); + assert.match(ui, /Base Branch Override \(optional\)/); + assert.match(ui, /overrideMode: params\.overrideMode/); + assert.match(ui, /overrideBaseBranch: params\.overrideBaseBranch/); + assert.match(server, /const overrideMode = typeof body\.overrideMode === "string" \? body\.overrideMode\.trim\(\) : ""/); + assert.match(server, /const overrideBaseBranch = typeof body\.overrideBaseBranch === "string" \? body\.overrideBaseBranch\.trim\(\) : ""/); +}); diff --git a/tests/installer/dashboard-source-actions-style.test.ts b/tests/installer/dashboard-source-actions-style.test.ts new file mode 100644 index 0000000..a63d7c4 --- /dev/null +++ b/tests/installer/dashboard-source-actions-style.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readCssRule(css: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\}`, "m"); + const match = css.match(regex); + assert.ok(match, `Expected CSS rule for selector: ${selector}`); + return match[1]; +} + +test("source action buttons enforce equal height contract", () => { + const stylesheet = path.resolve(process.cwd(), "src/installer-dashboard/web/src/styles.css"); + const css = fs.readFileSync(stylesheet, "utf8"); + const rule = readCssRule(css, ".source-actions .btn-inline"); + + assert.match(rule, /display:\s*inline-flex\s*;/); + assert.match(rule, /justify-content:\s*center\s*;/); + assert.match(rule, /align-items:\s*center\s*;/); + assert.match(rule, /min-block-size:\s*2\.6rem\s*;/); +}); + +test("publish quick-action buttons enforce equal height contract", () => { + const stylesheet = path.resolve(process.cwd(), "src/installer-dashboard/web/src/styles.css"); + const css = fs.readFileSync(stylesheet, "utf8"); + const rule = readCssRule(css, ".publish-quick-actions .btn"); + + assert.match(rule, /display:\s*inline-flex\s*;/); + assert.match(rule, /align-items:\s*center\s*;/); + assert.match(rule, /justify-content:\s*center\s*;/); + assert.match(rule, /min-block-size:\s*2\.7rem\s*;/); +}); diff --git a/tests/installer/skill-publish.test.ts b/tests/installer/skill-publish.test.ts new file mode 100644 index 0000000..ba5d83d --- /dev/null +++ b/tests/installer/skill-publish.test.ts @@ -0,0 +1,257 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createCredentialProvider } from "../../src/installer-core/credentials"; +import { addSource, loadSources } from "../../src/installer-core/sources"; +import { + contributeOfficialSkillBundle, + detectGitProvider, + publishSkillBundle, + sanitizeSkillName, + validateSkillBundle, +} from "../../src/installer-core/skillPublish"; + +function withStateHome(stateHome: string, fn: () => Promise): Promise { + const previous = process.env.ICA_STATE_HOME; + process.env.ICA_STATE_HOME = stateHome; + return fn().finally(() => { + if (previous === undefined) { + delete process.env.ICA_STATE_HOME; + } else { + process.env.ICA_STATE_HOME = previous; + } + }); +} + +function initGitRepo(repoDir: string): void { + execFileSync("git", ["init", "-q"], { cwd: repoDir }); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "user.name=ICA Test", "-c", "user.email=ica-test@example.com", "commit", "-q", "-m", "seed"], { + cwd: repoDir, + }); +} + +test("detectGitProvider maps common git providers", () => { + assert.equal(detectGitProvider("https://github.com/org/repo.git"), "github"); + assert.equal(detectGitProvider("git@gitlab.com:org/repo.git"), "gitlab"); + assert.equal(detectGitProvider("https://bitbucket.org/org/repo.git"), "bitbucket"); + assert.equal(detectGitProvider("https://example.com/org/repo.git"), "unknown"); +}); + +test("sanitizeSkillName enforces lowercase slug naming", () => { + assert.equal(sanitizeSkillName(" My Skill_Name "), "my-skill-name"); +}); + +test("loadSources migrates publish fields for legacy source entries", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-sources-")); + const sourcesFile = path.join(stateHome, "sources.json"); + fs.mkdirSync(stateHome, { recursive: true }); + fs.writeFileSync( + sourcesFile, + JSON.stringify( + { + sources: [ + { + id: "legacy-source", + name: "legacy-source", + repoUrl: "https://github.com/example/legacy.git", + transport: "https", + official: false, + enabled: true, + skillsRoot: "/skills", + removable: true, + }, + ], + }, + null, + 2, + ), + ); + + await withStateHome(stateHome, async () => { + const sources = await loadSources(); + const legacy = sources.find((source) => source.id === "legacy-source"); + assert.ok(legacy); + assert.equal(legacy?.publishDefaultMode, "branch-pr"); + assert.equal(legacy?.providerHint, "github"); + assert.equal(legacy?.officialContributionEnabled, false); + }); +}); + +test("validateSkillBundle distinguishes personal and official policy", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-validate-")); + const skillDir = path.join(root, "my-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: my-skill\ndescription: demo\n---\n", "utf8"); + + const personal = await validateSkillBundle({ localPath: skillDir }, "personal"); + assert.equal(personal.errors.length, 0); + + const official = await validateSkillBundle({ localPath: skillDir }, "official"); + assert.ok(official.errors.some((entry: string) => entry.includes("category"))); + assert.ok(official.errors.some((entry: string) => entry.includes("version"))); +}); + +test("publishSkillBundle supports direct-push and branch-only modes", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-state-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-remote-")); + const seedRepo = path.join(remoteRoot, "seed"); + fs.mkdirSync(path.join(seedRepo, "skills"), { recursive: true }); + fs.writeFileSync(path.join(seedRepo, "README.md"), "# test\n", "utf8"); + initGitRepo(seedRepo); + const remoteRepo = path.join(remoteRoot, "skills-remote.git"); + execFileSync("git", ["clone", "--bare", seedRepo, remoteRepo]); + + const localSkillRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-local-skill-")); + const localSkill = path.join(localSkillRoot, "sample"); + fs.mkdirSync(path.join(localSkill, "scripts"), { recursive: true }); + fs.writeFileSync( + path.join(localSkill, "SKILL.md"), + "---\nname: sample\ndescription: sample skill\ncategory: process\nversion: 1.0.0\n---\n", + "utf8", + ); + fs.writeFileSync(path.join(localSkill, "scripts", "run.sh"), "echo hi\n", "utf8"); + + await withStateHome(stateHome, async () => { + const source = await addSource({ + id: "publisher", + name: "publisher", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "direct-push", + enabled: true, + removable: true, + }); + + const direct = await publishSkillBundle( + { sourceId: source.id, bundle: { localPath: localSkill }, commitMessage: "add sample direct" }, + createCredentialProvider(), + ); + assert.equal(direct.mode, "direct-push"); + assert.equal(Boolean(direct.commitSha), true); + + await addSource({ + id: "publisher-branch", + name: "publisher-branch", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "branch-only", + enabled: true, + removable: true, + }); + + fs.writeFileSync(path.join(localSkill, "scripts", "branch-only.sh"), "echo branch\n", "utf8"); + + const branch = await publishSkillBundle( + { sourceId: "publisher-branch", bundle: { localPath: localSkill }, commitMessage: "add sample branch" }, + createCredentialProvider(), + ); + assert.equal(branch.mode, "branch-only"); + assert.equal(branch.branch.startsWith("skill/sample/"), true); + }); +}); + +test("publishSkillBundle applies per-run override mode and base branch", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-override-state-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-override-remote-")); + const seedRepo = path.join(remoteRoot, "seed"); + fs.mkdirSync(path.join(seedRepo, "skills"), { recursive: true }); + fs.writeFileSync(path.join(seedRepo, "README.md"), "# test\n", "utf8"); + initGitRepo(seedRepo); + execFileSync("git", ["branch", "dev"], { cwd: seedRepo }); + const remoteRepo = path.join(remoteRoot, "skills-remote.git"); + execFileSync("git", ["clone", "--bare", seedRepo, remoteRepo]); + + const localSkillRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-local-skill-override-")); + const localSkill = path.join(localSkillRoot, "override-sample"); + fs.mkdirSync(path.join(localSkill, "scripts"), { recursive: true }); + fs.writeFileSync( + path.join(localSkill, "SKILL.md"), + "---\nname: override-sample\ndescription: sample skill\ncategory: process\nversion: 1.0.0\n---\n", + "utf8", + ); + fs.writeFileSync(path.join(localSkill, "scripts", "run.sh"), "echo hi\n", "utf8"); + + await withStateHome(stateHome, async () => { + const source = await addSource({ + id: "publisher-override", + name: "publisher-override", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "branch-pr", + defaultBaseBranch: "master", + enabled: true, + removable: true, + }); + + const result = await publishSkillBundle( + { + sourceId: source.id, + bundle: { localPath: localSkill }, + commitMessage: "publish override sample", + overrideMode: "direct-push", + overrideBaseBranch: "dev", + } as any, + createCredentialProvider(), + ); + + assert.equal(result.mode, "direct-push"); + assert.equal(result.branch, "dev"); + }); +}); + +test("contributeOfficialSkillBundle runs strict validation and branch-pr publish flow", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-official-contrib-state-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-official-contrib-remote-")); + const seedRepo = path.join(remoteRoot, "seed"); + fs.mkdirSync(path.join(seedRepo, "skills"), { recursive: true }); + fs.writeFileSync(path.join(seedRepo, "README.md"), "# seed\n", "utf8"); + initGitRepo(seedRepo); + const remoteRepo = path.join(remoteRoot, "official.git"); + execFileSync("git", ["clone", "--bare", seedRepo, remoteRepo]); + + const localSkillRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-official-skill-")); + const localSkill = path.join(localSkillRoot, "official-sample"); + fs.mkdirSync(path.join(localSkill, "assets"), { recursive: true }); + fs.writeFileSync( + path.join(localSkill, "SKILL.md"), + "---\nname: official-sample\ndescription: official sample\ncategory: process\nversion: 1.0.0\n---\n", + "utf8", + ); + fs.writeFileSync(path.join(localSkill, "assets", "note.txt"), "hello\n", "utf8"); + + await withStateHome(stateHome, async () => { + await addSource({ + id: "official-local", + name: "official-local", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "direct-push", + defaultBaseBranch: "master", + providerHint: "unknown", + officialContributionEnabled: true, + official: true, + enabled: true, + removable: true, + }); + + const result = await contributeOfficialSkillBundle( + { + sourceId: "official-local", + bundle: { localPath: localSkill }, + commitMessage: "contribute official sample", + }, + createCredentialProvider(), + ); + assert.equal(result.mode, "branch-pr"); + assert.equal(result.branch.startsWith("skill/official-sample/"), true); + assert.equal(Boolean(result.commitSha), true); + }); +}); diff --git a/tests/installer/sources.test.ts b/tests/installer/sources.test.ts index f409152..df606d6 100644 --- a/tests/installer/sources.test.ts +++ b/tests/installer/sources.test.ts @@ -36,6 +36,10 @@ function fixtureCatalog(): SkillCatalog { official: true, enabled: true, skillsRoot: "/skills", + publishDefaultMode: "branch-pr", + defaultBaseBranch: "dev", + providerHint: "github", + officialContributionEnabled: true, removable: true, }, ], @@ -243,6 +247,10 @@ test("synced skills are stored in ~/.ica//skills", async () => { repoUrl: `file://${repoDir}`, transport: "https", skillsRoot: "/skills", + publishDefaultMode: "branch-pr", + defaultBaseBranch: "main", + providerHint: "unknown", + officialContributionEnabled: false, enabled: true, removable: true, }); @@ -286,6 +294,10 @@ test("all sources support legacy root layout when configured skillsRoot is missi official: false, enabled: true, skillsRoot: "/skills", + publishDefaultMode: "branch-pr", + defaultBaseBranch: "main", + providerHint: "unknown", + officialContributionEnabled: false, removable: true, }; From 3c2501a6a9e5cc84ea3cc6ae7b98ae0c9e24881d Mon Sep 17 00:00:00 2001 From: Karsten Samaschke Date: Sun, 15 Feb 2026 13:13:07 +0100 Subject: [PATCH 2/2] fix: restore publish workflows and stabilize merged release state --- src/catalog/skills.catalog.json | 2 +- src/installer-cli/index.ts | 1168 ++++++++++++++------ src/installer-core/catalog.ts | 269 +++-- src/installer-core/catalogMultiSource.ts | 290 +++-- src/installer-core/sources.ts | 101 +- tests/installer/cli-skills-publish.test.ts | 28 + 6 files changed, 1298 insertions(+), 560 deletions(-) create mode 100644 tests/installer/cli-skills-publish.test.ts diff --git a/src/catalog/skills.catalog.json b/src/catalog/skills.catalog.json index bac9a68..d1fe5fb 100644 --- a/src/catalog/skills.catalog.json +++ b/src/catalog/skills.catalog.json @@ -1,7 +1,7 @@ { "generatedAt": "1970-01-01T00:00:00.000Z", "source": "multi-source", - "version": "12.0.0", + "version": "12.1.1", "sources": [ { "id": "official-skills", diff --git a/src/installer-cli/index.ts b/src/installer-cli/index.ts index 0b78a53..641444e 100644 --- a/src/installer-cli/index.ts +++ b/src/installer-cli/index.ts @@ -3,7 +3,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; -import { spawn, ChildProcessWithoutNullStreams } from "node:child_process"; +import net from "node:net"; +import { spawn, execFile } from "node:child_process"; +import { promisify } from "node:util"; import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { executeOperation } from "../installer-core/executor"; @@ -19,10 +21,24 @@ import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from ".. import { loadHookInstallState } from "../installer-core/hookState"; import { registerRepository } from "../installer-core/repositories"; import { contributeOfficialSkillBundle, publishSkillBundle, validateSkillBundle } from "../installer-core/skillPublish"; +import { refreshSourcesAndHooks } from "../installer-core/sourceRefresh"; import { loadInstallState } from "../installer-core/state"; import { parseTargets, resolveTargetPaths } from "../installer-core/targets"; +import { checkForAppUpdate } from "../installer-core/updateCheck"; import { findRepoRoot } from "../installer-core/repo"; -import { InstallMode, InstallRequest, InstallScope, InstallSelection, OperationKind, PublishMode, TargetPlatform, ValidationProfile } from "../installer-core/types"; +import { + GitProvider, + InstallMode, + InstallRequest, + InstallScope, + InstallSelection, + OperationKind, + PublishMode, + PublishResult, + TargetPlatform, + ValidationProfile, + ValidationResult, +} from "../installer-core/types"; interface ParsedArgs { command: string; @@ -30,10 +46,18 @@ interface ParsedArgs { positionals: string[]; } -const HELPER_HOST = "127.0.0.1"; -const HELPER_PORT = Number(process.env.ICA_HELPER_PORT || "4174"); -const HELPER_TOKEN = process.env.ICA_HELPER_TOKEN || crypto.randomBytes(24).toString("hex"); -let helperProcess: ChildProcessWithoutNullStreams | null = null; +const execFileAsync = promisify(execFile); +const DEFAULT_DASHBOARD_IMAGE = "ghcr.io/intelligentcode-ai/ica-installer-dashboard:main"; + +export type ServeImageBuildMode = "auto" | "always" | "never"; +export type ServeReusePortsMode = boolean; + +export function redactCliErrorMessage(message: string): string { + return message + .replace(/(ICA_(?:UI_)?API_KEY=)[^\s]+/g, "$1[REDACTED]") + .replace(/(--(?:token|api-key)=)[^\s]+/g, "$1[REDACTED]") + .replace(/(x-ica-api-key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, "$1[REDACTED]"); +} function parseArgv(argv: string[]): ParsedArgs { const [command = "help", ...rest] = argv; @@ -80,6 +104,80 @@ function stringOption(options: Record, key: string, de return typeof value === "boolean" ? String(value) : value; } +function parseOptionalBooleanOption(options: Record, key: string): boolean | undefined { + const value = options[key]; + if (value === undefined) { + return undefined; + } + if (typeof value === "boolean") { + return value; + } + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + throw new Error(`Invalid --${key} value '${value}'. Supported: true|false`); +} + +function parsePublishModeOption(options: Record, key: string): PublishMode | undefined { + const value = stringOption(options, key, "").trim(); + if (!value) return undefined; + if (value === "direct-push" || value === "branch-only" || value === "branch-pr") { + return value; + } + throw new Error(`Invalid --${key} value '${value}'. Supported: direct-push|branch-only|branch-pr`); +} + +function parseValidationProfileOption(options: Record, key: string): ValidationProfile { + const value = stringOption(options, key, "personal").trim(); + if (value === "personal" || value === "official") { + return value; + } + throw new Error(`Invalid --${key} value '${value}'. Supported: personal|official`); +} + +function parseProviderHintOption(options: Record, key: string): GitProvider | undefined { + const value = stringOption(options, key, "").trim(); + if (!value) return undefined; + if (value === "github" || value === "gitlab" || value === "bitbucket" || value === "unknown") { + return value; + } + throw new Error(`Invalid --${key} value '${value}'. Supported: github|gitlab|bitbucket|unknown`); +} + +function printValidationResult(result: ValidationResult): void { + output.write(`Validation profile: ${result.profile}\n`); + output.write(`Detected files: ${result.detectedFiles.length}\n`); + if (result.warnings.length > 0) { + output.write(`Warnings:\n`); + for (const warning of result.warnings) { + output.write(`- ${warning}\n`); + } + } else { + output.write("Warnings: none\n"); + } + if (result.errors.length > 0) { + output.write(`Errors:\n`); + for (const error of result.errors) { + output.write(`- ${error}\n`); + } + } else { + output.write("Errors: none\n"); + } +} + +function printPublishResult(result: PublishResult): void { + output.write(`Mode: ${result.mode}\n`); + output.write(`Branch: ${result.branch}\n`); + output.write(`Commit: ${result.commitSha}\n`); + output.write(`Remote: ${result.pushedRemote}\n`); + if (result.prUrl) { + output.write(`PR: ${result.prUrl}\n`); + } + if (result.compareUrl) { + output.write(`Compare: ${result.compareUrl}\n`); + } +} + function splitCsv(value: string): string[] { return value .split(/[\s,]+/) @@ -156,105 +254,269 @@ function parseHookTargetsStrict(rawValue: string): HookTargetPlatform[] { return ["claude"]; } -async function helperRequest(pathname: string, body: Record): Promise> { - const response = await fetch(`http://${HELPER_HOST}:${HELPER_PORT}${pathname}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-ica-helper-token": HELPER_TOKEN, - }, - body: JSON.stringify(body), +async function commandExists(command: string): Promise { + try { + await execFileAsync(command, ["--version"], { maxBuffer: 1024 * 1024 }); + return true; + } catch { + return false; + } +} + +async function isLoopbackPortAvailable(port: number): Promise { + return await new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => { + resolve(false); + }); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); }); - const payload = (await response.json()) as Record; - if (!response.ok) { - throw new Error(typeof payload.error === "string" ? payload.error : "Helper request failed."); +} + +async function getListeningPids(port: number): Promise { + const pids = new Set(); + + try { + if (process.platform === "win32") { + const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { maxBuffer: 4 * 1024 * 1024 }); + const lines = stdout.split(/\r?\n/); + const matcher = new RegExp(`:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`, "i"); + for (const line of lines) { + const match = line.match(matcher); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) { + pids.add(pid); + } + } + return Array.from(pids); + } + + const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { maxBuffer: 1024 * 1024 }); + for (const line of stdout.split(/\r?\n/)) { + const pid = Number.parseInt(line.trim(), 10); + if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) { + pids.add(pid); + } + } + } catch { + return []; } - return payload; + + return Array.from(pids); } -async function waitForHelperReady(retries = 30): Promise { - for (let attempt = 0; attempt < retries; attempt += 1) { +function canSignalPid(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function isIcaOwnedServePid(pid: number): Promise { + if (process.platform === "win32") { + // Keep previous behavior on Windows where lightweight commandline checks are less portable. + return true; + } + try { + const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="], { maxBuffer: 1024 * 1024 }); + const command = (stdout || "").toLowerCase(); + return ( + command.includes("installer-api/server/index.js") || + command.includes("installer-bff/server/index.js") || + command.includes("dist/src/installer-api/server/index.js") || + command.includes("dist/src/installer-bff/server/index.js") + ); + } catch { + return false; + } +} + +async function waitForPortAvailable(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isLoopbackPortAvailable(port)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return await isLoopbackPortAvailable(port); +} + +async function reclaimLoopbackPort(port: number, flagName: "ui-port" | "api-port"): Promise { + if (await isLoopbackPortAvailable(port)) { + return; + } + + const pids = await getListeningPids(port); + if (pids.length === 0) { + throw new Error( + `Requested --${flagName}=${port} is in use, but ICA could not identify the owning process to stop it automatically.`, + ); + } + + for (const pid of pids) { + if (!(await isIcaOwnedServePid(pid))) { + throw new Error( + `Requested --${flagName}=${port} is in use by non-ICA process (pid ${pid}). Stop it manually or choose a different port.`, + ); + } + } + + output.write(`Notice: ${flagName} ${port} is busy; stopping existing process on that port.\n`); + for (const pid of pids) { try { - const response = await fetch(`http://${HELPER_HOST}:${HELPER_PORT}/health`, { - headers: { - "x-ica-helper-token": HELPER_TOKEN, - }, - }); - if (response.ok) return; + process.kill(pid, "SIGTERM"); } catch { - // retry + // ignore individual process signal failures } - await new Promise((resolve) => setTimeout(resolve, 150)); } - throw new Error("ICA helper did not become ready in time."); -} -async function ensureHelperRunning(repoRoot: string): Promise { - if (helperProcess && !helperProcess.killed) { + if (await waitForPortAvailable(port, 2000)) { + return; + } + + for (const pid of pids) { + if (!canSignalPid(pid)) continue; try { - await waitForHelperReady(1); - return; + process.kill(pid, "SIGKILL"); } catch { - // respawn below + // ignore individual process signal failures } } - const helperScript = path.join(repoRoot, "dist", "src", "installer-helper", "server.js"); - if (!fs.existsSync(helperScript)) { - throw new Error("Native helper is not built. Run: npm run build"); + if (await waitForPortAvailable(port, 1500)) { + return; } - helperProcess = spawn(process.execPath, [helperScript], { - env: { - ...process.env, - ICA_HELPER_PORT: String(HELPER_PORT), - ICA_HELPER_TOKEN: HELPER_TOKEN, - }, - stdio: "pipe", - }); - helperProcess.stderr.on("data", (chunk) => { - const message = chunk.toString("utf8"); - process.stderr.write(`[ica-helper] ${message}`); - }); - await waitForHelperReady(); + throw new Error(`Requested --${flagName}=${port} is still in use after attempting to stop the existing process.`); } -function openBrowser(url: string): void { - let command = ""; - let args: string[] = []; +export function parseServeImageBuildMode(rawValue: string): ServeImageBuildMode { + const normalized = rawValue.trim().toLowerCase(); + if (normalized === "auto" || normalized === "always" || normalized === "never") { + return normalized; + } + throw new Error(`Invalid --build-image value '${rawValue}'. Supported: auto|always|never`); +} - if (process.platform === "darwin") { - command = "open"; - args = [url]; - } else if (process.platform === "win32") { - command = "cmd"; - args = ["/c", "start", "", url]; - } else { - command = "xdg-open"; - args = [url]; +export function parseServeReusePorts(rawValue: string): ServeReusePortsMode { + const normalized = rawValue.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "off"].includes(normalized)) { + return false; } + throw new Error(`Invalid --reuse-ports value '${rawValue}'. Supported: true|false`); +} - try { - const child = spawn(command, args, { detached: true, stdio: "ignore" }); - child.unref(); - } catch (error) { - process.stderr.write(`Unable to open browser automatically: ${error instanceof Error ? error.message : String(error)}\n`); +export function parseServeRefreshMinutes(rawValue: string): number { + const trimmed = rawValue.trim(); + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid --sources-refresh-minutes value '${rawValue}'. Use 0 to disable or a positive number.`); } + return Math.floor(parsed); } -function parseServePort(rawValue: string, flagName: string): number { - const parsed = Number(rawValue.trim()); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error(`Invalid --${flagName} value '${rawValue}'.`); +export function shouldBuildDashboardImage(input: { + mode: ServeImageBuildMode; + image: string; + imageExists: boolean; + defaultImage: string; +}): boolean { + if (input.mode === "always") { + return true; } - return parsed; + if (input.mode === "never") { + return false; + } + if (input.imageExists) { + return false; + } + if (input.image.trim().toLowerCase().startsWith("ghcr.io/")) { + return false; + } + return input.image === input.defaultImage; +} + +export function shouldFallbackToSourceBuild(pullErrorMessage: string): boolean { + const normalized = pullErrorMessage.toLowerCase(); + return ( + normalized.includes("no matching manifest") || + normalized.includes("no match for platform in manifest") || + normalized.includes("manifest unknown") || + normalized.includes("manifest not found") || + normalized.includes("not found: manifest") + ); } -async function waitForDashboardReady(url: string, child: ReturnType, retries = 50): Promise { +function toDockerRunArgs(base: { + containerName: string; + image: string; + env?: string[]; + ports?: string[]; +}): string[] { + const args: string[] = ["run", "-d", "--name", base.containerName]; + for (const envItem of base.env || []) { + args.push("-e", envItem); + } + for (const port of base.ports || []) { + args.push("-p", port); + } + args.push(base.image); + return args; +} + +async function allocateLoopbackPort(input: { + preferredPort: number; + explicit: boolean; + flagName: "ui-port" | "api-port"; + blockedPorts?: Set; +}): Promise { + const blocked = input.blockedPorts || new Set(); + if (!blocked.has(input.preferredPort) && (await isLoopbackPortAvailable(input.preferredPort))) { + return input.preferredPort; + } + if (input.explicit) { + throw new Error(`Requested --${input.flagName}=${input.preferredPort} is unavailable. Choose a different port.`); + } + for (let candidate = input.preferredPort + 1; candidate <= Math.min(65535, input.preferredPort + 100); candidate += 1) { + if (blocked.has(candidate)) { + continue; + } + if (await isLoopbackPortAvailable(candidate)) { + output.write(`Notice: ${input.flagName} ${input.preferredPort} is busy; using ${candidate}.\n`); + return candidate; + } + } + throw new Error(`Unable to find a free localhost port for --${input.flagName} near ${input.preferredPort}.`); +} + +async function waitForApiReady(port: number, apiKey: string, retries = 40): Promise { for (let attempt = 0; attempt < retries; attempt += 1) { - if (child.exitCode !== null) { - throw new Error(`Dashboard process exited before becoming ready (code ${child.exitCode}).`); + try { + const response = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { + headers: { "x-ica-api-key": apiKey }, + }); + if (response.ok) return; + } catch { + // retry } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error("ICA API did not become ready in time."); +} + +async function waitForHttpReady(url: string, retries = 40): Promise { + for (let attempt = 0; attempt < retries; attempt += 1) { try { const response = await fetch(url); if (response.ok) return; @@ -263,7 +525,99 @@ async function waitForDashboardReady(url: string, child: ReturnType setTimeout(resolve, 150)); } - throw new Error(`Dashboard did not become ready in time at ${url}.`); + throw new Error(`Service did not become ready in time: ${url}`); +} + +async function dockerInspect(containerName: string): Promise { + try { + await execFileAsync("docker", ["inspect", containerName], { maxBuffer: 8 * 1024 * 1024 }); + return true; + } catch { + return false; + } +} + +async function reclaimDockerPublishedPort(port: number, expectedContainerName: string): Promise { + try { + const { stdout } = await execFileAsync( + "docker", + ["ps", "--filter", `publish=${port}`, "--format", "{{.ID}} {{.Names}}"], + { + maxBuffer: 4 * 1024 * 1024, + }, + ); + const ids = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (ids.length === 0) { + return 0; + } + let removed = 0; + for (const row of ids) { + const [id, name] = row.split(/\s+/, 2); + if (!id || !name || name !== expectedContainerName) { + continue; + } + await execFileAsync("docker", ["rm", "-f", id], { maxBuffer: 8 * 1024 * 1024 }); + removed += 1; + } + return removed; + } catch { + return 0; + } +} + +async function dockerImageExists(image: string): Promise { + try { + await execFileAsync("docker", ["image", "inspect", image], { maxBuffer: 8 * 1024 * 1024 }); + return true; + } catch { + return false; + } +} + +async function ensureDashboardImage(options: { + repoRoot: string; + image: string; + mode: ServeImageBuildMode; + defaultImage: string; +}): Promise { + const dockerfilePath = path.join(options.repoRoot, "src", "installer-dashboard", "Dockerfile"); + const buildFromSource = async (): Promise => { + if (!fs.existsSync(dockerfilePath)) { + throw new Error(`Dashboard Dockerfile not found at ${dockerfilePath}. Provide --image= or run with --build-image=never.`); + } + output.write(`Building dashboard image '${options.image}' from source...\n`); + await execFileAsync("docker", ["build", "-f", dockerfilePath, "-t", options.image, options.repoRoot], { maxBuffer: 16 * 1024 * 1024 }); + output.write(`Built dashboard image '${options.image}'.\n`); + }; + + const imageExists = await dockerImageExists(options.image); + const shouldBuild = shouldBuildDashboardImage({ + mode: options.mode, + image: options.image, + imageExists, + defaultImage: options.defaultImage, + }); + if (!shouldBuild) { + if (!imageExists && options.image.trim().toLowerCase().startsWith("ghcr.io/")) { + output.write(`Pulling dashboard image '${options.image}'...\n`); + try { + await execFileAsync("docker", ["pull", options.image], { maxBuffer: 16 * 1024 * 1024 }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const canFallback = options.mode !== "never" && fs.existsSync(dockerfilePath) && shouldFallbackToSourceBuild(message); + if (!canFallback) { + throw error; + } + output.write("Dashboard image pull failed for this platform; falling back to source build.\n"); + await buildFromSource(); + } + } + return; + } + await buildFromSource(); } function printHelp(): void { @@ -276,27 +630,28 @@ function printHelp(): void { output.write(` ica doctor\n`); output.write(` ica catalog\n\n`); output.write(` ica sources list\n`); - output.write(` ica sources add [--repo-url= | --repo-path=] [--name=] [--id=] [--transport=https|ssh]\n`); + output.write( + ` ica sources add [--repo-url= | --repo-path=] [--name=] [--id=] [--transport=https|ssh] [--publish-default-mode=direct-push|branch-only|branch-pr] [--default-base-branch=] [--provider-hint=github|gitlab|bitbucket|unknown]\n`, + ); output.write(` ica sources remove --id=\n`); output.write( - ` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--hooks-root=/hooks] [--enabled=true|false] [--publish-default-mode=direct-push|branch-only|branch-pr] [--default-base-branch=main] [--provider-hint=github|gitlab|bitbucket|unknown]\n`, + ` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--hooks-root=/hooks] [--enabled=true|false] [--publish-default-mode=direct-push|branch-only|branch-pr] [--default-base-branch=] [--provider-hint=github|gitlab|bitbucket|unknown] [--official-contribution-enabled=true|false]\n`, ); output.write(` ica sources auth --id= [--token=]\n`); output.write(` ica sources refresh [--id=]\n\n`); - output.write(` ica skills validate --path= [--profile=personal|official]\n`); - output.write( - ` ica skills publish --source= --path= [--message=] [--override-mode=direct-push|branch-only|branch-pr] [--override-base-branch=]\n`, - ); - output.write(` ica skills contribute-official --path= [--source=] [--message=]\n\n`); + output.write(` ica skills validate --path= --profile=personal|official\n`); + output.write(` ica skills publish --source= --path= [--message=""] [--override-mode=direct-push|branch-only|branch-pr] [--override-base-branch=]\n`); + output.write(` ica skills contribute-official --path= [--source=] [--message=""]\n\n`); output.write(` ica hooks list [--targets=claude,gemini] [--scope=user|project] [--project-path=/path]\n`); output.write(` ica hooks catalog [--json]\n`); output.write(` ica hooks install [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n`); output.write(` ica hooks uninstall [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n`); output.write(` ica hooks sync [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n\n`); - output.write(` ica serve [--host=127.0.0.1] [--ui-port=4173] [--open=true|false]\n`); + output.write( + ` ica serve [--host=127.0.0.1] [--ui-port=4173] [--open=true|false] [--api-port=4174] [--reuse-ports=true|false] [--image=ghcr.io/intelligentcode-ai/ica-installer-dashboard:main] [--build-image=auto|always|never]\n`, + ); output.write(` ica launch (alias for serve; deprecated)\n\n`); output.write(` Note: repository registration is unified. Adding one source auto-registers both skills and hooks mirrors.\n\n`); - output.write(` ica container mount-project --project-path= --confirm [--container-name=] [--image=] [--port=] [--json]\n\n`); output.write(`Common flags:\n`); output.write(` --targets=claude,codex\n`); output.write(` --scope=user|project\n`); @@ -313,6 +668,79 @@ function printHelp(): void { output.write(` --yes\n`); output.write(` --json\n`); output.write(` --refresh (for catalog: force live source refresh)\n`); + output.write(` --sources-refresh-minutes=60 (serve only; set 0 to disable periodic source refresh)\n`); +} + +function resolveInstallerVersion(repoRoot: string): string { + const versionFile = path.join(repoRoot, "VERSION"); + if (!fs.existsSync(versionFile)) { + return "0.0.0"; + } + try { + const value = fs.readFileSync(versionFile, "utf8").trim(); + return value || "0.0.0"; + } catch { + return "0.0.0"; + } +} + +async function maybePrintUpdateNotifier(repoRoot: string, options: Record): Promise { + if (boolOption(options, "json", false)) { + return; + } + const currentVersion = resolveInstallerVersion(repoRoot); + const update = await checkForAppUpdate(currentVersion); + if (!update.updateAvailable || !update.latestVersion) { + return; + } + const targetVersion = update.latestVersion.replace(/^v/i, ""); + const link = update.latestReleaseUrl || "https://github.com/intelligentcode-ai/intelligent-code-agents/releases/latest"; + output.write(`Update available: ICA ${targetVersion} (current ${currentVersion}). ${link}\n`); +} + +async function refreshSourcesOnCliStart(): Promise { + try { + const result = await refreshSourcesAndHooks({ + credentials: createCredentialProvider(), + loadSources, + loadHookSources, + syncSource, + syncHookSource, + }); + const errors = result.refreshed.flatMap((entry) => [entry.skills?.error, entry.hooks?.error]).filter((item): item is string => Boolean(item)); + if (errors.length > 0) { + output.write(`Warning: startup source refresh completed with ${errors.length} error(s).\n`); + } + } catch (error) { + output.write(`Warning: startup source refresh failed: ${error instanceof Error ? error.message : String(error)}\n`); + } +} + +function openBrowser(url: string): void { + let command = ""; + let args: string[] = []; + if (process.platform === "darwin") { + command = "open"; + args = [url]; + } else if (process.platform === "win32") { + command = "cmd"; + args = ["/c", "start", "", url]; + } else { + command = "xdg-open"; + args = [url]; + } + + try { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.unref(); + } catch (error) { + process.stderr.write(`Unable to open browser automatically: ${error instanceof Error ? error.message : String(error)}\n`); + } +} + +function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "localhost"; } async function promptInteractive(command: OperationKind, options: Record): Promise { @@ -475,7 +903,8 @@ async function runDoctor(options: Record): Promise): Promise { const repoRoot = findRepoRoot(__dirname); - const catalog = await loadCatalogFromSources(repoRoot, false); + const refresh = boolOption(options, "refresh", false); + const catalog = await loadCatalogFromSources(repoRoot, refresh); if (boolOption(options, "json", false)) { output.write(`${JSON.stringify(catalog, null, 2)}\n`); return; @@ -483,6 +912,21 @@ async function runCatalog(options: Record): Promise>[number]; hooks?: Awaited>[number]; }> @@ -553,10 +993,6 @@ async function runSources(positionals: string[], options: Record>[number]; hooks?: Awaited>[number]; } @@ -573,10 +1009,6 @@ async function runSources(positionals: string[], options: Record repo.id === sourceId) - : repositories.filter((repo) => repo.skills?.enabled !== false || repo.hooks?.enabled !== false); - if (targets.length === 0) { + const result = await refreshSourcesAndHooks( + { + credentials: credentialProvider, + loadSources, + loadHookSources, + syncSource, + syncHookSource, + }, + { sourceId, onlyEnabled: true }, + ); + if (!result.matched) { throw new Error(sourceId ? `Unknown source '${sourceId}'` : "No enabled sources found."); } + const refreshed = result.refreshed.map((item) => ({ + id: item.sourceId, + skills: item.skills, + hooks: item.hooks, + })); + output.write(json ? `${JSON.stringify(refreshed, null, 2)}\n` : `Refreshed ${refreshed.length} repositories.\n`); + return; + } - const refreshed: Array<{ - id: string; - skills?: { revision?: string; localPath?: string; error?: string }; - hooks?: { revision?: string; localPath?: string; error?: string }; - }> = []; - for (const repo of targets) { - const item: { - id: string; - skills?: { revision?: string; localPath?: string; error?: string }; - hooks?: { revision?: string; localPath?: string; error?: string }; - } = { id: repo.id }; + throw new Error(`Unknown sources action '${action}'. Supported: list|add|remove|update|auth|refresh`); +} - if (repo.skills) { - try { - const result = await syncSource(repo.skills, credentialProvider); - item.skills = { revision: result.revision, localPath: result.localPath }; - } catch (error) { - item.skills = { error: error instanceof Error ? error.message : String(error) }; - } - } - if (repo.hooks) { - try { - const result = await syncHookSource(repo.hooks, credentialProvider); - item.hooks = { revision: result.revision, localPath: result.localPath }; - } catch (error) { - item.hooks = { error: error instanceof Error ? error.message : String(error) }; - } - } - refreshed.push(item); +async function runSkills(positionals: string[], options: Record): Promise { + const action = (positionals[0] || "help").toLowerCase(); + const json = boolOption(options, "json", false); + const credentials = createCredentialProvider(); + + if (action === "help" || action === "") { + output.write("Skills commands: validate|publish|contribute-official\n"); + output.write("Use `ica help` to see full skills command syntax.\n"); + return; + } + + if (action === "validate") { + const skillPath = stringOption(options, "path", "").trim(); + if (!skillPath) { + throw new Error("Missing required option --path"); + } + const profile = parseValidationProfileOption(options, "profile"); + const result = await validateSkillBundle({ localPath: path.resolve(skillPath) }, profile); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printValidationResult(result); + } + if (result.errors.length > 0) { + throw new Error(`Validation failed with ${result.errors.length} error(s).`); } - output.write(json ? `${JSON.stringify(refreshed, null, 2)}\n` : `Refreshed ${refreshed.length} repositories.\n`); return; } - throw new Error(`Unknown sources action '${action}'. Supported: list|add|remove|update|auth|refresh`); + if (action === "publish") { + const sourceId = stringOption(options, "source", "").trim(); + if (!sourceId) { + throw new Error("Missing required option --source"); + } + const skillPath = stringOption(options, "path", "").trim(); + if (!skillPath) { + throw new Error("Missing required option --path"); + } + const result = await publishSkillBundle( + { + sourceId, + bundle: { localPath: path.resolve(skillPath) }, + commitMessage: stringOption(options, "message", "").trim() || undefined, + overrideMode: parsePublishModeOption(options, "override-mode"), + overrideBaseBranch: stringOption(options, "override-base-branch", "").trim() || undefined, + }, + credentials, + ); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printPublishResult(result); + } + return; + } + + if (action === "contribute-official") { + const skillPath = stringOption(options, "path", "").trim(); + if (!skillPath) { + throw new Error("Missing required option --path"); + } + const result = await contributeOfficialSkillBundle( + { + sourceId: stringOption(options, "source", "").trim() || undefined, + bundle: { localPath: path.resolve(skillPath) }, + commitMessage: stringOption(options, "message", "").trim() || undefined, + }, + credentials, + ); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printPublishResult(result); + } + return; + } + + throw new Error(`Unknown skills action '${action}'. Supported: validate|publish|contribute-official`); } async function runHooks(positionals: string[], options: Record): Promise { @@ -963,201 +1445,220 @@ async function runHooks(positionals: string[], options: Record): Promise { const repoRoot = findRepoRoot(__dirname); const host = stringOption(options, "host", "127.0.0.1").trim() || "127.0.0.1"; - const uiPort = parseServePort(stringOption(options, "ui-port", stringOption(options, "port", "4173")), "ui-port"); + if (!isLoopbackHost(host)) { + throw new Error(`Refusing non-loopback host '${host}'. The ICA API is localhost-only.`); + } + const uiPortInput = Number(stringOption(options, "ui-port", stringOption(options, "port", "4173")).trim() || "4173"); + const apiPortInput = Number(stringOption(options, "api-port", "4174").trim() || "4174"); + if (!Number.isInteger(uiPortInput) || uiPortInput <= 0) { + throw new Error("Invalid --ui-port value."); + } + if (!Number.isInteger(apiPortInput) || apiPortInput <= 0) { + throw new Error("Invalid --api-port value."); + } + const uiPortExplicit = options["ui-port"] !== undefined || options.port !== undefined; + const apiPortExplicit = options["api-port"] !== undefined; + const reusePorts = parseServeReusePorts(stringOption(options, "reuse-ports", process.env.ICA_REUSE_PORTS || "true")); + if (uiPortInput === apiPortInput) { + throw new Error("API and UI ports must be different. Choose distinct --api-port and --ui-port values."); + } + const containerName = stringOption(options, "container-name", process.env.ICA_DASHBOARD_CONTAINER_NAME || "ica-dashboard"); + const image = stringOption(options, "image", process.env.ICA_DASHBOARD_IMAGE || DEFAULT_DASHBOARD_IMAGE); + const buildMode = parseServeImageBuildMode(stringOption(options, "build-image", process.env.ICA_DASHBOARD_BUILD_IMAGE || "auto")); + const sourcesRefreshMinutes = parseServeRefreshMinutes( + stringOption(options, "sources-refresh-minutes", process.env.ICA_SOURCE_REFRESH_INTERVAL_MINUTES || "60"), + ); + if (!(await commandExists("docker"))) { + throw new Error("Docker CLI is not available."); + } + if (await dockerInspect(containerName)) { + await execFileAsync("docker", ["rm", "-f", containerName], { maxBuffer: 8 * 1024 * 1024 }); + } - const apiPortRaw = stringOption(options, "api-port", "").trim(); - if (apiPortRaw) { - output.write("Notice: --api-port is ignored by the local dashboard server.\n"); + let apiPort = apiPortInput; + let uiPort = uiPortInput; + let uiContainerPort = 0; + if (reusePorts) { + await reclaimLoopbackPort(apiPort, "api-port"); + await reclaimLoopbackPort(uiPort, "ui-port"); + } else { + apiPort = await allocateLoopbackPort({ + preferredPort: apiPortInput, + explicit: apiPortExplicit, + flagName: "api-port", + }); + uiPort = await allocateLoopbackPort({ + preferredPort: uiPortInput, + explicit: uiPortExplicit, + flagName: "ui-port", + blockedPorts: new Set([apiPort]), + }); } - if (options["image"] !== undefined || options["build-image"] !== undefined) { - output.write("Notice: --image/--build-image are ignored in local serve mode.\n"); + const preferredInternalUiPort = Math.max(uiPort, apiPort) + 1; + if (reusePorts) { + uiContainerPort = preferredInternalUiPort; + await reclaimDockerPublishedPort(uiContainerPort, containerName); + if (!(await isLoopbackPortAvailable(uiContainerPort))) { + throw new Error( + `Requested internal dashboard port ${uiContainerPort} is in use by another process. Choose a different --ui-port.`, + ); + } + } else { + uiContainerPort = await allocateLoopbackPort({ + preferredPort: preferredInternalUiPort, + explicit: false, + flagName: "ui-port", + blockedPorts: new Set([apiPort, uiPort]), + }); } - const dashboardScript = path.join(repoRoot, "dist", "src", "installer-dashboard", "server", "index.js"); - if (!fs.existsSync(dashboardScript)) { - throw new Error("Dashboard server is not built. Run: npm run build"); + const apiScript = path.join(repoRoot, "dist", "src", "installer-api", "server", "index.js"); + const bffScript = path.join(repoRoot, "dist", "src", "installer-bff", "server", "index.js"); + if (!fs.existsSync(apiScript)) { + throw new Error("ICA API runtime is not built. Run: npm run build"); + } + if (!fs.existsSync(bffScript)) { + throw new Error("ICA dashboard BFF runtime is not built. Run: npm run build"); } + const apiKey = crypto.randomBytes(24).toString("hex"); + const hostForUrl = host.includes(":") ? `[${host}]` : host; + const apiBaseUrl = `http://${hostForUrl}:${apiPort}`; + const staticOrigin = `http://${hostForUrl}:${uiContainerPort}`; + const dashboardUrl = `http://${hostForUrl}:${uiPort}`; + const open = boolOption(options, "open", false); + + await ensureDashboardImage({ + repoRoot, + image, + mode: buildMode, + defaultImage: DEFAULT_DASHBOARD_IMAGE, + }); - const dashboardUrl = `http://${host}:${uiPort}`; - const child = spawn(process.execPath, [dashboardScript], { - cwd: repoRoot, + const apiProcess = spawn(process.execPath, [apiScript], { + stdio: "inherit", env: { ...process.env, - ICA_DASHBOARD_HOST: host, - ICA_DASHBOARD_PORT: String(uiPort), + ICA_API_HOST: "127.0.0.1", + ICA_API_PORT: String(apiPort), + ICA_API_KEY: apiKey, + ICA_UI_PORT: String(uiPort), + ICA_SOURCE_REFRESH_INTERVAL_MINUTES: String(sourcesRefreshMinutes), }, + }); + let shutdownRequested = false; + let containerStarted = false; + let bffStarted = false; + let apiProcessError: Error | null = null; + let bffProcessError: Error | null = null; + apiProcess.once("error", (error) => { + apiProcessError = error; + shutdownRequested = true; + }); + const bffProcess = spawn(process.execPath, [bffScript], { stdio: "inherit", + env: { + ...process.env, + ICA_BFF_HOST: "127.0.0.1", + ICA_BFF_PORT: String(uiPort), + ICA_BFF_STATIC_ORIGIN: `http://127.0.0.1:${uiContainerPort}`, + ICA_BFF_API_ORIGIN: `http://127.0.0.1:${apiPort}`, + ICA_BFF_API_KEY: apiKey, + }, + }); + bffProcess.once("error", (error) => { + bffProcessError = error; + shutdownRequested = true; }); - const onSigint = () => child.kill("SIGINT"); - const onSigterm = () => child.kill("SIGTERM"); - process.on("SIGINT", onSigint); - process.on("SIGTERM", onSigterm); + const shutdown = async (): Promise => { + if (!shutdownRequested) { + shutdownRequested = true; + } + if (containerStarted) { + try { + await execFileAsync("docker", ["rm", "-f", containerName], { maxBuffer: 8 * 1024 * 1024 }); + } catch { + // ignore cleanup failure + } + } + if (apiProcess.exitCode === null && !apiProcess.killed) { + apiProcess.kill("SIGTERM"); + } + if (bffProcess.exitCode === null && !bffProcess.killed) { + bffProcess.kill("SIGTERM"); + } + }; try { - await waitForDashboardReady(dashboardUrl, child); - output.write(`Dashboard ready: ${dashboardUrl}\n`); - if (boolOption(options, "open", false)) { + await waitForApiReady(apiPort, apiKey); + const runArgs = toDockerRunArgs({ + containerName, + image, + ports: [`127.0.0.1:${uiContainerPort}:80`], + }); + const dockerRunResult = await execFileAsync("docker", runArgs, { maxBuffer: 8 * 1024 * 1024 }); + containerStarted = true; + await waitForHttpReady(`http://127.0.0.1:${uiContainerPort}/`); + await waitForHttpReady(`http://127.0.0.1:${uiPort}/health`); + bffStarted = true; + + output.write(`ICA dashboard listening at ${dashboardUrl}\n`); + output.write(`Dashboard proxy: http://${hostForUrl}:${uiPort} -> ${staticOrigin} + ${apiBaseUrl}\n`); + output.write(`Container: ${containerName} (${(dockerRunResult.stdout || "").trim()})\n`); + output.write( + sourcesRefreshMinutes > 0 + ? `Source auto-refresh: every ${sourcesRefreshMinutes} minute(s)\n` + : "Source auto-refresh: disabled\n", + ); + if (open) { openBrowser(dashboardUrl); } - await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0 || code === null) { - resolve(); - return; - } - reject(new Error(`Dashboard exited with code ${code}.`)); - }); - }); - } finally { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - } -} - -async function runContainer(positionals: string[], options: Record): Promise { - const action = (positionals[0] || "mount-project").toLowerCase(); - if (action !== "mount-project") { - throw new Error(`Unknown container action '${action}'. Supported: mount-project`); - } - const projectPath = stringOption(options, "project-path", "").trim(); - if (!projectPath) { - throw new Error("Missing required option --project-path"); - } - if (!boolOption(options, "confirm", false)) { - throw new Error("Container mount requires --confirm"); - } - - const repoRoot = findRepoRoot(__dirname); - await ensureHelperRunning(repoRoot); - const payload = await helperRequest("/container/mount-project", { - projectPath, - containerName: stringOption(options, "container-name", "") || undefined, - image: stringOption(options, "image", "") || undefined, - port: stringOption(options, "port", "") || undefined, - confirm: true, - }); - if (boolOption(options, "json", false)) { - output.write(`${JSON.stringify(payload, null, 2)}\n`); - return; - } - output.write(`Mounted project path '${projectPath}' into container '${String(payload.containerName || "")}'.\n`); - if (payload.command) { - output.write(`Command: ${String(payload.command)}\n`); - } -} - -async function runSkills(positionals: string[], options: Record): Promise { - const action = (positionals[0] || "validate").toLowerCase(); - const json = boolOption(options, "json", false); - const credentials = createCredentialProvider(); - - if (action === "validate") { - const skillPath = stringOption(options, "path", "").trim(); - if (!skillPath) { - throw new Error("Missing required option --path"); - } - const profile = (stringOption(options, "profile", "personal").trim() || "personal") as ValidationProfile; - if (profile !== "personal" && profile !== "official") { - throw new Error("Invalid --profile. Supported: personal|official"); - } + const requestShutdown = (): void => { + shutdownRequested = true; + }; + process.once("SIGINT", requestShutdown); + process.once("SIGTERM", requestShutdown); - const result = await validateSkillBundle({ localPath: skillPath }, profile); - if (json) { - output.write(`${JSON.stringify(result, null, 2)}\n`); - return; - } - output.write(`Profile: ${result.profile}\n`); - output.write(`Detected files: ${result.detectedFiles.length}\n`); - if (result.warnings.length > 0) { - output.write(`Warnings:\n`); - for (const warning of result.warnings) { - output.write(` - ${warning}\n`); + while (!shutdownRequested) { + if (apiProcess.exitCode !== null) { + throw new Error(`ICA API process exited with code ${apiProcess.exitCode}`); } - } - if (result.errors.length > 0) { - output.write(`Errors:\n`); - for (const err of result.errors) { - output.write(` - ${err}\n`); + if (bffProcess.exitCode !== null) { + throw new Error(`ICA dashboard BFF process exited with code ${bffProcess.exitCode}`); } - throw new Error("Validation failed."); + await new Promise((resolve) => setTimeout(resolve, 250)); } - output.write(`Validation passed.\n`); - return; - } - - if (action === "publish") { - const sourceId = stringOption(options, "source", stringOption(options, "id", "")).trim(); - const skillPath = stringOption(options, "path", "").trim(); - const overrideMode = stringOption(options, "override-mode", "").trim(); - const overrideBaseBranch = stringOption(options, "override-base-branch", "").trim(); - if (!sourceId) throw new Error("Missing required option --source"); - if (!skillPath) throw new Error("Missing required option --path"); - if (overrideMode && overrideMode !== "direct-push" && overrideMode !== "branch-only" && overrideMode !== "branch-pr") { - throw new Error("Invalid --override-mode. Supported: direct-push|branch-only|branch-pr"); + if (apiProcessError) { + throw apiProcessError; } - - const result = await publishSkillBundle( - { - sourceId, - bundle: { - localPath: skillPath, - skillName: stringOption(options, "skill-name", "").trim() || undefined, - }, - commitMessage: stringOption(options, "message", "").trim() || undefined, - overrideMode: overrideMode ? (overrideMode as PublishMode) : undefined, - overrideBaseBranch: overrideBaseBranch || undefined, - }, - credentials, - ); - if (json) { - output.write(`${JSON.stringify(result, null, 2)}\n`); - return; + if (bffProcessError) { + throw bffProcessError; } - output.write(`Published using mode '${result.mode}'.\n`); - output.write(`Branch: ${result.branch}\n`); - output.write(`Commit: ${result.commitSha}\n`); - output.write(`Pushed remote: ${result.pushedRemote}\n`); - if (result.prUrl) output.write(`PR: ${result.prUrl}\n`); - if (result.compareUrl) output.write(`Compare: ${result.compareUrl}\n`); - return; - } - - if (action === "contribute-official") { - const skillPath = stringOption(options, "path", "").trim(); - if (!skillPath) throw new Error("Missing required option --path"); - const result = await contributeOfficialSkillBundle( - { - sourceId: stringOption(options, "source", "").trim() || undefined, - bundle: { - localPath: skillPath, - skillName: stringOption(options, "skill-name", "").trim() || undefined, - }, - commitMessage: stringOption(options, "message", "").trim() || undefined, - }, - credentials, - ); - if (json) { - output.write(`${JSON.stringify(result, null, 2)}\n`); - return; + if (!bffStarted) { + throw new Error("ICA dashboard BFF did not start correctly."); } - output.write(`Official contribution prepared.\n`); - output.write(`Branch: ${result.branch}\n`); - output.write(`Commit: ${result.commitSha}\n`); - output.write(`Pushed remote: ${result.pushedRemote}\n`); - if (result.prUrl) output.write(`PR: ${result.prUrl}\n`); - if (result.compareUrl) output.write(`Compare: ${result.compareUrl}\n`); - return; + await shutdown(); + } catch (error) { + await shutdown(); + throw error; } +} - throw new Error(`Unknown skills action '${action}'. Supported: validate|publish|contribute-official`); +async function runLaunch(options: Record): Promise { + output.write("Deprecation notice: `ica launch` is now an alias of `ica serve` and will be removed in a future release.\n"); + await runServe(options); } async function main(): Promise { const { command, options, positionals } = parseArgv(process.argv.slice(2)); const normalized = command.toLowerCase(); + const repoRoot = findRepoRoot(__dirname); + + if (normalized !== "help") { + await refreshSourcesOnCliStart(); + await maybePrintUpdateNotifier(repoRoot, options); + } if (["install", "uninstall", "sync"].includes(normalized)) { await runOperation(normalized as OperationKind, options); @@ -1184,42 +1685,33 @@ async function main(): Promise { return; } - if (normalized === "hooks") { - await runHooks(positionals, options); - return; - } - if (normalized === "skills") { await runSkills(positionals, options); return; } - if (normalized === "serve") { - await runServe(options); + if (normalized === "hooks") { + await runHooks(positionals, options); return; } - if (normalized === "launch") { - output.write("Deprecation notice: `ica launch` is now an alias of `ica serve` and will be removed in a future release.\n"); + if (normalized === "serve") { await runServe(options); return; } - if (normalized === "container") { - await runContainer(positionals, options); + if (normalized === "launch") { + await runLaunch(options); return; } printHelp(); } -main().catch((error) => { - process.stderr.write(`ICA CLI failed: ${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); - -process.on("exit", () => { - if (helperProcess && !helperProcess.killed) { - helperProcess.kill(); - } -}); +if (require.main === module) { + main().catch((error) => { + const rawMessage = error instanceof Error ? error.message : String(error); + process.stderr.write(`ICA CLI failed: ${redactCliErrorMessage(rawMessage)}\n`); + process.exitCode = 1; + }); +} diff --git a/src/installer-core/catalog.ts b/src/installer-core/catalog.ts index 898d09a..f705062 100644 --- a/src/installer-core/catalog.ts +++ b/src/installer-core/catalog.ts @@ -1,40 +1,40 @@ import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import { SkillCatalog, SkillCatalogEntry, SkillResource, TargetPlatform } from "./types"; import { buildMultiSourceCatalog } from "./catalogMultiSource"; import { isSkillBlocked } from "./skillBlocklist"; -import { DEFAULT_PUBLISH_MODE, DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL } from "./sources"; +import { DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL, getIcaStateRoot } from "./sources"; +import { frontmatterList, frontmatterString, parseFrontmatter } from "./skillMetadata"; +import { pathExists, writeText } from "./fs"; +import { computeDirectoryDigest } from "./contentDigest"; -const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; interface LocalCatalogEntry { name: string; description: string; category: string; + scope?: string; + subcategory?: string; + tags?: string[]; + author?: string; + contactEmail?: string; + website?: string; dependencies: string[]; compatibleTargets: TargetPlatform[]; resources: SkillResource[]; sourcePath: string; + contentDigest?: string; + contentFileCount?: number; } -function parseFrontmatter(content: string): Record { - const match = content.match(FRONTMATTER_RE); - if (!match) { - return {}; - } - - const map: Record = {}; - for (const line of match[1].split("\n")) { - const idx = line.indexOf(":"); - if (idx === -1) continue; - const key = line.slice(0, idx).trim(); - const value = line.slice(idx + 1).trim(); - if (key) { - map[key] = value; - } - } - return map; +interface CacheRecord { + catalog: SkillCatalog; + savedAtMs: number; } +export const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000; +const CATALOG_CACHE_RELATIVE_PATH = path.join("catalog", "skills.catalog.json"); + function inferCategory(skillName: string): string { const roleSkills = new Set([ "pm", @@ -64,30 +64,22 @@ function inferCategory(skillName: string): string { function collectResources(skillDir: string): SkillResource[] { const resources: SkillResource[] = []; - const walk = (current: string): void => { - const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - if (entry.name === ".git") continue; - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - walk(absolute); - continue; - } - if (!(entry.isFile() || entry.isSymbolicLink())) continue; - if (entry.name === "SKILL.md") continue; - - const relative = path.relative(skillDir, absolute).replace(/\\/g, "/"); - const topLevel = relative.split("/", 1)[0]; - const resourceType: SkillResource["type"] = - topLevel === "references" || topLevel === "scripts" || topLevel === "assets" ? topLevel : "other"; + const directories: Array = ["references", "scripts", "assets"]; + + for (const resourceType of directories) { + const location = path.join(skillDir, resourceType); + if (!fs.existsSync(location)) continue; + + for (const file of fs + .readdirSync(location, { withFileTypes: true }) + .filter((entry) => entry.isFile() || entry.isSymbolicLink()) + .sort((a, b) => a.name.localeCompare(b.name))) { resources.push({ type: resourceType, - path: path.join("skills", path.basename(skillDir), relative).replace(/\\/g, "/"), + path: path.join("skills", path.basename(skillDir), resourceType, file.name), }); } - }; - walk(skillDir); - resources.sort((a, b) => a.path.localeCompare(b.path)); + } return resources; } @@ -100,21 +92,36 @@ function toCatalogEntry(skillDir: string, repoRoot: string): LocalCatalogEntry | const content = fs.readFileSync(skillFile, "utf8"); const frontmatter = parseFrontmatter(content); - const name = frontmatter.name || path.basename(skillDir); + const name = frontmatterString(frontmatter, "name") || path.basename(skillDir); if (isSkillBlocked(name)) { return null; } - const description = frontmatter.description || ""; - const explicitCategory = (frontmatter.category || "").trim().toLowerCase(); + const description = frontmatterString(frontmatter, "description") || ""; + const explicitCategory = (frontmatterString(frontmatter, "category") || "").trim().toLowerCase(); + const scope = (frontmatterString(frontmatter, "scope") || "").trim().toLowerCase() || undefined; + const subcategory = (frontmatterString(frontmatter, "subcategory") || "").trim().toLowerCase() || undefined; + const tags = frontmatterList(frontmatter, "tags"); + const author = frontmatterString(frontmatter, "author"); + const contactEmail = frontmatterString(frontmatter, "contact-email") || frontmatterString(frontmatter, "contactEmail"); + const website = frontmatterString(frontmatter, "website"); + const digest = computeDirectoryDigest(skillDir); return { name, description, category: explicitCategory || inferCategory(name), + scope, + subcategory, + tags: tags.length > 0 ? tags : undefined, + author, + contactEmail, + website, dependencies: [], compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], resources: collectResources(skillDir), sourcePath: path.relative(repoRoot, skillDir).replace(/\\/g, "/"), + contentDigest: digest.digest, + contentFileCount: digest.fileCount, }; } @@ -127,6 +134,115 @@ function resolveGeneratedAt(sourceDateEpoch?: string): string { return "1970-01-01T00:00:00.000Z"; } +function normalizeCatalog(repoRoot: string, catalog: SkillCatalog): SkillCatalog { + return { + ...catalog, + sources: catalog.sources || [], + skills: (catalog.skills || []).map((skill) => ({ + ...skill, + skillId: skill.skillId || `${skill.sourceId || "local"}/${skill.skillName || skill.name}`, + sourceId: skill.sourceId || "local", + sourceName: skill.sourceName || skill.sourceId || "local", + sourceUrl: skill.sourceUrl || "", + skillName: skill.skillName || skill.name, + sourcePath: path.isAbsolute(skill.sourcePath || "") ? (skill.sourcePath || "") : path.resolve(repoRoot, skill.sourcePath || ""), + })), + }; +} + +function withLiveDiagnostics(catalog: SkillCatalog): SkillCatalog { + return { + ...catalog, + stale: false, + catalogSource: "live", + staleReason: undefined, + cacheAgeSeconds: undefined, + nextRefreshAt: undefined, + }; +} + +function withSnapshotDiagnostics(catalog: SkillCatalog, staleReason: string): SkillCatalog { + return { + ...catalog, + stale: true, + catalogSource: "snapshot", + staleReason, + cacheAgeSeconds: undefined, + nextRefreshAt: undefined, + }; +} + +function withCacheDiagnostics(catalog: SkillCatalog, savedAtMs: number, nowMs: number, staleReason?: string): SkillCatalog { + const cacheAgeSeconds = Math.max(0, Math.floor((nowMs - savedAtMs) / 1000)); + const nextRefreshAt = new Date(savedAtMs + CATALOG_CACHE_TTL_MS).toISOString(); + const ttlExpired = nowMs >= savedAtMs + CATALOG_CACHE_TTL_MS; + const stale = Boolean(staleReason) || ttlExpired; + + return { + ...catalog, + stale, + catalogSource: "cache", + staleReason: staleReason || (stale ? "Cached catalog is older than refresh TTL." : undefined), + cacheAgeSeconds, + nextRefreshAt, + }; +} + +function liveUnavailableReason(catalog: SkillCatalog): string { + const failures = catalog.sources.filter((source) => source.enabled !== false && source.lastError).map((source) => `${source.id}: ${source.lastError}`); + if (failures.length > 0) { + return `Live catalog refresh failed (${failures.join("; ")}).`; + } + + const hasEnabledSource = catalog.sources.some((source) => source.enabled !== false); + if (!hasEnabledSource) { + return "No enabled skill sources are configured; serving fallback catalog."; + } + + return "Live catalog returned zero skills; serving fallback catalog."; +} + +function shouldAttemptLiveRefresh(refresh: boolean, cache: CacheRecord | null, nowMs: number): boolean { + if (refresh) return true; + if (!cache) return true; + return nowMs >= cache.savedAtMs + CATALOG_CACHE_TTL_MS; +} + +function cachePath(): string { + return path.join(getIcaStateRoot(), CATALOG_CACHE_RELATIVE_PATH); +} + +async function loadCatalogCache(repoRoot: string): Promise { + const targetPath = cachePath(); + if (!(await pathExists(targetPath))) { + return null; + } + + try { + const raw = await fsp.readFile(targetPath, "utf8"); + const parsed = JSON.parse(raw) as SkillCatalog; + const stat = await fsp.stat(targetPath); + return { + catalog: normalizeCatalog(repoRoot, parsed), + savedAtMs: stat.mtimeMs, + }; + } catch { + return null; + } +} + +async function saveCatalogCache(catalog: SkillCatalog): Promise { + const payload: SkillCatalog = { + ...catalog, + stale: undefined, + catalogSource: undefined, + staleReason: undefined, + cacheAgeSeconds: undefined, + nextRefreshAt: undefined, + }; + await writeText(cachePath(), `${JSON.stringify(payload, null, 2)}\n`); +} + export function buildDefaultSourceCatalog(version: string, sourceDateEpoch?: string): SkillCatalog { return { generatedAt: resolveGeneratedAt(sourceDateEpoch), @@ -141,7 +257,7 @@ export function buildDefaultSourceCatalog(version: string, sourceDateEpoch?: str official: true, enabled: true, skillsRoot: DEFAULT_SKILLS_ROOT, - publishDefaultMode: DEFAULT_PUBLISH_MODE, + publishDefaultMode: "branch-pr", defaultBaseBranch: "dev", providerHint: "github", officialContributionEnabled: true, @@ -190,19 +306,7 @@ export function loadCatalog(repoRoot: string, fallbackVersion = "0.0.0"): SkillC const catalogPath = path.join(repoRoot, "src", "catalog", "skills.catalog.json"); if (fs.existsSync(catalogPath)) { const catalog = loadCatalogFromFile(catalogPath); - const normalized: SkillCatalog = { - ...catalog, - sources: catalog.sources || [], - skills: catalog.skills.map((skill) => ({ - ...skill, - skillId: skill.skillId || `${skill.sourceId || "local"}/${skill.skillName || skill.name}`, - sourceId: skill.sourceId || "local", - sourceName: skill.sourceName || skill.sourceId || "local", - sourceUrl: skill.sourceUrl || "", - skillName: skill.skillName || skill.name, - sourcePath: path.isAbsolute(skill.sourcePath) ? skill.sourcePath : path.resolve(repoRoot, skill.sourcePath), - })), - }; + const normalized = normalizeCatalog(repoRoot, catalog); if (normalized.skills.length > 0) { return normalized; } @@ -214,19 +318,52 @@ export function loadCatalog(repoRoot: string, fallbackVersion = "0.0.0"): SkillC export async function loadCatalogFromSources(repoRoot: string, refresh = false): Promise { const versionFile = path.join(repoRoot, "VERSION"); const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, "utf8").trim() : "0.0.0"; - const multi = await buildMultiSourceCatalog({ - repoVersion: version, - refresh, - }); + const snapshot = loadCatalog(repoRoot, version); + const cache = await loadCatalogCache(repoRoot); + const nowMs = Date.now(); + let liveFailureReason: string | undefined; + + if (!shouldAttemptLiveRefresh(refresh, cache, nowMs) && cache) { + return withCacheDiagnostics(cache.catalog, cache.savedAtMs, nowMs); + } + + let multi: SkillCatalog; + try { + multi = await buildMultiSourceCatalog({ + repoVersion: version, + refresh, + }); + } catch { + liveFailureReason = "Live catalog refresh failed unexpectedly; serving fallback catalog."; + multi = { + generatedAt: new Date().toISOString(), + source: "multi-source", + version, + sources: [], + skills: [], + }; + } if (multi.skills.length > 0) { - return multi; + const live = withLiveDiagnostics(multi); + try { + await saveCatalogCache(live); + } catch { + // Cache persistence is best-effort; live catalog should still be returned. + } + return live; } - const fallback = loadCatalog(repoRoot, version); - return { - ...fallback, - source: multi.sources.length > 0 ? "multi-source" : fallback.source, - sources: multi.sources.length > 0 ? multi.sources : fallback.sources, + + const reason = liveFailureReason || liveUnavailableReason(multi); + if (cache) { + return withCacheDiagnostics(cache.catalog, cache.savedAtMs, nowMs, reason); + } + + const fallback: SkillCatalog = { + ...snapshot, + source: multi.sources.length > 0 ? "multi-source" : snapshot.source, + sources: multi.sources.length > 0 ? multi.sources : snapshot.sources, }; + return withSnapshotDiagnostics(fallback, `${reason} Serving bundled snapshot catalog.`); } export function findSkill(catalog: SkillCatalog, skillNameOrId: string): SkillCatalogEntry | undefined { diff --git a/src/installer-core/catalogMultiSource.ts b/src/installer-core/catalogMultiSource.ts index a27e370..87c8e42 100644 --- a/src/installer-core/catalogMultiSource.ts +++ b/src/installer-core/catalogMultiSource.ts @@ -1,90 +1,32 @@ import fs from "node:fs"; import path from "node:path"; import { createCredentialProvider } from "./credentials"; +import { safeErrorMessage } from "./security"; import { setSourceSyncStatus, ensureSourceRegistry, OFFICIAL_SOURCE_ID } from "./sources"; import { syncSource } from "./sourceSync"; import { CatalogSkill, InstallSelection, SkillCatalog, SkillResource, SkillSource, TargetPlatform } from "./types"; import { isSkillBlocked } from "./skillBlocklist"; - -const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; +import { frontmatterList, frontmatterString, parseFrontmatter } from "./skillMetadata"; +import { computeDirectoryDigest } from "./contentDigest"; interface CatalogOptions { repoVersion: string; refresh: boolean; } -interface ParsedFrontmatter { - values: Record; - lists: Record; -} - -function cleanFrontmatterValue(value: string): string { - const trimmed = value.trim(); - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1).trim(); - } - return trimmed; -} - -function parseFrontmatter(content: string): ParsedFrontmatter { - const match = content.match(FRONTMATTER_RE); - if (!match) return { values: {}, lists: {} }; - const values: Record = {}; - const lists: Record = {}; - let currentListKey: string | null = null; - - for (const rawLine of match[1].split("\n")) { - const line = rawLine.replace(/\r$/, ""); - const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); - if (keyMatch) { - const key = keyMatch[1].trim(); - const rawValue = keyMatch[2].trim(); - if (!key) { - currentListKey = null; - continue; - } - - if (rawValue.length === 0) { - currentListKey = key; - if (!lists[key]) lists[key] = []; - continue; - } - - if (rawValue.startsWith("[") && rawValue.endsWith("]")) { - const entries = rawValue - .slice(1, -1) - .split(",") - .map((entry) => cleanFrontmatterValue(entry)) - .filter(Boolean); - if (entries.length > 0) { - lists[key] = entries; - } - } else { - values[key] = cleanFrontmatterValue(rawValue); - } - currentListKey = null; - continue; - } - - const listMatch = line.match(/^\s*-\s*(.+)$/); - if (currentListKey && listMatch) { - const value = cleanFrontmatterValue(listMatch[1]); - if (value) { - if (!lists[currentListKey]) lists[currentListKey] = []; - lists[currentListKey].push(value); - } - continue; - } - - if (line.trim()) { - currentListKey = null; - } - } - - return { values, lists }; +interface SkillIndexEntry { + skillName?: string; + name?: string; + description?: string; + category?: string; + scope?: string; + subcategory?: string; + tags?: string[] | string; + version?: string; + author?: string; + "contact-email"?: string; + contactEmail?: string; + website?: string; } function inferCategory(skillName: string): string { @@ -116,30 +58,23 @@ function inferCategory(skillName: string): string { function collectResources(skillDir: string, skillName: string): SkillResource[] { const resources: SkillResource[] = []; - const walk = (current: string): void => { - const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - if (entry.name === ".git") continue; - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - walk(absolute); - continue; - } - if (!(entry.isFile() || entry.isSymbolicLink())) continue; - if (entry.name === "SKILL.md") continue; + const directories: Array = ["references", "scripts", "assets"]; + for (const type of directories) { + const base = path.join(skillDir, type); + if (!fs.existsSync(base)) continue; - const relative = path.relative(skillDir, absolute).replace(/\\/g, "/"); - const topLevel = relative.split("/", 1)[0]; - const type: SkillResource["type"] = - topLevel === "references" || topLevel === "scripts" || topLevel === "assets" ? topLevel : "other"; + const files = fs + .readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isFile() || entry.isSymbolicLink()) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const file of files) { resources.push({ type, - path: path.join("skills", skillName, relative).replace(/\\/g, "/"), + path: path.join("skills", skillName, type, file.name).replace(/\\/g, "/"), }); } - }; - walk(skillDir); - resources.sort((a, b) => a.path.localeCompare(b.path)); + } return resources; } @@ -151,27 +86,59 @@ function skillRootPath(source: SkillSource, localRepoPath: string): string { return path.join(localRepoPath, relativeRoot); } +function normalizeTags(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((item) => String(item).trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + return []; +} + +function loadSkillIndexEntries(localRepoPath: string, root: string): SkillIndexEntry[] | null { + const candidates = [path.join(localRepoPath, "skills.index.json"), path.join(root, "skills.index.json")]; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) continue; + try { + const raw = JSON.parse(fs.readFileSync(candidate, "utf8")) as { skills?: SkillIndexEntry[] } | SkillIndexEntry[]; + if (Array.isArray(raw)) { + return raw; + } + if (raw && Array.isArray(raw.skills)) { + return raw.skills; + } + } catch { + return null; + } + } + return null; +} + function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | null { const skillFile = path.join(skillDir, "SKILL.md"); if (!fs.existsSync(skillFile)) return null; const content = fs.readFileSync(skillFile, "utf8"); - const parsedFrontmatter = parseFrontmatter(content); - const frontmatter = parsedFrontmatter.values; - const skillName = frontmatter.name || path.basename(skillDir); + const frontmatter = parseFrontmatter(content); + const skillName = frontmatterString(frontmatter, "name") || path.basename(skillDir); if (isSkillBlocked(skillName)) { return null; } const skillId = `${source.id}/${skillName}`; const stat = fs.statSync(skillFile); - const explicitCategory = (frontmatter.category || "").trim().toLowerCase(); - const explicitScope = (frontmatter.scope || "").trim().toLowerCase(); - const tags = Array.from( - new Set( - (parsedFrontmatter.lists.tags || []) - .map((tag) => tag.trim().toLowerCase()) - .filter(Boolean), - ), - ); + const explicitCategory = (frontmatterString(frontmatter, "category") || "").trim().toLowerCase(); + const scope = (frontmatterString(frontmatter, "scope") || "").trim().toLowerCase() || undefined; + const subcategory = (frontmatterString(frontmatter, "subcategory") || "").trim().toLowerCase() || undefined; + const tags = frontmatterList(frontmatter, "tags"); + const author = frontmatterString(frontmatter, "author"); + const contactEmail = frontmatterString(frontmatter, "contact-email") || frontmatterString(frontmatter, "contactEmail"); + const website = frontmatterString(frontmatter, "website"); + const digest = computeDirectoryDigest(skillDir); return { skillId, @@ -180,16 +147,103 @@ function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | n sourceUrl: source.repoUrl, skillName, name: skillName, - description: frontmatter.description || "", + description: frontmatterString(frontmatter, "description") || "", category: explicitCategory || inferCategory(skillName), - scope: explicitScope || undefined, + scope, + subcategory, tags: tags.length > 0 ? tags : undefined, + author, + contactEmail, + website, dependencies: [], compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], resources: collectResources(skillDir, skillName), sourcePath: skillDir, - version: frontmatter.version, + version: frontmatterString(frontmatter, "version"), updatedAt: stat.mtime.toISOString(), + contentDigest: digest.digest, + contentFileCount: digest.fileCount, + }; +} + +function toCatalogSkillFromIndex(source: SkillSource, root: string, entry: SkillIndexEntry): CatalogSkill | null { + const skillName = (entry.skillName || entry.name || "").trim(); + if (!skillName || isSkillBlocked(skillName)) { + return null; + } + + const skillDir = path.join(root, skillName); + const skillFile = path.join(skillDir, "SKILL.md"); + if (!fs.existsSync(skillFile)) { + return null; + } + const stat = fs.statSync(skillFile); + const explicitCategory = (entry.category || "").trim().toLowerCase(); + const scope = (entry.scope || "").trim().toLowerCase() || undefined; + const subcategory = (entry.subcategory || "").trim().toLowerCase() || undefined; + const tags = normalizeTags(entry.tags); + const digest = computeDirectoryDigest(skillDir); + + return { + skillId: `${source.id}/${skillName}`, + sourceId: source.id, + sourceName: source.name, + sourceUrl: source.repoUrl, + skillName, + name: skillName, + description: (entry.description || "").trim(), + category: explicitCategory || inferCategory(skillName), + scope, + subcategory, + tags: tags.length > 0 ? tags : undefined, + author: entry.author?.trim() || undefined, + contactEmail: entry.contactEmail?.trim() || entry["contact-email"]?.trim() || undefined, + website: entry.website?.trim() || undefined, + dependencies: [], + compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], + resources: collectResources(skillDir, skillName), + sourcePath: skillDir, + version: entry.version?.trim() || undefined, + updatedAt: stat.mtime.toISOString(), + contentDigest: digest.digest, + contentFileCount: digest.fileCount, + }; +} + +function skillNameFromIndexEntry(entry: SkillIndexEntry): string { + return (entry.skillName || entry.name || "").trim(); +} + +function loadSkillIndexMap(localRepoPath: string, root: string): Map | null { + const entries = loadSkillIndexEntries(localRepoPath, root); + if (!entries) return null; + + const map = new Map(); + for (const entry of entries) { + const name = skillNameFromIndexEntry(entry); + if (!name) continue; + map.set(name, entry); + } + return map; +} + +function applyIndexMetadata(skill: CatalogSkill, entry: SkillIndexEntry): CatalogSkill { + const explicitCategory = (entry.category || "").trim().toLowerCase(); + const scope = (entry.scope || "").trim().toLowerCase() || undefined; + const subcategory = (entry.subcategory || "").trim().toLowerCase() || undefined; + const tags = normalizeTags(entry.tags); + + return { + ...skill, + description: (entry.description || "").trim() || skill.description, + category: explicitCategory || skill.category, + scope: scope || skill.scope, + subcategory: subcategory || skill.subcategory, + tags: tags.length > 0 ? tags : skill.tags, + author: entry.author?.trim() || skill.author, + contactEmail: entry.contactEmail?.trim() || entry["contact-email"]?.trim() || skill.contactEmail, + website: entry.website?.trim() || skill.website, + version: entry.version?.trim() || skill.version, }; } @@ -246,18 +300,32 @@ export async function buildMultiSourceCatalog(options: CatalogOptions): Promise< throw new Error(`Source '${source.id}' is invalid: missing skills root '${hydrated.skillsRoot}'.`); } + const indexMap = loadSkillIndexMap(localRepoPath, root); + const skillDirs = fs .readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => path.join(root, entry.name)) .sort((a, b) => path.basename(a).localeCompare(path.basename(b))); + const seenSkillNames = new Set(); for (const skillDir of skillDirs) { - const item = toCatalogSkill(hydrated, skillDir); - if (item) catalogSkills.push(item); + const discovered = toCatalogSkill(hydrated, skillDir); + if (!discovered) continue; + seenSkillNames.add(discovered.skillName); + const indexEntry = indexMap?.get(discovered.skillName); + catalogSkills.push(indexEntry ? applyIndexMetadata(discovered, indexEntry) : discovered); + } + + if (indexMap) { + for (const [skillName, entry] of indexMap.entries()) { + if (seenSkillNames.has(skillName)) continue; + const fromIndex = toCatalogSkillFromIndex(hydrated, root, entry); + if (fromIndex) catalogSkills.push(fromIndex); + } } } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = safeErrorMessage(error, "Source refresh failed."); hydratedSources.push({ ...source, lastError: message, diff --git a/src/installer-core/sources.ts b/src/installer-core/sources.ts index f7c5bd9..0375d47 100644 --- a/src/installer-core/sources.ts +++ b/src/installer-core/sources.ts @@ -1,6 +1,7 @@ import os from "node:os"; import path from "node:path"; import { ensureDir, pathExists, readText, writeText } from "./fs"; +import { redactSensitive, stripUrlCredentials } from "./security"; import { GitProvider, PublishMode, SourceTransport, SkillSource } from "./types"; export const OFFICIAL_SOURCE_ID = "official-skills"; @@ -8,7 +9,6 @@ export const OFFICIAL_SOURCE_NAME = "official"; export const OFFICIAL_SOURCE_URL = "https://github.com/intelligentcode-ai/skills.git"; export const DEFAULT_SKILLS_ROOT = "/skills"; export const DEFAULT_PUBLISH_MODE: PublishMode = "branch-pr"; -export const DEFAULT_BASE_BRANCH = "main"; interface AddOrUpdateSourceInput { id?: string; @@ -41,26 +41,6 @@ function detectTransport(repoUrl: string): SourceTransport { return "https"; } -function normalizePublishDefaultMode(mode?: string): PublishMode { - if (mode === "direct-push" || mode === "branch-only" || mode === "branch-pr") { - return mode; - } - return DEFAULT_PUBLISH_MODE; -} - -function normalizeDefaultBaseBranch(branch?: string): string | undefined { - const next = (branch || "").trim(); - return next || undefined; -} - -export function detectGitProvider(repoUrl: string): GitProvider { - const normalized = repoUrl.trim().toLowerCase(); - if (normalized.includes("github.com")) return "github"; - if (normalized.includes("gitlab.com")) return "gitlab"; - if (normalized.includes("bitbucket.org")) return "bitbucket"; - return "unknown"; -} - function slug(value: string): string { return value .toLowerCase() @@ -82,6 +62,37 @@ function sourceIdFromInput(input: AddOrUpdateSourceInput): string { return fromRepo || `source-${Date.now()}`; } +function normalizePublishDefaultMode(mode?: string): PublishMode { + if (mode === "direct-push" || mode === "branch-only" || mode === "branch-pr") { + return mode; + } + return DEFAULT_PUBLISH_MODE; +} + +function detectProviderFromUrl(repoUrl: string): GitProvider { + const normalized = repoUrl.toLowerCase(); + if (normalized.includes("github.com")) return "github"; + if (normalized.includes("gitlab.com")) return "gitlab"; + if (normalized.includes("bitbucket.org")) return "bitbucket"; + return "unknown"; +} + +export function detectGitProvider(repoUrl: string): GitProvider { + return detectProviderFromUrl(repoUrl); +} + +function normalizeProviderHint(providerHint: string | undefined, repoUrl: string): GitProvider { + if (providerHint === "github" || providerHint === "gitlab" || providerHint === "bitbucket" || providerHint === "unknown") { + return providerHint; + } + return detectProviderFromUrl(repoUrl); +} + +function normalizeBaseBranch(branch: string | undefined): string | undefined { + const value = (branch || "").trim(); + return value || undefined; +} + function uniqueSourceId(baseId: string, existing: Set): string { if (!existing.has(baseId)) return baseId; let counter = 2; @@ -93,18 +104,19 @@ function uniqueSourceId(baseId: string, existing: Set): string { function defaultSource(source?: Partial): SkillSource { const repoUrl = source?.repoUrl || OFFICIAL_SOURCE_URL; + const official = source?.official ?? true; return { id: source?.id || OFFICIAL_SOURCE_ID, name: source?.name || OFFICIAL_SOURCE_NAME, repoUrl, transport: source?.transport || detectTransport(repoUrl), - official: source?.official ?? true, + official, enabled: source?.enabled ?? true, skillsRoot: normalizeSkillsRoot(source?.skillsRoot), publishDefaultMode: normalizePublishDefaultMode(source?.publishDefaultMode), - defaultBaseBranch: normalizeDefaultBaseBranch(source?.defaultBaseBranch) || (source?.official ? "dev" : DEFAULT_BASE_BRANCH), - providerHint: source?.providerHint || detectGitProvider(repoUrl), - officialContributionEnabled: source?.officialContributionEnabled ?? Boolean(source?.official), + defaultBaseBranch: normalizeBaseBranch(source?.defaultBaseBranch) || (official ? "dev" : "main"), + providerHint: normalizeProviderHint(source?.providerHint, repoUrl), + officialContributionEnabled: source?.officialContributionEnabled ?? official, credentialRef: source?.credentialRef, removable: source?.removable ?? true, lastSyncAt: source?.lastSyncAt, @@ -138,34 +150,38 @@ export function getSourceRepoPath(sourceId: string): string { return path.join(getSourceCacheRoot(), sourceId, "repo"); } +export function getSourceWorkspaceRoot(): string { + return path.join(getIcaStateRoot(), "source-workspaces"); +} + export function getSourceWorkspaceRepoPath(sourceId: string): string { - return path.join(getIcaStateRoot(), "source-workspaces", sourceId, "repo"); + return path.join(getSourceWorkspaceRoot(), sourceId, "repo"); } export function getSourceSkillsPath(sourceId: string): string { return path.join(getSourceRoot(sourceId), "skills"); } -function normalizeSource(source: Partial & { id: string; repoUrl: string }): SkillSource { - const repoUrl = source.repoUrl.trim(); +function normalizeSource(source: SkillSource): SkillSource { + const cleanRepoUrl = stripUrlCredentials(source.repoUrl.trim()); const official = Boolean(source.official); return { ...source, id: slug(source.id), name: source.name?.trim() || source.id, - repoUrl, - transport: source.transport || detectTransport(repoUrl), + repoUrl: cleanRepoUrl, + transport: source.transport || detectTransport(source.repoUrl), skillsRoot: normalizeSkillsRoot(source.skillsRoot), - publishDefaultMode: normalizePublishDefaultMode(source.publishDefaultMode), - defaultBaseBranch: normalizeDefaultBaseBranch(source.defaultBaseBranch) || (official ? "dev" : DEFAULT_BASE_BRANCH), - providerHint: source.providerHint || detectGitProvider(repoUrl), - officialContributionEnabled: source.officialContributionEnabled ?? official, official, enabled: source.enabled !== false, removable: source.removable !== false, + publishDefaultMode: normalizePublishDefaultMode(source.publishDefaultMode), + defaultBaseBranch: normalizeBaseBranch(source.defaultBaseBranch) || (official ? "dev" : "main"), + providerHint: normalizeProviderHint(source.providerHint, cleanRepoUrl), + officialContributionEnabled: source.officialContributionEnabled ?? official, credentialRef: source.credentialRef?.trim() || undefined, lastSyncAt: source.lastSyncAt, - lastError: source.lastError, + lastError: source.lastError ? redactSensitive(source.lastError) : undefined, localPath: source.localPath, localSkillsPath: source.localSkillsPath, revision: source.revision, @@ -179,13 +195,9 @@ export async function loadSources(): Promise { } try { - const raw = JSON.parse(await readText(sourcesFile)) as { sources?: Array> }; + const raw = JSON.parse(await readText(sourcesFile)) as { sources?: SkillSource[] }; const parsed = Array.isArray(raw.sources) ? raw.sources : []; - const normalized = parsed - .filter((source): source is Partial & { id: string; repoUrl: string } => { - return Boolean(source && typeof source.id === "string" && typeof source.repoUrl === "string"); - }) - .map((source) => normalizeSource(source)); + const normalized = parsed.map((source) => normalizeSource(source)); if (!normalized.find((source) => source.official)) { normalized.unshift(defaultSource()); @@ -218,8 +230,8 @@ export async function addSource(input: AddOrUpdateSourceInput): Promise { + const cli = readCliSource(); + assert.match(cli, /ica skills validate --path= --profile=personal\|official/); + assert.match(cli, /ica skills publish --source= --path=/); + assert.match(cli, /ica skills contribute-official --path=/); +}); + +test("CLI dispatch supports skills command", () => { + const cli = readCliSource(); + assert.match(cli, /if \(normalized === "skills"\) \{/); + assert.match(cli, /await runSkills\(positionals,\s*options\);/); +}); + +test("CLI source updates include publish defaults flags", () => { + const cli = readCliSource(); + assert.match(cli, /publishDefaultMode:\s*parsePublishModeOption\(options,\s*"publish-default-mode"\)/); + assert.match(cli, /defaultBaseBranch:\s*stringOption\(options,\s*"default-base-branch",\s*""\)\s*\|\|\s*undefined/); + assert.match(cli, /providerHint:\s*parseProviderHintOption\(options,\s*"provider-hint"\)/); +});