diff --git a/.agents/PROJECT.md b/.agents/PROJECT.md index 78c531e7..87005d51 100644 --- a/.agents/PROJECT.md +++ b/.agents/PROJECT.md @@ -31,6 +31,8 @@ MCP server for orchestrated run management. Wraps run-core capabilities as five **Package:** `@codeassembly/mcp` (private) +**Bin:** `codeassembly-mcp` — the stdio server entry point. `.claude/settings.json` launches the server through `packages/mcp/bin/codeassembly-mcp.js`. + ### Agents (`packages/agents/`) The agents package is a CLI tool (`codeassembly-agents`) that installs reusable AI skills and subagent definitions into harness-specific directories. It also serves as the canonical home for all skill and subagent content. @@ -258,12 +260,14 @@ The package README documents the `kb.yaml` configuration schema and merge semant ### Build system -- Uses esbuild via custom `config/build.ts` for TypeScript packages -- Intelligent caching based on content hashes -- Automatic `.ts` to `.js` extension rewriting -- Alias resolution support (`~src/` -> `src/`) +- Uses `nmr-compile` (from `@williamthorsen/nmr`) for TypeScript packages, emitting `.js` and `.d.ts` in a single TypeScript pass +- Intelligent caching based on content hashes, keyed under `node_modules/.cache/nmr-compile/` +- Automatic `.ts` to `.js` extension rewriting, in compiled output and emitted declarations alike +- Alias resolution support for `~/`-prefixed imports - Factory uses Vite with `@vitejs/plugin-react` for the web app +Deleting `dist/` does not force a rebuild. The build cache lives outside it and is keyed on inputs alone, so a rebuild after a manual delete skips and leaves `dist/` empty. Clear `node_modules/.cache/nmr-compile/` too. Tracked upstream at williamthorsen/node-monorepo-tools#470. + ### TypeScript - Strict mode across all packages (`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitReturns`) diff --git a/.agents/nmr/AGENTS.md b/.agents/nmr/AGENTS.md index a2243f1e..a32fc50b 100644 --- a/.agents/nmr/AGENTS.md +++ b/.agents/nmr/AGENTS.md @@ -1,5 +1,5 @@ --- -source: '@williamthorsen/nmr@0.15.0' +source: '@williamthorsen/nmr@0.18.1' --- # nmr: agent guidance @@ -38,7 +38,7 @@ Some root scripts (e.g. `lint`, `typecheck`, `test`) expand to `nmr root:X && pn ## Managed build -The default `compile` script runs `nmr-compile`, a standalone bin that esbuild-compiles a package's `src` to `dist/esm`, rewriting `~/` (package-root) import aliases and `.ts`→`.js` specifiers, and skipping work when inputs are unchanged. There is no repo-local build script to maintain; `nmr build` runs `compile` then `generate-typings`. To find or debug the build, look to `nmr-compile`, not a `config/build.ts` in the consuming repo. +The default `compile` script runs `nmr-compile`, a standalone bin that uses the TypeScript compiler API to emit a package's `src` to `dist/esm` as `.js` and `.d.ts` in one pass, rewriting relative `.ts`→`.js` specifiers and tsconfig `paths` aliases in both outputs, and skipping work when inputs are unchanged. `typescript` is a peer dependency (`>=5.7.0`). There is no repo-local build script to maintain, and no separate typings step; `nmr build` runs `compile` alone. To find or debug the build, look to `nmr-compile`, not a `config/build.ts` in the consuming repo. ## Override behaviors diff --git a/.claude/settings.json b/.claude/settings.json index ee09428b..e015c1bf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "mcpServers": { "codeassembly": { "command": "node", - "args": ["packages/mcp/dist/esm/cli.js"] + "args": ["packages/mcp/bin/codeassembly-mcp.js"] } } } diff --git a/config/build.ts b/config/build.ts deleted file mode 100644 index 8abc491f..00000000 --- a/config/build.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createHash } from 'node:crypto'; -import { existsSync, readFileSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -import { build, type Format, type Platform, type Plugin } from 'esbuild'; -import { glob } from 'glob'; - -const CACHE_FILE = 'dist/esm/.cache'; -const format: Format = 'esm'; -const platform: Platform = 'node'; -const target = 'es2022'; - -const aliases = { - '~/src/': 'src/', -}; -const dependencies = ['package.json']; -const entryPoints = await glob(['src/**/*.ts'], { - ignore: ['**/__tests__/**', '**/test-utils/**'], -}); -const outputConfig = { format, platform, target: [target] }; - -if (await hashChanged()) { - await build({ - entryPoints, - outdir: 'dist/esm/', - bundle: false, - sourcemap: false, - plugins: [rewriteTsExtensions()], - ...outputConfig, - }); -} - -// region | Helper functions -async function hashChanged(): Promise { - const previousHash = existsSync(CACHE_FILE) ? readFileSync(CACHE_FILE, 'utf8') : undefined; - const currentHash = await computeHash(); - - if (previousHash === currentHash) { - console.info('No changes detected. Skipping build.'); - return false; - } - - console.info('Changes detected.'); - await mkdir(path.dirname(CACHE_FILE), { recursive: true }); - await writeFile(CACHE_FILE, currentHash); - return true; -} - -async function computeHash(): Promise { - const hash = createHash('sha256'); - for (const file of [...entryPoints, ...dependencies]) { - const content = await readFile(file); - hash.update(content); - } - - hash.update(JSON.stringify(outputConfig)); - return hash.digest('hex'); -} - -function rewriteTsExtensions(): Plugin { - return { - name: 'rewrite-ts-extensions', - setup(build) { - build.onLoad({ filter: /\.ts$/ }, async (args) => { - const fileDir = path.dirname(args.path); - let code = await readFile(args.path, 'utf8'); - - code = resolveAliasImports(code, fileDir, aliases); - code = rewriteTsImportExtensions(code); - - return { contents: code, loader: 'ts' }; - }); - }, - }; -} - -/** - * Rewrites alias import paths to relative filesystem paths from the importing file. - * - * @param code - The TypeScript source code. - * @param fileDir - The absolute path to the importing file’s directory. - * @param aliasMap - A map of alias prefixes (e.g. '@/') to base paths (e.g. 'src/'). - */ -function resolveAliasImports(code: string, fileDir: string, aliasMap: Record): string { - for (const [alias, targetDir] of Object.entries(aliasMap)) { - const escaped = alias.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); // escape regex - const regex = new RegExp(String.raw`(?<=from\s+['"])${escaped}([^'"]+)(?=['"])`, 'g'); - - code = code.replace(regex, (_, subpath: string) => { - const absolute = path.resolve(targetDir, subpath); - const relative = path.relative(fileDir, absolute); - return relative.startsWith('.') ? relative : `./${relative}`; - }); - } - - return code; -} - -/** - * Rewrites relative imports ending in `.ts` to `.js` to match compiled output. - */ -function rewriteTsImportExtensions(code: string): string { - return code.replaceAll(/(?<=from\s+['"])(\.{1,2}\/[^'"]+)\.ts(?=['"])/g, '$1.js'); -} -// endregion | Helper functions diff --git a/package.json b/package.json index bffa4db3..37de9ab0 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,10 @@ "@vitest/coverage-v8": "4.1.10", "@vitest/eslint-plugin": "1.6.23", "@williamthorsen/eslint-config-typescript": "5.17.6", - "@williamthorsen/nmr": "0.16.0", + "@williamthorsen/nmr": "0.18.1", "@williamthorsen/release-kit": "8.0.0", "@williamthorsen/strict-lint": "6.3.2", "dotenv": "17.4.2", - "esbuild": "0.28.1", "eslint": "9.39.5", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest-dom": "5.5.0", diff --git a/packages/agents/bin/codeassembly-agents.js b/packages/agents/bin/codeassembly-agents.js index 615f8f63..02293471 100755 --- a/packages/agents/bin/codeassembly-agents.js +++ b/packages/agents/bin/codeassembly-agents.js @@ -1,15 +1,24 @@ #!/usr/bin/env node +import { existsSync } from 'node:fs'; + // Thin wrapper so pnpm can symlink the bin at install time, before `dist/` // exists. The real entry point loads at runtime from the build output. // See packages/run-core/README.md ("Bin wrapper pattern") for details. +const entryPoint = new URL('../dist/esm/cli.js', import.meta.url); + +// Gate on the entry file itself: Node raises ERR_MODULE_NOT_FOUND for any unresolved +// module in the graph, so keying the build-first message off the error code would also +// fire when the build is present and one of its imports is missing. +if (!existsSync(entryPoint)) { + process.stderr.write('codeassembly-agents: build output not found — run `pnpm run build` first\n'); + process.exit(1); +} + try { - await import('../dist/esm/cli.js'); + await import(entryPoint.href); } catch (error) { - if (error.code === 'ERR_MODULE_NOT_FOUND') { - process.stderr.write('codeassembly-agents: build output not found — run `pnpm run build` first\n'); - } else { - process.stderr.write(`codeassembly-agents: failed to load: ${error.message}\n`); - } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`codeassembly-agents: failed to load: ${message}\n`); process.exit(1); } diff --git a/packages/agents/package.json b/packages/agents/package.json index 86ce6fbf..4d359f78 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -25,7 +25,8 @@ }, "devDependencies": { "@hyperjump/json-schema": "1.17.7", - "@williamthorsen/toolbelt.strings": "3.1.4" + "@williamthorsen/toolbelt.strings": "3.1.4", + "esbuild": "0.28.1" }, "engines": { "node": ">=24" diff --git a/packages/kb/bin/kb.js b/packages/kb/bin/kb.js index 6b561afd..2824bd3d 100755 --- a/packages/kb/bin/kb.js +++ b/packages/kb/bin/kb.js @@ -1,16 +1,24 @@ #!/usr/bin/env node +import { existsSync } from 'node:fs'; + // Thin wrapper so pnpm can symlink the bin at install time, before `dist/` exists. // The real entry point loads at runtime from the build output. // See packages/run-core/README.md ("Bin wrapper pattern") for details. +const entryPoint = new URL('../dist/esm/cli/index.js', import.meta.url); + +// Gate on the entry file itself: Node raises ERR_MODULE_NOT_FOUND for any unresolved +// module in the graph, so keying the build-first message off the error code would also +// fire when the build is present and one of its imports is missing. +if (!existsSync(entryPoint)) { + process.stderr.write('kb: build output not found — run `pnpm run build` first\n'); + process.exit(1); +} + try { - await import('../dist/esm/cli/index.js'); + await import(entryPoint.href); } catch (error) { - if (error.code === 'ERR_MODULE_NOT_FOUND') { - process.stderr.write('kb: build output not found — run `pnpm run build` first\n'); - } else { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`kb: failed to load: ${message}\n`); - } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`kb: failed to load: ${message}\n`); process.exit(1); } diff --git a/packages/kb/package.json b/packages/kb/package.json index dc1719d0..1cc736ce 100644 --- a/packages/kb/package.json +++ b/packages/kb/package.json @@ -76,8 +76,8 @@ "!dist/esm/test-utils" ], "scripts": { - "build": "nmr compile && nmr generate-typings", - "prepare": "nmr compile && nmr generate-typings" + "build": "nmr compile", + "prepare": "nmr compile" }, "dependencies": { "picomatch": "4.0.5", diff --git a/packages/kb/tsconfig.generate-typings.json b/packages/kb/tsconfig.generate-typings.json deleted file mode 100644 index cc9c8982..00000000 --- a/packages/kb/tsconfig.generate-typings.json +++ /dev/null @@ -1,13 +0,0 @@ -// TSConfig for declaration emit. esbuild owns the .js; tsc owns only the .d.ts. -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "emitDeclarationOnly": true, - "noEmit": false, - "outDir": "./dist/esm", - }, - // Tests and their support code are not part of the published type surface. - "exclude": ["**/__tests__/**", "**/*.test.ts", "src/test-utils/"], - "include": ["src/"], -} diff --git a/packages/mcp/bin/codeassembly-mcp.js b/packages/mcp/bin/codeassembly-mcp.js new file mode 100755 index 00000000..fd7e0a62 --- /dev/null +++ b/packages/mcp/bin/codeassembly-mcp.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import { existsSync } from 'node:fs'; + +// Committed launch path for the stdio server, so `.claude/settings.json` targets a file that +// exists before `dist/` is built. The real entry point loads at runtime from the build output. +// See packages/run-core/README.md ("Bin wrapper pattern") for details. +const entryPoint = new URL('../dist/esm/cli.js', import.meta.url); + +// Gate on the entry file itself: Node raises ERR_MODULE_NOT_FOUND for any unresolved +// module in the graph, so keying the build-first message off the error code would also +// fire when the build is present and one of its imports is missing. +if (!existsSync(entryPoint)) { + process.stderr.write('codeassembly-mcp: build output not found — run `pnpm run build` first\n'); + process.exit(1); +} + +try { + await import(entryPoint.href); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`codeassembly-mcp: failed to load: ${message}\n`); + process.exit(1); +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json index f1004431..f9cf953c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -4,6 +4,13 @@ "private": true, "description": "MCP server for orchestrated run management", "type": "module", + "bin": { + "codeassembly-mcp": "bin/codeassembly-mcp.js" + }, + "files": [ + "bin", + "dist" + ], "scripts": { "build": "nmr compile", "prepare": "nmr compile" diff --git a/packages/run-core/README.md b/packages/run-core/README.md index af7e15b2..a5a53950 100644 --- a/packages/run-core/README.md +++ b/packages/run-core/README.md @@ -31,6 +31,6 @@ codeassembly-runs --path # override the base projects directory The `bin` field in `package.json` points to `bin/codeassembly-runs.js`, a committed wrapper script that dynamically imports the build output at runtime. Do not point `bin` entries directly into `dist/` — pnpm creates bin symlinks during install, before lifecycle scripts like `prepare` run, so the target won't exist in a fresh worktree and `pnpm install` will emit confusing "Failed to create bin" warnings. -If invoked before building, the wrapper detects `ERR_MODULE_NOT_FOUND` and tells the user to run `pnpm run build`. +If invoked before building, the wrapper finds the entry file absent and tells the user to run `pnpm run build`. It checks for the file directly rather than keying off `ERR_MODULE_NOT_FOUND`, which Node raises for any unresolved module in the graph — including a missing dependency of a build output that is present, where advising a rebuild would be wrong. Any other load failure is reported verbatim. Any new `bin` entry in this monorepo should follow the same pattern. See `bin/codeassembly-runs.js` for the template, and the `@williamthorsen/node-monorepo-tools` packages for the original rationale. diff --git a/packages/run-core/bin/codeassembly-runs.js b/packages/run-core/bin/codeassembly-runs.js index b5e6fad5..6bb21c80 100755 --- a/packages/run-core/bin/codeassembly-runs.js +++ b/packages/run-core/bin/codeassembly-runs.js @@ -1,15 +1,24 @@ #!/usr/bin/env node +import { existsSync } from 'node:fs'; + // Thin wrapper so pnpm can symlink the bin at install time, before `dist/` // exists. The real entry point loads at runtime from the build output. // See packages/run-core/README.md ("Bin wrapper pattern") for details. +const entryPoint = new URL('../dist/esm/cli.js', import.meta.url); + +// Gate on the entry file itself: Node raises ERR_MODULE_NOT_FOUND for any unresolved +// module in the graph, so keying the build-first message off the error code would also +// fire when the build is present and one of its imports is missing. +if (!existsSync(entryPoint)) { + process.stderr.write('codeassembly-runs: build output not found — run `pnpm run build` first\n'); + process.exit(1); +} + try { - await import('../dist/esm/cli.js'); + await import(entryPoint.href); } catch (error) { - if (error.code === 'ERR_MODULE_NOT_FOUND') { - process.stderr.write('codeassembly-runs: build output not found — run `pnpm run build` first\n'); - } else { - process.stderr.write(`codeassembly-runs: failed to load: ${error.message}\n`); - } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`codeassembly-runs: failed to load: ${message}\n`); process.exit(1); } diff --git a/packages/run-core/package.json b/packages/run-core/package.json index f0597766..c1f524c6 100644 --- a/packages/run-core/package.json +++ b/packages/run-core/package.json @@ -35,8 +35,8 @@ "dist" ], "scripts": { - "build": "nmr compile && nmr generate-typings", - "prepare": "nmr compile && nmr generate-typings" + "build": "nmr compile", + "prepare": "nmr compile" }, "dependencies": { "yaml": "2.9.0", diff --git a/packages/run-core/tsconfig.generate-typings.json b/packages/run-core/tsconfig.generate-typings.json deleted file mode 100644 index eab75a69..00000000 --- a/packages/run-core/tsconfig.generate-typings.json +++ /dev/null @@ -1,13 +0,0 @@ -// TSConfig for declaration emit. esbuild owns the .js; tsc owns only the .d.ts. -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "emitDeclarationOnly": true, - "noEmit": false, - "outDir": "./dist/esm", - }, - // Tests are not part of the published type surface. - "exclude": ["**/__tests__/**", "**/*.test.ts"], - "include": ["src/"], -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b3a186b..f3297455 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: 5.17.6 version: 5.17.6(@types/estree@1.0.8)(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) '@williamthorsen/nmr': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.18.1 + version: 0.18.1(typescript@5.9.3) '@williamthorsen/release-kit': specifier: 8.0.0 version: 8.0.0 @@ -44,9 +44,6 @@ importers: dotenv: specifier: 17.4.2 version: 17.4.2 - esbuild: - specifier: 0.28.1 - version: 0.28.1 eslint: specifier: 9.39.5 version: 9.39.5(jiti@2.7.0) @@ -114,6 +111,9 @@ importers: '@williamthorsen/toolbelt.strings': specifier: 3.1.4 version: 3.1.4 + esbuild: + specifier: 0.28.1 + version: 0.28.1 packages/factory: dependencies: @@ -1139,15 +1139,14 @@ packages: eslint: '>=9' typescript: '>=5' - '@williamthorsen/nmr-core@0.5.0': - resolution: {integrity: sha512-7Jdo+IdZ2lsY7hNV+/zbU2TdL8TJNZFGKBEo43N6jfBaPNfoWHBqRouW+O9CZf1OdzkTbfxmPcs/PBbnIWYD3g==} - '@williamthorsen/nmr-core@0.7.0': resolution: {integrity: sha512-0Go/8helfB+nXldaAt6z9lDpV0m7gu9tZ8rrmu2KhEVGeH3VrnLB83wNwm41lZffmooC5tkj3cD3Z04xzt6yJg==} - '@williamthorsen/nmr@0.16.0': - resolution: {integrity: sha512-lPZ58Jt2JSF2eAFUoiOEIBhTwkgdR9eNZKVk0K22tnB5T/e6kaeiaxUQ46bDK/T9urjYMniAfYYPs8iJFjk6MQ==} + '@williamthorsen/nmr@0.18.1': + resolution: {integrity: sha512-wEXadR7+jQiPZnjuCSiiAAj3GD4IRQ3hKAH2b96HEWDxRytg6DeFU7uLZlS1wSmg9DSLmo4Hdg6Y/d8BOOqzdA==} hasBin: true + peerDependencies: + typescript: '>=5.7.0' '@williamthorsen/release-kit@8.0.0': resolution: {integrity: sha512-MYAC5QmoDtzIiN01H1rjZrQZREQYbh13S+CTY3f+YDt2QnkMDbHSTdGsI84CEcEz4VuQyiHkU12vdtPkW5rYyQ==} @@ -4282,16 +4281,14 @@ snapshots: - '@types/estree' - supports-color - '@williamthorsen/nmr-core@0.5.0': {} - '@williamthorsen/nmr-core@0.7.0': {} - '@williamthorsen/nmr@0.16.0': + '@williamthorsen/nmr@0.18.1(typescript@5.9.3)': dependencies: - '@williamthorsen/nmr-core': 0.5.0 - esbuild: 0.28.1 + '@williamthorsen/nmr-core': 0.7.0 glob: 13.0.6 jiti: 2.7.0 + typescript: 5.9.3 yaml: 2.9.0 zod: 4.4.3