UNPKG

@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
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