Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/skill-transforms.md
Original file line number Diff line number Diff line change
@@ -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+.
61 changes: 61 additions & 0 deletions docs/cli/intent-configure.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- ::start:tabs variant="package-manager" mode="local-install" -->

```text
@tanstack/intent@latest configure <package>#<skill> [--answer <question=value>] [--reset] [--global] [--global-only]
```

<!-- ::end:tabs -->

## 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 <skill>` to receive setup instructions.
2. The agent inspects the consumer's repository and asks the user questions.
3. Run `intent configure <skill>` with individual answers, batches, or stdin as instructed by the skill.
4. Follow any additional instructions returned by `configure()`.
5. Run `intent load <skill>` 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)
22 changes: 19 additions & 3 deletions docs/cli/intent-load.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- ::start:tabs variant="package-manager" mode="local-install" -->

```text
@tanstack/intent@latest load <package>#<skill> [--path] [--json] [--debug] [--global] [--global-only]
@tanstack/intent@latest load <package>#<skill> [...args] [--path] [--json] [--debug] [--global] [--global-only]
```

<!-- ::end:tabs -->

## 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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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 <package>#<skill>.`
Expand All @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions docs/concepts/skill-transforms.md
Original file line number Diff line number Diff line change
@@ -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 <skill> --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.
10 changes: 8 additions & 2 deletions docs/concepts/trust-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
{
"label": "Configuration",
"to": "concepts/configuration"
},
{
"label": "Skill Transforms",
"to": "concepts/skill-transforms"
}
]
},
Expand All @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions packages/intent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,6 +33,7 @@
],
"devDependencies": {
"@clack/prompts": "1.7.0",
"@platformatic/vfs": "0.4.0",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Polyfill for node:vfs by the author of node:vfs.
0.5.0 was unfortunately published without provenance so I've stuck to 0.4.0

"@types/semver": "^7.7.1",
"cac": "^7.0.0",
"jsonc-parser": "^3.3.1",
Expand Down
45 changes: 39 additions & 6 deletions packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -76,19 +77,51 @@ function createCli(
)

cli
.command('load [use]', 'Load a compact skill use and print its SKILL.md')
.usage('load <use> [--path] [--json] [--debug] [--global] [--global-only]')
.command(
'load [use] [...args]',
'Load a compact skill use and print its SKILL.md',
)
.usage(
'load <use> [...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')
.option('--global', 'Load from project packages, then global packages')
.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<string>,
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 <question=value>',
'Answer a question; repeat for multiple answers',
{ type: [String], default: [] },
)
.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')
Expand Down
Loading