diff --git a/packages/cli/README.md b/packages/cli/README.md index 319cea6d..13c48562 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,7 +1,7 @@ # agentworkforce CLI A thin command-line front end for the workload-router. Spawns the harness CLI -(`claude`, `codex`, `opencode`, `grok`) configured by a selected **persona** from the +(`claude`, `codex`, `opencode`, `grok`, `cursor`) configured by a selected **persona** from the project-local layer, configured source directories, or the small internal built-in system catalog. @@ -37,7 +37,7 @@ agentworkforce --version - `integrations` — discover available integrations, known trigger events, and connection status for the active workspace. - `sources` — list, add, or remove persona source directories. -- `harness check` — probe which harnesses (`claude`, `codex`, `opencode`, `grok`) +- `harness check` — probe which harnesses (`claude`, `codex`, `opencode`, `grok`, `cursor`) are installed. See [`## Harness check`](#harness-check) below. - `destroy` — tear down a deployed cloud agent: cancels all relaycron schedules and marks the agent as destroyed. Accepts either a persona @@ -333,7 +333,7 @@ combination; by default only the **recommended tier per intent** is shown | `--all` | off | Show every tier of every persona. Alias: `--no-recommended`. | | `--recommended` | on | Only show the recommended tier per intent. Implicit default; mostly useful for undoing `--all` earlier in a wrapper script. | | `--filter-rating ` | — | Restrict to a single tier (`best` \| `best-value` \| `minimum`). **Implicitly turns off the recommended-only default**, so filtering by `best` shows every persona's `best` row even when that's not the recommended tier. | -| `--filter-harness ` | — | Restrict to a single harness (`claude` \| `codex` \| `opencode` \| `grok`). Composable with `--filter-rating` and `--all`. | +| `--filter-harness ` | — | Restrict to a single harness (`claude` \| `codex` \| `opencode` \| `grok` \| `cursor`). Composable with `--filter-rating` and `--all`. | | `--no-display-description` | off | Hide the `DESCRIPTION` column. `--display-description` re-enables it. | | `--json` | off | Emit `{ "personas": [...] }` with one object per row. Same field set as the table, useful for scripting. | | `-h`, `--help` | — | Print a one-line usage string and exit. | @@ -425,7 +425,7 @@ agentworkforce harness check ``` Probes your PATH for each supported harness binary (`claude`, `codex`, -`opencode`, `grok`) and prints a table with status (`ok` / `missing`), resolved +`opencode`, `grok`, `cursor-agent`) and prints a table with status (`ok` / `missing`), resolved version, and the resolved path (or the error, for missing ones). Exit code is always `0` — this command is diagnostic, not a gate. @@ -780,7 +780,7 @@ persona JSON remains commit-safe as long as you only use references. ## Relayfile mount rules -Interactive harness sessions (`claude`, `opencode`, `grok`, `codex`) run inside +Interactive harness sessions (`claude`, `opencode`, `grok`, `codex`, `cursor`) run inside a Relayfile mount by default. File visibility and writability are controlled by the persona's `mount` block plus project-level dotfiles: @@ -827,9 +827,10 @@ mount rules (`.agentignore` / `.agentreadonly`) for that. from that server), `mcp____` (specific tool). - **Harness support today:** `claude` is wired for allow/deny/mode flags (`--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` maps - `mode: "bypassPermissions"` to `--always-approve`; other Grok permission - fields warn and are ignored. `codex` and `opencode` emit a warning and fall - back to their defaults when `permissions` is set. + `mode: "bypassPermissions"` to `--always-approve`; `cursor` maps it to + `--force`. Other Grok/Cursor permission fields warn and are ignored. + `codex` and `opencode` emit a warning and fall back to their defaults when + `permissions` is set. - **Cascade merge:** `allow` and `deny` are unions across layers (deduped on merge); `mode` is replaced by the topmost layer that sets it. So the library can declare the minimum-viable allow list, a user or configured @@ -915,6 +916,7 @@ verbatim. Three transport types: | codex | yes (via `--config mcp_servers....`) | not yet — SDK workflow path doesn't thread MCP | | opencode | not yet — warns and proceeds without MCP | not yet | | grok | not yet — warns and proceeds without MCP | not yet | +| cursor | not yet — warns and proceeds without MCP | not yet | For a persona that needs MCP today, pick `claude` or `codex` as the harness for that tier. @@ -941,10 +943,10 @@ By default, interactive harness sessions run inside a sandbox mount — see 1. Resolves the persona, walks the cascade, resolves `$VAR` refs. 2. **Stages skills outside the repo by default** (claude interactive only — - see **Skill staging** below). For codex / opencode / grok, or when + see **Skill staging** below). For codex / opencode / grok / cursor, or when `--install-in-repo` is passed, falls back to the legacy repo-relative install path (`.claude/skills/`, `.agents/skills/`, `.skills/`, - `.grok/skills/`). + `.grok/skills/`, `.cursor/rules/`). 3. Runs skill install (`prpm install …`) if the persona declares any skills, using the computed target (stage dir or repo). 4. Execs the harness binary with stdio inherited: @@ -961,6 +963,8 @@ By default, interactive harness sessions run inside a sandbox mount — see `opencode.json` in the sandbox carrying the persona model and prompt. - `grok`: `grok --no-auto-update --model `. In one-shot paths, the CLI uses Grok Build's `--single` mode and passes cwd/output flags. + - `cursor`: `cursor-agent --model `. In one-shot paths, the CLI + uses Cursor Agent's `--print --output-format text` mode. 5. Runs the skill cleanup command on exit, regardless of exit status. In stage-dir mode this is a single `rm -rf `. 6. Records launch metadata for the session and refreshes harness session logs @@ -1047,7 +1051,7 @@ stage dir conflicts with something else (network filesystem, read-only **Caveats for V1:** -- **Claude harness only.** codex, opencode, and grok continue to install into their +- **Claude harness only.** codex, opencode, grok, and cursor continue to install into their conventional repo-relative directories. The SDK throws if `installRoot` is passed with a non-claude harness. - **No cache layer yet.** Every interactive session runs a fresh prpm install @@ -1091,10 +1095,13 @@ back to the repo): | Pattern | Rationale | | --- | --- | -| `.agents`, `.claude/skills`, `.factory/skills`, `.grok/skills`, `.kiro/skills`, `skills` | skill.sh universal install root + per-harness symlink farms | +| `.agents`, `.claude/skills`, `.cursor/rules`, `.factory/skills`, `.grok/skills`, `.kiro/skills`, `skills` | skill.sh universal install root + per-harness symlink farms | | `.opencode`, `.skills` | prpm `--as ` output roots | | `prpm.lock`, `skills-lock.json` | provider lockfiles | +For `cursor`, the mount also hides root `CLAUDE.md` / `CLAUDE.local.md` +because Cursor Agent reads root Claude guidance alongside `AGENTS.md`. + **What's preserved:** - **User-level context** under `~/.claude/` — `CLAUDE.md`, skills, etc. @@ -1103,7 +1110,7 @@ back to the repo): - **Persona skills.** For claude, the `--plugin-dir` passed to the harness resolves to an absolute path *outside* the mount, so staged skills from `~/.agentworkforce/workforce/sessions//claude/plugin/` load normally. For - codex, opencode, and grok, the install runs inside the mount so the writes + codex, opencode, grok, and cursor, the install runs inside the mount so the writes land in the sandbox. - **Keychain auth.** The mount does not pass `--bare`; it only hides files. Claude Code's macOS keychain login stays active. @@ -1156,7 +1163,7 @@ stage dir is cleaned up by the existing `rm -rf` cleanup command. A persona's three tiers can use different harnesses. If a persona uses MCP, use `claude` or `codex` tiers. -`opencode` still does not inject persona `mcpServers` at spawn time. +`opencode`, `grok`, and `cursor` still do not inject persona `mcpServers` at spawn time. ## Troubleshooting @@ -1170,9 +1177,9 @@ If a persona uses MCP, use `claude` or `codex` tiers. auth interactively (e.g. Claude Code's MCP OAuth flow). - **`Failed to spawn "claude": binary not found on PATH.`** — Install the - harness CLI (`claude`, `codex`, `opencode`, or `grok`) and ensure it's on your PATH. + harness CLI (`claude`, `codex`, `opencode`, `grok`, or `cursor-agent`) and ensure it's on your PATH. -- **`warning: persona declares mcpServers but the opencode harness is not yet +- **`warning: persona declares mcpServers but the harness is not yet wired …`** — Switch that tier's `harness` to `claude` or `codex`, or drop the MCP requirement. diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 9108fc15..6ff27a76 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -102,6 +102,26 @@ function writeStandaloneCodexPersona(workforceHome: string, id = 'local-codex'): return id; } +function writeStandaloneCursorPersona(workforceHome: string, id = 'local-cursor'): string { + const personaDir = join(workforceHome, 'personas'); + mkdirSync(personaDir, { recursive: true }); + writeFileSync( + join(personaDir, `${id}.json`), + JSON.stringify({ + id, + intent: 'review', + tags: ['review'], + description: 'Local no-skill cursor persona for CLI subprocess tests.', + harness: 'cursor', + model: 'test-cursor', + systemPrompt: 'Run the local cursor test harness.', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 30 } + }), + 'utf8' + ); + return id; +} + test('parseAgentArgs: --install-in-repo sets flag and preserves positional selector', () => { const { flags, positional } = parseAgentArgs(['--install-in-repo', 'local-codex@best']); assert.equal(flags.installInRepo, true); @@ -409,6 +429,11 @@ test('decideCleanMode: grok defaults to mount', () => { assert.deepEqual(decideCleanMode('grok', true), { useClean: false }); }); +test('decideCleanMode: cursor defaults to mount', () => { + assert.deepEqual(decideCleanMode('cursor'), { useClean: true }); + assert.deepEqual(decideCleanMode('cursor', true), { useClean: false }); +}); + test('formatSandboxMountReadyMessage: appends mount metrics when available', () => { assert.equal( formatSandboxMountReadyMessage('/tmp/mount', { @@ -539,6 +564,7 @@ test('SKILL_INSTALL_IGNORED_PATTERNS: keeps skill-install artifacts out of the r assert.deepEqual([...SKILL_INSTALL_IGNORED_PATTERNS], [ '.agents', '.claude/skills', + '.cursor/rules', '.factory/skills', '.grok/skills', '.kiro/skills', @@ -598,6 +624,24 @@ test('buildRelayfileMountPatterns: merges Relayfile dotfiles with built-in claud } }); +test('buildRelayfileMountPatterns: cursor hides root memory files it reads', () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-cursor-patterns-')); + try { + const patterns = buildRelayfileMountPatterns({ + projectDir: dir, + personaId: 'cursor-persona', + harness: 'cursor' + }); + + assert.ok(patterns.ignoredPatterns.includes('AGENTS.md')); + assert.ok(patterns.ignoredPatterns.includes('.cursor/rules')); + assert.ok(patterns.ignoredPatterns.includes('CLAUDE.md')); + assert.ok(patterns.ignoredPatterns.includes('CLAUDE.local.md')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + // Integration-ish subprocess helper: spawn the built CLI, collect stderr, // and return once the child exits. We force the harness binary to fail to // spawn (PATH scrubbed) so these runs terminate quickly regardless of what @@ -1115,6 +1159,23 @@ test('loadSidecarForSelection: grok picks agentsMd, not claudeMd', () => { assert.equal(sidecar?.personaContent, '# agents\n'); }); +test('loadSidecarForSelection: cursor picks agentsMd, not claudeMd', () => { + const selection = { + personaId: 'p', + harness: 'cursor' as const, + model: 'gpt-5', + systemPrompt: 'X', + harnessSettings: { reasoning: 'medium' as const, timeoutSeconds: 300 }, + skills: [], + rationale: 'test', + claudeMdContent: '# claude\n', + agentsMdContent: '# agents\n' + }; + const { sidecar } = loadSidecarForSelection(selection); + assert.equal(sidecar?.mountFile, 'AGENTS.md'); + assert.equal(sidecar?.personaContent, '# agents\n'); +}); + test('main: codex sessions engage the sandbox mount by default', async () => { const root = mkdtempSync(join(tmpdir(), 'aw-cli-mount-')); try { @@ -1156,6 +1217,42 @@ test('main: codex --install-in-repo disengages the sandbox mount', async () => { } }); +test('main: cursor --install-in-repo passes systemPrompt as an initial prompt fallback', async () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-cli-cursor-no-mount-')); + try { + const cursorAgent = join(dir, 'cursor-agent'); + writeFileSync( + cursorAgent, + `#!/usr/bin/env node +process.stderr.write(JSON.stringify(process.argv.slice(2))); +process.exit(0); +`, + 'utf8' + ); + chmodSync(cursorAgent, 0o755); + + const workforceHome = join(dir, '.agentworkforce', 'workforce'); + const personaId = writeStandaloneCursorPersona(workforceHome); + const { stderr, exitCode } = await runCliCapturingStderr( + ['agent', `${personaId}`, '--install-in-repo'], + { + PATH: `${dir}:${process.env.PATH ?? ''}`, + AGENT_WORKFORCE_HOME: workforceHome, + AGENTWORKFORCE_LAUNCH_METADATA: '0' + } + ); + + assert.equal(exitCode, 0); + assert.match(stderr, /cannot safely materialize Cursor AGENTS\.md/); + assert.match( + stderr, + /\["--model","test-cursor","Run the local cursor test harness\."\]/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('main: preserves the harness exit code', async () => { const dir = mkdtempSync(join(tmpdir(), 'aw-cli-exit-code-')); try { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 07312b8c..36da2c8c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -132,7 +132,8 @@ Commands: install skills into the repo's harness-conventional directory (.claude/skills, .opencode/skills, - .agents/skills, .grok/skills, etc.). + .agents/skills, .grok/skills, + .cursor/rules, etc.). By default, interactive harness sessions run inside a @relayfile/local-mount sandbox so @@ -956,7 +957,7 @@ async function writeMarkerWithUpstream( * "install once, reuse forever" behaviour that claude gets for free via * `--plugin-dir `. The destination is expected to be the mount * root: skill artifacts under `.skills/`, `.opencode/`, `.agents/`, `.grok/skills`, - * etc. are + * `.cursor/rules`, etc. are * declared as mount-ignored patterns so the mirror does not sync back to the * user's real repo on session exit. */ @@ -1058,6 +1059,7 @@ export const SKILL_INSTALL_IGNORED_PATTERNS = [ // skill.sh universal install root + per-harness symlink farms '.agents', '.claude/skills', + '.cursor/rules', '.factory/skills', '.grok/skills', '.kiro/skills', @@ -1074,6 +1076,14 @@ export const SKILL_INSTALL_IGNORED_PATTERNS = [ 'AGENTS.md' ] as const; +const CURSOR_IGNORED_PATTERNS = [ + ...SKILL_INSTALL_IGNORED_PATTERNS, + // Cursor CLI reads root CLAUDE.md alongside AGENTS.md; hide repo-level + // Claude guidance so persona AGENTS.md remains the only root memory file. + 'CLAUDE.md', + 'CLAUDE.local.md' +] as const; + export interface RelayfileMountPatterns { ignoredPatterns: string[]; readonlyPatterns: string[]; @@ -1092,7 +1102,9 @@ export function buildRelayfileMountPatterns(input: { const builtInIgnored = input.harness === 'claude' ? CLEAN_IGNORED_PATTERNS - : SKILL_INSTALL_IGNORED_PATTERNS; + : input.harness === 'cursor' + ? CURSOR_IGNORED_PATTERNS + : SKILL_INSTALL_IGNORED_PATTERNS; return { ignoredPatterns: [ @@ -1192,7 +1204,7 @@ export function configureGitForMount(mountDir: string, patterns: readonly string * harness has no mount (`--install-in-repo`). */ export interface ResolvedSidecar { - /** Filename inside the mount: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex/grok). */ + /** Filename inside the mount: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex/grok/cursor). */ mountFile: 'CLAUDE.md' | 'AGENTS.md'; /** Persona-author content. Already inlined for built-ins; read from disk for local. */ personaContent: string; @@ -1215,7 +1227,8 @@ export function loadSidecarForSelection( harness !== 'claude' && harness !== 'opencode' && harness !== 'codex' && - harness !== 'grok' + harness !== 'grok' && + harness !== 'cursor' ) { return {}; } @@ -1245,7 +1258,7 @@ export function loadSidecarForSelection( } return {}; } - // opencode, codex, and grok all read AGENTS.md from cwd. The resolution + // opencode, codex, grok, and cursor all read AGENTS.md from cwd. The resolution // rule is identical for these harnesses here. if (selection.agentsMdContent) { return { @@ -1307,7 +1320,7 @@ export function buildSidecarBody( * * All interactive harnesses default to the mount. * The mount hides CLAUDE.md / .claude / .mcp.json (claude) or the - * skill-install patterns + AGENTS.md (codex / opencode / grok) so + * skill-install patterns + AGENTS.md (codex / opencode / grok / cursor) so * persona-supplied sidecars and any per-session writes stay sandboxed and * don't leak into the user's real repo. `--install-in-repo` is the single * opt-out that disengages the mount across all harnesses. @@ -1318,7 +1331,13 @@ export function decideCleanMode( harness: Harness, installInRepo = false ): { useClean: boolean } { - if (harness === 'claude' || harness === 'opencode' || harness === 'codex' || harness === 'grok') { + if ( + harness === 'claude' || + harness === 'opencode' || + harness === 'codex' || + harness === 'grok' || + harness === 'cursor' + ) { return { useClean: !installInRepo }; } return { useClean: false }; @@ -1851,13 +1870,28 @@ async function runInteractive( const hasConfigFiles = spec.configFiles.length > 0; const degradeConfigFiles = hasConfigFiles && !useClean; let effectiveArgs: readonly string[] = spec.args; + let effectiveInitialPrompt = spec.initialPrompt; if (degradeConfigFiles) { - process.stderr.write( - 'warning: --install-in-repo cannot safely materialize the persona agent config (would write opencode.json into your repo); launching without --agent. Drop --install-in-repo to apply the persona prompt.\n' - ); - effectiveArgs = stripAgentFlag(spec.args); + if (harness === 'opencode') { + process.stderr.write( + 'warning: --install-in-repo cannot safely materialize the persona agent config (would write opencode.json into your repo); launching without --agent. Drop --install-in-repo to apply the persona prompt.\n' + ); + effectiveArgs = stripAgentFlag(spec.args); + } else if (harness === 'cursor') { + process.stderr.write( + 'warning: --install-in-repo cannot safely materialize Cursor AGENTS.md (would write AGENTS.md into your repo); launching with the persona system prompt as Cursor\'s initial prompt. Drop --install-in-repo to apply the persona prompt via AGENTS.md.\n' + ); + effectiveInitialPrompt = systemPrompt || null; + } else { + const files = spec.configFiles.map((file) => file.path).join(', '); + process.stderr.write( + `warning: --install-in-repo cannot safely materialize persona config file(s) ${files} into your repo; launching without those config files. Drop --install-in-repo to apply them.\n` + ); + } } - const finalArgs = spec.initialPrompt ? [...effectiveArgs, spec.initialPrompt] : [...effectiveArgs]; + const finalArgs = effectiveInitialPrompt + ? [...effectiveArgs, effectiveInitialPrompt] + : [...effectiveArgs]; // Print a sanitized summary rather than raw argv: spec.args for the claude // harness contains the resolved --mcp-config JSON and the full system diff --git a/packages/cli/src/launch-metadata.test.ts b/packages/cli/src/launch-metadata.test.ts index abf6e00c..e6236992 100644 --- a/packages/cli/src/launch-metadata.test.ts +++ b/packages/cli/src/launch-metadata.test.ts @@ -208,4 +208,5 @@ test('launchMetadataIngestHarness maps AgentWorkforce claude to backend claude-c assert.equal(launchMetadataIngestHarness('codex'), 'codex'); assert.equal(launchMetadataIngestHarness('opencode'), 'opencode'); assert.equal(launchMetadataIngestHarness('grok'), 'grok'); + assert.equal(launchMetadataIngestHarness('cursor'), 'cursor'); }); diff --git a/packages/cli/src/launch-metadata.ts b/packages/cli/src/launch-metadata.ts index 03a803c5..3378812f 100644 --- a/packages/cli/src/launch-metadata.ts +++ b/packages/cli/src/launch-metadata.ts @@ -17,7 +17,12 @@ const LAUNCH_METADATA_BACKEND_CALL_TIMEOUT_MS = 5_000; */ const LAUNCH_METADATA_INGEST_FAILURE_WARN_AFTER = 3; -export type LaunchMetadataIngestHarness = 'claude-code' | 'codex' | 'opencode' | 'grok'; +export type LaunchMetadataIngestHarness = + | 'claude-code' + | 'codex' + | 'opencode' + | 'grok' + | 'cursor'; export type LaunchMetadataPendingStampHarness = Harness; export interface LaunchMetadataPendingStampOptions { @@ -123,6 +128,7 @@ export function launchMetadataSessionDirHint(harness: Harness): string | undefin case 'opencode': return join(home, '.local', 'share', 'opencode', 'storage', 'session'); case 'grok': + case 'cursor': return undefined; default: { const _exhaustive: never = harness; diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 10b66a77..27bf66e5 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -38,7 +38,7 @@ }, "harness": { "$ref": "#/definitions/Harness", - "description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`, `grok`). Required for interactive personas. Optional for handler-style personas ( {@link onEvent } set): only consumed when the handler calls `ctx.harness.run(...)`; pure orchestrators omit it." + "description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`, `grok`, `cursor`). Required for interactive personas. Optional for handler-style personas ( {@link onEvent } set): only consumed when the handler calls `ctx.harness.run(...)`; pure orchestrators omit it." }, "model": { "type": "string", @@ -64,11 +64,11 @@ "additionalProperties": { "$ref": "#/definitions/McpServerSpec" }, - "description": "MCP servers to attach to the harness session.\n- `claude`: passed via `--mcp-config`\n- `codex`: translated into `--config mcp_servers....` overrides\n- `opencode` / `grok`: currently warn and skip" + "description": "MCP servers to attach to the harness session.\n- `claude`: passed via `--mcp-config`\n- `codex`: translated into `--config mcp_servers....` overrides\n- `opencode` / `grok` / `cursor`: currently warn and skip" }, "permissions": { "$ref": "#/definitions/PersonaPermissions", - "description": "Permission policy (allow/deny lists, mode) for the harness session. `claude` is wired for allow/deny/mode flags (via `--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` maps `mode: \"bypassPermissions\"` to `--always-approve`; other fields/harnesses warn and skip." + "description": "Permission policy (allow/deny lists, mode) for the harness session. `claude` is wired for allow/deny/mode flags (via `--allowedTools`, `--disallowedTools`, `--permission-mode`). `grok` maps `mode: \"bypassPermissions\"` to `--always-approve`; `cursor` maps it to `--force`; other fields/harnesses warn and skip." }, "sandbox": { "type": "boolean", @@ -88,7 +88,7 @@ }, "agentsMd": { "type": "string", - "description": "Author-supplied path to an `AGENTS.md` sidecar that should be applied when the persona runs under the opencode/codex/grok harnesses. Same resolution rules as {@link claudeMd } ." + "description": "Author-supplied path to an `AGENTS.md` sidecar that should be applied when the persona runs under the opencode/codex/grok/cursor harnesses. Same resolution rules as {@link claudeMd } ." }, "agentsMdMode": { "$ref": "#/definitions/SidecarMdMode", @@ -229,7 +229,8 @@ "opencode", "codex", "claude", - "grok" + "grok", + "cursor" ] }, "HarnessSettings": { diff --git a/packages/persona-kit/src/constants.ts b/packages/persona-kit/src/constants.ts index f03ddbad..e14de586 100644 --- a/packages/persona-kit/src/constants.ts +++ b/packages/persona-kit/src/constants.ts @@ -1,6 +1,6 @@ import type { Harness, HarnessSkillTarget } from './types.js'; -export const HARNESS_VALUES = ['opencode', 'codex', 'claude', 'grok'] as const; +export const HARNESS_VALUES = ['opencode', 'codex', 'claude', 'grok', 'cursor'] as const; /** * The closed persona-tag vocabulary. The cloud deploy endpoint validates * `tags` against exactly this set and rejects anything else with @@ -98,5 +98,6 @@ export const HARNESS_SKILL_TARGETS: Record = { claude: { asFlag: 'claude', dir: '.claude/skills' }, codex: { asFlag: 'codex', dir: '.agents/skills' }, opencode: { asFlag: 'opencode', dir: '.skills' }, - grok: { asFlag: 'grok', dir: '.grok/skills' } + grok: { asFlag: 'grok', dir: '.grok/skills' }, + cursor: { asFlag: 'cursor', dir: '.cursor/rules' } }; diff --git a/packages/persona-kit/src/detect.ts b/packages/persona-kit/src/detect.ts index 15918bcc..3a06d73c 100644 --- a/packages/persona-kit/src/detect.ts +++ b/packages/persona-kit/src/detect.ts @@ -5,6 +5,14 @@ import { delimiter, join } from 'node:path'; import { HARNESS_VALUES } from './constants.js'; import type { Harness } from './types.js'; +const HARNESS_BINARIES: Record = { + claude: 'claude', + codex: 'codex', + opencode: 'opencode', + grok: 'grok', + cursor: 'cursor-agent' +}; + /** * Result of probing a harness binary on the caller's machine. * @@ -50,11 +58,12 @@ export function detectHarness( options: { timeoutMs?: number } = {} ): HarnessAvailability { const timeoutMs = options.timeoutMs ?? 3000; - const path = findOnPath(harness); + const bin = HARNESS_BINARIES[harness]; + const path = findOnPath(bin); if (!path) { return { harness, available: false, error: 'not found on PATH' }; } - const res = spawnSync(harness, ['--version'], { + const res = spawnSync(bin, ['--version'], { timeout: timeoutMs, encoding: 'utf8', shell: false diff --git a/packages/persona-kit/src/execute.ts b/packages/persona-kit/src/execute.ts index 45b79fd0..5a35c356 100644 --- a/packages/persona-kit/src/execute.ts +++ b/packages/persona-kit/src/execute.ts @@ -56,7 +56,8 @@ async function disposeAll(handles: readonly Disposer[]): Promise { /** * Run the plan's side effects in deterministic order with abort-on-failure. * After this returns successfully, the harness can be spawned at - * `handle.cwd` with `plan.cli` + `plan.args` and `plan.env`. + * `handle.cwd` with `plan.cli` (the executable binary) + `plan.args` and + * `plan.env`. * * Order: * 1. {@link applyPersonaMount} — mount policy first; everything else diff --git a/packages/persona-kit/src/index.test.ts b/packages/persona-kit/src/index.test.ts index d11b8bee..f8d7fa89 100644 --- a/packages/persona-kit/src/index.test.ts +++ b/packages/persona-kit/src/index.test.ts @@ -48,6 +48,10 @@ test('grok is a recognized harness value', () => { assert.ok(HARNESS_VALUES.includes('grok')); }); +test('cursor is a recognized harness value', () => { + assert.ok(HARNESS_VALUES.includes('cursor')); +}); + test('HARNESS_SKILL_TARGETS covers every harness value', () => { for (const harness of HARNESS_VALUES) { const target = HARNESS_SKILL_TARGETS[harness]; @@ -103,6 +107,18 @@ test('materializeSkills routes grok skills to .grok/skills via --as grok', () => assert.deepEqual([...install.cleanupPaths], ['.grok/skills/npm-trusted-publishing']); }); +test('materializeSkills routes cursor skills to .cursor/rules via --as cursor', () => { + const plan = materializeSkills([prpmSkill], 'cursor'); + const [install] = plan.installs; + assert.deepEqual( + [...install.installCommand], + ['npx', '-y', 'prpm', 'install', '@prpm/npm-trusted-publishing', '--as', 'cursor'] + ); + assert.equal(install.installedDir, '.cursor/rules/npm-trusted-publishing'); + assert.equal(install.installedManifest, '.cursor/rules/npm-trusted-publishing/SKILL.md'); + assert.deepEqual([...install.cleanupPaths], ['.cursor/rules/npm-trusted-publishing']); +}); + test('materializeSkills emits a skill.sh install for a github#skill source', () => { const plan = materializeSkills([skillShSkill], 'claude'); @@ -130,6 +146,7 @@ test('materializeSkills emits a skill.sh install for a github#skill source', () [ '.agents/skills/find-skills', '.claude/skills/find-skills', + '.cursor/rules/find-skills', '.factory/skills/find-skills', '.grok/skills/find-skills', '.kiro/skills/find-skills', diff --git a/packages/persona-kit/src/interactive-spec.test.ts b/packages/persona-kit/src/interactive-spec.test.ts index fe09afe1..52893389 100644 --- a/packages/persona-kit/src/interactive-spec.test.ts +++ b/packages/persona-kit/src/interactive-spec.test.ts @@ -428,7 +428,72 @@ test('grok non-interactive spec still adds always-approve for unsupported permis ]); }); -test('claude and codex emit an empty configFiles array; grok does so only with an empty systemPrompt', () => { +test('cursor launches cursor-agent and writes systemPrompt to AGENTS.md', () => { + const result = buildInteractiveSpec({ + harness: 'cursor', + personaId: 'test-persona', + model: 'gpt-5', + systemPrompt: 'you are a test' + }); + assert.equal(result.bin, 'cursor-agent'); + assert.deepEqual(result.args, ['--model', 'gpt-5']); + assert.equal(result.initialPrompt, null); + assert.deepEqual(result.configFiles, [ + { path: 'AGENTS.md', contents: 'you are a test\n' } + ]); + assert.deepEqual(result.warnings, []); +}); + +test('cursor maps bypassPermissions to force and warns for unsupported fields', () => { + const result = buildInteractiveSpec({ + harness: 'cursor', + personaId: 'test-persona', + model: 'gpt-5', + systemPrompt: 'x', + permissions: { + allow: ['Bash(git *)'], + deny: ['Bash(rm -rf *)'], + mode: 'bypassPermissions' + }, + harnessSettings: { + reasoning: 'medium', + timeoutSeconds: 300, + dangerouslyBypassApprovalsAndSandbox: true, + approvalPolicy: 'never' + } + }); + assert.deepEqual(result.args, ['--model', 'gpt-5', '--force']); + assert.deepEqual(result.warnings, [ + 'persona declares permission allow/deny lists but the cursor harness is not wired for allow/deny injection; proceeding without allow/deny rules.', + 'cursor harnessSettings.approvalPolicy is not supported; use permissions.mode "bypassPermissions" or dangerouslyBypassApprovalsAndSandbox for --force.' + ]); +}); + +test('cursor non-interactive spec uses print mode with text output', () => { + const result = buildNonInteractiveSpec({ + harness: 'cursor', + personaId: 'daily-ship', + model: 'gpt-5', + systemPrompt: 'Reply pong.', + task: 'say pong' + }); + + assert.equal(result.bin, 'cursor-agent'); + assert.deepEqual(result.args, [ + '--model', + 'gpt-5', + '--print', + '--output-format', + 'text', + 'say pong' + ]); + assert.deepEqual(result.configFiles, [ + { path: 'AGENTS.md', contents: 'Reply pong.\n' } + ]); + assert.deepEqual(result.warnings, []); +}); + +test('claude and codex emit an empty configFiles array; grok/cursor do so only with an empty systemPrompt', () => { const claude = buildInteractiveSpec({ harness: 'claude', personaId: 'test-persona', @@ -452,6 +517,14 @@ test('claude and codex emit an empty configFiles array; grok does so only with a systemPrompt: '' }); assert.deepEqual(grok.configFiles, []); + + const cursor = buildInteractiveSpec({ + harness: 'cursor', + personaId: 'test-persona', + model: 'gpt-5', + systemPrompt: '' + }); + assert.deepEqual(cursor.configFiles, []); }); test('claude branch omits --append-system-prompt when systemPrompt is empty', () => { @@ -561,7 +634,7 @@ test('claude branch omits --plugin-dir when pluginDirs is empty or absent', () = assert.ok(!without.args.includes('--plugin-dir')); }); -test('codex and opencode warn and ignore pluginDirs', () => { +test('codex, opencode, and cursor warn and ignore pluginDirs', () => { const codex = buildInteractiveSpec({ harness: 'codex', personaId: 'test-persona', @@ -581,6 +654,16 @@ test('codex and opencode warn and ignore pluginDirs', () => { }); assert.ok(!opencode.args.includes('--plugin-dir')); assert.ok(opencode.warnings.some((w) => /supported only for claude and grok/.test(w))); + + const cursor = buildInteractiveSpec({ + harness: 'cursor', + personaId: 'test-persona', + model: 'x', + systemPrompt: 'x', + pluginDirs: ['/tmp/session/plugin'] + }); + assert.ok(!cursor.args.includes('--plugin-dir')); + assert.ok(cursor.warnings.some((w) => /supported only for claude and grok/.test(w))); }); test('warnings are returned, not printed — library consumers route I/O themselves', () => { @@ -728,6 +811,20 @@ test('relayMcp under grok warns that MCP injection is unsupported', () => { assert.equal(result.mcpServers?.relaycast?.type, 'stdio'); }); +test('relayMcp under cursor warns that MCP injection is unsupported', () => { + const result = buildInteractiveSpec({ + harness: 'cursor', + personaId: 'p', + model: 'gpt-5', + systemPrompt: 'x', + relayMcp: { apiKey: 'wk_live_abc', agentName: 'Cursor1' } + }); + assert.deepEqual(result.warnings, [ + 'broker requested relaycast MCP injection but the cursor harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ]); + assert.equal(result.mcpServers?.relaycast?.type, 'stdio'); +}); + test('opencode warning names both persona and broker MCP sources when both are present', () => { const result = buildInteractiveSpec({ harness: 'opencode', @@ -759,3 +856,19 @@ test('grok warning names both persona and broker MCP sources when both are prese 'persona declares mcpServers and broker requested relaycast MCP injection, but the grok harness is not yet wired for runtime MCP injection; proceeding without MCP.' ]); }); + +test('cursor warning names both persona and broker MCP sources when both are present', () => { + const result = buildInteractiveSpec({ + harness: 'cursor', + personaId: 'p', + model: 'gpt-5', + systemPrompt: 'x', + mcpServers: { + posthog: { type: 'http', url: 'https://mcp.posthog.com/mcp' } + }, + relayMcp: { apiKey: 'wk_live_abc', agentName: 'Cursor1' } + }); + assert.deepEqual(result.warnings, [ + 'persona declares mcpServers and broker requested relaycast MCP injection, but the cursor harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ]); +}); diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 76a39b4f..49b5cfa0 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -19,7 +19,7 @@ export interface InteractiveConfigFile { /** Result of translating a persona's runtime into a spawnable command. */ export interface InteractiveSpec { - /** Binary to exec (e.g. `claude`, `codex`, `opencode`, `grok`). */ + /** Binary to exec (e.g. `claude`, `codex`, `opencode`, `grok`, `cursor-agent`). */ bin: string; /** Argv for the binary, in order. Callers should `spawn(bin, args)`. */ args: readonly string[]; @@ -29,7 +29,7 @@ export interface InteractiveSpec { * to carry the persona's system prompt as the initial user prompt. * Currently only codex takes this path; claude uses `--append-system-prompt`, * opencode writes the prompt into `opencode.json` (see `configFiles`), and - * grok writes the prompt into `AGENTS.md` (see `configFiles`). + * grok/cursor write the prompt into `AGENTS.md` (see `configFiles`). */ initialPrompt: string | null; /** @@ -41,8 +41,8 @@ export interface InteractiveSpec { * Config files the caller must write (relative to the harness cwd) before * launch. Opencode uses this to materialize an `opencode.json` carrying * the persona's agent definition (model + system prompt) so `--agent` can - * resolve it. Grok uses this to materialize `AGENTS.md` when `systemPrompt` - * is non-empty. Claude and codex return an empty array. + * resolve it. Grok and Cursor use this to materialize `AGENTS.md` when + * `systemPrompt` is non-empty. Claude and codex return an empty array. */ configFiles: InteractiveConfigFile[]; /** @@ -85,8 +85,8 @@ export interface RelayMcpConfig { export interface BuildInteractiveSpecInput { harness: Harness; /** - * Persona id — used as the opencode agent name. Claude, codex, and grok ignore - * this field today; keeping it required here keeps call sites honest and + * Persona id — used as the opencode agent name. Claude, codex, grok, and + * cursor ignore this field today; keeping it required here keeps call sites honest and * lets future harnesses consume it without another type change. */ personaId: string; @@ -98,8 +98,8 @@ export interface BuildInteractiveSpecInput { * When set, a `relaycast` MCP server is merged into {@link mcpServers} so a * persona running under an Agent Relay broker can talk to the team. A * persona-declared server literally named `relaycast` takes precedence (it - * is not overwritten). Wired for claude and codex; opencode/grok still warn - * that MCP injection is unsupported. + * is not overwritten). Wired for claude and codex; opencode/grok/cursor still + * warn that MCP injection is unsupported. */ relayMcp?: RelayMcpConfig; permissions?: PersonaPermissions; @@ -109,7 +109,7 @@ export interface BuildInteractiveSpecInput { * session (`--plugin-dir ` per entry where supported). Used to wire in out-of-repo * skill stages produced by * {@link SkillMaterializationOptions.installRoot}. - * Currently supported by Claude and Grok. Codex/opencode emit a warning and + * Currently supported by Claude and Grok. Codex/opencode/cursor emit a warning and * ignore the field. */ pluginDirs?: readonly string[]; @@ -217,6 +217,11 @@ function appendCodexMcpServerArgs( * mode writes the persona system prompt into AGENTS.md when present, and * one-shot mode uses `--single` because Grok has no separate system-prompt flag. * + * The cursor branch launches the Cursor Agent CLI (`cursor-agent`) with the + * persona model. Cursor reads AGENTS.md / CLAUDE.md / .cursor/rules from the + * working tree; this translator uses AGENTS.md for persona instructions and + * `--print --output-format text` for one-shot runs. + * * The opencode branch routes model + system prompt through opencode's * agent abstraction (see https://opencode.ai/config.json: `agent..{ * model, prompt, mode }`). It emits an `opencode.json` via `configFiles` @@ -232,9 +237,10 @@ function appendCodexMcpServerArgs( * `--config mcp_servers....` TOML overrides so codex sessions receive * the same declared MCP servers as the persona. * - * The opencode/grok branches emit a warning if the persona declares `mcpServers`. + * The opencode/grok/cursor branches emit a warning if the persona declares `mcpServers`. * Grok maps `permissions.mode: "bypassPermissions"` to `--always-approve`; - * other permission fields warn. + * Cursor maps `permissions.mode: "bypassPermissions"` to `--force`; other + * permission fields warn. */ /** * Build the stdio MCP server spec for relaycast, mirroring the env block the @@ -536,6 +542,71 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact mcpServers }; } + case 'cursor': { + if (hasPersonaMcpServers && injectsRelaycast) { + warnings.push( + 'persona declares mcpServers and broker requested relaycast MCP injection, but the cursor harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } else if (hasPersonaMcpServers) { + warnings.push( + 'persona declares mcpServers but the cursor harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } else if (injectsRelaycast) { + warnings.push( + 'broker requested relaycast MCP injection but the cursor harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } + if (permissions?.allow?.length || permissions?.deny?.length) { + warnings.push( + 'persona declares permission allow/deny lists but the cursor harness is not wired for allow/deny injection; proceeding without allow/deny rules.' + ); + } + const args = ['--model', model]; + if (permissions?.mode === 'bypassPermissions') { + args.push('--force'); + } else if (permissions?.mode && permissions.mode !== 'default') { + warnings.push( + `persona declares permissions.mode "${permissions.mode}" but the cursor harness only supports bypassPermissions via --force; proceeding with Cursor defaults.` + ); + } + if (hasPluginDirs) { + warnings.push( + 'pluginDirs is currently supported only for claude and grok; ignoring under the cursor harness. Skills must be staged via cursor conventions.' + ); + } + if (harnessSettings?.dangerouslyBypassApprovalsAndSandbox && !args.includes('--force')) { + args.push('--force'); + } + if (harnessSettings?.sandboxMode) { + warnings.push('cursor harnessSettings.sandboxMode is not yet wired; proceeding with Cursor defaults.'); + } + if (harnessSettings?.approvalPolicy) { + warnings.push( + 'cursor harnessSettings.approvalPolicy is not supported; use permissions.mode "bypassPermissions" or dangerouslyBypassApprovalsAndSandbox for --force.' + ); + } + if (harnessSettings?.workspaceWriteNetworkAccess !== undefined) { + warnings.push('cursor harnessSettings.workspaceWriteNetworkAccess is not yet wired; proceeding with Cursor defaults.'); + } + if (harnessSettings?.webSearch) { + warnings.push('cursor harnessSettings.webSearch is not wired to a Cursor CLI flag; proceeding with Cursor defaults.'); + } + return { + bin: 'cursor-agent', + args, + initialPrompt: null, + warnings, + configFiles: systemPrompt + ? [ + { + path: 'AGENTS.md', + contents: systemPrompt.endsWith('\n') ? systemPrompt : `${systemPrompt}\n` + } + ] + : [], + mcpServers + }; + } default: { // Exhaustiveness guard: if `Harness` gains a new variant, this // assertion will fail to compile and force the maintainer to handle @@ -569,6 +640,8 @@ export interface NonInteractiveSpec { * - `grok`: appends `--output-format plain [--cwd ] --always-approve * --single `, where prompt includes the persona system prompt plus * the one-shot task. + * - `cursor`: appends `--print --output-format text `; persona + * instructions are carried by the AGENTS.md config file. */ export function buildNonInteractiveSpec( input: BuildInteractiveSpecInput & { @@ -629,6 +702,14 @@ export function buildNonInteractiveSpec( warnings: interactive.warnings }; } + case 'cursor': { + return { + bin: interactive.bin, + args: [...interactive.args, '--print', '--output-format', 'text', input.task], + configFiles: interactive.configFiles, + warnings: interactive.warnings + }; + } default: { const _exhaustive: never = input.harness; throw new Error(`Unhandled harness: ${String(_exhaustive)}`); diff --git a/packages/persona-kit/src/plan.test.ts b/packages/persona-kit/src/plan.test.ts index a3ed3c5a..ccdd2fec 100644 --- a/packages/persona-kit/src/plan.test.ts +++ b/packages/persona-kit/src/plan.test.ts @@ -75,6 +75,22 @@ test('buildPersonaSpawnPlan emits AGENTS.md configFile for grok systemPrompt', ( ]); }); +test('buildPersonaSpawnPlan emits AGENTS.md configFile for cursor systemPrompt', () => { + const plan = buildPersonaSpawnPlan( + persona({ + personaId: 'sample', + harness: 'cursor', + model: 'gpt-5', + systemPrompt: 'cursor prompt' + }), + { processEnv: cleanEnv } + ); + assert.equal(plan.cli, 'cursor-agent'); + assert.deepEqual(plan.configFiles, [ + { path: 'AGENTS.md', contents: 'cursor prompt\n' } + ]); +}); + test('buildPersonaSpawnPlan resolves sidecars from claudeMdContent / agentsMdContent', () => { const claudePlan = buildPersonaSpawnPlan( persona({ @@ -110,6 +126,18 @@ test('buildPersonaSpawnPlan resolves sidecars from claudeMdContent / agentsMdCon assert.equal(grokPlan.sidecars.length, 1); assert.equal(grokPlan.sidecars[0].filename, 'AGENTS.md'); assert.equal(grokPlan.sidecars[0].contents, '# grok agents sidecar'); + + const cursorPlan = buildPersonaSpawnPlan( + persona({ + agentsMdContent: '# cursor agents sidecar', + harness: 'cursor', + model: 'gpt-5' + }), + { processEnv: cleanEnv } + ); + assert.equal(cursorPlan.sidecars.length, 1); + assert.equal(cursorPlan.sidecars[0].filename, 'AGENTS.md'); + assert.equal(cursorPlan.sidecars[0].contents, '# cursor agents sidecar'); }); test('buildPersonaSpawnPlan threads mount policy through when patterns present', () => { @@ -245,6 +273,18 @@ test('buildPersonaSpawnPlan emits sourcePath for AGENTS.md harness agentsMd path ); assert.equal(grokPlan.sidecars.length, 1); assert.equal(grokPlan.sidecars[0].sourcePath, '/abs/path/to/AGENTS.md'); + + const cursorPlan = buildPersonaSpawnPlan( + persona({ + harness: 'cursor', + model: 'gpt-5', + systemPrompt: 's', + agentsMd: '/abs/path/to/AGENTS.md' + }), + { processEnv: cleanEnv } + ); + assert.equal(cursorPlan.sidecars.length, 1); + assert.equal(cursorPlan.sidecars[0].sourcePath, '/abs/path/to/AGENTS.md'); }); test('buildPersonaSpawnPlan does not capture ambient env by default', () => { @@ -265,7 +305,7 @@ test('buildPersonaSpawnPlan opt-in includeProcessEnv captures process.env', () = }); test('buildPersonaSpawnPlan empty-skills case keeps installs empty', () => { - for (const harness of ['claude', 'codex', 'opencode', 'grok'] as Harness[]) { + for (const harness of ['claude', 'codex', 'opencode', 'grok', 'cursor'] as Harness[]) { const plan = buildPersonaSpawnPlan( persona({ harness, diff --git a/packages/persona-kit/src/plan.ts b/packages/persona-kit/src/plan.ts index f572261d..ee943cb7 100644 --- a/packages/persona-kit/src/plan.ts +++ b/packages/persona-kit/src/plan.ts @@ -35,7 +35,7 @@ export interface ResolvedMountPolicy { * JSON-serializable; the executor reads the file at write time. */ export type ResolvedSidecarWrite = { - /** Filename inside the cwd: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex/grok). */ + /** Filename inside the cwd: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex/grok/cursor). */ filename: 'CLAUDE.md' | 'AGENTS.md'; /** * `overwrite` writes verbatim; `extend` appends a `\n\n---\n\n`-joined @@ -66,8 +66,8 @@ export interface ResolvedInputBinding { export interface PersonaSpawnPlan { /** The fully resolved persona this plan was built from. */ persona: ResolvedPersona; - /** Which CLI to spawn (`claude` | `codex` | `opencode` | `grok`). */ - cli: Harness; + /** Which binary to spawn (`claude` | `codex` | `opencode` | `grok` | `cursor-agent`). */ + cli: string; /** argv (excluding the cli itself) that the harness should be spawned with. */ args: string[]; /** Optional initial prompt — used by codex's argv-driven prompt mode. */ @@ -161,7 +161,7 @@ function resolveSidecarWrite( } return []; } - if (harness === 'opencode' || harness === 'codex' || harness === 'grok') { + if (harness === 'opencode' || harness === 'codex' || harness === 'grok' || harness === 'cursor') { if (selection.agentsMdContent !== undefined) { return [ { @@ -280,7 +280,7 @@ export function buildPersonaSpawnPlan( const plan: PersonaSpawnPlan = { persona, - cli: harness, + cli: spec.bin, args: [...spec.args], configFiles: spec.configFiles.map((f) => ({ path: f.path, contents: f.contents })), skills, diff --git a/packages/persona-kit/src/skills.ts b/packages/persona-kit/src/skills.ts index 69d87940..22cc352d 100644 --- a/packages/persona-kit/src/skills.ts +++ b/packages/persona-kit/src/skills.ts @@ -128,6 +128,7 @@ function skillShArtifactPaths(installedName: string): readonly string[] { return Object.freeze([ `.agents/skills/${installedName}`, `.claude/skills/${installedName}`, + `.cursor/rules/${installedName}`, `.factory/skills/${installedName}`, `.grok/skills/${installedName}`, `.kiro/skills/${installedName}`, @@ -311,7 +312,7 @@ export function materializeSkills( if (installRoot !== undefined && harness !== 'claude') { throw new Error( `installRoot is only supported for the claude harness (got: ${harness}). ` + - `codex, opencode, and grok still install into the harness's conventional repo-relative directory.` + `codex, opencode, grok, and cursor still install into the harness's conventional repo-relative directory.` ); } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 55e24cea..bcd132df 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -383,7 +383,7 @@ export interface PersonaSpec { */ inputs?: Record; /** - * Harness binary used to run this persona (`claude`, `codex`, `opencode`, `grok`). + * Harness binary used to run this persona (`claude`, `codex`, `opencode`, `grok`, `cursor`). * Required for interactive personas. Optional for handler-style personas * ({@link onEvent} set): only consumed when the handler calls * `ctx.harness.run(...)`; pure orchestrators omit it. @@ -405,14 +405,15 @@ export interface PersonaSpec { * MCP servers to attach to the harness session. * - `claude`: passed via `--mcp-config` * - `codex`: translated into `--config mcp_servers....` overrides - * - `opencode` / `grok`: currently warn and skip + * - `opencode` / `grok` / `cursor`: currently warn and skip */ mcpServers?: Record; /** * Permission policy (allow/deny lists, mode) for the harness session. * `claude` is wired for allow/deny/mode flags (via `--allowedTools`, * `--disallowedTools`, `--permission-mode`). `grok` maps - * `mode: "bypassPermissions"` to `--always-approve`; other + * `mode: "bypassPermissions"` to `--always-approve`; `cursor` maps it + * to `--force`; other * fields/harnesses warn and skip. */ permissions?: PersonaPermissions; @@ -453,7 +454,7 @@ export interface PersonaSpec { claudeMdMode?: SidecarMdMode; /** * Author-supplied path to an `AGENTS.md` sidecar that should be applied - * when the persona runs under the opencode/codex/grok harnesses. Same resolution + * when the persona runs under the opencode/codex/grok/cursor harnesses. Same resolution * rules as {@link claudeMd}. */ agentsMd?: string; diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts index 88ced6f1..6677e0fe 100644 --- a/packages/runtime/src/cloud-defaults.ts +++ b/packages/runtime/src/cloud-defaults.ts @@ -838,7 +838,10 @@ function sidecarForPersona( }; } if ( - (persona.harness === 'codex' || persona.harness === 'opencode' || persona.harness === 'grok') && + (persona.harness === 'codex' || + persona.harness === 'opencode' || + persona.harness === 'grok' || + persona.harness === 'cursor') && persona.agentsMdContent ) { return { diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index 3b7f83fb..94757c93 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -252,6 +252,63 @@ test('cloud harness runner materializes AGENTS.md for grok personas', async () = } }); +test('cloud harness runner materializes AGENTS.md for cursor personas', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'workforce-cursor-cloud-')); + const binDir = path.join(root, 'bin'); + await mkdir(binDir, { recursive: true }); + await writeFile( + path.join(binDir, 'cursor-agent'), + [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const agents = fs.readFileSync(path.join(process.cwd(), "AGENTS.md"), "utf8");', + 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), agents }));' + ].join('\n'), + 'utf8' + ); + await chmod(path.join(binDir, 'cursor-agent'), 0o755); + + const envSnapshot = snapshotEnv(['PATH', 'WORKFORCE_SANDBOX_ROOT']); + process.env.PATH = `${binDir}${path.delimiter}${envSnapshot.PATH ?? ''}`; + process.env.WORKFORCE_SANDBOX_ROOT = root; + try { + const logs: Array<{ level: string; message: string; details?: unknown }> = []; + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + harness: 'cursor', + model: 'gpt-5', + systemPrompt: 'Cursor system prompt', + agentsMdContent: 'Cursor agents sidecar', + agentsMdMode: 'overwrite' + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: (level, message, details) => logs.push({ level, message, details }), + env: process.env + }); + + const result = await defaults.harnessRunner({ prompt: 'say hello' }); + assert.equal(result.exitCode, 0); + const parsed = JSON.parse(result.output) as { args: string[]; agents: string }; + assert.deepEqual(parsed.args, [ + '--model', + 'gpt-5', + '--print', + '--output-format', + 'text', + 'say hello' + ]); + assert.equal(parsed.agents, 'Cursor agents sidecar\n'); + assert.ok(logs.find((l) => l.message === 'harness.sidecar.materialized')); + } finally { + restoreEnv(envSnapshot); + await rm(root, { recursive: true, force: true }); + } +}); + async function writeFakeHarness(binDir: string, name: string, stdout: string): Promise { await mkdir(binDir, { recursive: true }); await writeFile(