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
122 changes: 122 additions & 0 deletions packages/cli/src/cmd/coder/skill/create.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
}
},
});
15 changes: 14 additions & 1 deletion packages/cli/src/cmd/coder/skill/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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"'
Expand All @@ -31,5 +38,11 @@ export const skillCommand = createCommand({
description: 'List skill buckets',
},
],
subcommands: [listSubcommand, saveSkillSubcommand, deleteSkillSubcommand, bucketsSubcommand],
subcommands: [
listSubcommand,
createCustomSkillSubcommand,
saveSkillSubcommand,
deleteSkillSubcommand,
bucketsSubcommand,
],
});
48 changes: 46 additions & 2 deletions packages/cli/src/cmd/coder/workspace/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,32 @@ import {
type CoderCreateWorkspaceRequest,
type CoderUpdateWorkspaceRequest,
type CoderWorkspaceDetail,
type CoderWorkspaceSystemPromptMode,
} from '@agentuity/core/coder';
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 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
Expand Down Expand Up @@ -46,11 +62,36 @@ export async function readSetupScript(input: {
}
}

export async function readSystemPrompt(input: {
systemPrompt?: string;
systemPromptFile?: string;
}): Promise<string | undefined> {
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
Expand All @@ -67,7 +108,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('; ');
}
Expand All @@ -94,6 +135,9 @@ export function printWorkspaceSummary(workspace: CoderWorkspaceDetail): void {
if (workspace.setupScript) {
tui.output(' Setup: configured');
}
if (workspace.systemPrompt) {
tui.output(` Prompt: configured (${workspace.systemPromptMode})`);
}
if (workspace.snapshot?.status) {
tui.output(` Snapshot: ${workspace.snapshot.status}`);
}
Expand Down
35 changes: 34 additions & 1 deletion packages/cli/src/cmd/coder/workspace/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
EMPTY_WORKSPACE_ERROR,
formatWorkspaceValidationMessage,
hasWorkspaceSelections,
normalizeSystemPromptMode,
parseCommaList,
printWorkspaceSummary,
readSystemPrompt,
readSetupScript,
} from './common';

Expand All @@ -38,6 +40,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 --system-prompt-mode overwrite'
),
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',
Expand Down Expand Up @@ -71,6 +79,18 @@ 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'),
systemPromptMode: z
.string()
.optional()
.describe('How to apply the system prompt: append or overwrite'),
enabledAgents: z
.string()
.optional()
Expand Down Expand Up @@ -116,12 +136,25 @@ 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;
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);
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
);
}
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/cmd/coder/workspace/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import { resolveGitHubRepo } from '../resolve-repo';
import {
formatWorkspaceValidationMessage,
hasWorkspaceUpdate,
normalizeSystemPromptMode,
parseCommaList,
printWorkspaceSummary,
readSystemPrompt,
readSetupScript,
} from './common';

Expand All @@ -33,6 +35,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 --system-prompt-mode append'
),
description: 'Update the workspace Lead system prompt',
},
],
schema: {
args: z.object({
Expand All @@ -57,6 +65,18 @@ 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'),
systemPromptMode: z
.string()
.optional()
.describe('How to apply the system prompt: append or overwrite'),
enabledAgents: z
.string()
.optional()
Expand Down Expand Up @@ -102,6 +122,19 @@ 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;
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);
return;
}
if (opts?.enabledAgents) {
body.enabledAgents = parseCommaList(opts.enabledAgents);
}
Expand Down
Loading
Loading