From 891fcc6924479dd00a4cf92d1434210d891ed105 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 26 Oct 2026 15:02:41 +0100 Subject: [PATCH 1/2] unreviewed initial implementation --- .changeset/skill-transforms.md | 5 + docs/cli/intent-configure.md | 61 ++++ docs/cli/intent-load.md | 22 +- docs/concepts/skill-transforms.md | 107 ++++++ docs/concepts/trust-model.md | 10 +- docs/config.json | 8 + packages/intent/package.json | 4 + packages/intent/src/cli.ts | 45 ++- packages/intent/src/commands/configure.ts | 34 ++ packages/intent/src/commands/load.ts | 9 +- packages/intent/src/commands/validate.ts | 12 + packages/intent/src/core/index.ts | 9 + packages/intent/src/core/intent-core.ts | 78 ++++- packages/intent/src/core/types.ts | 2 + packages/intent/src/transforms/commands.ts | 143 ++++++++ packages/intent/src/transforms/runtime.ts | 308 ++++++++++++++++++ packages/intent/src/transforms/types.ts | 38 +++ packages/intent/src/transforms/worker.ts | 115 +++++++ .../tests/integration/transforms.test.ts | 262 +++++++++++++++ .../intent/tests/transform-commands.test.ts | 113 +++++++ packages/intent/tests/transforms.test.ts | 252 ++++++++++++++ packages/intent/tsdown.config.ts | 27 +- pnpm-lock.yaml | 9 + 23 files changed, 1657 insertions(+), 16 deletions(-) create mode 100644 .changeset/skill-transforms.md create mode 100644 docs/cli/intent-configure.md create mode 100644 docs/concepts/skill-transforms.md create mode 100644 packages/intent/src/commands/configure.ts create mode 100644 packages/intent/src/transforms/commands.ts create mode 100644 packages/intent/src/transforms/runtime.ts create mode 100644 packages/intent/src/transforms/types.ts create mode 100644 packages/intent/src/transforms/worker.ts create mode 100644 packages/intent/tests/integration/transforms.test.ts create mode 100644 packages/intent/tests/transform-commands.test.ts create mode 100644 packages/intent/tests/transforms.test.ts diff --git a/.changeset/skill-transforms.md b/.changeset/skill-transforms.md new file mode 100644 index 00000000..bfdda9a3 --- /dev/null +++ b/.changeset/skill-transforms.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': minor +--- + +Add optional skill transforms for persistent project onboarding and individualized guidance. `intent load` invokes `load()`, and the new `intent configure` forwards question/answer pairs and stdin to `configure()`. Both functions receive invocation-aware load/configure commands for their instructions. Transforms run with in-memory skill/config filesystems inside a restricted Node subprocess and VM on Node 22+. diff --git a/docs/cli/intent-configure.md b/docs/cli/intent-configure.md new file mode 100644 index 00000000..2a8b9260 --- /dev/null +++ b/docs/cli/intent-configure.md @@ -0,0 +1,61 @@ +--- +title: intent configure +id: intent-configure +--- + +> **Note:** This command is intended to be called by agents in most cases, following the setup instructions returned by `intent load`. + +`intent configure` calls a skill's `intent-transform` module's `configure()` function. It forwards question/answer pairs and piped stdin, saves successful config changes, and prints the returned string. + + + +```text +@tanstack/intent@latest configure # [--answer ] [--reset] [--global] [--global-only] +``` + + + +## Inputs + +- `--reset` starts `configure()` with an empty config folder. Answers and stdin are still forwarded, so you can reset and submit new preferences in one call. The reset and any new writes are saved together only if the transform succeeds; failures preserve the previous configuration. + +- Repeat `--answer 'question=value'` to submit multiple answers. Intent splits each argument at the first `=` and preserves order and duplicate question IDs. IDs must be nonempty and cannot contain `=`. Values remain strings, including leading zeros, empty values (`Q1=`), additional `=` characters, and newlines. Quote arguments containing shell-special characters or whitespace. +- Piped stdin is forwarded as a UTF-8 string, independently of the flags. Intent does not parse JSON or decide which input takes precedence. The transform decides. +- With terminal stdin, Intent supplies an empty string without prompting. With a pipe, it reads until EOF, up to 4 MiB. +- `--global` includes global packages after project packages; `--global-only` resolves only global packages. Config still belongs to the consuming project. + +```sh +npx @tanstack/intent@latest configure '@apollo/client#apollo-client' \ + --answer 'Q1=closures' \ + --answer 'Q2=fragments' + +cat preferences.json | npx @tanstack/intent@latest configure '@apollo/client#apollo-client' +``` + +## Output and persistence + +The returned string is written to stdout without a banner or added newline. It can acknowledge saved choices or instruct the agent to inspect the repository and ask follow-up questions. Diagnostics go to stderr. + +The function also receives `commands.load` and `commands.configure`, reflecting the detected launcher (for example, npx with its package tag or `npm run intent --`). See [Invocation-aware instructions](../concepts/skill-transforms#invocation-aware-instructions) for detection and fallback behavior. + +Configuration lives under the owning consumer package's `.intent/skill-config/` directory, namespaced by the resolved package and canonical skill identity using portable encoded path segments. Without an owning `package.json`, the current directory is used. Package versions are excluded from the key, so upgrades retain answers. Commit this directory when selections should be shared with teammates; do not store secrets in skill preferences. + +Both commands use the same source allowlist and exclusions. A skill without `intent-transform` cannot be configured. Node.js 22+ is required for transforms. + +The transform's writes are staged in memory. Exceptions, invalid results, timeouts, and invalid paths discard the staged changes and exit nonzero. Concurrent configuration of the same skill is rejected with a retry message. After a process crash, a stale lock may require manual removal; the error identifies its path. + +## Conversation flow + +1. Run `intent load ` to receive setup instructions. +2. The agent inspects the consumer's repository and asks the user questions. +3. Run `intent configure ` with individual answers, batches, or stdin as instructed by the skill. +4. Follow any additional instructions returned by `configure()`. +5. Run `intent load ` again to receive individualized guidance. + +Intent does not define question IDs, preference schemas, or an onboarding-complete flag. Those belong to the transform. + +## Related + +- [Skill transforms](../concepts/skill-transforms) +- [intent load](./intent-load) +- [Trust model](../concepts/trust-model) diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 6e54ecb1..0729602b 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -3,18 +3,26 @@ title: intent load id: intent-load --- -`intent load` loads a compact skill identity from the current install and prints the matching `SKILL.md` content. +`intent load` loads a compact skill identity from the current install and prints its guidance. For a skill with `intent-transform`, it calls the module's `load()` function and prints the returned Markdown instead of the packaged body. ```text -@tanstack/intent@latest load # [--path] [--json] [--debug] [--global] [--global-only] +@tanstack/intent@latest load # [...args] [--path] [--json] [--debug] [--global] [--global-only] ``` ## Options +Trailing positional arguments are forwarded to the transform as `args`, an array of strings (empty by default). Their meaning is defined by the skill. For example, a skill could support: + +```sh +npx @tanstack/intent@latest load '@apollo/client#apollo-client' reconfigure +``` + +Use `--` before arguments that begin with a dash. Arguments have no effect for skills without a transform or when using `--path`. + - `--path`: print the resolved skill path instead of the file content - `--json`: print structured JSON with metadata and content - `--debug`: print resolution debug details to stderr @@ -38,7 +46,7 @@ id: intent-load ### Output -- Prints raw `SKILL.md` content by default +- Prints `SKILL.md` content, or the transform's returned string, with relative Markdown links resolved for the consumer - Prints the scanner-reported path when `--path` is passed - Prints debug details to stderr when `--debug` is passed @@ -59,6 +67,10 @@ Examples: ## JSON output +Transform functions receive `commands.load` and `commands.configure` so their instructions can reuse the detected launcher, including npm scripts and npx package tags. See [Invocation-aware instructions](../concepts/skill-transforms#invocation-aware-instructions). + +For transformed skills, `content` contains the individualized output while `path` continues to identify the original packaged `SKILL.md`. `--path` never executes a transform. + `--json` prints: ```json @@ -76,6 +88,8 @@ Examples: ## Common errors +Transforms require Node.js 22+ and can fail on invalid declarations, invalid output, or execution limits. Configuration writes are committed only after successful execution. See [Skill transforms](../concepts/skill-transforms). + ### Invalid skill identity - Missing separator: `Invalid skill use "@tanstack/query": expected #.` @@ -96,6 +110,8 @@ Examples: ## Related +- [intent configure](./intent-configure) +- [Skill transforms](../concepts/skill-transforms) - [intent list](./intent-list) - [intent install](./intent-install) - [Trust model](../concepts/trust-model) diff --git a/docs/concepts/skill-transforms.md b/docs/concepts/skill-transforms.md new file mode 100644 index 00000000..ba7218cd --- /dev/null +++ b/docs/concepts/skill-transforms.md @@ -0,0 +1,107 @@ +--- +title: Skill transforms +id: skill-transforms +--- + +A skill transform customizes guidance for the consuming application while keeping the installed package unchanged. On first load it can return onboarding instructions. After the agent collects preferences, subsequent loads return individualized guidance. An upgraded transform sees the saved answers and can ask only newly applicable questions. + +## Declare a transform + +Add this Intent extension to the packaged `SKILL.md` frontmatter: + +```yaml +--- +name: apollo-client +description: Guidance for applications using Apollo Client. +intent-transform: + entry: ./scripts/transform.mjs + api: 1 +--- +Load this skill through Intent to configure it for your application. +``` + +The entry must be a regular file inside the skill directory. The module must be self-contained JavaScript: imports are disabled, so bundle helpers into that file. Export synchronous `load` and `configure` functions that return strings. Promises and other return types fail. + +```js +function readAnswers(config) { + return config.exists('answers.json') + ? JSON.parse(config.readFile('answers.json')) + : {} +} + +export function load({ skill, config, commands }) { + const answers = readAnswers(config) + if (!answers.queryStyle) { + return `Inspect the application and ask whether to use Suspense. Save the choice with ${commands.configure} --answer 'queryStyle=suspense' or --answer 'queryStyle=standard', then run ${commands.load}.` + } + return skill.readFile( + answers.queryStyle === 'suspense' + ? 'references/suspense.md' + : 'references/queries.md', + ) +} + +export function configure({ config, answers, stdin, commands }) { + const saved = readAnswers(config) + const incoming = stdin + ? JSON.parse(stdin) + : Object.fromEntries(answers.map(({ question, answer }) => [question, answer])) + for (const [question, answer] of Object.entries(incoming)) { + if (question !== 'queryStyle' || !['suspense', 'standard'].includes(answer)) { + throw new Error('queryStyle must be suspense or standard') + } + saved[question] = answer + } + config.writeFile('answers.json', JSON.stringify(saved, null, 2)) + return `Preferences saved. Run ${commands.load}.` +} +``` + +## Transform API + +Both functions receive `skill`, `config`, and `commands`. `load` additionally receives `args`, an array of trailing command-line arguments, such as `['reconfigure']`. The skill defines what these arguments mean. `configure` additionally receives `answers`, an ordered array of `{ question, answer }` strings, and `stdin`, a string. Flags and stdin are both forwarded; the example chooses stdin when present, but your transform can choose another policy. + +`intent configure --reset` starts the configure function with an empty config folder, optionally alongside new answers or stdin. The reset is committed with the transform's writes only on success. + +| Method | `skill` | `config` | +| --- | --- | --- | +| `readFile(path): string` | Read UTF-8 text | Read UTF-8 text | +| `exists(path): boolean` | Check existence | Check existence | +| `listFiles(): string[]` | Sorted file paths | Sorted file paths | +| `writeFile(path, text): void` | Unavailable | Create or replace a file, including parent directories | +| `removeFile(path): void` | Unavailable | Remove a file; missing files are ignored | + +Paths are root-relative and use `/`. Absolute paths, `.`/`..` segments, backslashes, colons, and symbolic links are rejected. Files are text; binary assets are not supported by this API. Each filesystem is limited to 512 files and 4 MiB of file content. + +`load()` can migrate saved configuration. Preserve explicit answers, use stable question IDs, and ask again only when a choice is missing or cannot be migrated without a decision. Keep source templates separate from generated guidance. + +Relative Markdown links in returned strings are interpreted relative to the original skill directory, even when text was assembled from a reference file. Intent rewrites those links for the consuming project. Discovery continues to use the packaged frontmatter; transforms do not change skill identity or discovery descriptions. + +## Execution and persistence + +Transforms require Node.js 22+; ordinary skills retain Intent's existing Node support. Intent starts a separate permission-restricted Node process with an empty environment and no grants for host filesystem access, child processes, workers, or native addons. Inside it, a VM receives narrow interfaces to in-memory `@platformatic/vfs` instances (pinned to 0.4.0). The skill filesystem is read-only; config changes are staged until success. No VFS is mounted into the host filesystem. + +Node 25+ additionally denies network access through its permission model. Node 22–24 do not enforce network permissions, so network isolation there relies on the best-effort VM restrictions. + +The VM has no `process`, `require`, `fetch`, or timers, and string/Wasm code generation is disabled. Execution has a one-second VM timeout and a five-second subprocess timeout, a 64 MiB V8 heap limit, and bounded input/output. A returned string is limited to 4 MiB. These are best-effort restrictions for trusted dependency scripts, including preventing network tracking; they are not a hostile-code security boundary. + +Only `load` and `configure` execute transforms. Listing, installation discovery, and `load --path` do not. Enabling a skill source also enables its declared transform when these commands are invoked. See [Trust model](./trust-model). + +## Invocation-aware instructions + +Both functions receive `commands.load` and `commands.configure`: complete command strings for the resolved skill. Use these strings in instructions instead of hardcoding an Intent launcher. Append question/answer flags to `commands.configure`, quoting answer values appropriately. + +For example, an invocation through `npx @tanstack/intent@latest` produces: + +```json +{ + "load": "npx @tanstack/intent@latest load '@apollo/client#apollo-client'", + "configure": "npx @tanstack/intent@latest configure '@apollo/client#apollo-client'" +} +``` + +A reusable npm script such as `"intent": "intent"`, invoked as `npm run intent -- load ...`, produces `npm run 'intent' -- load ...` and `npm run 'intent' -- configure ...`. Scripts with fixed actions or compound shell commands cannot serve as a reusable launcher and are not reused. Resolution flags such as `--global-only` are retained; previous answers and output flags such as `--json` are not replayed. + +Intent uses package-manager metadata and, on POSIX systems, a bounded inspection of launcher ancestors to recover npm/npx package tags and reusable outer scripts. Shell quote style and whitespace are normalized. When the original launcher cannot be recovered, it uses the current executable (or the known package-manager exec command) rather than guessing a package version. Custom shell aliases and arbitrary wrappers cannot always be reconstructed. Library callers can provide `IntentCoreOptions.commandPrefix`; without it, the core API uses `intent`. + +A skill distributed without Intent can provide its own script and pass its own `commands` strings to these functions. Intent does not prescribe that script's persistence, rewriting, or execution model. diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 48bed8f5..b4422206 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -58,9 +58,15 @@ A review inside a workspace starts with inherited permissions. Confirmed edits c ## Static discovery -Intent reads package data as files. It never imports, requires, or executes the code of a discovered package to find or load a skill. Adding a package to your dependency tree cannot run that package's code through Intent. +Intent reads package data as files during discovery. Listing skills and resolving their paths never execute package-provided transforms. Loading an ordinary skill only reads its Markdown. -One exception is sanctioned: in Yarn Plug'n'Play projects, Intent loads Yarn's PnP runtime (`.pnp.cjs`) to map package identities to readable locations. It loads no package entry points, bins, lifecycle scripts, or other package-provided JavaScript. An ESLint rule enforces this invariant in the discovery code. +In Yarn Plug'n'Play projects, Intent loads Yarn's PnP runtime (`.pnp.cjs`) to map package identities to readable locations. Discovery loads no package entry points, bins, lifecycle scripts, or other package-provided JavaScript. An ESLint rule enforces this invariant in the discovery code. + +## Transform execution + +When a permitted skill declares `intent-transform`, `load` and `configure` execute that specific script in a separate permission-restricted Node process and VM. Enabling a source permits this behavior for its skills, including transforms introduced by updates. `load --path` does not execute scripts. + +Transforms receive read-only skill files and writable, project-scoped config through in-memory filesystems. The VM exposes no networking APIs and blocks imports. Node 25+ additionally denies network access at the process boundary; Node 22–24 rely on the VM restrictions for best-effort tracking prevention. These are best-effort restrictions for trusted dependency scripts, not a security boundary against malicious code. See [Skill transforms](./skill-transforms) for the complete contract and limits. ## Lifecycle boundaries diff --git a/docs/config.json b/docs/config.json index 22535d33..645fc164 100644 --- a/docs/config.json +++ b/docs/config.json @@ -37,6 +37,10 @@ { "label": "Configuration", "to": "concepts/configuration" + }, + { + "label": "Skill Transforms", + "to": "concepts/skill-transforms" } ] }, @@ -63,6 +67,10 @@ "label": "intent load", "to": "cli/intent-load" }, + { + "label": "intent configure", + "to": "cli/intent-configure" + }, { "label": "intent meta", "to": "cli/intent-meta" diff --git a/packages/intent/package.json b/packages/intent/package.json index 81a4fbe9..d67aea4f 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -7,6 +7,9 @@ "node": ">=20.12.0" }, "type": "module", + "imports": { + "#transform-worker": "./dist/transform-worker.mjs" + }, "repository": { "type": "git", "url": "https://github.com/TanStack/intent" @@ -30,6 +33,7 @@ ], "devDependencies": { "@clack/prompts": "1.7.0", + "@platformatic/vfs": "0.4.0", "@types/semver": "^7.7.1", "cac": "^7.0.0", "jsonc-parser": "^3.3.1", diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 9c52251f..87e20563 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -13,6 +13,7 @@ import type { } from './commands/install/command.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' +import type { ConfigureCommandOptions } from './commands/configure.js' import type { StaleCommandOptions } from './commands/stale.js' import type { MaintainerCommandOptions, @@ -76,8 +77,13 @@ function createCli( ) cli - .command('load [use]', 'Load a compact skill use and print its SKILL.md') - .usage('load [--path] [--json] [--debug] [--global] [--global-only]') + .command( + 'load [use] [...args]', + 'Load a compact skill use and print its SKILL.md', + ) + .usage( + 'load [...args] [--path] [--json] [--debug] [--global] [--global-only]', + ) .option('--path', 'Print the resolved skill path instead of file content') .option('--json', 'Output JSON') .option('--debug', 'Print resolution debug details to stderr') @@ -85,10 +91,37 @@ function createCli( .option('--global-only', 'Load from global packages only') .example('load @tanstack/query#core') .example('load @tanstack/query#core --path') - .action(async (use: string | undefined, options: LoadCommandOptions) => { - const { runLoadCommand } = await import('./commands/load.js') - await runLoadCommand(use, options) - }) + .action( + async ( + use: string | undefined, + args: Array, + options: LoadCommandOptions, + ) => { + const { runLoadCommand } = await import('./commands/load.js') + await runLoadCommand(use, options, args) + }, + ) + + cli + .command( + 'configure [use]', + 'Configure a skill transform with answers and stdin', + ) + .option( + '--answer ', + 'Answer a question; repeat for multiple answers', + { type: [String] }, + ) + .option('--reset', 'Clear saved configuration before configuring the skill') + .option('--global', 'Resolve project packages, then global packages') + .option('--global-only', 'Resolve global packages only') + .example('configure @apollo/client#apollo-client --answer Q1=closures') + .action( + async (use: string | undefined, options: ConfigureCommandOptions) => { + const { runConfigureCommand } = await import('./commands/configure.js') + await runConfigureCommand(use, options) + }, + ) cli .command('meta [name]', 'List meta-skills, or print one by name') diff --git a/packages/intent/src/commands/configure.ts b/packages/intent/src/commands/configure.ts new file mode 100644 index 00000000..e95dd85f --- /dev/null +++ b/packages/intent/src/commands/configure.ts @@ -0,0 +1,34 @@ +import { configureIntentSkill, resolveIntentSkill } from '../core/index.js' +import { detectInvocationPrefix } from '../transforms/commands.js' +import { parseAnswerPairs, readConfigureStdin } from '../transforms/runtime.js' +import { coreOptionsFromGlobalFlags } from './support.js' +import type { GlobalScanFlags } from './support.js' + +export interface ConfigureCommandOptions extends GlobalScanFlags { + answer?: string | Array + reset?: boolean +} + +export async function runConfigureCommand( + use: string | undefined, + options: ConfigureCommandOptions, +): Promise { + if (!use) + throw new Error( + 'Missing skill use. Expected: intent configure #', + ) + const answers = parseAnswerPairs(options.answer) + const coreOptions = coreOptionsFromGlobalFlags(options) + // Refuse excluded/missing skills before waiting for piped input. + resolveIntentSkill(use, coreOptions) + const stdin = await readConfigureStdin() + coreOptions.commandPrefix = detectInvocationPrefix() + const configured = configureIntentSkill( + use, + { answers, stdin, reset: options.reset }, + coreOptions, + ) + process.stdout.write(configured.content) + for (const warning of configured.warnings) + console.error(`Warning: ${warning}`) +} diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index a0160416..f3d82a91 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,4 +1,5 @@ import { fail } from '../shared/cli-error.js' +import { detectInvocationPrefix } from '../transforms/commands.js' import { IntentCoreError, loadIntentSkill, @@ -9,6 +10,7 @@ import type { LoadedIntentSkill, ResolvedIntentSkill } from '../core/index.js' import type { GlobalScanFlags } from './support.js' export interface LoadCommandOptions extends GlobalScanFlags { + '--'?: Array json?: boolean path?: boolean } @@ -35,6 +37,7 @@ function printLoadDebug(loaded: LoadedIntentSkill | ResolvedIntentSkill): void { export function runLoadCommand( use: string | undefined, options: LoadCommandOptions, + args: Array = [], ): void { if (!use) { fail('Missing skill use. Expected: intent load #') @@ -67,7 +70,11 @@ export function runLoadCommand( let loaded: LoadedIntentSkill try { - loaded = loadIntentSkill(use, coreOptions) + coreOptions.commandPrefix = detectInvocationPrefix() + loaded = loadIntentSkill(use, coreOptions, [ + ...args, + ...(options['--'] ?? []), + ]) } catch (err) { if (err instanceof IntentCoreError) { fail(err.message) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 14de2889..bf51a18e 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -7,6 +7,7 @@ import { } from 'node:fs' import { basename, dirname, join, relative, resolve } from 'node:path' import { parseDocument, parse as parseYaml } from 'yaml' +import { readTransformDeclaration } from '../transforms/runtime.js' import { fail, isCliFailure } from '../shared/cli-error.js' import { readScalarField } from '../shared/utils.js' import { resolveProjectContext } from '../core/project-context.js' @@ -547,6 +548,17 @@ async function runValidateCommandInternal( } } + if (fm['intent-transform'] !== undefined) { + try { + readTransformDeclaration(content) + } catch (error) { + errors.push({ + file: rel, + message: error instanceof Error ? error.message : String(error), + }) + } + } + if (!fm.name) { errors.push({ file: rel, message: 'Missing required field: name' }) } diff --git a/packages/intent/src/core/index.ts b/packages/intent/src/core/index.ts index e30c3460..6a26e11c 100644 --- a/packages/intent/src/core/index.ts +++ b/packages/intent/src/core/index.ts @@ -1 +1,10 @@ export * from './intent-core.js' +export type { + SkillTransform, + TransformContext, + TransformCommands, + ConfigureContext, + TransformAnswer, + SkillFileSystem, + ConfigFileSystem, +} from '../transforms/types.js' diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index a84402c2..d0b01cd4 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -1,7 +1,13 @@ -import { isAbsolute, relative, resolve } from 'node:path' +import { dirname, isAbsolute, relative, resolve } from 'node:path' import { createIntentFsCache } from '../discovery/fs-cache.js' import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' import { formatSkillUse, parseSkillUse } from '../skills/use.js' +import { transformCommands } from '../transforms/commands.js' +import { + readTransformDeclaration, + runTransform, + skillConfigDirectory, +} from '../transforms/runtime.js' import { compileExcludePatterns, getEffectiveExcludePatterns, @@ -16,6 +22,7 @@ import { readSkillSourcesConfig, scanForPolicedIntents, } from './source-policy.js' +import type { TransformAnswer } from '../transforms/types.js' import type { ResolveSkillResult } from '../skills/resolver.js' import type { IntentFsCache } from '../discovery/fs-cache.js' import type { ReadFs } from '../shared/utils.js' @@ -386,11 +393,34 @@ export function resolveIntentSkill( export function loadIntentSkill( use: string, options: IntentCoreOptions = {}, + args: Array = [], ): LoadedIntentSkill { const cwd = resolveCoreCwd(options) const resolved = resolveIntentSkillInCwd(cwd, use, options) + const source = resolved.readFs.readFileSync(resolved.realResolvedPath, 'utf8') + const declaration = readTransformDeclaration(source) const content = rewriteLoadedSkillMarkdownDestinations({ - content: resolved.readFs.readFileSync(resolved.realResolvedPath, 'utf8'), + content: declaration + ? runTransform({ + skillDir: dirname(resolved.realResolvedPath), + configDir: skillConfigDirectory( + resolveProjectContext({ cwd }).packageRoot ?? cwd, + resolved.result.packageName, + resolved.result.skillName, + ), + declaration, + operation: 'load', + args, + commands: transformCommands( + formatSkillUse( + resolved.result.packageName, + resolved.result.skillName, + ), + options, + ), + readFs: resolved.readFs, + }) + : source, cwd, packageRoot: resolved.realPackageRoot, skillFilePath: resolved.realResolvedPath, @@ -401,3 +431,47 @@ export function loadIntentSkill( content, } } + +/** Configure uses the same source policy and canonical identity as load. */ +export function configureIntentSkill( + use: string, + input: { + answers?: Array + stdin?: string + reset?: boolean + } = {}, + options: IntentCoreOptions = {}, +): LoadedIntentSkill { + const cwd = resolveCoreCwd(options) + const resolved = resolveIntentSkillInCwd(cwd, use, options) + const declaration = readTransformDeclaration( + resolved.readFs.readFileSync(resolved.realResolvedPath, 'utf8'), + ) + if (!declaration) + throw new Error(`Skill "${use}" does not declare intent-transform`) + const content = runTransform({ + skillDir: dirname(resolved.realResolvedPath), + configDir: skillConfigDirectory( + resolveProjectContext({ cwd }).packageRoot ?? cwd, + resolved.result.packageName, + resolved.result.skillName, + ), + declaration, + operation: 'configure', + commands: transformCommands( + formatSkillUse(resolved.result.packageName, resolved.result.skillName), + options, + ), + readFs: resolved.readFs, + ...input, + }) + return { + ...resolved.result, + content: rewriteLoadedSkillMarkdownDestinations({ + content, + cwd, + packageRoot: resolved.realPackageRoot, + skillFilePath: resolved.realResolvedPath, + }), + } +} diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index eb675b4c..e828e597 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -7,6 +7,8 @@ import type { } from '../shared/types.js' export interface IntentCoreOptions { + /** Launcher prefix used in transform instructions; defaults to `intent`. */ + commandPrefix?: string audience?: IntentAudience cwd?: string debug?: boolean diff --git a/packages/intent/src/transforms/commands.ts b/packages/intent/src/transforms/commands.ts new file mode 100644 index 00000000..773a2027 --- /dev/null +++ b/packages/intent/src/transforms/commands.ts @@ -0,0 +1,143 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' +import type { TransformCommands } from './types.js' + +function quote(value: string): string { + return "'" + value.replaceAll("'", "'\\''") + "'" +} + +/** Only these metadata fields are inspected; the environment is never sent to a transform. */ +interface Invocation { + env: NodeJS.ProcessEnv + ancestors: Array + executable: string + script: string | undefined + scripts: Record +} + +function reusableScript(script: string): boolean { + // Fixed actions and compound shell scripts cannot be safely reused for both operations. + return ( + !/[;&|`\n$]/.test(script) && + !/\b(load|configure)\b/.test(script) && + /(?:\bintent|\bcli\.(?:mjs|js)|@tanstack\/intent(?:@[\w.+-]+)?)['"]?\s*$/.test( + script, + ) + ) +} + +/** Recover launcher semantics, not the original shell's whitespace or quoting. */ +export function invocationPrefix(invocation: Invocation): string { + const { env, ancestors, scripts } = invocation + const manager = + env.npm_config_user_agent?.match(/^(npm|pnpm|yarn|bun)\//)?.[1] ?? 'npm' + const scriptCommand = (name: string, pm = manager) => + `${pm} run ${quote(name)}${pm === 'npm' ? ' --' : ''}` + const event = env.npm_lifecycle_event + if ( + event && + event !== 'npx' && + event !== 'exec' && + reusableScript(scripts[event] ?? env.npm_lifecycle_script ?? '') + ) + return scriptCommand(event) + + // A nested npx overwrites lifecycle metadata. Recover a reusable outer npm script. + for (const command of ancestors) { + const match = command.match( + /^(npm|pnpm|yarn|bun) (?:run|run-script) ([\w:.-]+)(?:\s|$)/, + ) + if (match && reusableScript(scripts[match[2]!] ?? '')) + return scriptCommand(match[2]!, match[1]) + } + for (const command of ancestors) { + const match = command.match( + /^(npx|npm exec|pnpm dlx|yarn dlx|bunx)\s+(?:--yes\s+|--\s+)?(@tanstack\/intent(?:@[\w.+-]+)?)(?:\s|$)/, + ) + if (match) + return `${match[1] === 'npm exec' ? 'npx' : match[1]} ${match[2]}` + } + const spec = env.npm_config_package + if ( + env.npm_command === 'exec' && + spec && + /^@tanstack\/intent(?:@[\w.+-]+)?$/.test(spec) + ) + return `npx ${spec}` + if (env.npm_command === 'exec') return 'npm exec --no -- intent' + if (manager === 'pnpm' && env.npm_execpath) return 'pnpm exec intent' + if (manager === 'yarn' && env.npm_execpath) return 'yarn exec intent' + if ( + invocation.script && + /^intent(?:\.cmd)?$/.test(basename(invocation.script)) + ) + return quote(invocation.script) + return invocation.script + ? `${quote(invocation.executable)} ${quote(invocation.script)}` + : 'intent' +} + +export function detectInvocationPrefix(): string { + const ancestors: Array = [] + // npm does not consistently forward the original package spec/tag to its child. + // Inspect only a few launcher ancestors, without executing any recovered text. + if (process.platform !== 'win32') { + let pid = process.ppid + for (let i = 0; i < 4 && pid > 1; i++) { + const result = spawnSync( + 'ps', + ['-p', String(pid), '-o', 'ppid=', '-o', 'args='], + { + encoding: 'utf8', + timeout: 250, + maxBuffer: 16384, + }, + ) + if (result.error || result.status !== 0) break + const match = result.stdout.trim().match(/^(\d+)\s+([\s\S]+)$/) + if (!match || Number(match[1]) === pid) break + ancestors.push(match[2]!) + pid = Number(match[1]) + } + } + let scripts: Record = {} + if (process.env.npm_package_json) { + try { + const value = JSON.parse( + readFileSync(process.env.npm_package_json, 'utf8'), + ).scripts + if (value && typeof value === 'object') + scripts = Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + } catch { + /* Missing metadata falls back to the current executable. */ + } + } + return invocationPrefix({ + env: process.env, + ancestors, + scripts, + executable: process.execPath, + script: process.argv[1], + }) +} + +export function transformCommands( + use: string, + options: { commandPrefix?: string; global?: boolean; globalOnly?: boolean }, +): TransformCommands { + const prefix = options.commandPrefix ?? 'intent' + const flags = options.globalOnly + ? ' --global-only' + : options.global + ? ' --global' + : '' + return { + load: `${prefix} load ${quote(use)}${flags}`, + configure: `${prefix} configure ${quote(use)}${flags}`, + } +} diff --git a/packages/intent/src/transforms/runtime.ts b/packages/intent/src/transforms/runtime.ts new file mode 100644 index 00000000..905e03e3 --- /dev/null +++ b/packages/intent/src/transforms/runtime.ts @@ -0,0 +1,308 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { parse } from 'yaml' +import { nodeReadFs } from '../shared/utils.js' +import type { ReadFs } from '../shared/utils.js' +import type { TransformAnswer, TransformCommands } from './types.js' + +const MAX_BYTES = 4 * 1024 * 1024 +const MAX_FILES = 512 +type Files = Record + +export interface TransformDeclaration { + entry: string + api: 1 +} + +function validTransformPath(path: string): boolean { + return ( + !!path && + !/[\\\0:]/.test(path) && + path.split('/').every((part) => !!part && part !== '.' && part !== '..') + ) +} + +export function readTransformDeclaration( + content: string, +): TransformDeclaration | undefined { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return undefined + const fields = parse(match[1]!) as Record | null + const declaration = fields?.['intent-transform'] + if (declaration === undefined) return undefined + if ( + !declaration || + typeof declaration !== 'object' || + Array.isArray(declaration) + ) + throw new Error('intent-transform must be a mapping with entry and api: 1') + const { entry, api } = declaration as Record + if ( + typeof entry !== 'string' || + !validTransformPath(entry.replace(/^\.\//, '')) || + api !== 1 + ) + throw new Error( + 'intent-transform requires a skill-relative entry and api: 1', + ) + return { entry: entry.replace(/^\.\//, ''), api } +} + +/** Snapshot through Intent's read adapter, including Yarn PnP archive files. */ +function snapshot(root: string, fs: ReadFs = nodeReadFs): Files { + const files: Files = Object.create(null) + let bytes = 0 + function visit(dir: string, prefix: string) { + for (const name of fs.readdirSync(dir).sort()) { + const key = prefix + name + if (!validTransformPath(key)) + throw new Error(`Invalid transform file path: ${key}`) + const path = join(dir, name) + const stat = fs.lstatSync(path) + if (stat.isSymbolicLink()) + throw new Error(`Transform files must not be symbolic links: ${key}`) + if (stat.isDirectory()) visit(path, key + '/') + else if (stat.isFile()) { + bytes += stat.size + if (bytes > MAX_BYTES || Object.keys(files).length >= MAX_FILES) + throw new Error('Transform filesystem exceeds 4 MiB or 512 files') + files[key] = fs.readFileSync(path, 'utf8') + } else throw new Error(`Transform files must be regular files: ${key}`) + } + } + if (fs.existsSync(root)) visit(root, '') + return files +} + +// Config is consumer-owned. Reject symlinks in every component before writing. +function ensureDirectory(path: string): void { + const absolute = resolve(path) + const parent = dirname(absolute) + if (parent !== absolute) ensureDirectory(parent) + if (!existsSync(absolute)) { + // lstat detects dangling symlinks that existsSync intentionally misses. + try { + if (lstatSync(absolute).isSymbolicLink()) + throw new Error(`Config path is a symbolic link: ${absolute}`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + mkdirSync(absolute) + } + const stat = lstatSync(absolute) + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new Error( + `Config path must be a directory without symbolic links: ${absolute}`, + ) +} + +function validateFiles(value: unknown): asserts value is Files { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid transform config output') + let bytes = 0 + const entries = Object.entries(value) + if (entries.length > MAX_FILES) + throw new Error('Too many transform config files') + for (const [name, content] of entries) { + if (!validTransformPath(name) || typeof content !== 'string') + throw new Error('Invalid transform config file') + bytes += Buffer.byteLength(content) + if (bytes > MAX_BYTES) throw new Error('Transform config exceeds 4 MiB') + } +} + +function commitConfig(configDir: string, files: Files): void { + const stage = `${configDir}.stage-${randomUUID()}` + const backup = `${configDir}.backup-${randomUUID()}` + mkdirSync(stage) + let backedUp = false + try { + for (const [name, text] of Object.entries(files)) { + const target = join(stage, ...name.split('/')) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, text, 'utf8') + } + if (existsSync(configDir)) { + renameSync(configDir, backup) + backedUp = true + } + try { + renameSync(stage, configDir) + } catch (error) { + if (backedUp) renameSync(backup, configDir) + throw error + } + } finally { + rmSync(stage, { recursive: true, force: true }) + } + if (backedUp) rmSync(backup, { recursive: true, force: true }) +} + +export function runTransform(options: { + skillDir: string + configDir: string + declaration: TransformDeclaration + operation: 'load' | 'configure' + commands: TransformCommands + answers?: Array + args?: Array + reset?: boolean + stdin?: string + readFs?: ReadFs +}): string { + const [major, minor] = process.versions.node.split('.').map(Number) + if (major! < 22) + throw new Error('Skill transforms require Node.js 22 or newer') + if (Buffer.byteLength(options.stdin ?? '') > MAX_BYTES) + throw new Error('Transform stdin exceeds 4 MiB') + const skill = snapshot(options.skillDir, options.readFs) + const code = skill[options.declaration.entry] + if (code === undefined) + throw new Error(`Transform entry not found: ${options.declaration.entry}`) + ensureDirectory(dirname(options.configDir)) + // Serialize load/configure across processes. Never steal a potentially live lock. + const lock = `${options.configDir}.lock` + try { + mkdirSync(lock) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') + throw new Error( + `Skill configuration is busy; retry later. If a runner crashed, remove ${lock}`, + ) + throw error + } + try { + if ( + existsSync(options.configDir) || + (() => { + try { + lstatSync(options.configDir) + return true + } catch { + return false + } + })() + ) + ensureDirectory(options.configDir) + const config = options.reset ? {} : snapshot(options.configDir) + const input = JSON.stringify({ + skill, + config, + code, + operation: options.operation, + commands: options.commands, + answers: options.answers ?? [], + args: options.args ?? [], + stdin: options.stdin ?? '', + }) + if (Buffer.byteLength(input) > MAX_BYTES * 4) + throw new Error('Transform input exceeds 16 MiB') + const child = spawnSync( + process.execPath, + [ + major === 22 && minor! < 13 + ? '--experimental-permission' + : '--permission', + '--experimental-vm-modules', + '--disable-proto=throw', + '--max-old-space-size=64', + '--no-warnings', + '--input-type=module', + ], + { + // Send the bundled worker and its input through stdin, avoiding both + // filesystem grants and platform command-line length limits. + input: + `globalThis.__intentTransformInput = ${JSON.stringify(input)};\n` + + readFileSync( + new URL(import.meta.resolve('#transform-worker')), + 'utf8', + ), + encoding: 'utf8', + env: {}, + timeout: 5000, + killSignal: 'SIGKILL', + maxBuffer: MAX_BYTES * 4, + windowsHide: true, + }, + ) + if (child.error) throw new Error(`Transform failed: ${child.error.message}`) + if (child.status !== 0) + throw new Error( + `Transform failed: ${child.stderr.trim() || child.signal || child.status}`, + ) + const output = JSON.parse(child.stdout) as { + content?: unknown + files?: unknown + } + if ( + typeof output.content !== 'string' || + Buffer.byteLength(output.content) > MAX_BYTES + ) + throw new Error('Transform must return a string of at most 4 MiB') + validateFiles(output.files) + if (JSON.stringify(config) !== JSON.stringify(output.files)) + commitConfig(options.configDir, output.files) + return output.content + } finally { + rmSync(lock, { recursive: true, force: true }) + } +} + +export function skillConfigDirectory( + projectRoot: string, + packageName: string, + skillName: string, +): string { + // Hex is portable, collision-free even on case-insensitive filesystems, and + // cannot introduce path separators, reserved device names, or dot segments. + const encode = (name: string) => 's-' + Buffer.from(name).toString('hex') + return join( + projectRoot, + '.intent', + 'skill-config', + encode(packageName), + ...skillName.split('/').map(encode), + ) +} + +export function parseAnswerPairs(answers: unknown): Array { + const values: Array = + answers === undefined ? [] : Array.isArray(answers) ? answers : [answers] + return values.map((value) => { + const separator = typeof value === 'string' ? value.indexOf('=') : -1 + if (typeof value !== 'string' || separator < 1) + throw new Error( + 'Each --answer must be question=value with a nonempty question ID', + ) + return { + question: value.slice(0, separator), + answer: value.slice(separator + 1), + } + }) +} + +export async function readConfigureStdin( + input: NodeJS.ReadStream = process.stdin, +): Promise { + if (input.isTTY) return '' + const chunks: Array = [] + let size = 0 + for await (const chunk of input) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + size += buffer.length + if (size > MAX_BYTES) throw new Error('Transform stdin exceeds 4 MiB') + chunks.push(buffer) + } + return Buffer.concat(chunks).toString('utf8') +} diff --git a/packages/intent/src/transforms/types.ts b/packages/intent/src/transforms/types.ts new file mode 100644 index 00000000..c59c3afa --- /dev/null +++ b/packages/intent/src/transforms/types.ts @@ -0,0 +1,38 @@ +/** Text-only, root-relative filesystem exposed to skill transforms. */ +export interface SkillFileSystem { + readFile: (path: string) => string + exists: (path: string) => boolean + listFiles: () => Array +} + +export interface ConfigFileSystem extends SkillFileSystem { + writeFile: (path: string, content: string) => void + removeFile: (path: string) => void +} + +export interface TransformAnswer { + question: string + answer: string +} + +export interface TransformContext { + skill: SkillFileSystem + config: ConfigFileSystem + commands: TransformCommands +} + +/** Complete commands for this skill, ready for the transform to include in instructions. */ +export interface TransformCommands { + load: string + configure: string +} + +export interface ConfigureContext extends TransformContext { + answers: Array + stdin: string +} + +export interface SkillTransform { + load: (context: TransformContext & { args: Array }) => string + configure: (context: ConfigureContext) => string +} diff --git a/packages/intent/src/transforms/worker.ts b/packages/intent/src/transforms/worker.ts new file mode 100644 index 00000000..b7167146 --- /dev/null +++ b/packages/intent/src/transforms/worker.ts @@ -0,0 +1,115 @@ +// Self-contained bootstrap for the restricted transform subprocess. +// Only this trusted bootstrap runs outside the VM. No host paths are granted. +import { Script, SourceTextModule, createContext } from 'node:vm' +import { VirtualFileSystem } from '@platformatic/vfs' +import type { MemoryProvider } from '@platformatic/vfs' + +// Some platforms add process variables even when spawn receives env: {}. +for (const key of Object.keys(process.env)) delete process.env[key] + +// The parent prepends the serialized request to this script on stdin. +const globals = globalThis as typeof globalThis & { + __intentTransformInput?: string +} +const request = JSON.parse(globals.__intentTransformInput!) +delete globals.__intentTransformInput +const skill = new VirtualFileSystem(undefined, { + moduleHooks: false, + overlay: false, +}) +const config = new VirtualFileSystem(undefined, { + moduleHooks: false, + overlay: false, +}) +const configNames = new Set(Object.keys(request.config)) +function path(name: string) { + if ( + typeof name !== 'string' || + !name || + name.includes('\\') || + name.includes('\0') || + name.includes(':') || + name.split('/').some((p) => !p || p === '.' || p === '..') + ) + throw new Error('Expected a root-relative file path: ' + name) + return '/' + name +} +function write(fs: VirtualFileSystem, name: string, text: string) { + if (typeof text !== 'string') throw new Error('File content must be a string') + const target = path(name) + const parent = target.slice(0, target.lastIndexOf('/')) + if (parent) fs.mkdirSync(parent, { recursive: true }) + fs.writeFileSync(target, text, 'utf8') +} +for (const [name, text] of Object.entries( + request.skill as Record, +)) + write(skill, name, text) +for (const [name, text] of Object.entries( + request.config as Record, +)) + write(config, name, text) +;(skill.provider as MemoryProvider).setReadOnly() +function reader(fs: VirtualFileSystem, names: Iterable) { + return { + readFile: (name: string) => fs.readFileSync(path(name), 'utf8'), + exists: (name: string) => fs.existsSync(path(name)), + listFiles: () => [...names].sort(), + } +} +const configApi = Object.freeze({ + ...reader(config, configNames), + writeFile(name: string, text: string) { + write(config, name, text) + configNames.add(name) + }, + removeFile(name: string) { + if (config.existsSync(path(name))) config.unlinkSync(path(name)) + configNames.delete(name) + }, +}) +const context = createContext( + Object.assign(Object.create(null), { + fetch: undefined, + WebSocket: undefined, + XMLHttpRequest: undefined, + process: undefined, + require: undefined, + skill: Object.freeze(reader(skill, Object.keys(request.skill))), + config: configApi, + inputJson: JSON.stringify({ + answers: request.answers, + args: request.args, + stdin: request.stdin, + commands: request.commands, + }), + }), + { + codeGeneration: { strings: false, wasm: false }, + microtaskMode: 'afterEvaluate', + }, +) +// No default loader: transforms are self-contained ES modules, with no imports. +const module = new SourceTextModule(request.code, { + context, + identifier: 'intent-transform', +}) +await module.link(() => { + throw new Error( + 'Transform imports are disabled; bundle your transform into one file', + ) +}) +await module.evaluate({ timeout: 1000 }) +context.transform = module.namespace +context.operation = request.operation +const result = new Script( + 'transform[operation]({ skill, config, ...JSON.parse(inputJson) })', +).runInContext(context, { timeout: 1000 }) +if (typeof result !== 'string') + throw new Error('Transform must return a string synchronously') +const files = Object.fromEntries( + [...configNames] + .sort() + .map((name) => [name, config.readFileSync(path(name), 'utf8')]), +) +process.stdout.write(JSON.stringify({ content: result, files })) diff --git a/packages/intent/tests/integration/transforms.test.ts b/packages/intent/tests/integration/transforms.test.ts new file mode 100644 index 00000000..7cf8add5 --- /dev/null +++ b/packages/intent/tests/integration/transforms.test.ts @@ -0,0 +1,262 @@ +import { spawnSync } from 'node:child_process' +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '../..') +const cli = join(packageRoot, 'dist/cli.mjs') +let root: string +let skillDir: string +const metadata = `---\nname: guide\ndescription: Test guidance\nintent-transform:\n entry: scripts/transform.mjs\n api: 1\n---\n` +const code = ` +export function load({ config }) { + return config.exists('saved.json') ? config.readFile('saved.json') : 'Ask Q1'; +} +export function configure({ config, answers, stdin }) { + config.writeFile('saved.json', JSON.stringify({ answers, stdin })); + return 'Next question'; +} +` +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'intent-transform-cli-'))) + skillDir = join(root, 'node_modules', 'example', 'skills', 'guide') + mkdirSync(join(skillDir, 'scripts'), { recursive: true }) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + dependencies: { example: '1' }, + intent: { skills: ['example'] }, + }), + ) + writeFileSync( + join(skillDir, '../..', 'package.json'), + JSON.stringify({ + name: 'example', + version: '1.0.0', + intent: { version: 1, repo: 'example/example', docs: 'docs' }, + }), + ) + writeFileSync(join(skillDir, 'SKILL.md'), metadata + 'Bootstrap') + writeFileSync(join(skillDir, 'scripts/transform.mjs'), code) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function run(file: string, args: Array, input = '') { + return spawnSync(process.execPath, [file, ...args], { + cwd: root, + input, + encoding: 'utf8', + timeout: 15000, + }) +} + +describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( + 'packaged transform runners', + () => { + it('runs from published files without installed runtime dependencies', () => { + const installed = join(root, 'standalone-intent') + mkdirSync(installed) + cpSync(join(packageRoot, 'dist'), join(installed, 'dist'), { + recursive: true, + }) + cpSync(join(packageRoot, 'package.json'), join(installed, 'package.json')) + const result = run(join(installed, 'dist/cli.mjs'), [ + 'load', + 'example#guide', + ]) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toBe('Ask Q1') + }) + + it('forwards trailing load arguments as strings', () => { + writeFileSync( + join(skillDir, 'scripts/transform.mjs'), + 'export function load({ args }) { return JSON.stringify(args); }', + ) + const result = run(cli, [ + 'load', + 'example#guide', + 'reconfigure', + '001', + 'a=b', + ]) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual(['reconfigure', '001', 'a=b']) + const escaped = run(cli, [ + 'load', + 'example#guide', + 'reconfigure', + '--', + '--custom', + '001', + ]) + expect(escaped.status, escaped.stderr).toBe(0) + expect(JSON.parse(escaped.stdout)).toEqual([ + 'reconfigure', + '--custom', + '001', + ]) + }) + + it('resets configuration transactionally before forwarding new answers', () => { + expect( + run(cli, ['configure', 'example#guide', '--answer', 'Q1=old']).status, + ).toBe(0) + writeFileSync( + join(skillDir, 'scripts/transform.mjs'), + ` + export function configure({ config, answers }) { + if (config.listFiles().length) throw new Error('Expected empty config'); + if (!answers.length) throw new Error('Abort reset'); + config.writeFile('new.json', JSON.stringify(answers)); + return 'Reset'; + } + export function load({ config }) { return JSON.stringify(config.listFiles()); } + `, + ) + const failed = run(cli, ['configure', 'example#guide', '--reset']) + expect(failed.status).not.toBe(0) + expect(JSON.parse(run(cli, ['load', 'example#guide']).stdout)).toEqual([ + 'saved.json', + ]) + const reset = run(cli, [ + 'configure', + 'example#guide', + '--reset', + '--answer', + 'Q2=new', + ]) + expect(reset.status, reset.stderr).toBe(0) + expect(reset.stdout).toBe('Reset') + expect(JSON.parse(run(cli, ['load', 'example#guide']).stdout)).toEqual([ + 'new.json', + ]) + }) + + it('forwards repeated string flags and stdin, and keeps JSON/path modes intact', () => { + const configured = run( + cli, + [ + 'configure', + 'example#guide', + '--answer', + 'Q1=001', + '--answer', + 'Q2=', + '--answer=Q1=a=b\nsecond line', + '--answer', + 'Q3=-1', + ], + '{"complex":[1,2]}\n', + ) + expect(configured.status, configured.stderr).toBe(0) + expect(configured.stdout).toBe('Next question') + const loaded = run(cli, ['load', 'example#guide', '--json']) + expect(loaded.status, loaded.stderr).toBe(0) + expect(JSON.parse(JSON.parse(loaded.stdout).content)).toEqual({ + answers: [ + { question: 'Q1', answer: '001' }, + { question: 'Q2', answer: '' }, + { question: 'Q1', answer: 'a=b\nsecond line' }, + { question: 'Q3', answer: '-1' }, + ], + stdin: '{"complex":[1,2]}\n', + }) + const path = run(cli, ['load', 'example#guide', '--path']) + expect(path.stdout.trim()).toContain('SKILL.md') + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toBe( + metadata + 'Bootstrap', + ) + for (const answer of ['missing', '=value']) { + const failed = run(cli, [ + 'configure', + 'example#guide', + '--answer', + answer, + ]) + expect(failed.status).toBe(1) + expect(failed.stderr).toContain('question=value') + } + }) + + it('reflects an actual npm run invocation in both transform functions', () => { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + dependencies: { example: '1' }, + intent: { skills: ['example'] }, + scripts: { intent: 'node "' + cli + '"' }, + }), + ) + writeFileSync( + join(skillDir, 'scripts/transform.mjs'), + `export function load({ commands }) { return JSON.stringify(commands) } export const configure = load;`, + ) + for (const action of ['load', 'configure']) { + const result = spawnSync( + 'npm', + ['run', '--silent', 'intent', '--', action, 'example#guide'], + { + cwd: root, + input: '', + encoding: 'utf8', + timeout: 15000, + shell: process.platform === 'win32', + }, + ) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + load: "npm run 'intent' -- load 'example#guide'", + configure: "npm run 'intent' -- configure 'example#guide'", + }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'preserves the package version through an offline npx launch', + () => { + const version = JSON.parse( + readFileSync(join(packageRoot, 'package.json'), 'utf8'), + ).version + mkdirSync(join(root, 'node_modules/@tanstack'), { recursive: true }) + mkdirSync(join(root, 'node_modules/.bin'), { recursive: true }) + symlinkSync( + packageRoot, + join(root, 'node_modules/@tanstack/intent'), + 'dir', + ) + symlinkSync(cli, join(root, 'node_modules/.bin/intent')) + writeFileSync( + join(skillDir, 'scripts/transform.mjs'), + `export function load({ commands }) { return JSON.stringify(commands) }`, + ) + const result = spawnSync( + 'npx', + ['--offline', `@tanstack/intent@${version}`, 'load', 'example#guide'], + { + cwd: root, + input: '', + encoding: 'utf8', + timeout: 15000, + }, + ) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + load: `npx @tanstack/intent@${version} load 'example#guide'`, + configure: `npx @tanstack/intent@${version} configure 'example#guide'`, + }) + }, + ) + }, +) diff --git a/packages/intent/tests/transform-commands.test.ts b/packages/intent/tests/transform-commands.test.ts new file mode 100644 index 00000000..f218c44d --- /dev/null +++ b/packages/intent/tests/transform-commands.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + invocationPrefix, + transformCommands, +} from '../src/transforms/commands.js' + +const base = { + env: {}, + ancestors: [], + scripts: {}, + executable: '/usr/bin/node', + script: '/tools/intent/cli.mjs', +} + +describe('transform command instructions', () => { + it('preserves npx tags and fixed versions recovered from npm', () => { + for (const spec of [ + '@tanstack/intent@latest', + '@tanstack/intent@0.5.6', + '@tanstack/intent', + ]) { + expect( + invocationPrefix({ + ...base, + ancestors: [`npm exec ${spec} load example#guide`], + }), + ).toBe(`npx ${spec}`) + } + }) + it('preserves npm scripts and the argument forwarding separator', () => { + const prefix = invocationPrefix({ + ...base, + env: { + npm_command: 'run-script', + npm_lifecycle_event: 'intent', + npm_lifecycle_script: 'intent', + }, + }) + expect( + transformCommands('example#guide', { commandPrefix: prefix }), + ).toEqual({ + load: "npm run 'intent' -- load 'example#guide'", + configure: "npm run 'intent' -- configure 'example#guide'", + }) + }) + it('recovers an outer script when nested npx has replaced lifecycle metadata', () => { + expect( + invocationPrefix({ + ...base, + env: { npm_lifecycle_event: 'npx' }, + scripts: { intent: 'npx @tanstack/intent@latest' }, + ancestors: [ + 'npm exec @tanstack/intent@latest load example#guide', + 'npm run intent load example#guide', + ], + }), + ).toBe("npm run 'intent' --") + }) + it('does not reuse scripts with a fixed action or compound shell commands', () => { + for (const script of ['intent load example#guide', 'intent && echo done']) { + expect( + invocationPrefix({ + ...base, + env: { npm_lifecycle_event: 'intent', npm_lifecycle_script: script }, + }), + ).toBe("'/usr/bin/node' '/tools/intent/cli.mjs'") + } + }) + it('preserves other known package manager launchers', () => { + expect( + invocationPrefix({ + ...base, + ancestors: ['pnpm dlx @tanstack/intent@latest load example#guide'], + }), + ).toBe('pnpm dlx @tanstack/intent@latest') + expect( + invocationPrefix({ + ...base, + env: { + npm_config_user_agent: 'pnpm/11', + npm_lifecycle_event: 'intent', + npm_lifecycle_script: 'intent', + }, + }), + ).toBe("pnpm run 'intent'") + }) + it('uses exec package metadata or the actual executable without guessing latest', () => { + expect( + invocationPrefix({ + ...base, + env: { + npm_command: 'exec', + npm_config_package: '@tanstack/intent@0.5.6', + }, + }), + ).toBe('npx @tanstack/intent@0.5.6') + expect(invocationPrefix(base)).toBe( + "'/usr/bin/node' '/tools/intent/cli.mjs'", + ) + }) + it('quotes identities and retains resolution scope without replaying output flags or answers', () => { + expect( + transformCommands("pkg#skill'$(echo unsafe)", { + commandPrefix: 'npx @tanstack/intent@latest', + globalOnly: true, + }), + ).toEqual({ + load: "npx @tanstack/intent@latest load 'pkg#skill'\\''$(echo unsafe)' --global-only", + configure: + "npx @tanstack/intent@latest configure 'pkg#skill'\\''$(echo unsafe)' --global-only", + }) + }) +}) diff --git a/packages/intent/tests/transforms.test.ts b/packages/intent/tests/transforms.test.ts new file mode 100644 index 00000000..f06d657a --- /dev/null +++ b/packages/intent/tests/transforms.test.ts @@ -0,0 +1,252 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + configureIntentSkill, + listIntentSkills, + loadIntentSkill, + resolveIntentSkill, +} from '../src/core/index.js' +import { + parseAnswerPairs, + readTransformDeclaration, + runTransform, + skillConfigDirectory, +} from '../src/transforms/runtime.js' + +let root: string +let skillDir: string +const use = '@example/library#guide' +const source = `---\nname: guide\ndescription: Help with this library\nintent-transform:\n entry: ./transform.mjs\n api: 1\n---\nBootstrap\n` +const transform = ` +export function load({ skill, config }) { + const answers = config.exists('answers.json') ? JSON.parse(config.readFile('answers.json')) : {}; + return answers.Q1 ? 'Use ' + answers.Q1 + ': ' + skill.readFile('references/guide.md') : 'Ask Q1'; +} +export function configure({ config, answers, stdin }) { + const saved = config.exists('answers.json') ? JSON.parse(config.readFile('answers.json')) : {}; + for (const { question, answer } of answers) saved[question] = answer; + if (stdin) Object.assign(saved, JSON.parse(stdin)); + config.writeFile('answers.json', JSON.stringify(saved)); + return 'Saved; load the skill again'; +} +` + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'intent-transform-'))) + skillDir = join( + root, + 'node_modules', + '@example', + 'library', + 'skills', + 'guide', + ) + mkdirSync(join(skillDir, 'references'), { recursive: true }) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'consumer', + dependencies: { '@example/library': '1' }, + intent: { skills: ['@example/library'] }, + }), + ) + writeFileSync( + join(skillDir, '..', '..', 'package.json'), + JSON.stringify({ + name: '@example/library', + version: '1.0.0', + keywords: ['tanstack-intent'], + intent: { version: 1, repo: 'example/library', docs: 'docs/' }, + }), + ) + writeFileSync(join(skillDir, 'SKILL.md'), source) + writeFileSync(join(skillDir, 'transform.mjs'), transform) + writeFileSync( + join(skillDir, 'references', 'guide.md'), + '[guide](references/guide.md)', + ) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('validates declarations and preserves answer text and order', () => { + expect(readTransformDeclaration(source)).toEqual({ + entry: 'transform.mjs', + api: 1, + }) + expect(() => + readTransformDeclaration( + source.replace('./transform.mjs', '../escape.mjs'), + ), + ).toThrow() + expect(() => + readTransformDeclaration(source.replace('api: 1', 'api: 2')), + ).toThrow() + expect(parseAnswerPairs(['Q1=001', 'Q1='])).toEqual([ + { question: 'Q1', answer: '001' }, + { question: 'Q1', answer: '' }, + ]) + expect(parseAnswerPairs('Q2=a=b\nsecond line')).toEqual([ + { question: 'Q2', answer: 'a=b\nsecond line' }, + ]) + expect(parseAnswerPairs(undefined)).toEqual([]) + for (const invalid of ['Q1', '=value', '', 42, true]) { + expect(() => parseAnswerPairs(invalid)).toThrow(/question=value/) + } +}) + +const modernNode = Number(process.versions.node.split('.')[0]) >= 22 +describe.skipIf(!modernNode)('restricted transform execution', () => { + it('passes the same launcher-aware commands to load and configure', () => { + writeFileSync( + join(skillDir, 'transform.mjs'), + `export function load({ commands }) { return JSON.stringify(commands) } export const configure = load;`, + ) + const options = { cwd: root, commandPrefix: 'npx @tanstack/intent@latest' } + const expected = { + load: `npx @tanstack/intent@latest load '${use}'`, + configure: `npx @tanstack/intent@latest configure '${use}'`, + } + expect(JSON.parse(loadIntentSkill(use, options).content)).toEqual(expected) + expect(JSON.parse(configureIntentSkill(use, {}, options).content)).toEqual( + expected, + ) + }) + it('onboards incrementally, forwards stdin, and retains preferences after an upgrade', () => { + expect(loadIntentSkill(use, { cwd: root }).content).toBe('Ask Q1') + expect( + configureIntentSkill( + use, + { answers: [{ question: 'Q1', answer: 'closures' }] }, + { cwd: root }, + ).content, + ).toContain('Saved') + configureIntentSkill(use, { stdin: '{"Q2":"fragments"}' }, { cwd: root }) + expect(loadIntentSkill(use, { cwd: root }).content).toContain( + 'Use closures: [guide](node_modules/', + ) + const configDir = skillConfigDirectory(root, '@example/library', 'guide') + expect( + JSON.parse(readFileSync(join(configDir, 'answers.json'), 'utf8')), + ).toEqual({ Q1: 'closures', Q2: 'fragments' }) + writeFileSync( + join(skillDir, 'transform.mjs'), + transform.replace( + 'return answers.Q1 ?', + "if (!answers.Q3) return 'Ask Q3'; return answers.Q1 ?", + ), + ) + expect(loadIntentSkill(use, { cwd: root }).content).toBe('Ask Q3') + configureIntentSkill(use, { stdin: '{"Q3":"yes"}' }, { cwd: root }) + expect(loadIntentSkill(use, { cwd: root }).content).toContain( + 'Use closures', + ) + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toBe(source) + }) + + it('never executes during discovery or path resolution, and enforces source policy', () => { + writeFileSync( + join(skillDir, 'transform.mjs'), + 'throw new Error("must not execute")', + ) + expect(listIntentSkills({ cwd: root }).skills).toHaveLength(1) + expect(resolveIntentSkill(use, { cwd: root }).path).toContain('SKILL.md') + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ intent: { skills: [] } }), + ) + expect(() => configureIntentSkill(use, {}, { cwd: root })).toThrow( + /not listed/, + ) + expect(() => loadIntentSkill(use, { cwd: root })).toThrow(/not listed/) + }) + + it.each([ + [ + 'exception', + 'config.writeFile("bad", "data"); throw new Error("failure")', + ], + ['invalid result', 'config.writeFile("bad", "data"); return 42'], + [ + 'promise', + 'config.writeFile("bad", "data"); return Promise.resolve("no")', + ], + ['traversal', 'config.writeFile("../outside", "data"); return "no"'], + ['infinite loop', 'while (true) {}'], + ['skill writes', 'skill.writeFile("SKILL.md", "changed"); return "no"'], + ])('rolls back config on %s', (_name, body) => { + configureIntentSkill(use, { stdin: '{"Q1":"original"}' }, { cwd: root }) + writeFileSync( + join(skillDir, 'transform.mjs'), + `export function configure({ config, skill }) { ${body} }`, + ) + expect(() => configureIntentSkill(use, {}, { cwd: root })).toThrow() + const configDir = skillConfigDirectory(root, '@example/library', 'guide') + expect( + JSON.parse(readFileSync(join(configDir, 'answers.json'), 'utf8')), + ).toEqual({ Q1: 'original' }) + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toBe(source) + }) + + it('withholds globals and imports and restricts host permissions', () => { + writeFileSync( + join(skillDir, 'transform.mjs'), + `export function load() { return [typeof process, typeof require, typeof fetch, typeof setTimeout, typeof WebSocket, typeof XMLHttpRequest].join(',') }`, + ) + expect(loadIntentSkill(use, { cwd: root }).content).toBe( + 'undefined,undefined,undefined,undefined,undefined,undefined', + ) + writeFileSync( + join(skillDir, 'transform.mjs'), + `import fs from 'node:fs'; export function load() { return 'bad' }`, + ) + expect(() => loadIntentSkill(use, { cwd: root })).toThrow( + /imports are disabled/, + ) + // Even a host-function escape from the best-effort VM encounters the + // permission-restricted subprocess. Network permission is enforced on + // Node 25+ only; older versions also return false for unknown scopes. + // No network request is made. + writeFileSync( + join(skillDir, 'transform.mjs'), + `export function load({ skill }) { + const p = skill.readFile.constructor('return process')(); + return JSON.stringify({ network: p.permission.has('net'), write: p.permission.has('fs.write'), env: Object.keys(p.env) }); + }`, + ) + expect(JSON.parse(loadIntentSkill(use, { cwd: root }).content)).toEqual({ + network: false, + write: false, + env: [], + }) + }) + + it('rejects symlinks and concurrent writers', () => { + const configDir = join(root, 'config') + mkdirSync(configDir + '.lock') + const run = () => + runTransform({ + skillDir, + configDir, + declaration: { entry: 'transform.mjs', api: 1 }, + operation: 'load', + commands: { + load: 'intent load test', + configure: 'intent configure test', + }, + }) + expect(run).toThrow(/busy/) + rmSync(configDir + '.lock', { recursive: true }) + symlinkSync(join(root, 'package.json'), join(skillDir, 'outside')) + expect(run).toThrow(/symbolic links/) + }) +}) diff --git a/packages/intent/tsdown.config.ts b/packages/intent/tsdown.config.ts index d1f4df09..7893dfca 100644 --- a/packages/intent/tsdown.config.ts +++ b/packages/intent/tsdown.config.ts @@ -2,14 +2,37 @@ import { fileURLToPath } from 'node:url' import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { defineConfig } from 'tsdown' +import { build, defineConfig } from 'tsdown' export default defineConfig({ entry: ['src/index.ts', 'src/cli.ts', 'src/core.ts'], format: 'esm', platform: 'node', dts: { entry: ['src/index.ts', 'src/core.ts'] }, - onSuccess(config) { + async onSuccess(config) { + // A single bundle is passed via stdin so the restricted child needs no + // filesystem grants, including for loading the VFS implementation. + await build({ + config: false, + entry: { 'transform-worker': 'src/transforms/worker.ts' }, + outDir: config.outDir, + clean: false, + format: 'esm', + platform: 'node', + target: 'node22', + dts: false, + minify: true, + // Avoid loading the unused SQLite/real-filesystem providers. + alias: { + '@platformatic/vfs': fileURLToPath( + new URL( + './node_modules/@platformatic/vfs/lib/file_system.js', + import.meta.url, + ), + ), + }, + deps: { alwaysBundle: ['@platformatic/vfs'] }, + }) const packageDir = fileURLToPath(new URL('.', import.meta.url)) const root = join(packageDir, '../..') const version = JSON.parse( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18c5c9d9..320cbc67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@clack/prompts': specifier: 1.7.0 version: 1.7.0 + '@platformatic/vfs': + specifier: 0.4.0 + version: 0.4.0 '@types/semver': specifier: ^7.7.1 version: 7.8.0 @@ -796,6 +799,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@platformatic/vfs@0.4.0': + resolution: {integrity: sha512-JwRxSIG63e/VaDSkYXkSBajySt5MJzxYIKuknYda28fhhmJMqyUGf5rbKrGW+vO9L83nIjQH3+YI12fp7EKICQ==} + engines: {node: '>= 22'} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -4700,6 +4707,8 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@platformatic/vfs@0.4.0': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 From a77f3ade98ac57b38a0056acea24c32fb1c1a31d Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 29 Oct 2026 09:02:16 +0100 Subject: [PATCH 2/2] some fixup --- packages/intent/src/cli.ts | 2 +- packages/intent/src/transforms/runtime.ts | 2 +- packages/intent/src/transforms/worker.ts | 6 ++++- .../tests/integration/transforms.test.ts | 27 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 87e20563..58b04a0b 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -110,7 +110,7 @@ function createCli( .option( '--answer ', 'Answer a question; repeat for multiple answers', - { type: [String] }, + { type: [String], default: [] }, ) .option('--reset', 'Clear saved configuration before configuring the skill') .option('--global', 'Resolve project packages, then global packages') diff --git a/packages/intent/src/transforms/runtime.ts b/packages/intent/src/transforms/runtime.ts index 905e03e3..00396940 100644 --- a/packages/intent/src/transforms/runtime.ts +++ b/packages/intent/src/transforms/runtime.ts @@ -251,7 +251,7 @@ export function runTransform(options: { ) throw new Error('Transform must return a string of at most 4 MiB') validateFiles(output.files) - if (JSON.stringify(config) !== JSON.stringify(output.files)) + if (options.reset || JSON.stringify(config) !== JSON.stringify(output.files)) commitConfig(options.configDir, output.files) return output.content } finally { diff --git a/packages/intent/src/transforms/worker.ts b/packages/intent/src/transforms/worker.ts index b7167146..7b70c2bd 100644 --- a/packages/intent/src/transforms/worker.ts +++ b/packages/intent/src/transforms/worker.ts @@ -99,7 +99,11 @@ await module.link(() => { 'Transform imports are disabled; bundle your transform into one file', ) }) -await module.evaluate({ timeout: 1000 }) +const evaluation = module.evaluate({ timeout: 1000 }) +// Older Node releases queue evaluation's completion in the VM context when +// microtaskMode is afterEvaluate. Drain it before awaiting from the host. +new Script('').runInContext(context, { timeout: 1000 }) +await evaluation context.transform = module.namespace context.operation = request.operation const result = new Script( diff --git a/packages/intent/tests/integration/transforms.test.ts b/packages/intent/tests/integration/transforms.test.ts index 7cf8add5..25a4a98f 100644 --- a/packages/intent/tests/integration/transforms.test.ts +++ b/packages/intent/tests/integration/transforms.test.ts @@ -127,6 +127,7 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( ) const failed = run(cli, ['configure', 'example#guide', '--reset']) expect(failed.status).not.toBe(0) + expect(failed.stderr).toContain('Abort reset') expect(JSON.parse(run(cli, ['load', 'example#guide']).stdout)).toEqual([ 'saved.json', ]) @@ -144,6 +145,32 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( ]) }) + it('allows reset and stdin-only configuration without answer flags', () => { + expect( + run(cli, ['configure', 'example#guide', '--answer', 'Q1=old']).status, + ).toBe(0) + writeFileSync( + join(skillDir, 'scripts/transform.mjs'), + ` + export function configure({ config, answers, stdin }) { + if (answers.length) throw new Error('Expected no answers'); + if (!stdin && config.listFiles().length) throw new Error('Expected reset'); + if (stdin) config.writeFile('stdin.txt', stdin); + return 'Saved'; + } + export function load({ config }) { return JSON.stringify(config.listFiles()); } + `, + ) + const reset = run(cli, ['configure', 'example#guide', '--reset']) + expect(reset.status, reset.stderr).toBe(0) + expect(JSON.parse(run(cli, ['load', 'example#guide']).stdout)).toEqual([]) + const configured = run(cli, ['configure', 'example#guide'], 'preferences') + expect(configured.status, configured.stderr).toBe(0) + expect(JSON.parse(run(cli, ['load', 'example#guide']).stdout)).toEqual([ + 'stdin.txt', + ]) + }) + it('forwards repeated string flags and stdin, and keeps JSON/path modes intact', () => { const configured = run( cli,