diff --git a/.changeset/young-bats-shop.md b/.changeset/young-bats-shop.md new file mode 100644 index 0000000..c294397 --- /dev/null +++ b/.changeset/young-bats-shop.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Improves the intent CLI with better setup validation, clearer feedback, version conflict detection, and improved monorepo support. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfedf7e..31b74ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,11 @@ name: Release on: push: - # branches: [main, alpha, beta, rc] + branches: [main, '*-pre', '*-maint'] concurrency: - group: ${{ github.workflow }}-${{ github.event.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} @@ -19,7 +19,7 @@ permissions: jobs: release: name: Release - if: github.repository_owner == 'TanStack' + if: "github.repository_owner == 'TanStack' && !contains(github.event.head_commit.message, 'ci: changeset release')" runs-on: ubuntu-latest steps: - name: Checkout @@ -29,13 +29,50 @@ jobs: - name: Setup Tools uses: tanstack/config/.github/setup@main - name: Run Tests - run: pnpm run test:ci - - name: Run Changesets (version or publish) - uses: changesets/action@v1.5.3 - with: - version: pnpm run changeset:version - publish: pnpm run changeset:publish - commit: 'ci: Version Packages' - title: 'ci: Version Packages' + run: pnpm run test:ci --parallel=3 + - name: Version Packages + run: pnpm run changeset:version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Commit Release + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add . + if git commit -m "ci: changeset release"; then + git push + echo "committed=true" >> "$GITHUB_OUTPUT" + fi + - name: Set Dist Tag + id: dist-tag + if: steps.commit.outputs.committed == 'true' + run: | + branch="${GITHUB_REF_NAME}" + if [[ "$branch" == *-pre ]]; then + echo "tag=next" >> "$GITHUB_OUTPUT" + elif [[ "$branch" == *-maint ]]; then + echo "tag=maint" >> "$GITHUB_OUTPUT" + fi + - name: Publish Packages + if: steps.commit.outputs.committed == 'true' + run: | + if [[ -n "${{ steps.dist-tag.outputs.tag }}" ]]; then + pnpm run changeset:publish --tag "${{ steps.dist-tag.outputs.tag }}" + else + pnpm run changeset:publish + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create GitHub Release + if: steps.commit.outputs.committed == 'true' + run: | + if [[ "${{ steps.dist-tag.outputs.tag }}" == 'next' ]]; then + node scripts/create-github-release.mjs --prerelease + else + node scripts/create-github-release.mjs + fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index ff6d002..7680243 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -28,10 +28,14 @@ They also ask you to: 1. Check for an existing block first 2. Run `intent list` to discover installed skills -3. Add task-to-skill mappings -4. Preserve all content outside the tagged block +3. Ask whether you want a config target other than `AGENTS.md` +4. Update an existing block in place when one already exists +5. Add task-to-skill mappings +6. Preserve all content outside the tagged block + +If no existing block is found, `AGENTS.md` is the default target. ## Related - [intent list](./intent-list) -- [Setting Up Agent Config](../guides/consumers/agent-config-setup) +- [Quick Start for Consumers](../getting-started/quick-start-consumers) diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 4b39ae9..904117c 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -14,11 +14,14 @@ npx @tanstack/intent@latest install ``` This prints a skill that instructs your AI agent to: -1. Check for existing `intent-skills` mappings in your config files (CLAUDE.md, .cursorrules, etc.) -2. Run `intent list` to discover available skills from installed packages +1. Check for existing `intent-skills` mappings in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.) +2. Run `npx @tanstack/intent@latest list` to discover available skills from installed packages 3. Scan your repository structure to understand your project 4. Propose relevant skill-to-task mappings based on your codebase patterns -5. Write or update an `intent-skills` block in your agent config +5. Ask if you want a target other than `AGENTS.md` +6. Write or update an `intent-skills` block in your agent config + +If an `intent-skills` block already exists, the agent updates that file in place. If no block exists, `AGENTS.md` is the default target. Your agent will create mappings like: @@ -45,7 +48,7 @@ Skills version with library releases. When you update a library: npm update @tanstack/react-query ``` -The new version brings updated skills automatically — you don't need to do anything. The skills are shipped with the library, so you always get the version that matches your installed code. +The new version brings updated skills automatically — you don't need to do anything. The skills are shipped with the library, so you always get the version that matches your installed code. If a package is installed both locally and globally, Intent prefers the local version. If you need to see what skills have changed, run: diff --git a/docs/overview.md b/docs/overview.md index 3c765a7..faa17f4 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -5,7 +5,7 @@ id: overview `@tanstack/intent` is a CLI for shipping and consuming Agent Skills as package artifacts. -Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases, ships them inside npm packages, discovers them from `node_modules`, and helps agents load them automatically when working on matching tasks. +Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases, ships them inside npm packages, discovers them from local and accessible global `node_modules`, and helps agents load them automatically when working on matching tasks. ## What Intent does @@ -30,13 +30,13 @@ Intent provides tooling for two workflows: npx @tanstack/intent@latest list ``` -Scans `node_modules` for intent-enabled packages and shows available skills with paths and descriptions. +Scans local `node_modules` and any accessible global `node_modules` for intent-enabled packages, preferring local packages when both exist. ```bash npx @tanstack/intent@latest install ``` -Prints instructions for your agent to create `intent-skills` mappings in your config files (CLAUDE.md, .cursorrules, etc.). +Prints instructions for your agent to create `intent-skills` mappings in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing mappings are updated in place; otherwise `AGENTS.md` is the default target. ### Scaffolding and validation diff --git a/packages/intent/README.md b/packages/intent/README.md index 26ed8ed..901204d 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -10,9 +10,9 @@ Docs target humans who browse. Types check individual API calls but can't encode The ecosystem already moves toward agent-readable knowledge — Cursor rules, CLAUDE.md files, skills directories. But delivery is stuck in copy-paste: hunt for a community-maintained rules file, paste it into your config, repeat for every tool. No versioning, no update path, no staleness signal. -## Skills: versioned knowledge in npm +## Skills: versioned knowledge in your package manager -A skill is a short, versioned document that tells agents how to use a specific capability of your library — correct patterns, common mistakes, and when to apply them. Skills ship inside your npm package and travel with the tool via `npm update` — not the model's training cutoff, not community-maintained rules files, not prompt snippets in READMEs. Versioned knowledge the maintainer owns, updated when the package updates. +A skill is a short, versioned document that tells agents how to use a specific capability of your library — correct patterns, common mistakes, and when to apply them. Skills ship inside your package and travel with the tool via your normal package manager update flow — not the model's training cutoff, not community-maintained rules files, not prompt snippets in READMEs. Versioned knowledge the maintainer owns, updated when the package updates. Each skill declares its source docs. When those docs change, the CLI flags the skill for review. One source of truth, one derived artifact that stays in sync. @@ -20,6 +20,18 @@ The [Agent Skills spec](https://agentskills.io) is an open standard already adop ## Quick Start +### Command runners + +Use whichever command runner matches your environment: + +| Tool | Pattern | +| ---- | -------------------------------------------- | +| npm | `npx @tanstack/intent@latest ` | +| pnpm | `pnpm dlx @tanstack/intent@latest ` | +| bun | `bunx @tanstack/intent@latest ` | + +If you use Deno, support is best-effort today via `npm:` interop with `node_modules` enabled. First-class Deno runtime support is not implemented yet. + ### For library consumers Set up skill-to-task mappings in your project's agent config files (CLAUDE.md, .cursorrules, etc.): @@ -28,7 +40,7 @@ Set up skill-to-task mappings in your project's agent config files (CLAUDE.md, . npx @tanstack/intent@latest install ``` -No per-library setup. No hunting for rules files. Install the package, run `npx @tanstack/intent@latest install`, and the agent understands the tool. Update the package, and skills update too. +No per-library setup. No hunting for rules files. Install the package, run `npx @tanstack/intent@latest install` through your preferred command runner, and the agent understands the tool. Update the package, and skills update too. List available skills from installed packages: @@ -52,23 +64,48 @@ Validate your skill files: npx @tanstack/intent@latest validate ``` +In a monorepo, you can validate a package from the repo root: + +```bash +npx @tanstack/intent@latest validate packages/router/skills +``` + Check for skills that have fallen behind their sources: ```bash npx @tanstack/intent@latest stale ``` +From a monorepo root, `intent stale` checks every workspace package that ships skills. To scope it to one package, pass a directory like `intent stale packages/router`. + Copy CI workflow templates into your repo so validation and staleness checks run on every push: ```bash npx @tanstack/intent@latest setup-github-actions ``` +## Compatibility + +| Environment | Status | Notes | +| -------------- | ----------- | -------------------------------------------------- | +| Node.js + npm | Supported | Use `npx @tanstack/intent@latest ` | +| Node.js + pnpm | Supported | Use `pnpm dlx @tanstack/intent@latest ` | +| Node.js + Bun | Supported | Use `bunx @tanstack/intent@latest ` | +| Deno | Best-effort | Requires `npm:` interop and `node_modules` support | +| Yarn PnP | Unsupported | `@tanstack/intent` scans `node_modules` | + +## Monorepos + +- Run `npx @tanstack/intent@latest setup-github-actions` from either the repo root or a package directory. Intent detects the workspace root and writes workflows to the repo-level `.github/workflows/` directory. +- Generated workflows are monorepo-aware: validation loops over workspace packages with skills, staleness checks run from the workspace root, and notify workflows watch package `src/` and docs paths. +- Run `npx @tanstack/intent@latest validate packages//skills` from the repo root to validate one package without root-level packaging warnings. +- Run `npx @tanstack/intent@latest stale` from the repo root to check all workspace packages with skills, or `intent stale packages/` to check one package. + ## Keeping skills current The real risk with any derived artifact is staleness. `npx @tanstack/intent@latest stale` flags skills whose source docs have changed, and CI templates catch drift before it ships. -The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` lets users submit structured reports when a skill produces wrong output — which skill, which version, what broke. That context flows back to the maintainer, and the fix ships to everyone on the next `npm update`. Every support interaction produces an artifact that prevents the same class of problem for all future users — not just the one who reported it. +The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` lets users submit structured reports when a skill produces wrong output — which skill, which version, what broke. That context flows back to the maintainer, and the fix ships to everyone on the next package update. Every support interaction produces an artifact that prevents the same class of problem for all future users — not just the one who reported it. ## CLI Commands @@ -80,7 +117,7 @@ The feedback loop runs both directions. `npx @tanstack/intent@latest feedback` l | `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt | | `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files | | `npx @tanstack/intent@latest setup-github-actions` | Copy CI templates into your repo | -| `npx @tanstack/intent@latest stale [--json]` | Check skills for version drift | +| `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift | | `npx @tanstack/intent@latest feedback` | Submit skill feedback | ## License diff --git a/packages/intent/meta/feedback-collection/SKILL.md b/packages/intent/meta/feedback-collection/SKILL.md index 1684da8..080b84e 100644 --- a/packages/intent/meta/feedback-collection/SKILL.md +++ b/packages/intent/meta/feedback-collection/SKILL.md @@ -36,7 +36,7 @@ during the session: - **Loaded and used:** Skills you read and actively followed. - **Available but not loaded:** Skills that were installed (discoverable via - `intent list`) but you never read. This is important — many issues stem from + `npx @tanstack/intent@latest list`) but you never read. This is important — many issues stem from the agent not loading the right skill, not from the skill itself being wrong. ### 1b: Gap detection diff --git a/packages/intent/meta/templates/workflows/check-skills.yml b/packages/intent/meta/templates/workflows/check-skills.yml index 602d01e..5e46ca1 100644 --- a/packages/intent/meta/templates/workflows/check-skills.yml +++ b/packages/intent/meta/templates/workflows/check-skills.yml @@ -7,7 +7,7 @@ # Triggers: new release published, or manual workflow_dispatch. # # Template variables (replaced by `intent setup`): -# {{PACKAGE_NAME}} — e.g. @tanstack/query +# {{PACKAGE_LABEL}} — e.g. @tanstack/query or my-workspace workspace name: Check Skills @@ -36,12 +36,12 @@ jobs: node-version: 20 - name: Install intent - run: npm install {{PACKAGE_NAME}} + run: npm install -g @tanstack/intent - name: Check staleness id: stale run: | - OUTPUT=$(npx @tanstack/intent stale --json 2>&1) || true + OUTPUT=$(intent stale --json 2>&1) || true echo "$OUTPUT" # Check if any skills need review @@ -81,7 +81,7 @@ jobs: const summary = lines.join('\n'); const prompt = [ - 'Review and update the following stale intent skills for {{PACKAGE_NAME}}:', + 'Review and update the following stale intent skills for {{PACKAGE_LABEL}}:', '', ...stale.map(s => '- ' + s.skill + ': ' + s.reasons.join(', ')), '', diff --git a/packages/intent/meta/templates/workflows/notify-intent.yml b/packages/intent/meta/templates/workflows/notify-intent.yml index cc18685..db9ebfe 100644 --- a/packages/intent/meta/templates/workflows/notify-intent.yml +++ b/packages/intent/meta/templates/workflows/notify-intent.yml @@ -9,9 +9,9 @@ # as the INTENT_NOTIFY_TOKEN repository secret. # # Template variables (replaced by `intent setup`): -# {{PACKAGE_NAME}} — e.g. @tanstack/query -# {{DOCS_PATH}} — e.g. docs/** -# {{SRC_PATH}} — e.g. packages/query-core/src/** +# {{PAYLOAD_PACKAGE}} — e.g. @tanstack/query or my-workspace workspace +# {{DOCS_PATH}} — e.g. docs/** +# {{SRC_PATH}} — e.g. packages/query-core/src/** name: Notify Intent @@ -46,7 +46,7 @@ jobs: event-type: skill-check client-payload: | { - "package": "{{PACKAGE_NAME}}", + "package": "{{PAYLOAD_PACKAGE}}", "sha": "${{ github.sha }}", "changed_files": ${{ steps.changes.outputs.files }} } diff --git a/packages/intent/meta/tree-generator/SKILL.md b/packages/intent/meta/tree-generator/SKILL.md index d84841e..26b83a2 100644 --- a/packages/intent/meta/tree-generator/SKILL.md +++ b/packages/intent/meta/tree-generator/SKILL.md @@ -40,7 +40,7 @@ Every skill has a `type` field in its frontmatter. Valid types: | `composition` | Integration between two or more libraries | `electric-drizzle` | | `security` | Audit checklist or security validation | `electric-security-check` | -Agents discover skills via `tanstack intent list` and read them directly +Agents discover skills via `npx @tanstack/intent list` and read them directly from `node_modules`. Framework skills declare a `requires` dependency on their core skill so agents load them in the right order. @@ -274,7 +274,7 @@ packages/ │ └── package.json # Add "skills" to files array ``` -Run `intent edit-package-json` to wire each package's `package.json` +Run `npx @tanstack/intent@latest edit-package-json` to wire each package's `package.json` automatically (adds `"skills"`, `"bin"`, and `"!skills/_artifacts"` to the `files` array, and adds the `bin` entry if missing). diff --git a/packages/intent/package.json b/packages/intent/package.json index 2e8887c..0a9dd9d 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -14,7 +14,8 @@ "types": "./dist/index.d.mts" }, "./intent-library": { - "import": "./dist/intent-library.mjs" + "import": "./dist/intent-library.mjs", + "types": "./dist/intent-library.d.mts" } }, "bin": { @@ -32,7 +33,7 @@ "tsdown": "^0.19.0" }, "scripts": { - "prepack": "pnpm run build", + "prepack": "npm run build", "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts --format esm --dts", "test:lib": "vitest run", "test:types": "tsc --noEmit" diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 5599ad8..58892b9 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -2,47 +2,121 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join, relative, sep } from 'node:path' -import { fileURLToPath } from 'node:url' -import { parse as parseYaml } from 'yaml' -import { computeSkillNameWidth, printSkillTree, printTable } from './display.js' -import { scanForIntents } from './scanner.js' -import { findSkillFiles, parseFrontmatter } from './utils.js' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { INSTALL_PROMPT } from './install-prompt.js' import type { ScanResult } from './types.js' -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) return join(thisDir, '..', 'meta') } -// --------------------------------------------------------------------------- -// Commands -// --------------------------------------------------------------------------- +type CliFailure = { + message: string + exitCode: number +} -async function cmdList(args: Array): Promise { - const jsonOutput = args.includes('--json') +function fail(message: string, exitCode = 1): never { + throw { message, exitCode } satisfies CliFailure +} + +function isCliFailure(value: unknown): value is CliFailure { + return ( + !!value && + typeof value === 'object' && + 'message' in value && + typeof value.message === 'string' && + 'exitCode' in value && + typeof value.exitCode === 'number' + ) +} + +async function scanIntentsOrFail(): Promise { + const { scanForIntents } = await import('./scanner.js') - let result: ScanResult try { - result = await scanForIntents() + return await scanForIntents() } catch (err) { - console.error((err as Error).message) - process.exit(1) + fail((err as Error).message) + } +} + +function printWarnings(warnings: Array): void { + if (warnings.length === 0) return + + console.log('Warnings:') + for (const warning of warnings) { + console.log(` ⚠ ${warning}`) + } +} + +function formatScanCoverage(result: ScanResult): string { + const coverage: Array = [] + + if (result.nodeModules.local.scanned) coverage.push('project node_modules') + if (result.nodeModules.global.scanned) coverage.push('global node_modules') + + return coverage.join(', ') +} + +function printVersionConflicts(result: ScanResult): void { + if (result.conflicts.length === 0) return + + console.log('\nVersion conflicts:\n') + for (const conflict of result.conflicts) { + console.log(` ${conflict.packageName} -> using ${conflict.chosen.version}`) + console.log(` chosen: ${conflict.chosen.packageRoot}`) + + for (const variant of conflict.variants) { + if (variant.packageRoot === conflict.chosen.packageRoot) continue + console.log( + ` also found: ${variant.version} at ${variant.packageRoot}`, + ) + } + + console.log() + } +} + +function buildValidationFailure( + errors: Array<{ file: string; message: string }>, + warnings: Array, +): string { + const lines = ['', `❌ Validation failed with ${errors.length} error(s):`, ''] + + for (const { file, message } of errors) { + lines.push(` ${file}: ${message}`) + } + + if (warnings.length > 0) { + lines.push('', '⚠ Packaging warnings:') + for (const warning of warnings) { + lines.push(` ${warning}`) + } } + return lines.join('\n') +} + +async function cmdList(args: Array): Promise { + const { computeSkillNameWidth, printSkillTree, printTable } = + await import('./display.js') + const jsonOutput = args.includes('--json') + const result = await scanIntentsOrFail() + if (jsonOutput) { console.log(JSON.stringify(result, null, 2)) return } + const scanCoverage = formatScanCoverage(result) + if (result.packages.length === 0) { console.log('No intent-enabled packages found.') + if (scanCoverage) console.log(`Scanned: ${scanCoverage}`) if (result.warnings.length > 0) { - console.log(`\nWarnings:`) - for (const w of result.warnings) console.log(` ⚠ ${w}`) + console.log() + printWarnings(result.warnings) } return } @@ -54,6 +128,11 @@ async function cmdList(args: Array): Promise { console.log( `\n${result.packages.length} intent-enabled packages, ${totalSkills} skills (${result.packageManager})\n`, ) + if (scanCoverage) { + console.log( + `Scanned: ${scanCoverage}${result.nodeModules.global.scanned ? ' (local packages take precedence)' : ''}\n`, + ) + } // Summary table const rows = result.packages.map((pkg) => [ @@ -64,6 +143,8 @@ async function cmdList(args: Array): Promise { ]) printTable(['PACKAGE', 'VERSION', 'SKILLS', 'REQUIRES'], rows) + printVersionConflicts(result) + // Skills detail const allSkills = result.packages.map((p) => p.skills) const nameWidth = computeSkillNameWidth(allSkills) @@ -85,40 +166,33 @@ async function cmdList(args: Array): Promise { ) console.log() - if (result.warnings.length > 0) { - console.log(`Warnings:`) - for (const w of result.warnings) console.log(` ⚠ ${w}`) - } + printWarnings(result.warnings) } -function cmdMeta(args: Array): void { +async function cmdMeta(args: Array): Promise { + const { parseFrontmatter } = await import('./utils.js') const metaDir = getMetaDir() if (!existsSync(metaDir)) { - console.error('Meta-skills directory not found.') - process.exit(1) + fail('Meta-skills directory not found.') } if (args.length > 0) { const name = args[0]! if (name.includes('..') || name.includes('/') || name.includes('\\')) { - console.error(`Invalid meta-skill name: "${name}"`) - process.exit(1) + fail(`Invalid meta-skill name: "${name}"`) } const skillFile = join(metaDir, name, 'SKILL.md') if (!existsSync(skillFile)) { - console.error(`Meta-skill "${name}" not found.`) - console.error( - `Run \`npx @tanstack/intent meta\` to list available meta-skills.`, + fail( + `Meta-skill "${name}" not found. Run \`intent meta\` to list available meta-skills.`, ) - process.exit(1) } try { console.log(readFileSync(skillFile, 'utf8')) } catch (err) { const msg = err instanceof Error ? err.message : String(err) - console.error(`Failed to read meta-skill "${name}": ${msg}`) - process.exit(1) + fail(`Failed to read meta-skill "${name}": ${msg}`) } return } @@ -205,13 +279,89 @@ function collectPackagingWarnings(root: string): Array { return warnings } -function cmdValidate(args: Array): void { +function resolvePackageRoot(startDir: string): string { + let dir = startDir + + while (true) { + if (existsSync(join(dir, 'package.json'))) { + return dir + } + + const next = dirname(dir) + if (next === dir) { + return startDir + } + + dir = next + } +} + +function readPackageName(root: string): string { + try { + const pkgJson = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as { + name?: unknown + } + return typeof pkgJson.name === 'string' + ? pkgJson.name + : relative(process.cwd(), root) || 'unknown' + } catch { + return relative(process.cwd(), root) || 'unknown' + } +} + +async function resolveStaleTargets(targetDir?: string) { + const resolvedRoot = targetDir + ? join(process.cwd(), targetDir) + : process.cwd() + const { checkStaleness } = await import('./staleness.js') + + if (existsSync(join(resolvedRoot, 'skills'))) { + return { + reports: [ + await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)), + ], + } + } + + const { findPackagesWithSkills, findWorkspaceRoot } = + await import('./setup.js') + const workspaceRoot = findWorkspaceRoot(resolvedRoot) + if (workspaceRoot) { + const packageDirs = findPackagesWithSkills(workspaceRoot) + if (packageDirs.length > 0) { + return { + reports: await Promise.all( + packageDirs.map((packageDir) => + checkStaleness(packageDir, readPackageName(packageDir)), + ), + ), + } + } + } + + const staleResult = await scanIntentsOrFail() + return { + reports: await Promise.all( + staleResult.packages.map((pkg) => + checkStaleness(pkg.packageRoot, pkg.name), + ), + ), + } +} + +async function cmdValidate(args: Array): Promise { + const [{ parse: parseYaml }, { findSkillFiles }] = await Promise.all([ + import('yaml'), + import('./utils.js'), + ]) const targetDir = args[0] ?? 'skills' const skillsDir = join(process.cwd(), targetDir) + const packageRoot = resolvePackageRoot(skillsDir) if (!existsSync(skillsDir)) { - console.error(`Skills directory not found: ${skillsDir}`) - process.exit(1) + fail(`Skills directory not found: ${skillsDir}`) } interface ValidationError { @@ -223,8 +373,7 @@ function cmdValidate(args: Array): void { const skillFiles = findSkillFiles(skillsDir) if (skillFiles.length === 0) { - console.error('No SKILL.md files found') - process.exit(1) + fail('No SKILL.md files found') } for (const filePath of skillFiles) { @@ -335,25 +484,15 @@ function cmdValidate(args: Array): void { } } - const warnings = collectPackagingWarnings(process.cwd()) - - const printWarnings = (log: (...args: Array) => void): void => { - if (warnings.length === 0) return - log(`\n⚠ Packaging warnings:`) - for (const w of warnings) log(` ${w}`) - } + const warnings = collectPackagingWarnings(packageRoot) if (errors.length > 0) { - console.error(`\n❌ Validation failed with ${errors.length} error(s):\n`) - for (const { file, message } of errors) { - console.error(` ${file}: ${message}`) - } - printWarnings(console.error) - process.exit(1) + fail(buildValidationFailure(errors, warnings)) } console.log(`✅ Validated ${skillFiles.length} skill files — all passed`) - printWarnings(console.log) + if (warnings.length > 0) console.log() + printWarnings(warnings) } function cmdScaffold(): void { @@ -416,7 +555,7 @@ This produces: individual SKILL.md files. ## After all skills are generated -1. Run \`npx @tanstack/intent validate\` in each package directory +1. Run \`intent validate\` in each package directory 2. Commit skills/ and artifacts 3. For each publishable package, run: \`npx @tanstack/intent add-library-bin\` 4. For each publishable package, run: \`npx @tanstack/intent edit-package-json\` @@ -432,7 +571,7 @@ This produces: individual SKILL.md files. // Main // --------------------------------------------------------------------------- -const USAGE = `TanStack Intent CLI +export const USAGE = `TanStack Intent CLI Usage: intent list [--json] Discover intent-enabled packages @@ -443,143 +582,177 @@ Usage: intent add-library-bin Generate bin/intent.{js,mjs} bridge file intent edit-package-json Wire package.json (files, bin) for skill publishing intent setup-github-actions Copy CI workflow templates to .github/workflows/ - intent stale Check skills for staleness` - -const command = process.argv[2] -const commandArgs = process.argv.slice(3) - -switch (command) { - case 'list': - await cmdList(commandArgs) - break - case 'meta': - cmdMeta(commandArgs) - break - case 'validate': - cmdValidate(commandArgs) - break - case 'install': { - const prompt = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. - -Follow these steps in order: - -1. CHECK FOR EXISTING MAPPINGS - Search the project's agent config files (CLAUDE.md, AGENTS.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings and ask "What would you like to update?" - Then skip to step 4 with their requested changes. - - If not found: continue to step 2. - -2. DISCOVER AVAILABLE SKILLS - Run: intent list - This outputs each skill's name, description, and full path — grouped by package. - -3. SCAN THE REPOSITORY - Build a picture of the project's structure and patterns: - - Read package.json for library dependencies - - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) - - Note recurring patterns (routing, data fetching, auth, UI components, etc.) - - Based on this, propose 3–5 skill-to-task mappings. For each one explain: - - The task or code area (in plain language the user would recognise) - - Which skill applies and why - - Then ask: "What other tasks do you commonly use AI coding agents for? - I'll create mappings for those too." - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file - (prefer CLAUDE.md; create it if none exists) with this exact block: - - -# Skill mappings — when working in these areas, load the linked skill file into context. -skills: - - task: "describe the task or code area here" - load: "node_modules/package-name/skills/skill-name/SKILL.md" - - - Rules: - - Use the user's own words for task descriptions - - Include the exact path from \`intent list\` output so agents can load it directly - - Keep entries concise — this block is read on every agent task - - Preserve all content outside the block tags unchanged` - - console.log(prompt) - break - } - case 'scaffold': { - cmdScaffold() - break + intent stale [dir] [--json] Check skills for staleness` + +const HELP_BY_COMMAND: Record = { + list: `${USAGE} + +Examples: + intent list + intent list --json`, + meta: `intent meta [name] + +List shipped meta-skills, or print a single meta-skill by name. + +Examples: + intent meta + intent meta domain-discovery`, + validate: `intent validate [dir] + +Validate SKILL.md files in the target directory. + +Examples: + intent validate + intent validate packages/query/skills`, + install: `intent install + +Print the install prompt used to set up skill-to-task mappings.`, + scaffold: `intent scaffold + +Print the guided maintainer prompt for generating skills.`, + stale: `intent stale [dir] [--json] + +Check installed skills for version and source drift. + +Examples: + intent stale + intent stale packages/query + intent stale --json`, + 'add-library-bin': `intent add-library-bin + +Generate bin/intent.{js,mjs} bridge files for publishable packages.`, + 'edit-package-json': `intent edit-package-json + +Update package.json files so skills and shims are published.`, + 'setup-github-actions': `intent setup-github-actions + +Copy Intent CI workflow templates into .github/workflows/.`, +} + +function isHelpFlag(arg: string | undefined): boolean { + return arg === '-h' || arg === '--help' +} + +function printHelp(command?: string): void { + if (!command) { + console.log(`${USAGE} + +Run \`intent help \` for details on a specific command.`) + return } - case 'stale': { - const { checkStaleness } = await import('./staleness.js') - const { scanForIntents: scanStale } = await import('./scanner.js') - let staleResult - try { - staleResult = await scanStale() - } catch (err) { - console.error((err as Error).message) - process.exit(1) - } - if (staleResult.packages.length === 0) { - console.log('No intent-enabled packages found.') - break + console.log(HELP_BY_COMMAND[command] ?? USAGE) +} + +export async function main(argv: Array = process.argv.slice(2)) { + const command = argv[0] + const commandArgs = argv.slice(1) + + try { + if (!command || isHelpFlag(command)) { + printHelp() + return 0 } - const jsonStale = commandArgs.includes('--json') - const reports = await Promise.all( - staleResult.packages.map((pkg) => { - const pkgDir = join(process.cwd(), 'node_modules', pkg.name) - return checkStaleness(pkgDir, pkg.name) - }), - ) + if (command === 'help') { + printHelp(commandArgs[0]) + return 0 + } - if (jsonStale) { - console.log(JSON.stringify(reports, null, 2)) - break + if (isHelpFlag(commandArgs[0])) { + printHelp(command) + return 0 } - for (const report of reports) { - const driftLabel = report.versionDrift - ? ` [${report.versionDrift} drift]` - : '' - const vLabel = - report.skillVersion && report.currentVersion - ? ` (${report.skillVersion} → ${report.currentVersion})` - : '' - console.log(`${report.library}${vLabel}${driftLabel}`) - - const stale = report.skills.filter((s) => s.needsReview) - if (stale.length === 0) { - console.log(' All skills up-to-date') - } else { - for (const skill of stale) { - console.log(` ⚠ ${skill.name}: ${skill.reasons.join(', ')}`) + switch (command) { + case 'list': + await cmdList(commandArgs) + return 0 + case 'meta': + await cmdMeta(commandArgs) + return 0 + case 'validate': + await cmdValidate(commandArgs) + return 0 + case 'install': { + console.log(INSTALL_PROMPT) + return 0 + } + case 'scaffold': { + cmdScaffold() + return 0 + } + case 'stale': { + const jsonStale = commandArgs.includes('--json') + const targetDir = commandArgs.find((arg) => !arg.startsWith('-')) + const { reports } = await resolveStaleTargets(targetDir) + + if (reports.length === 0) { + console.log('No intent-enabled packages found.') + return 0 + } + + if (jsonStale) { + console.log(JSON.stringify(reports, null, 2)) + return 0 } + + for (const report of reports) { + const driftLabel = report.versionDrift + ? ` [${report.versionDrift} drift]` + : '' + const vLabel = + report.skillVersion && report.currentVersion + ? ` (${report.skillVersion} → ${report.currentVersion})` + : '' + console.log(`${report.library}${vLabel}${driftLabel}`) + + const stale = report.skills.filter((s) => s.needsReview) + if (stale.length === 0) { + console.log(' All skills up-to-date') + } else { + for (const skill of stale) { + console.log(` ⚠ ${skill.name}: ${skill.reasons.join(', ')}`) + } + } + console.log() + } + return 0 } - console.log() + case 'add-library-bin': { + const { runAddLibraryBinAll } = await import('./setup.js') + runAddLibraryBinAll(process.cwd()) + return 0 + } + case 'edit-package-json': { + const { runEditPackageJsonAll } = await import('./setup.js') + runEditPackageJsonAll(process.cwd()) + return 0 + } + case 'setup-github-actions': { + const { runSetupGithubActions } = await import('./setup.js') + runSetupGithubActions(process.cwd(), getMetaDir()) + return 0 + } + default: + printHelp() + return command ? 1 : 0 } - break - } - case 'add-library-bin': { - const { runAddLibraryBinAll } = await import('./setup.js') - runAddLibraryBinAll(process.cwd()) - break - } - case 'edit-package-json': { - const { runEditPackageJsonAll } = await import('./setup.js') - runEditPackageJsonAll(process.cwd()) - break - } - case 'setup-github-actions': { - const { runSetupGithubActions } = await import('./setup.js') - runSetupGithubActions(process.cwd(), getMetaDir()) - break + } catch (err) { + if (isCliFailure(err)) { + console.error(err.message) + return err.exitCode + } + + throw err } - default: - console.log(USAGE) - process.exit(command ? 1 : 0) +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href + +if (isMain) { + const exitCode = await main() + process.exit(exitCode) } diff --git a/packages/intent/src/feedback.ts b/packages/intent/src/feedback.ts index b39c93d..d1561e3 100644 --- a/packages/intent/src/feedback.ts +++ b/packages/intent/src/feedback.ts @@ -2,6 +2,7 @@ import { execFileSync, execSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { + FeedbackFrequency, FeedbackPayload, IntentProjectConfig, MetaFeedbackPayload, @@ -51,28 +52,40 @@ function getHomeConfigDir(): string { ) } -export function resolveFrequency(root: string): string { - // 1. User override (~/.config/intent/config.json) - const userConfigPath = join(getHomeConfigDir(), 'intent', 'config.json') +function parseFrequency(value: unknown): FeedbackFrequency | null { + if (value === 'always' || value === 'never') return value + if (typeof value !== 'string') return null + + const match = /^every-(\d+)$/.exec(value) + if (!match) return null + + const count = Number(match[1]) + return Number.isInteger(count) && count > 0 + ? (`every-${count}` as FeedbackFrequency) + : null +} + +function readFrequency(filePath: string): FeedbackFrequency | null { try { - const userCfg = JSON.parse( - readFileSync(userConfigPath, 'utf8'), + const config = JSON.parse( + readFileSync(filePath, 'utf8'), ) as Partial - if (userCfg.feedback?.frequency) return userCfg.feedback.frequency + return parseFrequency(config.feedback?.frequency) } catch { - /* fallback */ + return null } +} + +export function resolveFrequency(root: string): string { + // 1. User override (~/.config/intent/config.json) + const userConfigPath = join(getHomeConfigDir(), 'intent', 'config.json') + const userFrequency = readFrequency(userConfigPath) + if (userFrequency) return userFrequency // 2. Project config const projectConfigPath = join(root, 'intent.config.json') - try { - const projCfg = JSON.parse( - readFileSync(projectConfigPath, 'utf8'), - ) as Partial - if (projCfg.feedback?.frequency) return projCfg.feedback.frequency - } catch { - /* fallback */ - } + const projectFrequency = readFrequency(projectConfigPath) + if (projectFrequency) return projectFrequency // 3. Default return 'every-5' @@ -156,6 +169,13 @@ const VALID_META_SKILLS = [ const VALID_AGENTS = ['claude-code', 'cursor', 'copilot', 'codex', 'other'] const VALID_QUALITY_RATINGS = ['good', 'mixed', 'bad'] +const VALID_INTERVIEW_QUALITY_RATINGS = ['good', 'mixed', 'bad', 'skipped'] +const VALID_FAILURE_MODE_QUALITY_RATINGS = [ + 'good', + 'mixed', + 'bad', + 'not-applicable', +] export function validateMetaPayload(payload: unknown): { valid: boolean @@ -195,6 +215,24 @@ export function validateMetaPayload(payload: unknown): { errors.push('userRating must be one of: good, mixed, bad') } + if ( + obj.interviewQuality && + !VALID_INTERVIEW_QUALITY_RATINGS.includes(obj.interviewQuality as string) + ) { + errors.push('interviewQuality must be one of: good, mixed, bad, skipped') + } + + if ( + obj.failureModeQuality && + !VALID_FAILURE_MODE_QUALITY_RATINGS.includes( + obj.failureModeQuality as string, + ) + ) { + errors.push( + 'failureModeQuality must be one of: good, mixed, bad, not-applicable', + ) + } + // Secret scan const allText = Object.values(obj) .filter((v) => typeof v === 'string') diff --git a/packages/intent/src/install-prompt.ts b/packages/intent/src/install-prompt.ts new file mode 100644 index 0000000..6803823 --- /dev/null +++ b/packages/intent/src/install-prompt.ts @@ -0,0 +1,55 @@ +export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. + +Follow these steps in order: + +1. CHECK FOR EXISTING MAPPINGS + Search the project's agent config files (AGENTS.md, CLAUDE.md, .cursorrules, + .github/copilot-instructions.md) for a block delimited by: + + + - If found: show the user the current mappings, keep that file as the source of truth, + and ask "What would you like to update?" Then skip to step 4 with their requested changes. + - If not found: continue to step 2. + +2. DISCOVER AVAILABLE SKILLS + Run: \`npx @tanstack/intent@latest list\` + This outputs each skill's name, description, full path, and whether it was found in + project-local node_modules or accessible global node_modules. + This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop + with node_modules enabled). + +3. SCAN THE REPOSITORY + Build a picture of the project's structure and patterns: + - Read package.json for library dependencies + - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) + - Note recurring patterns (routing, data fetching, auth, UI components, etc.) + + Based on this, propose 3-5 skill-to-task mappings. For each one explain: + - The task or code area (in plain language the user would recognise) + - Which skill applies and why + + Then ask: "What other tasks do you commonly use AI coding agents for? + I'll create mappings for those too." + Also ask: "I'll default to AGENTS.md unless you want another supported config file. + Do you have a preference?" + +4. WRITE THE MAPPINGS BLOCK + Once you have the full set of mappings, write or update the agent config file. + - If you found an existing intent-skills block, update that file in place. + - Otherwise prefer AGENTS.md by default, unless the user asked for another supported file. + + Use this exact block: + + +# Skill mappings - when working in these areas, load the linked skill file into context. +skills: + - task: "describe the task or code area here" + load: "node_modules/package-name/skills/skill-name/SKILL.md" + + + Rules: + - Use the user's own words for task descriptions + - Include the exact path from \`npx @tanstack/intent@latest list\` output so agents can load it directly + - Keep entries concise - this block is read on every agent task + - Preserve all content outside the block tags unchanged + - If the user is on Deno, note that this setup is best-effort today and relies on npm interop` diff --git a/packages/intent/src/intent-library.ts b/packages/intent/src/intent-library.ts index 1c8961d..0fddfc6 100644 --- a/packages/intent/src/intent-library.ts +++ b/packages/intent/src/intent-library.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { computeSkillNameWidth, printSkillTree, printTable } from './display.js' +import { INSTALL_PROMPT } from './install-prompt.js' import { scanLibrary } from './library-scanner.js' import type { LibraryScanResult } from './library-scanner.js' @@ -70,54 +71,7 @@ async function cmdList(): Promise { } function cmdInstall(): void { - const prompt = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. - -Follow these steps in order: - -1. CHECK FOR EXISTING MAPPINGS - Search the project's agent config files (CLAUDE.md, AGENTS.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings and ask "What would you like to update?" - Then skip to step 4 with their requested changes. - - If not found: continue to step 2. - -2. DISCOVER AVAILABLE SKILLS - Run: intent list - This outputs each skill's name, description, and full path — grouped by package. - -3. SCAN THE REPOSITORY - Build a picture of the project's structure and patterns: - - Read package.json for library dependencies - - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) - - Note recurring patterns (routing, data fetching, auth, UI components, etc.) - - Based on this, propose 3–5 skill-to-task mappings. For each one explain: - - The task or code area (in plain language the user would recognise) - - Which skill applies and why - - Then ask: "What other tasks do you commonly use AI coding agents for? - I'll create mappings for those too." - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file - (prefer CLAUDE.md; create it if none exists) with this exact block: - - -# Skill mappings — when working in these areas, load the linked skill file into context. -skills: - - task: "describe the task or code area here" - load: "node_modules/package-name/skills/skill-name/SKILL.md" - - - Rules: - - Use the user's own words for task descriptions - - Include the exact path from \`intent list\` output so agents can load it directly - - Keep entries concise — this block is read on every agent task - - Preserve all content outside the block tags unchanged` - - console.log(prompt) + console.log(INSTALL_PROMPT) } // --------------------------------------------------------------------------- diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 56b172b..64070c3 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -1,11 +1,20 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join, relative, sep } from 'node:path' -import { getDeps, parseFrontmatter, resolveDepDir } from './utils.js' +import { + detectGlobalNodeModules, + getDeps, + listNodeModulesPackageDirs, + parseFrontmatter, + resolveDepDir, +} from './utils.js' import type { + InstalledVariant, IntentConfig, IntentPackage, + NodeModulesScanTarget, ScanResult, SkillEntry, + VersionConflict, } from './types.js' import type { Dirent } from 'node:fs' @@ -186,6 +195,115 @@ function topoSort(packages: Array): Array { return sorted } +function getPackageDepth(packageRoot: string, projectRoot: string): number { + return relative(projectRoot, packageRoot).split(sep).length +} + +interface ParsedSemver { + major: number + minor: number + patch: number + prerelease: Array +} + +function parseSemver(version: string): ParsedSemver | null { + const match = + /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version, + ) + if (!match) return null + + const prerelease = match[4] + ? match[4].split('.').map((identifier) => { + return /^\d+$/.test(identifier) ? Number(identifier) : identifier + }) + : [] + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease, + } +} + +function comparePrereleaseIdentifiers( + a: string | number | undefined, + b: string | number | undefined, +): number { + if (a === undefined) return b === undefined ? 0 : 1 + if (b === undefined) return -1 + + if (typeof a === 'number' && typeof b === 'number') { + return a - b + } + + if (typeof a === 'number') return -1 + if (typeof b === 'number') return 1 + + return a.localeCompare(b) +} + +function comparePackageVersions(a: string, b: string): number { + const parsedA = parseSemver(a) + const parsedB = parseSemver(b) + + if (!parsedA || !parsedB) { + if (parsedA) return 1 + if (parsedB) return -1 + return 0 + } + + for (const key of ['major', 'minor', 'patch'] as const) { + const diff = parsedA[key] - parsedB[key] + if (diff !== 0) return diff + } + + const length = Math.max(parsedA.prerelease.length, parsedB.prerelease.length) + for (let i = 0; i < length; i++) { + const diff = comparePrereleaseIdentifiers( + parsedA.prerelease[i], + parsedB.prerelease[i], + ) + if (diff !== 0) return diff + } + + return 0 +} + +function formatVariantWarning( + name: string, + variants: Array, + chosen: IntentPackage, +): string | null { + const uniqueVersions = new Set(variants.map((variant) => variant.version)) + if (uniqueVersions.size <= 1) return null + + const details = variants + .map((variant) => `${variant.version} at ${variant.packageRoot}`) + .join(', ') + + return `Found ${variants.length} installed variants of ${name} across ${uniqueVersions.size} versions (${details}). Using ${chosen.version} from ${chosen.packageRoot}.` +} + +function toVersionConflict( + packageName: string, + variants: Array, + chosen: IntentPackage, +): VersionConflict | null { + const uniqueVersions = new Set(variants.map((variant) => variant.version)) + if (uniqueVersions.size <= 1) return null + + return { + packageName, + chosen: { + version: chosen.version, + packageRoot: chosen.packageRoot, + }, + variants, + } +} + // --------------------------------------------------------------------------- // Main scanner // --------------------------------------------------------------------------- @@ -194,53 +312,99 @@ export async function scanForIntents(root?: string): Promise { const projectRoot = root ?? process.cwd() const packageManager = detectPackageManager(projectRoot) const nodeModulesDir = join(projectRoot, 'node_modules') + const explicitGlobalNodeModules = + process.env.INTENT_GLOBAL_NODE_MODULES?.trim() || null const packages: Array = [] const warnings: Array = [] + const conflicts: Array = [] + const nodeModules: ScanResult['nodeModules'] = { + local: { + path: nodeModulesDir, + detected: true, + exists: existsSync(nodeModulesDir), + scanned: false, + }, + global: { + path: explicitGlobalNodeModules, + detected: Boolean(explicitGlobalNodeModules), + exists: explicitGlobalNodeModules + ? existsSync(explicitGlobalNodeModules) + : false, + scanned: false, + source: explicitGlobalNodeModules + ? 'INTENT_GLOBAL_NODE_MODULES' + : undefined, + }, + } + const resolutionRoots = [nodeModulesDir] - if (!existsSync(nodeModulesDir)) { - return { packageManager, packages, warnings } + // Track registered package names to avoid duplicates across phases + const packageIndexes = new Map() + const packageJsonCache = new Map | null>() + const packageVariants = new Map< + string, + Map + >() + + function rememberVariant(pkg: IntentPackage): void { + let variants = packageVariants.get(pkg.name) + if (!variants) { + variants = new Map() + packageVariants.set(pkg.name, variants) + } + variants.set(pkg.packageRoot, { + version: pkg.version, + packageRoot: pkg.packageRoot, + }) } - // Collect all package directories to check - const packageDirs: Array<{ dirPath: string }> = [] + function ensureGlobalNodeModules(): void { + if (!nodeModules.global.path && !explicitGlobalNodeModules) { + const detected = detectGlobalNodeModules(packageManager) + nodeModules.global.path = detected.path + nodeModules.global.source = detected.source + nodeModules.global.detected = Boolean(detected.path) + nodeModules.global.exists = detected.path + ? existsSync(detected.path) + : false + } - let topEntries: Array> - try { - topEntries = readdirSync(nodeModulesDir, { - withFileTypes: true, - encoding: 'utf8', - }) - } catch { - return { packageManager, packages, warnings } + if ( + nodeModules.global.exists && + nodeModules.global.path && + nodeModules.global.path !== nodeModulesDir && + !resolutionRoots.includes(nodeModules.global.path) + ) { + resolutionRoots.push(nodeModules.global.path) + } } - for (const entry of topEntries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue - const dirPath = join(nodeModulesDir, entry.name) - - if (entry.name.startsWith('@')) { - // Scoped package — check children - let scopedEntries: Array> - try { - scopedEntries = readdirSync(dirPath, { - withFileTypes: true, - encoding: 'utf8', - }) - } catch { - continue - } - for (const scoped of scopedEntries) { - if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue - packageDirs.push({ dirPath: join(dirPath, scoped.name) }) - } - } else if (!entry.name.startsWith('.')) { - packageDirs.push({ dirPath }) + function readPkgJson(dirPath: string): Record | null { + if (packageJsonCache.has(dirPath)) { + return packageJsonCache.get(dirPath) ?? null + } + + try { + const pkgJson = JSON.parse( + readFileSync(join(dirPath, 'package.json'), 'utf8'), + ) as Record + packageJsonCache.set(dirPath, pkgJson) + return pkgJson + } catch { + packageJsonCache.set(dirPath, null) + return null } } - // Track registered package names to avoid duplicates across phases - const foundNames = new Set() + function scanTarget(target: NodeModulesScanTarget): void { + if (!target.path || !target.exists || target.scanned) return + target.scanned = true + + for (const dirPath of listNodeModulesPackageDirs(target.path)) { + tryRegister(dirPath, 'unknown') + } + } /** * Try to register a package with a skills/ directory. Reads its @@ -251,17 +415,15 @@ export async function scanForIntents(root?: string): Promise { const skillsDir = join(dirPath, 'skills') if (!existsSync(skillsDir)) return false - let pkgJson: Record - try { - pkgJson = JSON.parse(readFileSync(join(dirPath, 'package.json'), 'utf8')) - } catch { + const pkgJson = readPkgJson(dirPath) + if (!pkgJson) { warnings.push(`Could not read package.json for ${dirPath}`) return false } const name = typeof pkgJson.name === 'string' ? pkgJson.name : fallbackName - if (foundNames.has(name)) return false - + const version = + typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0' const intent = validateIntentField(name, pkgJson.intent) ?? deriveIntentConfig(pkgJson) if (!intent) { @@ -271,20 +433,44 @@ export async function scanForIntents(root?: string): Promise { return false } - packages.push({ + const candidate: IntentPackage = { name, - version: typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0', + version, intent, skills: discoverSkills(skillsDir, name), - }) - foundNames.add(name) + packageRoot: dirPath, + } + const existingIndex = packageIndexes.get(name) + if (existingIndex === undefined) { + rememberVariant(candidate) + packageIndexes.set(name, packages.push(candidate) - 1) + return true + } + + const existing = packages[existingIndex]! + if (existing.packageRoot === candidate.packageRoot) { + return false + } + + rememberVariant(existing) + rememberVariant(candidate) + + const existingDepth = getPackageDepth(existing.packageRoot, projectRoot) + const candidateDepth = getPackageDepth(candidate.packageRoot, projectRoot) + const shouldReplace = + candidateDepth < existingDepth || + (candidateDepth === existingDepth && + comparePackageVersions(candidate.version, existing.version) > 0) + + if (shouldReplace) { + packages[existingIndex] = candidate + } + return true } - // Phase 1: Check each top-level package for skills/ - for (const { dirPath } of packageDirs) { - tryRegister(dirPath, 'unknown') - } + // Phase 1: Check local top-level packages for skills/ + scanTarget(nodeModules.local) // Phase 2: Walk dependency trees to discover transitive deps with skills. // This handles pnpm and other non-hoisted layouts where transitive deps @@ -292,13 +478,11 @@ export async function scanForIntents(root?: string): Promise { const walkVisited = new Set() function walkDeps(pkgDir: string, pkgName: string): void { - if (walkVisited.has(pkgName)) return - walkVisited.add(pkgName) + if (walkVisited.has(pkgDir)) return + walkVisited.add(pkgDir) - let pkgJson: Record - try { - pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) - } catch { + const pkgJson = readPkgJson(pkgDir) + if (!pkgJson) { warnings.push( `Could not read package.json for ${pkgName} (skipping dependency walk)`, ) @@ -306,52 +490,89 @@ export async function scanForIntents(root?: string): Promise { } for (const depName of getDeps(pkgJson)) { - if (foundNames.has(depName) || walkVisited.has(depName)) continue - - const depDir = resolveDepDir(depName, pkgDir, pkgName, nodeModulesDir) - if (!depDir) continue + const depDir = resolveDepDir(depName, pkgDir, pkgName, resolutionRoots) + if (!depDir || walkVisited.has(depDir)) continue tryRegister(depDir, depName) walkDeps(depDir, depName) } } - // Walk from packages found in Phase 1 - for (const pkg of [...packages]) { - walkDeps(join(nodeModulesDir, pkg.name), pkg.name) + function walkKnownPackages(): void { + for (const pkg of [...packages]) { + walkDeps(pkg.packageRoot, pkg.name) + } } - // Walk from project's direct deps that weren't found in Phase 1 - let projectPkg: Record | null = null - try { - projectPkg = JSON.parse( - readFileSync(join(projectRoot, 'package.json'), 'utf8'), - ) - } catch (err: unknown) { - const isNotFound = - err && - typeof err === 'object' && - 'code' in err && - (err as NodeJS.ErrnoException).code === 'ENOENT' - if (!isNotFound) { - warnings.push( - `Could not read project package.json: ${err instanceof Error ? err.message : String(err)}`, - ) + function walkProjectDeps(): void { + let projectPkg: Record | null = null + try { + projectPkg = JSON.parse( + readFileSync(join(projectRoot, 'package.json'), 'utf8'), + ) as Record + } catch (err: unknown) { + const isNotFound = + err && + typeof err === 'object' && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!isNotFound) { + warnings.push( + `Could not read project package.json: ${err instanceof Error ? err.message : String(err)}`, + ) + } } - } - if (projectPkg) { + if (!projectPkg) return + for (const depName of getDeps(projectPkg, true)) { - if (walkVisited.has(depName)) continue - const depDir = join(nodeModulesDir, depName) - if (existsSync(join(depDir, 'package.json'))) { + const depDir = resolveDepDir( + depName, + projectRoot, + depName, + resolutionRoots, + ) + if (depDir && !walkVisited.has(depDir)) { walkDeps(depDir, depName) } } } + walkKnownPackages() + walkProjectDeps() + + if ( + explicitGlobalNodeModules || + packages.length === 0 || + !nodeModules.local.exists + ) { + ensureGlobalNodeModules() + scanTarget(nodeModules.global) + walkKnownPackages() + walkProjectDeps() + } + + if (!nodeModules.local.exists && !nodeModules.global.exists) { + return { packageManager, packages, warnings, conflicts, nodeModules } + } + + for (const pkg of packages) { + const variants = packageVariants.get(pkg.name) + if (!variants) continue + + const conflict = toVersionConflict(pkg.name, [...variants.values()], pkg) + if (conflict) { + conflicts.push(conflict) + } + + const warning = formatVariantWarning(pkg.name, [...variants.values()], pkg) + if (warning) { + warnings.push(warning) + } + } + // Sort by dependency order const sorted = topoSort(packages) - return { packageManager, packages: sorted, warnings } + return { packageManager, packages: sorted, warnings, conflicts, nodeModules } } diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 7bbac1b..dd99431 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -5,7 +5,7 @@ import { readdirSync, writeFileSync, } from 'node:fs' -import { join, relative } from 'node:path' +import { basename, join, relative } from 'node:path' import { parse as parseYaml } from 'yaml' import { findSkillFiles } from './utils.js' @@ -35,20 +35,22 @@ export interface MonorepoResult { interface TemplateVars { PACKAGE_NAME: string + PACKAGE_LABEL: string + PAYLOAD_PACKAGE: string REPO: string DOCS_PATH: string SRC_PATH: string + WATCH_PATHS: string } // --------------------------------------------------------------------------- // Variable detection from package.json // --------------------------------------------------------------------------- -function detectVars(root: string): TemplateVars { +function readPackageJson(root: string): Record { const pkgPath = join(root, 'package.json') - let pkgJson: Record = {} try { - pkgJson = JSON.parse(readFileSync(pkgPath, 'utf8')) + return JSON.parse(readFileSync(pkgPath, 'utf8')) as Record } catch (err: unknown) { const isNotFound = err && @@ -60,17 +62,88 @@ function detectVars(root: string): TemplateVars { `Warning: could not read ${pkgPath}: ${err instanceof Error ? err.message : err}`, ) } + return {} } +} - const name = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' +function detectRepo( + pkgJson: Record, + fallback: string, +): string { const intent = pkgJson.intent as Record | undefined + if (typeof intent?.repo === 'string') { + return intent.repo + } - const repo = - typeof intent?.repo === 'string' - ? intent.repo - : name.replace(/^@/, '').replace(/\//, '/') + if (typeof pkgJson.repository === 'string') { + return pkgJson.repository + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^https?:\/\/github\.com\//, '') + } - const docs = typeof intent?.docs === 'string' ? intent.docs : 'docs/' + if ( + pkgJson.repository && + typeof pkgJson.repository === 'object' && + typeof (pkgJson.repository as Record).url === 'string' + ) { + return ((pkgJson.repository as Record).url as string) + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^https?:\/\/github\.com\//, '') + } + + return fallback +} + +function normalizePattern(pattern: string): string { + return pattern.endsWith('**') ? pattern : pattern.replace(/\/$/, '') + '/**' +} + +function buildWatchPaths(root: string, packageDirs: Array): string { + const paths = new Set() + + if (existsSync(join(root, 'docs'))) { + paths.add('docs/**') + } + + for (const packageDir of packageDirs) { + const relDir = relative(root, packageDir).split('\\').join('/') + if (existsSync(join(packageDir, 'src'))) { + paths.add(`${relDir}/src/**`) + } + + const pkgJson = readPackageJson(packageDir) + const intent = pkgJson.intent as Record | undefined + const docs = typeof intent?.docs === 'string' ? intent.docs : 'docs/' + if (!docs.startsWith('http://') && !docs.startsWith('https://')) { + paths.add(normalizePattern(join(relDir, docs).split('\\').join('/'))) + } + } + + if (paths.size === 0) { + paths.add('packages/*/src/**') + paths.add('packages/*/docs/**') + } + + return [...paths] + .sort() + .map((path) => ` - '${path}'`) + .join('\n') +} + +function detectVars(root: string, packageDirs?: Array): TemplateVars { + const pkgJson = readPackageJson(root) + const name = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' + const docs = + typeof (pkgJson.intent as Record | undefined)?.docs === + 'string' + ? ((pkgJson.intent as Record).docs as string) + : 'docs/' + const repo = detectRepo(pkgJson, name.replace(/^@/, '').replace(/\//, '/')) + const isMonorepo = packageDirs !== undefined + const packageLabel = + isMonorepo && name === 'unknown' ? `${basename(root)} workspace` : name // Best-guess src path from common monorepo patterns const shortName = name.replace(/^@[^/]+\//, '') @@ -81,9 +154,14 @@ function detectVars(root: string): TemplateVars { return { PACKAGE_NAME: name, + PACKAGE_LABEL: packageLabel, + PAYLOAD_PACKAGE: packageLabel, REPO: repo, DOCS_PATH: docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**', SRC_PATH: srcPath, + WATCH_PATHS: isMonorepo + ? buildWatchPaths(root, packageDirs) + : ` - '${docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**'}'\n - '${srcPath}'`, } } @@ -94,9 +172,12 @@ function detectVars(root: string): TemplateVars { function applyVars(content: string, vars: TemplateVars): string { return content .replace(/\{\{PACKAGE_NAME\}\}/g, vars.PACKAGE_NAME) + .replace(/\{\{PACKAGE_LABEL\}\}/g, vars.PACKAGE_LABEL) + .replace(/\{\{PAYLOAD_PACKAGE\}\}/g, vars.PAYLOAD_PACKAGE) .replace(/\{\{REPO\}\}/g, vars.REPO) .replace(/\{\{DOCS_PATH\}\}/g, vars.DOCS_PATH) .replace(/\{\{SRC_PATH\}\}/g, vars.SRC_PATH) + .replace(/\{\{WATCH_PATHS\}\}/g, vars.WATCH_PATHS) } // --------------------------------------------------------------------------- @@ -124,7 +205,13 @@ function copyTemplates( continue } - const content = readFileSync(srcPath, 'utf8') + let content = readFileSync(srcPath, 'utf8') + if (vars.WATCH_PATHS.includes('\n')) { + content = content.replace( + /\s+- '?\{\{DOCS_PATH\}\}'?\n\s+- '?\{\{SRC_PATH\}\}'?/, + vars.WATCH_PATHS, + ) + } const substituted = applyVars(content, vars) writeFileSync(destPath, substituted) copied.push(destPath) @@ -348,7 +435,7 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { // Monorepo workspace resolution // --------------------------------------------------------------------------- -function readWorkspacePatterns(root: string): Array | null { +export function readWorkspacePatterns(root: string): Array | null { // pnpm-workspace.yaml const pnpmWs = join(root, 'pnpm-workspace.yaml') if (existsSync(pnpmWs)) { @@ -453,10 +540,24 @@ function collectPackageDirs(dir: string, result: Array): void { } } +export function findWorkspaceRoot(start: string): string | null { + let dir = start + + while (true) { + if (readWorkspacePatterns(dir)) { + return dir + } + + const next = join(dir, '..') + if (next === dir) return null + dir = next + } +} + /** * Find workspace packages that contain at least one SKILL.md file. */ -function findPackagesWithSkills(root: string): Array { +export function findPackagesWithSkills(root: string): Array { const patterns = readWorkspacePatterns(root) if (!patterns) return [] @@ -519,11 +620,16 @@ export function runSetupGithubActions( root: string, metaDir: string, ): SetupGithubActionsResult { - const vars = detectVars(root) + const workspaceRoot = findWorkspaceRoot(root) ?? root + const packageDirs = findPackagesWithSkills(workspaceRoot) + const vars = detectVars( + workspaceRoot, + packageDirs.length > 0 ? packageDirs : undefined, + ) const result: SetupGithubActionsResult = { workflows: [], skipped: [] } const srcDir = join(metaDir, 'templates', 'workflows') - const destDir = join(root, '.github', 'workflows') + const destDir = join(workspaceRoot, '.github', 'workflows') const { copied, skipped } = copyTemplates(srcDir, destDir, vars) result.workflows = copied result.skipped = skipped @@ -535,10 +641,11 @@ export function runSetupGithubActions( console.log('No templates directory found. Is @tanstack/intent installed?') } else if (result.workflows.length > 0) { console.log(`\nTemplate variables applied:`) - console.log(` Package: ${vars.PACKAGE_NAME}`) + console.log(` Package: ${vars.PACKAGE_LABEL}`) console.log(` Repo: ${vars.REPO}`) - console.log(` Docs: ${vars.DOCS_PATH}`) - console.log(` Src: ${vars.SRC_PATH}`) + console.log( + ` Mode: ${packageDirs.length > 0 ? `monorepo (${packageDirs.length} packages with skills)` : 'single package'}`, + ) } return result diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 60209f4..679c2ec 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -59,10 +59,50 @@ interface SyncState { skills?: Record }> } +function isStringRecord(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === 'string') + ) +} + +function parseSyncState(value: unknown): SyncState | null { + if (!value || typeof value !== 'object') return null + + const raw = value as Record + const parsed: SyncState = {} + + if (typeof raw.library_version === 'string') { + parsed.library_version = raw.library_version + } + + if (raw.skills && typeof raw.skills === 'object') { + const skills: Record }> = {} + + for (const [skillName, skillValue] of Object.entries(raw.skills)) { + if (!skillValue || typeof skillValue !== 'object') continue + + const sourcesSha = (skillValue as Record).sources_sha + if (sourcesSha !== undefined && !isStringRecord(sourcesSha)) continue + + skills[skillName] = {} + if (sourcesSha) { + skills[skillName]!.sources_sha = sourcesSha + } + } + + parsed.skills = skills + } + + return parsed +} + function readSyncState(packageDir: string): SyncState | null { const statePath = join(packageDir, 'skills', 'sync-state.json') try { - return JSON.parse(readFileSync(statePath, 'utf8')) as SyncState + return parseSyncState(JSON.parse(readFileSync(statePath, 'utf8'))) } catch { return null } diff --git a/packages/intent/src/types.ts b/packages/intent/src/types.ts index b2ab9a8..f7ef756 100644 --- a/packages/intent/src/types.ts +++ b/packages/intent/src/types.ts @@ -17,6 +17,19 @@ export interface ScanResult { packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'unknown' packages: Array warnings: Array + conflicts: Array + nodeModules: { + local: NodeModulesScanTarget + global: NodeModulesScanTarget + } +} + +export interface NodeModulesScanTarget { + path: string | null + detected: boolean + exists: boolean + scanned: boolean + source?: string } export interface IntentPackage { @@ -24,6 +37,18 @@ export interface IntentPackage { version: string intent: IntentConfig skills: Array + packageRoot: string +} + +export interface InstalledVariant { + version: string + packageRoot: string +} + +export interface VersionConflict { + packageName: string + chosen: InstalledVariant + variants: Array } export interface SkillEntry { @@ -94,12 +119,14 @@ export interface MetaFeedbackPayload { userRating: 'good' | 'mixed' | 'bad' } +export type FeedbackFrequency = 'always' | 'never' | `every-${number}` + // --------------------------------------------------------------------------- // Config types // --------------------------------------------------------------------------- export interface IntentProjectConfig { feedback: { - frequency: string // "always" | "every-N" | "never" + frequency: FeedbackFrequency } } diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index ddd9029..d727ddd 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -1,4 +1,11 @@ -import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { + existsSync, + readFileSync, + readdirSync, + realpathSync, + type Dirent, +} from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' @@ -42,6 +49,100 @@ export function getDeps( return [...deps] } +export function listNodeModulesPackageDirs( + nodeModulesDir: string, +): Array { + if (!existsSync(nodeModulesDir)) return [] + + let topEntries: Array> + try { + topEntries = readdirSync(nodeModulesDir, { + withFileTypes: true, + encoding: 'utf8', + }) + } catch { + return [] + } + + const packageDirs: Array = [] + + for (const entry of topEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue + const dirPath = join(nodeModulesDir, entry.name) + + if (entry.name.startsWith('@')) { + let scopedEntries: Array> + try { + scopedEntries = readdirSync(dirPath, { + withFileTypes: true, + encoding: 'utf8', + }) + } catch { + continue + } + + for (const scoped of scopedEntries) { + if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue + packageDirs.push(join(dirPath, scoped.name)) + } + } else if (!entry.name.startsWith('.')) { + packageDirs.push(dirPath) + } + } + + return packageDirs +} + +export function detectGlobalNodeModules(packageManager: string): { + path: string | null + source?: string +} { + const envPath = process.env.INTENT_GLOBAL_NODE_MODULES?.trim() + if (envPath) { + return { + path: envPath, + source: 'INTENT_GLOBAL_NODE_MODULES', + } + } + + const commands: Array<{ + command: string + args: Array + transform?: (output: string) => string + }> = [] + + if (packageManager === 'pnpm') { + commands.push({ command: 'pnpm', args: ['root', '-g'] }) + } + if (packageManager === 'yarn') { + commands.push({ + command: 'yarn', + args: ['global', 'dir'], + transform: (output) => join(output, 'node_modules'), + }) + } + commands.push({ command: 'npm', args: ['root', '-g'] }) + + for (const candidate of commands) { + try { + const output = execFileSync(candidate.command, candidate.args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + if (!output) continue + + return { + path: candidate.transform ? candidate.transform(output) : output, + source: `${candidate.command} ${candidate.args.join(' ')}`, + } + } catch { + continue + } + } + + return { path: null } +} + /** * Resolve the directory of a dependency by name. First checks the top-level * node_modules (hoisted layout — npm, yarn, bun), then resolves through the @@ -52,15 +153,27 @@ export function resolveDepDir( depName: string, parentDir: string, parentName: string, - nodeModulesDir: string, + nodeModulesDirs: string | Array, ): string | null { if (!parentName) return null + const roots = Array.isArray(nodeModulesDirs) + ? nodeModulesDirs + : [nodeModulesDirs] + // 1. Top-level (hoisted) - const topLevel = join(nodeModulesDir, depName) - if (existsSync(join(topLevel, 'package.json'))) return topLevel + for (const nodeModulesDir of roots) { + const topLevel = join(nodeModulesDir, depName) + if (existsSync(join(topLevel, 'package.json'))) return topLevel + } + + // 2. Resolve nested installs under the parent package (npm/pnpm/bun) + const nestedNodeModules = join(parentDir, 'node_modules', depName) + if (existsSync(join(nestedNodeModules, 'package.json'))) { + return nestedNodeModules + } - // 2. Resolve through parent's real path (pnpm virtual store) + // 3. Resolve through parent's real path (pnpm virtual store) try { const realParent = realpathSync(parentDir) const segments = parentName.split('/').length diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 7822d35..5205c1d 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1,94 +1,378 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { parse as parseYaml } from 'yaml' - -// ── Meta-skills tests (intent meta) ── +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { INSTALL_PROMPT } from '../src/install-prompt.js' +import { main, USAGE } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') +const packageJsonPath = join(thisDir, '..', 'package.json') -describe('intent meta', () => { - it('meta directory exists', () => { - expect(existsSync(metaDir)).toBe(true) - }) - - it('contains expected meta-skills', () => { - const entries = readdirSync(metaDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .filter((e) => existsSync(join(metaDir, e.name, 'SKILL.md'))) - .map((e) => e.name) - - expect(entries).toContain('domain-discovery') - expect(entries).toContain('tree-generator') - expect(entries).toContain('generate-skill') - expect(entries).toContain('skill-staleness-check') - }) - - it('each meta-skill has a description in frontmatter', () => { - const entries = readdirSync(metaDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .filter((e) => existsSync(join(metaDir, e.name, 'SKILL.md'))) - - for (const entry of entries) { - const content = readFileSync( - join(metaDir, entry.name, 'SKILL.md'), - 'utf8', - ) - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - expect(match, `${entry.name} should have frontmatter`).not.toBeNull() - - const fm = parseYaml(match![1]!) as Record - expect( - fm.description, - `${entry.name} should have a description`, - ).toBeTruthy() +function writeJson(filePath: string, data: unknown): void { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + +function writeSkillMd(dir: string, frontmatter: Record): void { + mkdirSync(dir, { recursive: true }) + const yamlLines = Object.entries(frontmatter) + .map( + ([key, value]) => + `${key}: ${typeof value === 'string' ? `"${value}"` : value}`, + ) + .join('\n') + + writeFileSync( + join(dir, 'SKILL.md'), + `---\n${yamlLines}\n---\n\nSkill content here.\n`, + ) +} + +let originalCwd: string +let logSpy: ReturnType +let errorSpy: ReturnType +let tempDirs: Array + +beforeEach(() => { + originalCwd = process.cwd() + tempDirs = [] + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + process.chdir(originalCwd) + logSpy.mockRestore() + errorSpy.mockRestore() + for (const dir of tempDirs) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }) } + } +}) + +describe('intent meta', () => { + it('lists the shipped public meta-skills', async () => { + const exitCode = await main(['meta']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('Meta-skills') + expect(output).toContain('domain-discovery') + expect(output).toContain('tree-generator') + expect(output).toContain('generate-skill') + expect(output).toContain('skill-staleness-check') + }) + + it('prints the requested meta-skill content', async () => { + const expected = readFileSync( + join(metaDir, 'domain-discovery', 'SKILL.md'), + 'utf8', + ) + + const exitCode = await main(['meta', 'domain-discovery']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith(expected) + }) + + it('fails cleanly for invalid meta-skill names', async () => { + const exitCode = await main(['meta', '../bad']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Invalid meta-skill name: "../bad"') + }) + + it('fails cleanly when a meta-skill does not exist', async () => { + const exitCode = await main(['meta', 'missing-skill']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Meta-skill "missing-skill" not found. Run `intent meta` to list available meta-skills.', + ) }) }) -// ── Validate command logic ── - -describe('intent validate', () => { - it('finds SKILL.md files in meta directory', () => { - function findSkillFiles(dir: string): Array { - const files: Array = [] - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - files.push(...findSkillFiles(fullPath)) - } else if (entry.name === 'SKILL.md') { - files.push(fullPath) - } - } - return files +describe('cli commands', () => { + it('prints top-level help when no command is provided', async () => { + const exitCode = await main([]) + + expect(exitCode).toBe(0) + expect(logSpy.mock.calls[0]?.[0]).toContain(USAGE) + expect(logSpy.mock.calls[0]?.[0]).toContain('Run `intent help `') + }) + + it('prints top-level help for --help', async () => { + const exitCode = await main(['--help']) + + expect(exitCode).toBe(0) + expect(logSpy.mock.calls[0]?.[0]).toContain('Run `intent help `') + }) + + it('prints command help for help subcommands', async () => { + const exitCode = await main(['help', 'validate']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('intent validate [dir]'), + ) + }) + + it('prints command help when --help is passed after a subcommand', async () => { + const exitCode = await main(['list', '--help']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('intent list --json'), + ) + }) + + it('prints the install prompt', async () => { + const exitCode = await main(['install']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) + }) + + it('lists installed intent packages as json', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-list-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'db') + + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/db', + version: '0.5.2', + intent: { version: 1, repo: 'TanStack/db', docs: 'docs/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'db-core'), { + name: 'db-core', + description: 'Core database concepts', + }) + + process.chdir(root) + + const exitCode = await main(['list', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const parsed = JSON.parse(String(output)) as { + packages: Array<{ name: string; version: string; packageRoot: string }> + conflicts: Array<{ packageName: string }> + warnings: Array } - const files = findSkillFiles(metaDir) - expect(files.length).toBeGreaterThan(0) + expect(exitCode).toBe(0) + expect(parsed.packages).toHaveLength(1) + expect(parsed.packages[0]).toMatchObject({ + name: '@tanstack/db', + version: '0.5.2', + packageRoot: pkgDir, + }) + expect(parsed.conflicts).toEqual([]) + expect(parsed.warnings).toEqual([]) + }) + + it('explains which package version was chosen when conflicts exist', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-conflicts-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = join(root, 'node_modules', 'consumer-a') + const consumerBDir = join(root, 'node_modules', 'consumer-b') + const queryV4Dir = join(consumerADir, 'node_modules', '@tanstack', 'query') + const queryV5Dir = join(consumerBDir, 'node_modules', '@tanstack', 'query') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': '4.0.0' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + writeJson(join(queryV4Dir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(queryV5Dir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(queryV4Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v4 skill', + }) + writeSkillMd(join(queryV5Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v5 skill', + }) + + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('Version conflicts:') + expect(output).toContain('@tanstack/query -> using 5.0.0') + expect(output).toContain(`chosen: ${queryV5Dir}`) + expect(output).toContain(`also found: 4.0.0 at ${queryV4Dir}`) + }) + + it('validates a well-formed skills directory', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-validate-')) + tempDirs.push(root) + + writeSkillMd(join(root, 'skills', 'db-core'), { + name: 'db-core', + description: 'Core database concepts', + }) + + process.chdir(root) + + const exitCode = await main(['validate']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith( + '✅ Validated 1 skill files — all passed', + ) + }) + + it('validates package skills from repo root without root packaging warnings', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-validate-mono-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + devDependencies: { '@tanstack/intent': '^0.0.18' }, + bin: { intent: './bin/intent.js' }, + files: ['skills', 'bin', '!skills/_artifacts'], + }) + mkdirSync(join(root, 'packages', 'router', 'bin'), { recursive: true }) + writeFileSync(join(root, 'packages', 'router', 'bin', 'intent.js'), '') + writeSkillMd(join(root, 'packages', 'router', 'skills', 'db-core'), { + name: 'db-core', + description: 'Core database concepts', + }) + + process.chdir(root) + + const exitCode = await main(['validate', 'packages/router/skills']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(output).not.toContain('@tanstack/intent is not in devDependencies') + }) + + it('fails cleanly when validate is run without a skills directory', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-missing-skills-')) + tempDirs.push(root) + process.chdir(root) + + const exitCode = await main(['validate']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + `Skills directory not found: ${join(root, 'skills')}`, + ) + }) + + it('fails cleanly for unsupported yarn pnp projects', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-pnp-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + process.chdir(root) + + const exitCode = await main(['list']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Yarn PnP is not yet supported. Add `nodeLinker: node-modules` to your .yarnrc.yml to use intent.', + ) + }) + + it('fails cleanly for deno projects without node_modules', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-deno-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + writeFileSync(join(root, 'deno.json'), '{"nodeModulesDir":"none"}\n') + process.chdir(root) + + const exitCode = await main(['list']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Deno without node_modules is not yet supported. Add `"nodeModulesDir": "auto"` to your deno.json to use intent.', + ) + }) + + it('checks workspace packages for staleness from the monorepo root', async () => { + const root = mkdtempSync(join(tmpdir(), 'intent-cli-stale-mono-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + library_version: '1.0.0', + }) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.0.0' }), + } as Response) + + process.chdir(root) + + const exitCode = await main(['stale', '--json']) + const output = logSpy.mock.calls.at(-1)?.[0] + const reports = JSON.parse(String(output)) as Array<{ library: string }> + + expect(exitCode).toBe(0) + expect(reports).toHaveLength(1) + expect(reports[0]!.library).toBe('@tanstack/router') + + fetchSpy.mockRestore() }) }) -// ── Scanner JSON output shape ── - -describe('intent list --json shape', () => { - it('scanForIntents returns correct shape', async () => { - const { scanForIntents } = await import('../src/scanner.js') - // Run against a dir with no node_modules — should return valid shape - const { mkdtempSync } = await import('node:fs') - const { tmpdir } = await import('node:os') - const root = mkdtempSync(join(tmpdir(), 'cli-test-')) - - const result = await scanForIntents(root) - expect(result).toHaveProperty('packageManager') - expect(result).toHaveProperty('packages') - expect(result).toHaveProperty('warnings') - expect(Array.isArray(result.packages)).toBe(true) - expect(Array.isArray(result.warnings)).toBe(true) - - // Cleanup - const { rmSync } = await import('node:fs') - rmSync(root, { recursive: true, force: true }) +describe('package metadata', () => { + it('uses a package-manager-neutral prepack script', () => { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + scripts?: Record + } + + expect(packageJson.scripts?.prepack).toBe('npm run build') }) }) diff --git a/packages/intent/tests/feedback.test.ts b/packages/intent/tests/feedback.test.ts index 650b0a9..cbeb231 100644 --- a/packages/intent/tests/feedback.test.ts +++ b/packages/intent/tests/feedback.test.ts @@ -224,6 +224,24 @@ describe('resolveFrequency', () => { expect(resolveFrequency(tmpDir)).toBe('every-5') }) + it('ignores invalid project config values and falls back to default', () => { + writeFileSync( + join(tmpDir, 'intent.config.json'), + JSON.stringify({ feedback: { frequency: 'sometimes' } }), + ) + + expect(resolveFrequency(tmpDir)).toBe('every-5') + }) + + it('accepts validated every-N frequencies', () => { + writeFileSync( + join(tmpDir, 'intent.config.json'), + JSON.stringify({ feedback: { frequency: 'every-12' } }), + ) + + expect(resolveFrequency(tmpDir)).toBe('every-12') + }) + it('reads user override via XDG_CONFIG_HOME', () => { const configDir = join(tmpDir, 'xdg') mkdirSync(join(configDir, 'intent'), { recursive: true }) diff --git a/packages/intent/tests/meta-feedback.test.ts b/packages/intent/tests/meta-feedback.test.ts index 1e7d682..2217c87 100644 --- a/packages/intent/tests/meta-feedback.test.ts +++ b/packages/intent/tests/meta-feedback.test.ts @@ -128,6 +128,21 @@ describe('validateMetaPayload', () => { ) expect(result.valid).toBe(true) }) + + it('rejects invalid optional quality fields', () => { + const result = validateMetaPayload( + validMetaPayload({ + interviewQuality: 'excellent' as any, + failureModeQuality: 'unknown' as any, + }), + ) + + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('interviewQuality'))).toBe(true) + expect(result.errors.some((e) => e.includes('failureModeQuality'))).toBe( + true, + ) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 829bf7c..1be71c3 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -35,13 +35,24 @@ function writeSkillMd(dir: string, frontmatter: Record): void { // ── Setup / Teardown ── let root: string +let globalRoot: string +let previousGlobalNodeModules: string | undefined beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'intent-test-')) + globalRoot = mkdtempSync(join(tmpdir(), 'intent-global-test-')) + previousGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES + delete process.env.INTENT_GLOBAL_NODE_MODULES }) afterEach(() => { rmSync(root, { recursive: true, force: true }) + rmSync(globalRoot, { recursive: true, force: true }) + if (previousGlobalNodeModules === undefined) { + delete process.env.INTENT_GLOBAL_NODE_MODULES + } else { + process.env.INTENT_GLOBAL_NODE_MODULES = previousGlobalNodeModules + } }) // ── Tests ── @@ -51,6 +62,7 @@ describe('scanForIntents', () => { const result = await scanForIntents(root) expect(result.packages).toEqual([]) expect(result.warnings).toEqual([]) + expect(result.nodeModules.local.exists).toBe(false) }) it('returns empty packages when node_modules has no intent packages', async () => { @@ -85,6 +97,7 @@ describe('scanForIntents', () => { expect(result.packages).toHaveLength(1) expect(result.packages[0]!.name).toBe('@tanstack/db') expect(result.packages[0]!.version).toBe('0.5.2') + expect(result.packages[0]!.packageRoot).toBe(pkgDir) expect(result.packages[0]!.skills).toHaveLength(1) expect(result.packages[0]!.skills[0]!.name).toBe('db-core') expect(result.packages[0]!.skills[0]!.description).toBe( @@ -235,6 +248,300 @@ describe('scanForIntents', () => { expect(result.packages).toHaveLength(0) expect(result.warnings).toHaveLength(0) }) + + it('discovers global-only intent packages', async () => { + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + + const pkgDir = createDir(globalRoot, '@tanstack', 'query') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + }) + + const result = await scanForIntents(root) + + expect(result.nodeModules.global.detected).toBe(true) + expect(result.nodeModules.global.exists).toBe(true) + expect(result.nodeModules.global.scanned).toBe(true) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/query') + }) + + it('prefers local packages over global packages with the same name', async () => { + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + + const localPkgDir = createDir(root, 'node_modules', '@tanstack', 'query') + writeJson(join(localPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.1.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(localPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Local fetching skill', + }) + + const globalPkgDir = createDir(globalRoot, '@tanstack', 'query') + writeJson(join(globalPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(globalPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + }) + + const result = await scanForIntents(root) + + expect(result.nodeModules.global.detected).toBe(true) + expect(result.nodeModules.global.scanned).toBe(true) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.version).toBe('5.1.0') + expect(result.packages[0]!.skills[0]!.description).toBe( + 'Local fetching skill', + ) + expect( + result.warnings.some( + (warning) => + warning.includes('Found 2 installed variants of @tanstack/query') && + warning.includes('Using 5.1.0'), + ), + ).toBe(true) + }) + + it('chooses the highest version when duplicate package names exist at the same depth', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + 'consumer-c': '1.0.0', + }, + }) + + const consumerADir = createDir(root, 'node_modules', 'consumer-a') + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { + '@tanstack/query': '4.0.0', + }, + }) + + const consumerBDir = createDir(root, 'node_modules', 'consumer-b') + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { + '@tanstack/query': '5.0.0', + }, + }) + + const consumerCDir = createDir(root, 'node_modules', 'consumer-c') + writeJson(join(consumerCDir, 'package.json'), { + name: 'consumer-c', + version: '1.0.0', + dependencies: { + '@tanstack/query': '3.0.0', + }, + }) + + const queryV4Dir = createDir( + consumerADir, + 'node_modules', + '@tanstack', + 'query', + ) + writeJson(join(queryV4Dir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(queryV4Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v4 skill', + }) + + const queryV5Dir = createDir( + consumerBDir, + 'node_modules', + '@tanstack', + 'query', + ) + writeJson(join(queryV5Dir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(queryV5Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v5 skill', + }) + + const queryV3Dir = createDir( + consumerCDir, + 'node_modules', + '@tanstack', + 'query', + ) + writeJson(join(queryV3Dir, 'package.json'), { + name: '@tanstack/query', + version: '3.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(queryV3Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v3 skill', + }) + + const result = await scanForIntents(root) + const versionWarning = result.warnings.find((warning) => + warning.includes('@tanstack/query'), + ) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/query') + expect(result.packages[0]!.version).toBe('5.0.0') + expect(result.packages[0]!.packageRoot).toBe(queryV5Dir) + expect(versionWarning).toContain( + 'Found 3 installed variants of @tanstack/query', + ) + expect(versionWarning).toContain('across 3 versions') + expect(versionWarning).toContain('Using 5.0.0') + }) + + it('prefers stable releases over prereleases at the same depth', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = createDir(root, 'node_modules', 'consumer-a') + const consumerBDir = createDir(root, 'node_modules', 'consumer-b') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0-beta.1' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + + const prereleaseDir = createDir( + consumerADir, + 'node_modules', + '@tanstack', + 'query', + ) + const stableDir = createDir( + consumerBDir, + 'node_modules', + '@tanstack', + 'query', + ) + + writeJson(join(prereleaseDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0-beta.1', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(stableDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(prereleaseDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Prerelease query skill', + }) + writeSkillMd(createDir(stableDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Stable query skill', + }) + + const result = await scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.version).toBe('5.0.0') + expect(result.packages[0]!.packageRoot).toBe(stableDir) + }) + + it('prefers valid semver versions over invalid ones at the same depth', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = createDir(root, 'node_modules', 'consumer-a') + const consumerBDir = createDir(root, 'node_modules', 'consumer-b') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': 'workspace-dev' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + + const invalidDir = createDir( + consumerADir, + 'node_modules', + '@tanstack', + 'query', + ) + const validDir = createDir( + consumerBDir, + 'node_modules', + '@tanstack', + 'query', + ) + + writeJson(join(invalidDir, 'package.json'), { + name: '@tanstack/query', + version: 'workspace-dev', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(validDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(createDir(invalidDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Invalid version query skill', + }) + writeSkillMd(createDir(validDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Valid version query skill', + }) + + const result = await scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.version).toBe('5.0.0') + expect(result.packages[0]!.packageRoot).toBe(validDir) + }) }) describe('package manager detection', () => { diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 2e9c95f..1975828 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -42,7 +42,11 @@ beforeEach(() => { writeFileSync( join(metaDir, 'templates', 'workflows', 'notify-intent.yml'), - 'package: {{PACKAGE_NAME}}\nrepo: {{REPO}}\ndocs: {{DOCS_PATH}}\nsrc: {{SRC_PATH}}', + 'package: {{PAYLOAD_PACKAGE}}\nrepo: {{REPO}}\npaths:\n - {{DOCS_PATH}}\n - {{SRC_PATH}}', + ) + writeFileSync( + join(metaDir, 'templates', 'workflows', 'check-skills.yml'), + 'label: {{PACKAGE_LABEL}}\ninstall: npm install -g @tanstack/intent', ) }) @@ -259,7 +263,7 @@ describe('runSetupGithubActions', () => { }) const result = runSetupGithubActions(root, metaDir) - expect(result.workflows).toHaveLength(1) + expect(result.workflows).toHaveLength(2) expect(result.skipped).toHaveLength(0) const wfContent = readFileSync( @@ -268,12 +272,13 @@ describe('runSetupGithubActions', () => { ) expect(wfContent).toContain('package: @tanstack/query') expect(wfContent).toContain('repo: TanStack/query') - expect(wfContent).toContain('docs: docs/**') + expect(wfContent).toContain('paths:') + expect(wfContent).toContain("'docs/**'") }) it('copies templates with defaults when no package.json', () => { const result = runSetupGithubActions(root, metaDir) - expect(result.workflows).toHaveLength(1) + expect(result.workflows).toHaveLength(2) const wfPath = join(root, '.github', 'workflows', 'notify-intent.yml') expect(existsSync(wfPath)).toBe(true) @@ -285,7 +290,7 @@ describe('runSetupGithubActions', () => { runSetupGithubActions(root, metaDir) const result = runSetupGithubActions(root, metaDir) expect(result.workflows).toHaveLength(0) - expect(result.skipped).toHaveLength(1) + expect(result.skipped).toHaveLength(2) }) it('handles missing templates directory gracefully', () => { @@ -294,6 +299,71 @@ describe('runSetupGithubActions', () => { const result = runSetupGithubActions(root, emptyMeta) expect(result.workflows).toHaveLength(0) }) + + it('writes workflows to the workspace root with monorepo-aware substitutions', () => { + const monoRoot = createMonorepo({ + packages: [ + { name: 'router', hasSkills: true }, + { name: 'start', hasSkills: true }, + ], + }) + + writeFileSync( + join(monoRoot, 'package.json'), + JSON.stringify( + { name: '@tanstack/router', private: true, workspaces: ['packages/*'] }, + null, + 2, + ), + ) + writeFileSync( + join(monoRoot, 'packages', 'router', 'package.json'), + JSON.stringify( + { + name: '@tanstack/react-router', + intent: { repo: 'TanStack/router', docs: 'docs/' }, + }, + null, + 2, + ), + ) + mkdirSync(join(monoRoot, 'packages', 'router', 'src'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'router', 'docs'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'start', 'src'), { recursive: true }) + + const result = runSetupGithubActions( + join(monoRoot, 'packages', 'router'), + metaDir, + ) + + expect(result.workflows).toEqual( + expect.arrayContaining([ + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + ]), + ) + expect( + existsSync(join(monoRoot, 'packages', 'router', '.github', 'workflows')), + ).toBe(false) + + const notifyContent = readFileSync( + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + 'utf8', + ) + expect(notifyContent).toContain('package: @tanstack/router') + expect(notifyContent).toContain("- 'packages/router/docs/**'") + expect(notifyContent).toContain("- 'packages/router/src/**'") + expect(notifyContent).toContain("- 'packages/start/src/**'") + + const checkContent = readFileSync( + join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + 'utf8', + ) + expect(checkContent).toContain('label: @tanstack/router') + expect(checkContent).toContain('npm install -g @tanstack/intent') + + rmSync(monoRoot, { recursive: true, force: true }) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index 64586cb..d6c2333 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -225,6 +225,28 @@ describe('checkStaleness', () => { expect(report.skills[0]!.needsReview).toBe(false) }) + it('ignores malformed sync-state entries instead of flagging false positives', async () => { + writeSkill(tmpDir, 'core', { + name: 'core', + description: 'Core', + sources: ['docs/api.md'], + }) + + writeSyncState(tmpDir, { + skills: { + core: { + sources_sha: ['not', 'a', 'record'], + }, + }, + }) + + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) + + const report = await checkStaleness(tmpDir, '@example/lib') + expect(report.skills[0]!.needsReview).toBe(false) + expect(report.skills[0]!.reasons).toEqual([]) + }) + it('handles nested skill directories', async () => { writeSkill(tmpDir, 'react/hooks', { name: 'react/hooks', diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs new file mode 100644 index 0000000..584f8c8 --- /dev/null +++ b/scripts/create-github-release.mjs @@ -0,0 +1,235 @@ +// @ts-nocheck + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execSync } from 'node:child_process' + +const rootDir = path.resolve(import.meta.dirname, '..') +const packagesDir = path.join(rootDir, 'packages') +const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN +const isPrerelease = process.argv.includes('--prerelease') + +function run(command, options = {}) { + return execSync(command, { + cwd: rootDir, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + ...options, + }).trim() +} + +function maybeRun(command) { + try { + return run(command) + } catch { + return null + } +} + +function getReleaseCommits() { + const output = maybeRun( + 'git log --grep="ci: changeset release" --format=%H --no-merges', + ) + + if (!output) { + return [] + } + + return output.split('\n').filter(Boolean) +} + +function getPackages() { + return fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const dir = entry.name + const packageJsonPath = path.join(packagesDir, dir, 'package.json') + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + + return { + dir, + packageJsonPath, + packageJson, + } + }) + .filter(({ packageJson }) => !packageJson.private) +} + +function getPreviousPackageJson(releaseCommit, packageJsonPath) { + if (!releaseCommit) { + return null + } + + const relativePath = path.relative(rootDir, packageJsonPath) + const content = maybeRun(`git show ${releaseCommit}:'${relativePath}'`) + + if (!content) { + return null + } + + return JSON.parse(content) +} + +function getChangedPackages(previousReleaseCommit) { + return getPackages() + .map(({ dir, packageJsonPath, packageJson }) => { + const previousPackageJson = getPreviousPackageJson( + previousReleaseCommit, + packageJsonPath, + ) + + if ( + !previousPackageJson || + previousPackageJson.version !== packageJson.version + ) { + return { + dir, + name: packageJson.name, + version: packageJson.version, + previousVersion: previousPackageJson?.version ?? null, + } + } + + return null + }) + .filter(Boolean) + .sort((left, right) => left.name.localeCompare(right.name)) +} + +function getChangelogSection(changelogPath, version) { + if (!fs.existsSync(changelogPath)) { + return null + } + + const changelog = fs.readFileSync(changelogPath, 'utf8') + const marker = `## ${version}` + const start = changelog.indexOf(marker) + + if (start === -1) { + return null + } + + const bodyStart = changelog.indexOf('\n', start) + const nextSection = changelog.indexOf('\n## ', bodyStart + 1) + + return changelog + .slice(bodyStart + 1, nextSection === -1 ? undefined : nextSection) + .trim() +} + +function buildReleaseNotes(changedPackages) { + const sections = changedPackages.map((pkg) => { + const changelogPath = path.join(packagesDir, pkg.dir, 'CHANGELOG.md') + const content = + getChangelogSection(changelogPath, pkg.version) || + '- No changelog entries' + + return `#### ${pkg.name}\n\n${content}` + }) + + return sections.join('\n\n') +} + +function createReleaseTag() { + const now = new Date().toISOString() + const tag = `release-${now.slice(0, 10)}-${now.slice(11, 13)}${now.slice(14, 16)}` + const title = `Release ${now.slice(0, 10)} ${now.slice(11, 16)}` + + return { tag, title } +} + +function createReleaseBody(title, changedPackages, notes) { + const packages = changedPackages + .map((pkg) => `- ${pkg.name}@${pkg.version}`) + .join('\n') + + return `${title}\n\n## Changes\n\n${notes}\n\n## Packages\n\n${packages}` +} + +function pushTag(tag) { + const exists = maybeRun(`git rev-parse ${tag}`) + + if (exists) { + return false + } + + run(`git tag -a ${tag} -m "${tag}"`) + run(`git push origin ${tag}`) + return true +} + +function createGitHubRelease(tag, title, body) { + if (!token) { + throw new Error('Missing GH_TOKEN or GITHUB_TOKEN') + } + + const notesFile = path.join(os.tmpdir(), `${tag}.md`) + fs.writeFileSync(notesFile, body) + + const args = [ + 'gh', + 'release', + 'create', + tag, + '--title', + JSON.stringify(title), + '--notes-file', + JSON.stringify(notesFile), + ] + + if (isPrerelease) { + args.push('--prerelease') + } else { + args.push('--latest') + } + + try { + execSync(args.join(' '), { + cwd: rootDir, + stdio: 'inherit', + env: { + ...process.env, + GH_TOKEN: token, + GITHUB_TOKEN: token, + }, + }) + } finally { + fs.rmSync(notesFile, { force: true }) + } +} + +function rollbackTag(tag) { + maybeRun(`git push --delete origin ${tag}`) + maybeRun(`git tag -d ${tag}`) +} + +function main() { + const [, previousReleaseCommit] = getReleaseCommits() + const changedPackages = getChangedPackages(previousReleaseCommit) + + if (changedPackages.length === 0) { + console.log('No changed packages found for GitHub release.') + return + } + + const notes = buildReleaseNotes(changedPackages) + const { tag, title } = createReleaseTag() + const body = createReleaseBody(title, changedPackages, notes) + + let createdTag = false + + try { + createdTag = pushTag(tag) + createGitHubRelease(tag, title, body) + } catch (error) { + if (createdTag) { + rollbackTag(tag) + } + + throw error + } +} + +main()