mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
433 lines
12 KiB
TypeScript
433 lines
12 KiB
TypeScript
import { randomUUID } from 'crypto'
|
|
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
|
|
import { join, parse } from 'path'
|
|
import { z } from 'zod/v4'
|
|
import type { ToolResultBlockParam } from 'src/Tool.js'
|
|
import { buildTool } from 'src/Tool.js'
|
|
import { truncate } from 'src/utils/format.js'
|
|
import { safeParseJSON } from 'src/utils/json.js'
|
|
import {
|
|
WORKFLOW_DIR_NAME,
|
|
WORKFLOW_FILE_EXTENSIONS,
|
|
WORKFLOW_TOOL_NAME,
|
|
} from './constants.js'
|
|
|
|
const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
|
|
|
|
const inputSchema = z.object({
|
|
workflow: z.string().describe('Name of the workflow to execute'),
|
|
args: z.string().optional().describe('Arguments to pass to the workflow'),
|
|
action: z
|
|
.enum(['start', 'status', 'advance', 'cancel', 'list'])
|
|
.optional()
|
|
.describe('Workflow action. Defaults to start.'),
|
|
run_id: z
|
|
.string()
|
|
.optional()
|
|
.describe('Workflow run id for status, advance, or cancel.'),
|
|
})
|
|
type Input = typeof inputSchema
|
|
type WorkflowInput = z.infer<Input>
|
|
|
|
type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled'
|
|
|
|
type WorkflowStep = {
|
|
name: string
|
|
prompt: string
|
|
status: WorkflowStepStatus
|
|
startedAt?: number
|
|
completedAt?: number
|
|
}
|
|
|
|
type WorkflowRun = {
|
|
runId: string
|
|
workflow: string
|
|
args?: string
|
|
status: 'running' | 'completed' | 'cancelled'
|
|
createdAt: number
|
|
updatedAt: number
|
|
currentStepIndex: number
|
|
steps: WorkflowStep[]
|
|
}
|
|
|
|
type WorkflowOutput = { output: string }
|
|
|
|
async function findWorkflowFile(
|
|
workflowDir: string,
|
|
workflow: string,
|
|
): Promise<{ path: string; content: string } | null> {
|
|
for (const ext of WORKFLOW_FILE_EXTENSIONS) {
|
|
const path = join(workflowDir, `${workflow}${ext}`)
|
|
try {
|
|
return { path, content: await readFile(path, 'utf-8') }
|
|
} catch {
|
|
// try next
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function listAvailableWorkflows(workflowDir: string): Promise<string[]> {
|
|
try {
|
|
const files = await readdir(workflowDir)
|
|
return files
|
|
.filter(f =>
|
|
WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()),
|
|
)
|
|
.map(f => parse(f).name)
|
|
.sort()
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function workflowRunPath(cwd: string, runId: string): string {
|
|
return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`)
|
|
}
|
|
|
|
async function readWorkflowRun(
|
|
cwd: string,
|
|
runId: string,
|
|
): Promise<WorkflowRun | null> {
|
|
try {
|
|
const parsed = safeParseJSON(
|
|
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
|
|
false,
|
|
) as Partial<WorkflowRun> | null
|
|
if (
|
|
!parsed ||
|
|
typeof parsed.runId !== 'string' ||
|
|
typeof parsed.workflow !== 'string' ||
|
|
!Array.isArray(parsed.steps)
|
|
) {
|
|
return null
|
|
}
|
|
return parsed as WorkflowRun
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise<void> {
|
|
await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true })
|
|
await writeFile(
|
|
workflowRunPath(cwd, run.runId),
|
|
JSON.stringify(run, null, 2) + '\n',
|
|
'utf-8',
|
|
)
|
|
}
|
|
|
|
async function listWorkflowRuns(cwd: string): Promise<WorkflowRun[]> {
|
|
let files: string[]
|
|
try {
|
|
files = await readdir(join(cwd, WORKFLOW_RUNS_DIR))
|
|
} catch {
|
|
return []
|
|
}
|
|
const runs = await Promise.all(
|
|
files
|
|
.filter(f => f.endsWith('.json'))
|
|
.map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))),
|
|
)
|
|
return runs
|
|
.filter((run): run is WorkflowRun => run !== null)
|
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
}
|
|
|
|
function parseMarkdownSteps(content: string): WorkflowStep[] {
|
|
const steps: WorkflowStep[] = []
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.trim()
|
|
const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/)
|
|
const bulletMatch = line.match(/^[-*]\s+(.+)$/)
|
|
const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/)
|
|
const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1]
|
|
if (!text) continue
|
|
steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' })
|
|
}
|
|
return steps
|
|
}
|
|
|
|
function parseYamlSteps(content: string): WorkflowStep[] {
|
|
const steps: WorkflowStep[] = []
|
|
let current: Partial<WorkflowStep> | null = null
|
|
const flush = () => {
|
|
if (!current) return
|
|
const prompt = current.prompt ?? current.name
|
|
if (current.name && prompt) {
|
|
steps.push({
|
|
name: current.name,
|
|
prompt,
|
|
status: 'pending',
|
|
})
|
|
}
|
|
current = null
|
|
}
|
|
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.trim()
|
|
const stepText = line.match(/^-\s+(.+)$/)?.[1]
|
|
if (stepText) {
|
|
flush()
|
|
const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1]
|
|
current = {
|
|
name: inlineName ?? stepText,
|
|
prompt: inlineName ? undefined : stepText,
|
|
}
|
|
continue
|
|
}
|
|
const name = line.match(/^name:\s*(.+)$/)?.[1]
|
|
if (name) {
|
|
if (!current) current = {}
|
|
current.name = name
|
|
continue
|
|
}
|
|
const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2]
|
|
if (prompt) {
|
|
if (!current) current = {}
|
|
current.prompt = prompt
|
|
}
|
|
}
|
|
flush()
|
|
return steps
|
|
}
|
|
|
|
function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] {
|
|
const ext = parse(filePath).ext.toLowerCase()
|
|
const steps =
|
|
ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content)
|
|
if (steps.length > 0) {
|
|
return steps
|
|
}
|
|
return [
|
|
{
|
|
name: 'Execute workflow',
|
|
prompt: content.trim(),
|
|
status: 'pending',
|
|
},
|
|
]
|
|
}
|
|
|
|
function formatStep(step: WorkflowStep, index: number): string {
|
|
return `Step ${index + 1}: ${step.name}\n${step.prompt}`
|
|
}
|
|
|
|
function formatRunStatus(run: WorkflowRun): string {
|
|
const lines = [
|
|
`Workflow run: ${run.runId}`,
|
|
`Workflow: ${run.workflow}`,
|
|
`Status: ${run.status}`,
|
|
`Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`,
|
|
`Steps: ${run.steps.length}`,
|
|
]
|
|
for (let i = 0; i < run.steps.length; i += 1) {
|
|
const step = run.steps[i]!
|
|
lines.push(` ${i + 1}. [${step.status}] ${step.name}`)
|
|
}
|
|
return lines.join('\n')
|
|
}
|
|
|
|
async function startWorkflow(
|
|
input: WorkflowInput,
|
|
cwd: string,
|
|
): Promise<WorkflowOutput> {
|
|
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
|
|
const found = await findWorkflowFile(workflowDir, input.workflow)
|
|
if (!found) {
|
|
const available = await listAvailableWorkflows(workflowDir)
|
|
const hint =
|
|
available.length > 0
|
|
? `\nAvailable workflows: ${available.join(', ')}`
|
|
: `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.`
|
|
return { output: `Error: Workflow "${input.workflow}" not found.${hint}` }
|
|
}
|
|
|
|
const steps = parseWorkflowSteps(found.path, found.content)
|
|
const now = Date.now()
|
|
steps[0] = { ...steps[0]!, status: 'running', startedAt: now }
|
|
const run: WorkflowRun = {
|
|
runId: randomUUID(),
|
|
workflow: input.workflow,
|
|
...(input.args ? { args: input.args } : {}),
|
|
status: 'running',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
currentStepIndex: 0,
|
|
steps,
|
|
}
|
|
await writeWorkflowRun(cwd, run)
|
|
|
|
const argsSection = input.args ? `\n\nArguments:\n${input.args}` : ''
|
|
return {
|
|
output: [
|
|
`Workflow run started`,
|
|
`run_id: ${run.runId}`,
|
|
`workflow: ${run.workflow}`,
|
|
'',
|
|
formatStep(steps[0]!, 0),
|
|
argsSection,
|
|
'',
|
|
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
|
].join('\n'),
|
|
}
|
|
}
|
|
|
|
async function getRunOrError(
|
|
cwd: string,
|
|
runId: string | undefined,
|
|
): Promise<{ run?: WorkflowRun; output?: string }> {
|
|
if (!runId) return { output: 'Error: run_id is required for this action.' }
|
|
const run = await readWorkflowRun(cwd, runId)
|
|
if (!run) return { output: `Error: Workflow run "${runId}" not found.` }
|
|
return { run }
|
|
}
|
|
|
|
async function advanceWorkflow(
|
|
cwd: string,
|
|
runId: string | undefined,
|
|
): Promise<WorkflowOutput> {
|
|
const found = await getRunOrError(cwd, runId)
|
|
if (!found.run) return { output: found.output! }
|
|
const run = found.run
|
|
const now = Date.now()
|
|
const current = run.steps[run.currentStepIndex]
|
|
if (current && current.status === 'running') {
|
|
current.status = 'completed'
|
|
current.completedAt = now
|
|
}
|
|
const nextIndex = run.currentStepIndex + 1
|
|
if (nextIndex >= run.steps.length) {
|
|
run.status = 'completed'
|
|
run.updatedAt = now
|
|
await writeWorkflowRun(cwd, run)
|
|
return { output: `Workflow completed\nrun_id: ${run.runId}` }
|
|
}
|
|
run.currentStepIndex = nextIndex
|
|
run.steps[nextIndex] = {
|
|
...run.steps[nextIndex]!,
|
|
status: 'running',
|
|
startedAt: now,
|
|
}
|
|
run.updatedAt = now
|
|
await writeWorkflowRun(cwd, run)
|
|
return {
|
|
output: [
|
|
`Next workflow step`,
|
|
`run_id: ${run.runId}`,
|
|
'',
|
|
formatStep(run.steps[nextIndex]!, nextIndex),
|
|
'',
|
|
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
|
].join('\n'),
|
|
}
|
|
}
|
|
|
|
async function cancelWorkflow(
|
|
cwd: string,
|
|
runId: string | undefined,
|
|
): Promise<WorkflowOutput> {
|
|
const found = await getRunOrError(cwd, runId)
|
|
if (!found.run) return { output: found.output! }
|
|
const run = found.run
|
|
const now = Date.now()
|
|
run.status = 'cancelled'
|
|
run.updatedAt = now
|
|
for (const step of run.steps) {
|
|
if (step.status === 'pending' || step.status === 'running') {
|
|
step.status = 'cancelled'
|
|
}
|
|
}
|
|
await writeWorkflowRun(cwd, run)
|
|
return { output: `Workflow cancelled\nrun_id: ${run.runId}` }
|
|
}
|
|
|
|
async function listWorkflowRunsForOutput(cwd: string): Promise<WorkflowOutput> {
|
|
const runs = await listWorkflowRuns(cwd)
|
|
if (runs.length === 0) return { output: 'No workflow runs recorded.' }
|
|
return {
|
|
output: runs
|
|
.slice(0, 20)
|
|
.map(
|
|
run =>
|
|
`${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`,
|
|
)
|
|
.join('\n'),
|
|
}
|
|
}
|
|
|
|
export const WorkflowTool = buildTool({
|
|
name: WORKFLOW_TOOL_NAME,
|
|
searchHint: 'execute user-defined workflow scripts',
|
|
maxResultSizeChars: 50_000,
|
|
strict: true,
|
|
|
|
inputSchema,
|
|
|
|
async description() {
|
|
return 'Execute and track a user-defined workflow from .claude/workflows/'
|
|
},
|
|
async prompt() {
|
|
return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps.
|
|
|
|
Actions:
|
|
- start (default): create a persisted workflow run and return the first step to execute
|
|
- advance: mark the current step complete and return the next step
|
|
- status: inspect a workflow run by run_id
|
|
- cancel: cancel a workflow run
|
|
- list: list recent workflow runs
|
|
|
|
Workflow run state is persisted in .claude/workflow-runs/.`
|
|
},
|
|
userFacingName() {
|
|
return 'Workflow'
|
|
},
|
|
isReadOnly(input) {
|
|
return input.action === 'status' || input.action === 'list'
|
|
},
|
|
isEnabled() {
|
|
return true
|
|
},
|
|
|
|
renderToolUseMessage(input: Partial<WorkflowInput>) {
|
|
const name = input.workflow ?? 'unknown'
|
|
const action = input.action ?? 'start'
|
|
return input.args
|
|
? `Workflow: ${action} ${name} ${input.args}`
|
|
: `Workflow: ${action} ${name}`
|
|
},
|
|
|
|
mapToolResultToToolResultBlockParam(
|
|
content: WorkflowOutput,
|
|
toolUseID: string,
|
|
): ToolResultBlockParam {
|
|
return {
|
|
tool_use_id: toolUseID,
|
|
type: 'tool_result',
|
|
content: truncate(content.output, 50_000),
|
|
}
|
|
},
|
|
|
|
async call(input: WorkflowInput) {
|
|
const cwd = process.cwd()
|
|
const action = input.action ?? 'start'
|
|
switch (action) {
|
|
case 'start':
|
|
return { data: await startWorkflow(input, cwd) }
|
|
case 'status': {
|
|
const found = await getRunOrError(cwd, input.run_id)
|
|
return {
|
|
data: {
|
|
output: found.run ? formatRunStatus(found.run) : found.output!,
|
|
},
|
|
}
|
|
}
|
|
case 'advance':
|
|
return { data: await advanceWorkflow(cwd, input.run_id) }
|
|
case 'cancel':
|
|
return { data: await cancelWorkflow(cwd, input.run_id) }
|
|
case 'list':
|
|
return { data: await listWorkflowRunsForOutput(cwd) }
|
|
}
|
|
},
|
|
})
|