@agentled/cli
Version:
CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.
429 lines (397 loc) • 18.9 kB
JavaScript
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { findWorkspaceDir } from '../utils/workspace-folder.js';
import { printError, printOutput } from '../utils/output.js';
const defaultClient = (slug, name) => ({
name: name ?? slug,
stage: 'active-sprint',
workspaceAlias: slug,
primaryWorkflowIds: [],
goals: [],
risks: [],
nextActions: [],
lastClientUpdate: null,
});
const DOSSIER_GUIDANCE = `# Client Dossier Operating Guide
This dossier is local-first and DFE-facing. Keep it practical and execution-oriented.
## What goes here (DFE operating trail)
- Client context in \`client.json\`
- POCs in \`contacts.json\`
- Meeting notes in \`meetings/\`
- Decisions and approvals in \`decisions/\`
- Feedback snapshots in \`feedback/\`
- Draft replies/emails in \`replies/\`
- Validation evidence in \`validation/\`
## What should NOT go here
- Full implementation design docs for reusable platform architecture
- Long-form ADRs or general system design notes
- Prompt experiments unrelated to this specific client outcome
## Separation from KG text / design docs
- \`kg.text\` should contain durable, reusable product or workflow knowledge.
- Client dossiers should contain engagement execution context (who asked what, when, and what was approved).
- If a client-specific decision becomes reusable, summarize it into a design doc/KG entry and link back to the originating meeting or decision file.
`;
function meetingTemplate(params) {
return `# ${params.title}
Date: ${params.date}
Client: ${params.clientName}
Attendees:
-
## Summary
## Client Feedback
## Decisions
## Action Items
## Workflow Changes Requested
## Follow-Up Email Draft
`;
}
const DECISION_TEMPLATE = `# Decision
Date:
Client:
Owner:
Status: proposed
## Context
## Decision
## Rationale
## Impacted Workflows
## Follow-Ups
`;
const FEEDBACK_TEMPLATE = `# Client Feedback
Date:
Source:
Workflow IDs:
## Feedback
## Requested Changes
## Acceptance Notes
## Open Questions
`;
const REPLY_TEMPLATE = `# Draft Reply
Date:
To:
Topic:
## What changed
## What still needs validation
## What we need from the client
`;
const VALIDATION_TEMPLATE = `# Validation Notes
Date:
Workflow IDs:
Execution IDs:
## What was tested
## Result
## Evidence
## Blockers
## Client confirmation needed
`;
function slugify(s) {
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function normalizeClientSlug(slug) {
if (slug.includes('..') || /[\\/]/.test(slug)) {
printError(`Invalid client slug: ${slug}`);
}
const normalized = slugify(slug);
if (!normalized)
printError(`Invalid client slug: ${slug}`);
return normalized;
}
function requireWorkspace() {
const wsDir = findWorkspaceDir(process.cwd());
if (!wsDir)
printError('No Agentled workspace folder found. Run this from an agentled_<slug> folder.');
return wsDir;
}
function resolveClientsRoot() {
return join(requireWorkspace(), 'clients');
}
function resolveClientDir(slug) {
return join(resolveClientsRoot(), normalizeClientSlug(slug));
}
function ensureClientDir(slug) {
const dir = resolveClientDir(slug);
mkdirSync(dir, { recursive: true });
return dir;
}
function readJson(path) {
if (!existsSync(path))
return null;
return JSON.parse(readFileSync(path, 'utf-8'));
}
function readJsonArray(path) {
const raw = readJson(path);
return Array.isArray(raw) ? raw : [];
}
function dateStamp() {
return new Date().toISOString().slice(0, 10);
}
function latestFile(dir, extensions = ['.md', '.json']) {
if (!existsSync(dir))
return null;
const files = readdirSync(dir).filter((f) => !f.startsWith('_') && extensions.some((ext) => f.endsWith(ext))).sort();
return files.length ? files[files.length - 1] : null;
}
function countAuthoredFiles(dir, extensions = ['.md', '.json']) {
if (!existsSync(dir))
return 0;
return readdirSync(dir).filter((f) => !f.startsWith('_') && f !== 'README.md' && extensions.some((ext) => f.endsWith(ext))).length;
}
function latestFilePath(dir, extensions = ['.md', '.json']) {
const file = latestFile(dir, extensions);
return file ? join(dir, file) : null;
}
function countDepartmentPocProfiles(clientDir) {
const departmentsDir = join(clientDir, 'departments');
if (!existsSync(departmentsDir))
return { departments: 0, pocProfiles: 0 };
const departmentDirs = readdirSync(departmentsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
let profiles = 0;
for (const dep of departmentDirs) {
const pocDir = join(departmentsDir, dep.name, 'pocs');
if (!existsSync(pocDir))
continue;
profiles += readdirSync(pocDir).filter((file) => file.endsWith('_poc_profile.md')).length;
}
return { departments: departmentDirs.length, pocProfiles: profiles };
}
export function registerClientsCommands(program) {
const clients = program.command('clients').description('Manage local client dossiers and delivery ops in agentled workspace folders.');
clients
.command('init')
.argument('<slug>')
.option('--name <name>')
.option('--force', 'Overwrite existing files', false)
.option('--format <fmt>', 'Output format', 'json')
.action((slug, opts) => {
const clientSlug = normalizeClientSlug(slug);
const dir = resolveClientDir(clientSlug);
if (existsSync(dir) && !opts.force)
printError(`Client dossier already exists: ${dir}. Use --force to overwrite.`);
mkdirSync(join(dir, 'meetings'), { recursive: true });
mkdirSync(join(dir, 'decisions'), { recursive: true });
mkdirSync(join(dir, 'feedback'), { recursive: true });
mkdirSync(join(dir, 'replies'), { recursive: true });
mkdirSync(join(dir, 'validation'), { recursive: true });
mkdirSync(join(dir, 'departments', 'general', 'pocs'), { recursive: true });
writeFileSync(join(dir, 'client.json'), JSON.stringify(defaultClient(clientSlug, opts.name), null, 2) + '\n');
writeFileSync(join(dir, 'contacts.json'), JSON.stringify([], null, 2) + '\n');
writeFileSync(join(dir, 'workflows.json'), JSON.stringify([], null, 2) + '\n');
writeFileSync(join(dir, 'README.md'), DOSSIER_GUIDANCE + '\n');
writeFileSync(join(dir, 'meetings', '_TEMPLATE.md'), meetingTemplate({ title: 'Weekly Workflow Review', date: 'YYYY-MM-DD', clientName: opts.name ?? clientSlug }));
writeFileSync(join(dir, 'decisions', '_TEMPLATE.md'), DECISION_TEMPLATE);
writeFileSync(join(dir, 'feedback', '_TEMPLATE.md'), FEEDBACK_TEMPLATE);
writeFileSync(join(dir, 'replies', '_TEMPLATE.md'), REPLY_TEMPLATE);
writeFileSync(join(dir, 'validation', '_TEMPLATE.md'), VALIDATION_TEMPLATE);
writeFileSync(join(dir, 'replies', 'README.md'), '# Reply drafts\n\nStore draft client replies and emails as Markdown files.\n');
writeFileSync(join(dir, 'validation', 'README.md'), '# Validation notes\n\nTrack validation evidence and blockers with links to execution IDs/timelines.\n');
writeFileSync(join(dir, 'departments', 'README.md'), '# Departments and POCs\n\nUse one folder per client department/team.\n\nExample structure:\n- departments/sales/pocs/sales_poc_profile.md\n- departments/ops/pocs/ops_poc_profile.md\n');
writeFileSync(join(dir, 'departments', 'general', 'README.md'), '# General department\n\nDefault area for cross-team context before splitting into team folders.\n');
writeFileSync(join(dir, 'departments', 'general', 'pocs', 'general_poc_profile.md'), '# General POC Profile\n\n## Name\n\n## Team / Department\n\n## Role\n\n## Email\n\n## Decision Scope\n\n## Communication Style\n\n## Notes\n');
printOutput({ slug: clientSlug, path: dir, created: true }, (opts.format ?? 'json'));
});
clients.command('list').option('--format <fmt>', 'Output format', 'json').action((opts) => {
const root = resolveClientsRoot();
if (!existsSync(root))
return printOutput([], (opts.format ?? 'json'));
const rows = readdirSync(root, { withFileTypes: true })
.filter((e) => e.isDirectory())
.filter((e) => existsSync(join(root, e.name, 'client.json')))
.map((e) => {
const dir = join(root, e.name);
const c = readJson(join(dir, 'client.json'));
return { slug: e.name, name: c?.name ?? e.name, stage: c?.stage ?? 'unknown', lastMeeting: latestFile(join(dir, 'meetings')), openNextActions: c?.nextActions?.length ?? 0 };
});
printOutput(rows, (opts.format ?? 'json'));
});
clients.command('status').argument('<slug>').option('--format <fmt>', 'Output format', 'json').action((slug, opts) => {
const clientSlug = normalizeClientSlug(slug);
const dir = resolveClientDir(clientSlug);
if (!existsSync(dir))
printError(`Client dossier not found: ${clientSlug}`);
const client = readJson(join(dir, 'client.json'));
const contacts = readJsonArray(join(dir, 'contacts.json'));
const workflows = readJsonArray(join(dir, 'workflows.json'));
const pocStats = countDepartmentPocProfiles(dir);
const contactSummary = contacts.map((contact) => ({
name: typeof contact.name === 'string' ? contact.name : '',
role: typeof contact.role === 'string' ? contact.role : '',
email: typeof contact.email === 'string' ? contact.email : '',
decisionMaker: Boolean(contact.decisionMaker),
}));
printOutput({
client,
contactsCount: contacts.length,
contacts: contactSummary,
latestMeeting: latestFile(join(dir, 'meetings')),
departmentsRoot: join(dir, 'departments'),
latestFeedback: latestFile(join(dir, 'feedback')),
openDecisions: countAuthoredFiles(join(dir, 'decisions')),
linkedWorkflows: workflows.length,
primaryWorkflowIds: client?.primaryWorkflowIds ?? [],
departments: pocStats.departments,
pocProfiles: pocStats.pocProfiles,
}, (opts.format ?? 'json'));
});
clients.command('brief').argument('<slug>').option('--format <fmt>', 'Output format', 'json').action((slug, opts) => {
const clientSlug = normalizeClientSlug(slug);
const dir = resolveClientDir(clientSlug);
if (!existsSync(dir))
printError(`Client dossier not found: ${clientSlug}`);
const client = readJson(join(dir, 'client.json'));
const contacts = readJsonArray(join(dir, 'contacts.json'));
const lines = [
`Client: ${client?.name ?? clientSlug} (${clientSlug})`,
`Stage: ${client?.stage ?? 'unknown'}`,
`Goals: ${client?.goals?.join('; ') || 'none'}`,
`Risks: ${client?.risks?.join('; ') || 'none'}`,
`Next actions: ${client?.nextActions?.join('; ') || 'none'}`,
`Primary contacts: ${contacts.map((c) => `${c.name} (${c.role})`).join('; ') || 'none'}`,
`Latest meeting: ${latestFile(join(dir, 'meetings')) ?? 'none'}`,
'Scope: This brief is DFE client-operations context (not a platform design doc).',
];
printOutput({ brief: lines.join('\n') }, (opts.format ?? 'json'));
});
const meeting = clients.command('meeting').description('Manage client meeting notes.');
meeting
.command('new')
.argument('<slug>')
.requiredOption('--title <title>')
.option('--date <date>')
.option('--format <fmt>', 'Output format', 'json')
.action((slug, opts) => {
const clientSlug = normalizeClientSlug(slug);
const dir = resolveClientDir(clientSlug);
if (!existsSync(dir))
printError(`Client dossier not found: ${clientSlug}`);
const client = readJson(join(dir, 'client.json'));
const d = opts.date ?? dateStamp();
const file = `${d}-${slugify(opts.title)}.md`;
const body = meetingTemplate({ title: opts.title, date: d, clientName: client?.name ?? clientSlug });
const path = join(dir, 'meetings', file);
writeFileSync(path, body);
printOutput({ created: path }, (opts.format ?? 'json'));
});
const feedback = clients.command('feedback').description('Feedback capture for a client dossier');
feedback
.command('add <slug>')
.requiredOption('--from <name>', 'Feedback source/contact')
.option('--workflow <id...>', 'Linked workflow ID(s)')
.option('--step <id...>', 'Linked step ID(s)')
.option('--file <path>', 'Path to markdown notes to embed')
.option('--requested <text...>', 'Requested changes')
.option('--acceptance <text...>', 'Acceptance notes')
.option('--question <text...>', 'Open questions')
.option('--format <fmt>', 'Output format', 'json')
.action(async (slug, opts) => {
try {
const clientDir = ensureClientDir(slug);
const feedbackDir = join(clientDir, 'feedback');
mkdirSync(feedbackDir, { recursive: true });
const fileName = `${dateStamp()}-${slugify(opts.from)}.md`;
const outPath = join(feedbackDir, fileName);
const imported = opts.file ? readFileSync(opts.file, 'utf-8').trim() : '';
const body = [
'# Client Feedback',
'',
`- Source: ${opts.from}`,
`- Date: ${dateStamp()}`,
`- Workflow IDs: ${(opts.workflow ?? []).join(', ') || 'N/A'}`,
`- Step IDs: ${(opts.step ?? []).join(', ') || 'N/A'}`,
'',
'## Requested changes',
...((opts.requested ?? ['None provided']).map((x) => `- ${x}`)),
'',
'## Acceptance notes',
...((opts.acceptance ?? ['None provided']).map((x) => `- ${x}`)),
'',
'## Open questions',
...((opts.question ?? ['None provided']).map((x) => `- ${x}`)),
'',
'## Raw notes',
imported || '_No raw notes attached._',
'',
].join('\n');
writeFileSync(outPath, body);
printOutput({ ok: true, path: outPath }, (opts.format ?? 'json'));
}
catch (error) {
printError(error instanceof Error ? error.message : String(error));
}
});
clients.command('reply <slug>').option('--about <topic>', 'Reply topic/context').option('--out <path>', 'Write draft reply to a file').option('--format <fmt>', 'Output format', 'json').action(async (slug, opts) => {
try {
const clientSlug = normalizeClientSlug(slug);
const clientDir = ensureClientDir(clientSlug);
const client = readJson(join(clientDir, 'client.json'));
const contacts = readJsonArray(join(clientDir, 'contacts.json'));
const decisionsPath = latestFilePath(join(clientDir, 'decisions'));
const feedbackPath = latestFilePath(join(clientDir, 'feedback'), ['.md']);
const draft = [
`# Draft reply (${dateStamp()})`,
'',
`Hi ${contacts[0]?.name ?? 'team'},`,
'',
`Thanks for the latest feedback${opts.about ? ` about ${opts.about}` : ''}.`,
'',
'## What we changed',
'- Reviewed latest workflow/test status and incorporated requested updates.',
'',
'## What is pending validation',
'- Final client-side confirmation and any browser/manual verification notes.',
'',
'## What we need from you',
'- Confirm acceptance criteria and any priority changes for the next sprint.',
'',
`Context: client=${client?.name ?? clientSlug}; latestFeedback=${feedbackPath ?? 'none'}; latestDecision=${decisionsPath ?? 'none'}.`,
'',
].join('\n');
if (opts.out)
writeFileSync(opts.out, draft);
printOutput({ ok: true, draft, out: opts.out ?? null }, (opts.format ?? 'json'));
}
catch (error) {
printError(error instanceof Error ? error.message : String(error));
}
});
clients.command('validate <slug>').option('--notes <path>', 'Manual/browser validation notes file').option('--format <fmt>', 'Output format', 'json').action(async (slug, opts) => {
try {
const clientSlug = normalizeClientSlug(slug);
const clientDir = ensureClientDir(clientSlug);
const workflows = readJsonArray(join(clientDir, 'workflows.json'));
const testsDir = join(requireWorkspace(), 'tests');
const testFiles = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith('.test.json')) : [];
const notes = opts.notes && existsSync(opts.notes) ? readFileSync(opts.notes, 'utf-8').trim() : 'No manual notes provided.';
const outDir = join(clientDir, 'validation');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, `${dateStamp()}-validation-report.md`);
const md = [
`# Validation report (${dateStamp()})`,
'',
'## What was tested',
`- Linked workflows: ${workflows.length}`,
`- Local test files: ${testFiles.length}`,
'',
'## Passed/failed summary',
'- Local report generation succeeded. Run `agentled test <workflowId>` for assertion-level pass/fail.',
'',
'## Needs client confirmation',
'- UI/browser walkthrough and business acceptance review.',
'',
'## Evidence',
...workflows.map((w) => `- Workflow: ${w.workflowId ?? w.id ?? 'unknown'}; latestExecutionId: ${w.latestExecutionId ?? 'n/a'}`),
'',
'## Manual/browser notes',
notes,
'',
'## DFE recommendation',
'- Prepare a concise client-facing recap after the client confirms acceptance criteria.',
'',
].join('\n');
writeFileSync(outPath, md);
printOutput({ ok: true, path: outPath }, (opts.format ?? 'json'));
}
catch (error) {
printError(error instanceof Error ? error.message : String(error));
}
});
}
//# sourceMappingURL=clients.js.map