Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/vscode-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Issue #228. The server side it relies on is `choose-folder` (#234, the Browse bu
- **AI agent** sets OpenCraw's tools up for an agent. In VS Code's own agent mode there is nothing to do: the extension registers the MCP
server it carries, so OpenCraw is among the agent's tools. For Claude Code, Cursor and other hosts, **Copy the MCP configuration** puts a
`claude mcp add` command, or the common `mcpServers` JSON, on the clipboard with this installation's server path filled in.
- **Add the OpenCraw agent to this workspace** writes a specialist agent (it reads your pages and files and builds the recipes with you) for the agents
you use: a GitHub Copilot custom agent (`.github/agents/opencraw.agent.md`), a Claude Code subagent (`.claude/agents/opencraw.md`) and/or a section
of `AGENTS.md`. It never overwrites a file you changed without asking, and keeps what you wrote in `AGENTS.md`.
- **Learn** links the Studio manual, the tutorial, the recipe guides and where to ask.
- **OpenCraw: Get started** opens a walkthrough (it also opens once, the first time the extension runs).

Expand Down Expand Up @@ -56,6 +59,7 @@ editor (extension host) Studio process browser
- `src/recipe-run`: the Run action: the plan (which files, `--only`), the process that runs the bundled `opencraw` command line, the save dialog
and the format from the extension, and `opencraw export` to write the records in it.
- `src/mcp-setup`: registers the bundled `@opencraw/mcp` server with the editor's agent mode, the copy-configuration command and the AI agent view.
- `src/agent-setup`: writes the agent that `@opencraw/agent` describes into the workspace (the plan of what to create, replace or update is pure).
- `src/learn-studio`: the Learn view and the Get started walkthrough (`media/walkthrough`).
- `src/extension.ts`: the entry point the editor requires.

Expand Down
15 changes: 14 additions & 1 deletion apps/vscode-extension/e2e/suite/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,25 @@ async function run () {
const registered = await vscode.commands.getCommands(true)
assert.ok(registered.includes('opencraw.openStudio'), 'Open Studio is registered')
assert.ok(registered.includes('opencraw.openOn'), 'Open on OpenCraw is registered')
for (const name of ['runRecipe', 'openRecipeInStudio', 'refreshRecipes', 'showWalkthrough', 'openLink']) assert.ok(registered.includes(`opencraw.${name}`), `${name} is registered`)
for (const name of ['runRecipe', 'openRecipeInStudio', 'refreshRecipes', 'showWalkthrough', 'openLink', 'copyMcpConfig', 'addAgent']) assert.ok(registered.includes(`opencraw.${name}`), `${name} is registered`)

// The MCP server the extension registers with the editor's agent mode starts, and offers OpenCraw's tools.
const tools = await mcpToolNames(api.mcpServerScript())
for (const name of ['probe', 'validate', 'run', 'list_recipes', 'docs']) assert.ok(tools.includes(name), `the MCP server offers ${name} (offers: ${tools.join(', ')})`)

// The OpenCraw agent is written for every host: a Copilot agent, a Claude Code subagent and a section of AGENTS.md that keeps what was there.
const folderUri = vscode.workspace.workspaceFolders[0].uri
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(folderUri, 'AGENTS.md'), new TextEncoder().encode('# My project\n\nRun the tests.\n'))
const written = await api.addAgentTo(['copilot', 'claude-code', 'agents-md'])
assert.deepEqual(written.sort((a, b) => a.localeCompare(b)), ['.claude/agents/opencraw.md', '.github/agents/opencraw.agent.md', 'AGENTS.md'])
const read = async path => new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(folderUri, path)))
assert.match(await read('.github/agents/opencraw.agent.md'), /^---\nname: "OpenCraw"\ndescription: /)
assert.match(await read('.claude/agents/opencraw.md'), /^---\nname: opencraw\n/)
const agentsMd = await read('AGENTS.md')
assert.ok(agentsMd.startsWith('# My project\n\nRun the tests.'), 'AGENTS.md keeps what the person wrote')
assert.ok(agentsMd.includes('<!-- opencraw-agent:begin -->'), 'AGENTS.md has the agent section')
assert.deepEqual(await api.addAgentTo(['agents-md']), [], 'a second run changes nothing')

// The recipes view lists the workspace's recipes by the output they fill: an input whose output is missing is kept, under its name.
const groups = await api.recipeGroups()
assert.deepEqual(groups.map(group => [group.outputId, group.outputFile !== undefined, group.inputs.map(input => input.id)]), [['cars', true, ['byd', 'kia']], ['elsewhere', false, ['lost']]])
Expand Down
14 changes: 11 additions & 3 deletions apps/vscode-extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencraw/vscode",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"description": "OpenCraw in the editor: open folders, recipes and documents in Studio, run your recipes from a view and save the result in the format you choose, and learn how it all works.",
"type": "module",
Expand Down Expand Up @@ -54,7 +54,7 @@
},
{
"view": "opencraw.agent",
"contents": "Connect an AI agent to OpenCraw.\n[Copy the MCP configuration](command:opencraw.copyMcpConfig)"
"contents": "Connect an AI agent to OpenCraw.\n[Add the OpenCraw agent](command:opencraw.addAgent)\n[Copy the MCP configuration](command:opencraw.copyMcpConfig)"
}
],
"jsonValidation": [
Expand Down Expand Up @@ -127,6 +127,12 @@
"title": "Copy the MCP configuration for another agent",
"category": "OpenCraw",
"icon": "$(copy)"
},
{
"command": "opencraw.addAgent",
"title": "Add the OpenCraw agent to this workspace",
"category": "OpenCraw",
"icon": "$(sparkle)"
}
],
"menus": {
Expand Down Expand Up @@ -264,11 +270,12 @@
{
"id": "agent",
"title": "Let an AI agent use OpenCraw",
"description": "In VS Code's agent mode the OpenCraw tools are already there. For Claude Code, Cursor and others, copy the configuration.\n[Copy the MCP configuration](command:opencraw.copyMcpConfig)",
"description": "In VS Code's agent mode the OpenCraw tools are already there. Add the OpenCraw agent: a specialist who builds recipes with you. For Claude Code, Cursor and others, copy the MCP configuration.\n[Add the OpenCraw agent](command:opencraw.addAgent)\n[Copy the MCP configuration](command:opencraw.copyMcpConfig)",
"media": {
"markdown": "media/walkthrough/agent.md"
},
"completionEvents": [
"onCommand:opencraw.addAgent",
"onCommand:opencraw.copyMcpConfig"
]
}
Expand Down Expand Up @@ -364,6 +371,7 @@
}
},
"dependencies": {
"@opencraw/agent": "^0.0.1",
"@opencraw/cli": "^0.0.1",
"@opencraw/studio": "^0.0.1"
},
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode-extension/packaging/package-vsix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const repoRoot = resolve(appDir, '..', '..')
const stage = join(appDir, 'dist-vsix')
const tarballs = join(stage, 'tarballs')
const PACKAGES = ['core', 'office-reader', 'probe', 'studio', 'cli', 'mcp']
const PACKAGES = ['core', 'office-reader', 'probe', 'studio', 'cli', 'mcp', 'agent']

// Run npm's own CLI with this Node: no shell, so no quoting to get wrong and no .cmd shim on Windows.
const npmCli = process.env.npm_execpath ?? [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AGENT_HOSTS } from '@opencraw/agent'
import * as vscode from 'vscode'
import { addAgent } from './add-agent.use-case'

/**
* “Add the OpenCraw agent to this workspace…”: asks which agents the person uses (more than one is fine), writes the agent for each, and offers to open
* what was written. The agent is the same specialist whichever host reads it.
*/
export async function addAgentCommand (): Promise<void> {
try {
const picked = await vscode.window.showQuickPick(AGENT_HOSTS.map(host => ({ label: host.label, detail: host.detail, host: host.id, picked: host.id === 'copilot' })), {
canPickMany: true,
title: 'Add the OpenCraw agent for…',
placeHolder: 'A specialist who reads your pages and files and builds the recipes with you. Pick every agent you use.',
})
if (picked === undefined || picked.length === 0) return
const root = vscode.workspace.workspaceFolders?.[0]?.uri
const written = await addAgent(picked.map(item => item.host))
if (written.length === 0) return
const choice = await vscode.window.showInformationMessage(`OpenCraw: the agent was added (${written.join(', ')}). Make sure OpenCraw’s tools are on in your agent: in VS Code they already are.`, 'Open')
if (choice === 'Open' && root !== undefined) await vscode.commands.executeCommand('vscode.open', vscode.Uri.joinPath(root, written[0]))
} catch (error) {
await vscode.window.showErrorMessage(`OpenCraw: ${error instanceof Error ? error.message : String(error)}`)
}
}
56 changes: 56 additions & 0 deletions apps/vscode-extension/src/agent-setup/add-agent.use-case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { OPENCRAW_AGENT, renderAgentFiles } from '@opencraw/agent'
import type { AgentHost } from '@opencraw/agent'
import * as vscode from 'vscode'
import { planAgentWrites } from './agent-write-plan.algorithm'

/** The text of a file in the workspace, or `undefined` when it is not there. */
async function readIfThere (uri: vscode.Uri): Promise<string | undefined> {
try {
return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri))
} catch {
return undefined
}
}

/** The folder the agent goes into: the only one open, or the one the person picks. */
async function targetFolder (): Promise<vscode.Uri | undefined> {
const folders = (vscode.workspace.workspaceFolders ?? []).filter(folder => folder.uri.scheme === 'file')
if (folders.length === 0) {
await vscode.window.showWarningMessage('OpenCraw: open a folder first: the agent file is written into your workspace.')

return undefined
}
if (folders.length === 1) return folders[0].uri
const picked = await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Which folder should get the OpenCraw agent?' })

return picked?.uri
}

/**
* Writes the OpenCraw agent into the workspace for the hosts chosen: a Copilot custom agent, a Claude Code subagent, a section of AGENTS.md. A file that
* is not there is created; the agent’s own file, when it exists and differs, is replaced only if the person says so (they may have changed it); an
* AGENTS.md keeps everything the person wrote and only our section is updated.
*
* @param hosts - Which hosts.
* @param folder - The workspace folder; the only one open (or a pick) when omitted.
* @returns The workspace-relative paths written; empty when nothing was.
*/
export async function addAgent (hosts: readonly AgentHost[], folder?: vscode.Uri): Promise<string[]> {
const root = folder ?? await targetFolder()
if (root === undefined || hosts.length === 0) return []
const files = renderAgentFiles(OPENCRAW_AGENT, hosts)
const existing = new Map<string, string | undefined>()
for (const file of files) existing.set(file.path, await readIfThere(vscode.Uri.joinPath(root, file.path)))
const written: string[] = []
for (const write of planAgentWrites(files, existing)) {
if (write.action === 'unchanged') continue
if (write.action === 'replace') {
const answer = await vscode.window.showWarningMessage(`${write.path} already exists and differs from the agent OpenCraw ships. Replace it?`, { modal: true }, 'Replace')
if (answer !== 'Replace') continue
}
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(root, write.path), new TextEncoder().encode(write.content))
written.push(write.path)
}

return written
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { OPENCRAW_AGENT, renderAgentFiles } from '@opencraw/agent'
import { planAgentWrites } from './agent-write-plan.algorithm'

const FILES = renderAgentFiles(OPENCRAW_AGENT, ['copilot', 'agents-md'])

describe('planAgentWrites', () => {
it('creates what is not there', () => {
const plan = planAgentWrites(FILES, new Map())

expect(plan.map(write => [write.path, write.action])).toEqual([['.github/agents/opencraw.agent.md', 'create'], ['AGENTS.md', 'create']])
})

it('asks to replace the agent’s own file when it differs, and leaves it alone when it is the same', () => {
const [agentFile] = FILES
const edited = planAgentWrites([agentFile], new Map([[agentFile.path, 'my own version']]))
const same = planAgentWrites([agentFile], new Map([[agentFile.path, agentFile.content]]))

expect(edited[0].action).toBe('replace')
expect(same[0].action).toBe('unchanged')
})

it('adds the section to an existing AGENTS.md without losing what the person wrote, and a second run changes nothing', () => {
const section = FILES[1]
const first = planAgentWrites([section], new Map([['AGENTS.md', '# My project\n\nRun the tests.\n']]))
expect(first[0].action).toBe('update-section')
expect(first[0].content.startsWith('# My project\n\nRun the tests.')).toBe(true)

const second = planAgentWrites([section], new Map([['AGENTS.md', first[0].content]]))
expect(second[0].action).toBe('unchanged')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { mergeSection } from '@opencraw/agent'
import type { AgentFile } from '@opencraw/agent'

/** What writing one agent file will do to the workspace. */
export interface AgentWrite {
path: string
/** The content to write. */
content: string
/**
* `create`: the file is new. `replace`: it exists and is ours to replace (the agent’s own file): the person is asked first, since they may have edited it.
* `update-section`: it exists and only our section changes. `unchanged`: what is there already is what would be written.
*/
action: 'create' | 'replace' | 'update-section' | 'unchanged'
}

/**
* Decides what to write for each agent file, given what the workspace holds now.
*
* @param files - The files the agent is rendered to.
* @param existing - The content of each of those paths in the workspace, `undefined` when the file is not there.
* @returns One write per file.
*/
export function planAgentWrites (files: readonly AgentFile[], existing: ReadonlyMap<string, string | undefined>): AgentWrite[] {
return files.map((file) => {
const current = existing.get(file.path)
const content = file.mode === 'section' ? mergeSection(current, file.content) : file.content
if (current === undefined) return { path: file.path, content, action: 'create' }
if (current === content) return { path: file.path, content, action: 'unchanged' }

return { path: file.path, content, action: file.mode === 'section' ? 'update-section' : 'replace' }
})
}
4 changes: 4 additions & 0 deletions apps/vscode-extension/src/agent-setup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { addAgent } from './add-agent.use-case'
export { addAgentCommand } from './add-agent-command.use-case'
export { planAgentWrites } from './agent-write-plan.algorithm'
export type { AgentWrite } from './agent-write-plan.algorithm'
6 changes: 5 additions & 1 deletion apps/vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as vscode from 'vscode'
import { addAgent, addAgentCommand } from './agent-setup'
import { LearnTree } from './learn-studio'
import { AgentTree, copyMcpConfiguration, mcpScript, registerOpenCrawMcp } from './mcp-setup'
import { createStudioRunner } from './open-on-opencraw'
Expand All @@ -14,6 +15,8 @@ export interface OpenCrawApi {
recipeGroups: () => Promise<RecipeGroup[]>
/** The MCP server program this installation carries, for the editor end-to-end test to start. */
mcpServerScript: () => string
/** Writes the OpenCraw agent for the hosts into the first workspace folder, as the command does once the person has chosen; for the editor end-to-end test. */
addAgentTo: (hosts: Parameters<typeof addAgent>[0]) => Promise<string[]>
}

/** The extension's id in the editor, for the walkthrough's address. */
Expand Down Expand Up @@ -54,6 +57,7 @@ export function activate (context: vscode.ExtensionContext): OpenCrawApi {
const file = node === undefined ? undefined : fileOf(node)
if (file !== undefined) await runner.open(vscode.Uri.file(file))
}),
vscode.commands.registerCommand('opencraw.addAgent', addAgentCommand),
vscode.commands.registerCommand('opencraw.copyMcpConfig', async () => { await copyMcpConfiguration(context.extensionPath, hooksSetting) }),
vscode.commands.registerCommand('opencraw.refreshRecipes', () => { recipes.refresh() }),
vscode.commands.registerCommand('opencraw.showWalkthrough', async () => { await vscode.commands.executeCommand('workbench.action.openWalkthrough', WALKTHROUGH, false) }),
Expand All @@ -71,7 +75,7 @@ export function activate (context: vscode.ExtensionContext): OpenCrawApi {
)
void showWalkthroughOnFirstRun(context)

return { onDidResolveLink: runner.onDidResolveLink, recipeGroups: async () => groupByOutput(await index.headers()), mcpServerScript: () => mcpScript(context.extensionPath) }
return { onDidResolveLink: runner.onDidResolveLink, recipeGroups: async () => groupByOutput(await index.headers()), mcpServerScript: () => mcpScript(context.extensionPath), addAgentTo: async hosts => await addAgent(hosts) }
}

/** The first time the extension runs on a machine, the Get Started page opens once: a person who has never seen it learns what it does. */
Expand Down
1 change: 1 addition & 0 deletions apps/vscode-extension/src/mcp-setup/agent-tree.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface AgentRow {

/** What the AI agent view shows: what is ready, and the button for each thing a person may want to do next. */
const ROWS: readonly AgentRow[] = [
{ label: 'Add the OpenCraw agent…', description: 'A specialist that builds recipes with you (Copilot, Claude Code, AGENTS.md)', icon: 'sparkle', command: { command: 'opencraw.addAgent', title: 'Add the OpenCraw agent' } },
{ label: 'OpenCraw tools in VS Code', description: 'Ready: pick OpenCraw in agent mode\'s tools', icon: 'check' },
{ label: 'Use OpenCraw in another agent…', description: 'Copy the configuration for Claude Code, Cursor and others', icon: 'copy', command: { command: 'opencraw.copyMcpConfig', title: 'Copy MCP configuration' } },
]
Expand Down
3 changes: 3 additions & 0 deletions apps/vscode-extension/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
},
{
"path": "../../packages/cli/tsconfig.lib.json"
},
{
"path": "../../packages/agent/tsconfig.lib.json"
}
]
}
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions packages/agent/.spec.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
},
"keepClassNames": true,
"externalHelpers": true,
"loose": true
},
"module": {
"type": "es6"
},
"sourceMaps": true,
"exclude": []
}
22 changes: 22 additions & 0 deletions packages/agent/.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
},
"keepClassNames": true,
"externalHelpers": true,
"loose": true
},
"module": {
"type": "es6"
},
"sourceMaps": true,
"exclude": ["jest.config.[ct]s",".*\\.spec.tsx?$",".*\\.test.tsx?$","./src/jest-setup.ts$","./**/jest-setup.ts$",".*.js$"]
}
Loading
Loading