diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a99a7b1..2d42bfe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: run: bun run compile:context - name: Regenerate skills index - run: bash scripts/generate-skills-index.sh + run: bun run skills:index - name: Run tests run: bun test diff --git a/package.json b/package.json index efb71b2..c1ffc64 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "test": "bun test", "start": "bun src/index.ts", "dev": "bun --watch src/index.ts", - "docker:run": "bun scripts/ensure-singleton.ts" + "docker:run": "bun scripts/ensure-singleton.ts", + "skills:index": "bun scripts/generate-skills-index.ts", + "mcp:start": "bun scripts/start-mcp.ts" }, "author": "Orkait", "license": "MIT", diff --git a/scripts/generate-skills-index.sh b/scripts/generate-skills-index.sh deleted file mode 100755 index ed0fdff..0000000 --- a/scripts/generate-skills-index.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# Generates skills/INDEX.md from skill frontmatter category fields. -# Run: bash scripts/generate-skills-index.sh -# Categories: core (workflow/discipline), domain (specialized), meta (skills about skills) - -set -uo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SKILLS_DIR="${REPO_ROOT}/skills" -INDEX="${SKILLS_DIR}/INDEX.md" - -# Collect skills by category -core_list="" -domain_list="" -meta_list="" -uncategorized_list="" - -for dir in "${SKILLS_DIR}"/*/; do - skill_name=$(basename "$dir") - skill_file="${dir}SKILL.md" - [ -f "$skill_file" ] || continue - - category=$(grep "^category:" "$skill_file" 2>/dev/null | head -1 | sed 's/category:[[:space:]]*//' | tr -d '\r') - - # Extract first line of description - desc_line=$(awk '/^description:/{flag=1; sub(/^description:[[:space:]]*/,""); sub(/^>-?/,""); if($0!="") print; flag=1; next} flag && /^ / {sub(/^[[:space:]]+/,""); print; exit} flag && /^[a-z]/ {exit}' "$skill_file" | head -1 | tr -d '"') - desc_short=$(printf '%s' "$desc_line" | cut -c 1-120) - - line="| \`${skill_name}\` | ${desc_short} |" - case "$category" in - core) core_list+="${line}"$'\n' ;; - domain) domain_list+="${line}"$'\n' ;; - meta) meta_list+="${line}"$'\n' ;; - *) uncategorized_list+="${line}"$'\n' ;; - esac -done - -# Write index -cat > "$INDEX" <> "$INDEX" < dirent.isDirectory()); + + for (const dir of dirs) { + const skillPath = path.join(SKILLS_DIR, dir.name, "SKILL.md"); + if (!fs.existsSync(skillPath)) continue; + + const content = fs.readFileSync(skillPath, "utf8"); + + // Simple frontmatter parser + const categoryMatch = content.match(/^category:\s*(.*)/m); + const category = categoryMatch ? categoryMatch[1].trim() : "uncategorized"; + + // Extract description (handles multi-line and single-line) + const descMatch = content.match(/^description:\s*(?:>|-)?\s*(.*)/m); + let description = descMatch ? descMatch[1].trim() : ""; + + // If it's multi-line, we might need a more robust parser, + // but for the index, the first line is usually enough. + description = description.replace(/^["'>\-\s]+/, "").substring(0, 120); + + skills.push({ name: dir.name, category, description }); + } + + const core = skills.filter(s => s.category === "core"); + const domain = skills.filter(s => s.category === "domain"); + const meta = skills.filter(s => s.category === "meta"); + const uncategorized = skills.filter(s => !["core", "domain", "meta"].includes(s.category)); + + const formatTable = (list: SkillInfo[]) => { + if (list.length === 0) return ""; + return list.map(s => `| \`${s.name}\` | ${s.description} |`).sort().join("\n") + "\n"; + }; + + const output = `# Hyperstack Skills Index + +Auto-generated from each skill's frontmatter \`category\` field. +Regenerate with: \`bun scripts/generate-skills-index.ts\` or \`npm run skills:index\` + +Categories: +- **core** - workflow, discipline, and gates used on every task +- **domain** - specialized skills for specific contexts (visual, components, security, docs) +- **meta** - skills about skills (bootstrap, testing) + +--- + +## Core (workflow + discipline) + +| Skill | Description | +|---|---| +${formatTable(core)} +## Domain (specialized context) + +| Skill | Description | +|---|---| +${formatTable(domain)} +## Meta (skills about skills) + +| Skill | Description | +|---|---| +${formatTable(meta)} +${uncategorized.length > 0 ? ` +## Uncategorized (missing \`category:\` field) + +| Skill | Description | +|---|---| +${formatTable(uncategorized)} + +These skills need a \`category:\` added to their frontmatter. +` : ""} +`; + + fs.writeFileSync(INDEX_FILE, output); + console.log(`Wrote ${INDEX_FILE}`); +} + +generateIndex(); diff --git a/scripts/start-mcp.sh b/scripts/start-mcp.sh deleted file mode 100755 index 0aab4c2..0000000 --- a/scripts/start-mcp.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -CONTAINER="hyperstack-daemon" -IMAGE="hyperstack" -LOCK_FILE="/tmp/${CONTAINER}.lock" - -# Use a file lock so concurrent session startups don't race to create the container. -# Only one process runs the startup block at a time; the rest wait, then see the -# container already running and skip straight to docker exec. -( - flock -x 200 - - if ! docker ps -q --filter "name=^${CONTAINER}$" 2>/dev/null | grep -q .; then - # Remove any stopped container with the same name - docker rm -f "$CONTAINER" 2>/dev/null || true - - # Start a long-running daemon container (tail keeps it alive). - # Each MCP session gets its own node process via docker exec below. - docker run -d \ - --name "$CONTAINER" \ - --restart unless-stopped \ - --entrypoint tail \ - "$IMAGE" -f /dev/null - - sleep 0.3 - fi - -) 200>"$LOCK_FILE" - -exec docker exec -i "$CONTAINER" npx tsx src/index.ts diff --git a/scripts/start-mcp.ts b/scripts/start-mcp.ts new file mode 100644 index 0000000..ffb7de1 --- /dev/null +++ b/scripts/start-mcp.ts @@ -0,0 +1,74 @@ +import { execSync, spawn } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const IMAGE = "ghcr.io/orkait/hyperstack:main"; +const CONTAINER_NAME = "hyperstack-mcp"; +const LOCK_FILE = path.join(os.tmpdir(), "hyperstack-mcp-startup.lock"); + +/** + * Standardized Cross-Platform MCP Startup Logic + * - Ensures the persistent container is running. + * - Bridges the local I/O to the containerized MCP process via 'docker exec'. + */ +async function startMcp() { + let lockFd: number | null = null; + + try { + // 1. Concurrency control via file lock + try { + lockFd = fs.openSync(LOCK_FILE, 'wx'); + + // Check if the container is already running + const running = execSync(`docker ps -q --filter "name=^${CONTAINER_NAME}$"`, { stdio: "pipe" }).toString().trim(); + + if (!running) { + // If not running, ensure it's removed and start fresh + // Suppress errors if container doesn't exist + try { execSync(`docker rm -f ${CONTAINER_NAME}`, { stdio: "ignore" }); } catch {} + + execSync( + `docker run -d --name ${CONTAINER_NAME} --restart unless-stopped \ + --memory=512m --cpus=1 \ + --entrypoint sleep \ + ${IMAGE} infinity`, + { stdio: "inherit" } + ); + + // Brief pause to allow Docker to initialize the process + await new Promise(r => setTimeout(r, 300)); + } + } catch (e: any) { + if (e.code === 'EEXIST') { + // Another process is handling startup, wait briefly + await new Promise(r => setTimeout(r, 500)); + } else { + throw e; + } + } finally { + if (lockFd !== null) { + fs.closeSync(lockFd); + try { fs.unlinkSync(LOCK_FILE); } catch {} + } + } + + // 2. Connect to the MCP server inside the container + // This allows the local agent/IDE to communicate with the containerized Bun process. + const proc = spawn("docker", ["exec", "-i", CONTAINER_NAME, "bun", "/app/src/index.ts"], { + stdio: "inherit" + }); + + proc.on("exit", (code) => process.exit(code || 0)); + proc.on("error", (err) => { + console.error("Failed to bridge to containerized MCP:", err.message); + process.exit(1); + }); + + } catch (error: any) { + console.error("Critical error starting Hyperstack MCP:", error.message); + process.exit(1); + } +} + +startMcp();