From ff6639a9bb8bf8e8668a4aab2847da188c19f59e Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Tue, 5 May 2026 12:46:00 -0400 Subject: [PATCH 1/2] Add workspace system prompt support to coder SDK --- .../cli/src/cmd/coder/workspace/common.ts | 36 ++++- .../cli/src/cmd/coder/workspace/create.ts | 28 +++- .../cli/src/cmd/coder/workspace/update.ts | 26 ++++ packages/cli/test/cmd/coder/workspace.test.ts | 130 +++++++++++++++++- packages/core/src/services/coder/types.ts | 23 +++- packages/core/test/coder-client.test.ts | 29 +++- 6 files changed, 258 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/cmd/coder/workspace/common.ts b/packages/cli/src/cmd/coder/workspace/common.ts index d78306c05..278ece3e5 100644 --- a/packages/cli/src/cmd/coder/workspace/common.ts +++ b/packages/cli/src/cmd/coder/workspace/common.ts @@ -7,11 +7,15 @@ import { StructuredError } from '@agentuity/core'; import * as tui from '../../../tui'; export const EMPTY_WORKSPACE_ERROR = - 'A workspace needs at least one repo, dependency, setup script, saved skill, skill bucket, or agent'; + 'A workspace needs at least one repo, dependency, setup script, system prompt, saved skill, skill bucket, or agent'; export const SetupScriptValidationError = StructuredError('SetupScriptValidationError')<{ message: string; path?: string; }>(); +export const SystemPromptValidationError = StructuredError('SystemPromptValidationError')<{ + message: string; + path?: string; +}>(); export function parseCommaList(value?: string): string[] { return value @@ -46,11 +50,36 @@ export async function readSetupScript(input: { } } +export async function readSystemPrompt(input: { + systemPrompt?: string; + systemPromptFile?: string; +}): Promise { + if (input.systemPrompt !== undefined && input.systemPromptFile) { + throw new SystemPromptValidationError({ + message: 'Use either --system-prompt or --system-prompt-file, not both.', + }); + } + if (input.systemPrompt !== undefined) return input.systemPrompt; + if (!input.systemPromptFile) return undefined; + try { + return await Bun.file(input.systemPromptFile).text(); + } catch (error) { + throw new SystemPromptValidationError({ + message: `Failed to read system prompt file "${input.systemPromptFile}": ${ + error instanceof Error ? error.message : String(error) + }`, + path: input.systemPromptFile, + cause: error, + }); + } +} + export function hasWorkspaceSelections(input: CoderCreateWorkspaceRequest): boolean { return ( (input.repos?.length ?? 0) > 0 || (input.dependencies?.length ?? 0) > 0 || Boolean(input.setupScript?.trim()) || + Boolean(input.systemPrompt?.trim()) || (input.savedSkillIds?.length ?? 0) > 0 || (input.skillBucketIds?.length ?? 0) > 0 || (input.enabledAgents?.length ?? 0) > 0 @@ -67,7 +96,7 @@ export function formatWorkspaceValidationMessage(issues: Array<{ message: string return 'Invalid workspace configuration'; } if (messages.includes(EMPTY_WORKSPACE_ERROR)) { - return `${EMPTY_WORKSPACE_ERROR}. Use --repo, --dependency, --setup-script, or --enabled-agents.`; + return `${EMPTY_WORKSPACE_ERROR}. Use --repo, --dependency, --setup-script, --system-prompt, or --enabled-agents.`; } return messages.join('; '); } @@ -94,6 +123,9 @@ export function printWorkspaceSummary(workspace: CoderWorkspaceDetail): void { if (workspace.setupScript) { tui.output(' Setup: configured'); } + if (workspace.systemPrompt) { + tui.output(' Prompt: configured'); + } if (workspace.snapshot?.status) { tui.output(` Snapshot: ${workspace.snapshot.status}`); } diff --git a/packages/cli/src/cmd/coder/workspace/create.ts b/packages/cli/src/cmd/coder/workspace/create.ts index 212752216..cf47b5176 100644 --- a/packages/cli/src/cmd/coder/workspace/create.ts +++ b/packages/cli/src/cmd/coder/workspace/create.ts @@ -16,6 +16,7 @@ import { hasWorkspaceSelections, parseCommaList, printWorkspaceSummary, + readSystemPrompt, readSetupScript, } from './common'; @@ -38,6 +39,12 @@ export const createWorkspaceSubcommand = createSubcommand({ ), description: 'Create an org-scoped workspace with dependencies and a setup script', }, + { + command: getCommand( + 'coder workspace create "My Workspace" --system-prompt-file ./WORKSPACE_PROMPT.md' + ), + description: 'Create a workspace with Lead system prompt instructions', + }, { command: getCommand('coder workspace create "My Workspace" --enabled-agents code-review'), description: 'Create a workspace with an agent roster', @@ -71,6 +78,14 @@ export const createWorkspaceSubcommand = createSubcommand({ .string() .optional() .describe('Path to a shell script to run while preparing workspace snapshots'), + systemPrompt: z + .string() + .optional() + .describe('Inline Lead system prompt to apply to sessions created from this workspace'), + systemPromptFile: z + .string() + .optional() + .describe('Path to a file containing the workspace Lead system prompt'), enabledAgents: z .string() .optional() @@ -116,12 +131,23 @@ export const createWorkspaceSubcommand = createSubcommand({ tui.fatal(`Failed to read setup script: ${msg}`, ErrorCode.VALIDATION_FAILED); return; } + try { + const systemPrompt = await readSystemPrompt({ + systemPrompt: opts?.systemPrompt, + systemPromptFile: opts?.systemPromptFile, + }); + if (systemPrompt !== undefined) body.systemPrompt = systemPrompt; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + tui.fatal(`Failed to read system prompt: ${msg}`, ErrorCode.VALIDATION_FAILED); + return; + } if (opts?.enabledAgents) { body.enabledAgents = parseCommaList(opts.enabledAgents); } if (!hasWorkspaceSelections(body)) { tui.fatal( - `Failed to create workspace: ${EMPTY_WORKSPACE_ERROR}. Use --repo, --dependency, --setup-script, or --enabled-agents.`, + `Failed to create workspace: ${EMPTY_WORKSPACE_ERROR}. Use --repo, --dependency, --setup-script, --system-prompt, or --enabled-agents.`, ErrorCode.VALIDATION_FAILED ); } diff --git a/packages/cli/src/cmd/coder/workspace/update.ts b/packages/cli/src/cmd/coder/workspace/update.ts index 5baf76f81..d2a63145b 100644 --- a/packages/cli/src/cmd/coder/workspace/update.ts +++ b/packages/cli/src/cmd/coder/workspace/update.ts @@ -15,6 +15,7 @@ import { hasWorkspaceUpdate, parseCommaList, printWorkspaceSummary, + readSystemPrompt, readSetupScript, } from './common'; @@ -33,6 +34,12 @@ export const updateWorkspaceSubcommand = createSubcommand({ command: getCommand('coder workspace update ws_abc123 --setup-script-file ./setup.sh'), description: 'Update the workspace setup script', }, + { + command: getCommand( + 'coder workspace update ws_abc123 --system-prompt-file ./WORKSPACE_PROMPT.md' + ), + description: 'Update the workspace Lead system prompt', + }, ], schema: { args: z.object({ @@ -57,6 +64,14 @@ export const updateWorkspaceSubcommand = createSubcommand({ .string() .optional() .describe('Path to a shell script to run while preparing workspace snapshots'), + systemPrompt: z + .string() + .optional() + .describe('Inline Lead system prompt to apply to sessions created from this workspace'), + systemPromptFile: z + .string() + .optional() + .describe('Path to a file containing the workspace Lead system prompt'), enabledAgents: z .string() .optional() @@ -102,6 +117,17 @@ export const updateWorkspaceSubcommand = createSubcommand({ tui.fatal(`Failed to read setup script: ${msg}`, ErrorCode.VALIDATION_FAILED); return; } + try { + const systemPrompt = await readSystemPrompt({ + systemPrompt: opts?.systemPrompt, + systemPromptFile: opts?.systemPromptFile, + }); + if (systemPrompt !== undefined) body.systemPrompt = systemPrompt; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + tui.fatal(`Failed to read system prompt: ${msg}`, ErrorCode.VALIDATION_FAILED); + return; + } if (opts?.enabledAgents) { body.enabledAgents = parseCommaList(opts.enabledAgents); } diff --git a/packages/cli/test/cmd/coder/workspace.test.ts b/packages/cli/test/cmd/coder/workspace.test.ts index 9efa8df4c..2c036c7d3 100644 --- a/packages/cli/test/cmd/coder/workspace.test.ts +++ b/packages/cli/test/cmd/coder/workspace.test.ts @@ -67,6 +67,7 @@ function makeWorkspace(overrides: Record = {}) { repoCount: 0, dependencies: [], setupScript: '', + systemPrompt: '', savedSkillIds: [], skillBucketIds: [], enabledAgents: [], @@ -109,6 +110,8 @@ describe('coder workspace commands', () => { example.command.includes('--dependency') || example.command.includes('--setup-script') || example.command.includes('--setup-script-file') || + example.command.includes('--system-prompt') || + example.command.includes('--system-prompt-file') || example.command.includes('--enabled-agents'); expect(hasValidSelection).toBe(true); } @@ -130,7 +133,7 @@ describe('coder workspace commands', () => { expect(requestedUrls).toEqual([]); expect(fatal.stderr).toContain( - 'Failed to create workspace: A workspace needs at least one repo, dependency, setup script, saved skill, skill bucket, or agent. Use --repo, --dependency, --setup-script, or --enabled-agents.' + 'Failed to create workspace: A workspace needs at least one repo, dependency, setup script, system prompt, saved skill, skill bucket, or agent. Use --repo, --dependency, --setup-script, --system-prompt, or --enabled-agents.' ); expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); }); @@ -219,6 +222,67 @@ describe('coder workspace commands', () => { }); }); + test('create handler sends inline system prompt', async () => { + let requestBody: unknown; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + expect(String(url)).toBe('https://coder.example/api/hub/workspaces'); + expect(init?.method).toBe('POST'); + requestBody = JSON.parse(String(init?.body)); + return jsonResponse({ + workspace: makeWorkspace({ + systemPrompt: 'Follow the release checklist.', + selectionCount: 1, + }), + }); + }) as typeof globalThis.fetch; + + const result = await createWorkspaceSubcommand.handler( + makeContext({ opts: { systemPrompt: 'Follow the release checklist.' }, json: true }) + ); + + expect(requestBody).toMatchObject({ + name: 'My Workspace', + systemPrompt: 'Follow the release checklist.', + }); + expect(result).toMatchObject({ + systemPrompt: 'Follow the release checklist.', + }); + }); + + test('create handler reads system prompt from file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentuity-workspace-prompt-test-')); + const systemPromptFile = join(dir, 'WORKSPACE_PROMPT.md'); + writeFileSync(systemPromptFile, 'Prefer small changes.\n'); + let requestBody: unknown; + try { + globalThis.fetch = (async (url: string, init?: RequestInit) => { + expect(String(url)).toBe('https://coder.example/api/hub/workspaces'); + expect(init?.method).toBe('POST'); + requestBody = JSON.parse(String(init?.body)); + return jsonResponse({ + workspace: makeWorkspace({ + systemPrompt: 'Prefer small changes.\n', + selectionCount: 1, + }), + }); + }) as typeof globalThis.fetch; + + const result = await createWorkspaceSubcommand.handler( + makeContext({ opts: { systemPromptFile }, json: true }) + ); + + expect(requestBody).toMatchObject({ + name: 'My Workspace', + systemPrompt: 'Prefer small changes.\n', + }); + expect(result).toMatchObject({ + systemPrompt: 'Prefer small changes.\n', + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test('create handler reads setup script from file', async () => { const dir = mkdtempSync(join(tmpdir(), 'agentuity-workspace-test-')); const setupScriptFile = join(dir, 'setup.sh'); @@ -279,6 +343,32 @@ describe('coder workspace commands', () => { expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); }); + test('create handler fails locally when both system prompt options are provided', async () => { + const requestedUrls: string[] = []; + globalThis.fetch = (async (url: string) => { + requestedUrls.push(String(url)); + throw new Error('unexpected fetch'); + }) as typeof globalThis.fetch; + const fatal = interceptFatal(); + + await expect( + createWorkspaceSubcommand.handler( + makeContext({ + opts: { + systemPrompt: 'inline prompt', + systemPromptFile: './WORKSPACE_PROMPT.md', + }, + }) + ) + ).rejects.toThrow('__EXIT__'); + + expect(requestedUrls).toEqual([]); + expect(fatal.stderr).toContain( + 'Failed to read system prompt: Use either --system-prompt or --system-prompt-file, not both.' + ); + expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); + }); + test('create handler reports setup script file read failures as validation errors', async () => { const requestedUrls: string[] = []; globalThis.fetch = (async (url: string) => { @@ -304,6 +394,31 @@ describe('coder workspace commands', () => { expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); }); + test('create handler reports system prompt file read failures as validation errors', async () => { + const requestedUrls: string[] = []; + globalThis.fetch = (async (url: string) => { + requestedUrls.push(String(url)); + throw new Error('unexpected fetch'); + }) as typeof globalThis.fetch; + const fatal = interceptFatal(); + + await expect( + createWorkspaceSubcommand.handler( + makeContext({ + opts: { + systemPromptFile: join(tmpdir(), `missing-prompt-${crypto.randomUUID()}.md`), + }, + }) + ) + ).rejects.toThrow('__EXIT__'); + + expect(requestedUrls).toEqual([]); + expect(fatal.stderr).toContain( + 'Failed to read system prompt: Failed to read system prompt file' + ); + expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); + }); + test('update handler fails locally before fetch when no fields are provided', async () => { const requestedUrls: string[] = []; globalThis.fetch = (async (url: string) => { @@ -323,7 +438,7 @@ describe('coder workspace commands', () => { expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); }); - test('update handler patches dependencies and setup script', async () => { + test('update handler patches dependencies, setup script, and system prompt', async () => { let requestBody: unknown; globalThis.fetch = (async (url: string, init?: RequestInit) => { expect(String(url)).toBe('https://coder.example/api/hub/workspaces/ws_test'); @@ -334,8 +449,9 @@ describe('coder workspace commands', () => { id: 'ws_test', dependencies: ['git'], setupScript: 'echo updated', + systemPrompt: 'Use the workspace prompt.', snapshot: { status: 'building' }, - selectionCount: 2, + selectionCount: 3, }), }); }) as typeof globalThis.fetch; @@ -343,7 +459,11 @@ describe('coder workspace commands', () => { const result = await updateWorkspaceSubcommand.handler( makeContext({ args: { workspaceId: 'ws_test' }, - opts: { dependency: 'git', setupScript: 'echo updated' }, + opts: { + dependency: 'git', + setupScript: 'echo updated', + systemPrompt: 'Use the workspace prompt.', + }, json: true, }) ); @@ -351,11 +471,13 @@ describe('coder workspace commands', () => { expect(requestBody).toEqual({ dependencies: ['git'], setupScript: 'echo updated', + systemPrompt: 'Use the workspace prompt.', }); expect(result).toMatchObject({ id: 'ws_test', dependencies: ['git'], setupScript: 'echo updated', + systemPrompt: 'Use the workspace prompt.', snapshot: { status: 'building' }, }); }); diff --git a/packages/core/src/services/coder/types.ts b/packages/core/src/services/coder/types.ts index 4f77dc9b6..83fe1673b 100644 --- a/packages/core/src/services/coder/types.ts +++ b/packages/core/src/services/coder/types.ts @@ -123,6 +123,13 @@ export const CoderWorkspaceDetailSchema = z .optional() .default('') .describe('Shell script run while preparing workspace snapshots'), + systemPrompt: z + .string() + .optional() + .default('') + .describe( + 'Additional Lead agent system prompt applied to sessions created from this workspace' + ), snapshot: z .object({ status: z.string().describe('Workspace snapshot build status'), @@ -353,6 +360,7 @@ function hasWorkspaceSelections(input: { repos?: unknown[]; dependencies?: unknown[]; setupScript?: string; + systemPrompt?: string; savedSkillIds?: unknown[]; skillBucketIds?: unknown[]; enabledAgents?: unknown[]; @@ -361,6 +369,7 @@ function hasWorkspaceSelections(input: { (input.repos?.length ?? 0) > 0 || (input.dependencies?.length ?? 0) > 0 || Boolean(input.setupScript?.trim()) || + Boolean(input.systemPrompt?.trim()) || (input.savedSkillIds?.length ?? 0) > 0 || (input.skillBucketIds?.length ?? 0) > 0 || (input.enabledAgents?.length ?? 0) > 0 @@ -381,6 +390,12 @@ export const CoderCreateWorkspaceRequestSchema = z .string() .optional() .describe('Shell script run while preparing workspace snapshots'), + systemPrompt: z + .string() + .optional() + .describe( + 'Additional Lead agent system prompt applied to sessions created from this workspace' + ), savedSkillIds: z.array(z.string()).optional().describe('Saved skill IDs'), skillBucketIds: z.array(z.string()).optional().describe('Skill bucket IDs'), enabledAgents: z @@ -390,7 +405,7 @@ export const CoderCreateWorkspaceRequestSchema = z }) .refine(hasWorkspaceSelections, { message: - 'A workspace needs at least one repo, dependency, setup script, saved skill, skill bucket, or agent', + 'A workspace needs at least one repo, dependency, setup script, system prompt, saved skill, skill bucket, or agent', }) .describe('Request body for creating a workspace'); export type CoderCreateWorkspaceRequest = z.infer; @@ -409,6 +424,12 @@ export const CoderUpdateWorkspaceRequestSchema = z .string() .optional() .describe('Shell script run while preparing workspace snapshots'), + systemPrompt: z + .string() + .optional() + .describe( + 'Additional Lead agent system prompt applied to sessions created from this workspace' + ), savedSkillIds: z.array(z.string()).optional().describe('Saved skill IDs'), skillBucketIds: z.array(z.string()).optional().describe('Skill bucket IDs'), enabledAgents: z diff --git a/packages/core/test/coder-client.test.ts b/packages/core/test/coder-client.test.ts index 6c90fdc12..72a330363 100644 --- a/packages/core/test/coder-client.test.ts +++ b/packages/core/test/coder-client.test.ts @@ -73,6 +73,7 @@ function makeWorkspace(overrides: Record = {}) { skillBucketIds: [], enabledAgents: [], selectionCount: 0, + systemPrompt: '', createdAt: '2026-04-08T00:00:00.000Z', updatedAt: '2026-04-08T00:00:00.000Z', ...overrides, @@ -407,12 +408,14 @@ describe('CoderClient enabled agent roster contract', () => { const updateWorkspaceBody: Parameters[1] = { dependencies: ['git'], setupScript: 'echo ready', + systemPrompt: 'Use the workspace checklist.', }; expect(createSessionBody.enabledAgents).toEqual(['code-review']); expect(updateSessionBody.enabledAgents).toEqual(['code-review']); expect(createWorkspaceBody.enabledAgents).toEqual(['code-review']); expect(updateWorkspaceBody.dependencies).toEqual(['git']); + expect(updateWorkspaceBody.systemPrompt).toBe('Use the workspace checklist.'); }); test('workspace create schema rejects a name-only request', () => { @@ -426,14 +429,14 @@ describe('CoderClient enabled agent roster contract', () => { expect.arrayContaining([ expect.objectContaining({ message: - 'A workspace needs at least one repo, dependency, setup script, saved skill, skill bucket, or agent', + 'A workspace needs at least one repo, dependency, setup script, system prompt, saved skill, skill bucket, or agent', }), ]) ); } }); - test('workspace create schema accepts dependencies and setup scripts as selections', () => { + test('workspace create schema accepts dependencies, setup scripts, and system prompts as selections', () => { expect( CoderCreateWorkspaceRequestSchema.safeParse({ name: 'Dependency Workspace', @@ -446,6 +449,12 @@ describe('CoderClient enabled agent roster contract', () => { setupScript: 'echo ready', }).success ).toBe(true); + expect( + CoderCreateWorkspaceRequestSchema.safeParse({ + name: 'Prompt Workspace', + systemPrompt: 'Use the workspace checklist.', + }).success + ).toBe(true); }); test('workspace update schema rejects an empty body', () => { @@ -488,7 +497,7 @@ describe('CoderClient enabled agent roster contract', () => { issues: expect.arrayContaining([ expect.objectContaining({ message: - 'A workspace needs at least one repo, dependency, setup script, saved skill, skill bucket, or agent', + 'A workspace needs at least one repo, dependency, setup script, system prompt, saved skill, skill bucket, or agent', }), ]), }); @@ -634,7 +643,7 @@ describe('CoderClient enabled agent roster contract', () => { }); }); - test('createWorkspace sends dependencies and setupScript in the request body', async () => { + test('createWorkspace sends dependencies, setupScript, and systemPrompt in the request body', async () => { mockFetch(async (url, init) => { expect(url).toBe('https://coder.example/api/hub/workspaces'); expect(init?.method).toBe('POST'); @@ -642,6 +651,7 @@ describe('CoderClient enabled agent roster contract', () => { name: 'Workspace deps', dependencies: ['git', 'nodejs'], setupScript: 'echo ready', + systemPrompt: 'Use the workspace checklist.', }); return new Response( JSON.stringify({ @@ -649,7 +659,8 @@ describe('CoderClient enabled agent roster contract', () => { id: 'hworkspace_deps_create', dependencies: ['git', 'nodejs'], setupScript: 'echo ready', - selectionCount: 2, + systemPrompt: 'Use the workspace checklist.', + selectionCount: 3, snapshot: { status: 'building', buildId: 'wsbuild_test' }, }), }), @@ -671,22 +682,25 @@ describe('CoderClient enabled agent roster contract', () => { name: 'Workspace deps', dependencies: ['git', 'nodejs'], setupScript: 'echo ready', + systemPrompt: 'Use the workspace checklist.', }) ).resolves.toMatchObject({ id: 'hworkspace_deps_create', dependencies: ['git', 'nodejs'], setupScript: 'echo ready', + systemPrompt: 'Use the workspace checklist.', snapshot: { status: 'building' }, }); }); - test('updateWorkspace patches dependencies and setupScript', async () => { + test('updateWorkspace patches dependencies, setupScript, and systemPrompt', async () => { mockFetch(async (url, init) => { expect(url).toBe('https://coder.example/api/hub/workspaces/hworkspace_update'); expect(init?.method).toBe('PATCH'); expect(JSON.parse(String(init?.body))).toMatchObject({ dependencies: ['git'], setupScript: 'echo updated', + systemPrompt: 'Use the updated checklist.', }); return new Response( JSON.stringify({ @@ -694,6 +708,7 @@ describe('CoderClient enabled agent roster contract', () => { id: 'hworkspace_update', dependencies: ['git'], setupScript: 'echo updated', + systemPrompt: 'Use the updated checklist.', snapshot: { status: 'building' }, }), }), @@ -714,11 +729,13 @@ describe('CoderClient enabled agent roster contract', () => { client.updateWorkspace('hworkspace_update', { dependencies: ['git'], setupScript: 'echo updated', + systemPrompt: 'Use the updated checklist.', }) ).resolves.toMatchObject({ id: 'hworkspace_update', dependencies: ['git'], setupScript: 'echo updated', + systemPrompt: 'Use the updated checklist.', }); }); From 43022809271a4acf7e2cd6260a2e23c2299006f9 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Wed, 6 May 2026 16:47:45 -0400 Subject: [PATCH 2/2] Add Coder custom skill SDK and CLI support --- packages/cli/src/cmd/coder/skill/create.ts | 122 ++++++++++++ packages/cli/src/cmd/coder/skill/index.ts | 15 +- .../cli/src/cmd/coder/workspace/common.ts | 14 +- .../cli/src/cmd/coder/workspace/create.ts | 9 +- .../cli/src/cmd/coder/workspace/update.ts | 9 +- packages/cli/test/cmd/coder/skill.test.ts | 184 ++++++++++++++++++ packages/cli/test/cmd/coder/workspace.test.ts | 42 +++- packages/core/src/services/coder/client.ts | 10 + packages/core/src/services/coder/skills.ts | 19 ++ packages/core/src/services/coder/types.ts | 29 +++ packages/core/test/coder-client.test.ts | 108 ++++++++++ 11 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/cmd/coder/skill/create.ts create mode 100644 packages/cli/test/cmd/coder/skill.test.ts diff --git a/packages/cli/src/cmd/coder/skill/create.ts b/packages/cli/src/cmd/coder/skill/create.ts new file mode 100644 index 000000000..ba76815e5 --- /dev/null +++ b/packages/cli/src/cmd/coder/skill/create.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; +import { CoderClient } from '@agentuity/core/coder'; +import { ValidationOutputError } from '@agentuity/core'; +import { createSubcommand } from '../../../types'; +import * as tui from '../../../tui'; +import { getCommand } from '../../../command-prefix'; +import { ErrorCode } from '../../../errors'; + +async function readSkillContent(input: { + content?: string; + contentFile?: string; +}): Promise { + if (input.content !== undefined && input.contentFile) { + throw new Error('Use either --content or --content-file, not both.'); + } + if (input.content !== undefined) return input.content; + if (!input.contentFile) { + throw new Error('Provide --content or --content-file.'); + } + try { + return await Bun.file(input.contentFile).text(); + } catch (error) { + throw new Error( + `Failed to read content file "${input.contentFile}": ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error } + ); + } +} + +export const createCustomSkillSubcommand = createSubcommand({ + name: 'create', + aliases: ['new'], + description: 'Create a custom SKILL.md-backed skill', + tags: ['mutating', 'requires-auth'], + requires: { auth: true, org: true }, + examples: [ + { + command: getCommand( + 'coder skill create --skill-id release-checklist --name "Release checklist" --content-file ./SKILL.md' + ), + description: 'Create a custom skill from a SKILL.md file', + }, + { + command: getCommand( + 'coder skill create --skill-id release-checklist --name "Release checklist" --content "# Release checklist" --json' + ), + description: 'Create a custom skill from inline content and return JSON', + }, + ], + schema: { + options: z.object({ + url: z.string().optional().describe('Coder API URL override'), + skillId: z.string().describe('Skill identifier'), + name: z.string().describe('Skill name'), + description: z.string().optional().describe('Skill description'), + content: z.string().optional().describe('Inline SKILL.md content'), + contentFile: z.string().optional().describe('Path to a SKILL.md file'), + }), + }, + async handler(ctx) { + const { opts, options } = ctx; + const client = new CoderClient({ + apiKey: ctx.auth.apiKey, + url: opts?.url, + orgId: ctx.orgId, + }); + + let content: string; + try { + content = await readSkillContent({ + content: opts?.content, + contentFile: opts?.contentFile, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + tui.fatal(`Failed to create custom skill: ${msg}`, ErrorCode.VALIDATION_FAILED); + return; + } + + if (!content.trim()) { + tui.fatal( + 'Failed to create custom skill: SKILL.md content cannot be empty.', + ErrorCode.VALIDATION_FAILED + ); + return; + } + + try { + const saved = await client.createCustomSkill({ + skillId: opts.skillId, + name: opts.name, + ...(opts?.description !== undefined ? { description: opts.description } : {}), + content, + }); + + if (options.json) { + return saved; + } + + tui.success(`Custom skill ${saved.id} created.`); + tui.newline(); + tui.output(` Name: ${tui.bold(saved.name)}`); + tui.output(` Skill ID: ${saved.skillId}`); + tui.output(` Source: ${saved.source}`); + if (saved.description) { + tui.output(` Desc: ${saved.description}`); + } + + return saved; + } catch (err) { + if (err instanceof ValidationOutputError) { + ctx.logger.trace('Validation response URL: %s', err.url ?? 'unknown'); + ctx.logger.trace('Validation issues: %s', JSON.stringify(err.issues, null, 2)); + tui.fatal(`Failed to create custom skill: ${err.message}`, ErrorCode.VALIDATION_FAILED); + } + const msg = err instanceof Error ? err.message : String(err); + tui.fatal(`Failed to create custom skill: ${msg}`, ErrorCode.NETWORK_ERROR); + } + }, +}); diff --git a/packages/cli/src/cmd/coder/skill/index.ts b/packages/cli/src/cmd/coder/skill/index.ts index 50649e5d3..f455f3ed3 100644 --- a/packages/cli/src/cmd/coder/skill/index.ts +++ b/packages/cli/src/cmd/coder/skill/index.ts @@ -1,5 +1,6 @@ import { createCommand } from '../../../types'; import { listSubcommand } from './list'; +import { createCustomSkillSubcommand } from './create'; import { saveSkillSubcommand } from './save'; import { deleteSkillSubcommand } from './delete'; import { bucketsSubcommand } from './buckets'; @@ -16,6 +17,12 @@ export const skillCommand = createCommand({ command: getCommand('coder skill list'), description: 'List saved skills', }, + { + command: getCommand( + 'coder skill create --skill-id release-checklist --name "Release checklist" --content-file ./SKILL.md' + ), + description: 'Create a custom skill', + }, { command: getCommand( 'coder skill save --repo org/repo --skill-id sk_abc --name "My Skill"' @@ -31,5 +38,11 @@ export const skillCommand = createCommand({ description: 'List skill buckets', }, ], - subcommands: [listSubcommand, saveSkillSubcommand, deleteSkillSubcommand, bucketsSubcommand], + subcommands: [ + listSubcommand, + createCustomSkillSubcommand, + saveSkillSubcommand, + deleteSkillSubcommand, + bucketsSubcommand, + ], }); diff --git a/packages/cli/src/cmd/coder/workspace/common.ts b/packages/cli/src/cmd/coder/workspace/common.ts index 278ece3e5..165190a14 100644 --- a/packages/cli/src/cmd/coder/workspace/common.ts +++ b/packages/cli/src/cmd/coder/workspace/common.ts @@ -2,6 +2,7 @@ import { type CoderCreateWorkspaceRequest, type CoderUpdateWorkspaceRequest, type CoderWorkspaceDetail, + type CoderWorkspaceSystemPromptMode, } from '@agentuity/core/coder'; import { StructuredError } from '@agentuity/core'; import * as tui from '../../../tui'; @@ -17,6 +18,17 @@ export const SystemPromptValidationError = StructuredError('SystemPromptValidati path?: string; }>(); +export function normalizeSystemPromptMode( + value?: string +): CoderWorkspaceSystemPromptMode | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === 'append' || normalized === 'overwrite') return normalized; + throw new SystemPromptValidationError({ + message: 'Use --system-prompt-mode append or --system-prompt-mode overwrite.', + }); +} + export function parseCommaList(value?: string): string[] { return value ? value @@ -124,7 +136,7 @@ export function printWorkspaceSummary(workspace: CoderWorkspaceDetail): void { tui.output(' Setup: configured'); } if (workspace.systemPrompt) { - tui.output(' Prompt: configured'); + tui.output(` Prompt: configured (${workspace.systemPromptMode})`); } if (workspace.snapshot?.status) { tui.output(` Snapshot: ${workspace.snapshot.status}`); diff --git a/packages/cli/src/cmd/coder/workspace/create.ts b/packages/cli/src/cmd/coder/workspace/create.ts index cf47b5176..df4fa6087 100644 --- a/packages/cli/src/cmd/coder/workspace/create.ts +++ b/packages/cli/src/cmd/coder/workspace/create.ts @@ -14,6 +14,7 @@ import { EMPTY_WORKSPACE_ERROR, formatWorkspaceValidationMessage, hasWorkspaceSelections, + normalizeSystemPromptMode, parseCommaList, printWorkspaceSummary, readSystemPrompt, @@ -41,7 +42,7 @@ export const createWorkspaceSubcommand = createSubcommand({ }, { command: getCommand( - 'coder workspace create "My Workspace" --system-prompt-file ./WORKSPACE_PROMPT.md' + 'coder workspace create "My Workspace" --system-prompt-file ./WORKSPACE_PROMPT.md --system-prompt-mode overwrite' ), description: 'Create a workspace with Lead system prompt instructions', }, @@ -86,6 +87,10 @@ export const createWorkspaceSubcommand = createSubcommand({ .string() .optional() .describe('Path to a file containing the workspace Lead system prompt'), + systemPromptMode: z + .string() + .optional() + .describe('How to apply the system prompt: append or overwrite'), enabledAgents: z .string() .optional() @@ -137,6 +142,8 @@ export const createWorkspaceSubcommand = createSubcommand({ systemPromptFile: opts?.systemPromptFile, }); if (systemPrompt !== undefined) body.systemPrompt = systemPrompt; + const systemPromptMode = normalizeSystemPromptMode(opts?.systemPromptMode); + if (systemPromptMode !== undefined) body.systemPromptMode = systemPromptMode; } catch (err) { const msg = err instanceof Error ? err.message : String(err); tui.fatal(`Failed to read system prompt: ${msg}`, ErrorCode.VALIDATION_FAILED); diff --git a/packages/cli/src/cmd/coder/workspace/update.ts b/packages/cli/src/cmd/coder/workspace/update.ts index d2a63145b..33522476b 100644 --- a/packages/cli/src/cmd/coder/workspace/update.ts +++ b/packages/cli/src/cmd/coder/workspace/update.ts @@ -13,6 +13,7 @@ import { resolveGitHubRepo } from '../resolve-repo'; import { formatWorkspaceValidationMessage, hasWorkspaceUpdate, + normalizeSystemPromptMode, parseCommaList, printWorkspaceSummary, readSystemPrompt, @@ -36,7 +37,7 @@ export const updateWorkspaceSubcommand = createSubcommand({ }, { command: getCommand( - 'coder workspace update ws_abc123 --system-prompt-file ./WORKSPACE_PROMPT.md' + 'coder workspace update ws_abc123 --system-prompt-file ./WORKSPACE_PROMPT.md --system-prompt-mode append' ), description: 'Update the workspace Lead system prompt', }, @@ -72,6 +73,10 @@ export const updateWorkspaceSubcommand = createSubcommand({ .string() .optional() .describe('Path to a file containing the workspace Lead system prompt'), + systemPromptMode: z + .string() + .optional() + .describe('How to apply the system prompt: append or overwrite'), enabledAgents: z .string() .optional() @@ -123,6 +128,8 @@ export const updateWorkspaceSubcommand = createSubcommand({ systemPromptFile: opts?.systemPromptFile, }); if (systemPrompt !== undefined) body.systemPrompt = systemPrompt; + const systemPromptMode = normalizeSystemPromptMode(opts?.systemPromptMode); + if (systemPromptMode !== undefined) body.systemPromptMode = systemPromptMode; } catch (err) { const msg = err instanceof Error ? err.message : String(err); tui.fatal(`Failed to read system prompt: ${msg}`, ErrorCode.VALIDATION_FAILED); diff --git a/packages/cli/test/cmd/coder/skill.test.ts b/packages/cli/test/cmd/coder/skill.test.ts new file mode 100644 index 000000000..2f87bd721 --- /dev/null +++ b/packages/cli/test/cmd/coder/skill.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createCustomSkillSubcommand } from '../../../src/cmd/coder/skill/create'; +import { ErrorCode, getExitCode } from '../../../src/errors'; + +const ORIGINAL_EXIT = process.exit; +const ORIGINAL_STDERR_WRITE = process.stderr.write; +const ORIGINAL_FETCH = globalThis.fetch; + +function makeContext(input: { opts?: Record; json?: boolean } = {}) { + return { + args: {}, + opts: { + url: 'https://coder.example', + skillId: 'release-checklist', + name: 'Release checklist', + ...input.opts, + }, + options: { json: input.json ?? false }, + auth: { apiKey: 'ag_test' }, + orgId: 'org_test', + config: null, + logger: { + trace() {}, + }, + getExecutingAgent: () => 'codex', + } as any; +} + +function interceptFatal() { + let stderr = ''; + let exitCode: number | undefined; + + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('__EXIT__'); + }) as typeof process.exit; + + return { + get stderr() { + return stderr; + }, + get exitCode() { + return exitCode; + }, + }; +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('coder skill commands', () => { + beforeEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + }); + + afterEach(() => { + process.exit = ORIGINAL_EXIT; + process.stderr.write = ORIGINAL_STDERR_WRITE; + globalThis.fetch = ORIGINAL_FETCH; + }); + + test('create handler sends inline custom skill content', async () => { + let requestBody: unknown; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + expect(String(url)).toBe('https://coder.example/api/hub/skills/library'); + expect(init?.method).toBe('POST'); + requestBody = JSON.parse(String(init?.body)); + return jsonResponse({ + skill: { + id: 'hskill_custom', + source: 'custom', + repo: 'custom', + skillId: 'release-checklist', + name: 'Release checklist', + content: '# Release checklist', + createdAt: '2026-04-08T00:00:00.000Z', + updatedAt: '2026-04-08T00:00:00.000Z', + }, + }); + }) as typeof globalThis.fetch; + + const result = await createCustomSkillSubcommand.handler( + makeContext({ opts: { content: '# Release checklist' }, json: true }) + ); + + expect(requestBody).toEqual({ + source: 'custom', + repo: 'custom', + skillId: 'release-checklist', + name: 'Release checklist', + content: '# Release checklist', + }); + expect(result).toMatchObject({ + id: 'hskill_custom', + source: 'custom', + content: '# Release checklist', + }); + }); + + test('create handler reads custom skill content from file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentuity-custom-skill-test-')); + const contentFile = join(dir, 'SKILL.md'); + writeFileSync(contentFile, '# Release checklist\n'); + let requestBody: unknown; + try { + globalThis.fetch = (async (url: string, init?: RequestInit) => { + expect(String(url)).toBe('https://coder.example/api/hub/skills/library'); + requestBody = JSON.parse(String(init?.body)); + return jsonResponse({ + skill: { + id: 'hskill_custom', + source: 'custom', + repo: 'custom', + skillId: 'release-checklist', + name: 'Release checklist', + content: '# Release checklist\n', + createdAt: '2026-04-08T00:00:00.000Z', + updatedAt: '2026-04-08T00:00:00.000Z', + }, + }); + }) as typeof globalThis.fetch; + + await createCustomSkillSubcommand.handler( + makeContext({ opts: { contentFile }, json: true }) + ); + + expect(requestBody).toMatchObject({ + content: '# Release checklist\n', + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('create handler fails locally when both content options are provided', async () => { + const requestedUrls: string[] = []; + globalThis.fetch = (async (url: string) => { + requestedUrls.push(String(url)); + throw new Error('unexpected fetch'); + }) as typeof globalThis.fetch; + const fatal = interceptFatal(); + + await expect( + createCustomSkillSubcommand.handler( + makeContext({ + opts: { + content: '# Inline', + contentFile: './SKILL.md', + }, + }) + ) + ).rejects.toThrow('__EXIT__'); + + expect(requestedUrls).toEqual([]); + expect(fatal.stderr).toContain('Use either --content or --content-file, not both.'); + expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); + }); + + test('create handler fails locally when content is missing', async () => { + const requestedUrls: string[] = []; + globalThis.fetch = (async (url: string) => { + requestedUrls.push(String(url)); + throw new Error('unexpected fetch'); + }) as typeof globalThis.fetch; + const fatal = interceptFatal(); + + await expect(createCustomSkillSubcommand.handler(makeContext())).rejects.toThrow('__EXIT__'); + + expect(requestedUrls).toEqual([]); + expect(fatal.stderr).toContain('Provide --content or --content-file.'); + expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); + }); +}); diff --git a/packages/cli/test/cmd/coder/workspace.test.ts b/packages/cli/test/cmd/coder/workspace.test.ts index 2c036c7d3..6c253483f 100644 --- a/packages/cli/test/cmd/coder/workspace.test.ts +++ b/packages/cli/test/cmd/coder/workspace.test.ts @@ -68,6 +68,7 @@ function makeWorkspace(overrides: Record = {}) { dependencies: [], setupScript: '', systemPrompt: '', + systemPromptMode: 'append', savedSkillIds: [], skillBucketIds: [], enabledAgents: [], @@ -231,21 +232,30 @@ describe('coder workspace commands', () => { return jsonResponse({ workspace: makeWorkspace({ systemPrompt: 'Follow the release checklist.', + systemPromptMode: 'overwrite', selectionCount: 1, }), }); }) as typeof globalThis.fetch; const result = await createWorkspaceSubcommand.handler( - makeContext({ opts: { systemPrompt: 'Follow the release checklist.' }, json: true }) + makeContext({ + opts: { + systemPrompt: 'Follow the release checklist.', + systemPromptMode: 'overwrite', + }, + json: true, + }) ); expect(requestBody).toMatchObject({ name: 'My Workspace', systemPrompt: 'Follow the release checklist.', + systemPromptMode: 'overwrite', }); expect(result).toMatchObject({ systemPrompt: 'Follow the release checklist.', + systemPromptMode: 'overwrite', }); }); @@ -369,6 +379,32 @@ describe('coder workspace commands', () => { expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); }); + test('create handler fails locally when system prompt mode is invalid', async () => { + const requestedUrls: string[] = []; + globalThis.fetch = (async (url: string) => { + requestedUrls.push(String(url)); + throw new Error('unexpected fetch'); + }) as typeof globalThis.fetch; + const fatal = interceptFatal(); + + await expect( + createWorkspaceSubcommand.handler( + makeContext({ + opts: { + systemPrompt: 'inline prompt', + systemPromptMode: 'replace', + }, + }) + ) + ).rejects.toThrow('__EXIT__'); + + expect(requestedUrls).toEqual([]); + expect(fatal.stderr).toContain( + 'Failed to read system prompt: Use --system-prompt-mode append or --system-prompt-mode overwrite.' + ); + expect(fatal.exitCode).toBe(getExitCode(ErrorCode.VALIDATION_FAILED)); + }); + test('create handler reports setup script file read failures as validation errors', async () => { const requestedUrls: string[] = []; globalThis.fetch = (async (url: string) => { @@ -450,6 +486,7 @@ describe('coder workspace commands', () => { dependencies: ['git'], setupScript: 'echo updated', systemPrompt: 'Use the workspace prompt.', + systemPromptMode: 'overwrite', snapshot: { status: 'building' }, selectionCount: 3, }), @@ -463,6 +500,7 @@ describe('coder workspace commands', () => { dependency: 'git', setupScript: 'echo updated', systemPrompt: 'Use the workspace prompt.', + systemPromptMode: 'overwrite', }, json: true, }) @@ -472,12 +510,14 @@ describe('coder workspace commands', () => { dependencies: ['git'], setupScript: 'echo updated', systemPrompt: 'Use the workspace prompt.', + systemPromptMode: 'overwrite', }); expect(result).toMatchObject({ id: 'ws_test', dependencies: ['git'], setupScript: 'echo updated', systemPrompt: 'Use the workspace prompt.', + systemPromptMode: 'overwrite', snapshot: { status: 'building' }, }); }); diff --git a/packages/core/src/services/coder/client.ts b/packages/core/src/services/coder/client.ts index 6ce11aa50..9a4d323a9 100644 --- a/packages/core/src/services/coder/client.ts +++ b/packages/core/src/services/coder/client.ts @@ -32,6 +32,7 @@ import { coderUpdateCustomAgent, } from './agents.ts'; import { + coderCreateCustomSkill, coderCreateSkillBucket, coderDeleteSavedSkill, coderDeleteSkillBucket, @@ -79,6 +80,7 @@ import type { CoderSkillBucket, CoderSkillBucketListResponse, CoderCreateSkillBucketRequest, + CoderCreateCustomSkillRequest, CoderUpdateCustomAgentRequest, CoderCreateWorkspaceRequest, CoderUpdateWorkspaceRequest, @@ -449,6 +451,14 @@ export class CoderClient { return coderSaveSkill(client, { body }); } + /** + * Creates a custom SKILL.md-backed skill in the caller's library. + */ + async createCustomSkill(body: CoderCreateCustomSkillRequest): Promise { + const client = await this.#getClient(); + return coderCreateCustomSkill(client, { body }); + } + /** * Deletes a saved skill from the caller's library. */ diff --git a/packages/core/src/services/coder/skills.ts b/packages/core/src/services/coder/skills.ts index 358a68817..03f5b1e24 100644 --- a/packages/core/src/services/coder/skills.ts +++ b/packages/core/src/services/coder/skills.ts @@ -2,12 +2,14 @@ import { z } from 'zod/v4'; import { type APIClient } from '../api.ts'; import { CoderCreateSkillBucketRequestSchema, + CoderCreateCustomSkillRequestSchema, CoderSavedSkillListResponseSchema, CoderSavedSkillSchema, CoderSaveSkillRequestSchema, CoderSkillBucketListResponseSchema, CoderSkillBucketSchema, type CoderCreateSkillBucketRequest, + type CoderCreateCustomSkillRequest, type CoderSavedSkill, type CoderSavedSkillListResponse, type CoderSaveSkillRequest, @@ -59,6 +61,23 @@ export async function coderSaveSkill( return resp.skill; } +export async function coderCreateCustomSkill( + client: APIClient, + params: { body: CoderCreateCustomSkillRequest } +): Promise { + const body = CoderCreateCustomSkillRequestSchema.parse(params.body); + return coderSaveSkill(client, { + body: { + source: 'custom', + repo: 'custom', + skillId: body.skillId, + name: body.name, + ...(body.description !== undefined ? { description: body.description } : {}), + content: body.content, + }, + }); +} + export async function coderDeleteSavedSkill( client: APIClient, params: { skillId: string } diff --git a/packages/core/src/services/coder/types.ts b/packages/core/src/services/coder/types.ts index 83fe1673b..32b8ede1b 100644 --- a/packages/core/src/services/coder/types.ts +++ b/packages/core/src/services/coder/types.ts @@ -42,12 +42,18 @@ export const CoderSessionBucketSchema = z .describe('Derived bucket used for session listing and UI grouping'); export type CoderSessionBucket = z.infer; +export const CoderWorkspaceSystemPromptModeSchema = z + .enum(['append', 'overwrite']) + .describe('How a workspace system prompt is applied to the Lead agent prompt'); +export type CoderWorkspaceSystemPromptMode = z.infer; + export const CoderSkillRefSchema = z .object({ skillId: z.string().describe('Unique skill identifier'), repo: z.string().describe('Repository slug for the skill source'), name: z.string().optional().describe('Human-readable skill name'), url: z.string().optional().describe('Canonical URL for the skill repository or page'), + content: z.string().optional().describe('Inline SKILL.md content for custom skills'), }) .describe('Skill reference attached to a coder session'); export type CoderSkillRef = z.infer; @@ -83,6 +89,7 @@ export const CoderSavedSkillSchema = z description: z.string().optional().describe('Skill description'), url: z.string().optional().describe('Skill URL'), installs: z.number().optional().describe('Number of installs'), + content: z.string().optional().describe('Inline SKILL.md content for custom skills'), createdAt: z.string().describe('Creation timestamp (ISO-8601)'), updatedAt: z.string().describe('Last update timestamp (ISO-8601)'), }) @@ -130,6 +137,9 @@ export const CoderWorkspaceDetailSchema = z .describe( 'Additional Lead agent system prompt applied to sessions created from this workspace' ), + systemPromptMode: CoderWorkspaceSystemPromptModeSchema.optional() + .default('append') + .describe('Whether the workspace system prompt appends to or overwrites the Lead prompt'), snapshot: z .object({ status: z.string().describe('Workspace snapshot build status'), @@ -396,6 +406,9 @@ export const CoderCreateWorkspaceRequestSchema = z .describe( 'Additional Lead agent system prompt applied to sessions created from this workspace' ), + systemPromptMode: CoderWorkspaceSystemPromptModeSchema.optional().describe( + 'Whether the workspace system prompt appends to or overwrites the Lead prompt' + ), savedSkillIds: z.array(z.string()).optional().describe('Saved skill IDs'), skillBucketIds: z.array(z.string()).optional().describe('Skill bucket IDs'), enabledAgents: z @@ -430,6 +443,9 @@ export const CoderUpdateWorkspaceRequestSchema = z .describe( 'Additional Lead agent system prompt applied to sessions created from this workspace' ), + systemPromptMode: CoderWorkspaceSystemPromptModeSchema.optional().describe( + 'Whether the workspace system prompt appends to or overwrites the Lead prompt' + ), savedSkillIds: z.array(z.string()).optional().describe('Saved skill IDs'), skillBucketIds: z.array(z.string()).optional().describe('Skill bucket IDs'), enabledAgents: z @@ -550,6 +566,19 @@ export const CoderSaveSkillRequestSchema = z .describe('Request body for saving a skill to the library'); export type CoderSaveSkillRequest = z.infer; +export const CoderCreateCustomSkillRequestSchema = z + .object({ + skillId: z.string().describe('Skill identifier'), + name: z.string().describe('Skill name'), + description: z.string().optional().describe('Skill description'), + content: z + .string() + .refine((value) => value.trim().length > 0, 'SKILL.md content is required') + .describe('SKILL.md content'), + }) + .describe('Request body for creating a custom skill'); +export type CoderCreateCustomSkillRequest = z.infer; + export const CoderCreateSkillBucketRequestSchema = z .object({ name: z.string().describe('Skill bucket name'), diff --git a/packages/core/test/coder-client.test.ts b/packages/core/test/coder-client.test.ts index 72a330363..e4819e4a3 100644 --- a/packages/core/test/coder-client.test.ts +++ b/packages/core/test/coder-client.test.ts @@ -4,6 +4,7 @@ import { CoderClient } from '../src/services/coder/client.ts'; import { APIError, ValidationInputError } from '../src/services/api.ts'; import { CoderCreateAgentBuilderSessionRequestSchema, + CoderCreateCustomSkillRequestSchema, CoderCreateWorkspaceRequestSchema, CoderUpdateWorkspaceRequestSchema, } from '../src/services/coder/types.ts'; @@ -74,6 +75,22 @@ function makeWorkspace(overrides: Record = {}) { enabledAgents: [], selectionCount: 0, systemPrompt: '', + systemPromptMode: 'append', + createdAt: '2026-04-08T00:00:00.000Z', + updatedAt: '2026-04-08T00:00:00.000Z', + ...overrides, + }; +} + +function makeSavedSkill(overrides: Record = {}) { + return { + id: 'hskill_test123', + source: 'registry', + repo: 'agentuity/coder', + skillId: 'release-checklist', + name: 'Release checklist', + description: 'Release workflow guardrails', + url: 'https://skills.sh/agentuity/coder/release-checklist', createdAt: '2026-04-08T00:00:00.000Z', updatedAt: '2026-04-08T00:00:00.000Z', ...overrides, @@ -409,6 +426,7 @@ describe('CoderClient enabled agent roster contract', () => { dependencies: ['git'], setupScript: 'echo ready', systemPrompt: 'Use the workspace checklist.', + systemPromptMode: 'overwrite', }; expect(createSessionBody.enabledAgents).toEqual(['code-review']); @@ -416,6 +434,7 @@ describe('CoderClient enabled agent roster contract', () => { expect(createWorkspaceBody.enabledAgents).toEqual(['code-review']); expect(updateWorkspaceBody.dependencies).toEqual(['git']); expect(updateWorkspaceBody.systemPrompt).toBe('Use the workspace checklist.'); + expect(updateWorkspaceBody.systemPromptMode).toBe('overwrite'); }); test('workspace create schema rejects a name-only request', () => { @@ -453,8 +472,15 @@ describe('CoderClient enabled agent roster contract', () => { CoderCreateWorkspaceRequestSchema.safeParse({ name: 'Prompt Workspace', systemPrompt: 'Use the workspace checklist.', + systemPromptMode: 'overwrite', }).success ).toBe(true); + expect( + CoderCreateWorkspaceRequestSchema.safeParse({ + name: 'Mode Only Workspace', + systemPromptMode: 'overwrite', + }).success + ).toBe(false); }); test('workspace update schema rejects an empty body', () => { @@ -652,6 +678,7 @@ describe('CoderClient enabled agent roster contract', () => { dependencies: ['git', 'nodejs'], setupScript: 'echo ready', systemPrompt: 'Use the workspace checklist.', + systemPromptMode: 'overwrite', }); return new Response( JSON.stringify({ @@ -660,6 +687,7 @@ describe('CoderClient enabled agent roster contract', () => { dependencies: ['git', 'nodejs'], setupScript: 'echo ready', systemPrompt: 'Use the workspace checklist.', + systemPromptMode: 'overwrite', selectionCount: 3, snapshot: { status: 'building', buildId: 'wsbuild_test' }, }), @@ -683,12 +711,14 @@ describe('CoderClient enabled agent roster contract', () => { dependencies: ['git', 'nodejs'], setupScript: 'echo ready', systemPrompt: 'Use the workspace checklist.', + systemPromptMode: 'overwrite', }) ).resolves.toMatchObject({ id: 'hworkspace_deps_create', dependencies: ['git', 'nodejs'], setupScript: 'echo ready', systemPrompt: 'Use the workspace checklist.', + systemPromptMode: 'overwrite', snapshot: { status: 'building' }, }); }); @@ -701,6 +731,7 @@ describe('CoderClient enabled agent roster contract', () => { dependencies: ['git'], setupScript: 'echo updated', systemPrompt: 'Use the updated checklist.', + systemPromptMode: 'append', }); return new Response( JSON.stringify({ @@ -709,6 +740,7 @@ describe('CoderClient enabled agent roster contract', () => { dependencies: ['git'], setupScript: 'echo updated', systemPrompt: 'Use the updated checklist.', + systemPromptMode: 'append', snapshot: { status: 'building' }, }), }), @@ -730,12 +762,14 @@ describe('CoderClient enabled agent roster contract', () => { dependencies: ['git'], setupScript: 'echo updated', systemPrompt: 'Use the updated checklist.', + systemPromptMode: 'append', }) ).resolves.toMatchObject({ id: 'hworkspace_update', dependencies: ['git'], setupScript: 'echo updated', systemPrompt: 'Use the updated checklist.', + systemPromptMode: 'append', }); }); @@ -913,6 +947,80 @@ describe('CoderClient enabled agent roster contract', () => { expect(workspace.selectedEnabledAgents).toEqual(['code-review']); expect('agentSlugs' in workspace).toBe(false); }); + + test('createCustomSkill saves SKILL.md content as a custom library skill', async () => { + let requestBody: unknown; + mockFetch(async (url, init) => { + expect(url).toBe('https://coder.example/api/hub/skills/library'); + expect(init?.method).toBe('POST'); + requestBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + skill: makeSavedSkill({ + id: 'hskill_custom', + source: 'custom', + repo: 'custom', + skillId: 'release-checklist', + name: 'Release checklist', + content: '# Release checklist', + url: undefined, + }), + }), + { + status: 201, + headers: { 'content-type': 'application/json' }, + } + ); + }); + + const client = new CoderClient({ + apiKey: 'ag_test', + url: 'https://coder.example', + orgId: 'org_test', + }); + + await expect( + client.createCustomSkill({ + skillId: 'release-checklist', + name: 'Release checklist', + description: 'Release workflow guardrails', + content: '# Release checklist', + }) + ).resolves.toMatchObject({ + id: 'hskill_custom', + source: 'custom', + repo: 'custom', + skillId: 'release-checklist', + content: '# Release checklist', + }); + expect(requestBody).toEqual({ + source: 'custom', + repo: 'custom', + skillId: 'release-checklist', + name: 'Release checklist', + description: 'Release workflow guardrails', + content: '# Release checklist', + }); + }); + + test('custom skill schema rejects blank SKILL.md content', () => { + const result = CoderCreateCustomSkillRequestSchema.safeParse({ + skillId: 'empty-skill', + name: 'Empty skill', + content: ' ', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'SKILL.md content is required', + }), + ]) + ); + } + }); }); describe('CoderClient custom agent helpers', () => {