@agentled/cli
Version:
CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.
1,231 lines • 56.1 kB
JavaScript
/* eslint-disable no-console */
import { AgentledClient } from '../client.js';
import { printOutput, printError } from '../utils/output.js';
import { preflightPipeline, formatPreflightIssue } from '../utils/preflight.js';
import { registerScaffoldCommand } from './scaffold.js';
import * as fs from 'node:fs';
import { join, resolve } from 'node:path';
import { findWorkspaceDir } from '../utils/workspace-folder.js';
import { lintPipeline } from '../utils/pipeline-lint.js';
import { makeTestSkeleton } from '../utils/test-runner.js';
/**
* Exit code used when a workflow is created/updated but server-side validation
* reports errors. Distinct from `1` (user/network error) so CI pipelines can
* detect "the pipeline JSON is broken" vs "the CLI itself failed".
*/
const VALIDATION_EXIT_CODE = 2;
function formatIssueLine(issue) {
const scope = issue.stepId ? `[${issue.stepId}] ` : '';
const code = issue.code ? ` (${issue.code})` : '';
const msg = typeof issue.message === 'string' ? issue.message : JSON.stringify(issue);
const fix = issue.suggestedFix ? `\n fix: ${issue.suggestedFix}` : '';
return ` - ${scope}${msg}${code}${fix}`;
}
/**
* Calls validate_workflow on a freshly created/updated workflow. Returns a
* tagged outcome instead of a boolean so callers can distinguish "broken
* pipeline" (exit 2) from "CLI/network failure" (exit 1).
*
* In pretty formats this function prints the human-readable report directly.
* In JSON format it is silent — the caller is responsible for emitting the
* combined envelope so fields from the create/update mutation response
* (name, pathname, status, workspaceId, …) are preserved for script consumers.
*
* When called from `workflows create`, the caller sets `createdId` so the
* fix-flow hint can tell the user how to clean up.
*/
async function runAutoValidate(client, workflowId, opts) {
let result;
try {
let pipeline;
let validationSource = 'live';
if (opts.source === 'draft' || opts.source === 'auto') {
try {
const draft = await client.getDraft(workflowId);
if (draft?.draft?.config) {
pipeline = draft.draft.config;
validationSource = 'draft';
}
else if (opts.source === 'draft') {
throw new Error(`No draft exists for workflow ${workflowId}`);
}
}
catch (e) {
if (opts.source === 'draft')
throw e;
}
}
result = await client.validateWorkflow(workflowId, pipeline);
result = { ...result, source: validationSource };
}
catch (e) {
// The workflow was saved; only the post-hoc validate call failed.
// Surface as a structured warning but do not treat it as a pipeline
// defect — callers map this outcome to exit code 1, not 2.
console.error(`\nValidation request failed: ${e.message}`);
console.error(` The workflow was saved as ${workflowId}, but we could not validate it automatically.`);
console.error(` Run: agentled workflows validate ${workflowId}`);
return { status: 'request-failed' };
}
if (opts.format !== 'json') {
const errorCount = result.errors?.length ?? 0;
const warnCount = result.warnings?.length ?? 0;
if (result.source && result.source !== 'live') {
console.log(` Source: ${result.source}`);
}
if (errorCount === 0 && warnCount === 0) {
console.log(` ✓ Validated (0 errors, 0 warnings) — ${workflowId}`);
}
else {
if (errorCount > 0) {
console.error(`\n ✗ Validation failed: ${errorCount} error${errorCount === 1 ? '' : 's'}`);
for (const e of result.errors)
console.error(formatIssueLine(e));
}
if (warnCount > 0) {
console.log(`\n ⚠ ${warnCount} warning${warnCount === 1 ? '' : 's'}`);
for (const w of result.warnings)
console.log(formatIssueLine(w));
}
}
}
if (!result.valid) {
const id = opts.createdId ?? workflowId;
console.error(`\n Workflow ${id} was saved but is broken. Fix flow:`);
console.error(` 1. agentled workflows export ${id} --output pipeline.json`);
console.error(` 2. edit pipeline.json (use \`agentled workflows scaffold <pattern>\` or \`agentled examples <pattern>\` for reference)`);
console.error(` 3. agentled workflows validate --file pipeline.json # local preflight, no API call`);
console.error(` 4. agentled workflows update ${id} --file pipeline.json`);
console.error(` (or) agentled workflows delete ${id}`);
}
return { status: result.valid ? 'valid' : 'invalid', result };
}
/**
* Map a ValidateOutcome to its CLI exit code. `valid` returns 0 (caller does
* not exit), `invalid` returns 2 (pipeline broken), `request-failed` returns
* 1 (CLI/network problem).
*/
function exitCodeForOutcome(outcome) {
if (outcome.status === 'valid')
return 0;
if (outcome.status === 'invalid')
return VALIDATION_EXIT_CODE;
return 1;
}
const CONFIG_UPDATE_FIELDS = ['name', 'goal', 'description', 'steps', 'context', 'style', 'analyticsConfig'];
const READ_ONLY_WORKFLOW_FIELDS = new Set([
'_agentled',
'id',
'workspaceId',
'workspace',
'createdAt',
'updatedAt',
'owner',
'executions',
'executionInputs',
'computedMetrics',
'notificationBadge',
'agents',
'team',
'hasDraftSnapshot',
'draftSnapshot',
'urls',
]);
function normalizeRevision(value) {
const revision = typeof value === 'number'
? value
: typeof value === 'string'
? Number.parseInt(value, 10)
: Number.NaN;
return Number.isFinite(revision) && revision >= 0 ? revision : undefined;
}
function pickWorkflowConfig(workflow) {
const config = {};
for (const key of CONFIG_UPDATE_FIELDS) {
if (workflow[key] !== undefined)
config[key] = workflow[key];
}
return config;
}
function getWorkflowFromResponse(result) {
return (result?.workflow && typeof result.workflow === 'object') ? result.workflow : result;
}
function normalizeWorkflowUpdateFile(parsed) {
let source;
let updates = parsed;
if (parsed?.export?.pipeline) {
updates = parsed.export.pipeline;
source = parsed.export.sourceWorkflow;
}
else if (parsed?.exportVersion && parsed?.pipeline) {
updates = parsed.pipeline;
source = parsed.sourceWorkflow;
}
else if (parsed?.workflow) {
updates = parsed.workflow;
source = {
workflowId: parsed.workflow.id,
updatedAt: parsed.workflow.updatedAt,
revision: normalizeRevision(parsed.workflow.metadata?.revision),
status: parsed.workflow.status,
};
}
else if (parsed?._agentled) {
source = {
workflowId: parsed._agentled.workflowId,
updatedAt: parsed._agentled.updatedAt,
revision: normalizeRevision(parsed._agentled.revision),
status: parsed._agentled.status,
};
}
const cleaned = {};
for (const [key, value] of Object.entries(updates || {})) {
if (!READ_ONLY_WORKFLOW_FIELDS.has(key))
cleaned[key] = value;
}
return { updates: cleaned, source };
}
function hasConfigUpdate(updates) {
return CONFIG_UPDATE_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(updates, field));
}
function buildPulledWorkflowFile(workflow) {
return {
_agentled: {
kind: 'workflow-update-file',
workflowId: workflow.id,
updatedAt: workflow.updatedAt,
revision: normalizeRevision(workflow.metadata?.revision),
status: workflow.status,
pulledAt: new Date().toISOString(),
},
...pickWorkflowConfig(workflow),
};
}
function diffWorkflowConfig(remote, updates) {
const changedFields = CONFIG_UPDATE_FIELDS.filter((field) => Object.prototype.hasOwnProperty.call(updates, field)
&& JSON.stringify(remote[field] ?? null) !== JSON.stringify(updates[field] ?? null));
const remoteSteps = Array.isArray(remote.steps) ? remote.steps : [];
const nextSteps = Array.isArray(updates.steps) ? updates.steps : remoteSteps;
const remoteIds = new Set(remoteSteps.map((step) => step?.id).filter(Boolean));
const nextIds = new Set(nextSteps.map((step) => step?.id).filter(Boolean));
const addedSteps = Array.from(nextIds).filter((id) => !remoteIds.has(id));
const removedSteps = Array.from(remoteIds).filter((id) => !nextIds.has(id));
const changedSteps = nextSteps
.filter((step) => step?.id && remoteIds.has(step.id))
.filter((step) => {
const previous = remoteSteps.find((candidate) => candidate?.id === step.id);
return JSON.stringify(previous ?? null) !== JSON.stringify(step ?? null);
})
.map((step) => step.id);
return { changedFields, addedSteps, removedSteps, changedSteps };
}
async function verifySafeFileUpdate(client, workflowId, filePath, updates, source, opts) {
if (!hasConfigUpdate(updates) || opts.force)
return true;
const remote = getWorkflowFromResponse(await client.getWorkflow(workflowId));
const expectedUpdatedAt = opts.expectedUpdatedAt || source?.updatedAt;
const expectedRevision = normalizeRevision(source?.revision);
const remoteRevision = normalizeRevision(remote.metadata?.revision);
if (source?.workflowId && source.workflowId !== workflowId) {
printError(`File was pulled from workflow ${source.workflowId}, not ${workflowId}. Pull the target workflow first or use --force.`);
return false;
}
if (expectedRevision !== undefined && remoteRevision !== undefined) {
if (expectedRevision !== remoteRevision) {
printError(`Refusing stale update. File is based on revision ${expectedRevision}, but remote is revision ${remoteRevision}. ` +
`Run: agentled workflows pull ${workflowId} --output ${filePath} --force, compare/re-apply your edits, then update or replace again.`);
return false;
}
return true;
}
if (!expectedUpdatedAt) {
printError('Refusing config update from a file without a remote revision or updatedAt token. ' +
`Run: agentled workflows pull ${workflowId} --output ${filePath} --force, re-apply your edits, then run workflows diff/replace. ` +
'Use --force only when you intentionally want to overwrite remote config.');
return false;
}
if (expectedUpdatedAt !== remote.updatedAt) {
printError(`Refusing stale update. File is based on ${expectedUpdatedAt}, but remote is ${remote.updatedAt}. ` +
`Run: agentled workflows pull ${workflowId} --output ${filePath} --force, compare/re-apply your edits, then update or replace again.`);
return false;
}
return true;
}
export function registerWorkflowCommands(program) {
const workflows = program
.command('workflows')
.alias('wf')
.description('Manage workflows');
registerScaffoldCommand(workflows);
workflows
.command('list')
.description('List all workflows in the workspace')
.option('--status <status>', 'Filter by status (draft, live, paused, archived)')
.option('--limit <n>', 'Max results', parseInt)
.option('--format <fmt>', 'Output format: json, table, minimal', 'json')
.action(async (opts) => {
try {
const client = new AgentledClient();
const result = await client.listWorkflows({
status: opts.status,
limit: opts.limit,
});
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('get <id>')
.description('Get full workflow details by ID, including useCaseContext and operating-guide warnings when available')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.getWorkflow(id);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('pull <id>')
.description('Pull a workflow into an update-safe local file with a remote updatedAt token. Use this before workflows update --file.')
.option('--output <path>', 'Write workflow config to a specific path')
.option('--force', 'Overwrite existing files', false)
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.getWorkflow(id);
const pipeline = getWorkflowFromResponse(result);
const updateFile = buildPulledWorkflowFile(pipeline);
const wsDir = findWorkspaceDir();
const name = String(pipeline.name ?? id).toLowerCase().replace(/[^a-z0-9]+/g, '-');
const filename = `${name}-${id}.json`;
let savedPath;
let testPath = null;
let testSkippedReason = null;
if (opts.output) {
savedPath = resolve(opts.output);
if (fs.existsSync(savedPath) && !opts.force) {
printError(`Refusing to overwrite ${savedPath}. Use --force to overwrite.`);
return;
}
fs.writeFileSync(savedPath, JSON.stringify(updateFile, null, 2) + '\n');
}
else if (wsDir) {
const liveDir = join(wsDir, 'examples', 'live');
fs.mkdirSync(liveDir, { recursive: true });
savedPath = join(liveDir, filename);
if (fs.existsSync(savedPath) && !opts.force) {
printError(`Refusing to overwrite ${savedPath}. Use --force to overwrite.`);
return;
}
fs.writeFileSync(savedPath, JSON.stringify(updateFile, null, 2) + '\n');
const testsDir = join(wsDir, 'tests');
fs.mkdirSync(testsDir, { recursive: true });
testPath = join(testsDir, `${id}.test.json`);
if (!fs.existsSync(testPath) || opts.force) {
const skeleton = makeTestSkeleton(id, pipeline);
fs.writeFileSync(testPath, JSON.stringify(skeleton, null, 2) + '\n');
}
else {
testSkippedReason = 'test file already exists (use --force to regenerate)';
}
}
else {
savedPath = resolve(filename);
if (fs.existsSync(savedPath) && !opts.force) {
printError(`Refusing to overwrite ${savedPath}. Use --force to overwrite.`);
return;
}
fs.writeFileSync(savedPath, JSON.stringify(updateFile, null, 2) + '\n');
}
const allSteps = (Array.isArray(pipeline.steps) ? pipeline.steps : []);
const testableCount = allSteps.filter((s) => ['aiAction', 'aiActionWithTools', 'appAction', 'code'].includes(String(s.type ?? ''))).length;
printOutput({
workflowId: id,
workflowName: pipeline.name,
savedPath,
testFilePath: testPath,
testSkippedReason,
workspaceDir: wsDir,
stepCount: allSteps.length,
testableSteps: testableCount,
nextSteps: [
`edit ${savedPath}`,
`agentled workflows diff ${id} --file ${savedPath}`,
`agentled workflows replace ${id} --file ${savedPath}`,
`agentled workflows start ${id} # run once to capture fixtures`,
`agentled fixture capture <execId> --wf ${id}`,
`agentled test ${id} # run assertions against fixtures`,
],
}, (opts.format ?? 'json'));
}
catch (e) {
printError(e instanceof Error ? e.message : String(e));
}
});
workflows
.command('diff <id>')
.description('Compare a local workflow update file with the current remote workflow before updating.')
.requiredOption('--file <path>', 'Path to workflow update JSON file')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const raw = fs.readFileSync(opts.file, 'utf-8');
const { updates, source } = normalizeWorkflowUpdateFile(JSON.parse(raw));
const client = new AgentledClient();
const remote = getWorkflowFromResponse(await client.getWorkflow(id));
const diff = diffWorkflowConfig(remote, updates);
const remoteRevision = normalizeRevision(remote.metadata?.revision);
const sourceRevision = normalizeRevision(source?.revision);
const sourceMatchesRemote = sourceRevision !== undefined && remoteRevision !== undefined
? sourceRevision === remoteRevision
: source?.updatedAt
? source.updatedAt === remote.updatedAt
: undefined;
printOutput({
workflowId: id,
file: resolve(opts.file),
remoteRevision,
fileSourceRevision: sourceRevision,
remoteUpdatedAt: remote.updatedAt,
fileSourceUpdatedAt: source?.updatedAt,
sourceMatchesRemote,
stale: sourceMatchesRemote === false,
...diff,
summary: {
changedFields: diff.changedFields.length,
addedSteps: diff.addedSteps.length,
removedSteps: diff.removedSteps.length,
changedSteps: diff.changedSteps.length,
},
nextStep: sourceMatchesRemote === false
? `Remote changed since this file was pulled. Run: agentled workflows pull ${id} --output ${opts.file} --force, then re-apply your edits.`
: `If this diff is intended, run: agentled workflows replace ${id} --file ${opts.file}`,
}, (opts.format ?? 'json'));
if (sourceMatchesRemote === false)
process.exitCode = 1;
}
catch (e) {
printError(e instanceof Error ? e.message : String(e));
}
});
workflows
.command('lint <file>')
.description('Static gotcha checks for a pipeline JSON file (no API). Catches 11 documented failure modes (criteria/conditions, Gmail label_id, missing tools, email shape, etc.). For platform validators (graph wiring, variable resolution), run `agentled workflows validate <id>` after creating the workflow.')
.option('--format <fmt>', 'Output format', 'json')
.action((file, opts) => {
try {
const abs = resolve(file);
if (!fs.existsSync(abs)) {
printError(`File not found: ${abs}`);
process.exitCode = VALIDATION_EXIT_CODE;
return;
}
let pipeline;
try {
pipeline = JSON.parse(fs.readFileSync(abs, 'utf-8'));
}
catch (err) {
printError(`Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = VALIDATION_EXIT_CODE;
return;
}
const issues = lintPipeline(pipeline);
const errors = issues.filter((i) => i.severity === 'error');
const warnings = issues.filter((i) => i.severity === 'warning');
printOutput({
file: abs,
issues,
summary: {
total: issues.length,
errors: errors.length,
warnings: warnings.length,
},
}, (opts.format ?? 'json'));
if (errors.length > 0)
process.exitCode = VALIDATION_EXIT_CODE;
else if (warnings.length > 0)
process.exitCode = 1;
}
catch (e) {
printError(e instanceof Error ? e.message : String(e));
}
});
workflows
.command('create')
.description('Create a new workflow. Recommended: create with just name+goal (--skip-validate), then add-step one at a time for per-step validation. Full pipeline JSON via --file is for imports/templates.')
.option('--file <path>', 'Path to pipeline JSON file')
.option('--pipeline <json>', 'Inline pipeline JSON')
.option('--locale <locale>', 'Locale (default: en)')
.option('--format <fmt>', 'Output format', 'json')
.option('--skip-validate', 'Skip the post-create validate call (advanced — legacy raw create behavior)')
.action(async (opts) => {
try {
let pipeline;
if (opts.file) {
const raw = fs.readFileSync(opts.file, 'utf-8');
pipeline = JSON.parse(raw);
}
else if (opts.pipeline) {
pipeline = JSON.parse(opts.pipeline);
}
else {
printError('Provide --file or --pipeline');
return;
}
const client = new AgentledClient();
const format = (opts.format ?? 'json');
const result = await client.createWorkflow(pipeline, opts.locale);
const workflowId = (result?.id ?? result?.workflow?.id);
if (opts.skipValidate || !workflowId) {
printOutput(result, format);
return;
}
if (format === 'json') {
// Compound envelope: preserve every field from the create
// mutation response (name, pathname, status, workspaceId, …)
// AND include the validation report. Scripts that parse
// top-level fields continue to work.
const outcome = await runAutoValidate(client, workflowId, {
format,
createdId: workflowId,
});
const validation = outcome.status === 'request-failed'
? { requestFailed: true }
: outcome.result;
printOutput({ ...result, validation }, 'json');
const code = exitCodeForOutcome(outcome);
if (code !== 0)
process.exit(code);
return;
}
// Pretty-print formats: emit the create response first, then validation.
printOutput(result, format);
const outcome = await runAutoValidate(client, workflowId, {
format,
createdId: workflowId,
});
const code = exitCodeForOutcome(outcome);
if (code !== 0)
process.exit(code);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('update <id>')
.description('Update an existing workflow (auto-validates unless --skip-validate)')
.option('--file <path>', 'Path to updates JSON file')
.option('--updates <json>', 'Inline updates JSON')
.option('--expected-updated-at <timestamp>', 'Require the remote workflow updatedAt to match before applying --file')
.option('--force', 'Bypass the --file stale-update guard (advanced)')
.option('--locale <locale>', 'Locale')
.option('--format <fmt>', 'Output format', 'json')
.option('--skip-validate', 'Skip the post-update validate call (advanced)')
.action(async (id, opts) => {
try {
let updates;
let source;
if (opts.file) {
const raw = fs.readFileSync(opts.file, 'utf-8');
const normalized = normalizeWorkflowUpdateFile(JSON.parse(raw));
updates = normalized.updates;
source = normalized.source;
}
else if (opts.updates) {
updates = JSON.parse(opts.updates);
}
else {
printError('Provide --file or --updates');
return;
}
const client = new AgentledClient();
const format = (opts.format ?? 'json');
if (opts.file) {
const safe = await verifySafeFileUpdate(client, id, opts.file, updates, source, {
force: opts.force,
expectedUpdatedAt: opts.expectedUpdatedAt,
});
if (!safe)
return;
}
const result = await client.updateWorkflow(id, updates, opts.locale);
if (opts.skipValidate) {
printOutput(result, format);
return;
}
if (format === 'json') {
// Preserve the update mutation response fields in the JSON
// envelope — `result` may contain the refreshed workflow
// record, which script consumers parse.
const outcome = await runAutoValidate(client, id, {
format,
source: result?.editingDraft ? 'draft' : 'live',
});
const validation = outcome.status === 'request-failed'
? { requestFailed: true }
: outcome.result;
const envelope = result && typeof result === 'object'
? { ...result, validation }
: { workflowId: id, validation };
printOutput(envelope, 'json');
const code = exitCodeForOutcome(outcome);
if (code !== 0)
process.exit(code);
return;
}
printOutput(result, format);
const outcome = await runAutoValidate(client, id, {
format,
source: result?.editingDraft ? 'draft' : 'live',
});
const code = exitCodeForOutcome(outcome);
if (code !== 0)
process.exit(code);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('replace <id>')
.description('Replace workflow config fields from a pulled file. Uses true replace semantics for context and validates draft when editing live workflows.')
.requiredOption('--file <path>', 'Path to workflow JSON from workflows pull/export')
.option('--expected-updated-at <timestamp>', 'Require the remote workflow updatedAt to match before replacing')
.option('--force', 'Bypass the stale-file guard (advanced)')
.option('--locale <locale>', 'Locale')
.option('--format <fmt>', 'Output format', 'json')
.option('--skip-validate', 'Skip the post-replace validate call (advanced)')
.action(async (id, opts) => {
try {
const raw = fs.readFileSync(opts.file, 'utf-8');
const { updates, source } = normalizeWorkflowUpdateFile(JSON.parse(raw));
const client = new AgentledClient();
const format = (opts.format ?? 'json');
const safe = await verifySafeFileUpdate(client, id, opts.file, updates, source, {
force: opts.force,
expectedUpdatedAt: opts.expectedUpdatedAt,
});
if (!safe)
return;
const result = await client.replaceWorkflow(id, updates, opts.locale);
if (opts.skipValidate) {
printOutput(result, format);
return;
}
const validateOpts = {
format,
source: result?.editingDraft ? 'draft' : 'live',
};
if (format === 'json') {
const outcome = await runAutoValidate(client, id, validateOpts);
const validation = outcome.status === 'request-failed'
? { requestFailed: true }
: outcome.result;
const envelope = result && typeof result === 'object'
? { ...result, validation }
: { workflowId: id, validation };
printOutput(envelope, 'json');
const code = exitCodeForOutcome(outcome);
if (code !== 0)
process.exit(code);
return;
}
printOutput(result, format);
const outcome = await runAutoValidate(client, id, validateOpts);
const code = exitCodeForOutcome(outcome);
if (code !== 0)
process.exit(code);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('delete <id>')
.description('Preview or confirm permanent workflow deletion')
.option('--confirm-token <token>', 'Confirm deletion with the token returned by the preview')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.deleteWorkflow(id, opts.confirmToken);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('validate [id]')
.description('Validate a workflow. With <id>: server-side validation. With --file/--pipeline and no id: fast client-side preflight (no API call).')
.option('--file <path>', 'Path to pipeline JSON file (client-side preflight if no <id> is given)')
.option('--pipeline <json>', 'Inline pipeline JSON (overrides the saved pipeline when <id> is given; otherwise runs preflight)')
.option('--source <src>', 'Validation source when <id> is provided: auto (default) | live | draft', 'auto')
.option('--draft', 'Validate the current draft config for this workflow')
.option('--live', 'Validate the live stored workflow config')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
let pipeline = undefined;
if (opts.file) {
pipeline = JSON.parse(fs.readFileSync(opts.file, 'utf-8'));
}
else if (opts.pipeline) {
pipeline = JSON.parse(opts.pipeline);
}
// No workflow id → run a local preflight. This is the "pre-create"
// path: catches invalid step types, duplicate ids, dangling next
// references, stripped root fields, missing prompt templates —
// without spending a create+validate+delete round trip.
if (!id) {
if (!pipeline) {
printError('Provide either <id> (server validate) or --file/--pipeline (client preflight).');
return;
}
const result = preflightPipeline(pipeline);
const format = opts.format;
if (format === 'json') {
printOutput({ preflight: true, ...result }, 'json');
}
else {
if (result.valid) {
console.log(` ✓ Preflight OK (${result.stepCount} steps, ${result.warnings.length} warnings)`);
}
else {
console.error(`\n ✗ Preflight failed: ${result.errors.length} error${result.errors.length === 1 ? '' : 's'}`);
for (const e of result.errors)
console.error(formatPreflightIssue(e));
}
if (result.warnings.length > 0) {
console.log(`\n ⚠ ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'}`);
for (const w of result.warnings)
console.log(formatPreflightIssue(w));
}
console.log('\n Note: preflight is a fast structural check. Run `agentled workflows create` for full server-side validation.');
}
if (!result.valid)
process.exit(VALIDATION_EXIT_CODE);
return;
}
const client = new AgentledClient();
if (opts.draft && opts.live) {
printError('Use either --draft or --live, not both.');
return;
}
const source = opts.draft ? 'draft' : opts.live ? 'live' : String(opts.source ?? 'auto');
if (!['auto', 'live', 'draft'].includes(source)) {
printError(`Invalid --source "${source}". Must be one of: auto, live, draft.`);
return;
}
// When source is specified, resolve which stored config to validate:
// - draft: validates the current draft pipeline and errors if none exists
// - auto: validates draft if present, otherwise live
// - live: validates the live stored workflow
let resolvedSource = 'live';
if (!pipeline && source !== 'live') {
try {
const draft = await client.getDraft(id);
if (draft?.draft?.config) {
pipeline = draft.draft.config;
resolvedSource = 'draft';
}
else if (source === 'draft') {
printError(`No draft exists for workflow ${id}.`);
return;
}
}
catch {
if (source === 'draft') {
printError(`No draft exists for workflow ${id}.`);
return;
}
}
}
const result = await client.validateWorkflow(id, pipeline);
const format = opts.format;
const validationSource = opts.pipeline || opts.file
? 'inline'
: (pipeline ? resolvedSource : 'live');
if (format === 'json') {
printOutput({ ...result, source: validationSource }, 'json');
}
else {
const errorCount = result.errors?.length ?? 0;
const warnCount = result.warnings?.length ?? 0;
console.log(` Source: ${validationSource}`);
if (errorCount === 0 && warnCount === 0) {
console.log(` ✓ Validated (0 errors, 0 warnings) — ${id}`);
}
else {
if (errorCount > 0) {
console.error(`\n ✗ Validation failed: ${errorCount} error${errorCount === 1 ? '' : 's'}`);
for (const e of result.errors)
console.error(formatIssueLine(e));
}
if (warnCount > 0) {
console.log(`\n ⚠ ${warnCount} warning${warnCount === 1 ? '' : 's'}`);
for (const w of result.warnings)
console.log(formatIssueLine(w));
}
}
}
if (!result.valid) {
console.error(`\n Fix flow:`);
console.error(` 1. agentled workflows export ${id} --output pipeline.json`);
console.error(` 2. edit pipeline.json`);
console.error(` 3. agentled workflows validate --file pipeline.json # local preflight`);
console.error(` 4. agentled workflows update ${id} --file pipeline.json`);
console.error(` (or) agentled workflows delete ${id}`);
process.exit(VALIDATION_EXIT_CODE);
}
}
catch (e) {
printError(e.message);
}
});
workflows
.command('publish <id>')
.description('Change workflow status (live, paused, archived)')
.requiredOption('--status <status>', 'Target status: live, paused, archived')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.publishWorkflow(id, opts.status);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('start <id>')
.description('Start a workflow execution')
.option('--input <json>', 'Input data as JSON')
.option('--input-file <path>', 'Input data from JSON file')
.option('--metadata <json>', 'Execution metadata as JSON')
.option('--use-mocks', 'Honor per-step mock data (default behavior — zero credits for mocked steps)')
.option('--no-mocks', 'Force a real run that ignores all step mocks (consumes real credits)')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
let input;
if (opts.inputFile) {
input = JSON.parse(fs.readFileSync(opts.inputFile, 'utf-8'));
}
else if (opts.input) {
input = JSON.parse(opts.input);
}
let metadata = opts.metadata ? JSON.parse(opts.metadata) : undefined;
// Commander turns --no-mocks into opts.mocks === false, and --use-mocks into opts.useMocks === true.
// Only one should be set; if both are present --no-mocks wins (explicit opt-out).
const wantsRealRun = opts.mocks === false;
const wantsMocks = opts.useMocks === true && !wantsRealRun;
if (wantsRealRun || wantsMocks) {
metadata = {
...(metadata ?? {}),
mockConfig: { ...(metadata?.mockConfig ?? {}), disabled: wantsRealRun },
};
}
const client = new AgentledClient();
const result = await client.startWorkflow(id, input, metadata);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('export <id>')
.description('Export a workflow as portable JSON')
.option('--output <path>', 'Write export to file instead of stdout')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.exportWorkflow(id);
if (opts.output) {
fs.writeFileSync(opts.output, JSON.stringify(result, null, 2));
console.log(`Exported to ${opts.output}`);
}
else {
printOutput(result, opts.format);
}
}
catch (e) {
printError(e.message);
}
});
workflows
.command('import')
.description('Import a workflow from export JSON')
.option('--file <path>', 'Path to export JSON file')
.option('--data <json>', 'Inline export JSON')
.option('--locale <locale>', 'Locale')
.option('--format <fmt>', 'Output format', 'json')
.action(async (opts) => {
try {
let exportData;
if (opts.file) {
exportData = JSON.parse(fs.readFileSync(opts.file, 'utf-8'));
}
else if (opts.data) {
exportData = JSON.parse(opts.data);
}
else {
printError('Provide --file or --data');
return;
}
const client = new AgentledClient();
const result = await client.importWorkflow(exportData, opts.locale);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
// --- Snapshots ---
workflows
.command('snapshots <id>')
.description('List config snapshots for a workflow')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.listSnapshots(id);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('snapshot-content <id> <snapshotId>')
.description('Read a snapshot\'s full config without restoring it')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, snapshotId, opts) => {
try {
const client = new AgentledClient();
const result = await client.getSnapshotContent(id, snapshotId);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('restore <id> <snapshotId>')
.description('Restore a workflow to a previous config snapshot')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, snapshotId, opts) => {
try {
const client = new AgentledClient();
const result = await client.restoreSnapshot(id, snapshotId);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('snapshot-create <id>')
.description('Create a manual config snapshot for a workflow')
.option('--label <label>', 'Snapshot label')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.createSnapshot(id, opts.label);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('snapshot-delete <id> <snapshotId>')
.description('Delete a config snapshot')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, snapshotId, opts) => {
try {
const client = new AgentledClient();
const result = await client.deleteSnapshot(id, snapshotId);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
// --- Draft Lifecycle ---
const draft = workflows
.command('draft')
.description('Manage draft snapshots for live workflows');
draft
.command('get <id>')
.description('Get the draft snapshot for a live workflow')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.getDraft(id);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
draft
.command('promote <id>')
.description('Promote draft to live (overwrites live config)')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.promoteDraft(id);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
draft
.command('discard <id>')
.description('Discard draft changes (live config stays unchanged)')
.option('--format <fmt>', 'Output format', 'json')
.action(async (id, opts) => {
try {
const client = new AgentledClient();
const result = await client.discardDraft(id);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
// --- n8n Import ---
workflows
.command('import-n8n')
.description('Import an n8n workflow JSON into Agentled')
.option('--file <path>', 'Path to n8n JSON file')
.option('--data <json>', 'Inline n8n JSON')
.option('--name <name>', 'Workflow name override')
.option('--goal <goal>', 'Workflow goal override')
.option('--preview', 'Preview only — do not create workflow')
.option('--locale <locale>', 'Locale')
.option('--format <fmt>', 'Output format', 'json')
.action(async (opts) => {
try {
let n8nJson;
if (opts.file) {
n8nJson = JSON.parse(fs.readFileSync(opts.file, 'utf-8'));
}
else if (opts.data) {
n8nJson = JSON.parse(opts.data);
}
else {
printError('Provide --file or --data with n8n JSON');
return;
}
const workflow = (opts.name || opts.goal)
? { name: opts.name, goal: opts.goal }
: undefined;
const client = new AgentledClient();
if (opts.preview) {
const result = await client.previewN8nImport(n8nJson, undefined, workflow);
printOutput(result, opts.format);
}
else {
const result = await client.importN8nWorkflow(n8nJson, workflow, undefined, opts.locale);
printOutput(result, opts.format);
}
}
catch (e) {
printError(e.message);
}
});
// --- Step Operations ---
workflows
.command('add-step <workflowId>')
.description('Add a step to a workflow (recommended authoring path). Create an empty workflow first, then add-step one at a time — each call validates the step immediately. Use --insert-after + --rewire-next to chain steps.')
.requiredOption('--step <json>', 'Step definition as JSON')
.option('--insert-after <stepId>', 'Insert after this step ID')
.option('--rewire-next', 'Rewire the previous step\'s next pointer to the new step', false)
.option('--format <fmt>', 'Output format', 'json')
.action(async (workflowId, opts) => {
try {
const step = JSON.parse(opts.step);
const client = new AgentledClient();
const result = await client.addStep(workflowId, step, opts.insertAfter, opts.rewireNext);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('update-step <workflowId> <stepId>')
.description('Update a single step. --updates patches (one-level deep-merge); --replace overwrites paths wholesale (for dictionary-shaped fields like stepInputData.fieldUpdates); --unset deletes paths.')
.option('--updates <json>', 'Partial step updates as JSON (deep-merged one level)')
.option('--replace <paths>', 'Comma-separated dot-paths whose values in --updates should be assigned wholesale (skips deep-merge). Required for dictionary-shaped fields.', (val, prev = []) => prev.concat(val.split(',').map(s => s.trim()).filter(Boolean)), [])
.option('--unset <paths>', 'Comma-separated dot-paths to delete from the step.', (val, prev = []) => prev.concat(val.split(',').map(s => s.trim()).filter(Boolean)), [])
.option('--format <fmt>', 'Output format', 'json')
.action(async (workflowId, stepId, opts) => {
try {
const updates = opts.updates ? JSON.parse(opts.updates) : undefined;
const replace = opts.replace && opts.replace.length > 0 ? opts.replace : undefined;
const unset = opts.unset && opts.unset.length > 0 ? opts.unset : undefined;
if (!updates && !replace && !unset) {
printError('At least one of --updates, --replace, or --unset must be provided');
return;
}
const client = new AgentledClient();
const result = await client.updateStep(workflowId, stepId, { updates, replace, unset });
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('get-step <workflowId> <stepId>')
.description('Read a single step (cheap; ~1KB). Use before editing dictionary-shaped fields so you can send the full new object back via update-step --replace.')
.option('--source <src>', 'auto (default) | live | draft. auto returns draft if one exists, else live.', 'auto')
.option('--format <fmt>', 'Output format', 'json')
.action(async (workflowId, stepId, opts) => {
try {
const source = opts.source;
if (!['auto', 'live', 'draft'].includes(source)) {
printError(`Invalid --source "${source}". Must be one of: auto, live, draft.`);
return;
}
const client = new AgentledClient();
const result = await client.getStep(workflowId, stepId, source);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('move-step <workflowId> <stepId> [insertAfter]')
.description('Move a step to a new location in a workflow. Use legacy positional <insertAfter>, --after <stepId>, or --position first|last.')
.option('--after <stepId>', 'Insert after this step ID')
.option('--position <pos>', 'Move to first or last position')
.option('--format <fmt>', 'Output format', 'json')
.action(async (workflowId, stepId, insertAfter, opts) => {
try {
const after = opts.after || insertAfter;
const position = opts.position;
if (!after && !position) {
printError('Provide <insertAfter>, --after <stepId>, or --position first|last');
return;
}
if (after && position) {
printError('Provide either an insert-after target or --position, not both');
return;
}
if (position && position !== 'first' && position !== 'last') {
printError('--position must be "first" or "last"');
return;
}
const client = new AgentledClient();
const target = position ? { position: position } : after;
const result = await client.moveStep(workflowId, stepId, target);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('remove-step <workflowId> <stepId>')
.description('Remove a step from a workflow')
.option('--rewire-next', 'Rewire the previous step to point to the removed step\'s next', false)
.option('--format <fmt>', 'Output format', 'json')
.action(async (workflowId, stepId, opts) => {
try {
const client = new AgentledClient();
const result = await client.removeStep(workflowId, stepId, opts.rewireNext);
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('update-context <workflowId>')
.description('Surgical edit of workflow.context and workflow.metadata. Three explicit verbs (--updates / --replace / --unset) on workflow-relative paths under context.inputPages, context.outputPages, context.executionInputConfig, and metadata. OR legacy --context-key/--value for wholesale per-key replacement.')
.option('--updates <json>', 'Partial workflow patch as JSON. Top-level keys must be "context" and/or "metadata".')
.option('--replace <paths>', 'Comma-separated workflow-relative dot-paths whose values in --updates should be assigned wholesale (skips deep-merge). Allowed prefixes: context.inputPages, context.outputPages, context.executionInputConfig, metadata.', (val, prev = []) => prev.concat(val.split(',').map(s => s.trim()).filter(Boolean)), [])
.option('--unset <paths>', 'Comma-separated workflow-relative dot-paths to delete. Same allowed prefixes as --replace.', (val, prev = []) => prev.concat(val.split(',').map(s => s.trim()).filter(Boolean)), [])
.option('--context-key <key>', 'Legacy per-key shape: one of inputPages | outputPages | executionInputConfig.')
.option('--value <json>', 'Legacy per-key shape: new value for --context-key as JSON. Pass [] to clear a list.')
.option('--format <fmt>', 'Output format', 'json')
.action(async (workflowId, opts) => {
try {
const updates = opts.updates ? JSON.parse(opts.updates) : undefined;
const replace = opts.replace && opts.replace.length > 0 ? opts.replace : undefined;
const unset = opts.unset && opts.unset.length > 0 ? opts.unset : undefined;
const usingOps = updates !== undefined || replace !== undefined || unset !== undefined;
const usingLegacy = opts.contextKey !== undefined;
if (usingOps && usingLegacy) {
printError('Cannot mix --updates/--replace/--unset with --context-key/--value. Choose one shape.');
process.exit(1);
}
if (!usingOps && !usingLegacy) {
printError('At least one of --updates, --replace, --unset (ops shape) or --context-key/--value (legacy shape) must be provided');
return;
}
const client = new AgentledClient();
let result;
if (usingOps) {
result = await client.updateWorkflowContext(workflowId, { updates, replace, unset });
}
else {
if (!['inputPages', 'outputPages', 'executionInputConfig'].includes(opts.contextKey)) {
printError(`Invalid --context-key "${opts.contextKey}". Must be one of: inputPages, outputPages, executionInputConfig.`);
process.exit(1);
}
if (opts.value === undefined) {
printError('--value is required with --context-key');
process.exit(1);
}
let value;
try {
value = JSON.parse(opts.value);
}
catch {
value = opts.value;
}
result = await client.updateWorkflowContext(workflowId, opts.contextKey, value);
}
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
workflows
.command('step-schema')
.description('Get the full schema of allowed PipelineStep fields')
.option('--format <fmt>', 'Output format', 'json')
.action(async (opts) => {
try {
const client = new AgentledClient();
const result = await client.getStepSchema();
printOutput(result, opts.format);
}
catch (e) {
printError(e.message);
}
});
}
//# sourceMappingURL=workflows.js.map