From e34703178b68fe533afcb61b1f38b44b6ee1ff00 Mon Sep 17 00:00:00 2001 From: Karsten Samaschke Date: Fri, 13 Feb 2026 12:53:36 +0100 Subject: [PATCH 1/2] feat: unify skills and hooks source management Add unified repository registration and sync for skills/hooks, hook catalog/install flows in CLI and dashboard, pluginized dashboard APIs/UI integration, and release version bump to 12.0.0. --- CHANGELOG.md | 13 + VERSION | 2 +- package-lock.json | 4 +- package.json | 2 +- src/VERSION | 2 +- src/catalog/skills.catalog.json | 1084 +---------------- src/installer-cli/index.ts | 404 +++++- src/installer-core/catalog.ts | 23 + src/installer-core/catalog/generateCatalog.ts | 4 +- src/installer-core/executor.ts | 32 +- src/installer-core/hookCatalog.ts | 234 ++++ src/installer-core/hookExecutor.ts | 267 ++++ src/installer-core/hookSources.ts | 260 ++++ src/installer-core/hookState.ts | 94 ++ src/installer-core/hookSync.ts | 207 ++++ src/installer-core/repositories.ts | 162 +++ src/installer-core/sourceAuth.ts | 10 +- src/installer-dashboard/server/index.ts | 510 +++++++- .../server/pluginRegistry.ts | 6 + src/installer-dashboard/server/plugins.ts | 233 ++++ .../server/plugins/diagnostics.ts | 20 + .../web/src/InstallerDashboard.tsx | 444 ++++++- .../web/src/plugins/api.ts | 155 +++ .../web/src/plugins/diagnostics.tsx | 31 + .../web/src/plugins/registry.ts | 6 + src/installer-dashboard/web/src/styles.css | 6 + .../dashboard-server-plugins.test.ts | 108 ++ tests/installer/dashboard-ui-plugins.test.ts | 73 ++ tests/installer/executor-hooks.test.ts | 148 +++ tests/installer/hooks.test.ts | 162 +++ tests/installer/repositories.test.ts | 95 ++ 31 files changed, 3589 insertions(+), 1212 deletions(-) create mode 100644 src/installer-core/hookCatalog.ts create mode 100644 src/installer-core/hookExecutor.ts create mode 100644 src/installer-core/hookSources.ts create mode 100644 src/installer-core/hookState.ts create mode 100644 src/installer-core/hookSync.ts create mode 100644 src/installer-core/repositories.ts create mode 100644 src/installer-dashboard/server/pluginRegistry.ts create mode 100644 src/installer-dashboard/server/plugins.ts create mode 100644 src/installer-dashboard/server/plugins/diagnostics.ts create mode 100644 src/installer-dashboard/web/src/plugins/api.ts create mode 100644 src/installer-dashboard/web/src/plugins/diagnostics.tsx create mode 100644 src/installer-dashboard/web/src/plugins/registry.ts create mode 100644 tests/installer/dashboard-server-plugins.test.ts create mode 100644 tests/installer/dashboard-ui-plugins.test.ts create mode 100644 tests/installer/executor-hooks.test.ts create mode 100644 tests/installer/hooks.test.ts create mode 100644 tests/installer/repositories.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d9cf3..5e928f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.0.0] - 2026-02-13 + +### Added +- Unified repository registration that auto-discovers and syncs both skills and hooks from the same source registration flow. +- First-class hooks lifecycle support across CLI and dashboard: catalog, list, install, uninstall, and sync operations. +- Dedicated Hooks dashboard section with compatibility guidance and per-hook install state/reporting. +- Official hooks source bootstrap support targeting `https://github.com/intelligentcode-ai/hooks.git`. + +### Changed +- Dashboard/server plugin architecture now composes source-aware diagnostics and hooks management with clearer separation. +- Source refresh and sync flows now persist hooks under `~/.ica//hooks` while skills remain under `~/.ica//skills`. +- Multi-source cataloging and executor paths now treat hooks and skills as parallel, source-qualified artifacts. + ## [11.0.1] - 2026-02-13 ### Changed diff --git a/VERSION b/VERSION index 0719738..4044f90 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -11.0.1 +12.0.0 diff --git a/package-lock.json b/package-lock.json index 59d0626..b790161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "intelligent-code-agents", - "version": "11.0.0", + "version": "12.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "intelligent-code-agents", - "version": "11.0.0", + "version": "12.0.0", "dependencies": { "@fastify/cors": "^10.0.1", "@fastify/multipart": "^9.4.0", diff --git a/package.json b/package.json index 027a70e..456c393 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "intelligent-code-agents", - "version": "11.0.1", + "version": "12.0.0", "private": true, "description": "ICA cross-platform interactive installer, CLI, and dashboard", "bin": { diff --git a/src/VERSION b/src/VERSION index 0719738..4044f90 100644 --- a/src/VERSION +++ b/src/VERSION @@ -1 +1 @@ -11.0.1 +12.0.0 diff --git a/src/catalog/skills.catalog.json b/src/catalog/skills.catalog.json index f8ea692..ecf9ee5 100644 --- a/src/catalog/skills.catalog.json +++ b/src/catalog/skills.catalog.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-02-13T08:40:33.904Z", + "generatedAt": "1970-01-01T00:00:00.000Z", "source": "multi-source", "version": "11.0.1", "sources": [ @@ -11,1086 +11,8 @@ "official": true, "enabled": true, "skillsRoot": "/skills", - "removable": true, - "lastSyncAt": "2026-02-13T08:20:25.139Z", - "localPath": "/Users/karsten/.ica/source-cache/official-skills/repo", - "revision": "4c16ceff30a9ee0b72b521fa302b41e1acc02400", - "localSkillsPath": "/Users/karsten/.ica/official-skills/skills" - }, - { - "id": "local-demo", - "name": "local-demo", - "repoUrl": "file:///tmp/ica-skills-source", - "transport": "https", - "official": false, - "enabled": true, - "skillsRoot": "/skills", - "removable": true, - "lastSyncAt": "2026-02-13T08:20:25.430Z", - "localPath": "/Users/karsten/.ica/source-cache/local-demo/repo", - "revision": "f0b690b1f2366872d9ca3bda3fe46beb7c9ac244", - "localSkillsPath": "/Users/karsten/.ica/local-demo/skills" + "removable": true } ], - "skills": [ - { - "skillId": "local-demo/demo-skill", - "sourceId": "local-demo", - "sourceName": "local-demo", - "sourceUrl": "file:///tmp/ica-skills-source", - "skillName": "demo-skill", - "name": "demo-skill", - "description": "Demo skill for multi-source UI test", - "category": "process", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/local-demo/skills/demo-skill", - "version": "1.0.0", - "updatedAt": "2026-02-13T08:20:25.430Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/agent-browser", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.085Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/ai-engineer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.087Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/architect", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.087Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/autonomy", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.088Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/backend-tester", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.089Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/best-practices", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.090Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/branch-protection", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.091Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/commit-pr", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.092Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/database-engineer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.093Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/developer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.094Z" - }, - { - "skillId": "official-skills/devops-board", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "devops-board", - "name": "devops-board", - "description": "Manage work items on the Azure DevOps board for IT Operations / SRE. Use when the user asks to create, update, query, or list work items in Azure DevOps. Triggers on requests like \"create a DevOps task\", \"create work item\", \"add to Azure DevOps board\", \"update DevOps item\", \"list DevOps tasks\", \"what's on the DevOps board\", \"assign DevOps work item\".", - "category": "process", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/devops-board", - "updatedAt": "2026-02-13T08:20:25.095Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/devops-engineer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.096Z" - }, - { - "skillId": "official-skills/engineering-docs", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "engineering-docs", - "name": "engineering-docs", - "description": "Use this skill when the user asks to create a runbook, document an approach, create a guide, generate a post mortem, or add any documentation to the IT Operations Engineering Docs in Notion. Triggers on requests like \"create a runbook\", \"document this\", \"write a post mortem\", \"add to engineering docs\", \"create documentation for\".", - "category": "process", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/engineering-docs", - "updatedAt": "2026-02-13T08:20:25.097Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/file-placement", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.098Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/git-privacy", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.098Z" - }, - { - "skillId": "official-skills/hww-proposal", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "hww-proposal", - "name": "hww-proposal", - "description": "Use this skill when the user asks to create an HWW proposal, a \"how we work\" proposal, or any decision proposal using the IDM (Integrative Decision Making) process. Triggers on requests like \"create an HWW proposal\", \"new proposal for how we work\", \"HWW proposal\", \"create a process proposal\", \"IDM proposal\".", - "category": "process", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/hww-proposal", - "updatedAt": "2026-02-13T08:20:25.099Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/infrastructure-protection", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.103Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [ - { - "type": "references", - "path": "skills/mcp-client/references/example-mcp-config.json" - }, - { - "type": "references", - "path": "skills/mcp-client/references/mcp-servers.md" - }, - { - "type": "references", - "path": "skills/mcp-client/references/python-mcp-sdk.md" - }, - { - "type": "scripts", - "path": "skills/mcp-client/scripts/mcp_client.py" - } - ], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/mcp-client", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.104Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [ - { - "type": "scripts", - "path": "skills/mcp-common/scripts/ica_mcp_core.py" - } - ], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/mcp-common", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.108Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/mcp-config", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.111Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [ - { - "type": "scripts", - "path": "skills/mcp-proxy/scripts/mcp_proxy_cli.py" - }, - { - "type": "scripts", - "path": "skills/mcp-proxy/scripts/mcp_proxy_server.py" - } - ], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/mcp-proxy", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.112Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/memory", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.114Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/parallel-execution", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.120Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/pm", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.121Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/pr-automerge", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.121Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/pr-comments", - "updatedAt": "2026-02-13T08:20:25.122Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/process", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.123Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/qa-engineer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.124Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/release", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.124Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/requirements-engineer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.125Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/reviewer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.126Z" - }, - { - "skillId": "official-skills/search", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "search", - "name": "search", - "description": "Use this skill when the user asks to search for information, find documentation, look up something, or search across knowledge bases. Triggers on requests like \"search for\", \"find docs about\", \"look up\", \"where can I find\", \"search wiki\", \"search notion\".", - "category": "process", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/search", - "updatedAt": "2026-02-13T08:20:25.127Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/security-engineer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.128Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/skill-creator", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.129Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/skill-writer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.130Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/story-breakdown", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.131Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/suggest", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.131Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/system-engineer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.132Z" - }, - { - "skillId": "official-skills/taskboard", - "sourceId": "official-skills", - "sourceName": "official", - "sourceUrl": "https://github.com/intelligentcode-ai/skills.git", - "skillName": "taskboard", - "name": "taskboard", - "description": "Manage tasks in the IT Operations Taskboard (Notion). Use when the user asks to create, update, query, or list tasks in the Notion task tracker. Triggers on requests like \"create a task in Notion\", \"add a task to the board\", \"update task status\", \"assign task to\", \"list Notion tasks\", \"what tasks are open\", \"manage taskboard\", \"task tracker\".", - "category": "process", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/taskboard", - "updatedAt": "2026-02-13T08:20:25.133Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/tdd", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.134Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/thinking", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.135Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/user-tester", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.136Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/validate", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.137Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/web-designer", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.138Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/work-queue", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.139Z" - }, - { - "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", - "dependencies": [], - "compatibleTargets": [ - "claude", - "codex", - "cursor", - "gemini", - "antigravity" - ], - "resources": [], - "sourcePath": "/Users/karsten/.ica/official-skills/skills/workflow", - "version": "10.2.14", - "updatedAt": "2026-02-13T08:20:25.140Z" - } - ] + "skills": [] } diff --git a/src/installer-cli/index.ts b/src/installer-cli/index.ts index ffac190..1d99785 100644 --- a/src/installer-cli/index.ts +++ b/src/installer-cli/index.ts @@ -11,7 +11,13 @@ import { loadCatalogFromSources } from "../installer-core/catalog"; import { createCredentialProvider } from "../installer-core/credentials"; import { checkSourceAuth } from "../installer-core/sourceAuth"; import { syncSource } from "../installer-core/sourceSync"; -import { addSource, ensureSourceRegistry, loadSources, removeSource, updateSource } from "../installer-core/sources"; +import { ensureSourceRegistry, loadSources, removeSource, updateSource } from "../installer-core/sources"; +import { loadHookSources, removeHookSource, updateHookSource } from "../installer-core/hookSources"; +import { syncHookSource } from "../installer-core/hookSync"; +import { loadHookCatalogFromSources, HookInstallSelection } from "../installer-core/hookCatalog"; +import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from "../installer-core/hookExecutor"; +import { loadHookInstallState } from "../installer-core/hookState"; +import { registerRepository } from "../installer-core/repositories"; import { loadInstallState } from "../installer-core/state"; import { parseTargets, resolveTargetPaths } from "../installer-core/targets"; import { findRepoRoot } from "../installer-core/repo"; @@ -106,6 +112,32 @@ function parseSkillSelectors(tokens: string[]): { legacySkills: string[]; select return { legacySkills, selections }; } +function parseHookSelectors(tokens: string[]): { legacyHooks: string[]; selections: HookInstallSelection[] } { + const legacyHooks: string[] = []; + const selections: HookInstallSelection[] = []; + + for (const token of tokens) { + const idx = token.indexOf("/"); + if (idx > 0) { + const sourceId = token.slice(0, idx); + const hookName = token.slice(idx + 1); + if (!sourceId || !hookName) { + legacyHooks.push(token); + continue; + } + selections.push({ + sourceId, + hookName, + hookId: `${sourceId}/${hookName}`, + }); + continue; + } + legacyHooks.push(token); + } + + return { legacyHooks, selections }; +} + function parseTargetsStrict(rawValue: string): TargetPlatform[] { const parsed = parseTargets(rawValue); if (rawValue.trim().length > 0 && parsed.length === 0) { @@ -114,6 +146,15 @@ function parseTargetsStrict(rawValue: string): TargetPlatform[] { return parsed; } +function parseHookTargetsStrict(rawValue: string): HookTargetPlatform[] { + const parsed = parseTargets(rawValue).filter((target): target is HookTargetPlatform => target === "claude" || target === "gemini"); + if (rawValue.trim().length > 0 && parsed.length === 0) { + throw new Error(`No valid hook-capable targets were parsed from '${rawValue}'. Supported: claude,gemini`); + } + if (parsed.length > 0) return parsed; + return ["claude"]; +} + async function helperRequest(pathname: string, body: Record): Promise> { const response = await fetch(`http://${HELPER_HOST}:${HELPER_PORT}${pathname}`, { method: "POST", @@ -189,9 +230,17 @@ function printHelp(): void { 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 remove --id=\n`); - output.write(` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--enabled=true|false]\n`); + output.write( + ` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--hooks-root=/hooks] [--enabled=true|false]\n`, + ); output.write(` ica sources auth --id= [--token=]\n`); output.write(` ica sources refresh [--id=]\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(` Note: repository registration is unified. Adding one source auto-registers both skills and hooks mirrors.\n\n`); output.write(` ica container mount-project --project-path= --confirm [--container-name=] [--image=] [--port=] [--json]\n\n`); output.write(`Common flags:\n`); output.write(` --targets=claude,codex\n`); @@ -425,20 +474,81 @@ async function runSources(positionals: string[], options: Record>[number]; + hooks?: Awaited>[number]; + }> + > => { + const skillSources = await ensureSourceRegistry(); + const hookSources = await loadHookSources(); + const byId = new Map< + string, + { + id: string; + repoUrl: string; + transport: "https" | "ssh"; + name: string; + skills?: Awaited>[number]; + hooks?: Awaited>[number]; + } + >(); + + for (const source of skillSources) { + byId.set(source.id, { + ...(byId.get(source.id) || { + id: source.id, + repoUrl: source.repoUrl, + transport: source.transport, + name: source.name, + }), + repoUrl: source.repoUrl, + transport: source.transport, + name: source.name, + skills: source, + }); + } + for (const source of hookSources) { + byId.set(source.id, { + ...(byId.get(source.id) || { + id: source.id, + repoUrl: source.repoUrl, + transport: source.transport, + name: source.name, + }), + repoUrl: source.repoUrl, + transport: source.transport, + name: source.name, + hooks: source, + }); + } + return Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id)); + }; + if (action === "list") { - const sources = await ensureSourceRegistry(); + const repositories = await loadRepositoryRows(); if (json) { - output.write(`${JSON.stringify(sources, null, 2)}\n`); + output.write(`${JSON.stringify(repositories, null, 2)}\n`); return; } - for (const source of sources) { - output.write(`${source.id} (${source.transport}) ${source.enabled ? "enabled" : "disabled"}\n`); - output.write(` name: ${source.name}\n`); - output.write(` repo: ${source.repoUrl}\n`); - output.write(` root: ${source.skillsRoot}\n`); - output.write(` synced: ${source.lastSyncAt || "(never)"}\n`); - if (source.lastError) { - output.write(` lastError: ${source.lastError}\n`); + for (const repo of repositories) { + const enabled = repo.skills?.enabled !== false || repo.hooks?.enabled !== false; + output.write(`${repo.id} (${repo.transport}) ${enabled ? "enabled" : "disabled"}\n`); + output.write(` name: ${repo.name}\n`); + output.write(` repo: ${repo.repoUrl}\n`); + if (repo.skills) { + output.write(` skillsRoot: ${repo.skills.skillsRoot}\n`); + output.write(` skillsSynced: ${repo.skills.lastSyncAt || "(never)"}\n`); + if (repo.skills.lastError) output.write(` skillsError: ${repo.skills.lastError}\n`); + } + if (repo.hooks) { + output.write(` hooksRoot: ${repo.hooks.hooksRoot}\n`); + output.write(` hooksSynced: ${repo.hooks.lastSyncAt || "(never)"}\n`); + if (repo.hooks.lastError) output.write(` hooksError: ${repo.hooks.lastError}\n`); } } return; @@ -452,31 +562,46 @@ async function runSources(positionals: string[], options: Record item.id === sourceId); + const source = (await loadSources()).find((item) => item.id === sourceId) || (await loadHookSources()).find((item) => item.id === sourceId); if (!source) { throw new Error(`Unknown source '${sourceId}'`); } - const result = await checkSourceAuth(source, credentialProvider); + const result = await checkSourceAuth( + { + id: source.id, + repoUrl: source.repoUrl, + transport: source.transport, + }, + credentialProvider, + ); if (json) { output.write(`${JSON.stringify(result, null, 2)}\n`); } else { @@ -545,24 +715,161 @@ async function runSources(positionals: string[], options: Record source.id === sourceId) : sources.filter((source) => source.enabled); + const repositories = await loadRepositoryRows(); + const targets = sourceId + ? repositories.filter((repo) => repo.id === sourceId) + : repositories.filter((repo) => repo.skills?.enabled !== false || repo.hooks?.enabled !== false); if (targets.length === 0) { throw new Error(sourceId ? `Unknown source '${sourceId}'` : "No enabled sources found."); } - const refreshed: Array<{ id: string; revision: string; localPath: string }> = []; - for (const source of targets) { - const result = await syncSource(source, credentialProvider); - refreshed.push({ id: source.id, revision: result.revision, localPath: result.localPath }); + const refreshed: Array<{ + id: string; + skills?: { revision?: string; localPath?: string; error?: string }; + hooks?: { revision?: string; localPath?: string; error?: string }; + }> = []; + for (const repo of targets) { + const item: { + id: string; + skills?: { revision?: string; localPath?: string; error?: string }; + hooks?: { revision?: string; localPath?: string; error?: string }; + } = { id: repo.id }; + + if (repo.skills) { + try { + const result = await syncSource(repo.skills, credentialProvider); + item.skills = { revision: result.revision, localPath: result.localPath }; + } catch (error) { + item.skills = { error: error instanceof Error ? error.message : String(error) }; + } + } + if (repo.hooks) { + try { + const result = await syncHookSource(repo.hooks, credentialProvider); + item.hooks = { revision: result.revision, localPath: result.localPath }; + } catch (error) { + item.hooks = { error: error instanceof Error ? error.message : String(error) }; + } + } + refreshed.push(item); } - output.write(json ? `${JSON.stringify(refreshed, null, 2)}\n` : `Refreshed ${refreshed.length} source(s).\n`); + output.write(json ? `${JSON.stringify(refreshed, null, 2)}\n` : `Refreshed ${refreshed.length} repositories.\n`); return; } throw new Error(`Unknown sources action '${action}'. Supported: list|add|remove|update|auth|refresh`); } +async function runHooks(positionals: string[], options: Record): Promise { + const action = (positionals[0] || "list").toLowerCase(); + const json = boolOption(options, "json", false); + const repoRoot = findRepoRoot(__dirname); + + if (action === "catalog") { + const catalog = await loadHookCatalogFromSources(repoRoot, false); + if (json) { + output.write(`${JSON.stringify(catalog, null, 2)}\n`); + return; + } + output.write(`Hook catalog version: ${catalog.version}\n`); + output.write(`Generated at: ${catalog.generatedAt}\n`); + for (const hook of catalog.hooks) { + output.write(`- ${hook.hookId}\n`); + if (hook.description) { + output.write(` ${hook.description}\n`); + } + } + return; + } + + if (action === "list") { + const scope = (stringOption(options, "scope", "user") === "project" ? "project" : "user") as "user" | "project"; + const projectPath = stringOption(options, "project-path", "").trim() || (scope === "project" ? process.cwd() : undefined); + const targets = parseHookTargetsStrict(stringOption(options, "targets", "")); + const resolved = resolveTargetPaths(targets, scope, projectPath, stringOption(options, "agent-dir-name", "") || undefined); + + const rows: Array<{ target: HookTargetPlatform; installPath: string; managedHooks: string[]; updatedAt?: string }> = []; + for (const target of resolved) { + const state = await loadHookInstallState(target.installPath); + rows.push({ + target: target.target as HookTargetPlatform, + installPath: target.installPath, + managedHooks: (state?.managedHooks || []).map((hook) => hook.hookId || hook.name), + updatedAt: state?.updatedAt, + }); + } + + if (json) { + output.write(`${JSON.stringify(rows, null, 2)}\n`); + return; + } + for (const row of rows) { + output.write(`${row.target}: ${row.installPath}\n`); + output.write(` Hooks: ${row.managedHooks.length > 0 ? row.managedHooks.join(", ") : "(none)"}\n`); + if (row.updatedAt) { + output.write(` Updated: ${row.updatedAt}\n`); + } + } + return; + } + + if (!["install", "uninstall", "sync"].includes(action)) { + throw new Error(`Unknown hooks action '${action}'. Supported: list|catalog|install|uninstall|sync`); + } + + const catalog = await loadHookCatalogFromSources(repoRoot, false); + const targets = parseHookTargetsStrict(stringOption(options, "targets", "")); + const scope = (stringOption(options, "scope", "user") === "project" ? "project" : "user") as "user" | "project"; + const projectPath = scope === "project" ? stringOption(options, "project-path", "").trim() || process.cwd() : undefined; + const mode = (stringOption(options, "mode", "symlink") === "copy" ? "copy" : "symlink") as "symlink" | "copy"; + + const hooksRaw = stringOption(options, "hooks", ""); + const selectedTokens = + hooksRaw.trim().length > 0 + ? splitCsv(hooksRaw) + : action === "install" || action === "sync" + ? catalog.hooks.map((hook) => hook.hookId) + : []; + const parsedHooks = parseHookSelectors(selectedTokens); + + const request: HookInstallRequest = { + operation: action as "install" | "uninstall" | "sync", + targets, + scope, + projectPath, + agentDirName: stringOption(options, "agent-dir-name", "") || undefined, + mode, + hooks: parsedHooks.legacyHooks, + hookSelections: parsedHooks.selections.length > 0 ? parsedHooks.selections : undefined, + removeUnselected: action === "sync" ? true : boolOption(options, "remove-unselected", false), + force: boolOption(options, "force", false), + }; + + const report = await executeHookOperation(repoRoot, request); + if (json) { + output.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + + for (const target of report.targets) { + output.write(`\n[${target.target}] ${target.operation} -> ${target.installPath}\n`); + output.write(` applied: ${target.appliedHooks.join(", ") || "(none)"}\n`); + output.write(` removed: ${target.removedHooks.join(", ") || "(none)"}\n`); + output.write(` skipped: ${target.skippedHooks.join(", ") || "(none)"}\n`); + + if (target.warnings.length > 0) { + for (const warning of target.warnings) { + output.write(` warning(${warning.code}): ${warning.message}\n`); + } + } + if (target.errors.length > 0) { + for (const error of target.errors) { + output.write(` error(${error.code}): ${error.message}\n`); + } + } + } +} + async function runContainer(positionals: string[], options: Record): Promise { const action = (positionals[0] || "mount-project").toLowerCase(); if (action !== "mount-project") { @@ -624,6 +931,11 @@ async function main(): Promise { return; } + if (normalized === "hooks") { + await runHooks(positionals, options); + return; + } + if (normalized === "container") { await runContainer(positionals, options); return; diff --git a/src/installer-core/catalog.ts b/src/installer-core/catalog.ts index b77d33b..a61f51e 100644 --- a/src/installer-core/catalog.ts +++ b/src/installer-core/catalog.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { SkillCatalog, SkillCatalogEntry, SkillResource, TargetPlatform } from "./types"; import { buildMultiSourceCatalog } from "./catalogMultiSource"; import { isSkillBlocked } from "./skillBlocklist"; +import { DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL } from "./sources"; const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; interface LocalCatalogEntry { @@ -118,6 +119,28 @@ function resolveGeneratedAt(sourceDateEpoch?: string): string { return "1970-01-01T00:00:00.000Z"; } +export function buildDefaultSourceCatalog(version: string, sourceDateEpoch?: string): SkillCatalog { + return { + generatedAt: resolveGeneratedAt(sourceDateEpoch), + source: "multi-source", + version, + sources: [ + { + id: OFFICIAL_SOURCE_ID, + name: OFFICIAL_SOURCE_NAME, + repoUrl: OFFICIAL_SOURCE_URL, + transport: "https", + official: true, + enabled: true, + skillsRoot: DEFAULT_SKILLS_ROOT, + removable: true, + }, + ], + // Skills are discovered at runtime from configured sources. + skills: [], + }; +} + export function buildLocalCatalog(repoRoot: string, version: string, sourceDateEpoch?: string): SkillCatalog { const skillsRoot = path.join(repoRoot, "src", "skills"); const skills = fs.existsSync(skillsRoot) diff --git a/src/installer-core/catalog/generateCatalog.ts b/src/installer-core/catalog/generateCatalog.ts index d49ef82..750754b 100644 --- a/src/installer-core/catalog/generateCatalog.ts +++ b/src/installer-core/catalog/generateCatalog.ts @@ -1,13 +1,13 @@ import fs from "node:fs"; import path from "node:path"; -import { buildLocalCatalog, loadCatalogFromSources } from "../catalog"; +import { buildDefaultSourceCatalog } from "../catalog"; import { findRepoRoot } from "../repo"; async function main(): Promise { const repoRoot = findRepoRoot(__dirname); const versionFile = path.join(repoRoot, "VERSION"); const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, "utf8").trim() : "0.0.0"; - const catalog = await loadCatalogFromSources(repoRoot, false).catch(() => buildLocalCatalog(repoRoot, version, process.env.SOURCE_DATE_EPOCH)); + const catalog = buildDefaultSourceCatalog(version, process.env.SOURCE_DATE_EPOCH); const outPath = path.join(repoRoot, "src", "catalog", "skills.catalog.json"); fs.mkdirSync(path.dirname(outPath), { recursive: true }); diff --git a/src/installer-core/executor.ts b/src/installer-core/executor.ts index 9958d8a..36698a5 100644 --- a/src/installer-core/executor.ts +++ b/src/installer-core/executor.ts @@ -20,6 +20,25 @@ import { } from "./types"; import { parseTargets, resolveTargetPaths } from "./targets"; +export interface InstallHookContext { + repoRoot: string; + request: InstallRequest; + resolvedTarget: ResolvedTargetPath; +} + +export interface PostInstallHookContext extends InstallHookContext { + report: TargetOperationReport; +} + +export interface ExecuteOperationHooks { + onBeforeInstall?(context: InstallHookContext): Promise | void; + onAfterInstall?(context: PostInstallHookContext): Promise | void; +} + +export interface ExecuteOperationOptions { + hooks?: ExecuteOperationHooks; +} + function buildBaselinePaths(installPath: string): string[] { const dirs = BASELINE_DIRECTORIES.map((dirName) => path.join(installPath, dirName)); const files = BASELINE_FILES.map((fileName) => path.join(installPath, fileName)); @@ -270,7 +289,7 @@ function defaultTargetReport(target: TargetPlatform, installPath: string, operat }; } -export async function executeOperation(repoRoot: string, request: InstallRequest): Promise { +export async function executeOperation(repoRoot: string, request: InstallRequest, options: ExecuteOperationOptions = {}): Promise { const startedAt = new Date().toISOString(); const catalog = await loadCatalogFromSources(repoRoot, true); const targets = request.targets.length > 0 ? request.targets : parseTargets(undefined); @@ -290,7 +309,18 @@ export async function executeOperation(repoRoot: string, request: InstallRequest if (request.operation === "uninstall") { await uninstallTarget(request, resolved, report, catalog); } else { + await options.hooks?.onBeforeInstall?.({ + repoRoot, + request, + resolvedTarget: resolved, + }); await installOrSyncTarget(repoRoot, request, resolved, report, catalog); + await options.hooks?.onAfterInstall?.({ + repoRoot, + request, + resolvedTarget: resolved, + report, + }); } } catch (error) { pushError(report, "TARGET_OPERATION_FAILED", error instanceof Error ? error.message : String(error)); diff --git a/src/installer-core/hookCatalog.ts b/src/installer-core/hookCatalog.ts new file mode 100644 index 0000000..7601117 --- /dev/null +++ b/src/installer-core/hookCatalog.ts @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createCredentialProvider } from "./credentials"; +import { ensureHookSourceRegistry, HookSource, setHookSourceSyncStatus } from "./hookSources"; +import { syncHookSource } from "./hookSync"; +import { TargetPlatform } from "./types"; + +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; + +export interface CatalogHook { + hookId: string; + sourceId: string; + sourceName: string; + sourceUrl: string; + hookName: string; + name: string; + description: string; + sourcePath: string; + version?: string; + updatedAt?: string; + compatibleTargets: Array>; +} + +export interface HookCatalog { + generatedAt: string; + source: "multi-source"; + version: string; + sources: HookSource[]; + hooks: CatalogHook[]; +} + +export interface HookInstallSelection { + sourceId: string; + hookName: string; + hookId: string; +} + +interface CatalogOptions { + repoVersion: string; + refresh: boolean; +} + +function parseFrontmatter(content: string): Record { + const match = content.match(FRONTMATTER_RE); + if (!match) return {}; + const map: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) map[key] = value; + } + return map; +} + +function hookRootPath(source: HookSource, localRepoPath: string): string { + if (source.localHooksPath && fs.existsSync(source.localHooksPath)) { + return source.localHooksPath; + } + const relativeRoot = source.hooksRoot.replace(/^\/+/, ""); + return path.join(localRepoPath, relativeRoot); +} + +function toCatalogHook(source: HookSource, hookDir: string): CatalogHook | null { + const hookFile = path.join(hookDir, "HOOK.md"); + const statPath = fs.existsSync(hookFile) ? hookFile : hookDir; + const stat = fs.statSync(statPath); + + let frontmatter: Record = {}; + if (fs.existsSync(hookFile)) { + const content = fs.readFileSync(hookFile, "utf8"); + frontmatter = parseFrontmatter(content); + } + + const hookName = frontmatter.name || path.basename(hookDir); + const hookId = `${source.id}/${hookName}`; + + return { + hookId, + sourceId: source.id, + sourceName: source.name, + sourceUrl: source.repoUrl, + hookName, + name: hookName, + description: frontmatter.description || "", + sourcePath: hookDir, + version: frontmatter.version, + updatedAt: stat.mtime.toISOString(), + compatibleTargets: ["claude", "gemini"], + }; +} + +async function syncIfNeeded(source: HookSource, refresh: boolean): Promise { + if (!source.enabled) return source; + if ( + !refresh && + source.localPath && + source.localHooksPath && + fs.existsSync(path.join(source.localPath, ".git")) && + fs.existsSync(source.localHooksPath) + ) { + return source; + } + + const provider = createCredentialProvider(); + const synced = await syncHookSource(source, provider); + return { + ...source, + localPath: synced.localPath, + localHooksPath: synced.hooksPath, + revision: synced.revision, + lastSyncAt: new Date().toISOString(), + lastError: undefined, + }; +} + +function sortCatalogHooks(hooks: CatalogHook[]): CatalogHook[] { + return hooks.sort((a, b) => a.hookId.localeCompare(b.hookId)); +} + +async function buildMultiSourceHookCatalog(options: CatalogOptions): Promise { + const sources = await ensureHookSourceRegistry(); + const hydratedSources: HookSource[] = []; + const catalogHooks: CatalogHook[] = []; + + for (const source of sources) { + if (!source.enabled) { + hydratedSources.push(source); + continue; + } + + try { + const hydrated = await syncIfNeeded(source, options.refresh); + hydratedSources.push(hydrated); + + const localRepoPath = hydrated.localPath; + if (!localRepoPath) { + throw new Error(`Hook source '${source.id}' has no local checkout path.`); + } + + const root = hookRootPath(hydrated, localRepoPath); + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { + throw new Error(`Hook source '${source.id}' is invalid: missing hooks root '${hydrated.hooksRoot}'.`); + } + + const hookDirs = fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(root, entry.name)) + .sort((a, b) => path.basename(a).localeCompare(path.basename(b))); + + for (const hookDir of hookDirs) { + const item = toCatalogHook(hydrated, hookDir); + if (item) catalogHooks.push(item); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + hydratedSources.push({ + ...source, + lastError: message, + }); + await setHookSourceSyncStatus(source.id, { lastError: message }); + } + } + + return { + generatedAt: new Date().toISOString(), + source: "multi-source", + version: options.repoVersion, + sources: hydratedSources, + hooks: sortCatalogHooks(catalogHooks), + }; +} + +export async function loadHookCatalogFromSources(repoRoot: string, refresh = false): Promise { + const versionFile = path.join(repoRoot, "VERSION"); + const version = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, "utf8").trim() : "0.0.0"; + return buildMultiSourceHookCatalog({ + repoVersion: version, + refresh, + }); +} + +export function findHookById(catalog: HookCatalog, hookId: string): CatalogHook | undefined { + return catalog.hooks.find((hook) => hook.hookId === hookId); +} + +export function findHookBySourceAndName(catalog: HookCatalog, sourceId: string, hookName: string): CatalogHook | undefined { + return catalog.hooks.find((hook) => hook.sourceId === sourceId && hook.hookName === hookName); +} + +export function resolveHookSelections( + catalog: HookCatalog, + selections: HookInstallSelection[] | undefined, + legacyHooks: string[], +): HookInstallSelection[] { + if (Array.isArray(selections) && selections.length > 0) { + return selections.map((selection) => ({ + sourceId: selection.sourceId, + hookName: selection.hookName, + hookId: selection.hookId || `${selection.sourceId}/${selection.hookName}`, + })); + } + + const resolved: HookInstallSelection[] = []; + for (const raw of legacyHooks) { + const token = raw.trim(); + if (!token) continue; + const slashIndex = token.indexOf("/"); + if (slashIndex > 0) { + const sourceId = token.slice(0, slashIndex); + const hookName = token.slice(slashIndex + 1); + resolved.push({ + sourceId, + hookName, + hookId: `${sourceId}/${hookName}`, + }); + continue; + } + + const matches = catalog.hooks.filter((hook) => hook.hookName === token); + if (matches.length !== 1) { + throw new Error(`Legacy hook '${token}' is ambiguous or missing. Use source-qualified format '/'.`); + } + + resolved.push({ + sourceId: matches[0].sourceId, + hookName: matches[0].hookName, + hookId: matches[0].hookId, + }); + } + return resolved; +} diff --git a/src/installer-core/hookExecutor.ts b/src/installer-core/hookExecutor.ts new file mode 100644 index 0000000..1a12c46 --- /dev/null +++ b/src/installer-core/hookExecutor.ts @@ -0,0 +1,267 @@ +import path from "node:path"; +import { copyPath, ensureDir, removePath, trySymlinkDirectory } from "./fs"; +import { loadHookCatalogFromSources, findHookById, resolveHookSelections, HookInstallSelection } from "./hookCatalog"; +import { appendHookHistory, createEmptyHookState, loadHookInstallState, ManagedHookState, saveHookInstallState } from "./hookState"; +import { parseTargets, resolveTargetPaths } from "./targets"; + +export type HookTargetPlatform = "claude" | "gemini"; +export type HookInstallScope = "user" | "project"; +export type HookInstallMode = "symlink" | "copy"; +export type HookOperation = "install" | "uninstall" | "sync"; + +export interface HookInstallRequest { + operation: HookOperation; + targets: HookTargetPlatform[]; + scope: HookInstallScope; + projectPath?: string; + agentDirName?: string; + mode: HookInstallMode; + hooks: string[]; + hookSelections?: HookInstallSelection[]; + removeUnselected?: boolean; + force?: boolean; +} + +export interface HookOperationWarning { + code: string; + message: string; +} + +export interface HookOperationError { + code: string; + message: string; +} + +export interface HookTargetOperationReport { + target: HookTargetPlatform; + installPath: string; + operation: HookOperation; + appliedHooks: string[]; + removedHooks: string[]; + skippedHooks: string[]; + warnings: HookOperationWarning[]; + errors: HookOperationError[]; +} + +export interface HookOperationReport { + startedAt: string; + completedAt: string; + request: HookInstallRequest; + targets: HookTargetOperationReport[]; +} + +function pushWarning(report: HookTargetOperationReport, code: string, message: string): void { + report.warnings.push({ code, message }); +} + +function pushError(report: HookTargetOperationReport, code: string, message: string): void { + report.errors.push({ code, message }); +} + +function defaultTargetReport(target: HookTargetPlatform, installPath: string, operation: HookOperation): HookTargetOperationReport { + return { + target, + installPath, + operation, + appliedHooks: [], + removedHooks: [], + skippedHooks: [], + warnings: [], + errors: [], + }; +} + +function toHookTargets(requested: HookTargetPlatform[]): HookTargetPlatform[] { + const valid = new Set(["claude", "gemini"]); + return requested.filter((target) => valid.has(target)); +} + +async function uninstallTarget(request: HookInstallRequest, installPath: string, report: HookTargetOperationReport): Promise { + if (request.force) { + await removePath(path.join(installPath, "hooks")); + await removePath(path.join(installPath, ".ica", "hook-install-state.json")); + return; + } + + const state = await loadHookInstallState(installPath); + if (!state) { + return; + } + + const selections = request.hookSelections || []; + const selected = new Set(selections.map((selection) => selection.hookId)); + const removeAll = selections.length === 0; + + for (const managed of state.managedHooks) { + const managedId = managed.hookId || managed.name; + if (!removeAll && !selected.has(managedId) && !selected.has(managed.name)) continue; + await removePath(managed.destinationPath); + report.removedHooks.push(managedId); + } + + const removedSet = new Set(report.removedHooks); + const remainingHooks = state.managedHooks.filter((managed) => !removedSet.has(managed.hookId || managed.name)); + + if (removeAll && remainingHooks.length === 0) { + await removePath(path.join(installPath, ".ica", "hook-install-state.json")); + return; + } + + const next = appendHookHistory( + { + ...state, + managedHooks: remainingHooks, + }, + "uninstall", + `Removed ${report.removedHooks.length} hook(s)`, + ); + await saveHookInstallState(installPath, next); +} + +async function installOrSyncTarget(repoRoot: string, request: HookInstallRequest, installPath: string, report: HookTargetOperationReport): Promise { + const catalog = await loadHookCatalogFromSources(repoRoot, true); + + const rawState = + (await loadHookInstallState(installPath)) || + createEmptyHookState({ + installerVersion: catalog.version, + target: report.target, + scope: request.scope, + projectPath: request.projectPath, + }); + const selections = resolveHookSelections(catalog, request.hookSelections, request.hooks); + const selectedHookIds = selections.map((selection) => selection.hookId); + + const hooksDir = path.join(installPath, "hooks"); + await ensureDir(hooksDir); + + const removeUnselected = request.operation === "sync" || Boolean(request.removeUnselected); + + const existingById = new Map(rawState.managedHooks.map((managed) => [managed.hookId || managed.name, managed])); + const nextManagedHooks = [...rawState.managedHooks]; + + if (removeUnselected) { + for (const managed of rawState.managedHooks) { + const managedId = managed.hookId || managed.name; + if (selectedHookIds.includes(managedId)) continue; + await removePath(managed.destinationPath); + report.removedHooks.push(managedId); + } + } + + const selectedNames = new Set(); + for (const hookId of selectedHookIds) { + const hook = findHookById(catalog, hookId); + if (!hook) { + report.skippedHooks.push(hookId); + pushWarning(report, "UNKNOWN_HOOK", `Unknown hook '${hookId}' was skipped.`); + continue; + } + + if (selectedNames.has(hook.hookName)) { + report.skippedHooks.push(hook.hookId); + pushWarning( + report, + "DUPLICATE_HOOK_NAME", + `Skipped '${hook.hookId}' because hook name '${hook.hookName}' is already selected from another source.`, + ); + continue; + } + selectedNames.add(hook.hookName); + + const destination = path.join(hooksDir, hook.name); + await removePath(destination); + + let effectiveMode: HookInstallMode = request.mode; + if (request.mode === "symlink") { + try { + await trySymlinkDirectory(hook.sourcePath, destination); + } catch { + effectiveMode = "copy"; + await copyPath(hook.sourcePath, destination); + pushWarning(report, "SYMLINK_FALLBACK", `Symlink failed for '${hook.name}', fell back to copy mode.`); + } + } else { + await copyPath(hook.sourcePath, destination); + } + + const managed: ManagedHookState = { + name: hook.name, + hookName: hook.hookName, + hookId: hook.hookId, + sourceId: hook.sourceId, + sourceUrl: hook.sourceUrl, + sourceRevision: catalog.sources.find((source) => source.id === hook.sourceId)?.revision, + orphaned: false, + installMode: request.mode, + effectiveMode, + destinationPath: destination, + sourcePath: hook.sourcePath, + }; + + const existing = existingById.get(hook.hookId); + if (existing) { + const index = nextManagedHooks.findIndex((item) => (item.hookId || item.name) === hook.hookId); + if (index >= 0) nextManagedHooks[index] = managed; + } else { + nextManagedHooks.push(managed); + } + + report.appliedHooks.push(hook.hookId); + } + + const removedSet = new Set(report.removedHooks); + const finalManagedHooks = nextManagedHooks + .filter((managed) => !removedSet.has(managed.hookId || managed.name)) + .sort((a, b) => (a.hookId || a.name).localeCompare(b.hookId || b.name)); + + const state = appendHookHistory( + { + ...rawState, + installerVersion: catalog.version, + target: report.target, + scope: request.scope, + projectPath: request.projectPath, + managedHooks: finalManagedHooks, + }, + request.operation, + `Applied ${report.appliedHooks.length}, removed ${report.removedHooks.length}, skipped ${report.skippedHooks.length}`, + ); + + await saveHookInstallState(installPath, state); +} + +export async function executeHookOperation(repoRoot: string, request: HookInstallRequest): Promise { + const startedAt = new Date().toISOString(); + + const requestedTargets = request.targets.length > 0 ? request.targets : (parseTargets(undefined) as HookTargetPlatform[]); + const targets = toHookTargets(requestedTargets); + if (targets.length === 0) { + throw new Error("No hook-capable targets were specified or discovered"); + } + + const resolvedTargets = resolveTargetPaths(targets, request.scope, request.projectPath, request.agentDirName); + const reports: HookTargetOperationReport[] = []; + + for (const resolved of resolvedTargets) { + const report = defaultTargetReport(resolved.target as HookTargetPlatform, resolved.installPath, request.operation); + reports.push(report); + + try { + if (request.operation === "uninstall") { + await uninstallTarget(request, resolved.installPath, report); + } else { + await installOrSyncTarget(repoRoot, request, resolved.installPath, report); + } + } catch (error) { + pushError(report, "TARGET_OPERATION_FAILED", error instanceof Error ? error.message : String(error)); + } + } + + return { + startedAt, + completedAt: new Date().toISOString(), + request, + targets: reports, + }; +} diff --git a/src/installer-core/hookSources.ts b/src/installer-core/hookSources.ts new file mode 100644 index 0000000..99dd075 --- /dev/null +++ b/src/installer-core/hookSources.ts @@ -0,0 +1,260 @@ +import path from "node:path"; +import { ensureDir, pathExists, readText, writeText } from "./fs"; +import { getIcaStateRoot } from "./sources"; +import { SourceTransport } from "./types"; + +export interface HookSource { + id: string; + name: string; + repoUrl: string; + transport: SourceTransport; + official: boolean; + enabled: boolean; + hooksRoot: string; + credentialRef?: string; + removable: boolean; + lastSyncAt?: string; + lastError?: string; + localPath?: string; + localHooksPath?: string; + revision?: string; +} + +interface AddOrUpdateHookSourceInput { + id?: string; + name?: string; + repoUrl: string; + transport?: SourceTransport; + official?: boolean; + enabled?: boolean; + hooksRoot?: string; + credentialRef?: string; + removable?: boolean; +} + +export const OFFICIAL_HOOK_SOURCE_ID = "official-hooks"; +export const OFFICIAL_HOOK_SOURCE_NAME = "official-hooks"; +export const OFFICIAL_HOOK_SOURCE_URL = "https://github.com/intelligentcode-ai/hooks.git"; +export const DEFAULT_HOOKS_ROOT = "/hooks"; + +function normalizeHooksRoot(hooksRoot?: string): string { + const next = (hooksRoot || DEFAULT_HOOKS_ROOT).trim(); + if (!next.startsWith("/")) { + throw new Error(`hooksRoot must be absolute inside repository (example: '/hooks'). Received: '${hooksRoot || ""}'`); + } + return next.replace(/\/+$/, "") || "/"; +} + +function detectTransport(repoUrl: string): SourceTransport { + if (repoUrl.startsWith("git@") || repoUrl.startsWith("ssh://")) { + return "ssh"; + } + return "https"; +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64); +} + +function hookSourceIdFromInput(input: AddOrUpdateHookSourceInput): string { + if (input.id && input.id.trim()) { + return slug(input.id); + } + + const fromName = input.name ? slug(input.name) : ""; + if (fromName) return fromName; + + const fromRepo = slug(input.repoUrl.replace(/^https?:\/\//, "").replace(/^ssh:\/\//, "").replace(/^git@/, "")); + return fromRepo || `hook-source-${Date.now()}`; +} + +function uniqueHookSourceId(baseId: string, existing: Set): string { + if (!existing.has(baseId)) return baseId; + let counter = 2; + while (existing.has(`${baseId}-${counter}`)) { + counter += 1; + } + return `${baseId}-${counter}`; +} + +function defaultHookSource(source?: Partial): HookSource { + return { + id: source?.id || OFFICIAL_HOOK_SOURCE_ID, + name: source?.name || OFFICIAL_HOOK_SOURCE_NAME, + repoUrl: source?.repoUrl || OFFICIAL_HOOK_SOURCE_URL, + transport: source?.transport || detectTransport(source?.repoUrl || OFFICIAL_HOOK_SOURCE_URL), + official: source?.official ?? true, + enabled: source?.enabled ?? true, + hooksRoot: normalizeHooksRoot(source?.hooksRoot), + credentialRef: source?.credentialRef, + removable: source?.removable ?? true, + lastSyncAt: source?.lastSyncAt, + lastError: source?.lastError, + localPath: source?.localPath, + localHooksPath: source?.localHooksPath, + revision: source?.revision, + }; +} + +export function getHookSourcesFilePath(): string { + return path.join(getIcaStateRoot(), "hook-sources.json"); +} + +export function getHookSourceCacheRoot(): string { + return path.join(getIcaStateRoot(), "hook-source-cache"); +} + +export function getHookSourceRoot(sourceId: string): string { + return path.join(getIcaStateRoot(), sourceId); +} + +export function getHookSourceRepoPath(sourceId: string): string { + return path.join(getHookSourceCacheRoot(), sourceId, "repo"); +} + +export function getHookSourceHooksPath(sourceId: string): string { + return path.join(getHookSourceRoot(sourceId), "hooks"); +} + +function normalizeHookSource(source: HookSource): HookSource { + return { + ...source, + id: slug(source.id), + name: source.name?.trim() || source.id, + repoUrl: source.repoUrl.trim(), + transport: source.transport || detectTransport(source.repoUrl), + hooksRoot: normalizeHooksRoot(source.hooksRoot), + official: Boolean(source.official), + enabled: source.enabled !== false, + removable: source.removable !== false, + credentialRef: source.credentialRef?.trim() || undefined, + lastSyncAt: source.lastSyncAt, + lastError: source.lastError, + localPath: source.localPath, + localHooksPath: source.localHooksPath, + revision: source.revision, + }; +} + +export async function loadHookSources(): Promise { + const sourceFile = getHookSourcesFilePath(); + if (!(await pathExists(sourceFile))) { + return [defaultHookSource()]; + } + + try { + const raw = JSON.parse(await readText(sourceFile)) as { sources?: HookSource[] }; + const parsed = Array.isArray(raw.sources) ? raw.sources : []; + const normalized = parsed.map((source) => normalizeHookSource(source)); + if (!normalized.find((source) => source.official)) { + normalized.unshift(defaultHookSource()); + } + return normalized; + } catch (error) { + throw new Error(`Failed to read hook source registry (${sourceFile}): ${error instanceof Error ? error.message : String(error)}`); + } +} + +export async function saveHookSources(sources: HookSource[]): Promise { + const normalized = sources.map((source) => normalizeHookSource(source)); + await ensureDir(path.dirname(getHookSourcesFilePath())); + await writeText(getHookSourcesFilePath(), `${JSON.stringify({ sources: normalized }, null, 2)}\n`); +} + +export async function addHookSource(input: AddOrUpdateHookSourceInput): Promise { + const sources = await loadHookSources(); + const existingIds = new Set(sources.map((source) => source.id)); + + const baseId = hookSourceIdFromInput(input); + const id = uniqueHookSourceId(baseId, existingIds); + + const source = normalizeHookSource({ + id, + name: input.name || id, + repoUrl: input.repoUrl, + transport: input.transport || detectTransport(input.repoUrl), + official: input.official ?? false, + enabled: input.enabled ?? true, + hooksRoot: normalizeHooksRoot(input.hooksRoot), + credentialRef: input.credentialRef, + removable: input.removable ?? true, + }); + + sources.push(source); + await saveHookSources(sources); + return source; +} + +export async function updateHookSource(sourceId: string, patch: Partial): Promise { + const sources = await loadHookSources(); + const idx = sources.findIndex((source) => source.id === sourceId); + if (idx === -1) { + throw new Error(`Unknown hook source '${sourceId}'`); + } + + const current = sources[idx]; + const next: HookSource = normalizeHookSource({ + ...current, + name: patch.name ?? current.name, + repoUrl: patch.repoUrl ?? current.repoUrl, + transport: patch.transport ?? current.transport, + enabled: patch.enabled ?? current.enabled, + hooksRoot: patch.hooksRoot ?? current.hooksRoot, + credentialRef: patch.credentialRef ?? current.credentialRef, + removable: patch.removable ?? current.removable, + official: patch.official ?? current.official, + }); + + sources[idx] = next; + await saveHookSources(sources); + return next; +} + +export async function removeHookSource(sourceId: string): Promise { + const sources = await loadHookSources(); + const idx = sources.findIndex((source) => source.id === sourceId); + if (idx === -1) { + throw new Error(`Unknown hook source '${sourceId}'`); + } + + const source = sources[idx]; + if (!source.removable) { + throw new Error(`Hook source '${sourceId}' cannot be removed.`); + } + + sources.splice(idx, 1); + await saveHookSources(sources); + return source; +} + +export async function setHookSourceSyncStatus( + sourceId: string, + status: { lastSyncAt?: string; lastError?: string; localPath?: string; localHooksPath?: string; revision?: string }, +): Promise { + const sources = await loadHookSources(); + const idx = sources.findIndex((source) => source.id === sourceId); + if (idx === -1) return; + + const source = sources[idx]; + const next: HookSource = { + ...source, + lastSyncAt: status.lastSyncAt ?? source.lastSyncAt, + lastError: status.lastError, + localPath: status.localPath ?? source.localPath, + localHooksPath: status.localHooksPath ?? source.localHooksPath, + revision: status.revision ?? source.revision, + }; + sources[idx] = normalizeHookSource(next); + await saveHookSources(sources); +} + +export async function ensureHookSourceRegistry(): Promise { + const sources = await loadHookSources(); + await saveHookSources(sources); + return sources; +} diff --git a/src/installer-core/hookState.ts b/src/installer-core/hookState.ts new file mode 100644 index 0000000..593435a --- /dev/null +++ b/src/installer-core/hookState.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import { ensureDir, pathExists, readText, writeText } from "./fs"; + +export type HookOperationKind = "install" | "uninstall" | "sync"; + +export interface ManagedHookState { + name: string; + hookName?: string; + hookId: string; + sourceId: string; + sourceUrl: string; + sourceRevision?: string; + orphaned?: boolean; + installMode: "symlink" | "copy"; + effectiveMode: "symlink" | "copy"; + destinationPath: string; + sourcePath: string; +} + +export interface HookOperationLogEntry { + timestamp: string; + operation: HookOperationKind; + summary: string; +} + +export interface HookInstallState { + schemaVersion: string; + installerVersion: string; + target: "claude" | "gemini"; + scope: "user" | "project"; + projectPath?: string; + installedAt: string; + updatedAt: string; + managedHooks: ManagedHookState[]; + managedBaselinePaths: string[]; + history: HookOperationLogEntry[]; +} + +export const HOOK_INSTALL_STATE_SCHEMA_VERSION = "1.0.0"; + +export function getHookStatePath(installPath: string): string { + return path.join(installPath, ".ica", "hook-install-state.json"); +} + +export async function loadHookInstallState(installPath: string): Promise { + const statePath = getHookStatePath(installPath); + if (!(await pathExists(statePath))) { + return null; + } + + const content = await readText(statePath); + return JSON.parse(content) as HookInstallState; +} + +export async function saveHookInstallState(installPath: string, state: HookInstallState): Promise { + const statePath = getHookStatePath(installPath); + await ensureDir(path.dirname(statePath)); + await writeText(statePath, `${JSON.stringify(state, null, 2)}\n`); +} + +export function createEmptyHookState(params: { + installerVersion: string; + target: HookInstallState["target"]; + scope: HookInstallState["scope"]; + projectPath?: string; +}): HookInstallState { + const now = new Date().toISOString(); + return { + schemaVersion: HOOK_INSTALL_STATE_SCHEMA_VERSION, + installerVersion: params.installerVersion, + target: params.target, + scope: params.scope, + projectPath: params.projectPath, + installedAt: now, + updatedAt: now, + managedHooks: [], + managedBaselinePaths: [], + history: [], + }; +} + +export function appendHookHistory(state: HookInstallState, operation: HookOperationKind, summary: string): HookInstallState { + const next = { ...state }; + next.updatedAt = new Date().toISOString(); + next.history = [ + ...next.history, + { + timestamp: next.updatedAt, + operation, + summary, + }, + ].slice(-100); + return next; +} diff --git a/src/installer-core/hookSync.ts b/src/installer-core/hookSync.ts new file mode 100644 index 0000000..a861996 --- /dev/null +++ b/src/installer-core/hookSync.ts @@ -0,0 +1,207 @@ +import path from "node:path"; +import fsp from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { CredentialProvider } from "./credentials"; +import { copyPath, ensureDir, pathExists, removePath } from "./fs"; +import { withHttpsCredential } from "./sourceAuth"; +import { getHookSourceHooksPath, getHookSourceRepoPath, HookSource, setHookSourceSyncStatus } from "./hookSources"; + +const execFileAsync = promisify(execFile); +const hookSourceSyncLocks = new Map>(); + +export interface HookSourceSyncResult { + source: HookSource; + localPath: string; + hooksPath: string; + revision: string; +} + +async function runGit(args: string[], cwd?: string): Promise { + const result = await execFileAsync("git", args, { + cwd, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + }, + timeout: 120_000, + maxBuffer: 8 * 1024 * 1024, + }); + return (result.stdout || "").trim(); +} + +function isConfigLockError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /could not lock config file/i.test(message); +} + +async function runGitWithLockRetry(args: string[], cwd?: string, maxAttempts = 5): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return await runGit(args, cwd); + } catch (error) { + if (!isConfigLockError(error) || attempt >= maxAttempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 120 * attempt)); + } + } + throw new Error("git command failed unexpectedly"); +} + +async function withHookSourceSyncLock(sourceId: string, task: () => Promise): Promise { + const previous = hookSourceSyncLocks.get(sourceId) || Promise.resolve(); + let releaseLock = () => {}; + const lock = new Promise((resolve) => { + releaseLock = resolve; + }); + const chain = previous.then(() => lock); + hookSourceSyncLocks.set(sourceId, chain); + + await previous; + try { + return await task(); + } finally { + releaseLock(); + if (hookSourceSyncLocks.get(sourceId) === chain) { + hookSourceSyncLocks.delete(sourceId); + } + } +} + +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 to common defaults. + } + + try { + await runGit(["show-ref", "--verify", "--quiet", "refs/remotes/origin/main"], repoPath); + return "main"; + } catch { + return "master"; + } +} + +async function setOriginUrl(repoPath: string, repoUrl: string): Promise { + await runGitWithLockRetry(["remote", "set-url", "origin", repoUrl], repoPath); +} + +function buildRemoteUrl(source: HookSource, token: string | null): string { + if (source.transport === "https" && token) { + return withHttpsCredential(source.repoUrl, token); + } + return source.repoUrl; +} + +function hasHookMarker(repoRoot: string, hookDirName: string): Promise { + const hookDir = path.join(repoRoot, hookDirName); + const candidate = path.join(hookDir, "HOOK.md"); + return pathExists(candidate); +} + +async function findHookRootWithFallback(source: HookSource, repoPath: string): Promise { + const configuredRoot = path.join(repoPath, source.hooksRoot.replace(/^\/+/, "")); + if (await pathExists(configuredRoot)) { + return configuredRoot; + } + + // Compatibility fallback: when /hooks is absent, allow repo-root hooks. + const entries = await fsp.readdir(repoPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === ".git") continue; + if (await hasHookMarker(repoPath, entry.name)) { + return repoPath; + } + + // Optional HOOK.md structure: if absent, still allow directories that contain regular files. + const dirEntries = await fsp.readdir(path.join(repoPath, entry.name), { withFileTypes: true }); + if (dirEntries.some((item) => item.isFile() || item.isSymbolicLink())) { + return repoPath; + } + } + + throw new Error(`Hook source '${source.id}' is invalid: missing required hooks root '${source.hooksRoot}'.`); +} + +async function mirrorHooksToStateStore(source: HookSource, repoPath: string): Promise { + const repoHooksRoot = await findHookRootWithFallback(source, repoPath); + const destinationRoot = getHookSourceHooksPath(source.id); + await removePath(destinationRoot); + await ensureDir(destinationRoot); + + const entries = await fsp.readdir(repoHooksRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === ".git") { + continue; + } + const from = path.join(repoHooksRoot, entry.name); + const to = path.join(destinationRoot, entry.name); + await copyPath(from, to); + } + + return destinationRoot; +} + +export async function syncHookSource(source: HookSource, credentials: CredentialProvider): Promise { + return withHookSourceSyncLock(source.id, async () => { + const repoPath = getHookSourceRepoPath(source.id); + const sourceRoot = path.dirname(repoPath); + await ensureDir(sourceRoot); + + const token = source.transport === "https" ? await credentials.get(source.id) : null; + const remoteUrl = buildRemoteUrl(source, token); + const hasGitRepo = await pathExists(path.join(repoPath, ".git")); + const plainRemote = source.repoUrl; + + try { + if (!hasGitRepo) { + await runGit(["clone", "--depth", "1", remoteUrl, repoPath], sourceRoot); + await setOriginUrl(repoPath, plainRemote); + } else { + await setOriginUrl(repoPath, remoteUrl); + await runGit(["fetch", "--all", "--prune"], repoPath); + await setOriginUrl(repoPath, plainRemote); + } + + const branch = await detectDefaultBranch(repoPath); + await runGit(["checkout", "-f", branch], repoPath); + await runGit(["reset", "--hard", `origin/${branch}`], repoPath); + const revision = await runGit(["rev-parse", "HEAD"], repoPath); + const hooksPath = await mirrorHooksToStateStore(source, repoPath); + + await setHookSourceSyncStatus(source.id, { + lastSyncAt: new Date().toISOString(), + lastError: undefined, + localPath: repoPath, + localHooksPath: hooksPath, + revision, + }); + + return { + source, + localPath: repoPath, + hooksPath, + revision, + }; + } catch (error) { + await setHookSourceSyncStatus(source.id, { + lastError: error instanceof Error ? error.message : String(error), + localPath: repoPath, + }); + throw error; + } finally { + if (hasGitRepo) { + try { + await setOriginUrl(repoPath, plainRemote); + } catch { + // Ignore fallback reset failures. + } + } + } + }); +} diff --git a/src/installer-core/repositories.ts b/src/installer-core/repositories.ts new file mode 100644 index 0000000..47bb8a5 --- /dev/null +++ b/src/installer-core/repositories.ts @@ -0,0 +1,162 @@ +import { CredentialProvider } from "./credentials"; +import { addHookSource, HookSource, loadHookSources, updateHookSource } from "./hookSources"; +import { syncHookSource } from "./hookSync"; +import { addSource, loadSources, updateSource } from "./sources"; +import { SkillSource } from "./types"; +import { SourceTransport } from "./types"; +import { syncSource } from "./sourceSync"; + +export interface RepositoryRegistrationInput { + id?: string; + name?: string; + repoUrl: string; + transport?: SourceTransport; + enabled?: boolean; + removable?: boolean; + official?: boolean; + skillsRoot?: string; + hooksRoot?: string; + token?: string; +} + +export interface RepositorySyncResult { + ok: boolean; + revision?: string; + localPath?: string; + error?: string; +} + +export interface RepositoryRegistrationResult { + skillSource: SkillSource; + hookSource: HookSource; + sync: { + skills: RepositorySyncResult; + hooks: RepositorySyncResult; + }; +} + +function sourceTransport(input: RepositoryRegistrationInput): SourceTransport { + if (input.transport) return input.transport; + if (input.repoUrl.startsWith("git@") || input.repoUrl.startsWith("ssh://")) { + return "ssh"; + } + return "https"; +} + +async function upsertSkillSource(input: RepositoryRegistrationInput): Promise { + const id = input.id?.trim(); + if (id) { + const existing = (await loadSources()).find((source) => source.id === id); + if (existing) { + return updateSource(id, { + name: input.name, + repoUrl: input.repoUrl, + transport: sourceTransport(input), + enabled: input.enabled, + skillsRoot: input.skillsRoot, + removable: input.removable, + official: input.official, + }); + } + } + + return addSource({ + id: input.id, + name: input.name, + repoUrl: input.repoUrl, + transport: sourceTransport(input), + enabled: input.enabled, + skillsRoot: input.skillsRoot, + removable: input.removable, + official: input.official, + }); +} + +async function upsertHookSource(input: RepositoryRegistrationInput): Promise { + const id = input.id?.trim(); + if (id) { + const existing = (await loadHookSources()).find((source) => source.id === id); + if (existing) { + return updateHookSource(id, { + name: input.name, + repoUrl: input.repoUrl, + transport: sourceTransport(input), + enabled: input.enabled, + hooksRoot: input.hooksRoot, + removable: input.removable, + official: input.official, + }); + } + } + + return addHookSource({ + id: input.id, + name: input.name, + repoUrl: input.repoUrl, + transport: sourceTransport(input), + enabled: input.enabled, + hooksRoot: input.hooksRoot, + removable: input.removable, + official: input.official, + }); +} + +export async function registerRepository( + input: RepositoryRegistrationInput, + credentials: CredentialProvider, +): Promise { + const skillSource = await upsertSkillSource(input); + const hookSource = await upsertHookSource({ + ...input, + id: input.id || skillSource.id, + name: input.name || skillSource.name, + }); + + const token = input.token?.trim(); + if (token && sourceTransport(input) === "https") { + await credentials.store(skillSource.id, token); + if (hookSource.id !== skillSource.id) { + await credentials.store(hookSource.id, token); + } + } + + let skillSync: RepositorySyncResult = { ok: false, error: "Not synced." }; + let hookSync: RepositorySyncResult = { ok: false, error: "Not synced." }; + + try { + const sync = await syncSource(skillSource, credentials); + skillSync = { + ok: true, + revision: sync.revision, + localPath: sync.localPath, + }; + } catch (error) { + skillSync = { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + try { + const sync = await syncHookSource(hookSource, credentials); + hookSync = { + ok: true, + revision: sync.revision, + localPath: sync.localPath, + }; + } catch (error) { + hookSync = { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + return { + skillSource, + hookSource, + sync: { + skills: skillSync, + hooks: hookSync, + }, + }; +} diff --git a/src/installer-core/sourceAuth.ts b/src/installer-core/sourceAuth.ts index e369c4b..1e6fcb4 100644 --- a/src/installer-core/sourceAuth.ts +++ b/src/installer-core/sourceAuth.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { CredentialProvider } from "./credentials"; -import { SkillSource } from "./types"; +import { SourceTransport } from "./types"; const execFileAsync = promisify(execFile); @@ -11,6 +11,12 @@ export interface SourceAuthCheckResult { message: string; } +export interface AuthCheckSource { + id: string; + repoUrl: string; + transport: SourceTransport; +} + export function withHttpsCredential(repoUrl: string, token: string): string { const parsed = new URL(repoUrl); parsed.username = "oauth2"; @@ -29,7 +35,7 @@ async function runGitLsRemote(repoUrl: string): Promise { }); } -export async function checkSourceAuth(source: SkillSource, credentials: CredentialProvider): Promise { +export async function checkSourceAuth(source: AuthCheckSource, credentials: CredentialProvider): Promise { let token: string | null = null; if (source.transport === "https") { token = await credentials.get(source.id); diff --git a/src/installer-dashboard/server/index.ts b/src/installer-dashboard/server/index.ts index 3dac9fd..597a3db 100644 --- a/src/installer-dashboard/server/index.ts +++ b/src/installer-dashboard/server/index.ts @@ -9,18 +9,26 @@ import { loadCatalogFromSources } from "../../installer-core/catalog"; import { createCredentialProvider } from "../../installer-core/credentials"; import { checkSourceAuth } from "../../installer-core/sourceAuth"; import { syncSource } from "../../installer-core/sourceSync"; -import { addSource, loadSources, removeSource, setSourceSyncStatus, updateSource } from "../../installer-core/sources"; +import { loadSources, removeSource, setSourceSyncStatus, updateSource } from "../../installer-core/sources"; +import { loadHookSources, removeHookSource, updateHookSource } from "../../installer-core/hookSources"; +import { syncHookSource } from "../../installer-core/hookSync"; +import { loadHookCatalogFromSources, HookInstallSelection } from "../../installer-core/hookCatalog"; +import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from "../../installer-core/hookExecutor"; +import { loadHookInstallState } from "../../installer-core/hookState"; +import { registerRepository } from "../../installer-core/repositories"; import { loadInstallState } from "../../installer-core/state"; import { discoverTargets, resolveTargetPaths } from "../../installer-core/targets"; import { SUPPORTED_TARGETS } from "../../installer-core/constants"; import { findRepoRoot } from "../../installer-core/repo"; import { InstallRequest, InstallScope, InstallSelection, TargetPlatform } from "../../installer-core/types"; - -interface Capability { - id: string; - title: string; - enabled: boolean; -} +import { + Capability, + loadDashboardServerPlugins, + mergeCapabilities, + parseDashboardPluginConfig, + parseEnabledDashboardPlugins, +} from "./plugins"; +import { dashboardServerPluginRegistry } from "./pluginRegistry"; interface InstallationSkillView { name: string; @@ -31,9 +39,19 @@ interface InstallationSkillView { orphaned?: boolean; } +interface InstallationHookView { + name: string; + hookId?: string; + sourceId?: string; + installMode: string; + effectiveMode: string; + orphaned?: boolean; +} + function capabilityRegistry(): Capability[] { return [ { id: "skills-catalog", title: "Skill catalog browsing", enabled: true }, + { id: "hooks-catalog", title: "Hook catalog browsing", enabled: true }, { id: "multi-source", title: "Multi-source repository management", enabled: true }, { id: "target-selection", title: "Target platform selection", enabled: true }, { id: "native-project-picker", title: "Native host project picker", enabled: true }, @@ -43,6 +61,8 @@ function capabilityRegistry(): Capability[] { ]; } +const HOOK_CAPABLE_TARGETS = new Set(["claude", "gemini"]); + function parseScope(value?: string): InstallScope { return value === "project" ? "project" : "user"; } @@ -144,6 +164,20 @@ function asInstallSelection(input: unknown): InstallSelection[] | undefined { 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)) { @@ -189,6 +223,51 @@ function detectLegacyInstalledSkills(installPath: string, catalogSkillNames: Set 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); @@ -201,6 +280,13 @@ async function main(): Promise { }); } + 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, @@ -210,7 +296,14 @@ async function main(): Promise { }); app.get("/api/v1/capabilities", async () => { - return { capabilities: capabilityRegistry() }; + 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 () => { @@ -223,6 +316,16 @@ async function main(): Promise { }; }); + 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(), @@ -275,9 +378,131 @@ async function main(): Promise { 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; + 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, + 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, + 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: await loadSources(), + sources: Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id)), }; }); @@ -291,42 +516,40 @@ async function main(): Promise { return reply.code(400).send({ error: "repoUrl is required." }); } - const source = await addSource({ - 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, - enabled: body.enabled !== false, - removable: body.removable !== false, - official: body.official === true, - }); - 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` }); - } - - const auth = await checkSourceAuth(source, credentialProvider); + 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, + 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 }); } - const sync = await syncSource(source, credentialProvider); - if (!fs.existsSync(sync.skillsPath) || !fs.statSync(sync.skillsPath).isDirectory()) { - return reply - .code(400) - .send({ error: `Source '${source.id}' is invalid: missing required skills root '${source.skillsRoot}'.`, source }); - } return { - source: { - ...source, - localPath: sync.localPath, - revision: sync.revision, - }, + source, + sync: registration.sync, }; }); @@ -348,12 +571,27 @@ async function main(): Promise { 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 }; @@ -365,10 +603,20 @@ async function main(): Promise { app.delete("/api/v1/sources/:id", async (request, reply) => { const params = request.params as { id: string }; try { - const removed = await removeSource(params.id); + 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 }; + return { source: removed || { id: params.id } }; } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -380,7 +628,7 @@ async function main(): Promise { string, unknown >; - const source = (await loadSources()).find((item) => item.id === params.id); + 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}'.` }); } @@ -390,8 +638,20 @@ async function main(): Promise { 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(source, credentialProvider); + const auth = await checkSourceAuth( + { + id: source.id, + repoUrl: source.repoUrl, + transport: source.transport, + }, + credentialProvider, + ); if (!auth.ok) { return reply.code(400).send(auth); } @@ -400,14 +660,31 @@ async function main(): Promise { app.post("/api/v1/sources/:id/refresh", async (request, reply) => { const params = request.params as { id: string }; - const source = (await loadSources()).find((item) => item.id === params.id); - if (!source) { + 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 = await syncSource(source, credentialProvider); - return { sourceId: source.id, revision: refreshed.revision, localPath: refreshed.localPath }; + 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) }); } @@ -415,22 +692,45 @@ async function main(): Promise { app.post("/api/v1/sources/refresh-all", async () => { const credentialProvider = createCredentialProvider(); - const sources = (await loadSources()).filter((source) => source.enabled); - const refreshed: Array<{ sourceId: string; revision?: string; localPath?: string; error?: string }> = []; - for (const source of sources) { - try { - const result = await syncSource(source, credentialProvider); - refreshed.push({ - sourceId: source.id, - revision: result.revision, - localPath: result.localPath, - }); - } catch (error) { - refreshed.push({ - sourceId: source.id, - error: error instanceof Error ? error.message : String(error), - }); + 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 }; }); @@ -500,6 +800,17 @@ async function main(): Promise { }; } + 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), + }; + } + function normalizeTargets(value: Partial["targets"]): TargetPlatform[] { if (!Array.isArray(value)) { return discoverTargets(); @@ -510,6 +821,13 @@ async function main(): Promise { 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); @@ -533,7 +851,7 @@ async function main(): Promise { envFile: body.envFile, }; - return executeOperation(repoRoot, installRequest); + return executeOperation(repoRoot, installRequest, { hooks: pluginRuntime.installHooks }); }); app.post("/api/v1/uninstall/apply", async (request, reply) => { @@ -559,7 +877,7 @@ async function main(): Promise { envFile: body.envFile, }; - return executeOperation(repoRoot, uninstallRequest); + return executeOperation(repoRoot, uninstallRequest, { hooks: pluginRuntime.installHooks }); }); app.post("/api/v1/sync/apply", async (request, reply) => { @@ -585,7 +903,73 @@ async function main(): Promise { envFile: body.envFile, }; - return executeOperation(repoRoot, syncRequest); + return executeOperation(repoRoot, syncRequest, { hooks: pluginRuntime.installHooks }); + }); + + 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) => { diff --git a/src/installer-dashboard/server/pluginRegistry.ts b/src/installer-dashboard/server/pluginRegistry.ts new file mode 100644 index 0000000..046b139 --- /dev/null +++ b/src/installer-dashboard/server/pluginRegistry.ts @@ -0,0 +1,6 @@ +import { DashboardServerPluginRegistry } from "./plugins"; +import { diagnosticsDashboardPlugin } from "./plugins/diagnostics"; + +export const dashboardServerPluginRegistry: DashboardServerPluginRegistry = { + diagnostics: diagnosticsDashboardPlugin, +}; diff --git a/src/installer-dashboard/server/plugins.ts b/src/installer-dashboard/server/plugins.ts new file mode 100644 index 0000000..460794a --- /dev/null +++ b/src/installer-dashboard/server/plugins.ts @@ -0,0 +1,233 @@ +import { FastifyInstance, FastifyReply, FastifyRequest, RouteOptions } from "fastify"; +import { InstallRequest, ResolvedTargetPath, TargetOperationReport } from "../../installer-core/types"; +import { ExecuteOperationHooks, InstallHookContext, PostInstallHookContext } from "../../installer-core/executor"; + +export interface Capability { + id: string; + title: string; + enabled: boolean; +} + +export interface PluginScopedApp { + route(options: Omit & { url: string }): void; + get(url: string, handler: (request: FastifyRequest, reply: FastifyReply) => unknown): void; + post(url: string, handler: (request: FastifyRequest, reply: FastifyReply) => unknown): void; + patch(url: string, handler: (request: FastifyRequest, reply: FastifyReply) => unknown): void; + put(url: string, handler: (request: FastifyRequest, reply: FastifyReply) => unknown): void; + delete(url: string, handler: (request: FastifyRequest, reply: FastifyReply) => unknown): void; +} + +type DashboardBeforeInstallHook = (context: InstallHookContext) => Promise | void; +type DashboardAfterInstallHook = (context: PostInstallHookContext) => Promise | void; + +export interface DashboardServerPluginContext { + id: string; + mountPath: string; + app: FastifyInstance; + scopedApp: PluginScopedApp; + config: Record; + capabilities: { + add(capability: Capability): void; + }; + hooks: { + onBeforeInstall(handler: DashboardBeforeInstallHook): void; + onAfterInstall(handler: DashboardAfterInstallHook): void; + }; +} + +export interface DashboardServerPlugin { + id: string; + register(context: DashboardServerPluginContext): Promise | void; +} + +export type DashboardServerPluginRegistry = Record; + +export interface DashboardServerPluginRuntime { + loadedPluginIds: string[]; + capabilities: Capability[]; + installHooks: ExecuteOperationHooks; +} + +function normalizePluginUrlPath(url: string): string { + const trimmed = url.trim(); + if (!trimmed || trimmed === "/") return ""; + const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return normalized.replace(/\/+$/g, ""); +} + +function buildScopedRoutePath(mountPath: string, url: string): string { + const normalizedPath = normalizePluginUrlPath(url); + return `${mountPath}${normalizedPath}`; +} + +function createScopedApp(app: FastifyInstance, mountPath: string): PluginScopedApp { + return { + route(options) { + const { url, ...rest } = options; + app.route({ + ...rest, + url: buildScopedRoutePath(mountPath, url), + }); + }, + get(url, handler) { + app.route({ + method: "GET", + url: buildScopedRoutePath(mountPath, url), + handler, + }); + }, + post(url, handler) { + app.route({ + method: "POST", + url: buildScopedRoutePath(mountPath, url), + handler, + }); + }, + patch(url, handler) { + app.route({ + method: "PATCH", + url: buildScopedRoutePath(mountPath, url), + handler, + }); + }, + put(url, handler) { + app.route({ + method: "PUT", + url: buildScopedRoutePath(mountPath, url), + handler, + }); + }, + delete(url, handler) { + app.route({ + method: "DELETE", + url: buildScopedRoutePath(mountPath, url), + handler, + }); + }, + }; +} + +export function parseEnabledDashboardPlugins(value: string | undefined): string[] { + if (!value) return []; + const parsed = value + .split(/[\s,]+/) + .map((token) => token.trim()) + .filter(Boolean); + return Array.from(new Set(parsed)); +} + +export async function loadDashboardServerPlugins(params: { + app: FastifyInstance; + enabledPluginIds: string[]; + registry: DashboardServerPluginRegistry; + pluginConfigs?: Record>; +}): Promise { + const { app, enabledPluginIds, registry, pluginConfigs } = params; + const capabilityMap = new Map(); + const beforeInstallHandlers: DashboardBeforeInstallHook[] = []; + const afterInstallHandlers: DashboardAfterInstallHook[] = []; + const loadedPluginIds: string[] = []; + + for (const pluginId of enabledPluginIds) { + const plugin = registry[pluginId]; + if (!plugin) continue; + const mountPath = `/api/v1/plugins/${plugin.id}`; + const context: DashboardServerPluginContext = { + id: plugin.id, + mountPath, + app, + scopedApp: createScopedApp(app, mountPath), + config: pluginConfigs?.[plugin.id] || {}, + capabilities: { + add(capability) { + capabilityMap.set(capability.id, capability); + }, + }, + hooks: { + onBeforeInstall(handler) { + beforeInstallHandlers.push(handler); + }, + onAfterInstall(handler) { + afterInstallHandlers.push(handler); + }, + }, + }; + await plugin.register(context); + loadedPluginIds.push(plugin.id); + } + + return { + loadedPluginIds, + capabilities: Array.from(capabilityMap.values()).sort((a, b) => a.id.localeCompare(b.id)), + installHooks: { + async onBeforeInstall(context: InstallHookContext): Promise { + for (const handler of beforeInstallHandlers) { + await handler(context); + } + }, + async onAfterInstall(context: PostInstallHookContext): Promise { + for (const handler of afterInstallHandlers) { + await handler(context); + } + }, + }, + }; +} + +export function parseDashboardPluginConfig(value: string | undefined): Record> { + if (!value || !value.trim()) { + return {}; + } + try { + const parsed = JSON.parse(value) as Record; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const normalized: Record> = {}; + for (const [pluginId, config] of Object.entries(parsed)) { + if (config && typeof config === "object" && !Array.isArray(config)) { + normalized[pluginId] = config as Record; + } + } + return normalized; + } catch { + return {}; + } +} + +export function mergeCapabilities(base: Capability[], contributed: Capability[]): Capability[] { + const map = new Map(); + for (const capability of base) { + map.set(capability.id, capability); + } + for (const capability of contributed) { + map.set(capability.id, capability); + } + return Array.from(map.values()).sort((a, b) => a.id.localeCompare(b.id)); +} + +export function createInstallHookContext(input: { + repoRoot: string; + request: InstallRequest; + resolvedTarget: ResolvedTargetPath; +}): InstallHookContext { + return { + repoRoot: input.repoRoot, + request: input.request, + resolvedTarget: input.resolvedTarget, + }; +} + +export function createPostInstallHookContext(input: { + repoRoot: string; + request: InstallRequest; + resolvedTarget: ResolvedTargetPath; + report: TargetOperationReport; +}): PostInstallHookContext { + return { + repoRoot: input.repoRoot, + request: input.request, + resolvedTarget: input.resolvedTarget, + report: input.report, + }; +} diff --git a/src/installer-dashboard/server/plugins/diagnostics.ts b/src/installer-dashboard/server/plugins/diagnostics.ts new file mode 100644 index 0000000..5b2b362 --- /dev/null +++ b/src/installer-dashboard/server/plugins/diagnostics.ts @@ -0,0 +1,20 @@ +import { DashboardServerPlugin } from "../plugins"; + +export const diagnosticsDashboardPlugin: DashboardServerPlugin = { + id: "diagnostics", + async register(context) { + context.capabilities.add({ + id: "plugin-diagnostics", + title: "Plugin diagnostics endpoint", + enabled: true, + }); + + context.scopedApp.get("/health", async () => { + return { + ok: true, + pluginId: context.id, + mode: context.config.mode || "default", + }; + }); + }, +}; diff --git a/src/installer-dashboard/web/src/InstallerDashboard.tsx b/src/installer-dashboard/web/src/InstallerDashboard.tsx index 8325429..a75e3c6 100644 --- a/src/installer-dashboard/web/src/InstallerDashboard.tsx +++ b/src/installer-dashboard/web/src/InstallerDashboard.tsx @@ -10,6 +10,7 @@ type Source = { official: boolean; enabled: boolean; skillsRoot: string; + hooksRoot?: string; credentialRef?: string; removable: boolean; lastSyncAt?: string; @@ -50,6 +51,37 @@ type InstallationRow = { updatedAt?: string; }; +type Hook = { + hookId: string; + sourceId: string; + sourceName: string; + sourceUrl: string; + hookName: string; + name: string; + description: string; + version?: string; + updatedAt?: string; +}; + +type HookInstallation = { + name: string; + hookId?: string; + sourceId?: string; + installMode: string; + effectiveMode: string; + orphaned?: boolean; +}; + +type HookInstallationRow = { + target: "claude" | "gemini"; + installPath: string; + scope: "user" | "project"; + projectPath?: string; + installed: boolean; + managedHooks: HookInstallation[]; + updatedAt?: string; +}; + type OperationTargetReport = { target: string; installPath: string; @@ -68,7 +100,25 @@ type OperationReport = { targets: OperationTargetReport[]; }; -type DashboardTab = "skills" | "settings" | "state"; +type HookOperationTargetReport = { + target: "claude" | "gemini"; + installPath: string; + operation: "install" | "uninstall" | "sync"; + appliedHooks: string[]; + removedHooks: string[]; + skippedHooks: string[]; + warnings: Array<{ code: string; message: string }>; + errors: Array<{ code: string; message: string }>; +}; + +type HookOperationReport = { + startedAt: string; + completedAt: string; + request?: unknown; + targets: HookOperationTargetReport[]; +}; + +type DashboardTab = "skills" | "hooks" | "settings" | "state"; type DashboardMode = "light" | "dark"; type DashboardAccent = "slate" | "blue" | "red" | "green" | "amber"; type DashboardBackground = "slate" | "ocean" | "sand" | "forest" | "wine"; @@ -174,13 +224,18 @@ export function InstallerDashboard(): JSX.Element { const [sources, setSources] = useState([]); const [skills, setSkills] = useState([]); const [selectedSkills, setSelectedSkills] = useState>(new Set()); + const [hooks, setHooks] = useState([]); + const [selectedHooks, setSelectedHooks] = useState>(new Set()); const [targets, setTargets] = useState>(new Set(["codex"])); const [searchQuery, setSearchQuery] = useState(""); + const [hookSearchQuery, setHookSearchQuery] = useState(""); const [scope, setScope] = useState<"user" | "project">("user"); const [projectPath, setProjectPath] = useState(""); const [mode, setMode] = useState<"symlink" | "copy">("symlink"); const [installations, setInstallations] = useState([]); + const [hookInstallations, setHookInstallations] = useState([]); const [report, setReport] = useState(null); + const [hookReport, setHookReport] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [catalogLoading, setCatalogLoading] = useState(false); @@ -198,13 +253,21 @@ export function InstallerDashboard(): JSX.Element { const [appearanceOpen, setAppearanceOpen] = useState(false); const [sourceFilter, setSourceFilter] = useState("all"); const [installedOnly, setInstalledOnly] = useState(false); + const [hookSourceFilter, setHookSourceFilter] = useState("all"); + const [hooksInstalledOnly, setHooksInstalledOnly] = useState(false); + const [hookSelectionCustomized, setHookSelectionCustomized] = useState(false); const appearancePanelRef = useRef(null); const appearanceTriggerRef = useRef(null); const selectedTargetList = useMemo(() => Array.from(targets).sort(), [targets]); + const selectedHookTargetList = useMemo( + () => selectedTargetList.filter((target): target is "claude" | "gemini" => target === "claude" || target === "gemini"), + [selectedTargetList], + ); const trimmedProjectPath = projectPath.trim(); const targetKey = selectedTargetList.join(","); 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 installedSkillIds = useMemo(() => { @@ -222,10 +285,29 @@ export function InstallerDashboard(): JSX.Element { return names; }, [installations, skills]); + const installedHookIds = useMemo(() => { + const names = new Set(); + for (const row of hookInstallations) { + for (const hook of row.managedHooks || []) { + if (hook.hookId) { + names.add(hook.hookId); + } else { + const match = hooks.find((item) => item.hookName === hook.name); + if (match) names.add(match.hookId); + } + } + } + return names; + }, [hookInstallations, hooks]); + 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 hookSourceFilterOptions = useMemo(() => { + return Array.from(new Set(hooks.map((hook) => hook.sourceId))).sort((a, b) => a.localeCompare(b)); + }, [hooks]); const visibleSkills = useMemo(() => { return skills.filter((skill) => { @@ -254,6 +336,22 @@ export function InstallerDashboard(): JSX.Element { return Array.from(byCategory.entries()).sort((a, b) => a[0].localeCompare(b[0])); }, [visibleSkills]); + const visibleHooks = useMemo(() => { + return hooks.filter((hook) => { + if (hookSourceFilter !== "all" && hook.sourceId !== hookSourceFilter) { + return false; + } + if (hooksInstalledOnly && !installedHookIds.has(hook.hookId)) { + return false; + } + if (!normalizedHookQuery) { + return true; + } + const haystack = `${hook.hookId} ${hook.description}`.toLowerCase(); + return haystack.includes(normalizedHookQuery); + }); + }, [hooks, hookSourceFilter, hooksInstalledOnly, installedHookIds, normalizedHookQuery]); + async function fetchSources(): Promise { const res = await fetch("/api/v1/sources"); const payload = (await res.json()) as { sources?: Source[]; error?: string }; @@ -304,6 +402,15 @@ export function InstallerDashboard(): JSX.Element { } } + async function fetchHooks(): Promise { + 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.")); + } + setHooks(Array.isArray(payload.hooks) ? payload.hooks : []); + } + async function fetchDiscoveredTargets(): Promise { const res = await fetch("/api/v1/targets/discovered"); const payload = (await res.json()) as { targets?: Target[]; error?: string }; @@ -339,6 +446,31 @@ export function InstallerDashboard(): JSX.Element { setInstallations(Array.isArray(payload.installations) ? payload.installations : []); } + async function fetchHookInstallations(): Promise { + if (scope === "project" && !trimmedProjectPath) { + setHookInstallations([]); + return; + } + + if (selectedHookTargetList.length === 0) { + setHookInstallations([]); + return; + } + + const query = new URLSearchParams({ + scope, + ...(scope === "project" ? { projectPath: trimmedProjectPath } : {}), + targets: selectedHookTargetList.join(","), + }); + + 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.")); + } + setHookInstallations(Array.isArray(payload.installations) ? payload.installations : []); + } + function setSkillsSelection(skillIds: string[], shouldSelect: boolean): void { setSelectionCustomized(true); setSelectedSkills((current) => { @@ -351,6 +483,18 @@ export function InstallerDashboard(): JSX.Element { }); } + function setHooksSelection(hookIds: string[], shouldSelect: boolean): void { + setHookSelectionCustomized(true); + setSelectedHooks((current) => { + const next = new Set(current); + for (const id of hookIds) { + if (shouldSelect) next.add(id); + else next.delete(id); + } + return next; + }); + } + function toggleSkill(skillId: string): void { setSelectionCustomized(true); setSelectedSkills((current) => { @@ -361,6 +505,16 @@ export function InstallerDashboard(): JSX.Element { }); } + function toggleHook(hookId: string): void { + setHookSelectionCustomized(true); + setSelectedHooks((current) => { + const next = new Set(current); + if (next.has(hookId)) next.delete(hookId); + else next.add(hookId); + return next; + }); + } + function toggleTarget(target: Target): void { setTargets((current) => { const next = new Set(current); @@ -436,6 +590,65 @@ export function InstallerDashboard(): JSX.Element { } } + async function runHookOperation(operation: "install" | "uninstall" | "sync"): Promise { + setBusy(true); + setError(""); + setHookReport(null); + + if (selectedHookTargetList.length === 0) { + setBusy(false); + setError("Hooks are supported only for Claude and Gemini targets. Select at least one of those."); + return; + } + if (scope === "project" && !trimmedProjectPath) { + setBusy(false); + setError("Project scope requires a project path."); + return; + } + + try { + const selections = Array.from(selectedHooks) + .map((hookId) => { + const hook = hookById.get(hookId); + if (!hook) return null; + return { + sourceId: hook.sourceId, + hookName: hook.hookName, + hookId: hook.hookId, + }; + }) + .filter((item): item is { sourceId: string; hookName: string; hookId: string } => Boolean(item)); + + const res = await fetch(`/api/v1/hooks/${operation}/apply`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operation, + targets: selectedHookTargetList, + scope, + projectPath: scope === "project" ? trimmedProjectPath : undefined, + mode, + hooks: [], + hookSelections: selections, + removeUnselected: operation === "sync", + }), + }); + + const payload = (await res.json()) as HookOperationReport | { error?: string }; + if (!res.ok) { + throw new Error(asErrorMessage(payload, "Hook operation failed.")); + } + setHookReport(payload as HookOperationReport); + await fetchHookInstallations(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + async function addSourceFromForm(): Promise { setBusy(true); setError(""); @@ -459,6 +672,7 @@ export function InstallerDashboard(): JSX.Element { setSourceToken(""); await fetchSources(); await fetchSkills(true); + await fetchHooks(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -482,6 +696,7 @@ export function InstallerDashboard(): JSX.Element { } await fetchSources(); await fetchSkills(); + await fetchHooks(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -500,6 +715,7 @@ export function InstallerDashboard(): JSX.Element { } await fetchSources(); await fetchSkills(); + await fetchHooks(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -562,20 +778,29 @@ export function InstallerDashboard(): JSX.Element { useEffect(() => { fetchDiscoveredTargets().catch((err) => setError(err instanceof Error ? err.message : String(err))); fetchSources() - .then(() => fetchSkills(true)) + .then(async () => { + await fetchSkills(true); + await fetchHooks(); + }) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, []); useEffect(() => { setSelectionCustomized(false); - fetchInstallations().catch((err) => setError(err instanceof Error ? err.message : String(err))); - }, [scope, trimmedProjectPath, targetKey]); + 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; setSelectedSkills(new Set(installedSkillIds)); }, [installedSkillIds, selectionCustomized]); + useEffect(() => { + if (hookSelectionCustomized) return; + setSelectedHooks(new Set(installedHookIds)); + }, [installedHookIds, hookSelectionCustomized]); + useEffect(() => { if (sourceFilter === "all") return; if (!sourceFilterOptions.includes(sourceFilter)) { @@ -583,6 +808,13 @@ export function InstallerDashboard(): JSX.Element { } }, [sourceFilter, sourceFilterOptions]); + useEffect(() => { + if (hookSourceFilter === "all") return; + if (!hookSourceFilterOptions.includes(hookSourceFilter)) { + setHookSourceFilter("all"); + } + }, [hookSourceFilter, hookSourceFilterOptions]); + useEffect(() => { if (catalogLoading || skills.length === 0) return; setSelectedSkills((current) => { @@ -599,6 +831,22 @@ export function InstallerDashboard(): JSX.Element { }); }, [catalogLoading, skills.length, skillById]); + useEffect(() => { + if (hooks.length === 0) return; + setSelectedHooks((current) => { + let changed = false; + const next = new Set(); + for (const hookId of current) { + if (hookById.has(hookId)) { + next.add(hookId); + } else { + changed = true; + } + } + return changed ? next : current; + }); + }, [hooks.length, hookById]); + useEffect(() => { document.body.dataset.mode = appearanceMode; document.body.dataset.accent = appearanceAccent; @@ -649,6 +897,17 @@ export function InstallerDashboard(): JSX.Element { }, [selectedSkills, skillById]); const selectedUnknownSkillCount = Math.max(0, selectedSkillCount - selectedKnownSkillCount); const installedSkillCount = installedSkillIds.size; + const totalHooks = hooks.length; + const filteredHooksCount = visibleHooks.length; + const selectedKnownHookCount = useMemo(() => { + let count = 0; + for (const hookId of selectedHooks) { + if (hookById.has(hookId)) count += 1; + } + return count; + }, [selectedHooks, hookById]); + const selectedUnknownHookCount = Math.max(0, selectedHooks.size - selectedKnownHookCount); + const installedHookCount = installedHookIds.size; return (
@@ -657,12 +916,12 @@ export function InstallerDashboard(): JSX.Element {

ICA COMMAND CENTER

Multi-source

-

Skills Dashboard

-

Manage source repositories, pick project paths natively, and install source-pinned skills across targets.

+

Skills & Hooks Dashboard

+

Manage repositories once, then install source-pinned skills and hooks across targets.

{sources.length} sources - {installedSkillCount} installed - {selectedKnownSkillCount} selected + {installedSkillCount} skills installed + {installedHookCount} hooks installed
@@ -704,6 +963,15 @@ export function InstallerDashboard(): JSX.Element { > Settings + + + + + + + +
+
+
+
+

Hook Catalog

+

+ {totalHooks > 0 ? `${selectedKnownHookCount}/${totalHooks} selected` : `${selectedKnownHookCount} selected`} + {selectedUnknownHookCount > 0 ? ` • ${selectedUnknownHookCount} unavailable` : ""} + {normalizedHookQuery ? ` • ${filteredHooksCount} shown` : ""} +

+
+
+ + +
+
+ +
+ setHookSearchQuery(event.target.value)} + /> +
+
+ Source +
+ + {hookSourceFilterOptions.map((sourceId) => ( + + ))} +
+
+ +
+
+ + {visibleHooks.length === 0 &&
No hooks match this search. Try a broader term.
} + +
+ {visibleHooks.map((hook) => { + const isSelected = selectedHooks.has(hook.hookId); + const isInstalled = installedHookIds.has(hook.hookId); + return ( +
+
+ +
+ {sourceNameById.get(hook.sourceId) || hook.sourceId} + {isInstalled && installed} +
+
+

{hook.description || "No description provided."}

+
+ {hook.version && v{hook.version}} + {hook.updatedAt && Updated {new Date(hook.updatedAt).toLocaleDateString()}} +
+
+ ); + })} +
+
+
+ + )} + {activeTab === "settings" && (

Repository Management

-

Attach skill sources, validate access, and keep local mirrors fresh.

+

Attach repositories once; ICA syncs skills and hooks mirrors automatically.

{sources.length} configured
{sources.map((source) => (
{source.id} {source.repoUrl} + + roots: {source.skillsRoot || "(no /skills)"} / {source.hooksRoot || "(no /hooks)"} + {source.lastSyncAt ? `synced ${new Date(source.lastSyncAt).toLocaleString()}` : "never synced"} {source.lastError && {source.lastError}}
@@ -1022,10 +1426,10 @@ export function InstallerDashboard(): JSX.Element { )}
@@ -1090,7 +1494,7 @@ export function InstallerDashboard(): JSX.Element {

States & Reports

-

Inspect installed skill state per target and review the last operation payload.

+

Inspect installed skill/hook state per target and review the latest operation payloads.

@@ -1108,6 +1512,22 @@ export function InstallerDashboard(): JSX.Element {
{report ? JSON.stringify(report, null, 2) : "No operation run yet."}
+ +
+ + Installed Hooks State + {hookInstallations.length} target entries + +
{JSON.stringify(hookInstallations, null, 2)}
+
+ +
+ + Hook Operation Report + {hookReport ? "latest run available" : "no operation yet"} + +
{hookReport ? JSON.stringify(hookReport, null, 2) : "No hook operation run yet."}
+
)}
diff --git a/src/installer-dashboard/web/src/plugins/api.ts b/src/installer-dashboard/web/src/plugins/api.ts new file mode 100644 index 0000000..4803553 --- /dev/null +++ b/src/installer-dashboard/web/src/plugins/api.ts @@ -0,0 +1,155 @@ +import type { ReactNode } from "react"; + +export type DashboardUiPanelLocation = "skills.sidebar" | "sources.footer" | "settings.sections"; +export type DashboardUiActionLocation = "skills.sidebar" | "settings.sections"; + +export interface DashboardUiTabContribution { + id: string; + title: string; + order?: number; + render: () => ReactNode; +} + +export interface DashboardUiPanelContribution { + id: string; + location: DashboardUiPanelLocation; + title: string; + order?: number; + render: () => ReactNode; +} + +export interface DashboardUiSettingsSectionContribution { + id: string; + title: string; + order?: number; + render: () => ReactNode; +} + +export interface DashboardUiActionContribution { + id: string; + location: DashboardUiActionLocation; + label: string; + order?: number; + run: () => void | Promise; +} + +export interface DashboardUiPluginContext { + id: string; + config: Record; + addTab(tab: DashboardUiTabContribution): void; + addPanel(panel: DashboardUiPanelContribution): void; + addSettingsSection(section: DashboardUiSettingsSectionContribution): void; + addAction(action: DashboardUiActionContribution): void; +} + +export interface DashboardUiPlugin { + id: string; + register(context: DashboardUiPluginContext): void; +} + +export type DashboardUiPluginRegistry = Record; + +export interface DashboardUiPluginRuntime { + loadedPluginIds: string[]; + tabs: DashboardUiTabContribution[]; + panelsByLocation: Record; + settingsSections: DashboardUiSettingsSectionContribution[]; + actionsByLocation: Record; +} + +function sortByOrder(items: T[]): T[] { + return [...items].sort((a, b) => { + const orderA = a.order ?? 1000; + const orderB = b.order ?? 1000; + if (orderA !== orderB) return orderA - orderB; + return a.id.localeCompare(b.id); + }); +} + +export function parseEnabledDashboardUiPlugins(value: string | undefined): string[] { + if (!value) return []; + const parsed = value + .split(/[\s,]+/) + .map((token) => token.trim()) + .filter(Boolean); + return Array.from(new Set(parsed)); +} + +export function parseDashboardUiPluginConfig(value: unknown): Record> { + if (!value) return {}; + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown; + return parseDashboardUiPluginConfig(parsed); + } catch { + return {}; + } + } + if (typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const normalized: Record> = {}; + for (const [pluginId, config] of Object.entries(value)) { + if (config && typeof config === "object" && !Array.isArray(config)) { + normalized[pluginId] = config as Record; + } + } + return normalized; +} + +export function loadDashboardUiPlugins(params: { + enabledPluginIds: string[]; + registry: DashboardUiPluginRegistry; + pluginConfigs?: Record>; +}): DashboardUiPluginRuntime { + const { enabledPluginIds, registry, pluginConfigs } = params; + const tabs = new Map(); + const settingsSections = new Map(); + const panelsByLocation: Record> = { + "skills.sidebar": new Map(), + "sources.footer": new Map(), + "settings.sections": new Map(), + }; + const actionsByLocation: Record> = { + "skills.sidebar": new Map(), + "settings.sections": new Map(), + }; + const loadedPluginIds: string[] = []; + + for (const pluginId of enabledPluginIds) { + const plugin = registry[pluginId]; + if (!plugin) continue; + plugin.register({ + id: plugin.id, + config: pluginConfigs?.[plugin.id] || {}, + addTab(tab) { + tabs.set(tab.id, tab); + }, + addPanel(panel) { + panelsByLocation[panel.location].set(panel.id, panel); + }, + addSettingsSection(section) { + settingsSections.set(section.id, section); + }, + addAction(action) { + actionsByLocation[action.location].set(action.id, action); + }, + }); + loadedPluginIds.push(plugin.id); + } + + return { + loadedPluginIds, + tabs: sortByOrder(Array.from(tabs.values())), + panelsByLocation: { + "skills.sidebar": sortByOrder(Array.from(panelsByLocation["skills.sidebar"].values())), + "sources.footer": sortByOrder(Array.from(panelsByLocation["sources.footer"].values())), + "settings.sections": sortByOrder(Array.from(panelsByLocation["settings.sections"].values())), + }, + settingsSections: sortByOrder(Array.from(settingsSections.values())), + actionsByLocation: { + "skills.sidebar": sortByOrder(Array.from(actionsByLocation["skills.sidebar"].values())), + "settings.sections": sortByOrder(Array.from(actionsByLocation["settings.sections"].values())), + }, + }; +} diff --git a/src/installer-dashboard/web/src/plugins/diagnostics.tsx b/src/installer-dashboard/web/src/plugins/diagnostics.tsx new file mode 100644 index 0000000..616430e --- /dev/null +++ b/src/installer-dashboard/web/src/plugins/diagnostics.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { DashboardUiPlugin } from "./api"; + +export const diagnosticsUiPlugin: DashboardUiPlugin = { + id: "diagnostics", + register(context) { + context.addTab({ + id: "plugin-diagnostics", + title: "Diagnostics", + order: 200, + render: () => ( +
+
+

Plugin Diagnostics

+

Plugin ID: {context.id}

+
+
+
{JSON.stringify(context.config, null, 2)}
+
+
+ ), + }); + + context.addSettingsSection({ + id: "plugin-diagnostics-settings", + title: "Diagnostics Settings", + order: 200, + render: () =>

Diagnostics plugin is enabled.

, + }); + }, +}; diff --git a/src/installer-dashboard/web/src/plugins/registry.ts b/src/installer-dashboard/web/src/plugins/registry.ts new file mode 100644 index 0000000..8adbab2 --- /dev/null +++ b/src/installer-dashboard/web/src/plugins/registry.ts @@ -0,0 +1,6 @@ +import { DashboardUiPluginRegistry } from "./api"; +import { diagnosticsUiPlugin } from "./diagnostics"; + +export const dashboardUiPluginRegistry: DashboardUiPluginRegistry = { + diagnostics: diagnosticsUiPlugin, +}; diff --git a/src/installer-dashboard/web/src/styles.css b/src/installer-dashboard/web/src/styles.css index f5df774..1ca16b9 100644 --- a/src/installer-dashboard/web/src/styles.css +++ b/src/installer-dashboard/web/src/styles.css @@ -822,6 +822,12 @@ input[type="radio"]:focus-visible { line-height: 1.55; } +.hook-support-warning { + border-color: var(--status-info-border); + background: var(--status-info-bg); + color: var(--status-info-text); +} + .action-row { display: grid; gap: var(--space-2); diff --git a/tests/installer/dashboard-server-plugins.test.ts b/tests/installer/dashboard-server-plugins.test.ts new file mode 100644 index 0000000..093f5a8 --- /dev/null +++ b/tests/installer/dashboard-server-plugins.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { InstallRequest } from "../../src/installer-core/types"; +import { + DashboardServerPlugin, + DashboardServerPluginRegistry, + loadDashboardServerPlugins, + parseEnabledDashboardPlugins, +} from "../../src/installer-dashboard/server/plugins"; + +test("parseEnabledDashboardPlugins trims and deduplicates ids", () => { + const parsed = parseEnabledDashboardPlugins(" alpha, beta alpha ,,gamma "); + assert.deepEqual(parsed, ["alpha", "beta", "gamma"]); +}); + +test("loadDashboardServerPlugins registers only enabled plugins with scoped routes", async () => { + const routes: Array<{ method: string; url: string }> = []; + const app = { + route(input: { method: string; url: string }): void { + routes.push({ method: input.method, url: input.url }); + }, + }; + + const beforeInstallCalls: string[] = []; + const afterInstallCalls: string[] = []; + + const registry: DashboardServerPluginRegistry = { + diagnostics: { + id: "diagnostics", + async register(ctx): Promise { + ctx.capabilities.add({ + id: "diagnostics-health", + title: "Diagnostics health endpoint", + enabled: true, + }); + ctx.scopedApp.get("/health", async () => ({ ok: true })); + ctx.hooks.onBeforeInstall(async ({ request }) => { + beforeInstallCalls.push(request.operation); + }); + ctx.hooks.onAfterInstall(async ({ request }) => { + afterInstallCalls.push(request.operation); + }); + }, + } satisfies DashboardServerPlugin, + disabled: { + id: "disabled", + async register(ctx): Promise { + ctx.scopedApp.get("/health", async () => ({ ok: true })); + }, + } satisfies DashboardServerPlugin, + }; + + const runtime = await loadDashboardServerPlugins({ + app: app as never, + enabledPluginIds: ["diagnostics"], + registry, + pluginConfigs: { + diagnostics: { + mode: "test", + }, + }, + }); + + assert.deepEqual(runtime.loadedPluginIds, ["diagnostics"]); + assert.equal(runtime.capabilities.length, 1); + assert.equal(runtime.capabilities[0].id, "diagnostics-health"); + assert.deepEqual(routes, [{ method: "GET", url: "/api/v1/plugins/diagnostics/health" }]); + + const installRequest: InstallRequest = { + operation: "install", + targets: ["codex"], + scope: "user", + mode: "copy", + skills: [], + }; + + await runtime.installHooks.onBeforeInstall?.({ + repoRoot: process.cwd(), + request: installRequest, + resolvedTarget: { + target: "codex", + installPath: "/tmp/.codex", + scope: "user", + }, + }); + await runtime.installHooks.onAfterInstall?.({ + repoRoot: process.cwd(), + request: installRequest, + resolvedTarget: { + target: "codex", + installPath: "/tmp/.codex", + scope: "user", + }, + report: { + target: "codex", + installPath: "/tmp/.codex", + operation: "install", + appliedSkills: [], + removedSkills: [], + skippedSkills: [], + warnings: [], + errors: [], + }, + }); + + assert.deepEqual(beforeInstallCalls, ["install"]); + assert.deepEqual(afterInstallCalls, ["install"]); +}); diff --git a/tests/installer/dashboard-ui-plugins.test.ts b/tests/installer/dashboard-ui-plugins.test.ts new file mode 100644 index 0000000..c118def --- /dev/null +++ b/tests/installer/dashboard-ui-plugins.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + DashboardUiPlugin, + DashboardUiPluginRegistry, + loadDashboardUiPlugins, + parseEnabledDashboardUiPlugins, +} from "../../src/installer-dashboard/web/src/plugins/api"; + +test("parseEnabledDashboardUiPlugins trims and deduplicates ids", () => { + const parsed = parseEnabledDashboardUiPlugins("one, two one,,three"); + assert.deepEqual(parsed, ["one", "two", "three"]); +}); + +test("loadDashboardUiPlugins loads enabled plugin contributions in stable order", () => { + const registry: DashboardUiPluginRegistry = { + diagnostics: { + id: "diagnostics", + register(ctx): void { + ctx.addTab({ + id: "diagnostics-tab", + title: "Diagnostics", + order: 20, + render: () => "diagnostics", + }); + ctx.addPanel({ + id: "diagnostics-panel", + location: "skills.sidebar", + title: "Diagnostics Panel", + order: 10, + render: () => "panel", + }); + ctx.addSettingsSection({ + id: "diagnostics-settings", + title: "Diagnostics Settings", + order: 5, + render: () => "settings", + }); + ctx.addAction({ + id: "diagnostics-refresh", + location: "skills.sidebar", + label: "Refresh diagnostics", + order: 3, + run: () => undefined, + }); + }, + } satisfies DashboardUiPlugin, + disabled: { + id: "disabled", + register(): void {}, + } satisfies DashboardUiPlugin, + }; + + const runtime = loadDashboardUiPlugins({ + enabledPluginIds: ["diagnostics"], + registry, + pluginConfigs: { + diagnostics: { + mode: "test", + }, + }, + }); + + assert.deepEqual(runtime.loadedPluginIds, ["diagnostics"]); + assert.equal(runtime.tabs.length, 1); + assert.equal(runtime.tabs[0].id, "diagnostics-tab"); + assert.equal(runtime.settingsSections.length, 1); + assert.equal(runtime.settingsSections[0].id, "diagnostics-settings"); + assert.equal(runtime.panelsByLocation["skills.sidebar"].length, 1); + assert.equal(runtime.panelsByLocation["skills.sidebar"][0].id, "diagnostics-panel"); + assert.equal(runtime.actionsByLocation["skills.sidebar"].length, 1); + assert.equal(runtime.actionsByLocation["skills.sidebar"][0].id, "diagnostics-refresh"); +}); diff --git a/tests/installer/executor-hooks.test.ts b/tests/installer/executor-hooks.test.ts new file mode 100644 index 0000000..959c0cb --- /dev/null +++ b/tests/installer/executor-hooks.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { executeOperation } from "../../src/installer-core/executor"; +import { createCredentialProvider } from "../../src/installer-core/credentials"; +import { syncSource } from "../../src/installer-core/sourceSync"; +import { addSource } from "../../src/installer-core/sources"; + +const repoRoot = path.resolve(__dirname, "../../.."); + +async function setupExternalSkillsSource(prefix: string): Promise<{ sourceId: string; tempStateRoot: string }> { + const tempStateRoot = fs.mkdtempSync(path.join(os.tmpdir(), `ica-installer-state-${prefix}-`)); + const tempSourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), `ica-installer-skills-${prefix}-`)); + const repoDir = path.join(tempSourceRoot, "repo"); + fs.mkdirSync(path.join(repoDir, "skills", "developer"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "skills", "developer", "SKILL.md"), + "---\nname: developer\ndescription: external test developer\n---\n", + "utf8", + ); + 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 skills"], { + cwd: repoDir, + }); + + const sourceId = `test-hooks-source-${prefix}`; + const previous = process.env.ICA_STATE_HOME; + process.env.ICA_STATE_HOME = tempStateRoot; + try { + const source = await addSource({ + id: sourceId, + name: sourceId, + repoUrl: `file://${repoDir}`, + transport: "https", + skillsRoot: "/skills", + enabled: true, + removable: true, + }); + await syncSource(source, createCredentialProvider()); + } finally { + if (previous === undefined) { + delete process.env.ICA_STATE_HOME; + } else { + process.env.ICA_STATE_HOME = previous; + } + } + + return { sourceId, tempStateRoot }; +} + +test("executeOperation invokes install hooks for install", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-installer-hooks-test-")); + const { sourceId, tempStateRoot } = await setupExternalSkillsSource("invoke"); + const previous = process.env.ICA_STATE_HOME; + process.env.ICA_STATE_HOME = tempStateRoot; + + const calls: string[] = []; + try { + const report = await executeOperation( + repoRoot, + { + operation: "install", + targets: ["codex"], + scope: "project", + projectPath: tempRoot, + mode: "copy", + skills: [], + skillSelections: [ + { + sourceId, + skillName: "developer", + skillId: `${sourceId}/developer`, + }, + ], + removeUnselected: false, + installClaudeIntegration: false, + }, + { + hooks: { + onBeforeInstall: async ({ request }) => { + calls.push(`before:${request.operation}`); + }, + onAfterInstall: async ({ request }) => { + calls.push(`after:${request.operation}`); + }, + }, + }, + ); + assert.equal(report.targets[0].errors.length, 0); + assert.deepEqual(calls, ["before:install", "after:install"]); + } finally { + if (previous === undefined) { + delete process.env.ICA_STATE_HOME; + } else { + process.env.ICA_STATE_HOME = previous; + } + } +}); + +test("executeOperation surfaces hook failures as target errors", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-installer-hooks-test-")); + const { sourceId, tempStateRoot } = await setupExternalSkillsSource("block"); + const previous = process.env.ICA_STATE_HOME; + process.env.ICA_STATE_HOME = tempStateRoot; + + try { + const report = await executeOperation( + repoRoot, + { + operation: "install", + targets: ["codex"], + scope: "project", + projectPath: tempRoot, + mode: "copy", + skills: [], + skillSelections: [ + { + sourceId, + skillName: "developer", + skillId: `${sourceId}/developer`, + }, + ], + removeUnselected: false, + installClaudeIntegration: false, + }, + { + hooks: { + onBeforeInstall: async () => { + throw new Error("blocked by policy"); + }, + }, + }, + ); + assert.equal(report.targets[0].errors.length, 1); + assert.equal(report.targets[0].errors[0].code, "TARGET_OPERATION_FAILED"); + assert.match(report.targets[0].errors[0].message, /blocked by policy/i); + } finally { + if (previous === undefined) { + delete process.env.ICA_STATE_HOME; + } else { + process.env.ICA_STATE_HOME = previous; + } + } +}); diff --git a/tests/installer/hooks.test.ts b/tests/installer/hooks.test.ts new file mode 100644 index 0000000..f49d0df --- /dev/null +++ b/tests/installer/hooks.test.ts @@ -0,0 +1,162 @@ +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 { + OFFICIAL_HOOK_SOURCE_ID, + addHookSource, + ensureHookSourceRegistry, + getHookSourceHooksPath, + getHookSourcesFilePath, + loadHookSources, +} from "../../src/installer-core/hookSources"; +import { createCredentialProvider } from "../../src/installer-core/credentials"; +import { syncHookSource } from "../../src/installer-core/hookSync"; +import { loadHookCatalogFromSources } from "../../src/installer-core/hookCatalog"; +import { executeHookOperation } from "../../src/installer-core/hookExecutor"; +import { loadHookInstallState } from "../../src/installer-core/hookState"; + +const repoRoot = path.resolve(__dirname, "../../.."); + +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 initRepo(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 hooks"], { + cwd: repoDir, + }); +} + +test("ensureHookSourceRegistry bootstraps official hooks source", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-state-")); + await withStateHome(stateHome, async () => { + const sources = await ensureHookSourceRegistry(); + assert.ok(sources.some((source) => source.id === OFFICIAL_HOOK_SOURCE_ID)); + }); +}); + +test("custom hook repositories are stored and reloaded from disk", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-state-")); + await withStateHome(stateHome, async () => { + await ensureHookSourceRegistry(); + const added = await addHookSource({ + id: "custom-hooks", + name: "custom-hooks", + repoUrl: "https://github.com/example/custom-hooks.git", + transport: "https", + hooksRoot: "/hooks", + enabled: true, + removable: true, + }); + assert.equal(added.id, "custom-hooks"); + assert.ok(fs.existsSync(getHookSourcesFilePath())); + + const loaded = await loadHookSources(); + const match = loaded.find((source) => source.id === "custom-hooks"); + assert.ok(match); + assert.equal(match?.hooksRoot, "/hooks"); + }); +}); + +test("hook sync stores hooks under ~/.ica//hooks and supports root fallback", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-state-")); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-repo-")); + const repoDir = path.join(repoBase, "repo"); + fs.mkdirSync(path.join(repoDir, "plain-hook"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "plain-hook", "run.sh"), "#!/usr/bin/env bash\necho ok\n", "utf8"); + initRepo(repoDir); + + await withStateHome(stateHome, async () => { + const source = await addHookSource({ + id: "fallback-hooks", + name: "fallback-hooks", + repoUrl: `file://${repoDir}`, + transport: "https", + hooksRoot: "/hooks", + enabled: true, + removable: true, + }); + const synced = await syncHookSource(source, createCredentialProvider()); + assert.equal(synced.hooksPath, getHookSourceHooksPath(source.id)); + assert.ok(fs.existsSync(path.join(synced.hooksPath, "plain-hook", "run.sh"))); + }); +}); + +test("install and uninstall selected hooks in project and user scope", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-state-")); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-repo-")); + const repoDir = path.join(repoBase, "repo"); + fs.mkdirSync(path.join(repoDir, "hooks", "guard"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "hooks", "guard", "HOOK.md"), "---\nname: guard\ndescription: guard hook\n---\n", "utf8"); + fs.writeFileSync(path.join(repoDir, "hooks", "guard", "index.js"), "console.log('guard');\n", "utf8"); + initRepo(repoDir); + + await withStateHome(stateHome, async () => { + const source = await addHookSource({ + id: "ops-hooks", + name: "ops-hooks", + repoUrl: `file://${repoDir}`, + transport: "https", + hooksRoot: "/hooks", + enabled: true, + removable: true, + }); + await syncHookSource(source, createCredentialProvider()); + const catalog = await loadHookCatalogFromSources(repoRoot, false); + const selected = catalog.hooks.find((hook) => hook.hookId === "ops-hooks/guard"); + assert.ok(selected); + + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-project-")); + const installReport = await executeHookOperation(repoRoot, { + operation: "install", + targets: ["claude", "gemini"], + scope: "project", + projectPath: projectRoot, + mode: "copy", + hooks: [], + hookSelections: [ + { + sourceId: "ops-hooks", + hookName: "guard", + hookId: "ops-hooks/guard", + }, + ], + }); + assert.equal(installReport.targets.every((target) => target.errors.length === 0), true); + + const claudeState = await loadHookInstallState(path.join(projectRoot, ".claude")); + assert.ok(claudeState); + assert.equal(claudeState?.managedHooks.length, 1); + + const uninstallReport = await executeHookOperation(repoRoot, { + operation: "uninstall", + targets: ["claude"], + scope: "project", + projectPath: projectRoot, + mode: "copy", + hooks: [], + hookSelections: [ + { + sourceId: "ops-hooks", + hookName: "guard", + hookId: "ops-hooks/guard", + }, + ], + }); + assert.equal(uninstallReport.targets[0].errors.length, 0); + assert.ok(uninstallReport.targets[0].removedHooks.includes("ops-hooks/guard")); + }); +}); diff --git a/tests/installer/repositories.test.ts b/tests/installer/repositories.test.ts new file mode 100644 index 0000000..2d0bd71 --- /dev/null +++ b/tests/installer/repositories.test.ts @@ -0,0 +1,95 @@ +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 { loadSources } from "../../src/installer-core/sources"; +import { loadHookSources } from "../../src/installer-core/hookSources"; +import { registerRepository } from "../../src/installer-core/repositories"; + +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 initRepo(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 repo"], { + cwd: repoDir, + }); +} + +test("registerRepository adds one repo to both skills and hooks registries", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-repos-state-")); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), "ica-repos-src-")); + const repoDir = path.join(repoBase, "repo"); + + fs.mkdirSync(path.join(repoDir, "skills", "demo-skill"), { recursive: true }); + fs.mkdirSync(path.join(repoDir, "hooks", "demo-hook"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "skills", "demo-skill", "SKILL.md"), "---\nname: demo-skill\ndescription: demo\n---\n", "utf8"); + fs.writeFileSync(path.join(repoDir, "hooks", "demo-hook", "HOOK.md"), "---\nname: demo-hook\ndescription: demo\n---\n", "utf8"); + initRepo(repoDir); + + await withStateHome(stateHome, async () => { + const result = await registerRepository( + { + id: "team-repo", + name: "team-repo", + repoUrl: `file://${repoDir}`, + transport: "https", + }, + createCredentialProvider(), + ); + + assert.equal(result.skillSource.id, "team-repo"); + assert.equal(result.hookSource.id, "team-repo"); + assert.equal(result.sync.skills.ok, true); + assert.equal(result.sync.hooks.ok, true); + + const skillSources = await loadSources(); + const hookSources = await loadHookSources(); + assert.ok(skillSources.some((source) => source.id === "team-repo")); + assert.ok(hookSources.some((source) => source.id === "team-repo")); + }); +}); + +test("registerRepository succeeds even when one artifact type is unavailable", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-repos-state-")); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), "ica-repos-src-")); + const repoDir = path.join(repoBase, "repo"); + + fs.mkdirSync(path.join(repoDir, "skills", "skill-only"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "skills", "skill-only", "SKILL.md"), "---\nname: skill-only\ndescription: only skill\n---\n", "utf8"); + initRepo(repoDir); + + await withStateHome(stateHome, async () => { + const result = await registerRepository( + { + id: "skill-only-repo", + name: "skill-only-repo", + repoUrl: `file://${repoDir}`, + transport: "https", + }, + createCredentialProvider(), + ); + + assert.equal(result.sync.skills.ok, true); + assert.equal(result.sync.hooks.ok, false); + assert.ok(typeof result.sync.hooks.error === "string"); + + const skillSources = await loadSources(); + const hookSources = await loadHookSources(); + assert.ok(skillSources.some((source) => source.id === "skill-only-repo")); + assert.ok(hookSources.some((source) => source.id === "skill-only-repo")); + }); +}); From b4fd07a4706c6bb4abbf264e35c420ec7df10b6f Mon Sep 17 00:00:00 2001 From: Karsten Samaschke Date: Sun, 15 Feb 2026 12:49:12 +0100 Subject: [PATCH 2/2] feat: add unified skill publishing pipeline Implement source-configured skill publishing flows across core, CLI, and dashboard, including personal publish modes, official contribution handling, bundle validation, and recursive resource support.\n\nAdd per-run advanced publish overrides (mode/base branch), official-source guardrails in UI/API, updated docs and schemas, and focused installer/dashboard/publish tests.\n\nStabilize the pre-commit test gate by cleaning stale compiled dist tests before build:quick so npm test executes only current compiled test sources. --- CONTRIBUTING.md | 21 +- README.md | 19 +- docs/README.md | 1 + docs/architecture.md | 2 + docs/configuration-guide.md | 40 + docs/index.md | 5 +- docs/installation-guide.md | 31 + docs/skill-publishing-guide.md | 159 +++ docs/skills-reference.md | 21 + ica.config.default.json | 2 + package.json | 3 +- schemas/skill-catalog.schema.json | 22 +- src/catalog/skills.catalog.json | 6 +- src/installer-cli/index.ts | 273 ++++- src/installer-core/catalog.ts | 38 +- src/installer-core/catalogMultiSource.ts | 124 +- src/installer-core/claudeIntegration.ts | 4 +- src/installer-core/repositories.ts | 13 + src/installer-core/skillPublish.ts | 742 ++++++++++++ src/installer-core/sources.ts | 71 +- src/installer-core/types.ts | 40 +- src/installer-dashboard/server/index.ts | 171 ++- .../web/src/InstallerDashboard.tsx | 1036 ++++++++++++++++- src/installer-dashboard/web/src/styles.css | 282 +++++ src/schemas/ica.config.schema.json | 9 + tests/installer/claude-integration.test.ts | 119 ++ tests/installer/cli-serve.test.ts | 21 + .../dashboard-skill-publish-ux.test.ts | 60 + .../dashboard-source-actions-style.test.ts | 34 + tests/installer/skill-publish.test.ts | 257 ++++ tests/installer/sources.test.ts | 12 + 31 files changed, 3572 insertions(+), 66 deletions(-) create mode 100644 docs/skill-publishing-guide.md create mode 100644 src/installer-core/skillPublish.ts create mode 100644 tests/installer/claude-integration.test.ts create mode 100644 tests/installer/cli-serve.test.ts create mode 100644 tests/installer/dashboard-skill-publish-ux.test.ts create mode 100644 tests/installer/dashboard-source-actions-style.test.ts create mode 100644 tests/installer/skill-publish.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60aaf5c..fff05ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,25 @@ We welcome contributions in many forms: 4. Test your changes thoroughly 5. Submit a pull request to the `dev` branch +### Contributing Skills to Official Source + +If you are contributing a skill bundle, you can validate and propose via ICA: + +```bash +node dist/src/installer-cli/index.js skills validate --path=/path/to/skill --profile=official +node dist/src/installer-cli/index.js skills contribute-official --path=/path/to/skill --message="Add my-skill" +``` + +Expected skill structure: +- required: `SKILL.md` +- optional: `scripts/`, `references/`, `assets/`, and other files needed by the skill + +Official contribution validation requires `SKILL.md` frontmatter fields: +- `name` +- `description` +- `category` +- `version` + ## Branching Strategy ### Branch Structure @@ -175,4 +194,4 @@ If you have questions about contributing: 2. Ask in GitHub Discussions 3. Create an issue with the `question` label -We appreciate all contributions, big and small. Thank you for helping make Intelligent Code Agents better! \ No newline at end of file +We appreciate all contributions, big and small. Thank you for helping make Intelligent Code Agents better! diff --git a/README.md b/README.md index 151ad32..f532e0e 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,11 @@ 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 @@ -96,7 +98,10 @@ Commands: - `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: @@ -110,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`). @@ -193,6 +209,7 @@ Tag releases from `main` (`vX.Y.Z`). The `release-sign` workflow: ## Documentation - [Installation Guide](docs/installation-guide.md) +- [Skill Publishing Guide](docs/skill-publishing-guide.md) - [Configuration Guide](docs/configuration-guide.md) - [Workflow Guide](docs/workflow-guide.md) - [Release Signing](docs/release-signing.md) diff --git a/docs/README.md b/docs/README.md index eb09745..68f89b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,3 +6,4 @@ Deployment documentation in this repo now reflects only: - verified bootstrap installers - `ica` CLI workflows - dashboard workflows +- skill publishing and official contribution workflows diff --git a/docs/architecture.md b/docs/architecture.md index 28c3f2d..d23f2e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,6 +15,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 b3faa97..47854b0 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 211b74c..5fdffa8 100644 --- a/docs/installation-guide.md +++ b/docs/installation-guide.md @@ -37,7 +37,38 @@ node dist/src/installer-cli/index.js catalog 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 456c393..154a827 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 adc99b5..b5f0425 100644 --- a/schemas/skill-catalog.schema.json +++ b/schemas/skill-catalog.schema.json @@ -12,7 +12,19 @@ "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" }, @@ -21,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" }, @@ -58,6 +74,8 @@ "name": { "type": "string" }, "description": { "type": "string" }, "category": { "type": "string" }, + "scope": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, "dependencies": { "type": "array", "items": { "type": "string" } }, "compatibleTargets": { "type": "array", @@ -72,7 +90,7 @@ "type": "object", "required": ["type", "path"], "properties": { - "type": { "type": "string", "enum": ["references", "scripts", "assets"] }, + "type": { "type": "string", "enum": ["references", "scripts", "assets", "other"] }, "path": { "type": "string" } } } diff --git a/src/catalog/skills.catalog.json b/src/catalog/skills.catalog.json index ecf9ee5..bac9a68 100644 --- a/src/catalog/skills.catalog.json +++ b/src/catalog/skills.catalog.json @@ -1,7 +1,7 @@ { "generatedAt": "1970-01-01T00:00:00.000Z", "source": "multi-source", - "version": "11.0.1", + "version": "12.0.0", "sources": [ { "id": "official-skills", @@ -11,6 +11,10 @@ "official": true, "enabled": true, "skillsRoot": "/skills", + "publishDefaultMode": "branch-pr", + "defaultBaseBranch": "dev", + "providerHint": "github", + "officialContributionEnabled": true, "removable": true } ], diff --git a/src/installer-cli/index.ts b/src/installer-cli/index.ts index 1d99785..0b78a53 100644 --- a/src/installer-cli/index.ts +++ b/src/installer-cli/index.ts @@ -18,10 +18,11 @@ import { loadHookCatalogFromSources, HookInstallSelection } from "../installer-c import { executeHookOperation, HookInstallRequest, HookTargetPlatform } from "../installer-core/hookExecutor"; import { loadHookInstallState } from "../installer-core/hookState"; import { registerRepository } from "../installer-core/repositories"; +import { contributeOfficialSkillBundle, publishSkillBundle, validateSkillBundle } from "../installer-core/skillPublish"; import { loadInstallState } from "../installer-core/state"; import { parseTargets, resolveTargetPaths } from "../installer-core/targets"; import { findRepoRoot } from "../installer-core/repo"; -import { InstallMode, InstallRequest, InstallScope, InstallSelection, OperationKind, TargetPlatform } from "../installer-core/types"; +import { InstallMode, InstallRequest, InstallScope, InstallSelection, OperationKind, PublishMode, TargetPlatform, ValidationProfile } from "../installer-core/types"; interface ParsedArgs { command: string; @@ -218,6 +219,53 @@ async function ensureHelperRunning(repoRoot: string): Promise { await waitForHelperReady(); } +function openBrowser(url: string): void { + let command = ""; + let args: string[] = []; + + if (process.platform === "darwin") { + command = "open"; + args = [url]; + } else if (process.platform === "win32") { + command = "cmd"; + args = ["/c", "start", "", url]; + } else { + command = "xdg-open"; + args = [url]; + } + + try { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.unref(); + } catch (error) { + process.stderr.write(`Unable to open browser automatically: ${error instanceof Error ? error.message : String(error)}\n`); + } +} + +function parseServePort(rawValue: string, flagName: string): number { + const parsed = Number(rawValue.trim()); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + throw new Error(`Invalid --${flagName} value '${rawValue}'.`); + } + return parsed; +} + +async function waitForDashboardReady(url: string, child: ReturnType, retries = 50): Promise { + for (let attempt = 0; attempt < retries; attempt += 1) { + if (child.exitCode !== null) { + throw new Error(`Dashboard process exited before becoming ready (code ${child.exitCode}).`); + } + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error(`Dashboard did not become ready in time at ${url}.`); +} + function printHelp(): void { output.write(`ICA Installer CLI\n\n`); output.write(`Commands:\n`); @@ -231,15 +279,22 @@ function printHelp(): void { output.write(` ica sources add [--repo-url= | --repo-path=] [--name=] [--id=] [--transport=https|ssh]\n`); output.write(` ica sources remove --id=\n`); output.write( - ` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--hooks-root=/hooks] [--enabled=true|false]\n`, + ` ica sources update --id= [--name=] [--repo-url=] [--transport=https|ssh] [--skills-root=/skills] [--hooks-root=/hooks] [--enabled=true|false] [--publish-default-mode=direct-push|branch-only|branch-pr] [--default-base-branch=main] [--provider-hint=github|gitlab|bitbucket|unknown]\n`, ); output.write(` ica sources auth --id= [--token=]\n`); output.write(` ica sources refresh [--id=]\n\n`); + output.write(` ica skills validate --path= [--profile=personal|official]\n`); + output.write( + ` ica skills publish --source= --path= [--message=] [--override-mode=direct-push|branch-only|branch-pr] [--override-base-branch=]\n`, + ); + output.write(` ica skills contribute-official --path= [--source=] [--message=]\n\n`); output.write(` ica hooks list [--targets=claude,gemini] [--scope=user|project] [--project-path=/path]\n`); output.write(` ica hooks catalog [--json]\n`); output.write(` ica hooks install [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n`); output.write(` ica hooks uninstall [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n`); output.write(` ica hooks sync [--targets=claude,gemini] [--scope=user|project] [--project-path=/path] [--mode=symlink|copy] [--hooks=]\n\n`); + output.write(` ica serve [--host=127.0.0.1] [--ui-port=4173] [--open=true|false]\n`); + output.write(` ica launch (alias for serve; deprecated)\n\n`); output.write(` Note: repository registration is unified. Adding one source auto-registers both skills and hooks mirrors.\n\n`); output.write(` ica container mount-project --project-path= --confirm [--container-name=] [--image=] [--port=] [--json]\n\n`); output.write(`Common flags:\n`); @@ -257,6 +312,7 @@ function printHelp(): void { output.write(` --force\n`); output.write(` --yes\n`); output.write(` --json\n`); + output.write(` --refresh (for catalog: force live source refresh)\n`); } async function promptInteractive(command: OperationKind, options: Record): Promise { @@ -480,6 +536,10 @@ async function runSources(positionals: string[], options: Record>[number]; hooks?: Awaited>[number]; }> @@ -493,6 +553,10 @@ async function runSources(positionals: string[], options: Record>[number]; hooks?: Awaited>[number]; } @@ -509,6 +573,10 @@ async function runSources(positionals: string[], options: Record): Promise { + const repoRoot = findRepoRoot(__dirname); + const host = stringOption(options, "host", "127.0.0.1").trim() || "127.0.0.1"; + const uiPort = parseServePort(stringOption(options, "ui-port", stringOption(options, "port", "4173")), "ui-port"); + + const apiPortRaw = stringOption(options, "api-port", "").trim(); + if (apiPortRaw) { + output.write("Notice: --api-port is ignored by the local dashboard server.\n"); + } + if (options["image"] !== undefined || options["build-image"] !== undefined) { + output.write("Notice: --image/--build-image are ignored in local serve mode.\n"); + } + + const dashboardScript = path.join(repoRoot, "dist", "src", "installer-dashboard", "server", "index.js"); + if (!fs.existsSync(dashboardScript)) { + throw new Error("Dashboard server is not built. Run: npm run build"); + } + + const dashboardUrl = `http://${host}:${uiPort}`; + const child = spawn(process.execPath, [dashboardScript], { + cwd: repoRoot, + env: { + ...process.env, + ICA_DASHBOARD_HOST: host, + ICA_DASHBOARD_PORT: String(uiPort), + }, + stdio: "inherit", + }); + + const onSigint = () => child.kill("SIGINT"); + const onSigterm = () => child.kill("SIGTERM"); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + + try { + await waitForDashboardReady(dashboardUrl, child); + output.write(`Dashboard ready: ${dashboardUrl}\n`); + if (boolOption(options, "open", false)) { + openBrowser(dashboardUrl); + } + + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0 || code === null) { + resolve(); + return; + } + reject(new Error(`Dashboard exited with code ${code}.`)); + }); + }); + } finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + } +} + async function runContainer(positionals: string[], options: Record): Promise { const action = (positionals[0] || "mount-project").toLowerCase(); if (action !== "mount-project") { @@ -902,6 +1049,112 @@ async function runContainer(positionals: string[], options: Record): Promise { + const action = (positionals[0] || "validate").toLowerCase(); + const json = boolOption(options, "json", false); + const credentials = createCredentialProvider(); + + if (action === "validate") { + const skillPath = stringOption(options, "path", "").trim(); + if (!skillPath) { + throw new Error("Missing required option --path"); + } + const profile = (stringOption(options, "profile", "personal").trim() || "personal") as ValidationProfile; + if (profile !== "personal" && profile !== "official") { + throw new Error("Invalid --profile. Supported: personal|official"); + } + + const result = await validateSkillBundle({ localPath: skillPath }, profile); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + output.write(`Profile: ${result.profile}\n`); + output.write(`Detected files: ${result.detectedFiles.length}\n`); + if (result.warnings.length > 0) { + output.write(`Warnings:\n`); + for (const warning of result.warnings) { + output.write(` - ${warning}\n`); + } + } + if (result.errors.length > 0) { + output.write(`Errors:\n`); + for (const err of result.errors) { + output.write(` - ${err}\n`); + } + throw new Error("Validation failed."); + } + output.write(`Validation passed.\n`); + return; + } + + if (action === "publish") { + const sourceId = stringOption(options, "source", stringOption(options, "id", "")).trim(); + const skillPath = stringOption(options, "path", "").trim(); + const overrideMode = stringOption(options, "override-mode", "").trim(); + const overrideBaseBranch = stringOption(options, "override-base-branch", "").trim(); + if (!sourceId) throw new Error("Missing required option --source"); + if (!skillPath) throw new Error("Missing required option --path"); + if (overrideMode && overrideMode !== "direct-push" && overrideMode !== "branch-only" && overrideMode !== "branch-pr") { + throw new Error("Invalid --override-mode. Supported: direct-push|branch-only|branch-pr"); + } + + const result = await publishSkillBundle( + { + sourceId, + bundle: { + localPath: skillPath, + skillName: stringOption(options, "skill-name", "").trim() || undefined, + }, + commitMessage: stringOption(options, "message", "").trim() || undefined, + overrideMode: overrideMode ? (overrideMode as PublishMode) : undefined, + overrideBaseBranch: overrideBaseBranch || undefined, + }, + credentials, + ); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + output.write(`Published using mode '${result.mode}'.\n`); + output.write(`Branch: ${result.branch}\n`); + output.write(`Commit: ${result.commitSha}\n`); + output.write(`Pushed remote: ${result.pushedRemote}\n`); + if (result.prUrl) output.write(`PR: ${result.prUrl}\n`); + if (result.compareUrl) output.write(`Compare: ${result.compareUrl}\n`); + return; + } + + if (action === "contribute-official") { + const skillPath = stringOption(options, "path", "").trim(); + if (!skillPath) throw new Error("Missing required option --path"); + const result = await contributeOfficialSkillBundle( + { + sourceId: stringOption(options, "source", "").trim() || undefined, + bundle: { + localPath: skillPath, + skillName: stringOption(options, "skill-name", "").trim() || undefined, + }, + commitMessage: stringOption(options, "message", "").trim() || undefined, + }, + credentials, + ); + if (json) { + output.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + output.write(`Official contribution prepared.\n`); + output.write(`Branch: ${result.branch}\n`); + output.write(`Commit: ${result.commitSha}\n`); + output.write(`Pushed remote: ${result.pushedRemote}\n`); + if (result.prUrl) output.write(`PR: ${result.prUrl}\n`); + if (result.compareUrl) output.write(`Compare: ${result.compareUrl}\n`); + return; + } + + throw new Error(`Unknown skills action '${action}'. Supported: validate|publish|contribute-official`); +} + async function main(): Promise { const { command, options, positionals } = parseArgv(process.argv.slice(2)); const normalized = command.toLowerCase(); @@ -936,6 +1189,22 @@ async function main(): Promise { return; } + if (normalized === "skills") { + await runSkills(positionals, options); + return; + } + + if (normalized === "serve") { + await runServe(options); + return; + } + + if (normalized === "launch") { + output.write("Deprecation notice: `ica launch` is now an alias of `ica serve` and will be removed in a future release.\n"); + await runServe(options); + return; + } + if (normalized === "container") { await runContainer(positionals, options); return; diff --git a/src/installer-core/catalog.ts b/src/installer-core/catalog.ts index a61f51e..898d09a 100644 --- a/src/installer-core/catalog.ts +++ b/src/installer-core/catalog.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { SkillCatalog, SkillCatalogEntry, SkillResource, TargetPlatform } from "./types"; import { buildMultiSourceCatalog } from "./catalogMultiSource"; import { isSkillBlocked } from "./skillBlocklist"; -import { DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL } from "./sources"; +import { DEFAULT_PUBLISH_MODE, DEFAULT_SKILLS_ROOT, OFFICIAL_SOURCE_ID, OFFICIAL_SOURCE_NAME, OFFICIAL_SOURCE_URL } from "./sources"; const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; interface LocalCatalogEntry { @@ -64,22 +64,30 @@ function inferCategory(skillName: string): string { function collectResources(skillDir: string): SkillResource[] { const resources: SkillResource[] = []; - const directories: Array = ["references", "scripts", "assets"]; - - for (const resourceType of directories) { - const location = path.join(skillDir, resourceType); - if (!fs.existsSync(location)) continue; - - for (const file of fs - .readdirSync(location, { withFileTypes: true }) - .filter((entry) => entry.isFile() || entry.isSymbolicLink()) - .sort((a, b) => a.name.localeCompare(b.name))) { + const walk = (current: string): void => { + const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.name === ".git") continue; + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(absolute); + continue; + } + if (!(entry.isFile() || entry.isSymbolicLink())) continue; + if (entry.name === "SKILL.md") continue; + + const relative = path.relative(skillDir, absolute).replace(/\\/g, "/"); + const topLevel = relative.split("/", 1)[0]; + const resourceType: SkillResource["type"] = + topLevel === "references" || topLevel === "scripts" || topLevel === "assets" ? topLevel : "other"; resources.push({ type: resourceType, - path: path.join("skills", path.basename(skillDir), resourceType, file.name), + path: path.join("skills", path.basename(skillDir), relative).replace(/\\/g, "/"), }); } - } + }; + walk(skillDir); + resources.sort((a, b) => a.path.localeCompare(b.path)); return resources; } @@ -133,6 +141,10 @@ export function buildDefaultSourceCatalog(version: string, sourceDateEpoch?: str official: true, enabled: true, skillsRoot: DEFAULT_SKILLS_ROOT, + publishDefaultMode: DEFAULT_PUBLISH_MODE, + defaultBaseBranch: "dev", + providerHint: "github", + officialContributionEnabled: true, removable: true, }, ], diff --git a/src/installer-core/catalogMultiSource.ts b/src/installer-core/catalogMultiSource.ts index 0328307..a27e370 100644 --- a/src/installer-core/catalogMultiSource.ts +++ b/src/installer-core/catalogMultiSource.ts @@ -13,18 +13,78 @@ interface CatalogOptions { refresh: boolean; } -function parseFrontmatter(content: string): Record { +interface ParsedFrontmatter { + values: Record; + lists: Record; +} + +function cleanFrontmatterValue(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function parseFrontmatter(content: string): ParsedFrontmatter { const match = content.match(FRONTMATTER_RE); - if (!match) return {}; - 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; + if (!match) return { values: {}, lists: {} }; + const values: Record = {}; + const lists: Record = {}; + let currentListKey: string | null = null; + + for (const rawLine of match[1].split("\n")) { + const line = rawLine.replace(/\r$/, ""); + const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (keyMatch) { + const key = keyMatch[1].trim(); + const rawValue = keyMatch[2].trim(); + if (!key) { + currentListKey = null; + continue; + } + + if (rawValue.length === 0) { + currentListKey = key; + if (!lists[key]) lists[key] = []; + continue; + } + + if (rawValue.startsWith("[") && rawValue.endsWith("]")) { + const entries = rawValue + .slice(1, -1) + .split(",") + .map((entry) => cleanFrontmatterValue(entry)) + .filter(Boolean); + if (entries.length > 0) { + lists[key] = entries; + } + } else { + values[key] = cleanFrontmatterValue(rawValue); + } + currentListKey = null; + continue; + } + + const listMatch = line.match(/^\s*-\s*(.+)$/); + if (currentListKey && listMatch) { + const value = cleanFrontmatterValue(listMatch[1]); + if (value) { + if (!lists[currentListKey]) lists[currentListKey] = []; + lists[currentListKey].push(value); + } + continue; + } + + if (line.trim()) { + currentListKey = null; + } } - return map; + + return { values, lists }; } function inferCategory(skillName: string): string { @@ -56,23 +116,30 @@ function inferCategory(skillName: string): string { function collectResources(skillDir: string, skillName: string): SkillResource[] { const resources: SkillResource[] = []; - const directories: Array = ["references", "scripts", "assets"]; - for (const type of directories) { - const base = path.join(skillDir, type); - if (!fs.existsSync(base)) continue; - - const files = fs - .readdirSync(base, { withFileTypes: true }) - .filter((entry) => entry.isFile() || entry.isSymbolicLink()) - .sort((a, b) => a.name.localeCompare(b.name)); + const walk = (current: string): void => { + const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.name === ".git") continue; + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(absolute); + continue; + } + if (!(entry.isFile() || entry.isSymbolicLink())) continue; + if (entry.name === "SKILL.md") continue; - for (const file of files) { + const relative = path.relative(skillDir, absolute).replace(/\\/g, "/"); + const topLevel = relative.split("/", 1)[0]; + const type: SkillResource["type"] = + topLevel === "references" || topLevel === "scripts" || topLevel === "assets" ? topLevel : "other"; resources.push({ type, - path: path.join("skills", skillName, type, file.name).replace(/\\/g, "/"), + path: path.join("skills", skillName, relative).replace(/\\/g, "/"), }); } - } + }; + walk(skillDir); + resources.sort((a, b) => a.path.localeCompare(b.path)); return resources; } @@ -88,7 +155,8 @@ function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | n const skillFile = path.join(skillDir, "SKILL.md"); if (!fs.existsSync(skillFile)) return null; const content = fs.readFileSync(skillFile, "utf8"); - const frontmatter = parseFrontmatter(content); + const parsedFrontmatter = parseFrontmatter(content); + const frontmatter = parsedFrontmatter.values; const skillName = frontmatter.name || path.basename(skillDir); if (isSkillBlocked(skillName)) { return null; @@ -96,6 +164,14 @@ function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | n const skillId = `${source.id}/${skillName}`; const stat = fs.statSync(skillFile); const explicitCategory = (frontmatter.category || "").trim().toLowerCase(); + const explicitScope = (frontmatter.scope || "").trim().toLowerCase(); + const tags = Array.from( + new Set( + (parsedFrontmatter.lists.tags || []) + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean), + ), + ); return { skillId, @@ -106,6 +182,8 @@ function toCatalogSkill(source: SkillSource, skillDir: string): CatalogSkill | n name: skillName, description: frontmatter.description || "", category: explicitCategory || inferCategory(skillName), + scope: explicitScope || undefined, + tags: tags.length > 0 ? tags : undefined, dependencies: [], compatibleTargets: ["claude", "codex", "cursor", "gemini", "antigravity"] satisfies TargetPlatform[], resources: collectResources(skillDir, skillName), 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 47bb8a5..a151d89 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 { syncSource } from "./sourceSync"; @@ -15,6 +16,10 @@ export interface RepositoryRegistrationInput { removable?: boolean; official?: boolean; skillsRoot?: string; + publishDefaultMode?: PublishMode; + defaultBaseBranch?: string; + providerHint?: GitProvider; + officialContributionEnabled?: boolean; hooksRoot?: string; token?: string; } @@ -54,6 +59,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 9c28eb9..f7c5bd9 100644 --- a/src/installer-core/sources.ts +++ b/src/installer-core/sources.ts @@ -1,12 +1,14 @@ import os from "node:os"; import path from "node:path"; import { ensureDir, pathExists, readText, writeText } from "./fs"; -import { SourceTransport, SkillSource } from "./types"; +import { GitProvider, PublishMode, SourceTransport, SkillSource } from "./types"; export const OFFICIAL_SOURCE_ID = "official-skills"; export const OFFICIAL_SOURCE_NAME = "official"; export const OFFICIAL_SOURCE_URL = "https://github.com/intelligentcode-ai/skills.git"; export const DEFAULT_SKILLS_ROOT = "/skills"; +export const DEFAULT_PUBLISH_MODE: PublishMode = "branch-pr"; +export const DEFAULT_BASE_BRANCH = "main"; interface AddOrUpdateSourceInput { id?: string; @@ -16,6 +18,10 @@ interface AddOrUpdateSourceInput { official?: boolean; enabled?: boolean; skillsRoot?: string; + publishDefaultMode?: PublishMode; + defaultBaseBranch?: string; + providerHint?: GitProvider; + officialContributionEnabled?: boolean; credentialRef?: string; removable?: boolean; } @@ -35,6 +41,26 @@ function detectTransport(repoUrl: string): SourceTransport { return "https"; } +function normalizePublishDefaultMode(mode?: string): PublishMode { + if (mode === "direct-push" || mode === "branch-only" || mode === "branch-pr") { + return mode; + } + return DEFAULT_PUBLISH_MODE; +} + +function normalizeDefaultBaseBranch(branch?: string): string | undefined { + const next = (branch || "").trim(); + return next || undefined; +} + +export function detectGitProvider(repoUrl: string): GitProvider { + const normalized = repoUrl.trim().toLowerCase(); + if (normalized.includes("github.com")) return "github"; + if (normalized.includes("gitlab.com")) return "gitlab"; + if (normalized.includes("bitbucket.org")) return "bitbucket"; + return "unknown"; +} + function slug(value: string): string { return value .toLowerCase() @@ -66,14 +92,19 @@ function uniqueSourceId(baseId: string, existing: Set): string { } function defaultSource(source?: Partial): SkillSource { + const repoUrl = source?.repoUrl || OFFICIAL_SOURCE_URL; return { id: source?.id || OFFICIAL_SOURCE_ID, name: source?.name || OFFICIAL_SOURCE_NAME, - repoUrl: source?.repoUrl || OFFICIAL_SOURCE_URL, - transport: source?.transport || detectTransport(source?.repoUrl || OFFICIAL_SOURCE_URL), + repoUrl, + transport: source?.transport || detectTransport(repoUrl), official: source?.official ?? true, enabled: source?.enabled ?? true, skillsRoot: normalizeSkillsRoot(source?.skillsRoot), + publishDefaultMode: normalizePublishDefaultMode(source?.publishDefaultMode), + defaultBaseBranch: normalizeDefaultBaseBranch(source?.defaultBaseBranch) || (source?.official ? "dev" : DEFAULT_BASE_BRANCH), + providerHint: source?.providerHint || detectGitProvider(repoUrl), + officialContributionEnabled: source?.officialContributionEnabled ?? Boolean(source?.official), credentialRef: source?.credentialRef, removable: source?.removable ?? true, lastSyncAt: source?.lastSyncAt, @@ -107,19 +138,29 @@ export function getSourceRepoPath(sourceId: string): string { return path.join(getSourceCacheRoot(), sourceId, "repo"); } +export function getSourceWorkspaceRepoPath(sourceId: string): string { + return path.join(getIcaStateRoot(), "source-workspaces", sourceId, "repo"); +} + export function getSourceSkillsPath(sourceId: string): string { return path.join(getSourceRoot(sourceId), "skills"); } -function normalizeSource(source: SkillSource): SkillSource { +function normalizeSource(source: Partial & { id: string; repoUrl: string }): SkillSource { + const repoUrl = source.repoUrl.trim(); + const official = Boolean(source.official); return { ...source, id: slug(source.id), name: source.name?.trim() || source.id, - repoUrl: source.repoUrl.trim(), - transport: source.transport || detectTransport(source.repoUrl), + repoUrl, + transport: source.transport || detectTransport(repoUrl), skillsRoot: normalizeSkillsRoot(source.skillsRoot), - official: Boolean(source.official), + publishDefaultMode: normalizePublishDefaultMode(source.publishDefaultMode), + defaultBaseBranch: normalizeDefaultBaseBranch(source.defaultBaseBranch) || (official ? "dev" : DEFAULT_BASE_BRANCH), + providerHint: source.providerHint || detectGitProvider(repoUrl), + officialContributionEnabled: source.officialContributionEnabled ?? official, + official, enabled: source.enabled !== false, removable: source.removable !== false, credentialRef: source.credentialRef?.trim() || undefined, @@ -138,9 +179,13 @@ export async function loadSources(): Promise { } try { - const raw = JSON.parse(await readText(sourcesFile)) as { sources?: SkillSource[] }; + const raw = JSON.parse(await readText(sourcesFile)) as { sources?: Array> }; const parsed = Array.isArray(raw.sources) ? raw.sources : []; - const normalized = parsed.map((source) => normalizeSource(source)); + const normalized = parsed + .filter((source): source is Partial & { id: string; repoUrl: string } => { + return Boolean(source && typeof source.id === "string" && typeof source.repoUrl === "string"); + }) + .map((source) => normalizeSource(source)); if (!normalized.find((source) => source.official)) { normalized.unshift(defaultSource()); @@ -172,6 +217,10 @@ export async function addSource(input: AddOrUpdateSourceInput): Promise { enabled: boolean; skillsRoot?: string; hooksRoot?: string; + publishDefaultMode?: PublishMode; + defaultBaseBranch?: string; + providerHint?: "github" | "gitlab" | "bitbucket" | "unknown"; + officialContributionEnabled?: boolean; credentialRef?: string; removable: boolean; lastSyncAt?: string; @@ -467,6 +480,10 @@ async function main(): Promise { 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, @@ -493,6 +510,10 @@ async function main(): Promise { 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, @@ -525,6 +546,18 @@ async function main(): Promise { 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, @@ -566,6 +599,18 @@ async function main(): Promise { 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, @@ -735,6 +780,130 @@ async function main(): Promise { 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, diff --git a/src/installer-dashboard/web/src/InstallerDashboard.tsx b/src/installer-dashboard/web/src/InstallerDashboard.tsx index a75e3c6..b82c0b2 100644 --- a/src/installer-dashboard/web/src/InstallerDashboard.tsx +++ b/src/installer-dashboard/web/src/InstallerDashboard.tsx @@ -11,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; @@ -27,7 +31,10 @@ type Skill = { name: string; description: string; category: string; + scope?: string; + tags?: string[]; resources: Array<{ type: string; path: string }>; + sourcePath?: string; version?: string; updatedAt?: string; }; @@ -118,6 +125,24 @@ 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"; @@ -220,6 +245,57 @@ function titleCase(value: string): string { .replace(/\b[a-z]/g, (match) => match.toUpperCase()); } +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); + } + } + + 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 { const [sources, setSources] = useState([]); const [skills, setSkills] = useState([]); @@ -246,16 +322,37 @@ export function InstallerDashboard(): JSX.Element { 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); const [appearanceBackground, setAppearanceBackground] = useState(() => readStoredAppearance().background); const [appearanceOpen, setAppearanceOpen] = useState(false); const [sourceFilter, setSourceFilter] = useState("all"); + const [scopeFilter, setScopeFilter] = useState("all"); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [tagFilter, setTagFilter] = useState("all"); const [installedOnly, setInstalledOnly] = useState(false); 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); @@ -269,6 +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 selectedPublishSource = useMemo( + () => sources.find((source) => source.id === editingSourceId) || null, + [sources, editingSourceId], + ); const installedSkillIds = useMemo(() => { const names = new Set(); @@ -303,13 +404,35 @@ 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]); + 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; @@ -317,14 +440,74 @@ export function InstallerDashboard(): JSX.Element { if (installedOnly && !installedSkillIds.has(skill.skillId)) { return false; } + 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 (categoryFilter !== "all" && skillCategory !== categoryFilter) { + return false; + } + if (tagFilter !== "all" && !skillTags.includes(tagFilter)) { + return false; + } if (!normalizedQuery) { return true; } const resourceText = skill.resources.map((item) => `${item.type} ${item.path}`).join(" "); - const haystack = `${skill.skillId} ${skill.description} ${skill.category} ${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, 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(); @@ -660,6 +843,10 @@ export function InstallerDashboard(): JSX.Element { name: sourceName.trim() || undefined, repoUrl: sourceRepoUrl.trim(), transport: sourceTransport, + publishDefaultMode: sourcePublishDefaultMode, + defaultBaseBranch: sourceDefaultBaseBranch.trim() || undefined, + providerHint: sourceProviderHint, + officialContributionEnabled: sourceOfficialContributionEnabled, token: sourceToken.trim() || undefined, }), }); @@ -670,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(); @@ -723,6 +914,316 @@ export function InstallerDashboard(): JSX.Element { } } + async function saveSourcePublishSettings(): Promise { + if (!editingSourceId) { + setError("Select a source to update."); + return; + } + setBusy(true); + setError(""); + try { + const res = await fetch(`/api/v1/sources/${editingSourceId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + 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, "Failed to update source publish settings.")); + } + await fetchSources(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + 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 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, "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(); + setPublishComposerOpen(false); + setPublishAdvancedOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + 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(""); @@ -785,6 +1286,26 @@ export function InstallerDashboard(): JSX.Element { .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, []); + useEffect(() => { + if (sources.length === 0) { + setEditingSourceId(""); + return; + } + if (!editingSourceId || !sources.some((source) => source.id === editingSourceId)) { + setEditingSourceId(sources[0].id); + } + }, [sources, editingSourceId]); + + useEffect(() => { + 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(() => { setSelectionCustomized(false); setHookSelectionCustomized(false); @@ -808,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)) { @@ -815,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) => { @@ -1096,6 +1661,58 @@ export function InstallerDashboard(): JSX.Element {
+ +
+
+
+

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)}
+
+ )} +
@@ -1153,6 +1770,72 @@ export function InstallerDashboard(): JSX.Element { ))} +
+ Scope +
+ + {scopeFilterOptions.map((scopeValue) => ( + + ))} +
+
+
+ Category +
+ + {categoryFilterOptions.map((categoryValue) => ( + + ))} +
+
+
+ Tag +
+ + {tagFilterOptions.map((tagValue) => ( + + ))} +
+
{sourceNameById.get(skill.sourceId) || skill.sourceId} + {skill.scope && {titleCase(skill.scope)}} + {(skill.tags || []).slice(0, 2).map((tag) => ( + + #{tag} + + ))} {isInstalled && installed}
@@ -1377,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}}
+ @@ -1392,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) + + @@ -1530,6 +2296,262 @@ export function InstallerDashboard(): JSX.Element { )} + + {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) => ( + + )) + )} +
+
+
+ )} ); } diff --git a/src/installer-dashboard/web/src/styles.css b/src/installer-dashboard/web/src/styles.css index 1ca16b9..e0a94aa 100644 --- a/src/installer-dashboard/web/src/styles.css +++ b/src/installer-dashboard/web/src/styles.css @@ -833,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); @@ -1062,9 +1305,12 @@ input[type="radio"]:focus-visible { .source-actions .btn-inline { 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 { @@ -1237,6 +1483,7 @@ input[type="radio"]:focus-visible { display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; gap: var(--space-4); } @@ -1485,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); @@ -1616,4 +1878,24 @@ pre { .chip { min-width: 0; } + + .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); + } + + .publish-picker-head { + flex-direction: column; + align-items: stretch; + } } diff --git a/src/schemas/ica.config.schema.json b/src/schemas/ica.config.schema.json index 8568f1b..5d944ed 100644 --- a/src/schemas/ica.config.schema.json +++ b/src/schemas/ica.config.schema.json @@ -26,6 +26,15 @@ "type": "boolean", "description": "Always activate PM role" }, + "work_item_pipeline_enabled": { + "type": "boolean", + "description": "Automatically orchestrate create-work-items -> plan-work-items -> run-work-items for actionable findings/comments" + }, + "work_item_pipeline_mode": { + "type": "string", + "enum": ["batch_auto", "batch_confirm", "item_confirm"], + "description": "Confirmation mode for actionable-finding ingestion when work item pipeline is enabled" + }, "l3_settings": { "type": "object", "description": "L3 autonomous mode settings", diff --git a/tests/installer/claude-integration.test.ts b/tests/installer/claude-integration.test.ts new file mode 100644 index 0000000..d5d9a60 --- /dev/null +++ b/tests/installer/claude-integration.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyClaudeIntegration } from "../../src/installer-core/claudeIntegration"; + +const repoRoot = path.resolve(__dirname, "../../.."); + +test("applyClaudeIntegration writes string matchers for managed PreToolUse hooks", async () => { + const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-integration-")); + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-project-")); + + fs.writeFileSync( + path.join(installPath, "settings.json"), + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: "Read", + hooks: [{ type: "command", command: "node /tmp/existing.js" }], + }, + ], + }, + }, + null, + 2, + ), + "utf8", + ); + + await applyClaudeIntegration({ + repoRoot, + installPath, + scope: "project", + projectPath, + agentDirName: ".claude", + }); + + const settings = JSON.parse(fs.readFileSync(path.join(installPath, "settings.json"), "utf8")) as { + hooks?: { PreToolUse?: Array<{ matcher?: unknown; hooks?: Array<{ command?: string }> }> }; + }; + + const preToolUse = settings.hooks?.PreToolUse ?? []; + const managed = preToolUse.filter((entry) => + entry.hooks?.some((hook) => + (hook.command || "").includes("agent-infrastructure-protection.js") || + (hook.command || "").includes("summary-file-enforcement.js"), + ), + ); + + assert.equal(managed.length, 2); + assert.ok(managed.every((entry) => typeof entry.matcher === "string")); + const matchers = managed.map((entry) => String(entry.matcher)).sort(); + assert.deepEqual(matchers, ["^(BashTool|Bash)$", "^(FileWriteTool|FileEditTool|Write|Edit)$"]); +}); + +test("applyClaudeIntegration keeps unrelated hooks and replaces prior managed entries", async () => { + const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-integration-")); + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "ica-claude-project-")); + + fs.writeFileSync( + path.join(installPath, "settings.json"), + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: "Read", + hooks: [{ type: "command", command: "node /tmp/keep-me.js" }], + }, + { + matcher: "legacy", + hooks: [{ type: "command", command: "node /tmp/hooks/agent-infrastructure-protection.js" }], + }, + { + matcher: "legacy", + hooks: [{ type: "command", command: "node /tmp/hooks/summary-file-enforcement.js" }], + }, + ], + }, + }, + null, + 2, + ), + "utf8", + ); + + await applyClaudeIntegration({ + repoRoot, + installPath, + scope: "project", + projectPath, + agentDirName: ".claude", + }); + + const settings = JSON.parse(fs.readFileSync(path.join(installPath, "settings.json"), "utf8")) as { + hooks?: { PreToolUse?: Array<{ matcher?: unknown; hooks?: Array<{ command?: string }> }> }; + }; + const preToolUse = settings.hooks?.PreToolUse ?? []; + + const userReadHooks = preToolUse.filter((entry) => + entry.hooks?.some((hook) => (hook.command || "").includes("keep-me.js")), + ); + assert.equal(userReadHooks.length, 1); + + const infraHooks = preToolUse.filter((entry) => + entry.hooks?.some((hook) => (hook.command || "").includes("agent-infrastructure-protection.js")), + ); + assert.equal(infraHooks.length, 1); + assert.equal(infraHooks[0].matcher, "^(BashTool|Bash)$"); + + const summaryHooks = preToolUse.filter((entry) => + entry.hooks?.some((hook) => (hook.command || "").includes("summary-file-enforcement.js")), + ); + assert.equal(summaryHooks.length, 1); + assert.equal(summaryHooks[0].matcher, "^(FileWriteTool|FileEditTool|Write|Edit)$"); +}); diff --git a/tests/installer/cli-serve.test.ts b/tests/installer/cli-serve.test.ts new file mode 100644 index 0000000..8e71eaf --- /dev/null +++ b/tests/installer/cli-serve.test.ts @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readFile(relativePath: string): string { + return fs.readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); +} + +test("CLI help advertises serve/launch commands", () => { + const cli = readFile("src/installer-cli/index.ts"); + assert.match(cli, /ica serve \[--host=127\.0\.0\.1\] \[--ui-port=4173\] \[--open=true\|false\]/); + assert.match(cli, /ica launch \(alias for serve; deprecated\)/); +}); + +test("CLI main dispatch handles serve and launch", () => { + const cli = readFile("src/installer-cli/index.ts"); + assert.match(cli, /if \(normalized === "serve"\) \{/); + assert.match(cli, /if \(normalized === "launch"\) \{/); + assert.match(cli, /await runServe\(options\);/); +}); diff --git a/tests/installer/dashboard-skill-publish-ux.test.ts b/tests/installer/dashboard-skill-publish-ux.test.ts new file mode 100644 index 0000000..54a5234 --- /dev/null +++ b/tests/installer/dashboard-skill-publish-ux.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readWorkspaceFile(relativePath: string): string { + return fs.readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); +} + +test("skills UI keeps only two primary publish actions on panel and moves configuration to overlays", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.match(ui, />\s*Publish\s*Scope<\/span>/); + assert.match(ui, /Category<\/span>/); + assert.match(ui, /Tag<\/span>/); + assert.doesNotMatch(ui, /skill-publish-btn/); + assert.doesNotMatch(ui, /runQuickPublishFromSkillCard/); +}); + +test("source filter options include all discovered source ids", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.match(ui, /new Set\(sources\.filter\(\(source\) => source\.enabled\)\.map\(\(source\) => source\.id\)\)/); +}); + +test("quick publish derives candidates from visible selected skills", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + + assert.match(ui, /const selectedVisibleSkillPublishCandidates = useMemo/); + assert.match(ui, /selectedVisibleSkillPublishCandidates\.length === 1/); +}); + +test("dashboard server exposes a dedicated skill directory picker endpoint", () => { + const server = readWorkspaceFile("src/installer-dashboard/server/index.ts"); + + assert.match(server, /app\.post\("\/api\/v1\/skills\/pick"/); +}); + +test("dashboard server blocks publishing official skills to non-official sources", () => { + const server = readWorkspaceFile("src/installer-dashboard/server/index.ts"); + + assert.match(server, /Official skills can only be published to official sources/); +}); + +test("advanced publish flow supports per-run override mode and base branch", () => { + const ui = readWorkspaceFile("src/installer-dashboard/web/src/InstallerDashboard.tsx"); + const server = readWorkspaceFile("src/installer-dashboard/server/index.ts"); + + assert.match(ui, /Publish Mode Override \(optional\)/); + assert.match(ui, /Base Branch Override \(optional\)/); + assert.match(ui, /overrideMode: params\.overrideMode/); + assert.match(ui, /overrideBaseBranch: params\.overrideBaseBranch/); + assert.match(server, /const overrideMode = typeof body\.overrideMode === "string" \? body\.overrideMode\.trim\(\) : ""/); + assert.match(server, /const overrideBaseBranch = typeof body\.overrideBaseBranch === "string" \? body\.overrideBaseBranch\.trim\(\) : ""/); +}); diff --git a/tests/installer/dashboard-source-actions-style.test.ts b/tests/installer/dashboard-source-actions-style.test.ts new file mode 100644 index 0000000..a63d7c4 --- /dev/null +++ b/tests/installer/dashboard-source-actions-style.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function readCssRule(css: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\}`, "m"); + const match = css.match(regex); + assert.ok(match, `Expected CSS rule for selector: ${selector}`); + return match[1]; +} + +test("source action buttons enforce equal height contract", () => { + const stylesheet = path.resolve(process.cwd(), "src/installer-dashboard/web/src/styles.css"); + const css = fs.readFileSync(stylesheet, "utf8"); + const rule = readCssRule(css, ".source-actions .btn-inline"); + + assert.match(rule, /display:\s*inline-flex\s*;/); + assert.match(rule, /justify-content:\s*center\s*;/); + assert.match(rule, /align-items:\s*center\s*;/); + assert.match(rule, /min-block-size:\s*2\.6rem\s*;/); +}); + +test("publish quick-action buttons enforce equal height contract", () => { + const stylesheet = path.resolve(process.cwd(), "src/installer-dashboard/web/src/styles.css"); + const css = fs.readFileSync(stylesheet, "utf8"); + const rule = readCssRule(css, ".publish-quick-actions .btn"); + + assert.match(rule, /display:\s*inline-flex\s*;/); + assert.match(rule, /align-items:\s*center\s*;/); + assert.match(rule, /justify-content:\s*center\s*;/); + assert.match(rule, /min-block-size:\s*2\.7rem\s*;/); +}); diff --git a/tests/installer/skill-publish.test.ts b/tests/installer/skill-publish.test.ts new file mode 100644 index 0000000..ba5d83d --- /dev/null +++ b/tests/installer/skill-publish.test.ts @@ -0,0 +1,257 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createCredentialProvider } from "../../src/installer-core/credentials"; +import { addSource, loadSources } from "../../src/installer-core/sources"; +import { + contributeOfficialSkillBundle, + detectGitProvider, + publishSkillBundle, + sanitizeSkillName, + validateSkillBundle, +} from "../../src/installer-core/skillPublish"; + +function withStateHome(stateHome: string, fn: () => Promise): Promise { + const previous = process.env.ICA_STATE_HOME; + process.env.ICA_STATE_HOME = stateHome; + return fn().finally(() => { + if (previous === undefined) { + delete process.env.ICA_STATE_HOME; + } else { + process.env.ICA_STATE_HOME = previous; + } + }); +} + +function initGitRepo(repoDir: string): void { + execFileSync("git", ["init", "-q"], { cwd: repoDir }); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "user.name=ICA Test", "-c", "user.email=ica-test@example.com", "commit", "-q", "-m", "seed"], { + cwd: repoDir, + }); +} + +test("detectGitProvider maps common git providers", () => { + assert.equal(detectGitProvider("https://github.com/org/repo.git"), "github"); + assert.equal(detectGitProvider("git@gitlab.com:org/repo.git"), "gitlab"); + assert.equal(detectGitProvider("https://bitbucket.org/org/repo.git"), "bitbucket"); + assert.equal(detectGitProvider("https://example.com/org/repo.git"), "unknown"); +}); + +test("sanitizeSkillName enforces lowercase slug naming", () => { + assert.equal(sanitizeSkillName(" My Skill_Name "), "my-skill-name"); +}); + +test("loadSources migrates publish fields for legacy source entries", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-sources-")); + const sourcesFile = path.join(stateHome, "sources.json"); + fs.mkdirSync(stateHome, { recursive: true }); + fs.writeFileSync( + sourcesFile, + JSON.stringify( + { + sources: [ + { + id: "legacy-source", + name: "legacy-source", + repoUrl: "https://github.com/example/legacy.git", + transport: "https", + official: false, + enabled: true, + skillsRoot: "/skills", + removable: true, + }, + ], + }, + null, + 2, + ), + ); + + await withStateHome(stateHome, async () => { + const sources = await loadSources(); + const legacy = sources.find((source) => source.id === "legacy-source"); + assert.ok(legacy); + assert.equal(legacy?.publishDefaultMode, "branch-pr"); + assert.equal(legacy?.providerHint, "github"); + assert.equal(legacy?.officialContributionEnabled, false); + }); +}); + +test("validateSkillBundle distinguishes personal and official policy", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-validate-")); + const skillDir = path.join(root, "my-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: my-skill\ndescription: demo\n---\n", "utf8"); + + const personal = await validateSkillBundle({ localPath: skillDir }, "personal"); + assert.equal(personal.errors.length, 0); + + const official = await validateSkillBundle({ localPath: skillDir }, "official"); + assert.ok(official.errors.some((entry: string) => entry.includes("category"))); + assert.ok(official.errors.some((entry: string) => entry.includes("version"))); +}); + +test("publishSkillBundle supports direct-push and branch-only modes", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-state-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-remote-")); + const seedRepo = path.join(remoteRoot, "seed"); + fs.mkdirSync(path.join(seedRepo, "skills"), { recursive: true }); + fs.writeFileSync(path.join(seedRepo, "README.md"), "# test\n", "utf8"); + initGitRepo(seedRepo); + const remoteRepo = path.join(remoteRoot, "skills-remote.git"); + execFileSync("git", ["clone", "--bare", seedRepo, remoteRepo]); + + const localSkillRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-local-skill-")); + const localSkill = path.join(localSkillRoot, "sample"); + fs.mkdirSync(path.join(localSkill, "scripts"), { recursive: true }); + fs.writeFileSync( + path.join(localSkill, "SKILL.md"), + "---\nname: sample\ndescription: sample skill\ncategory: process\nversion: 1.0.0\n---\n", + "utf8", + ); + fs.writeFileSync(path.join(localSkill, "scripts", "run.sh"), "echo hi\n", "utf8"); + + await withStateHome(stateHome, async () => { + const source = await addSource({ + id: "publisher", + name: "publisher", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "direct-push", + enabled: true, + removable: true, + }); + + const direct = await publishSkillBundle( + { sourceId: source.id, bundle: { localPath: localSkill }, commitMessage: "add sample direct" }, + createCredentialProvider(), + ); + assert.equal(direct.mode, "direct-push"); + assert.equal(Boolean(direct.commitSha), true); + + await addSource({ + id: "publisher-branch", + name: "publisher-branch", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "branch-only", + enabled: true, + removable: true, + }); + + fs.writeFileSync(path.join(localSkill, "scripts", "branch-only.sh"), "echo branch\n", "utf8"); + + const branch = await publishSkillBundle( + { sourceId: "publisher-branch", bundle: { localPath: localSkill }, commitMessage: "add sample branch" }, + createCredentialProvider(), + ); + assert.equal(branch.mode, "branch-only"); + assert.equal(branch.branch.startsWith("skill/sample/"), true); + }); +}); + +test("publishSkillBundle applies per-run override mode and base branch", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-override-state-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-publish-override-remote-")); + const seedRepo = path.join(remoteRoot, "seed"); + fs.mkdirSync(path.join(seedRepo, "skills"), { recursive: true }); + fs.writeFileSync(path.join(seedRepo, "README.md"), "# test\n", "utf8"); + initGitRepo(seedRepo); + execFileSync("git", ["branch", "dev"], { cwd: seedRepo }); + const remoteRepo = path.join(remoteRoot, "skills-remote.git"); + execFileSync("git", ["clone", "--bare", seedRepo, remoteRepo]); + + const localSkillRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-local-skill-override-")); + const localSkill = path.join(localSkillRoot, "override-sample"); + fs.mkdirSync(path.join(localSkill, "scripts"), { recursive: true }); + fs.writeFileSync( + path.join(localSkill, "SKILL.md"), + "---\nname: override-sample\ndescription: sample skill\ncategory: process\nversion: 1.0.0\n---\n", + "utf8", + ); + fs.writeFileSync(path.join(localSkill, "scripts", "run.sh"), "echo hi\n", "utf8"); + + await withStateHome(stateHome, async () => { + const source = await addSource({ + id: "publisher-override", + name: "publisher-override", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "branch-pr", + defaultBaseBranch: "master", + enabled: true, + removable: true, + }); + + const result = await publishSkillBundle( + { + sourceId: source.id, + bundle: { localPath: localSkill }, + commitMessage: "publish override sample", + overrideMode: "direct-push", + overrideBaseBranch: "dev", + } as any, + createCredentialProvider(), + ); + + assert.equal(result.mode, "direct-push"); + assert.equal(result.branch, "dev"); + }); +}); + +test("contributeOfficialSkillBundle runs strict validation and branch-pr publish flow", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-official-contrib-state-")); + const remoteRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-official-contrib-remote-")); + const seedRepo = path.join(remoteRoot, "seed"); + fs.mkdirSync(path.join(seedRepo, "skills"), { recursive: true }); + fs.writeFileSync(path.join(seedRepo, "README.md"), "# seed\n", "utf8"); + initGitRepo(seedRepo); + const remoteRepo = path.join(remoteRoot, "official.git"); + execFileSync("git", ["clone", "--bare", seedRepo, remoteRepo]); + + const localSkillRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-official-skill-")); + const localSkill = path.join(localSkillRoot, "official-sample"); + fs.mkdirSync(path.join(localSkill, "assets"), { recursive: true }); + fs.writeFileSync( + path.join(localSkill, "SKILL.md"), + "---\nname: official-sample\ndescription: official sample\ncategory: process\nversion: 1.0.0\n---\n", + "utf8", + ); + fs.writeFileSync(path.join(localSkill, "assets", "note.txt"), "hello\n", "utf8"); + + await withStateHome(stateHome, async () => { + await addSource({ + id: "official-local", + name: "official-local", + repoUrl: `file://${remoteRepo}`, + transport: "https", + skillsRoot: "/skills", + publishDefaultMode: "direct-push", + defaultBaseBranch: "master", + providerHint: "unknown", + officialContributionEnabled: true, + official: true, + enabled: true, + removable: true, + }); + + const result = await contributeOfficialSkillBundle( + { + sourceId: "official-local", + bundle: { localPath: localSkill }, + commitMessage: "contribute official sample", + }, + createCredentialProvider(), + ); + assert.equal(result.mode, "branch-pr"); + assert.equal(result.branch.startsWith("skill/official-sample/"), true); + assert.equal(Boolean(result.commitSha), true); + }); +}); diff --git a/tests/installer/sources.test.ts b/tests/installer/sources.test.ts index d35bf0f..ace1588 100644 --- a/tests/installer/sources.test.ts +++ b/tests/installer/sources.test.ts @@ -26,6 +26,10 @@ function fixtureCatalog(): SkillCatalog { official: true, enabled: true, skillsRoot: "/skills", + publishDefaultMode: "branch-pr", + defaultBaseBranch: "dev", + providerHint: "github", + officialContributionEnabled: true, removable: true, }, ], @@ -119,6 +123,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, }); @@ -162,6 +170,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, };