diff --git a/CHANGELOG.md b/CHANGELOG.md index cd49f8a..041d389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## [Unreleased] — 1.0.0 (stable API) + +First stable line: the CLI surface, MCP tool surface, and manifest schema are +now considered stable and versioned under SemVer. Released as a **major** because +the vendor-file output format changed (see Changed). + +### Added +- `pack --budget ` to override the manifest token budget per run, and + `pack --exact` to count tokens with the bundled `gpt-tokenizer` (cl100k) + instead of the `chars/4` estimate. +- `lint` now warns about fragments whose content alone exceeds the budget + (they can never be packed). +- `build --target ` to compile only selected vendors + (`claude`, `gemini`, `agents`, `cursor`). +- `agenticscope schema` — generate `schema/manifest.schema.json` from the zod + schema for TOML editor autocomplete/validation. +- `agenticscope mcp-config` — print ready-to-paste MCP server config per host + (Claude Desktop, Cursor, generic). +- MCP server **HTTP transport**: `agenticscope-mcp --http [port]` serves over + Streamable HTTP for remote/hosted use, alongside the default stdio transport. + +### Changed +- **BREAKING:** vendor files are now rendered per vendor. `CLAUDE.md`/`GEMINI.md`/ + `AGENTS.md` get vendor-specific markdown preambles and `.cursorrules` is a plain + instruction file (no markdown H1). The old single `# Agent context for ` + header is gone. Re-run `agenticscope build` to regenerate. + +### Security +- MCP `project` arguments are scope-guarded to the workspace root; paths that + escape it (`../…`, absolute paths outside) are rejected. + ## [0.2.1](https://github.com/jessn-dev/agentic-scope/compare/v0.2.0...v0.2.1) (2026-06-24) diff --git a/README.md b/README.md index 0f1f5b8..532c753 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,12 @@ **A directory-as-context standard plus a read-only MCP server that gives any AI agent live, structured awareness of your workspace — without burning your tokens re-reading everything.** -> **Status:** Draft 0.1.0 · **License:** MIT · **Requires:** Node.js ≥ 22 +[![npm version](https://img.shields.io/npm/v/agenticscope.svg)](https://www.npmjs.com/package/agenticscope) +[![CI](https://github.com/jessn-dev/agentic-scope/actions/workflows/ci.yml/badge.svg)](https://github.com/jessn-dev/agentic-scope/actions/workflows/ci.yml) +[![license](https://img.shields.io/npm/l/agenticscope.svg)](./LICENSE) +[![node](https://img.shields.io/node/v/agenticscope.svg)](https://nodejs.org) + +> **License:** MIT · **Requires:** Node.js ≥ 22 · Published to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements). --- @@ -155,9 +160,13 @@ agenticscope pack "refactor handler" -p src/api/handler.ts -p src/db/schema.sql | Command | Does | | :--- | :--- | | `agenticscope init [dir]` | Scaffold a manifest + `.scope/` tree | -| `agenticscope lint [dir]` | Validate the manifest; flag missing paths, dupe ids, dead fragments | -| `agenticscope build [dir]` | Compile `.scope/` into all vendor files | -| `agenticscope pack ` | Resolve a task into a budgeted context block (`-d` dir, `-p` paths, `--raw`) | +| `agenticscope lint [dir]` | Validate the manifest; flag missing paths, dupe ids, dead fragments, and fragments too big for the budget | +| `agenticscope build [dir]` | Compile `.scope/` into vendor files (`-t, --target claude\|gemini\|agents\|cursor` to pick a subset) | +| `agenticscope pack ` | Resolve a task into a budgeted context block (`-d` dir, `-p` paths, `-b, --budget` override, `--exact` tokenizer, `--raw`) | +| `agenticscope schema [dir]` | Generate `schema/manifest.schema.json` for TOML editor autocomplete (`-o` out path) | +| `agenticscope mcp-config` | Print ready-to-paste MCP config (`--workspace`, `--host claude\|cursor\|generic`) | + +**Exact token counts.** `pack` estimates tokens with a fast `chars/4` heuristic by default. Pass `--exact` to use the bundled `gpt-tokenizer` (cl100k) for precise counts when a budget is tight. ### Wire up the MCP server @@ -176,7 +185,15 @@ Point any MCP-capable host at the server and give it your workspace root. The ho } ``` -The same server works in **Gemini (Gemini CLI)**, **ChatGPT / OpenAI agents**, **Cursor**, **Zed**, and **Windsurf** — each just has its own config file. You can also set the workspace with the `AGENTICSCOPE_WORKSPACE` environment variable instead of `--workspace`. +The same server works in **Gemini (Gemini CLI)**, **ChatGPT / OpenAI agents**, **Cursor**, **Zed**, and **Windsurf** — each just has its own config file. You can also set the workspace with the `AGENTICSCOPE_WORKSPACE` environment variable instead of `--workspace`, or run `agenticscope mcp-config --host cursor` to print a ready-to-paste block. + +**Stdio by default; HTTP for remote/hosted use.** Desktop hosts launch the server over stdio. To run it as a shared service, start it over Streamable HTTP: + +```bash +agenticscope-mcp --http 3000 --workspace ~/Documents # serves POST/GET http://localhost:3000/mcp +``` + +**Safe by construction.** Every tool is read-only, errors return a clean `isError` result instead of crashing the host, and `project` arguments are **scope-guarded** to the workspace root — a client can't coax the server into reading `/etc` or `../../secrets`. Once connected, I ask things like *"what's in flight across my workspace?"* and the host calls `list_plans` + `git_status` and answers from structured data — **no file dumps, no token burn.** That's the whole point delivered. @@ -216,16 +233,12 @@ agenticscope/ └── sample-workspace/ # a working .scope/ project to try the commands against ``` -## Changelog - -See [CHANGELOG.md](CHANGELOG.md) for what shipped, what I fixed, and what others contributed. - ## Contributing Contributions are welcome. - **Found a bug or have an idea?** Open an issue: https://github.com/jessn-dev/agentic-scope/issues -- **Sending a pull request?** Fork, branch, run `npm run typecheck && npm test` before you push, and describe the change. I record merged PRs in the Changelog above. +- **Sending a pull request?** Fork, branch, run `npm run typecheck && npm test` before you push, and describe the change. Releases and merged changes are recorded in [CHANGELOG.md](CHANGELOG.md). - **Local setup:** ```bash npm install @@ -237,7 +250,7 @@ Contributions are welcome. ## Status & roadmap -This is an early draft (0.1.0). The standard and the MCP tool surface come first; expect the resolver and tooling to keep evolving. Feedback, forks, and competing designs are all welcome — I want a better way to feed agents context, not a walled garden. +The `1.0` line marks a stable CLI + MCP tool surface and manifest schema. The standard and tooling will keep evolving; breaking changes are released as new majors. Feedback, forks, and competing designs are all welcome — I want a better way to feed agents context, not a walled garden. --- diff --git a/context.md b/context.md index c83d7ec..2136e5a 100644 --- a/context.md +++ b/context.md @@ -1,6 +1,6 @@ # agenticscope — project context -Working notes: what exists, what's left. Last updated 2026-06-23. +Working notes: what exists, what's left. Last updated 2026-06-24. ## What this is A directory-as-context standard + a read-only MCP server that gives AI agents @@ -10,72 +10,75 @@ vendor-folder sprawl, and zero workspace-level awareness. - **Stack:** TypeScript (ESM, NodeNext), Node >= 22. - **Repo:** https://github.com/jessn-dev/agentic-scope -- **npm:** `agenticscope` (name reserved; `0.1.0` published, `0.2.0` pending publish). +- **npm:** `agenticscope` — `0.1.0`, `0.2.0`, `0.2.1` published (provenance, OIDC). - **Two bins:** `agenticscope` (CLI), `agenticscope-mcp` (MCP server). +## Release pipeline (working) +- Push to `main` → `.github/workflows/release.yml` runs semantic-release + (Conventional Commits → version/CHANGELOG/tag/GitHub release), then a + **top-level** `npm publish` via **npm OIDC Trusted Publishing** (no token). +- Node 24 in CI (npm 11.x, needed for OIDC). Key gotchas, all solved: + Trusted Publisher must match `jessn-dev` / `agentic-scope` / `release.yml` / + blank env; setup-node's `_authToken` placeholder is stripped from the npmrc; + publish must be top-level (semantic-release's exec env shadows OIDC). +- `ci.yml` runs typecheck + test + build on PRs. + ## Done ### Core (`src/core/`) -- `types.ts` — zod schemas. Fragment fields: `id`, `type` (rule|knowledge|spec|persona), - `path`, `triggers` (globs→paths), `keywords` (words→text), `priority`, `always`. - Scope: `version`, `budget`, `name`, `precedence` ("type" | "priority"). -- `manifest.ts` — load/parse/validate `agenticscope.toml` (smol-toml + zod). -- `tokens.ts` — dependency-free `chars/4` estimator. -- `fragments.ts` — `matchTriggers` (glob vs keyword split, false-positive fixed), - `pack` (precedence ordering + hard budget cap + skip reasons), `renderPack`. -- `vendor.ts` — `compile`/`build` → CLAUDE.md, GEMINI.md, AGENTS.md, .cursorrules. +- `types.ts` — zod schemas (Fragment, Manifest, precedence). +- `manifest.ts` — load/parse/validate `agenticscope.toml`. +- `tokens.ts` — `chars/4` estimator; `{ exact: true }` uses `gpt-tokenizer` (lazy). +- `fragments.ts` — `matchTriggers`, `pack` (budget override + exact opt), `renderPack`, + `oversizedFragments` (lint helper). +- `vendor.ts` — per-vendor `compile`/`build`, `resolveTargets` (target selection). +- `schema.ts` — JSON Schema generation from zod (`zod-to-json-schema`). ### CLI (`src/cli.ts`) -- `init` (scaffold), `lint` (validate + dead-fragment/dup checks), `build`, `pack`. +- `init`, `lint` (+ oversized-fragment warning), `build` (`--target`), + `pack` (`--budget`, `--exact`, `-p`, `--raw`), `schema`, `mcp-config`. +- `--version` reads from package.json (no drift). ### MCP server (`src/mcp/`) -- `server.ts` — stdio transport, 6 tools, all wrapped in `guard()` → clean `isError`. -- Tools: `list_projects`, `list_subagents`, `list_plans`, `git_status`, - `grep_memory`, `pack_context`. -- `workspace.ts` (scan depth-1 for projects), `git.ts` (read-only simple-git), - `grep.ts` (pure-Node grep over `.scope/memory/`). +- `server.ts` — `createServer()` factory; stdio transport (default) **and** + `--http [port]` Streamable HTTP transport for remote/hosted use. +- 6 tools, all `guard()`-wrapped → clean `isError`. +- `scope.ts` — path-scope guard: `project` args restricted to the workspace root. +- `pack_context` accepts a `budget` override. ### Tests + CI -- Vitest, 18 tests passing (manifest, match, pack, vendor, grep). `npm test`. -- `.github/workflows/ci.yml` — typecheck + test + build on push/PR. -- `.github/workflows/publish.yml` — tag `v*` → `npm publish --provenance` - (needs `NPM_TOKEN` repo secret w/ bypass-2FA; NOT set yet). +- Vitest, **34 tests passing** (manifest, match, pack, budget, vendor, tokens, + schema, scope, grep). `npm test`. ### Docs / meta -- README: full spec, usage, MCP config, Changelog, Contributing. Active first-person voice. -- LICENSE (MIT, Jesse B Ngolab), `.gitignore` (ignores generated vendor files + .idea). -- `examples/sample-workspace/api/` — working demo project. -- package.json metadata (author/repo/bugs/homepage) filled. +- README: full spec + usage, badges (npm/CI/license/node), new commands/flags, + HTTP transport, security note. CHANGELOG.md (semantic-release-managed). +- `schema/manifest.schema.json` generated and committed. ## State -- Local: bumped to `0.2.0`, commit + tag `v0.2.0` created. -- `0.2.0` NOT yet published to npm, and the bump commit/tag may not be pushed. - -## TODO - -### Immediate (finish the 0.2.0 release) -- [ ] `npm publish --otp=XXXXXX` (blocked on 2FA OTP — needs the human). -- [ ] `git push && git push --tags`. -- [ ] (optional) Add `NPM_TOKEN` secret so tag pushes auto-publish. +- **`develop` carries the 1.0.0 work** (all of the former backlog below). +- Merging `develop` → `main` is intended to cut a **major (1.0.0)** release. + semantic-release needs a `BREAKING CHANGE:` footer (or `feat!:`) on at least + one commit to bump to 1.0.0 — the vendor-output format change is the break. -### Tier 3 — next features (agreed backlog) -- [ ] #5 done? precedence config shipped. (priority-vs-type configurable — DONE) -- [ ] #6 `--budget` override on `pack`; lint warning when one fragment > budget. -- [ ] #7 Generate `schema/manifest.schema.json` from zod for TOML autocomplete - (the `schema/` dir + package keyword exist but the file is empty). -- [ ] #8 Per-vendor formatting (Cursor vs Claude idioms) + `--target` flag on build. -- [ ] #9 `agenticscope mcp-config` — print ready-to-paste MCP JSON per host. -- [ ] #10 Optional exact tokenizer (gpt-tokenizer, pure JS) behind a flag. +## Backlog — DONE in this 1.0.0 cycle +- [x] #6 `pack --budget` override + lint warning for oversized fragments. +- [x] #7 `schema/manifest.schema.json` generated from zod (+ `schema` command). +- [x] #8 per-vendor formatting + `build --target`. +- [x] #9 `agenticscope mcp-config`. +- [x] #10 optional exact tokenizer (`gpt-tokenizer`) behind `--exact`. +- [x] #12 MCP HTTP transport (`--http`). +- [x] #13 MCP path-scope guard. +- [x] README badges (npm/CI/license/node). -### Tier 4 — distribution / hardening -- [ ] #11 Separate CHANGELOG.md (keep-a-changelog) + README badges (npm/CI/license). -- [ ] #12 HTTP transport for MCP (currently stdio-only) for remote/hosted use. -- [ ] #13 Path-scope guard: restrict MCP `project` args to within the workspace root. +## Future ideas (not started) +- Watch mode for `build` (recompile on `.scope/` change). +- Stateful HTTP MCP sessions (current HTTP mode is stateless per-request). +- Per-fragment include/exclude globs beyond a single `path`. -### Known issues / notes -- Dev-only audit vulns in `vite-node` (via vitest). Prod deps: 0 vulns. Shipped - package is `dist/` only, so users are unaffected. Clearing needs a breaking - vitest major bump — deferred. -- `list_subagents` / `list_plans` require an absolute `project` path argument; - consider accepting a project name resolved against the workspace. -- Token counts are estimates (chars/4); budgets are approximate by design. +## Known issues / notes +- Dev-only audit vulns in `vite-node` (via vitest). Prod deps clean; shipped + package is `dist/` only. Clearing needs a breaking vitest bump — deferred. +- Token counts default to estimates (`chars/4`); use `--exact` / `exact: true` + for precise counts. Budgets are approximate by design otherwise. +- `context.md` is untracked working notes (not committed to any branch). diff --git a/package-lock.json b/package-lock.json index 594a80a..40e77a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,22 @@ { "name": "agenticscope", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenticscope", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "commander": "^12.1.0", + "gpt-tokenizer": "^2.9.0", "picomatch": "^4.0.2", "simple-git": "^3.27.0", "smol-toml": "^1.3.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" }, "bin": { "agenticscope": "dist/cli.js", @@ -3354,6 +3356,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gpt-tokenizer": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-2.9.0.tgz", + "integrity": "sha512-YSpexBL/k4bfliAzMrRqn3M6+it02LutVyhVpDeMKrC/O9+pCe/5s8U2hYKa2vFLD5/vHhsKc8sOn/qGqII8Kg==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", diff --git a/package.json b/package.json index f1157c0..96f92d8 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,12 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "commander": "^12.1.0", + "gpt-tokenizer": "^2.9.0", "picomatch": "^4.0.2", "simple-git": "^3.27.0", "smol-toml": "^1.3.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json new file mode 100644 index 0000000..4b17039 --- /dev/null +++ b/schema/manifest.schema.json @@ -0,0 +1,95 @@ +{ + "$ref": "#/definitions/AgenticscopeManifest", + "definitions": { + "AgenticscopeManifest": { + "type": "object", + "properties": { + "scope": { + "type": "object", + "properties": { + "version": { + "type": "string", + "default": "0.1.0" + }, + "budget": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 4000 + }, + "name": { + "type": "string" + }, + "precedence": { + "type": "string", + "enum": [ + "type", + "priority" + ], + "default": "type" + } + }, + "additionalProperties": false + }, + "fragment": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": [ + "rule", + "knowledge", + "spec", + "persona" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "priority": { + "type": "integer", + "default": 50 + }, + "always": { + "type": "boolean", + "default": false + } + }, + "required": [ + "id", + "type", + "path" + ], + "additionalProperties": false + }, + "default": [] + } + }, + "required": [ + "scope" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/cli.ts b/src/cli.ts index daf256b..ed4cb2c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,9 @@ import { Command } from "commander"; import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { loadManifest, MANIFEST_FILENAME, isProject } from "./core/manifest.js"; -import { pack, renderPack } from "./core/fragments.js"; -import { build, VENDOR_TARGETS } from "./core/vendor.js"; +import { pack, renderPack, oversizedFragments } from "./core/fragments.js"; +import { build, VENDOR_TARGETS, resolveTargets } from "./core/vendor.js"; +import { writeSchema, SCHEMA_PATH } from "./core/schema.js"; // Single source of truth for the version: package.json (kept in sync by semantic-release). const pkg = JSON.parse( @@ -67,6 +68,10 @@ program problems.push(`${f.id}: no triggers/keywords and not 'always' → will never be packed`); } } + // Warn (don't fail) about fragments that alone exceed the budget. + for (const o of oversizedFragments(loaded)) { + console.warn(`⚠ ${o.id}: ${o.tokens} tok exceeds budget ${o.budget} → can never be packed`); + } if (problems.length === 0) { console.log(`✓ Valid. ${loaded.manifest.fragment.length} fragment(s), budget ${loaded.manifest.scope.budget} tok.`); } else { @@ -81,9 +86,20 @@ program .command("build") .description("Compile .scope/ into vendor files (CLAUDE.md, GEMINI.md, AGENTS.md, .cursorrules).") .argument("[dir]", "Project directory", ".") - .action((dir: string) => { + .option( + "-t, --target ", + `Only build these vendors (${VENDOR_TARGETS.map((t) => t.name).join(", ")})`, + ) + .action((dir: string, opts: { target?: string[] }) => { const loaded = loadManifest(resolve(dir)); - const { written } = build(loaded); + let targets; + try { + targets = resolveTargets(opts.target); + } catch (err) { + console.error(`✗ ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + const { written } = build(loaded, targets); console.log(`✓ Built ${written.length} vendor file(s):`); for (const f of written) { const label = VENDOR_TARGETS.find((t) => t.file === f)?.label ?? ""; @@ -98,21 +114,73 @@ program .argument("", "Task description") .option("-d, --dir ", "Project directory", ".") .option("-p, --path ", "Concrete file paths the task touches") + .option("-b, --budget ", "Override the manifest token budget", (v) => parseInt(v, 10)) + .option("--exact", "Use the exact tokenizer (gpt-tokenizer) instead of the chars/4 estimate") .option("--raw", "Print only the packed context (no report)") - .action((taskWords: string[], opts: { dir: string; path?: string[]; raw?: boolean }) => { - const loaded = loadManifest(resolve(opts.dir)); - const text = taskWords.join(" "); - const result = pack(loaded, { text, paths: opts.path }); - if (opts.raw) { - console.log(renderPack(result)); - return; - } - console.log(`► "${text}" — matched ${result.included.length} fragment(s) (budget ${result.budget}, used ${result.used})`); - for (const r of result.included) { - console.log(` [${r.fragment.type}] ${r.fragment.id.padEnd(20)} ${String(r.tokens).padStart(5)} tok`); - } - for (const s of result.skipped) console.log(` — skipped ${s.id} (${s.reason})`); - console.log("\n" + renderPack(result)); + .action( + ( + taskWords: string[], + opts: { dir: string; path?: string[]; budget?: number; exact?: boolean; raw?: boolean }, + ) => { + const loaded = loadManifest(resolve(opts.dir)); + const text = taskWords.join(" "); + const result = pack(loaded, { text, paths: opts.path }, { budget: opts.budget, exact: opts.exact }); + if (opts.raw) { + console.log(renderPack(result)); + return; + } + const tk = opts.exact ? "exact" : "est"; + console.log( + `► "${text}" — matched ${result.included.length} fragment(s) (budget ${result.budget}, used ${result.used} ${tk})`, + ); + for (const r of result.included) { + console.log(` [${r.fragment.type}] ${r.fragment.id.padEnd(20)} ${String(r.tokens).padStart(5)} tok`); + } + for (const s of result.skipped) console.log(` — skipped ${s.id} (${s.reason})`); + console.log("\n" + renderPack(result)); + }, + ); + +// ---- schema ---------------------------------------------------------------- +program + .command("schema") + .description("Generate the JSON Schema for agenticscope.toml (editor autocomplete/validation).") + .argument("[dir]", "Project directory", ".") + .option("-o, --out ", "Output path", SCHEMA_PATH) + .action((dir: string, opts: { out: string }) => { + const out = resolve(dir, opts.out); + writeSchema(out); + console.log(`✓ Wrote manifest JSON Schema → ${opts.out}`); + console.log(" Point your TOML editor at it (e.g. Even Better TOML) for autocomplete."); + }); + +// ---- mcp-config ------------------------------------------------------------ +program + .command("mcp-config") + .description("Print ready-to-paste MCP server config for a host (Claude Desktop, Cursor, generic).") + .option("-w, --workspace ", "Workspace root the server should scan", ".") + .option("--host ", "claude | cursor | generic", "claude") + .action((opts: { workspace: string; host: string }) => { + const workspace = resolve(opts.workspace); + const serverEntry = { + command: "agenticscope-mcp", + args: ["--workspace", workspace], + }; + const host = opts.host.toLowerCase(); + // Claude Desktop and Cursor both nest under "mcpServers"; generic is the same + // shape, shown bare so it can be adapted to any host. + const config = + host === "generic" + ? { agenticscope: serverEntry } + : { mcpServers: { agenticscope: serverEntry } }; + const where = + host === "claude" + ? "→ claude_desktop_config.json" + : host === "cursor" + ? "→ .cursor/mcp.json (or Cursor Settings → MCP)" + : ""; + if (where) console.log(`# ${where}`); + console.log(JSON.stringify(config, null, 2)); }); // ---- starter templates ----------------------------------------------------- diff --git a/src/core/fragments.ts b/src/core/fragments.ts index 6ea9c94..8616fee 100644 --- a/src/core/fragments.ts +++ b/src/core/fragments.ts @@ -71,6 +71,14 @@ function loadContent(root: string, fragment: Fragment): string { return readFileSync(full, "utf8"); } +/** Options controlling a pack: budget override and tokenizer selection. */ +export interface PackOptions { + /** Override the manifest's token budget for this pack. */ + budget?: number; + /** Use the exact tokenizer (gpt-tokenizer) instead of the chars/4 estimate. */ + exact?: boolean; +} + /** * Resolve a query into a budgeted set of fragments. * @@ -78,9 +86,13 @@ function loadContent(root: string, fragment: Fragment): string { * (desc), then smaller token cost first. Fragments are added until the token * budget is exhausted; the rest are reported as skipped. */ -export function pack(loaded: LoadedManifestLikeShape, query: PackQuery): PackResult { +export function pack( + loaded: LoadedManifestLikeShape, + query: PackQuery, + opts: PackOptions = {}, +): PackResult { const { manifest, root } = loaded; - const budget = manifest.scope.budget; + const budget = opts.budget ?? manifest.scope.budget; const candidates = manifest.fragment .map((fragment) => { @@ -115,7 +127,7 @@ export function pack(loaded: LoadedManifestLikeShape, query: PackQuery): PackRes for (const { fragment, matchedTriggers } of ordered) { const content = loadContent(root, fragment); - const tokens = estimateTokens(content); + const tokens = estimateTokens(content, { exact: opts.exact }); if (used + tokens > budget) { skipped.push({ id: fragment.id, @@ -130,6 +142,26 @@ export function pack(loaded: LoadedManifestLikeShape, query: PackQuery): PackRes return { query, budget, used, included, skipped }; } +/** + * Find fragments whose own content exceeds the budget — they can never be + * packed, no matter the query. Missing files are skipped (lint reports those + * separately). Used by `lint` to warn about dead weight. + */ +export function oversizedFragments( + loaded: LoadedManifestLikeShape, + opts: PackOptions = {}, +): { id: string; tokens: number; budget: number }[] { + const { manifest, root } = loaded; + const budget = opts.budget ?? manifest.scope.budget; + const over: { id: string; tokens: number; budget: number }[] = []; + for (const fragment of manifest.fragment) { + if (!existsSync(join(root, fragment.path))) continue; + const tokens = estimateTokens(loadContent(root, fragment), { exact: opts.exact }); + if (tokens > budget) over.push({ id: fragment.id, tokens, budget }); + } + return over; +} + /** Render a PackResult to a plain-text context block for pasting into any model. */ export function renderPack(result: PackResult): string { const parts: string[] = []; diff --git a/src/core/schema.ts b/src/core/schema.ts new file mode 100644 index 0000000..a92646b --- /dev/null +++ b/src/core/schema.ts @@ -0,0 +1,30 @@ +import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { ManifestSchema } from "./types.js"; + +/** Path (relative to project root) where the generated schema lives. */ +export const SCHEMA_PATH = "schema/manifest.schema.json"; + +/** + * Build a JSON Schema for agenticscope.toml from the zod ManifestSchema. + * Editors with TOML support (e.g. Even Better TOML) use this for validation + * and autocomplete. Kept generated so it can never drift from the zod source. + */ +export function manifestJsonSchema(): Record { + return zodToJsonSchema(ManifestSchema, { + name: "AgenticscopeManifest", + $refStrategy: "none", + }) as Record; +} + +/** Serialize the schema as pretty JSON (newline-terminated). */ +export function renderSchema(): string { + return JSON.stringify(manifestJsonSchema(), null, 2) + "\n"; +} + +/** Write the schema to `outPath`, creating parent dirs as needed. */ +export function writeSchema(outPath: string): void { + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, renderSchema(), "utf8"); +} diff --git a/src/core/tokens.ts b/src/core/tokens.ts index edbfd7b..8a4f222 100644 --- a/src/core/tokens.ts +++ b/src/core/tokens.ts @@ -1,13 +1,40 @@ +import { createRequire } from "node:module"; + /** * Lightweight token estimator. * - * Deliberately dependency-free: a real BPE tokenizer (tiktoken) is heavy and - * model-specific. For budgeting decisions a ~chars/4 heuristic is close enough, - * and it never blocks install or adds native build steps. Swap this single - * function out if you need exact, per-model counts. + * Default: dependency-free ~chars/4 heuristic. A real BPE tokenizer is heavier + * and model-specific, but for budgeting decisions the heuristic is close enough + * and never blocks install or adds native build steps. + * + * Exact mode: pass `{ exact: true }` to use `gpt-tokenizer` (pure JS, cl100k + * base — what GPT-4/3.5 and roughly Claude use). The dependency is loaded lazily + * the first time exact mode is requested, so the default path stays zero-cost. */ -export function estimateTokens(text: string): number { +export interface TokenOptions { + exact?: boolean; +} + +const require = createRequire(import.meta.url); +let exactEncode: ((text: string) => unknown[]) | null = null; + +/** Lazily load gpt-tokenizer's encoder; throws a friendly error if absent. */ +function getExactEncoder(): (text: string) => unknown[] { + if (exactEncode) return exactEncode; + try { + const mod = require("gpt-tokenizer") as { encode: (t: string) => unknown[] }; + exactEncode = mod.encode; + return exactEncode; + } catch { + throw new Error( + "Exact tokenization needs the optional 'gpt-tokenizer' package. Install it with `npm i gpt-tokenizer`.", + ); + } +} + +export function estimateTokens(text: string, opts: TokenOptions = {}): number { if (!text) return 0; + if (opts.exact) return getExactEncoder()(text).length; // ~4 chars per token is the standard rough English heuristic. return Math.ceil(text.length / 4); } diff --git a/src/core/vendor.ts b/src/core/vendor.ts index a6f15b6..f59514d 100644 --- a/src/core/vendor.ts +++ b/src/core/vendor.ts @@ -4,17 +4,49 @@ import type { Fragment } from "./types.js"; import type { LoadedManifest } from "./manifest.js"; export interface VendorTarget { + /** Selector used by `build --target` (e.g. "claude", "cursor"). */ + name: string; /** Output filename, relative to project root. */ file: string; label: string; + /** Vendor-specific preamble rendered above the shared context sections. */ + preamble: (projectName: string) => string; } +const GENERATED_NOTE = + ""; + /** Vendor files agenticscope can compile the .scope/ source into. */ export const VENDOR_TARGETS: VendorTarget[] = [ - { file: "CLAUDE.md", label: "Claude" }, - { file: "GEMINI.md", label: "Gemini" }, - { file: "AGENTS.md", label: "OpenAI / Codex" }, - { file: ".cursorrules", label: "Cursor" }, + { + name: "claude", + file: "CLAUDE.md", + label: "Claude", + preamble: (n) => + `# CLAUDE.md\n\n${GENERATED_NOTE}\n\nProject memory for **${n}**. Treat the rules below as standing instructions.`, + }, + { + name: "gemini", + file: "GEMINI.md", + label: "Gemini", + preamble: (n) => + `# GEMINI.md\n\n${GENERATED_NOTE}\n\nContext for **${n}**. Apply the rules; use the knowledge as reference.`, + }, + { + name: "agents", + file: "AGENTS.md", + label: "OpenAI / Codex", + preamble: (n) => + `# AGENTS.md\n\n${GENERATED_NOTE}\n\nAgent instructions for **${n}**, following the AGENTS.md convention.`, + }, + { + // .cursorrules is a plain instruction file, not a markdown doc — no H1. + name: "cursor", + file: ".cursorrules", + label: "Cursor", + preamble: (n) => + `You are an AI assistant working on ${n}. Follow these rules and use the context below.\n# (generated by agenticscope — edit .scope/, then run \`agenticscope build\`)`, + }, ]; const ORDER: Fragment["type"][] = ["rule", "persona", "spec", "knowledge"]; @@ -25,17 +57,10 @@ const HEADINGS: Record = { knowledge: "Reference knowledge", }; -/** Compile a manifest's fragments into a single vendor-agnostic markdown doc. */ -export function compile(loaded: LoadedManifest): string { +/** Render the shared context sections (vendor-agnostic body). */ +function renderSections(loaded: LoadedManifest): string { const { manifest, root } = loaded; - const name = manifest.scope.name ?? "this project"; - const lines: string[] = [ - `# Agent context for ${name}`, - "", - "", - "", - ]; - + const lines: string[] = []; for (const type of ORDER) { const frags = manifest.fragment.filter((f) => f.type === type); if (frags.length === 0) continue; @@ -49,19 +74,40 @@ export function compile(loaded: LoadedManifest): string { lines.push(`### ${f.id}`, "", readFileSync(full, "utf8").trim(), ""); } } - return lines.join("\n").trim() + "\n"; + return lines.join("\n").trim(); +} + +/** + * Compile a manifest's fragments into a doc for a specific vendor. When no + * target is given, the default Claude formatting is used (back-compat). + */ +export function compile(loaded: LoadedManifest, target: VendorTarget = VENDOR_TARGETS[0]!): string { + const name = loaded.manifest.scope.name ?? "this project"; + return `${target.preamble(name)}\n\n${renderSections(loaded)}\n`; } export interface BuildResult { written: string[]; } -/** Compile and write all vendor files to the project root. */ +/** Resolve --target names to VendorTargets; throws on an unknown name. */ +export function resolveTargets(names?: string[]): VendorTarget[] { + if (!names || names.length === 0) return VENDOR_TARGETS; + return names.map((n) => { + const t = VENDOR_TARGETS.find((v) => v.name === n.toLowerCase()); + if (!t) { + const valid = VENDOR_TARGETS.map((v) => v.name).join(", "); + throw new Error(`Unknown vendor target "${n}". Valid targets: ${valid}.`); + } + return t; + }); +} + +/** Compile and write the given vendor files (all of them by default). */ export function build(loaded: LoadedManifest, targets = VENDOR_TARGETS): BuildResult { - const doc = compile(loaded); const written: string[] = []; for (const t of targets) { - writeFileSync(join(loaded.root, t.file), doc, "utf8"); + writeFileSync(join(loaded.root, t.file), compile(loaded, t), "utf8"); written.push(t.file); } return { written }; diff --git a/src/mcp/scope.ts b/src/mcp/scope.ts new file mode 100644 index 0000000..796df43 --- /dev/null +++ b/src/mcp/scope.ts @@ -0,0 +1,19 @@ +import { resolve, relative, isAbsolute, sep } from "node:path"; + +/** + * Path-scope guard. Resolve `project` against the workspace root and reject + * anything that escapes it. Stops a client from using the MCP server to read + * arbitrary host paths (e.g. "/etc/passwd" or "../../secrets"). + * + * Returns the resolved absolute path when it is inside (or equal to) the root. + */ +export function assertWithinWorkspace(workspaceRoot: string, project: string): string { + const root = resolve(workspaceRoot); + const abs = resolve(root, project); + const rel = relative(root, abs); + if (rel === "") return abs; // the workspace root itself + if (rel.startsWith("..") || isAbsolute(rel) || rel.split(sep)[0] === "..") { + throw new Error(`project path is outside the workspace root: ${project}`); + } + return abs; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 47ab821..1ef1e73 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,25 +1,40 @@ #!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; +import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from "node:http"; import { resolve } from "node:path"; import { homedir } from "node:os"; +import { readFileSync } from "node:fs"; import { scanProjects, fragmentsByType } from "./workspace.js"; +import { assertWithinWorkspace } from "./scope.js"; import { gitStatus } from "./git.js"; import { grepMemory } from "./grep.js"; import { loadManifest } from "../core/manifest.js"; import { pack, renderPack } from "../core/fragments.js"; +const pkg = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +) as { version: string }; + +/** Read a CLI flag value, e.g. --workspace ; returns undefined if absent. */ +function flag(name: string): string | undefined { + const i = process.argv.indexOf(name); + return i !== -1 ? process.argv[i + 1] : undefined; +} + /** Resolve the workspace root from --workspace, AGENTICSCOPE_WORKSPACE, or cwd. */ function resolveWorkspace(): string { - const flagIdx = process.argv.indexOf("--workspace"); - const fromFlag = flagIdx !== -1 ? process.argv[flagIdx + 1] : undefined; - const raw = fromFlag ?? process.env.AGENTICSCOPE_WORKSPACE ?? process.cwd(); + const raw = flag("--workspace") ?? process.env.AGENTICSCOPE_WORKSPACE ?? process.cwd(); return resolve(raw.replace(/^~(?=$|\/)/, homedir())); } const WORKSPACE = resolveWorkspace(); +/** Resolve + scope-check a project arg against this server's workspace root. */ +const withinWorkspace = (project: string) => assertWithinWorkspace(WORKSPACE, project); + const json = (data: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], }); @@ -45,98 +60,170 @@ function guard(fn: (args: A) => Promise>) { }; } -const server = new McpServer({ name: "agenticscope", version: "0.1.0" }); - -server.registerTool( - "list_projects", - { - description: - "List every agenticscope project in the workspace, with fragment counts and git presence.", - inputSchema: {}, - }, - guard(async () => json({ workspace: WORKSPACE, projects: scanProjects(WORKSPACE) })), -); - -server.registerTool( - "list_subagents", - { - description: - "List the personas/subagents defined for a project (its .scope/personas fragments).", - inputSchema: { project: z.string().describe("Absolute path to the project directory") }, - }, - guard(async ({ project }) => json({ project, subagents: fragmentsByType(project, "persona") })), -); - -server.registerTool( - "list_plans", - { - description: "List the plans/specs currently in flight for a project (.scope/specs fragments).", - inputSchema: { project: z.string().describe("Absolute path to the project directory") }, - }, - guard(async ({ project }) => json({ project, plans: fragmentsByType(project, "spec") })), -); - -server.registerTool( - "git_status", - { - description: "Read-only git state for every project in the workspace (branch, ahead/behind, dirty count).", - inputSchema: {}, - }, - guard(async () => { - const projects = scanProjects(WORKSPACE); - const results = await Promise.all( - projects.map(async (p) => ({ name: p.name, path: p.path, git: await gitStatus(p.path) })), - ); - return json({ workspace: WORKSPACE, repos: results }); - }), -); - -server.registerTool( - "grep_memory", - { - description: "Fast grep over .scope/memory/ files across all workspace projects.", - inputSchema: { - pattern: z.string().describe("Regex or literal string to search for"), - ignoreCase: z.boolean().optional().describe("Case-insensitive (default true)"), +/** Build a fully-configured agenticscope MCP server (no transport attached). */ +export function createServer(): McpServer { + const server = new McpServer({ name: "agenticscope", version: pkg.version }); + + server.registerTool( + "list_projects", + { + description: + "List every agenticscope project in the workspace, with fragment counts and git presence.", + inputSchema: {}, + }, + guard(async () => json({ workspace: WORKSPACE, projects: scanProjects(WORKSPACE) })), + ); + + server.registerTool( + "list_subagents", + { + description: + "List the personas/subagents defined for a project (its .scope/personas fragments).", + inputSchema: { project: z.string().describe("Project directory (within the workspace)") }, }, - }, - guard(async ({ pattern, ignoreCase }) => { - const projects = scanProjects(WORKSPACE); - const hits = grepMemory(projects, pattern, { ignoreCase: ignoreCase ?? true }); - return json({ pattern, hitCount: hits.length, hits }); - }), -); - -server.registerTool( - "pack_context", - { - description: - "Resolve a task into a token-budgeted set of context fragments for a project. Returns the packed context and what was skipped and why.", - inputSchema: { - project: z.string().describe("Absolute path to the project directory"), - task: z.string().describe("Free-text task description, e.g. 'fix the sql migration'"), - paths: z.array(z.string()).optional().describe("Concrete file paths the task touches"), + guard(async ({ project }) => { + const abs = withinWorkspace(project); + return json({ project: abs, subagents: fragmentsByType(abs, "persona") }); + }), + ); + + server.registerTool( + "list_plans", + { + description: "List the plans/specs currently in flight for a project (.scope/specs fragments).", + inputSchema: { project: z.string().describe("Project directory (within the workspace)") }, + }, + guard(async ({ project }) => { + const abs = withinWorkspace(project); + return json({ project: abs, plans: fragmentsByType(abs, "spec") }); + }), + ); + + server.registerTool( + "git_status", + { + description: + "Read-only git state for every project in the workspace (branch, ahead/behind, dirty count).", + inputSchema: {}, + }, + guard(async () => { + const projects = scanProjects(WORKSPACE); + const results = await Promise.all( + projects.map(async (p) => ({ name: p.name, path: p.path, git: await gitStatus(p.path) })), + ); + return json({ workspace: WORKSPACE, repos: results }); + }), + ); + + server.registerTool( + "grep_memory", + { + description: "Fast grep over .scope/memory/ files across all workspace projects.", + inputSchema: { + pattern: z.string().describe("Regex or literal string to search for"), + ignoreCase: z.boolean().optional().describe("Case-insensitive (default true)"), + }, }, - }, - guard(async ({ project, task, paths }) => { - const loaded = loadManifest(project); - const result = pack(loaded, { text: task, paths }); - return json({ - budget: result.budget, - used: result.used, - included: result.included.map((r) => ({ - id: r.fragment.id, - type: r.fragment.type, - tokens: r.tokens, - matchedTriggers: r.matchedTriggers, - })), - skipped: result.skipped, - context: renderPack(result), + guard(async ({ pattern, ignoreCase }) => { + const projects = scanProjects(WORKSPACE); + const hits = grepMemory(projects, pattern, { ignoreCase: ignoreCase ?? true }); + return json({ pattern, hitCount: hits.length, hits }); + }), + ); + + server.registerTool( + "pack_context", + { + description: + "Resolve a task into a token-budgeted set of context fragments for a project. Returns the packed context and what was skipped and why.", + inputSchema: { + project: z.string().describe("Project directory (within the workspace)"), + task: z.string().describe("Free-text task description, e.g. 'fix the sql migration'"), + paths: z.array(z.string()).optional().describe("Concrete file paths the task touches"), + budget: z.number().int().positive().optional().describe("Override the manifest token budget"), + }, + }, + guard(async ({ project, task, paths, budget }) => { + const abs = withinWorkspace(project); + const loaded = loadManifest(abs); + const result = pack(loaded, { text: task, paths }, { budget }); + return json({ + budget: result.budget, + used: result.used, + included: result.included.map((r) => ({ + id: r.fragment.id, + type: r.fragment.type, + tokens: r.tokens, + matchedTriggers: r.matchedTriggers, + })), + skipped: result.skipped, + context: renderPack(result), + }); + }), + ); + + return server; +} + +/** Read and JSON-parse an HTTP request body. */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolveBody, rejectBody) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c as Buffer)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) return resolveBody(undefined); + try { + resolveBody(JSON.parse(raw)); + } catch (err) { + rejectBody(err); + } }); - }), -); + req.on("error", rejectBody); + }); +} + +/** + * Serve over Streamable HTTP (stateless: a fresh server + transport per + * request). Suitable for remote/hosted use behind a reverse proxy. + */ +async function startHttp(port: number): Promise { + const httpServer = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.url !== "/mcp") { + res.writeHead(404).end("not found"); + return; + } + try { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const server = createServer(); + res.on("close", () => { + void transport.close(); + void server.close(); + }); + await server.connect(transport); + const body = req.method === "POST" ? await readBody(req) : undefined; + await transport.handleRequest(req, res, body); + } catch (err) { + if (!res.headersSent) res.writeHead(500); + res.end(`agenticscope MCP error: ${err instanceof Error ? err.message : String(err)}`); + } + }); + await new Promise((ready) => httpServer.listen(port, ready)); + console.error(`agenticscope MCP server ready on http://localhost:${port}/mcp (workspace: ${WORKSPACE})`); +} + +/** Serve over stdio (the default — what desktop MCP hosts launch). */ +async function startStdio(): Promise { + const server = createServer(); + await server.connect(new StdioServerTransport()); + // stderr is safe; stdout is reserved for the MCP protocol stream. + console.error(`agenticscope MCP server ready (workspace: ${WORKSPACE})`); +} -const transport = new StdioServerTransport(); -await server.connect(transport); -// stderr is safe; stdout is reserved for the MCP protocol stream. -console.error(`agenticscope MCP server ready (workspace: ${WORKSPACE})`); +const httpFlag = process.argv.includes("--http"); +if (httpFlag) { + const port = Number(flag("--http") ?? process.env.PORT ?? 3000); + await startHttp(Number.isFinite(port) ? port : 3000); +} else { + await startStdio(); +} diff --git a/tests/budget.test.ts b/tests/budget.test.ts new file mode 100644 index 0000000..fb88f0c --- /dev/null +++ b/tests/budget.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { pack, oversizedFragments } from "../src/core/fragments.js"; +import { makeProject } from "./fixture.js"; + +let cleanups: Array<() => void> = []; +afterEach(() => { + cleanups.forEach((c) => c()); + cleanups = []; +}); + +function project(manifest: string, files: { path: string; content: string }[]) { + const fx = makeProject(manifest, files); + cleanups.push(fx.cleanup); + return fx.loaded; +} + +const MANIFEST = ` +[scope] +budget = 4000 +[[fragment]] +id = "rules" +type = "rule" +path = "rules.md" +keywords = ["refactor"] +`; +// ~50 tokens of content. +const FILES = [{ path: "rules.md", content: "x".repeat(200) }]; + +describe("pack budget override", () => { + it("uses the manifest budget by default", () => { + const loaded = project(MANIFEST, FILES); + const res = pack(loaded, { text: "refactor" }); + expect(res.budget).toBe(4000); + expect(res.included.map((r) => r.fragment.id)).toEqual(["rules"]); + }); + + it("respects a budget override that excludes a fragment", () => { + const loaded = project(MANIFEST, FILES); + const res = pack(loaded, { text: "refactor" }, { budget: 5 }); + expect(res.budget).toBe(5); + expect(res.included).toHaveLength(0); + expect(res.skipped.find((s) => s.id === "rules")?.reason).toMatch(/over budget/); + }); +}); + +describe("oversizedFragments", () => { + it("flags fragments whose content alone exceeds the budget", () => { + const loaded = project(MANIFEST, FILES); + const over = oversizedFragments(loaded, { budget: 5 }); + expect(over.map((o) => o.id)).toEqual(["rules"]); + expect(over[0]!.tokens).toBeGreaterThan(5); + }); + + it("returns nothing when fragments fit", () => { + const loaded = project(MANIFEST, FILES); + expect(oversizedFragments(loaded)).toEqual([]); + }); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..a82f9a4 --- /dev/null +++ b/tests/schema.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { manifestJsonSchema, renderSchema } from "../src/core/schema.js"; + +describe("manifest JSON schema", () => { + it("generates a JSON Schema describing the manifest shape", () => { + const schema = manifestJsonSchema(); + const json = JSON.stringify(schema); + // The generated schema should describe the scope + fragment structure. + expect(json).toContain("scope"); + expect(json).toContain("fragment"); + expect(json).toContain("budget"); + expect(json).toContain("precedence"); + }); + + it("renders valid, parseable JSON", () => { + expect(() => JSON.parse(renderSchema())).not.toThrow(); + }); +}); diff --git a/tests/scope.test.ts b/tests/scope.test.ts new file mode 100644 index 0000000..afbeea0 --- /dev/null +++ b/tests/scope.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { resolve, join } from "node:path"; +import { assertWithinWorkspace } from "../src/mcp/scope.js"; + +const WS = resolve("/tmp/workspace"); + +describe("assertWithinWorkspace", () => { + it("allows the workspace root itself", () => { + expect(assertWithinWorkspace(WS, WS)).toBe(WS); + }); + + it("allows a project inside the workspace", () => { + expect(assertWithinWorkspace(WS, join(WS, "api"))).toBe(join(WS, "api")); + }); + + it("resolves a relative project against the workspace", () => { + expect(assertWithinWorkspace(WS, "api")).toBe(join(WS, "api")); + }); + + it("rejects a path that escapes via ..", () => { + expect(() => assertWithinWorkspace(WS, "../secrets")).toThrow(/outside the workspace/); + }); + + it("rejects an absolute path outside the workspace", () => { + expect(() => assertWithinWorkspace(WS, "/etc/passwd")).toThrow(/outside the workspace/); + }); +}); diff --git a/tests/tokens.test.ts b/tests/tokens.test.ts new file mode 100644 index 0000000..963bccc --- /dev/null +++ b/tests/tokens.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { estimateTokens } from "../src/core/tokens.js"; + +describe("estimateTokens", () => { + it("uses the chars/4 heuristic by default", () => { + expect(estimateTokens("")).toBe(0); + expect(estimateTokens("x".repeat(40))).toBe(10); + }); + + it("counts exact tokens with gpt-tokenizer when exact: true", () => { + const text = "The quick brown fox jumps over the lazy dog."; + const exact = estimateTokens(text, { exact: true }); + expect(exact).toBeGreaterThan(0); + // Exact BPE differs from the chars/4 estimate for normal prose. + expect(exact).not.toBe(estimateTokens(text)); + }); +}); diff --git a/tests/vendor.test.ts b/tests/vendor.test.ts index b111489..92b5c26 100644 --- a/tests/vendor.test.ts +++ b/tests/vendor.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { compile, build } from "../src/core/vendor.js"; +import { compile, build, resolveTargets, VENDOR_TARGETS } from "../src/core/vendor.js"; import { makeProject } from "./fixture.js"; let cleanup = () => {}; @@ -28,8 +28,9 @@ describe("vendor build", () => { it("compiles fragments into a single doc with type sections in order", () => { const fx = makeProject(MANIFEST, FILES); cleanup = fx.cleanup; - const doc = compile(fx.loaded); - expect(doc).toContain("# Agent context for demo"); + const doc = compile(fx.loaded); // defaults to Claude + expect(doc).toContain("# CLAUDE.md"); + expect(doc).toContain("Project memory for **demo**"); expect(doc).toContain("GENERATED by agenticscope"); // rules section appears before knowledge section expect(doc.indexOf("Rules")).toBeLessThan(doc.indexOf("Reference knowledge")); @@ -37,15 +38,37 @@ describe("vendor build", () => { expect(doc).toContain("CREATE TABLE t"); }); + it("renders vendor-specific preambles", () => { + const fx = makeProject(MANIFEST, FILES); + cleanup = fx.cleanup; + const cursor = VENDOR_TARGETS.find((t) => t.name === "cursor")!; + const doc = compile(fx.loaded, cursor); + // .cursorrules is a plain instruction file — no markdown H1, no CLAUDE.md. + expect(doc).toContain("You are an AI assistant working on demo"); + expect(doc).not.toContain("# CLAUDE.md"); + expect(doc).toContain("no any types"); + }); + it("writes all vendor target files to disk", () => { const fx = makeProject(MANIFEST, FILES); cleanup = fx.cleanup; const { written } = build(fx.loaded); expect(written).toEqual(["CLAUDE.md", "GEMINI.md", "AGENTS.md", ".cursorrules"]); for (const f of written) { - const p = join(fx.root, f); - expect(existsSync(p)).toBe(true); - expect(readFileSync(p, "utf8")).toContain("Agent context for demo"); + expect(existsSync(join(fx.root, f))).toBe(true); } + expect(readFileSync(join(fx.root, "GEMINI.md"), "utf8")).toContain("# GEMINI.md"); + }); + + it("build --target writes only the selected vendors", () => { + const fx = makeProject(MANIFEST, FILES); + cleanup = fx.cleanup; + const { written } = build(fx.loaded, resolveTargets(["cursor", "claude"])); + expect(written.sort()).toEqual([".cursorrules", "CLAUDE.md"]); + expect(existsSync(join(fx.root, "GEMINI.md"))).toBe(false); + }); + + it("resolveTargets rejects an unknown vendor", () => { + expect(() => resolveTargets(["nope"])).toThrow(/Unknown vendor target/); }); });