diff --git a/.claude/settings.json b/.claude/settings.json index ca899e1..74312a7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -29,22 +29,23 @@ { "type": "command", "command": "bunx @pleaseai/code format --stdin", - "timeout": 30 + "timeout": 10 }, { "type": "command", "command": "bunx @pleaseai/code lsp --stdin", - "timeout": 60 + "timeout": 5 } ] } ] }, "enabledPlugins": { - "hookify@claude-plugins-official": false, + "hookify@claude-plugins-official": true, "github@claude-plugins-official": true, "asana@claude-plugins-official": true, "typescript-lsp@claude-plugins-official": true, - "context7@claude-plugins-official": true + "context7@claude-plugins-official": true, + "plugin-dev@claude-plugins-official": true } } diff --git a/.please/config.json b/.please/config.json index a758497..06d5b14 100644 --- a/.please/config.json +++ b/.please/config.json @@ -72,7 +72,7 @@ "code_workspace": { "enabled": true }, - "lsp": {}, + "lsp": false, "ignore_patterns": [], "language": "en", "integrations": { diff --git a/.please/memory/progress.txt b/.please/memory/progress.txt new file mode 100644 index 0000000..bd785be --- /dev/null +++ b/.please/memory/progress.txt @@ -0,0 +1,8 @@ +# Please:Dev Progress +Feature: Create documentation site at apps/docs +Session: 20251221-docs-site +Phase: 1/8 (Discovery) +Tasks: 0/0 completed +Last Task: None +Last Checkpoint: None +Updated: 2025-12-21T10:00:00Z diff --git a/apps/docs/package.json b/apps/docs/package.json index 3879d63..ab07726 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,9 +11,9 @@ }, "dependencies": { "better-sqlite3": "^12.2.0", - "docs-please": "^0.2.5", + "docs-please": "^0.2.6", "nuxt": "^4.2.1", - "zod": "^4.2.1", + "zod": "^4.0.0", "zod-to-json-schema": "^3.25.0" }, "devDependencies": { diff --git a/bun.lock b/bun.lock index 8f84f57..76b834e 100644 --- a/bun.lock +++ b/bun.lock @@ -24,9 +24,9 @@ "name": "@pleaseai/code-please-docs-site", "dependencies": { "better-sqlite3": "^12.2.0", - "docs-please": "^0.2.5", + "docs-please": "^0.2.6", "nuxt": "^4.2.1", - "zod": "^4.2.1", + "zod": "^4.0.0", "zod-to-json-schema": "^3.25.0", }, "devDependencies": { @@ -34,13 +34,35 @@ "typescript": "^5.9.3", }, }, + "hooks/scripts": { + "name": "@pleaseai/hooks-scripts", + "version": "0.1.0", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0", + }, + }, + "packages/binaries": { + "name": "@pleaseai/binaries", + "version": "0.1.0", + "dependencies": { + "@pleaseai/logger": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0", + }, + }, "packages/code": { "name": "@pleaseai/code", "version": "0.1.7", "dependencies": { + "@pleaseai/binaries": "workspace:*", "@pleaseai/code-format": "workspace:*", "@pleaseai/code-lsp": "workspace:*", "@pleaseai/logger": "workspace:*", + "chalk": "^5.6.2", + "commander": "^14.0.2", }, "devDependencies": { "@types/bun": "latest", @@ -55,9 +77,10 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "@pleaseai/binaries": "workspace:*", "@pleaseai/code-lsp": "workspace:*", "@pleaseai/logger": "workspace:*", - "zod": "^3.25.0", + "zod": "^3.25.0 || ^4.0.0", }, "devDependencies": { "@types/bun": "latest", @@ -98,11 +121,12 @@ "name": "@pleaseai/code-lsp", "version": "0.1.6", "dependencies": { + "@pleaseai/binaries": "workspace:*", "@pleaseai/code-format": "workspace:*", "@pleaseai/logger": "workspace:*", "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-types": "^3.17.5", - "zod": "^3.25.32", + "zod": "^3.25.0 || ^4.0.0", }, "devDependencies": { "@types/bun": "latest", @@ -563,6 +587,8 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@pleaseai/binaries": ["@pleaseai/binaries@workspace:packages/binaries"], + "@pleaseai/code": ["@pleaseai/code@workspace:packages/code"], "@pleaseai/code-format": ["@pleaseai/code-format@workspace:packages/format"], @@ -573,6 +599,8 @@ "@pleaseai/dora": ["@pleaseai/dora@workspace:packages/dora"], + "@pleaseai/hooks-scripts": ["@pleaseai/hooks-scripts@workspace:hooks/scripts"], + "@pleaseai/logger": ["@pleaseai/logger@workspace:packages/logger"], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], @@ -1025,7 +1053,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], @@ -1199,7 +1227,7 @@ "diff-sequences": ["diff-sequences@27.5.1", "", {}, "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ=="], - "docs-please": ["docs-please@0.2.5", "", { "dependencies": { "@iconify-json/lucide": "^1.2.81", "@nuxt/content": "^3.9.0", "@nuxt/eslint": "^1.11.0", "@nuxt/icon": "^2.1.0", "@nuxt/image": "^2.0.0", "@nuxt/kit": "^4.2.2", "@nuxtjs/color-mode": "^4.0.0", "@nuxtjs/mdc": "^0.19.1", "@nuxtjs/robots": "^5.6.7", "@tabler/icons-vue": "^3.35.0", "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "debug": "^4.4.1", "defu": "^6.1.4", "eslint": "^9.39.1", "exsolve": "^1.0.8", "fuse.js": "^7.1.0", "lucide-vue-next": "^0.555.0", "nuxt-llms": "^0.1.3", "nuxt-og-image": "^5.1.13", "oxc-parser": "^0.99.0", "parse5": "^7.3.0", "rehype-raw": "^7.0.0", "reka-ui": "^2.6.1", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", "remark-mdc": "^3.5.1", "remark-rehype": "^11.1.2", "shadcn-nuxt": "2.4.3", "slugify": "^1.6.6", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "ufo": "^1.6.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "better-sqlite3": "12.x", "nuxt": "4.x" } }, "sha512-7Ba5CCuru2q6R/zyWpY8gtXMb3OxzVgmDsFENUbWkh6hq88UAADZbGIPFNhItcXd4ODmJFBlRRPQu/CgNZ3l4A=="], + "docs-please": ["docs-please@0.2.6", "", { "dependencies": { "@iconify-json/lucide": "^1.2.81", "@nuxt/content": "^3.9.0", "@nuxt/eslint": "^1.11.0", "@nuxt/icon": "^2.1.0", "@nuxt/image": "^2.0.0", "@nuxt/kit": "^4.2.2", "@nuxtjs/color-mode": "^4.0.0", "@nuxtjs/mdc": "^0.19.1", "@nuxtjs/robots": "^5.6.7", "@tabler/icons-vue": "^3.35.0", "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "debug": "^4.4.1", "defu": "^6.1.4", "eslint": "^9.39.1", "exsolve": "^1.0.8", "fuse.js": "^7.1.0", "lucide-vue-next": "^0.555.0", "nuxt-llms": "^0.1.3", "nuxt-og-image": "^5.1.13", "oxc-parser": "^0.99.0", "parse5": "^7.3.0", "rehype-raw": "^7.0.0", "reka-ui": "^2.6.1", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", "remark-mdc": "^3.5.1", "remark-rehype": "^11.1.2", "shadcn-nuxt": "2.4.3", "slugify": "^1.6.6", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "ufo": "^1.6.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "better-sqlite3": "12.x", "nuxt": "4.x" } }, "sha512-/IR4jTtYqPToAGtRFqm0IExvPRFhtFP2Y0EqAWok5WzCFBfQrYabiSVTOYy1zo8NG3lzEh9ufBekjaf0UNs3tA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -2635,7 +2663,7 @@ "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], @@ -2671,12 +2699,12 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], - "@nuxt/cli/@clack/prompts": ["@clack/prompts@1.0.0-alpha.8", "", { "dependencies": { "@clack/core": "1.0.0-alpha.7", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-YZGC4BmTKSF5OturNKEz/y4xNjYGmGk6NI785CQucJ7OEdX0qbMmL/zok+9bL6c7qE3WSYffyK5grh2RnkGNtQ=="], "@nuxt/content/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "@nuxt/content/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@nuxt/devtools/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "@nuxt/devtools/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], @@ -2703,8 +2731,6 @@ "@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="], - "@pleaseai/code-please-docs-site/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], - "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -2787,14 +2813,14 @@ "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], - "docs-please/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "eslint-plugin-es-x/eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "^7.5.4" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], "eslint-plugin-import-x/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], diff --git a/hooks/hooks.json b/hooks/hooks.json index 778dba7..aa51892 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,18 +1,29 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bun $CLAUDE_PLUGIN_ROOT/hooks/scripts/ast-grep-context.ts", + "timeout": 5 + } + ] + } + ], "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", - "command": "npx @pleaseai/code format --stdin", - "timeout": 30 + "command": "bunx @pleaseai/code format --stdin", + "timeout": 10 }, { "type": "command", - "command": "npx @pleaseai/code lsp --stdin", - "timeout": 30 + "command": "bunx @pleaseai/code lsp --stdin", + "timeout": 10 } ] } diff --git a/hooks/scripts/ast-grep-context.ts b/hooks/scripts/ast-grep-context.ts new file mode 100644 index 0000000..4359dc3 --- /dev/null +++ b/hooks/scripts/ast-grep-context.ts @@ -0,0 +1,55 @@ +/** + * SessionStart hook script that provides ast-grep context + * Outputs JSON with additionalContext for Claude Code sessions + */ + +import { $ } from 'bun' + +export const INSTALLED_CONTEXT = `When searching or modifying code, use ast-grep instead of grep, ripgrep, ag, sed, or regex-only tools. ast-grep matches against the AST (Abstract Syntax Tree), enabling safe, language-aware queries and rewrites. + +Always prefer ast-grep for code analysis, queries, or refactoring tasks. + +For detailed patterns and examples, use the ast-grep skill.` + +export const NOT_INSTALLED_CONTEXT = `Consider installing ast-grep for better code search (AST-based, language-aware): + + npx @pleaseai/code setup ast-grep # Recommended (local cache) + brew install ast-grep # macOS + cargo install ast-grep # Rust + +After installation, use ast-grep instead of grep/ripgrep/sed for code operations.` + +export async function checkAstGrepInstalled(): Promise { + return await $`which ast-grep` + .quiet() + .then(() => true) + .catch(() => false) +} + +export function getAdditionalContext(isInstalled: boolean): string { + return isInstalled ? INSTALLED_CONTEXT : NOT_INSTALLED_CONTEXT +} + +export interface HookOutput { + hookSpecificOutput: { + hookEventName: string + additionalContext: string + } +} + +export function createHookOutput(additionalContext: string): HookOutput { + return { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext, + }, + } +} + +// Only run when executed directly +if (import.meta.main) { + const isInstalled = await checkAstGrepInstalled() + const additionalContext = getAdditionalContext(isInstalled) + const output = createHookOutput(additionalContext) + console.log(JSON.stringify(output)) +} diff --git a/hooks/scripts/package.json b/hooks/scripts/package.json new file mode 100644 index 0000000..5aadc21 --- /dev/null +++ b/hooks/scripts/package.json @@ -0,0 +1,17 @@ +{ + "name": "@pleaseai/hooks-scripts", + "type": "module", + "version": "0.1.0", + "private": true, + "description": "Claude Code hook scripts", + "author": "Minsu Lee", + "license": "MIT", + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "bun test ./test" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + } +} diff --git a/hooks/scripts/test/ast-grep-context.test.ts b/hooks/scripts/test/ast-grep-context.test.ts new file mode 100644 index 0000000..9546e3e --- /dev/null +++ b/hooks/scripts/test/ast-grep-context.test.ts @@ -0,0 +1,132 @@ +import { $ } from 'bun' +import { describe, expect, it } from 'bun:test' +import { + checkAstGrepInstalled, + createHookOutput, + getAdditionalContext, + INSTALLED_CONTEXT, + NOT_INSTALLED_CONTEXT, +} from '../ast-grep-context' + +describe('ast-grep-context hook', () => { + describe('INSTALLED_CONTEXT', () => { + it('mentions ast-grep as preferred tool', () => { + expect(INSTALLED_CONTEXT).toContain('ast-grep') + }) + + it('mentions AST-based matching', () => { + expect(INSTALLED_CONTEXT).toContain('AST') + expect(INSTALLED_CONTEXT).toContain('Abstract Syntax Tree') + }) + + it('discourages grep/ripgrep/sed usage', () => { + expect(INSTALLED_CONTEXT).toContain('grep') + expect(INSTALLED_CONTEXT).toContain('ripgrep') + expect(INSTALLED_CONTEXT).toContain('sed') + }) + + it('references the ast-grep skill', () => { + expect(INSTALLED_CONTEXT).toContain('ast-grep skill') + }) + }) + + describe('NOT_INSTALLED_CONTEXT', () => { + it('suggests installation methods', () => { + expect(NOT_INSTALLED_CONTEXT).toContain('npx @pleaseai/code setup ast-grep') + expect(NOT_INSTALLED_CONTEXT).toContain('brew install ast-grep') + expect(NOT_INSTALLED_CONTEXT).toContain('cargo install ast-grep') + }) + + it('describes ast-grep as AST-based', () => { + expect(NOT_INSTALLED_CONTEXT).toContain('AST-based') + }) + + it('mentions language-aware capabilities', () => { + expect(NOT_INSTALLED_CONTEXT).toContain('language-aware') + }) + }) + + describe('checkAstGrepInstalled', () => { + it('returns a boolean', async () => { + const result = await checkAstGrepInstalled() + expect(typeof result).toBe('boolean') + }) + + it('correctly detects ast-grep installation status', async () => { + const whichResult = await $`which ast-grep` + .quiet() + .then(() => true) + .catch(() => false) + const result = await checkAstGrepInstalled() + expect(result).toBe(whichResult) + }) + }) + + describe('getAdditionalContext', () => { + it('returns INSTALLED_CONTEXT when installed is true', () => { + const result = getAdditionalContext(true) + expect(result).toBe(INSTALLED_CONTEXT) + }) + + it('returns NOT_INSTALLED_CONTEXT when installed is false', () => { + const result = getAdditionalContext(false) + expect(result).toBe(NOT_INSTALLED_CONTEXT) + }) + }) + + describe('createHookOutput', () => { + it('creates valid hook output structure', () => { + const context = 'test context' + const output = createHookOutput(context) + + expect(output).toHaveProperty('hookSpecificOutput') + expect(output.hookSpecificOutput).toHaveProperty('hookEventName') + expect(output.hookSpecificOutput).toHaveProperty('additionalContext') + }) + + it('sets hookEventName to SessionStart', () => { + const output = createHookOutput('test') + expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart') + }) + + it('includes provided additionalContext', () => { + const context = 'my custom context' + const output = createHookOutput(context) + expect(output.hookSpecificOutput.additionalContext).toBe(context) + }) + + it('produces valid JSON', () => { + const output = createHookOutput(INSTALLED_CONTEXT) + const json = JSON.stringify(output) + const parsed = JSON.parse(json) + expect(parsed).toEqual(output) + }) + }) + + describe('script execution', () => { + it('outputs valid JSON when run directly', async () => { + const result = await $`bun ${import.meta.dir}/../ast-grep-context.ts`.quiet() + const output = result.stdout.toString().trim() + + expect(() => JSON.parse(output)).not.toThrow() + + const parsed = JSON.parse(output) + expect(parsed).toHaveProperty('hookSpecificOutput') + expect(parsed.hookSpecificOutput).toHaveProperty('hookEventName', 'SessionStart') + expect(parsed.hookSpecificOutput).toHaveProperty('additionalContext') + }) + + it('outputs appropriate context based on installation', async () => { + const isInstalled = await checkAstGrepInstalled() + const result = await $`bun ${import.meta.dir}/../ast-grep-context.ts`.quiet() + const parsed = JSON.parse(result.stdout.toString().trim()) + + if (isInstalled) { + expect(parsed.hookSpecificOutput.additionalContext).toBe(INSTALLED_CONTEXT) + } + else { + expect(parsed.hookSpecificOutput.additionalContext).toBe(NOT_INSTALLED_CONTEXT) + } + }) + }) +}) diff --git a/hooks/scripts/tsconfig.json b/hooks/scripts/tsconfig.json new file mode 100644 index 0000000..5199237 --- /dev/null +++ b/hooks/scripts/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["*.ts", "test/**/*"] +} diff --git a/package.json b/package.json index e68c3dc..1c3e6f8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "description": "Auto-format and type-check hooks for AI coding", "workspaces": [ "packages/*", - "apps/*" + "apps/*", + "hooks/scripts" ], "engines": { "bun": ">=1.0.0" diff --git a/packages/binaries/package.json b/packages/binaries/package.json new file mode 100644 index 0000000..dd3be4b --- /dev/null +++ b/packages/binaries/package.json @@ -0,0 +1,37 @@ +{ + "name": "@pleaseai/binaries", + "type": "module", + "version": "0.1.0", + "description": "Binary download, caching, and platform utilities for AI coding tools", + "author": "Minsu Lee", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/chatbot-pf/code-please" + }, + "keywords": [ + "binary", + "download", + "cache", + "platform", + "ai-coding" + ], + "exports": { + ".": "./src/index.ts" + }, + "main": "src/index.ts", + "files": [ + "src" + ], + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "bun test ./test" + }, + "dependencies": { + "@pleaseai/logger": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + } +} diff --git a/packages/binaries/src/cache.ts b/packages/binaries/src/cache.ts new file mode 100644 index 0000000..d07a385 --- /dev/null +++ b/packages/binaries/src/cache.ts @@ -0,0 +1,83 @@ +/** + * Cache management utilities + */ + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +/** Default cache directory */ +export const DEFAULT_CACHE_DIR = path.join(os.homedir(), '.cache', 'pleaseai') + +/** + * Get the cache directory for a specific tool + */ +export function getCacheDir(toolId: string, baseCacheDir?: string): string { + const base = baseCacheDir ?? DEFAULT_CACHE_DIR + return path.join(base, toolId) +} + +/** + * Ensure cache directory exists and return the path + */ +export function ensureCacheDir(toolId: string, baseCacheDir?: string): string { + const cacheDir = getCacheDir(toolId, baseCacheDir) + fs.mkdirSync(cacheDir, { recursive: true }) + return cacheDir +} + +/** + * Get cached binary path for a tool + */ +export function getCachedBinaryPath( + toolId: string, + binaryName: string, + baseCacheDir?: string, +): string { + return path.join(getCacheDir(toolId, baseCacheDir), binaryName) +} + +/** + * Check if a file exists and is a valid binary (size > 10KB) + * This helps detect stub files or corrupted downloads + */ +export function isValidBinary(filePath: string, minSize = 10000): boolean { + try { + const stat = fs.statSync(filePath) + return stat.isFile() && stat.size > minSize + } + catch { + return false + } +} + +/** + * Make a file executable (chmod +x) + * No-op on Windows + */ +export function makeExecutable(filePath: string): void { + if (process.platform !== 'win32') { + fs.chmodSync(filePath, 0o755) + } +} + +/** + * Check if a cached binary exists and is valid + */ +export function hasCachedBinary( + toolId: string, + binaryName: string, + baseCacheDir?: string, +): boolean { + const binaryPath = getCachedBinaryPath(toolId, binaryName, baseCacheDir) + return isValidBinary(binaryPath) +} + +/** + * Remove cached files for a tool + */ +export function clearCache(toolId: string, baseCacheDir?: string): void { + const cacheDir = getCacheDir(toolId, baseCacheDir) + fs.rmSync(cacheDir, { recursive: true, force: true }) +} diff --git a/packages/binaries/src/download.ts b/packages/binaries/src/download.ts new file mode 100644 index 0000000..5ef2785 --- /dev/null +++ b/packages/binaries/src/download.ts @@ -0,0 +1,130 @@ +/** + * Download and extraction utilities + */ + +import { execSync } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { pipeline } from 'node:stream/promises' +import { createLogger } from '@pleaseai/logger' + +const log = createLogger('binaries') + +/** + * Download a file from URL to destination + */ +export async function downloadFile(url: string, dest: string): Promise { + const response = await fetch(url, { redirect: 'follow' }) + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + } + + const body = response.body + if (!body) { + throw new Error(`No response body for ${url}`) + } + + await fs.mkdir(path.dirname(dest), { recursive: true }) + const writeStream = createWriteStream(dest) + await pipeline(body as unknown as NodeJS.ReadableStream, writeStream) +} + +/** + * Extract a tar.gz archive + */ +export async function extractTarGz(archivePath: string, destDir: string): Promise { + await fs.mkdir(destDir, { recursive: true }) + execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: 'ignore' }) +} + +/** + * Extract a zip archive (cross-platform: uses PowerShell on Windows, unzip on Unix) + */ +export async function extractZip(archivePath: string, destDir: string): Promise { + await fs.mkdir(destDir, { recursive: true }) + + const isWindows = os.platform() === 'win32' + + let proc: ReturnType + if (isWindows) { + proc = Bun.spawn( + ['powershell', '-NoProfile', '-Command', `Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'`], + { stdout: 'pipe', stderr: 'pipe' }, + ) + } + else { + proc = Bun.spawn(['unzip', '-o', '-q', archivePath, '-d', destDir], { + stdout: 'pipe', + stderr: 'pipe', + }) + } + + const exitCode = await proc.exited + + if (exitCode !== 0) { + const stderrStream = proc.stderr + const stderr = stderrStream && typeof stderrStream !== 'number' + ? await new Response(stderrStream).text() + : 'Unknown error' + throw new Error(`Failed to extract ${archivePath}: ${stderr}`) + } +} + +/** + * Download and extract an archive to destination directory + * Automatically detects archive type from URL + */ +export async function downloadAndExtract( + url: string, + destDir: string, + options?: { + /** Override archive type detection */ + archiveType?: 'zip' | 'tar.gz' + /** Cleanup temp files after extraction (default: true) */ + cleanup?: boolean + }, +): Promise { + const cleanup = options?.cleanup ?? true + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'binaries-download-')) + + // Determine archive type + let archiveType = options?.archiveType + if (!archiveType) { + if (url.endsWith('.tar.gz') || url.endsWith('.tgz')) { + archiveType = 'tar.gz' + } + else if (url.endsWith('.zip')) { + archiveType = 'zip' + } + else { + throw new Error(`Cannot determine archive type for URL: ${url}`) + } + } + + const archiveExt = archiveType === 'tar.gz' ? '.tar.gz' : '.zip' + const tempFile = path.join(tempDir, `archive${archiveExt}`) + + try { + log.info({ url }, 'Downloading') + await downloadFile(url, tempFile) + + log.info({ destDir }, 'Extracting') + if (archiveType === 'tar.gz') { + await extractTarGz(tempFile, destDir) + } + else { + await extractZip(tempFile, destDir) + } + + log.info('Download complete') + } + finally { + if (cleanup) { + await fs.rm(tempDir, { recursive: true, force: true }).catch((err) => { + log.warn({ tempDir, err }, 'Failed to cleanup temp directory') + }) + } + } +} diff --git a/packages/binaries/src/index.ts b/packages/binaries/src/index.ts new file mode 100644 index 0000000..4e91d0b --- /dev/null +++ b/packages/binaries/src/index.ts @@ -0,0 +1,35 @@ +/** + * @pleaseai/binaries + * + * Binary download, caching, and platform utilities for AI coding tools + */ + +// Cache utilities +export { + clearCache, + DEFAULT_CACHE_DIR, + ensureCacheDir, + getCachedBinaryPath, + getCacheDir, + hasCachedBinary, + isValidBinary, + makeExecutable, +} from './cache' +// Download utilities +export { + downloadAndExtract, + downloadFile, + extractTarGz, + extractZip, +} from './download' + +// Platform utilities +export { + commandExists, + detectMusl, + getCommandOutput, + getPlatformId, + getPlatformInfo, +} from './platform' + +export type { PlatformId, PlatformInfo } from './platform' diff --git a/packages/binaries/src/platform.ts b/packages/binaries/src/platform.ts new file mode 100644 index 0000000..a052f30 --- /dev/null +++ b/packages/binaries/src/platform.ts @@ -0,0 +1,130 @@ +/** + * Platform detection utilities + */ + +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import process from 'node:process' + +export interface PlatformInfo { + platform: NodeJS.Platform + arch: NodeJS.Architecture + isMusl: boolean +} + +export type PlatformId + = | 'win-x64' + | 'win-arm64' + | 'osx-x64' + | 'osx-arm64' + | 'linux-x64' + | 'linux-arm64' + +/** + * Get platform information + */ +export function getPlatformInfo(): PlatformInfo { + return { + platform: process.platform, + arch: process.arch as NodeJS.Architecture, + isMusl: detectMusl(), + } +} + +/** + * Get platform identifier string (e.g., "osx-arm64", "linux-x64") + */ +export function getPlatformId(): PlatformId | undefined { + const platform = process.platform + const arch = process.arch + + const platformMap: Record = { + win32: 'win', + darwin: 'osx', + linux: 'linux', + } + + const archMap: Record = { + x64: 'x64', + arm64: 'arm64', + } + + const platformStr = platformMap[platform] + const archStr = archMap[arch] + + if (!platformStr || !archStr) { + return undefined + } + + return `${platformStr}-${archStr}` as PlatformId +} + +/** + * Detect if running on musl libc (Alpine Linux, etc.) + */ +export function detectMusl(): boolean { + if (process.platform !== 'linux') { + return false + } + + // Method 1: Check /usr/bin/ldd for musl + try { + const lddContent = fs.readFileSync('/usr/bin/ldd', 'utf-8') + if (lddContent.includes('musl')) { + return true + } + } + catch { + // Expected: file not found on some systems + } + + // Method 2: Check process.report for glibc version (Node 12+) + try { + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined + if (report?.header?.glibcVersionRuntime) { + return false // Has glibc, not musl + } + } + catch { + // Expected: process.report not available in all environments + } + + // Method 3: Run ldd --version and check output + try { + const output = execSync('ldd --version 2>&1', { encoding: 'utf-8' }) + if (output.includes('musl')) { + return true + } + } + catch { + // Expected: ldd not available on non-Linux systems + } + + return false +} + +/** + * Check if a command exists in PATH + */ +export function commandExists(command: string): boolean { + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which' + execSync(`${whichCmd} ${command}`, { stdio: 'ignore' }) + return true + } + catch { + return false + } +} + +/** + * Get command output or null if failed + */ +export function getCommandOutput(command: string): string | null { + try { + return execSync(command, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim() + } + catch { + return null + } +} diff --git a/packages/binaries/test/cache.test.ts b/packages/binaries/test/cache.test.ts new file mode 100644 index 0000000..9e004e3 --- /dev/null +++ b/packages/binaries/test/cache.test.ts @@ -0,0 +1,148 @@ +import { Buffer } from 'node:buffer' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + clearCache, + DEFAULT_CACHE_DIR, + ensureCacheDir, + getCachedBinaryPath, + getCacheDir, + hasCachedBinary, + isValidBinary, + makeExecutable, +} from '../src/cache' + +describe('cache utilities', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'binaries-test-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + describe('DEFAULT_CACHE_DIR', () => { + it('is in home directory', () => { + expect(DEFAULT_CACHE_DIR).toContain(os.homedir()) + expect(DEFAULT_CACHE_DIR).toContain('.cache') + expect(DEFAULT_CACHE_DIR).toContain('pleaseai') + }) + }) + + describe('getCacheDir', () => { + it('returns path with tool id', () => { + const result = getCacheDir('my-tool', tempDir) + expect(result).toBe(path.join(tempDir, 'my-tool')) + }) + + it('uses DEFAULT_CACHE_DIR when baseCacheDir not provided', () => { + const result = getCacheDir('my-tool') + expect(result).toBe(path.join(DEFAULT_CACHE_DIR, 'my-tool')) + }) + }) + + describe('ensureCacheDir', () => { + it('creates directory if not exists', () => { + const result = ensureCacheDir('new-tool', tempDir) + expect(result).toBe(path.join(tempDir, 'new-tool')) + expect(fs.existsSync(result)).toBe(true) + }) + + it('returns existing directory', () => { + const toolDir = path.join(tempDir, 'existing-tool') + fs.mkdirSync(toolDir) + const result = ensureCacheDir('existing-tool', tempDir) + expect(result).toBe(toolDir) + expect(fs.existsSync(result)).toBe(true) + }) + }) + + describe('getCachedBinaryPath', () => { + it('returns path with tool id and binary name', () => { + const result = getCachedBinaryPath('my-tool', 'binary', tempDir) + expect(result).toBe(path.join(tempDir, 'my-tool', 'binary')) + }) + }) + + describe('isValidBinary', () => { + it('returns false for non-existent file', () => { + expect(isValidBinary(path.join(tempDir, 'nonexistent'))).toBe(false) + }) + + it('returns false for file smaller than minSize', () => { + const filePath = path.join(tempDir, 'small-file') + fs.writeFileSync(filePath, 'small content') + expect(isValidBinary(filePath)).toBe(false) + }) + + it('returns true for file larger than minSize', () => { + const filePath = path.join(tempDir, 'large-file') + fs.writeFileSync(filePath, Buffer.alloc(15000)) + expect(isValidBinary(filePath)).toBe(true) + }) + + it('uses custom minSize', () => { + const filePath = path.join(tempDir, 'medium-file') + fs.writeFileSync(filePath, Buffer.alloc(500)) + expect(isValidBinary(filePath, 100)).toBe(true) + expect(isValidBinary(filePath, 1000)).toBe(false) + }) + + it('returns false for directory', () => { + const dirPath = path.join(tempDir, 'subdir') + fs.mkdirSync(dirPath) + expect(isValidBinary(dirPath)).toBe(false) + }) + }) + + describe('makeExecutable', () => { + it('makes file executable on non-Windows', () => { + if (process.platform === 'win32') { + return + } + const filePath = path.join(tempDir, 'executable') + fs.writeFileSync(filePath, '#!/bin/bash\necho hello') + makeExecutable(filePath) + const stat = fs.statSync(filePath) + expect(stat.mode & 0o755).toBe(0o755) + }) + }) + + describe('hasCachedBinary', () => { + it('returns false when binary does not exist', () => { + expect(hasCachedBinary('my-tool', 'binary', tempDir)).toBe(false) + }) + + it('returns false when binary is too small', () => { + const toolDir = path.join(tempDir, 'my-tool') + fs.mkdirSync(toolDir) + fs.writeFileSync(path.join(toolDir, 'binary'), 'small') + expect(hasCachedBinary('my-tool', 'binary', tempDir)).toBe(false) + }) + + it('returns true when valid binary exists', () => { + const toolDir = path.join(tempDir, 'my-tool') + fs.mkdirSync(toolDir) + fs.writeFileSync(path.join(toolDir, 'binary'), Buffer.alloc(15000)) + expect(hasCachedBinary('my-tool', 'binary', tempDir)).toBe(true) + }) + }) + + describe('clearCache', () => { + it('removes cache directory', () => { + const toolDir = path.join(tempDir, 'my-tool') + fs.mkdirSync(toolDir) + fs.writeFileSync(path.join(toolDir, 'file'), 'content') + clearCache('my-tool', tempDir) + expect(fs.existsSync(toolDir)).toBe(false) + }) + + it('does not throw when directory does not exist', () => { + expect(() => clearCache('nonexistent', tempDir)).not.toThrow() + }) + }) +}) diff --git a/packages/binaries/test/download.test.ts b/packages/binaries/test/download.test.ts new file mode 100644 index 0000000..ef11ea3 --- /dev/null +++ b/packages/binaries/test/download.test.ts @@ -0,0 +1,91 @@ +import { Buffer } from 'node:buffer' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { downloadAndExtract, extractTarGz, extractZip } from '../src/download' + +describe('download utilities', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'binaries-download-test-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + describe('extractTarGz', () => { + it('creates destination directory if not exists', async () => { + const destDir = path.join(tempDir, 'new-dir') + const archivePath = path.join(tempDir, 'test.tar.gz') + + // Create minimal tar.gz (empty tar, gzipped) + const emptyTarHeader = Buffer.alloc(512, 0) + const { gzipSync } = await import('node:zlib') + fs.writeFileSync(archivePath, gzipSync(emptyTarHeader)) + + await extractTarGz(archivePath, destDir) + expect(fs.existsSync(destDir)).toBe(true) + }) + + it('throws for invalid archive', async () => { + const destDir = path.join(tempDir, 'dest') + const archivePath = path.join(tempDir, 'invalid.tar.gz') + fs.writeFileSync(archivePath, 'not a valid archive') + + await expect(extractTarGz(archivePath, destDir)).rejects.toThrow() + }) + }) + + describe('extractZip', () => { + it('throws for invalid archive', async () => { + const destDir = path.join(tempDir, 'dest') + const archivePath = path.join(tempDir, 'invalid.zip') + fs.writeFileSync(archivePath, 'not a valid zip') + + await expect(extractZip(archivePath, destDir)).rejects.toThrow() + }) + }) + + describe('downloadAndExtract', () => { + it('throws for unknown archive type', async () => { + const destDir = path.join(tempDir, 'dest') + await expect( + downloadAndExtract('https://example.com/file.unknown', destDir), + ).rejects.toThrow('Cannot determine archive type') + }) + + it('detects tar.gz from URL', async () => { + const destDir = path.join(tempDir, 'dest') + // This will fail on network, but we're testing the archive type detection + await expect( + downloadAndExtract('https://invalid-domain-12345.test/file.tar.gz', destDir), + ).rejects.toThrow() + }) + + it('detects tgz from URL', async () => { + const destDir = path.join(tempDir, 'dest') + await expect( + downloadAndExtract('https://invalid-domain-12345.test/file.tgz', destDir), + ).rejects.toThrow() + }) + + it('detects zip from URL', async () => { + const destDir = path.join(tempDir, 'dest') + await expect( + downloadAndExtract('https://invalid-domain-12345.test/file.zip', destDir), + ).rejects.toThrow() + }) + + it('uses archiveType override', async () => { + const destDir = path.join(tempDir, 'dest') + await expect( + downloadAndExtract('https://invalid-domain-12345.test/file', destDir, { + archiveType: 'zip', + }), + ).rejects.toThrow() + }) + }) +}) diff --git a/packages/binaries/test/platform.test.ts b/packages/binaries/test/platform.test.ts new file mode 100644 index 0000000..806c6ef --- /dev/null +++ b/packages/binaries/test/platform.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'bun:test' +import { + commandExists, + detectMusl, + getCommandOutput, + getPlatformId, + getPlatformInfo, +} from '../src/platform' + +describe('platform utilities', () => { + describe('getPlatformInfo', () => { + it('returns platform, arch, and isMusl', () => { + const info = getPlatformInfo() + expect(info).toHaveProperty('platform') + expect(info).toHaveProperty('arch') + expect(info).toHaveProperty('isMusl') + expect(typeof info.isMusl).toBe('boolean') + }) + + it('returns current platform', () => { + const info = getPlatformInfo() + expect(info.platform).toBe(process.platform) + }) + + it('returns current arch', () => { + const info = getPlatformInfo() + expect(info.arch).toBe(process.arch) + }) + }) + + describe('getPlatformId', () => { + it('returns valid platform id for supported platforms', () => { + const id = getPlatformId() + if (id) { + expect(['win-x64', 'win-arm64', 'osx-x64', 'osx-arm64', 'linux-x64', 'linux-arm64']).toContain(id) + } + }) + + it('returns undefined for unsupported arch', () => { + const originalArch = process.arch + Object.defineProperty(process, 'arch', { value: 'mips', configurable: true }) + try { + expect(getPlatformId()).toBeUndefined() + } + finally { + Object.defineProperty(process, 'arch', { value: originalArch, configurable: true }) + } + }) + }) + + describe('detectMusl', () => { + it('returns false on non-Linux platforms', () => { + if (process.platform !== 'linux') { + expect(detectMusl()).toBe(false) + } + }) + + it('returns boolean on Linux', () => { + if (process.platform === 'linux') { + expect(typeof detectMusl()).toBe('boolean') + } + }) + }) + + describe('commandExists', () => { + it('returns true for common commands', () => { + const command = process.platform === 'win32' ? 'cmd' : 'ls' + expect(commandExists(command)).toBe(true) + }) + + it('returns false for non-existent command', () => { + expect(commandExists('definitely-not-a-real-command-12345')).toBe(false) + }) + }) + + describe('getCommandOutput', () => { + it('returns output for valid command', () => { + const command = process.platform === 'win32' ? 'echo hello' : 'echo hello' + const output = getCommandOutput(command) + expect(output).toBe('hello') + }) + + it('returns null for invalid command', () => { + expect(getCommandOutput('definitely-not-a-command-12345')).toBeNull() + }) + + it('trims output', () => { + const output = getCommandOutput('echo " hello "') + expect(output).not.toStartWith(' ') + expect(output).not.toEndWith(' ') + }) + }) +}) diff --git a/packages/binaries/tsconfig.json b/packages/binaries/tsconfig.json new file mode 100644 index 0000000..e5df8a6 --- /dev/null +++ b/packages/binaries/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/code/package.json b/packages/code/package.json index 0e14407..e78eb71 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -28,9 +28,12 @@ "test": "bun test ./test" }, "dependencies": { + "@pleaseai/binaries": "workspace:*", "@pleaseai/code-format": "workspace:*", "@pleaseai/code-lsp": "workspace:*", - "@pleaseai/logger": "workspace:*" + "@pleaseai/logger": "workspace:*", + "chalk": "^5.6.2", + "commander": "^14.0.2" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 6d3de2d..8c2aa96 100755 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -19,6 +19,7 @@ import { Format } from '@pleaseai/code-format' import { getServerById } from '@pleaseai/code-lsp' import { createLogger } from '@pleaseai/logger' import pkg from '../package.json' +import { createSetupCommand } from './commands/setup' import { runLSPDiagnostics } from './hooks/lsp' import { parseArgs } from './utils' @@ -201,6 +202,7 @@ Commands: format Format a file using configured formatters lsp Get LSP diagnostics for a file lsp-server Start an LSP server (for Claude Code plugin) + setup [tool] Check and install required tools version Show version help Show this help @@ -208,6 +210,11 @@ Hook mode (for Claude Code): code format --stdin Format file from hook input code lsp --stdin Get diagnostics from hook input +Setup: + code setup Check and install all tools + code setup --check Check only, do not install + code setup ast-grep Setup specific tool + LSP Servers: biome, vue, svelte, deno, kotlin, dart, prisma, astro, typescript, pyright, gopls, rust-analyzer, and more. @@ -294,6 +301,15 @@ async function main(): Promise { break } + case 'setup': { + const setupCommand = createSetupCommand() + // Parse remaining args with commander + await setupCommand.parseAsync(['node', 'setup', ...args, ...Object.entries(flags) + .filter(([k]) => k !== 'project') + .flatMap(([k, v]) => v === true ? [`--${k}`] : [`--${k}`, String(v)])]) + break + } + case 'version': versionCommand() break diff --git a/packages/code/src/commands/setup.ts b/packages/code/src/commands/setup.ts new file mode 100644 index 0000000..d861dd2 --- /dev/null +++ b/packages/code/src/commands/setup.ts @@ -0,0 +1,238 @@ +/** + * Setup command - Check and install required tools + */ + +import process from 'node:process' +import chalk from 'chalk' +import { Command } from 'commander' +import { checkAllTools, checkTool, getTool, getToolIds, tools } from '../tools' + +interface SetupOptions { + check?: boolean +} + +/** + * Display tool status with colored output + */ +function displayToolStatus( + name: string, + installed: boolean, + version?: string, + path?: string, +): void { + if (installed) { + const versionStr = version ? ` (${version})` : '' + const pathStr = path ? chalk.dim(` - ${path}`) : '' + console.log(` ${chalk.green('✓')} ${name}${versionStr}${pathStr}`) + } + else { + console.log(` ${chalk.red('✗')} ${name} ${chalk.dim('(not installed)')}`) + } +} + +/** + * Prompt user for confirmation (simple y/n) + */ +async function confirm(message: string): Promise { + const stdin = process.stdin + const stdout = process.stdout + + return new Promise((resolve) => { + stdout.write(`${message} [Y/n] `) + + // Check if stdin is a TTY + if (!stdin.isTTY) { + // Non-interactive mode: default to yes + console.log('y') + resolve(true) + return + } + + stdin.setRawMode(true) + stdin.resume() + stdin.setEncoding('utf8') + + const onSigint = (): void => { + stdin.setRawMode(false) + stdin.pause() + console.log() + process.exit(130) + } + + const onData = (key: string): void => { + stdin.setRawMode(false) + stdin.pause() + stdin.removeListener('data', onData) + process.removeListener('SIGINT', onSigint) + + console.log(key.trim() || 'y') + + if (key.toLowerCase() === 'n') { + resolve(false) + } + else { + resolve(true) + } + } + + process.on('SIGINT', onSigint) + stdin.on('data', onData) + }) +} + +/** + * Run setup for all tools + */ +async function setupAll(options: SetupOptions): Promise { + console.log(chalk.bold('\nChecking tools...\n')) + + const statuses = await checkAllTools() + const missing = statuses.filter(s => !s.installed) + + // Display status + for (const status of statuses) { + displayToolStatus(status.name, status.installed, status.version, status.path) + } + + console.log() + + // Check-only mode + if (options.check) { + if (missing.length > 0) { + console.log(chalk.yellow(`${missing.length} tool(s) not installed.`)) + console.log(chalk.dim('Run `npx @pleaseai/code setup` to install.\n')) + process.exit(1) + } + else { + console.log(chalk.green('All tools installed!\n')) + } + return + } + + // Install missing tools + if (missing.length === 0) { + console.log(chalk.green('All tools ready!\n')) + return + } + + const names = missing.map(m => m.name).join(', ') + const shouldInstall = await confirm(`\nInstall missing tools (${names})?`) + + if (!shouldInstall) { + console.log(chalk.dim('\nSkipped.\n')) + return + } + + console.log() + + for (const status of missing) { + const tool = getTool(status.id) + if (!tool) + continue + + process.stdout.write(` Installing ${status.name}... `) + + try { + await tool.install() + console.log(chalk.green('done')) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.log(chalk.red('failed')) + console.log(chalk.red(` ${message}`)) + } + } + + console.log(chalk.green('\nSetup complete!\n')) +} + +/** + * Run setup for a specific tool + */ +async function setupTool(toolId: string, options: SetupOptions): Promise { + const tool = getTool(toolId) + if (!tool) { + console.log(chalk.red(`Unknown tool: ${toolId}`)) + console.log(chalk.dim(`Available tools: ${getToolIds().join(', ')}\n`)) + process.exit(1) + } + + console.log(chalk.bold(`\nChecking ${tool.name}...\n`)) + + const status = await checkTool(toolId) + if (!status) { + console.log(chalk.red('Failed to check tool status\n')) + process.exit(1) + } + + displayToolStatus(status.name, status.installed, status.version, status.path) + console.log() + + // Check-only mode + if (options.check) { + if (!status.installed) { + console.log(chalk.yellow(`${tool.name} is not installed.`)) + console.log(chalk.dim(`Run \`npx @pleaseai/code setup ${toolId}\` to install.\n`)) + process.exit(1) + } + return + } + + // Already installed + if (status.installed) { + console.log(chalk.green(`${tool.name} is ready!\n`)) + return + } + + // Install + const shouldInstall = await confirm(`Install ${tool.name}?`) + + if (!shouldInstall) { + console.log(chalk.dim('\nSkipped.\n')) + return + } + + process.stdout.write(`\n Installing ${tool.name}... `) + + try { + await tool.install() + console.log(chalk.green('done\n')) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.log(chalk.red('failed')) + console.log(chalk.red(` ${message}\n`)) + process.exit(1) + } +} + +/** + * Create the setup command + */ +export function createSetupCommand(): Command { + const command = new Command('setup') + .description('Check and install required tools') + .argument('[tool]', 'Specific tool to setup (optional)') + .option('-c, --check', 'Check only, do not install') + .action(async (toolId: string | undefined, options: SetupOptions) => { + if (toolId) { + await setupTool(toolId, options) + } + else { + await setupAll(options) + } + }) + + // Add list of available tools to help + command.addHelpText('after', ` +Available tools: +${tools.map(t => ` ${t.id.padEnd(15)} ${t.description}`).join('\n')} + +Examples: + $ npx @pleaseai/code setup # Check and install all tools + $ npx @pleaseai/code setup --check # Check only + $ npx @pleaseai/code setup ast-grep # Setup specific tool +`) + + return command +} diff --git a/packages/code/src/tools/ast-grep.ts b/packages/code/src/tools/ast-grep.ts new file mode 100644 index 0000000..5f3a91d --- /dev/null +++ b/packages/code/src/tools/ast-grep.ts @@ -0,0 +1,192 @@ +/** + * ast-grep tool definition + * Handles installation and detection of ast-grep CLI + */ + +import type { Tool } from './types' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { + CACHE_DIR, + commandExists, + downloadFile, + ensureCacheDir, + extractTarGz, + extractZip, + getCachedBinaryPath, + getCommandOutput, + getPlatformInfo, + isValidBinary, + makeExecutable, +} from './downloader' + +const TOOL_ID = 'ast-grep' +const BINARY_NAME = process.platform === 'win32' ? 'ast-grep.exe' : 'ast-grep' + +/** Pinned ast-grep version for stability and reproducibility */ +const AST_GREP_VERSION = '0.40.3' + +/** GitHub release URL for the pinned version */ +const GITHUB_RELEASE_URL = `https://github.com/ast-grep/ast-grep/releases/download/${AST_GREP_VERSION}` + +/** + * Get the platform-specific archive name for ast-grep + */ +function getArchiveName(): string | null { + const { platform, arch } = getPlatformInfo() + + const platformMap: Record> = { + darwin: { + arm64: 'ast-grep-aarch64-apple-darwin.tar.gz', + x64: 'ast-grep-x86_64-apple-darwin.tar.gz', + }, + linux: { + arm64: 'ast-grep-aarch64-unknown-linux-gnu.tar.gz', + x64: 'ast-grep-x86_64-unknown-linux-gnu.tar.gz', + }, + win32: { + x64: 'ast-grep-x86_64-pc-windows-msvc.zip', + arm64: 'ast-grep-aarch64-pc-windows-msvc.zip', + }, + } + + return platformMap[platform]?.[arch] ?? null +} + +/** + * Find ast-grep binary in various locations + */ +async function findBinary(): Promise { + // 1. Check cached binary + const cachedPath = getCachedBinaryPath(TOOL_ID, BINARY_NAME) + if (isValidBinary(cachedPath)) { + return cachedPath + } + + // 2. Check Homebrew paths (macOS) + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/ast-grep', + '/usr/local/bin/ast-grep', + ] + for (const p of homebrewPaths) { + if (isValidBinary(p)) { + return p + } + } + } + + // 3. Check system PATH + if (commandExists('ast-grep')) { + const output = getCommandOutput('which ast-grep') + if (output && isValidBinary(output)) { + return output + } + return 'ast-grep' // Fallback to command name + } + + return null +} + +/** + * Install ast-grep to local cache + */ +async function installToCache(): Promise { + const archiveName = getArchiveName() + if (!archiveName) { + throw new Error(`Unsupported platform: ${process.platform}-${process.arch}`) + } + + const downloadUrl = `${GITHUB_RELEASE_URL}/${archiveName}` + const toolDir = ensureCacheDir(TOOL_ID) + const archivePath = path.join(toolDir, archiveName) + + // Download archive + await downloadFile(downloadUrl, archivePath) + + // Extract archive + if (archiveName.endsWith('.tar.gz')) { + await extractTarGz(archivePath, toolDir) + } + else if (archiveName.endsWith('.zip')) { + await extractZip(archivePath, toolDir) + } + + // Make binary executable + const binaryPath = getCachedBinaryPath(TOOL_ID, BINARY_NAME) + if (fs.existsSync(binaryPath)) { + makeExecutable(binaryPath) + } + else { + // Binary might be in a subdirectory, try to find it + const files = fs.readdirSync(toolDir) + for (const file of files) { + const filePath = path.join(toolDir, file) + if (file === BINARY_NAME || file === 'ast-grep' || file === 'sg') { + if (file !== BINARY_NAME) { + fs.renameSync(filePath, binaryPath) + } + makeExecutable(binaryPath) + break + } + } + } + + // Clean up archive + fs.unlinkSync(archivePath) + + // Verify installation + if (!isValidBinary(getCachedBinaryPath(TOOL_ID, BINARY_NAME))) { + throw new Error('Failed to install ast-grep: binary not found after extraction') + } +} + +/** + * Get ast-grep version + */ +async function getVersion(): Promise { + const binaryPath = await findBinary() + if (!binaryPath) { + return null + } + + const output = getCommandOutput(`"${binaryPath}" --version`) + if (output) { + // Output format: "ast-grep 0.x.x" - extract version + const match = output.match(/(\d+\.\d+\.\d+)/) + return match?.[1] ?? output + } + + return null +} + +export const astGrepTool: Tool = { + id: TOOL_ID, + name: 'ast-grep', + description: 'AST-based code search and rewriting tool', + + async isInstalled(): Promise { + const binaryPath = await findBinary() + return binaryPath !== null + }, + + async getBinaryPath(): Promise { + return findBinary() + }, + + async install(): Promise { + await installToCache() + }, + + async getVersion(): Promise { + return getVersion() + }, +} + +/** + * Export the cached binary path for use in other modules + */ +export function getAstGrepCachePath(): string { + return path.join(CACHE_DIR, TOOL_ID, BINARY_NAME) +} diff --git a/packages/code/src/tools/downloader.ts b/packages/code/src/tools/downloader.ts new file mode 100644 index 0000000..024091b --- /dev/null +++ b/packages/code/src/tools/downloader.ts @@ -0,0 +1,23 @@ +/** + * Binary downloader utility + * Re-exports from @pleaseai/binaries with local defaults + */ + +export { + DEFAULT_CACHE_DIR as CACHE_DIR, + commandExists, + downloadAndExtract, + downloadFile, + ensureCacheDir, + extractTarGz, + extractZip, + getCachedBinaryPath, + getCacheDir, + getCommandOutput, + getPlatformId, + getPlatformInfo, + hasCachedBinary, + isValidBinary, + makeExecutable, +} from '@pleaseai/binaries' +export type { PlatformId, PlatformInfo } from '@pleaseai/binaries' diff --git a/packages/code/src/tools/index.ts b/packages/code/src/tools/index.ts new file mode 100644 index 0000000..09e3ed9 --- /dev/null +++ b/packages/code/src/tools/index.ts @@ -0,0 +1,83 @@ +/** + * Tools registry + * Central registry for all tools that can be installed via setup command + */ + +import type { Tool, ToolStatus } from './types' +import { astGrepTool } from './ast-grep' + +export { getAstGrepCachePath } from './ast-grep' +export { CACHE_DIR, getCachedBinaryPath } from './downloader' +export type { Tool, ToolStatus } from './types' + +/** All available tools */ +export const tools: Tool[] = [ + astGrepTool, + // Add more tools here as needed: + // biomeTool, + // typescriptLspTool, + // etc. +] + +/** Get a tool by ID */ +export function getTool(id: string): Tool | undefined { + return tools.find(t => t.id === id) +} + +/** Get all tool IDs */ +export function getToolIds(): string[] { + return tools.map(t => t.id) +} + +/** Check status of all tools */ +export async function checkAllTools(): Promise { + const results: ToolStatus[] = [] + + for (const tool of tools) { + const installed = await tool.isInstalled() + const status: ToolStatus = { + id: tool.id, + name: tool.name, + installed, + } + + if (installed) { + const version = await tool.getVersion?.() + const binaryPath = await tool.getBinaryPath() + if (version) + status.version = version + if (binaryPath) + status.path = binaryPath + } + + results.push(status) + } + + return results +} + +/** Check status of a specific tool */ +export async function checkTool(id: string): Promise { + const tool = getTool(id) + if (!tool) { + return null + } + + const installed = await tool.isInstalled() + const status: ToolStatus = { + id: tool.id, + name: tool.name, + installed, + } + + if (installed) { + const version = await tool.getVersion?.() + const binaryPath = await tool.getBinaryPath() + if (version) + status.version = version + if (binaryPath) + status.path = binaryPath + } + + return status +} diff --git a/packages/code/src/tools/types.ts b/packages/code/src/tools/types.ts new file mode 100644 index 0000000..e59886d --- /dev/null +++ b/packages/code/src/tools/types.ts @@ -0,0 +1,37 @@ +/** + * Tool type definitions for the setup command + */ + +export type { PlatformId, PlatformInfo } from '@pleaseai/binaries' + +export interface Tool { + /** Tool identifier */ + id: string + /** Display name */ + name: string + /** Tool description */ + description: string + /** Check if tool is installed */ + isInstalled: () => Promise + /** Get the binary path (if installed) */ + getBinaryPath: () => Promise + /** Install the tool */ + install: () => Promise + /** Get version (if installed) */ + getVersion?: () => Promise +} + +export interface ToolStatus { + id: string + name: string + installed: boolean + version?: string | undefined + path?: string | undefined +} + +export interface DownloadOptions { + url: string + dest: string + extract?: boolean + binaryName?: string +} diff --git a/packages/dora/package.json b/packages/dora/package.json index 7254296..c663fb9 100644 --- a/packages/dora/package.json +++ b/packages/dora/package.json @@ -29,9 +29,10 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "@pleaseai/binaries": "workspace:*", "@pleaseai/code-lsp": "workspace:*", "@pleaseai/logger": "workspace:*", - "zod": "^3.25.0" + "zod": "^3.25.0 || ^4.0.0" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/dora/src/providers/ast-grep/constants.ts b/packages/dora/src/providers/ast-grep/constants.ts index 49c66d2..a3a5c10 100644 --- a/packages/dora/src/providers/ast-grep/constants.ts +++ b/packages/dora/src/providers/ast-grep/constants.ts @@ -5,7 +5,10 @@ import type { PlatformConfig, PlatformId } from './types' import * as os from 'node:os' import * as path from 'node:path' -import process from 'node:process' +import { getPlatformId as getPlatformIdBase } from '@pleaseai/binaries' + +// Re-export getPlatformId from binaries +export const getPlatformId = getPlatformIdBase // CLI supported languages (25 total) export const CLI_LANGUAGES = [ @@ -82,7 +85,8 @@ const GITHUB_RELEASE_BASE = 'https://github.com/ast-grep/ast-grep/releases/downl // Platform-specific binary configurations // Note: Release asset names use "app-" prefix, e.g., app-x86_64-unknown-linux-gnu.zip // The binary inside the archive is named "ast-grep" (or "ast-grep.exe" on Windows) -export const PLATFORM_CONFIGS: Record = { +// Note: win-arm64 is not supported as ast-grep doesn't provide binaries for that platform +export const PLATFORM_CONFIGS: Partial> = { 'win-x64': { url: `${GITHUB_RELEASE_BASE}/${AST_GREP_VERSION}/app-x86_64-pc-windows-msvc.zip`, binaryPath: 'ast-grep.exe', @@ -105,34 +109,6 @@ export const PLATFORM_CONFIGS: Record = { }, } -/** - * Get platform identifier for current system - */ -export function getPlatformId(): PlatformId | undefined { - const platform = process.platform - const arch = process.arch - - const platformMap: Record = { - win32: 'win', - darwin: 'osx', - linux: 'linux', - } - - const archMap: Record = { - x64: 'x64', - arm64: 'arm64', - } - - const platformKey = platformMap[platform] - const archKey = archMap[arch] - - if (!platformKey || !archKey) { - return undefined - } - - return `${platformKey}-${archKey}` as PlatformId -} - /** * Get cache directory for ast-grep binary */ @@ -146,10 +122,14 @@ export function getAstGrepCacheDir(): string { export function getCachedBinaryPath(): string { const platformId = getPlatformId() if (!platformId) { - return 'sg' // Fallback to PATH + return 'ast-grep' // Fallback to PATH } const config = PLATFORM_CONFIGS[platformId] + if (!config) { + return 'ast-grep' // Fallback to PATH for unsupported platforms + } + return path.join(getAstGrepCacheDir(), config.binaryPath) } diff --git a/packages/dora/src/providers/ast-grep/downloader.ts b/packages/dora/src/providers/ast-grep/downloader.ts index 2adce8e..95aec33 100644 --- a/packages/dora/src/providers/ast-grep/downloader.ts +++ b/packages/dora/src/providers/ast-grep/downloader.ts @@ -5,7 +5,7 @@ * Caches to ~/.cache/dora/ast-grep/ */ -import type { PlatformId } from './types' +import type { PlatformId } from '@pleaseai/binaries' import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import process from 'node:process' import { createLogger } from '@pleaseai/logger' diff --git a/packages/dora/src/providers/ast-grep/types.ts b/packages/dora/src/providers/ast-grep/types.ts index eacbc8e..1ac6535 100644 --- a/packages/dora/src/providers/ast-grep/types.ts +++ b/packages/dora/src/providers/ast-grep/types.ts @@ -95,13 +95,8 @@ export interface RunSgOptions { ruleFile?: string } -/** Platform identifier */ -export type PlatformId - = | 'win-x64' - | 'linux-x64' - | 'linux-arm64' - | 'osx-x64' - | 'osx-arm64' +// Re-export PlatformId from binaries for consistency +export type { PlatformId } from '@pleaseai/binaries' /** Platform-specific binary configuration */ export interface PlatformConfig { diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 5f6b4ef..5d91f84 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -30,11 +30,12 @@ "test:integration": "bun test ./test/integration" }, "dependencies": { + "@pleaseai/binaries": "workspace:*", "@pleaseai/code-format": "workspace:*", "@pleaseai/logger": "workspace:*", "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-types": "^3.17.5", - "zod": "^3.25.32" + "zod": "^3.25.0 || ^4.0.0" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/lsp/src/server/types.ts b/packages/lsp/src/server/types.ts index 1475944..eb55ffe 100644 --- a/packages/lsp/src/server/types.ts +++ b/packages/lsp/src/server/types.ts @@ -5,6 +5,9 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' +// Re-export PlatformId from binaries for consistency +export type { PlatformId } from '@pleaseai/binaries' + export interface LSPServerHandle { process: ChildProcessWithoutNullStreams initialization?: Record @@ -23,9 +26,3 @@ export interface LSPServerInfo { root: RootFunction spawn: (root: string) => Promise } - -/** - * Supported platform identifiers for auto-download dependencies - * Note: win-arm64 is not supported as JRE distributions are not available - */ -export type PlatformId = 'win-x64' | 'linux-x64' | 'linux-arm64' | 'osx-x64' | 'osx-arm64' diff --git a/packages/lsp/src/server/utils.ts b/packages/lsp/src/server/utils.ts index 30bbb0c..e41aae2 100644 --- a/packages/lsp/src/server/utils.ts +++ b/packages/lsp/src/server/utils.ts @@ -4,111 +4,21 @@ */ import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import type { PlatformId, RootFunction } from './types' -import { createWriteStream } from 'node:fs' +import type { RootFunction } from './types' import fs from 'node:fs/promises' -import os from 'node:os' import path from 'node:path' -import process from 'node:process' -import { pipeline } from 'node:stream/promises' import { createLogger } from '@pleaseai/logger' -export const log = createLogger('lsp') - -// ============================================================================= -// Platform Detection Utilities -// ============================================================================= - -/** - * Get platform identifier for current system - */ -export function getPlatformId(): PlatformId | undefined { - const platform = process.platform - const arch = process.arch - - const platformMap: Record = { - win32: 'win', - darwin: 'osx', - linux: 'linux', - } - - const archMap: Record = { - x64: 'x64', - arm64: 'arm64', - } +// Re-export from @pleaseai/binaries +export { + downloadAndExtract, + downloadFile, + extractZip, + getPlatformId, +} from '@pleaseai/binaries' +export type { PlatformId } from '@pleaseai/binaries' - const platformStr = platformMap[platform] - const archStr = archMap[arch] - - if (!platformStr || !archStr) - return undefined - - return `${platformStr}-${archStr}` as PlatformId -} - -// ============================================================================= -// Download Utilities -// ============================================================================= - -/** - * Download a file from URL to destination - */ -export async function downloadFile(url: string, dest: string): Promise { - const response = await fetch(url, { redirect: 'follow' }) - if (!response.ok) { - throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) - } - - const body = response.body - if (!body) { - throw new Error(`No response body for ${url}`) - } - - await fs.mkdir(path.dirname(dest), { recursive: true }) - const writeStream = createWriteStream(dest) - await pipeline(body as unknown as NodeJS.ReadableStream, writeStream) -} - -/** - * Extract a zip archive using system unzip command - */ -export async function extractZip(zipPath: string, destDir: string): Promise { - await fs.mkdir(destDir, { recursive: true }) - - // Use system unzip command via Bun.spawn - const proc = Bun.spawn(['unzip', '-o', '-q', zipPath, '-d', destDir], { - stdout: 'pipe', - stderr: 'pipe', - }) - const exitCode = await proc.exited - - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - throw new Error(`Failed to extract ${zipPath}: ${stderr}`) - } -} - -/** - * Download and extract an archive - */ -export async function downloadAndExtract(url: string, destDir: string): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lsp-download-')) - const tempFile = path.join(tempDir, 'archive.zip') - - try { - log.info({ url }, 'Downloading') - await downloadFile(url, tempFile) - log.info({ destDir }, 'Extracting') - await extractZip(tempFile, destDir) - log.info('Download complete') - } - finally { - // Cleanup temp files - await fs.rm(tempDir, { recursive: true, force: true }).catch((err) => { - log.warn({ tempDir, err }, 'Failed to cleanup temp directory') - }) - } -} +export const log = createLogger('lsp') // ============================================================================= // Process Lifecycle Utilities diff --git a/skills/ast-grep/SKILL.md b/skills/ast-grep/SKILL.md index e322f1f..38a08e5 100644 --- a/skills/ast-grep/SKILL.md +++ b/skills/ast-grep/SKILL.md @@ -2,8 +2,8 @@ name: ast-grep description: Guide for writing ast-grep rules to perform structural code search and analysis. Use when users need to search codebases using Abstract Syntax Tree (AST) patterns, find specific code structures, or perform complex code queries that go beyond simple text search. This skill should be used when users ask to search for code patterns, find specific language constructs, or locate code with particular structural characteristics. metadata: - version: "1.0.0" - lastUpdated: "2025-12-19" + version: "1.1.0" + lastUpdated: "2025-12-23" --- # ast-grep Code Search @@ -268,6 +268,14 @@ rule: bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml +## Note for Linux Users + +On Linux, `sg` is a system command for `setgroups`. Always use the full command name `ast-grep` instead of the `sg` alias. If you prefer the shorter alias, add this to your shell profile: + +```bash +alias sg=ast-grep +``` + ## References - [ast-grep Documentation](https://ast-grep.github.io/)