@agentled/cli
Version:
CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.
488 lines • 23.1 kB
JavaScript
/* eslint-disable no-console */
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
function validateManifest(obj) {
const errors = [];
function err(path, msg) { errors.push({ path, message: msg, severity: 'error' }); }
function warn(path, msg) { errors.push({ path, message: msg, severity: 'warning' }); }
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
err('$', 'Manifest must be a JSON object');
return errors;
}
// Required top-level fields
if (!obj.manifestVersion)
err('manifestVersion', 'Required. Use semantic version format e.g. "0.1.0"');
if (obj.manifestVersion && !/^\d+\.\d+\.\d+$/.test(obj.manifestVersion)) {
err('manifestVersion', 'Must be a semantic version string (e.g. "0.1.0")');
}
if (!obj.group) {
err('group', 'Required. Must include at minimum { id, name }');
return errors; // can't continue without group
}
if (!obj.group.id)
err('group.id', 'Required. Use a lowercase-slug like "founder-showcase"');
if (obj.group.id && !/^[a-z0-9-]+$/.test(obj.group.id)) {
err('group.id', 'Must be lowercase alphanumeric with hyphens (e.g. "founder-showcase")');
}
if (!obj.group.name)
err('group.name', 'Required. Human-readable name for the group');
if (!obj.group.goal)
warn('group.goal', 'Missing. Document what business objective this group optimizes for');
if (!obj.group.description)
warn('group.description', 'Missing. Add a one-sentence description');
// Collect declared local IDs for cross-reference checks
const workflowLocalIds = new Set();
const existingWorkflowIds = new Set();
const knowledgeListKeys = new Set();
const stateMachineKeys = new Set();
// knowledgeLists
if (obj.knowledgeLists !== undefined) {
if (typeof obj.knowledgeLists !== 'object' || Array.isArray(obj.knowledgeLists)) {
err('knowledgeLists', 'Must be an object (keys = listKeys)');
}
else {
for (const [listKey, listDef] of Object.entries(obj.knowledgeLists)) {
knowledgeListKeys.add(listKey);
const ld = listDef;
if (!ld.description)
warn(`knowledgeLists.${listKey}.description`, 'Missing. Describe this list\'s purpose');
if (!ld.userKeyField)
warn(`knowledgeLists.${listKey}.userKeyField`, 'Missing. Specify the field used as dedup key in kg.upsert-rows');
if (!ld.producers || ld.producers.length === 0)
warn(`knowledgeLists.${listKey}.producers`, 'No producers listed. Which workflow(s) write to this list?');
if (!ld.consumers || ld.consumers.length === 0)
warn(`knowledgeLists.${listKey}.consumers`, 'No consumers listed. Which workflow(s) read from this list?');
}
}
}
// stateMachines
if (obj.stateMachines !== undefined) {
if (typeof obj.stateMachines !== 'object' || Array.isArray(obj.stateMachines)) {
err('stateMachines', 'Must be an object (keys = listKeys)');
}
else {
for (const [listKey, sm] of Object.entries(obj.stateMachines)) {
stateMachineKeys.add(listKey);
const s = sm;
const stateSet = new Set(s.states ?? []);
if (!s.states || s.states.length === 0) {
err(`stateMachines.${listKey}.states`, 'Required. List all valid status values');
}
if (s.initialState && !stateSet.has(s.initialState)) {
err(`stateMachines.${listKey}.initialState`, `"${s.initialState}" is not in states[]`);
}
if (s.transitions) {
for (let i = 0; i < s.transitions.length; i++) {
const t = s.transitions[i];
if (!stateSet.has(t.from))
err(`stateMachines.${listKey}.transitions[${i}].from`, `"${t.from}" is not in states[]`);
if (!stateSet.has(t.to))
err(`stateMachines.${listKey}.transitions[${i}].to`, `"${t.to}" is not in states[]`);
}
}
// State machine references a listKey not declared in knowledgeLists
if (obj.knowledgeLists && !knowledgeListKeys.has(listKey)) {
warn(`stateMachines.${listKey}`, `List key "${listKey}" is not declared in knowledgeLists`);
}
}
}
}
// workflows
if (obj.workflows !== undefined) {
if (!Array.isArray(obj.workflows)) {
err('workflows', 'Must be an array');
}
else {
for (let i = 0; i < obj.workflows.length; i++) {
const wf = obj.workflows[i];
const loc = `workflows[${i}]`;
if (typeof wf !== 'object' || wf === null || Array.isArray(wf)) {
err(loc, 'Each workflows[] entry must be an object');
continue;
}
if (!wf.id)
err(`${loc}.id`, 'Required. Use a local slug for cross-references in this manifest');
if (wf.id)
workflowLocalIds.add(wf.id);
if (!wf.name)
err(`${loc}.name`, 'Required');
if (!wf.purpose)
warn(`${loc}.purpose`, 'Missing. Add a one-sentence purpose statement');
// Check reads/writes reference declared KG lists
if (obj.knowledgeLists) {
for (const listKey of (wf.reads ?? [])) {
if (!knowledgeListKeys.has(listKey)) {
err(`${loc}.reads`, `References list "${listKey}" not declared in knowledgeLists`);
}
}
for (const listKey of (wf.writes ?? [])) {
if (!knowledgeListKeys.has(listKey)) {
err(`${loc}.writes`, `References list "${listKey}" not declared in knowledgeLists`);
}
}
}
}
}
}
// existingWorkflows
if (obj.existingWorkflows !== undefined) {
if (!Array.isArray(obj.existingWorkflows)) {
err('existingWorkflows', 'Must be an array');
}
else {
const deprecations = [];
for (let i = 0; i < obj.existingWorkflows.length; i++) {
const ew = obj.existingWorkflows[i];
const loc = `existingWorkflows[${i}]`;
if (!ew.workflowId)
err(`${loc}.workflowId`, 'Required');
if (ew.workflowId)
existingWorkflowIds.add(ew.workflowId);
if (!ew.action)
err(`${loc}.action`, 'Required. One of: reuse, extend, deprecate, replace');
if (!['reuse', 'extend', 'deprecate', 'replace'].includes(ew.action)) {
err(`${loc}.action`, `Unknown action "${ew.action}". Must be one of: reuse, extend, deprecate, replace`);
}
if (ew.action === 'deprecate' || ew.action === 'replace') {
deprecations.push(ew.workflowId ?? loc);
}
}
if (deprecations.length > 0) {
warn('existingWorkflows', `${deprecations.length} workflow(s) marked for deprecation/replace: ${deprecations.join(', ')}. Confirm this is intentional.`);
}
}
}
// buildOrder
if (obj.buildOrder !== undefined) {
if (!Array.isArray(obj.buildOrder)) {
err('buildOrder', 'Must be an array of workflow IDs/slugs');
}
else {
const declared = new Set([...workflowLocalIds]);
for (let i = 0; i < obj.buildOrder.length; i++) {
const ref = obj.buildOrder[i];
// callsWorkflows references that come before their dependency
if (!declared.has(ref)) {
warn(`buildOrder[${i}]`, `"${ref}" is not declared in workflows[].id — verify the slug matches`);
}
}
}
}
// integrations
if (obj.integrations !== undefined) {
if (typeof obj.integrations !== 'object' || Array.isArray(obj.integrations)) {
err('integrations', 'Must be an object (keys = app IDs)');
}
}
// Check for customer-facing send steps without approvalGates
if (Array.isArray(obj.workflows)) {
for (const wf of obj.workflows) {
if (!wf.approvalGates || wf.approvalGates.length === 0) {
const hasOutreach = (wf.purpose ?? '').toLowerCase().match(/email|outreach|message|send|notify/);
if (hasOutreach) {
warn(`workflows[${wf.id}].approvalGates`, 'Workflow appears to send messages but has no approvalGates defined. Customer-facing sends should require human approval.');
}
}
}
}
// openQuestions
if (Array.isArray(obj.openQuestions)) {
const unanswered = obj.openQuestions.filter((q) => q.status !== 'resolved');
if (unanswered.length > 0) {
warn('openQuestions', `${unanswered.length} open question(s) unresolved. Answer before building.`);
}
}
return errors;
}
const SCAFFOLDS = {
'event-driven-funnel': () => ({
$schema: 'https://schemas.agentled.app/group-manifest/v0.json',
manifestVersion: '0.1.0',
group: {
id: 'my-funnel',
name: 'My Event-Driven Funnel',
description: 'Continuous sourcing → KG enrichment → per-entity activation.',
goal: 'Optimize for conversion, not curation.',
style: { color: '#6366f1', iconName: 'Sparkles' },
},
operatingPrinciples: [
'Sourcing is continuous and broad — not batch per theme.',
'Processing fires per entity when it enters the KG, not on a fixed schedule.',
'Customer-facing messages are approval-gated.',
],
knowledgeLists: {
entities: {
description: 'Entities sourced and tracked through the funnel.',
userKeyField: 'url',
fields: [
{ name: 'name', type: 'string' },
{ name: 'url', type: 'string' },
{ name: 'score', type: 'number' },
{ name: 'status', type: 'string' },
],
producers: ['source-entities'],
consumers: ['enrich-and-score'],
},
},
stateMachines: {
entities: {
states: ['new', 'enriched', 'scored', 'outreached', 'converted', 'rejected'],
initialState: 'new',
transitions: [
{ from: 'new', to: 'enriched', trigger: 'enrich-and-score', description: 'Enrichment completed' },
{ from: 'enriched', to: 'scored', trigger: 'enrich-and-score', description: 'Scoring completed' },
{ from: 'scored', to: 'outreached', trigger: 'send-outreach', description: 'Outreach sent' },
{ from: 'outreached', to: 'converted', trigger: 'process-reply', description: 'Positive reply received' },
{ from: 'scored', to: 'rejected', trigger: 'enrich-and-score', description: 'Score below threshold' },
],
},
},
workflows: [
{
id: 'source-entities',
name: 'Source Entities',
purpose: 'Find entities from external sources and write them to the KG with status: new.',
trigger: 'schedule',
schedule: 'daily',
writes: ['entities'],
creditEstimate: '5-15',
status: 'planned',
},
{
id: 'enrich-and-score',
name: 'Enrich and Score',
purpose: 'Enrich new entities, score against ICP, and update status in KG.',
trigger: 'schedule',
schedule: 'daily',
reads: ['entities'],
writes: ['entities'],
creditEstimate: '10-30',
status: 'planned',
},
{
id: 'send-outreach',
name: 'Send Outreach',
purpose: 'Draft personalized outreach for scored entities and send after approval.',
trigger: 'schedule',
schedule: 'weekly',
reads: ['entities'],
writes: ['entities'],
approvalGates: ['draft-outreach-email'],
creditEstimate: '5-10',
status: 'planned',
},
],
existingWorkflows: [],
integrations: {
'agentled': { required: true, purpose: 'LinkedIn enrichment' },
'kg': { required: true, purpose: 'KG list reads/writes' },
'gmail': { required: true, purpose: 'Outreach email sending' },
},
buildOrder: ['source-entities', 'enrich-and-score', 'send-outreach'],
openQuestions: [
{
question: 'What is the primary success metric: conversion rate, entities contacted per week, or operator time saved?',
impact: 'Determines which step outputs to track in analytics.',
status: 'open',
},
{
question: 'What is the system of record for conversions — a KG status update, a Stripe webhook, or a manual update?',
impact: 'Determines whether a separate webhook listener workflow is needed.',
status: 'open',
},
],
}),
'sourcing-kit': () => ({
$schema: 'https://schemas.agentled.app/group-manifest/v0.json',
manifestVersion: '0.1.0',
group: {
id: 'my-sourcing-kit',
name: 'My Sourcing Kit',
description: 'Multiple source workflows feeding a single canonical KG list.',
goal: 'Build a deduplicated, always-fresh entity list from multiple sources.',
},
knowledgeLists: {
'sourced-entities': {
description: 'Canonical entity list from all sources.',
userKeyField: 'url',
fields: [
{ name: 'name', type: 'string' },
{ name: 'url', type: 'string' },
{ name: 'source', type: 'string' },
{ name: 'status', type: 'string' },
],
producers: ['source-a', 'source-b'],
consumers: ['process-entities'],
},
},
stateMachines: {
'sourced-entities': {
states: ['new', 'processed', 'rejected'],
initialState: 'new',
transitions: [
{ from: 'new', to: 'processed', trigger: 'process-entities' },
{ from: 'new', to: 'rejected', trigger: 'process-entities' },
],
},
},
workflows: [
{ id: 'source-a', name: 'Source A', purpose: 'Source entities from channel A.', trigger: 'schedule', writes: ['sourced-entities'], status: 'planned' },
{ id: 'source-b', name: 'Source B', purpose: 'Source entities from channel B.', trigger: 'schedule', writes: ['sourced-entities'], status: 'planned' },
{ id: 'process-entities', name: 'Process Entities', purpose: 'Enrich and act on new entities.', trigger: 'schedule', reads: ['sourced-entities'], writes: ['sourced-entities'], status: 'planned' },
],
buildOrder: ['source-a', 'source-b', 'process-entities'],
openQuestions: [],
}),
'intake-approval': () => ({
$schema: 'https://schemas.agentled.app/group-manifest/v0.json',
manifestVersion: '0.1.0',
group: {
id: 'my-intake-approval',
name: 'My Intake + Approval',
description: 'Inbound intake → triage → human approval → action.',
goal: 'Process inbound items quickly while keeping humans in the loop for customer-facing actions.',
},
knowledgeLists: {
'intake-items': {
description: 'Inbound items awaiting triage and approval.',
userKeyField: 'id',
fields: [
{ name: 'id', type: 'string' },
{ name: 'source', type: 'string' },
{ name: 'content', type: 'string' },
{ name: 'priority', type: 'string' },
{ name: 'status', type: 'string' },
],
producers: ['intake'],
consumers: ['triage', 'respond'],
},
},
stateMachines: {
'intake-items': {
states: ['new', 'triaged', 'approved', 'responded', 'dismissed'],
initialState: 'new',
transitions: [
{ from: 'new', to: 'triaged', trigger: 'triage' },
{ from: 'triaged', to: 'approved', trigger: 'respond', description: 'Human approves response' },
{ from: 'triaged', to: 'dismissed', trigger: 'respond', description: 'Human dismisses item' },
{ from: 'approved', to: 'responded', trigger: 'respond', description: 'Response sent' },
],
},
},
workflows: [
{ id: 'intake', name: 'Intake', purpose: 'Collect inbound items from email, webhook, or form.', trigger: 'schedule', writes: ['intake-items'], status: 'planned' },
{ id: 'triage', name: 'Triage', purpose: 'Classify priority and draft a response for each new item.', trigger: 'schedule', reads: ['intake-items'], writes: ['intake-items'], status: 'planned' },
{ id: 'respond', name: 'Respond', purpose: 'Present drafted responses for approval, then send.', trigger: 'schedule', reads: ['intake-items'], writes: ['intake-items'], approvalGates: ['send-response'], status: 'planned' },
],
buildOrder: ['intake', 'triage', 'respond'],
openQuestions: [
{
question: 'What is the intake channel: email, webhook, form, or a combination?',
impact: 'Determines trigger type and intake step app action.',
status: 'open',
},
],
}),
};
const PATTERN_DESCRIPTIONS = {
'event-driven-funnel': 'Continuous sourcing → KG enrichment → per-entity activation (outreach, booking, etc.)',
'sourcing-kit': 'Multiple source workflows feeding a single deduplicated canonical KG list',
'intake-approval': 'Inbound intake → AI triage → human approval gate → action',
};
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
export function registerGroupManifestCommands(program) {
const gm = program
.command('group-manifest')
.alias('gm')
.description('Local validation and scaffolding for multi-workflow group manifests');
gm.command('validate <file>')
.description('Validate a group manifest JSON file against the schema')
.action((file) => {
if (!existsSync(file)) {
console.error(`Error: file not found: ${file}`);
process.exit(1);
}
let raw;
try {
raw = readFileSync(file, 'utf-8');
}
catch (e) {
console.error(`Error reading file: ${e.message}`);
process.exit(1);
}
let obj;
try {
obj = JSON.parse(raw);
}
catch (e) {
console.error(`Invalid JSON: ${e.message}`);
process.exit(1);
}
const results = validateManifest(obj);
const errors = results.filter(r => r.severity === 'error');
const warnings = results.filter(r => r.severity === 'warning');
if (errors.length === 0 && warnings.length === 0) {
console.log(`✓ ${file} — valid group manifest`);
return;
}
if (errors.length > 0) {
console.error(`\n${errors.length} error(s):\n`);
for (const e of errors) {
console.error(` [error] ${e.path}: ${e.message}`);
}
}
if (warnings.length > 0) {
console.warn(`\n${warnings.length} warning(s):\n`);
for (const w of warnings) {
console.warn(` [warn] ${w.path}: ${w.message}`);
}
}
if (errors.length > 0) {
console.error(`\nValidation failed with ${errors.length} error(s).`);
process.exit(1);
}
else {
console.log(`\nValid with ${warnings.length} warning(s). Review warnings before building.`);
}
});
gm.command('scaffold [pattern]')
.description('Emit a starter group manifest for a named pattern')
.option('--out <file>', 'Write output to a file instead of stdout')
.option('--list', 'List available scaffold patterns')
.action((pattern, opts) => {
if (opts.list || !pattern || pattern === 'list') {
console.log('Available group-manifest scaffold patterns:\n');
for (const [key, desc] of Object.entries(PATTERN_DESCRIPTIONS)) {
console.log(` ${key.padEnd(24)} ${desc}`);
}
console.log('\nUsage: agentled group-manifest scaffold <pattern> [--out <file>]');
return;
}
const key = pattern;
if (!SCAFFOLDS[key]) {
console.error(`Unknown pattern "${pattern}". Run \`agentled group-manifest scaffold --list\` to see available patterns.`);
process.exit(1);
}
const manifest = SCAFFOLDS[key]();
const json = JSON.stringify(manifest, null, 2);
if (opts.out) {
try {
writeFileSync(opts.out, json + '\n', 'utf-8');
console.log(`Scaffold written to ${opts.out}`);
console.log(`\nNext steps:`);
console.log(` 1. Edit ${opts.out} — fill in workspace IDs, list schemas, and open questions`);
console.log(` 2. agentled group-manifest validate ${opts.out}`);
console.log(` 3. Build workflows in the order listed in buildOrder[]`);
}
catch (e) {
console.error(`Error writing file: ${e.message}`);
process.exit(1);
}
}
else {
console.log(json);
}
});
}
//# sourceMappingURL=group-manifest.js.map