diff --git a/CHANGELOG.md b/CHANGELOG.md index cf13ba4..95fea8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [12.2.0] - 2026-02-15 + +### Added +- Unified skill publishing pipeline for full skill bundles (content + assets/scripts/references) across personal and official contribution flows. +- CLI skill publishing commands: `ica skills validate`, `ica skills publish`, and `ica skills contribute-official`. +- Source-level publish configuration fields (`publishDefaultMode`, `defaultBaseBranch`, `providerHint`, `officialContributionEnabled`) across source registration and update paths. +- Publish validation profiles (`personal` and `official`) with stricter checks for official contribution paths. + +### Changed +- Preserved the current serve/BFF orchestration contract from `dev` while integrating publishing workflow changes. +- Updated dashboard publishing UX to keep quick actions primary and move target/advanced options into overlays. +- Strengthened source/catalog compatibility to keep modern cache/snapshot fallback behavior while supporting publish metadata. + +### Fixed +- Restored missing CLI skill-publish command surface after merge conflict recovery. +- Fixed source helper/export regressions required by the publish workspace path and provider detection logic. +- Restored publish-related source defaults and migration behavior while preserving credential redaction and URL sanitization. + ## [12.1.1] - 2026-02-15 ### Added 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/VERSION b/VERSION index a554529..6853326 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -12.1.1 +12.2.0 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/VERSION b/src/VERSION index a554529..6853326 100644 --- a/src/VERSION +++ b/src/VERSION @@ -1 +1 @@ -12.1.1 +12.2.0 diff --git a/src/catalog/skills.catalog.json b/src/catalog/skills.catalog.json index 49d4397..450b7b3 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.2.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..641444e 100644 --- a/src/installer-cli/index.ts +++ b/src/installer-cli/index.ts @@ -20,12 +20,25 @@ 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 { 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, TargetPlatform } from "../installer-core/types"; +import { + GitProvider, + InstallMode, + InstallRequest, + InstallScope, + InstallSelection, + OperationKind, + PublishMode, + PublishResult, + TargetPlatform, + ValidationProfile, + ValidationResult, +} from "../installer-core/types"; interface ParsedArgs { command: string; @@ -91,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,]+/) @@ -543,20 +630,25 @@ 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]\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 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`, + ` 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`); @@ -950,6 +1042,10 @@ async function runSources(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).`); + } + return; + } + + 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 { const action = (positionals[0] || "list").toLowerCase(); const json = boolOption(options, "json", false); @@ -1501,6 +1685,11 @@ async function main(): Promise { return; } + if (normalized === "skills") { + await runSkills(positionals, options); + return; + } + if (normalized === "hooks") { await runHooks(positionals, options); return; diff --git a/src/installer-core/catalog.ts b/src/installer-core/catalog.ts index 26641cd..f705062 100644 --- a/src/installer-core/catalog.ts +++ b/src/installer-core/catalog.ts @@ -257,6 +257,10 @@ export function buildDefaultSourceCatalog(version: string, sourceDateEpoch?: str official: true, enabled: true, skillsRoot: DEFAULT_SKILLS_ROOT, + publishDefaultMode: "branch-pr", + defaultBaseBranch: "dev", + providerHint: "github", + officialContributionEnabled: true, removable: true, }, ], 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..0375d47 100644 --- a/src/installer-core/sources.ts +++ b/src/installer-core/sources.ts @@ -2,12 +2,13 @@ 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"; 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; } @@ -57,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; @@ -67,14 +103,20 @@ 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: source?.repoUrl || OFFICIAL_SOURCE_URL, - transport: source?.transport || detectTransport(source?.repoUrl || OFFICIAL_SOURCE_URL), - official: source?.official ?? true, + repoUrl, + transport: source?.transport || detectTransport(repoUrl), + official, enabled: source?.enabled ?? true, skillsRoot: normalizeSkillsRoot(source?.skillsRoot), + publishDefaultMode: normalizePublishDefaultMode(source?.publishDefaultMode), + 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, @@ -108,12 +150,21 @@ 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(getSourceWorkspaceRoot(), 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()); + const official = Boolean(source.official); return { ...source, id: slug(source.id), @@ -121,9 +172,13 @@ function normalizeSource(source: SkillSource): SkillSource { repoUrl: cleanRepoUrl, transport: source.transport || detectTransport(source.repoUrl), skillsRoot: normalizeSkillsRoot(source.skillsRoot), - official: Boolean(source.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 ? redactSensitive(source.lastError) : undefined, @@ -174,6 +229,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/cli-skills-publish.test.ts b/tests/installer/cli-skills-publish.test.ts new file mode 100644 index 0000000..05dac06 --- /dev/null +++ b/tests/installer/cli-skills-publish.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readCliSource(): string { + return fs.readFileSync(path.resolve(process.cwd(), "src/installer-cli/index.ts"), "utf8"); +} + +test("CLI help advertises skills publish workflows", () => { + 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"\)/); +}); 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, };