/ src / cli / index.js
index.js
   1  'use strict'
   2  
   3  /* eslint-disable @typescript-eslint/no-require-imports */
   4  
   5  const fs = require('fs')
   6  const path = require('path')
   7  
   8  function cmd(action, method, route, description, extra = {}) {
   9    return { action, method, route, description, ...extra }
  10  }
  11  
  12  const COMMAND_GROUPS = [
  13    {
  14      name: 'agents',
  15      description: 'Manage agents',
  16      commands: [
  17        cmd('list', 'GET', '/agents', 'List agents'),
  18        cmd('get', 'GET', '/agents/:id', 'Get an agent by id'),
  19        cmd('create', 'POST', '/agents', 'Create an agent', { expectsJsonBody: true }),
  20        cmd('update', 'PUT', '/agents/:id', 'Update an agent', { expectsJsonBody: true }),
  21        cmd('delete', 'DELETE', '/agents/:id', 'Delete an agent'),
  22        cmd('trash', 'GET', '/agents/trash', 'List trashed agents'),
  23        cmd('restore', 'POST', '/agents/trash', 'Restore a trashed agent', { expectsJsonBody: true }),
  24        cmd('purge', 'DELETE', '/agents/trash', 'Permanently delete a trashed agent', { expectsJsonBody: true }),
  25        cmd('thread', 'POST', '/agents/:id/thread', 'Get or create agent thread session'),
  26        cmd('clone', 'POST', '/agents/:id/clone', 'Clone an agent'),
  27        cmd('bulk-update', 'PATCH', '/agents/bulk', 'Bulk update agents', { expectsJsonBody: true }),
  28        cmd('status', 'GET', '/agents/:id/status', 'Get live status for an agent'),
  29        cmd('dream', 'GET', '/agents/:id/dream', 'Get agent dream config and recent cycles'),
  30        cmd('dream-update', 'PATCH', '/agents/:id/dream', 'Update agent dream config', { expectsJsonBody: true }),
  31      ],
  32    },
  33    {
  34      name: 'activity',
  35      description: 'Query activity feed events',
  36      commands: [
  37        cmd('list', 'GET', '/activity', 'List activity events (use --query limit=50, --query entityType=task, --query action=updated)'),
  38      ],
  39    },
  40    {
  41      name: 'auth',
  42      description: 'Access key auth helpers',
  43      commands: [
  44        cmd('status', 'GET', '/auth', 'Check auth setup status'),
  45        cmd('login', 'POST', '/auth', 'Validate an access key', {
  46          expectsJsonBody: true,
  47          bodyFlagMap: { key: 'key' },
  48        }),
  49      ],
  50    },
  51    {
  52      name: 'autonomy',
  53      description: 'Inspect supervisor incidents and reflection output',
  54      commands: [
  55        cmd('incidents', 'GET', '/autonomy/incidents', 'List supervisor incidents (use --query sessionId=..., --query taskId=..., --query limit=50)'),
  56        cmd('reflections', 'GET', '/autonomy/reflections', 'List run reflections (use --query sessionId=..., --query taskId=..., --query limit=50)'),
  57        cmd('estop', 'GET', '/autonomy/estop', 'Get autonomy emergency-stop state'),
  58        cmd('estop-set', 'POST', '/autonomy/estop', 'Engage or resume autonomy emergency-stop state', { expectsJsonBody: true }),
  59        cmd('guardian-restore', 'POST', '/autonomy/guardian/restore', 'Restore the latest guardian checkpoint after approval', { expectsJsonBody: true }),
  60      ],
  61    },
  62    {
  63      name: 'approvals',
  64      description: 'List and resolve human-loop approvals',
  65      commands: [
  66        cmd('list', 'GET', '/approvals', 'List pending human-loop approvals'),
  67        cmd('resolve', 'POST', '/approvals', 'Resolve a human-loop approval', { expectsJsonBody: true }),
  68      ],
  69    },
  70    {
  71      name: 'claude-skills',
  72      description: 'Read local Claude skills directory metadata',
  73      commands: [
  74        cmd('list', 'GET', '/claude-skills', 'List Claude skills discovered on host'),
  75      ],
  76    },
  77    {
  78      name: 'clawhub',
  79      description: 'Browse and install ClawHub skills',
  80      commands: [
  81        cmd('search', 'GET', '/clawhub/search', 'Search ClawHub skills catalog'),
  82        cmd('preview', 'POST', '/clawhub/preview', 'Preview a ClawHub skill install without writing files', { expectsJsonBody: true }),
  83        cmd('install', 'POST', '/clawhub/install', 'Install a skill from ClawHub', { expectsJsonBody: true }),
  84      ],
  85    },
  86    {
  87      name: 'chatrooms',
  88      description: 'Manage multi-agent chatrooms',
  89      commands: [
  90        cmd('list', 'GET', '/chatrooms', 'List chatrooms'),
  91        cmd('get', 'GET', '/chatrooms/:id', 'Get chatroom by id'),
  92        cmd('create', 'POST', '/chatrooms', 'Create a chatroom', { expectsJsonBody: true }),
  93        cmd('update', 'PUT', '/chatrooms/:id', 'Update a chatroom', { expectsJsonBody: true }),
  94        cmd('delete', 'DELETE', '/chatrooms/:id', 'Delete a chatroom'),
  95        cmd('chat', 'POST', '/chatrooms/:id/chat', 'Post a message to a chatroom and stream agent replies', {
  96          expectsJsonBody: true,
  97          responseType: 'sse',
  98        }),
  99        cmd('add-member', 'POST', '/chatrooms/:id/members', 'Add an agent to a chatroom (use --data \'{"agentId":"..."}\')', { expectsJsonBody: true }),
 100        cmd('remove-member', 'DELETE', '/chatrooms/:id/members', 'Remove an agent from a chatroom (use --data \'{"agentId":"..."}\')', { expectsJsonBody: true }),
 101        cmd('react', 'POST', '/chatrooms/:id/reactions', 'Toggle a reaction on a chatroom message', {
 102          expectsJsonBody: true,
 103        }),
 104        cmd('pin', 'POST', '/chatrooms/:id/pins', 'Toggle pin on a chatroom message', {
 105          expectsJsonBody: true,
 106        }),
 107        cmd('moderate', 'POST', '/chatrooms/:id/moderate', 'Run moderation action on a chatroom', { expectsJsonBody: true }),
 108      ],
 109    },
 110    {
 111      name: 'connectors',
 112      description: 'Manage chat connectors',
 113      commands: [
 114        cmd('list', 'GET', '/connectors', 'List connectors'),
 115        cmd('get', 'GET', '/connectors/:id', 'Get connector'),
 116        cmd('create', 'POST', '/connectors', 'Create connector', { expectsJsonBody: true }),
 117        cmd('update', 'PUT', '/connectors/:id', 'Update connector', { expectsJsonBody: true }),
 118        cmd('delete', 'DELETE', '/connectors/:id', 'Delete connector'),
 119        cmd('webhook', 'POST', '/connectors/:id/webhook', 'Trigger connector webhook ingress', { expectsJsonBody: true }),
 120        cmd('start', 'PUT', '/connectors/:id', 'Start connector', {
 121          expectsJsonBody: true,
 122          defaultBody: { action: 'start' },
 123        }),
 124        cmd('stop', 'PUT', '/connectors/:id', 'Stop connector', {
 125          expectsJsonBody: true,
 126          defaultBody: { action: 'stop' },
 127        }),
 128        cmd('repair', 'PUT', '/connectors/:id', 'Repair connector', {
 129          expectsJsonBody: true,
 130          defaultBody: { action: 'repair' },
 131        }),
 132        cmd('health', 'GET', '/connectors/:id/health', 'Get connector health status'),
 133        cmd('access-get', 'GET', '/connectors/:id/access', 'Get connector access and ownership state'),
 134        cmd('access-set', 'PUT', '/connectors/:id/access', 'Update connector access and ownership state', { expectsJsonBody: true }),
 135        cmd('doctor', 'GET', '/connectors/:id/doctor', 'Get connector doctor diagnostics'),
 136        cmd('doctor-preview', 'POST', '/connectors/:id/doctor', 'Preview connector doctor diagnostics with temporary overrides', { expectsJsonBody: true }),
 137        cmd('doctor-draft', 'POST', '/connectors/doctor', 'Preview connector doctor diagnostics before saving a connector', { expectsJsonBody: true }),
 138      ],
 139    },
 140    {
 141      name: 'credentials',
 142      description: 'Manage encrypted provider credentials',
 143      commands: [
 144        cmd('list', 'GET', '/credentials', 'List credentials'),
 145        cmd('get', 'GET', '/credentials/:id', 'Get credential metadata by id'),
 146        cmd('create', 'POST', '/credentials', 'Create credential', { expectsJsonBody: true }),
 147        cmd('delete', 'DELETE', '/credentials/:id', 'Delete credential'),
 148      ],
 149    },
 150    {
 151      name: 'daemon',
 152      description: 'Control background daemon',
 153      commands: [
 154        cmd('status', 'GET', '/daemon', 'Get daemon status'),
 155        cmd('action', 'POST', '/daemon', 'Set daemon action via JSON body', { expectsJsonBody: true }),
 156        cmd('start', 'POST', '/daemon', 'Start daemon', {
 157          expectsJsonBody: true,
 158          defaultBody: { action: 'start' },
 159        }),
 160        cmd('stop', 'POST', '/daemon', 'Stop daemon', {
 161          expectsJsonBody: true,
 162          defaultBody: { action: 'stop' },
 163        }),
 164        cmd('health-check', 'POST', '/daemon/health-check', 'Run daemon health checks immediately'),
 165      ],
 166    },
 167    {
 168      name: 'delegation-jobs',
 169      description: 'Delegation job status',
 170      commands: [
 171        cmd('list', 'GET', '/delegation-jobs', 'List active and recent delegation jobs'),
 172      ],
 173    },
 174    {
 175      name: 'dirs',
 176      description: 'Directory listing and native picker',
 177      commands: [
 178        cmd('list', 'GET', '/dirs', 'List directories (use --query path=/abs/path)'),
 179        cmd('pick', 'POST', '/dirs/pick', 'Open native picker (mode=file|folder)', { expectsJsonBody: true }),
 180      ],
 181    },
 182    {
 183      name: 'perf',
 184      description: 'Inspect or control runtime perf tracing',
 185      commands: [
 186        cmd('status', 'GET', '/perf', 'Get current perf tracing status and recent entries'),
 187        cmd('enable', 'POST', '/perf', 'Enable perf tracing and clear existing entries', {
 188          expectsJsonBody: true,
 189          defaultBody: { action: 'enable' },
 190        }),
 191        cmd('disable', 'POST', '/perf', 'Disable perf tracing', {
 192          expectsJsonBody: true,
 193          defaultBody: { action: 'disable' },
 194        }),
 195        cmd('clear', 'POST', '/perf', 'Clear recent perf entries', {
 196          expectsJsonBody: true,
 197          defaultBody: { action: 'clear' },
 198        }),
 199      ],
 200    },
 201    {
 202      name: 'documents',
 203      description: 'Manage documents',
 204      commands: [
 205        cmd('list', 'GET', '/documents', 'List documents'),
 206        cmd('get', 'GET', '/documents/:id', 'Get document by id'),
 207        cmd('create', 'POST', '/documents', 'Create document', { expectsJsonBody: true }),
 208        cmd('update', 'PUT', '/documents/:id', 'Update document', { expectsJsonBody: true }),
 209        cmd('delete', 'DELETE', '/documents/:id', 'Delete document'),
 210        cmd('revisions', 'GET', '/documents/:id/revisions', 'List document revisions'),
 211      ],
 212    },
 213    {
 214      name: 'eval',
 215      description: 'Run agent evaluation scenarios',
 216      commands: [
 217        cmd('scenarios', 'GET', '/eval/scenarios', 'List available eval scenarios'),
 218        cmd('suites', 'GET', '/eval/suites', 'List available eval suites (core, swe-bench-lite, gaia-l1, ...)'),
 219        cmd('status', 'GET', '/eval/run', 'Get eval run status'),
 220        cmd('run', 'POST', '/eval/run', 'Run an eval scenario against an agent', { expectsJsonBody: true }),
 221        cmd('suite', 'POST', '/eval/suite', 'Run a full eval suite against an agent (pass { suite: "swe-bench-lite" } in body)', { expectsJsonBody: true }),
 222      ],
 223    },
 224    {
 225      name: 'a2a',
 226      description: 'A2A Protocol gateway',
 227      commands: [
 228        cmd('send', 'POST', '/a2a', 'Send a JSON-RPC request to the A2A endpoint', { expectsJsonBody: true }),
 229        cmd('agent-card', 'GET', '/.well-known/agent-card', 'Get agent card for a SwarmClaw agent'),
 230        cmd('task-status', 'GET', '/a2a/tasks/:taskId/status', 'Check A2A task status'),
 231      ],
 232    },
 233    {
 234      name: 'external-agents',
 235      description: 'Manage external agent runtimes',
 236      commands: [
 237        cmd('list', 'GET', '/external-agents', 'List external agent runtimes'),
 238        cmd('create', 'POST', '/external-agents', 'Register an external agent runtime', { expectsJsonBody: true }),
 239        cmd('update', 'PUT', '/external-agents/:id', 'Update an external agent runtime', { expectsJsonBody: true }),
 240        cmd('delete', 'DELETE', '/external-agents/:id', 'Delete an external agent runtime'),
 241        cmd('heartbeat', 'POST', '/external-agents/:id/heartbeat', 'Record an external agent heartbeat', { expectsJsonBody: true }),
 242      ],
 243    },
 244    {
 245      name: 'files',
 246      description: 'Serve and manage local files',
 247      commands: [
 248        cmd('serve', 'GET', '/files/serve', 'Serve a local file (use --query path=/abs/path)'),
 249        cmd('open', 'POST', '/files/open', 'Open a local file path via the host default app/browser', { expectsJsonBody: true }),
 250      ],
 251    },
 252    {
 253      name: 'gateways',
 254      description: 'Manage named OpenClaw gateway profiles',
 255      commands: [
 256        cmd('list', 'GET', '/gateways', 'List configured gateway profiles'),
 257        cmd('create', 'POST', '/gateways', 'Create a gateway profile', { expectsJsonBody: true }),
 258        cmd('update', 'PUT', '/gateways/:id', 'Update a gateway profile', { expectsJsonBody: true }),
 259        cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
 260        cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
 261      ],
 262    },
 263    {
 264      name: 'ip',
 265      description: 'Get local IP/port metadata',
 266      commands: [
 267        cmd('get', 'GET', '/ip', 'Get host IP and port'),
 268      ],
 269    },
 270    {
 271      name: 'knowledge',
 272      description: 'Manage knowledge base entries',
 273      commands: [
 274        cmd('list', 'GET', '/knowledge', 'List knowledge entries'),
 275        cmd('get', 'GET', '/knowledge/:id', 'Get knowledge entry by id'),
 276        cmd('create', 'POST', '/knowledge', 'Create knowledge entry', { expectsJsonBody: true }),
 277        cmd('update', 'PUT', '/knowledge/:id', 'Update knowledge entry', { expectsJsonBody: true }),
 278        cmd('delete', 'DELETE', '/knowledge/:id', 'Delete knowledge entry'),
 279        cmd('upload', 'POST', '/knowledge/upload', 'Upload document for knowledge extraction', {
 280          requestType: 'upload',
 281          inputPositional: 'filePath',
 282        }),
 283        cmd('hygiene', 'GET', '/knowledge/hygiene', 'Get knowledge hygiene summary'),
 284        cmd('hygiene-run', 'POST', '/knowledge/hygiene', 'Run knowledge hygiene maintenance'),
 285        cmd('sources', 'GET', '/knowledge/sources', 'List knowledge sources'),
 286        cmd('source-create', 'POST', '/knowledge/sources', 'Create knowledge source', { expectsJsonBody: true }),
 287        cmd('source-get', 'GET', '/knowledge/sources/:id', 'Get knowledge source detail'),
 288        cmd('source-update', 'PUT', '/knowledge/sources/:id', 'Update knowledge source', { expectsJsonBody: true }),
 289        cmd('source-delete', 'DELETE', '/knowledge/sources/:id', 'Delete knowledge source'),
 290        cmd('source-archive', 'POST', '/knowledge/sources/:id/archive', 'Archive knowledge source', { expectsJsonBody: true }),
 291        cmd('source-restore', 'POST', '/knowledge/sources/:id/restore', 'Restore archived knowledge source'),
 292        cmd('source-supersede', 'POST', '/knowledge/sources/:id/supersede', 'Mark source as superseded', { expectsJsonBody: true }),
 293        cmd('source-sync', 'POST', '/knowledge/sources/:id/sync', 'Re-sync file/URL knowledge source'),
 294      ],
 295    },
 296    {
 297      name: 'logs',
 298      description: 'Read or clear app logs',
 299      commands: [
 300        cmd('list', 'GET', '/logs', 'List logs (use --query lines=200, --query level=INFO,ERROR)'),
 301        cmd('clear', 'DELETE', '/logs', 'Clear logs file'),
 302        cmd('report', 'POST', '/logs', 'Write a client/browser error entry to the application log', {
 303          expectsJsonBody: true,
 304        }),
 305      ],
 306    },
 307    {
 308      name: 'memory',
 309      description: 'Manage memory entries',
 310      commands: [
 311        cmd('list', 'GET', '/memory', 'List memory entries (use --query q=, --query agentId=)'),
 312        cmd('get', 'GET', '/memory/:id', 'Get memory by id'),
 313        cmd('create', 'POST', '/memory', 'Create memory entry', { expectsJsonBody: true }),
 314        cmd('update', 'PUT', '/memory/:id', 'Update memory entry', { expectsJsonBody: true }),
 315        cmd('delete', 'DELETE', '/memory/:id', 'Delete memory entry'),
 316        cmd('maintenance', 'GET', '/memory/maintenance', 'Analyze memory dedupe/prune candidates'),
 317        cmd('maintenance-run', 'POST', '/memory/maintenance', 'Run memory dedupe/prune maintenance', { expectsJsonBody: true }),
 318        cmd('graph', 'GET', '/memory/graph', 'Get memory graph (nodes and links) for visualization'),
 319        cmd('dream', 'GET', '/memory/dream', 'List dream cycles'),
 320        cmd('dream-trigger', 'POST', '/memory/dream', 'Trigger a dream cycle', { expectsJsonBody: true }),
 321        cmd('dream-get', 'GET', '/memory/dream/:id', 'Get dream cycle by id'),
 322      ],
 323    },
 324    {
 325      name: 'memory-images',
 326      description: 'Fetch stored memory image assets',
 327      commands: [
 328        cmd('get', 'GET', '/memory-images/:filename', 'Download memory image by filename', { responseType: 'binary' }),
 329      ],
 330    },
 331  
 332    {
 333      name: 'notifications',
 334      description: 'Manage in-app notifications',
 335      commands: [
 336        cmd('list', 'GET', '/notifications', 'List notifications (use --query unreadOnly=true --query limit=100)'),
 337        cmd('create', 'POST', '/notifications', 'Create notification', { expectsJsonBody: true }),
 338        cmd('clear', 'DELETE', '/notifications', 'Clear read notifications'),
 339        cmd('mark-read', 'PUT', '/notifications/:id', 'Mark notification as read'),
 340        cmd('delete', 'DELETE', '/notifications/:id', 'Delete notification by id'),
 341      ],
 342    },
 343    {
 344      name: 'protocols',
 345      description: 'Manage Structured Session runs and templates',
 346      commands: [
 347        cmd('list', 'GET', '/protocols/runs', 'List structured session runs'),
 348        cmd('get', 'GET', '/protocols/runs/:id', 'Get structured session run detail'),
 349        cmd('events', 'GET', '/protocols/runs/:id/events', 'Get structured session run events'),
 350        cmd('create', 'POST', '/protocols/runs', 'Create a structured session run', { expectsJsonBody: true }),
 351        cmd('action', 'POST', '/protocols/runs/:id/actions', 'Run a structured session action', { expectsJsonBody: true }),
 352        cmd('templates', 'GET', '/protocols/templates', 'List structured session templates'),
 353        cmd('template-get', 'GET', '/protocols/templates/:id', 'Get structured session template detail'),
 354        cmd('template-create', 'POST', '/protocols/templates', 'Create a structured session template', { expectsJsonBody: true }),
 355        cmd('template-update', 'PATCH', '/protocols/templates/:id', 'Update a structured session template', { expectsJsonBody: true }),
 356        cmd('template-delete', 'DELETE', '/protocols/templates/:id', 'Delete a structured session template'),
 357      ],
 358    },
 359    {
 360      name: 'mcp-servers',
 361      description: 'Manage MCP server configurations',
 362      commands: [
 363        cmd('list', 'GET', '/mcp-servers', 'List MCP servers'),
 364        cmd('get', 'GET', '/mcp-servers/:id', 'Get MCP server by id'),
 365        cmd('create', 'POST', '/mcp-servers', 'Create MCP server', { expectsJsonBody: true }),
 366        cmd('update', 'PUT', '/mcp-servers/:id', 'Update MCP server', { expectsJsonBody: true }),
 367        cmd('delete', 'DELETE', '/mcp-servers/:id', 'Delete MCP server'),
 368        cmd('test', 'POST', '/mcp-servers/:id/test', 'Test MCP server connection'),
 369        cmd('tools', 'GET', '/mcp-servers/:id/tools', 'List tools available on an MCP server'),
 370        cmd('tools-info', 'GET', '/mcp-servers/:id/tools-info', 'List tools with token-cost estimates and exposure status'),
 371        cmd('conformance', 'POST', '/mcp-servers/:id/conformance', 'Run MCP conformance checks for a server', { expectsJsonBody: true }),
 372        cmd('invoke', 'POST', '/mcp-servers/:id/invoke', 'Invoke an MCP tool on a server', { expectsJsonBody: true }),
 373      ],
 374    },
 375    {
 376      name: 'mcp-registry',
 377      description: 'Browse the public SwarmDock MCP Registry',
 378      commands: [
 379        cmd('search', 'GET', '/mcp-registry', 'Search registry servers (supports --query q=postgres,limit=20)'),
 380        cmd('get', 'GET', '/mcp-registry/:slug', 'Get registry server detail by slug'),
 381      ],
 382    },
 383    {
 384      name: 'memories',
 385      description: 'Alias of memory command group',
 386      aliasFor: 'memory',
 387      commands: [],
 388    },
 389    {
 390      name: 'openclaw',
 391      description: 'OpenClaw discovery, gateway control, and runtime APIs',
 392      commands: [
 393        cmd('discover', 'GET', '/openclaw/discover', 'Discover OpenClaw gateways'),
 394        cmd('deploy-status', 'GET', '/openclaw/deploy', 'Get managed OpenClaw deploy status'),
 395        cmd('deploy-local-start', 'POST', '/openclaw/deploy', 'Start a managed local OpenClaw deployment (use --data JSON for port/token overrides)', {
 396          expectsJsonBody: true,
 397          defaultBody: { action: 'start-local' },
 398        }),
 399        cmd('deploy-local-stop', 'POST', '/openclaw/deploy', 'Stop the managed local OpenClaw deployment', {
 400          expectsJsonBody: true,
 401          defaultBody: { action: 'stop-local' },
 402        }),
 403        cmd('deploy-local-restart', 'POST', '/openclaw/deploy', 'Restart the managed local OpenClaw deployment (use --data JSON for port/token overrides)', {
 404          expectsJsonBody: true,
 405          defaultBody: { action: 'restart-local' },
 406        }),
 407        cmd('deploy-bundle', 'POST', '/openclaw/deploy', 'Generate an OpenClaw remote deployment bundle (use --data JSON for template/target/token)', {
 408          expectsJsonBody: true,
 409          defaultBody: { action: 'bundle' },
 410        }),
 411        cmd('deploy-ssh', 'POST', '/openclaw/deploy', 'Push the official-image OpenClaw bundle to a remote host over SSH (use --data JSON for target/ssh/provider)', {
 412          expectsJsonBody: true,
 413          defaultBody: { action: 'ssh-deploy' },
 414        }),
 415        cmd('deploy-verify', 'POST', '/openclaw/deploy', 'Verify an OpenClaw endpoint/token pair (use --data JSON for endpoint/token)', {
 416          expectsJsonBody: true,
 417          defaultBody: { action: 'verify' },
 418        }),
 419        cmd('remote-start', 'POST', '/openclaw/deploy', 'Start a remote SSH-managed OpenClaw stack', {
 420          expectsJsonBody: true,
 421          defaultBody: { action: 'remote-start' },
 422        }),
 423        cmd('remote-stop', 'POST', '/openclaw/deploy', 'Stop a remote SSH-managed OpenClaw stack', {
 424          expectsJsonBody: true,
 425          defaultBody: { action: 'remote-stop' },
 426        }),
 427        cmd('remote-restart', 'POST', '/openclaw/deploy', 'Restart a remote SSH-managed OpenClaw stack', {
 428          expectsJsonBody: true,
 429          defaultBody: { action: 'remote-restart' },
 430        }),
 431        cmd('remote-upgrade', 'POST', '/openclaw/deploy', 'Upgrade a remote SSH-managed OpenClaw stack', {
 432          expectsJsonBody: true,
 433          defaultBody: { action: 'remote-upgrade' },
 434        }),
 435        cmd('remote-backup', 'POST', '/openclaw/deploy', 'Create a remote backup on an SSH-managed OpenClaw host', {
 436          expectsJsonBody: true,
 437          defaultBody: { action: 'remote-backup' },
 438        }),
 439        cmd('remote-restore', 'POST', '/openclaw/deploy', 'Restore a remote backup on an SSH-managed OpenClaw host', {
 440          expectsJsonBody: true,
 441          defaultBody: { action: 'remote-restore' },
 442        }),
 443        cmd('remote-rotate-token', 'POST', '/openclaw/deploy', 'Rotate the gateway token on an SSH-managed OpenClaw host', {
 444          expectsJsonBody: true,
 445          defaultBody: { action: 'remote-rotate-token' },
 446        }),
 447        cmd('directory', 'GET', '/openclaw/directory', 'List directory entries from running OpenClaw connectors'),
 448        cmd('gateway-status', 'GET', '/openclaw/gateway', 'Check OpenClaw gateway connection status'),
 449        cmd('gateway', 'POST', '/openclaw/gateway', 'Call OpenClaw gateway RPC/control action', { expectsJsonBody: true }),
 450        cmd('config-sync', 'GET', '/openclaw/config-sync', 'Detect OpenClaw gateway config issues'),
 451        cmd('config-sync-repair', 'POST', '/openclaw/config-sync', 'Repair a detected OpenClaw config issue', { expectsJsonBody: true }),
 452        cmd('approvals', 'GET', '/openclaw/approvals', 'List pending OpenClaw execution approvals'),
 453        cmd('approvals-resolve', 'POST', '/openclaw/approvals', 'Resolve an OpenClaw execution approval', { expectsJsonBody: true }),
 454        cmd('cron', 'GET', '/openclaw/cron', 'List OpenClaw cron jobs'),
 455        cmd('cron-action', 'POST', '/openclaw/cron', 'Create/run/remove OpenClaw cron jobs', { expectsJsonBody: true }),
 456        cmd('agent-files', 'GET', '/openclaw/agent-files', 'Fetch OpenClaw agent files'),
 457        cmd('agent-files-set', 'PUT', '/openclaw/agent-files', 'Save an OpenClaw agent file', { expectsJsonBody: true }),
 458        cmd('dotenv-keys', 'GET', '/openclaw/dotenv-keys', 'List gateway .env keys'),
 459        cmd('exec-config', 'GET', '/openclaw/exec-config', 'Fetch OpenClaw exec approval config'),
 460        cmd('exec-config-set', 'PUT', '/openclaw/exec-config', 'Save OpenClaw exec approval config', { expectsJsonBody: true }),
 461        cmd('history-preview', 'GET', '/openclaw/history', 'Preview OpenClaw session history'),
 462        cmd('history-merge', 'POST', '/openclaw/history', 'Merge OpenClaw session history into local session', { expectsJsonBody: true }),
 463        cmd('media', 'GET', '/openclaw/media', 'Proxy OpenClaw media/file content'),
 464        cmd('models', 'GET', '/openclaw/models', 'List allowed OpenClaw models'),
 465        cmd('permissions', 'GET', '/openclaw/permissions', 'Get OpenClaw permission preset/config'),
 466        cmd('permissions-set', 'PUT', '/openclaw/permissions', 'Apply OpenClaw permission preset', { expectsJsonBody: true }),
 467        cmd('sandbox-env', 'GET', '/openclaw/sandbox-env', 'List OpenClaw sandbox env allowlist'),
 468        cmd('sandbox-env-set', 'PUT', '/openclaw/sandbox-env', 'Update OpenClaw sandbox env allowlist', { expectsJsonBody: true }),
 469        cmd('skills', 'GET', '/openclaw/skills', 'List OpenClaw skills and eligibility'),
 470        cmd('skills-update', 'PATCH', '/openclaw/skills', 'Update OpenClaw skill state/config', { expectsJsonBody: true }),
 471        cmd('skills-save', 'PUT', '/openclaw/skills', 'Save OpenClaw skill allowlist mode/config', { expectsJsonBody: true }),
 472        cmd('skills-install', 'POST', '/openclaw/skills/install', 'Install OpenClaw skill dependencies', { expectsJsonBody: true }),
 473        cmd('skills-remove', 'POST', '/openclaw/skills/remove', 'Remove OpenClaw skill', { expectsJsonBody: true }),
 474        cmd('sync', 'POST', '/openclaw/sync', 'Run OpenClaw sync action', { expectsJsonBody: true }),
 475        cmd('dashboard-url', 'GET', '/openclaw/dashboard-url', 'Get tokenized OpenClaw dashboard URL for an agent'),
 476        cmd('doctor', 'GET', '/openclaw/doctor', 'Run OpenClaw doctor check (read-only)'),
 477        cmd('doctor-fix', 'POST', '/openclaw/doctor', 'Run OpenClaw doctor with auto-fix', { expectsJsonBody: true }),
 478      ],
 479    },
 480    {
 481      name: 'preview-server',
 482      description: 'Manage preview dev servers',
 483      commands: [
 484        cmd('manage', 'POST', '/preview-server', 'Start/stop/status/detect preview server', { expectsJsonBody: true }),
 485      ],
 486    },
 487    {
 488      name: 'projects',
 489      description: 'Manage projects',
 490      commands: [
 491        cmd('list', 'GET', '/projects', 'List projects'),
 492        cmd('get', 'GET', '/projects/:id', 'Get project by id'),
 493        cmd('create', 'POST', '/projects', 'Create project', { expectsJsonBody: true }),
 494        cmd('update', 'PUT', '/projects/:id', 'Update project', { expectsJsonBody: true }),
 495        cmd('delete', 'DELETE', '/projects/:id', 'Delete project'),
 496      ],
 497    },
 498    {
 499      name: 'extensions',
 500      description: 'Manage extensions and marketplace',
 501      commands: [
 502        cmd('list', 'GET', '/extensions', 'List installed extensions'),
 503        cmd('set', 'POST', '/extensions', 'Enable or disable an extension', { expectsJsonBody: true }),
 504        cmd('delete', 'DELETE', '/extensions', 'Delete an external extension (use --query filename=extension.js)'),
 505        cmd('update', 'PATCH', '/extensions', 'Update an extension (use --query id=extension.js or --query all=true)'),
 506        cmd('install', 'POST', '/extensions/install', 'Install an extension from URL', { expectsJsonBody: true }),
 507        cmd('install-deps', 'POST', '/extensions/dependencies', 'Install or refresh extension workspace dependencies', { expectsJsonBody: true }),
 508        cmd('marketplace', 'GET', '/extensions/marketplace', 'Get extension marketplace catalog'),
 509        cmd('settings-get', 'GET', '/extensions/settings', 'Get extension settings (use --query extensionId=extension_name)'),
 510        cmd('settings-set', 'PUT', '/extensions/settings', 'Set extension settings (use --query extensionId=extension_name and --data JSON)', { expectsJsonBody: true }),
 511        cmd('ui', 'GET', '/extensions/ui', 'List extension UI modules (use --query type=sidebar|header|chat_actions|connectors)'),
 512        cmd('builtins', 'GET', '/extensions/builtins', 'List built-in extensions'),
 513      ],
 514    },
 515    {
 516      name: 'providers',
 517      description: 'Manage providers and model overrides',
 518      commands: [
 519        cmd('list', 'GET', '/providers', 'List providers'),
 520        cmd('get', 'GET', '/providers/:id', 'Get provider config'),
 521        cmd('create', 'POST', '/providers', 'Create custom provider', { expectsJsonBody: true }),
 522        cmd('update', 'PUT', '/providers/:id', 'Update provider', { expectsJsonBody: true }),
 523        cmd('delete', 'DELETE', '/providers/:id', 'Delete provider'),
 524        cmd('configs', 'GET', '/providers/configs', 'List saved provider configs'),
 525        cmd('discover-models', 'GET', '/providers/:id/discover-models', 'Discover provider models via endpoint or credential checks'),
 526        cmd('ollama', 'GET', '/providers/ollama', 'List local Ollama models (use --query endpoint=http://localhost:11434)'),
 527        cmd('openclaw-health', 'GET', '/providers/openclaw/health', 'Probe OpenClaw endpoint/auth (use --query endpoint= --query credentialId= --query model=)'),
 528        cmd('models', 'GET', '/providers/:id/models', 'Get provider model overrides'),
 529        cmd('models-set', 'PUT', '/providers/:id/models', 'Set provider model overrides', { expectsJsonBody: true }),
 530        cmd('models-clear', 'DELETE', '/providers/:id/models', 'Clear provider model overrides'),
 531      ],
 532    },
 533    {
 534      name: 'search',
 535      description: 'Global search across app resources',
 536      commands: [
 537        cmd('query', 'GET', '/search', 'Search agents/tasks/chats/schedules/webhooks/skills (use --query q=term)'),
 538      ],
 539    },
 540    {
 541      name: 'runs',
 542      description: 'Session run queue/history',
 543      commands: [
 544        cmd('list', 'GET', '/runs', 'List runs (use --query sessionId=, --query status=, --query limit=)'),
 545        cmd('get', 'GET', '/runs/:id', 'Get run by id'),
 546        cmd('events', 'GET', '/runs/:id/events', 'Get run event history by run id'),
 547      ],
 548    },
 549    {
 550      name: 'schedules',
 551      description: 'Manage schedules',
 552      commands: [
 553        cmd('list', 'GET', '/schedules', 'List schedules'),
 554        cmd('get', 'GET', '/schedules/:id', 'Get schedule by id'),
 555        cmd('create', 'POST', '/schedules', 'Create schedule', { expectsJsonBody: true }),
 556        cmd('update', 'PUT', '/schedules/:id', 'Update schedule', { expectsJsonBody: true }),
 557        cmd('delete', 'DELETE', '/schedules/:id', 'Delete schedule'),
 558        cmd('run', 'POST', '/schedules/:id/run', 'Trigger schedule now'),
 559      ],
 560    },
 561    {
 562      name: 'secrets',
 563      description: 'Manage reusable encrypted secrets',
 564      commands: [
 565        cmd('list', 'GET', '/secrets', 'List secrets metadata'),
 566        cmd('get', 'GET', '/secrets/:id', 'Get secret metadata by id'),
 567        cmd('create', 'POST', '/secrets', 'Create secret', { expectsJsonBody: true }),
 568        cmd('update', 'PUT', '/secrets/:id', 'Update secret metadata', { expectsJsonBody: true }),
 569        cmd('delete', 'DELETE', '/secrets/:id', 'Delete secret'),
 570      ],
 571    },
 572    {
 573      name: 'chats',
 574      description: 'Manage agent chats and runtime controls',
 575      commands: [
 576        cmd('list', 'GET', '/chats', 'List chats'),
 577        cmd('get', 'GET', '/chats/:id', 'Get chat by id'),
 578        cmd('create', 'POST', '/chats', 'Create chat', { expectsJsonBody: true }),
 579        cmd('update', 'PUT', '/chats/:id', 'Update chat', { expectsJsonBody: true }),
 580        cmd('delete', 'DELETE', '/chats/:id', 'Delete chat'),
 581        cmd('delete-many', 'DELETE', '/chats', 'Delete multiple chats (body: {"ids":[...]})', { expectsJsonBody: true }),
 582        cmd('heartbeat-disable-all', 'POST', '/chats/heartbeat', 'Disable all chat heartbeats and cancel queued heartbeat runs', {
 583          expectsJsonBody: true,
 584          defaultBody: { action: 'disable_all' },
 585        }),
 586        cmd('messages', 'GET', '/chats/:id/messages', 'Get chat messages'),
 587        cmd('messages-update', 'PUT', '/chats/:id/messages', 'Update chat message metadata (e.g. bookmark)', { expectsJsonBody: true }),
 588        cmd('messages-send', 'POST', '/chats/:id/messages', 'Append a user/system message to a chat', { expectsJsonBody: true }),
 589        cmd('messages-delete', 'DELETE', '/chats/:id/messages', 'Delete a message from a chat', { expectsJsonBody: true }),
 590        cmd('edit-resend', 'POST', '/chats/:id/edit-resend', 'Edit and resend from a specific message index', { expectsJsonBody: true }),
 591        cmd('turn-snapshot', 'GET', '/chats/:id/turns/:index/snapshot', 'Snapshot the input state of a prior user turn (for external replay)'),
 592        cmd('chat', 'POST', '/chats/:id/chat', 'Send chat message (streaming)', {
 593          expectsJsonBody: true,
 594          responseType: 'sse',
 595        }),
 596        cmd('stop', 'POST', '/chats/:id/stop', 'Stop chat run(s)'),
 597        cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages (returns undoToken with a 30s TTL)'),
 598        cmd('clear-undo', 'POST', '/chats/:id/clear/undo', 'Restore a cleared chat via its undoToken', { expectsJsonBody: true }),
 599        cmd('compact', 'POST', '/chats/:id/compact', 'Summarize and compact chat history (accepts optional keepLastN)', { expectsJsonBody: true }),
 600        cmd('context-status', 'GET', '/chats/:id/context-status', 'Report token usage and context-window status for a chat'),
 601        cmd('browser-status', 'GET', '/chats/:id/browser', 'Check browser status'),
 602        cmd('browser-close', 'DELETE', '/chats/:id/browser', 'Close browser'),
 603        cmd('mailbox', 'GET', '/chats/:id/mailbox', 'List chat mailbox envelopes'),
 604        cmd('mailbox-action', 'POST', '/chats/:id/mailbox', 'Send/ack/clear mailbox envelopes', { expectsJsonBody: true }),
 605        cmd('queue', 'GET', '/chats/:id/queue', 'List queued follow-up turns for a chat'),
 606        cmd('queue-add', 'POST', '/chats/:id/queue', 'Enqueue a follow-up turn for a busy chat', { expectsJsonBody: true }),
 607        cmd('queue-clear', 'DELETE', '/chats/:id/queue', 'Remove queued follow-up turns from a chat', { expectsJsonBody: true }),
 608        cmd('retry', 'POST', '/chats/:id/retry', 'Retry last assistant message'),
 609        cmd('deploy', 'POST', '/chats/:id/deploy', 'Deploy current chat branch', { expectsJsonBody: true }),
 610        cmd('devserver', 'POST', '/chats/:id/devserver', 'Dev server action via JSON body', { expectsJsonBody: true }),
 611        cmd('devserver-start', 'POST', '/chats/:id/devserver', 'Start chat dev server', {
 612          expectsJsonBody: true,
 613          defaultBody: { action: 'start' },
 614        }),
 615        cmd('devserver-stop', 'POST', '/chats/:id/devserver', 'Stop chat dev server', {
 616          expectsJsonBody: true,
 617          defaultBody: { action: 'stop' },
 618        }),
 619        cmd('devserver-status', 'POST', '/chats/:id/devserver', 'Check chat dev server status', {
 620          expectsJsonBody: true,
 621          defaultBody: { action: 'status' },
 622        }),
 623        cmd('checkpoints', 'GET', '/chats/:id/checkpoints', 'List checkpoint history for a chat'),
 624        cmd('execution-log', 'GET', '/chats/:id/execution-log', 'Get execution log entries for a chat'),
 625        cmd('migrate-messages', 'POST', '/chats/migrate-messages', 'Migrate messages from session blobs to relational table'),
 626      ],
 627    },
 628    {
 629      name: 'settings',
 630      description: 'Read/update app settings',
 631      commands: [
 632        cmd('get', 'GET', '/settings', 'Get settings'),
 633        cmd('update', 'PUT', '/settings', 'Update settings', { expectsJsonBody: true }),
 634      ],
 635    },
 636    {
 637      name: 'setup',
 638      description: 'Setup and provider validation helpers',
 639      commands: [
 640        cmd('check-provider', 'POST', '/setup/check-provider', 'Validate provider credentials/endpoint', { expectsJsonBody: true }),
 641        cmd('doctor', 'GET', '/setup/doctor', 'Run local setup diagnostics'),
 642        cmd('openclaw-device', 'GET', '/setup/openclaw-device', 'Show the local OpenClaw device ID'),
 643      ],
 644    },
 645    {
 646      name: 'learned-skills',
 647      description: 'Inspect agent-scoped learned skills',
 648      commands: [
 649        cmd('list', 'GET', '/learned-skills', 'List learned skills'),
 650        cmd('promote', 'POST', '/learned-skills/:id?action=promote', 'Promote a review-ready skill to active'),
 651        cmd('dismiss', 'POST', '/learned-skills/:id?action=dismiss', 'Dismiss a learned skill'),
 652        cmd('delete', 'DELETE', '/learned-skills/:id', 'Delete a learned skill'),
 653        cmd('review-counts', 'GET', '/skill-review-counts', 'Show pending review counts'),
 654      ],
 655    },
 656    {
 657      name: 'share',
 658      description: 'Public share links for missions, skills, and sessions',
 659      commands: [
 660        cmd('list', 'GET', '/share', 'List share links (supports --query entityType=mission,entityId=...)'),
 661        cmd('mint', 'POST', '/share', 'Mint a new share link', { expectsJsonBody: true }),
 662        cmd('get', 'GET', '/share/:id', 'Get a share link by id'),
 663        cmd('revoke', 'DELETE', '/share/:id', 'Revoke a share link'),
 664        cmd('resolve', 'GET', '/s/:token', 'Resolve a public share token to its scrubbed payload'),
 665        cmd('raw', 'GET', '/s/:token/raw', 'Fetch the raw markdown body for a share token (skill/mission/session)'),
 666      ],
 667    },
 668    {
 669      name: 'skills',
 670      description: 'Manage reusable skills',
 671      commands: [
 672        cmd('list', 'GET', '/skills', 'List skills'),
 673        cmd('get', 'GET', '/skills/:id', 'Get skill'),
 674        cmd('create', 'POST', '/skills', 'Create skill', { expectsJsonBody: true }),
 675        cmd('update', 'PUT', '/skills/:id', 'Update skill', { expectsJsonBody: true }),
 676        cmd('delete', 'DELETE', '/skills/:id', 'Delete skill'),
 677        cmd('import', 'POST', '/skills/import', 'Import skill from URL', { expectsJsonBody: true }),
 678      ],
 679    },
 680    {
 681      name: 'skill-suggestions',
 682      description: 'Review conversation-derived skill drafts',
 683      commands: [
 684        cmd('list', 'GET', '/skill-suggestions', 'List skill suggestions'),
 685        cmd('draft', 'POST', '/skill-suggestions', 'Generate or refresh a draft from a session', {
 686          expectsJsonBody: true,
 687          bodyFlagMap: { session: 'sessionId' },
 688        }),
 689        cmd('approve', 'POST', '/skill-suggestions/:id/approve', 'Approve a skill suggestion and materialize it'),
 690        cmd('reject', 'POST', '/skill-suggestions/:id/reject', 'Reject a skill suggestion draft'),
 691      ],
 692    },
 693    {
 694      name: 'souls',
 695      description: 'Browse and manage soul library templates',
 696      commands: [
 697        cmd('list', 'GET', '/souls', 'List soul templates'),
 698        cmd('get', 'GET', '/souls/:id', 'Get soul template by id'),
 699        cmd('create', 'POST', '/souls', 'Create custom soul template', { expectsJsonBody: true }),
 700        cmd('update', 'PUT', '/souls/:id', 'Update soul template', { expectsJsonBody: true }),
 701        cmd('delete', 'DELETE', '/souls/:id', 'Delete soul template'),
 702      ],
 703    },
 704    {
 705      name: 'tasks',
 706      description: 'Manage task board items',
 707      commands: [
 708        cmd('list', 'GET', '/tasks', 'List tasks'),
 709        cmd('get', 'GET', '/tasks/:id', 'Get task'),
 710        cmd('create', 'POST', '/tasks', 'Create task', { expectsJsonBody: true }),
 711        cmd('bulk', 'POST', '/tasks/bulk', 'Bulk update tasks (status/agent/project)', { expectsJsonBody: true }),
 712        cmd('update', 'PUT', '/tasks/:id', 'Update task', { expectsJsonBody: true }),
 713        cmd('delete', 'DELETE', '/tasks/:id', 'Delete task'),
 714        cmd('purge', 'DELETE', '/tasks', 'Bulk delete tasks', { expectsJsonBody: true }),
 715        cmd('approve', 'POST', '/tasks/:id/approve', 'Approve or reject a pending tool execution', { expectsJsonBody: true }),
 716        cmd('claim', 'POST', '/tasks/claim', 'Claim a pool-mode task for an agent', { expectsJsonBody: true }),
 717        cmd('import-github', 'POST', '/tasks/import/github', 'Import GitHub issues into tasks', { expectsJsonBody: true }),
 718        cmd('metrics', 'GET', '/tasks/metrics', 'Get task board metrics (supports --query range=24h|7d|30d)'),
 719      ],
 720    },
 721    {
 722      name: 'tts',
 723      description: 'Text-to-speech endpoint',
 724      commands: [
 725        cmd('speak', 'POST', '/tts', 'Generate TTS audio', {
 726          expectsJsonBody: true,
 727          responseType: 'binary',
 728          bodyFlagMap: { text: 'text' },
 729        }),
 730        cmd('stream', 'POST', '/tts/stream', 'Generate streaming TTS audio', {
 731          expectsJsonBody: true,
 732          responseType: 'binary',
 733          bodyFlagMap: { text: 'text' },
 734        }),
 735      ],
 736    },
 737    {
 738      name: 'upload',
 739      description: 'Upload raw file/blob',
 740      commands: [
 741        cmd('file', 'POST', '/upload', 'Upload file', {
 742          requestType: 'upload',
 743          inputPositional: 'filePath',
 744        }),
 745      ],
 746    },
 747    {
 748      name: 'uploads',
 749      description: 'Manage uploaded artifacts',
 750      commands: [
 751        cmd('list', 'GET', '/uploads', 'List uploaded artifacts'),
 752        cmd('get', 'GET', '/uploads/:filename', 'Download uploaded artifact', { responseType: 'binary' }),
 753        cmd('delete', 'DELETE', '/uploads/:filename', 'Delete uploaded artifact by filename'),
 754        cmd('delete-many', 'DELETE', '/uploads', 'Delete uploads by filter/body (filenames, olderThanDays, category, or all)', { expectsJsonBody: true }),
 755      ],
 756    },
 757    {
 758      name: 'system-status',
 759      description: 'Lightweight system health summary',
 760      commands: [
 761        cmd('get', 'GET', '/system/status', 'Get system health summary (safe for external monitors)'),
 762      ],
 763    },
 764    {
 765      name: 'healthz',
 766      description: 'Public liveness probe',
 767      commands: [
 768        cmd('get', 'GET', '/healthz', 'Get public health check payload'),
 769      ],
 770    },
 771    {
 772      name: 'usage',
 773      description: 'Usage and cost summary',
 774      commands: [
 775        cmd('get', 'GET', '/usage', 'Get usage summary'),
 776        cmd('live', 'GET', '/usage/live', 'Get live per-session usage (use --query sessionId=...)'),
 777      ],
 778    },
 779    {
 780      name: 'version',
 781      description: 'Version and update checks',
 782      commands: [
 783        cmd('get', 'GET', '/version', 'Get local/remote version info'),
 784        cmd('update', 'POST', '/version/update', 'Update to latest stable release tag (fallback: main) and install deps when needed'),
 785      ],
 786    },
 787    {
 788      name: 'webhooks',
 789      description: 'Manage and trigger webhooks',
 790      commands: [
 791        cmd('list', 'GET', '/webhooks', 'List webhooks'),
 792        cmd('get', 'GET', '/webhooks/:id', 'Get webhook by id'),
 793        cmd('create', 'POST', '/webhooks', 'Create webhook', { expectsJsonBody: true }),
 794        cmd('update', 'PUT', '/webhooks/:id', 'Update webhook', { expectsJsonBody: true }),
 795        cmd('delete', 'DELETE', '/webhooks/:id', 'Delete webhook'),
 796        cmd('trigger', 'POST', '/webhooks/:id', 'Trigger webhook by id', {
 797          expectsJsonBody: true,
 798          waitEntityFrom: 'runId',
 799        }),
 800        cmd('history', 'GET', '/webhooks/:id/history', 'Get webhook delivery history'),
 801      ],
 802    },
 803    {
 804      name: 'portability',
 805      description: 'Export and import agent configurations',
 806      commands: [
 807        cmd('export', 'GET', '/portability/export', 'Export agents, skills, and schedules as a portable JSON manifest'),
 808        cmd('import', 'POST', '/portability/import', 'Import a portable JSON manifest', { expectsJsonBody: true }),
 809      ],
 810    },
 811    {
 812      name: 'wallets',
 813      description: 'Manage agent wallets',
 814      commands: [
 815        cmd('list', 'GET', '/wallets', 'List wallets'),
 816        cmd('get', 'GET', '/wallets/:id', 'Get wallet by id'),
 817        cmd('create', 'POST', '/wallets', 'Create a wallet', { expectsJsonBody: true }),
 818        cmd('generate', 'POST', '/wallets/generate', 'Generate a new wallet for an agent', { expectsJsonBody: true }),
 819        cmd('update', 'PATCH', '/wallets/:id', 'Update wallet settings', { expectsJsonBody: true }),
 820        cmd('delete', 'DELETE', '/wallets/:id', 'Delete a wallet'),
 821      ],
 822    },
 823    {
 824      name: 'goals',
 825      description: 'Manage goal hierarchy',
 826      commands: [
 827        cmd('list', 'GET', '/goals', 'List goals'),
 828        cmd('get', 'GET', '/goals/:id', 'Get goal by id'),
 829        cmd('create', 'POST', '/goals', 'Create a goal', { expectsJsonBody: true }),
 830        cmd('update', 'PATCH', '/goals/:id', 'Update a goal', { expectsJsonBody: true }),
 831        cmd('delete', 'DELETE', '/goals/:id', 'Delete a goal'),
 832      ],
 833    },
 834    {
 835      name: 'missions',
 836      description: 'Manage autonomous missions',
 837      commands: [
 838        cmd('list', 'GET', '/missions', 'List autonomous missions'),
 839        cmd('get', 'GET', '/missions/:id', 'Get a mission by id'),
 840        cmd('create', 'POST', '/missions', 'Create an autonomous mission', { expectsJsonBody: true }),
 841        cmd('update', 'PUT', '/missions/:id', 'Update a mission', { expectsJsonBody: true }),
 842        cmd('delete', 'DELETE', '/missions/:id', 'Delete a mission'),
 843        cmd('control', 'POST', '/missions/:id/control', 'Start, pause, resume, cancel, complete, or fail a mission', { expectsJsonBody: true }),
 844        cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
 845        cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
 846        cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
 847        cmd('templates', 'GET', '/missions/templates', 'List built-in mission templates'),
 848        cmd('instantiate', 'POST', '/missions/templates/:id/instantiate', 'Create a mission from a template', { expectsJsonBody: true }),
 849      ],
 850    },
 851    {
 852      name: 'workspaces',
 853      description: 'Manage logical workspaces (multi-workspace scaffolding)',
 854      commands: [
 855        cmd('list', 'GET', '/workspaces', 'List workspaces'),
 856        cmd('create', 'POST', '/workspaces', 'Create a workspace', { expectsJsonBody: true }),
 857        cmd('update', 'PATCH', '/workspaces', 'Update a workspace', { expectsJsonBody: true }),
 858        cmd('delete', 'DELETE', '/workspaces', 'Delete a workspace (use --query id=...)'),
 859        cmd('active', 'GET', '/workspaces/active', 'Get the active workspace'),
 860        cmd('set-active', 'POST', '/workspaces/active', 'Set the active workspace', { expectsJsonBody: true }),
 861      ],
 862    },
 863    {
 864      name: 'workflow-states',
 865      description: 'Manage customizable task workflow states',
 866      commands: [
 867        cmd('list', 'GET', '/task-workflow-states', 'List workflow states'),
 868        cmd('create', 'POST', '/task-workflow-states', 'Create or update a workflow state', { expectsJsonBody: true }),
 869        cmd('delete', 'DELETE', '/task-workflow-states', 'Delete a workflow state (use --query id=... or --query reset=true)'),
 870      ],
 871    },
 872    {
 873      name: 'config-versions',
 874      description: 'Inspect and restore configuration version history',
 875      commands: [
 876        cmd('list', 'GET', '/config-versions', 'List versions for an entity (use --query entityKind=agent,entityId=...)'),
 877        cmd('restore', 'POST', '/config-versions/restore', 'Restore an entity to a prior version', { expectsJsonBody: true }),
 878      ],
 879    },
 880    {
 881      name: 'cost-attribution',
 882      description: 'Aggregate LLM cost by billing-code tags',
 883      commands: [
 884        cmd('by-code', 'GET', '/usage/by-code', 'Roll up cost by billing code (use --query codes=foo,bar,range=7d)'),
 885      ],
 886    },
 887    {
 888      name: 'chatroom-policy',
 889      description: 'Configure chatroom delegation refusal policies',
 890      commands: [
 891        cmd('set', 'POST', '/chatrooms/refusal-policy', 'Set onRefusal policy for a chatroom', { expectsJsonBody: true }),
 892        cmd('simulate', 'PUT', '/chatrooms/refusal-policy', 'Simulate a refusal-handling decision', { expectsJsonBody: true }),
 893      ],
 894    },
 895    {
 896      name: 'swarmfeed',
 897      description: 'SwarmFeed social network',
 898      commands: [
 899        cmd('feed', 'GET', '/swarmfeed', 'Get SwarmFeed timeline'),
 900        cmd('search', 'GET', '/swarmfeed/search', 'Search SwarmFeed posts, agents, channels, or hashtags'),
 901        cmd('channels', 'GET', '/swarmfeed/channels', 'List SwarmFeed channels'),
 902        cmd('bookmarks', 'GET', '/swarmfeed/bookmarks', 'Get bookmarked SwarmFeed posts for an agent'),
 903        cmd('notifications', 'GET', '/swarmfeed/notifications', 'Get SwarmFeed notifications for an agent'),
 904        cmd('suggested', 'GET', '/swarmfeed/suggested', 'Get suggested SwarmFeed follows'),
 905        cmd('posts', 'GET', '/swarmfeed/posts', 'Get recent posts'),
 906        cmd('post-get', 'GET', '/swarmfeed/posts/:postId', 'Get a SwarmFeed post by id'),
 907        cmd('replies', 'GET', '/swarmfeed/posts/:postId/replies', 'Get replies for a SwarmFeed post'),
 908        cmd('post', 'POST', '/swarmfeed/posts', 'Create a post', { expectsJsonBody: true }),
 909        cmd('profile', 'GET', '/swarmfeed/profiles/:agentId', 'Get a SwarmFeed agent profile'),
 910        cmd('profile-posts', 'GET', '/swarmfeed/profiles/:agentId/posts', 'Get recent posts for a SwarmFeed agent profile'),
 911        cmd('action', 'POST', '/swarmfeed/actions', 'Run a SwarmFeed action such as follow, bookmark, repost, or quote repost', {
 912          expectsJsonBody: true,
 913        }),
 914      ],
 915    },
 916    {
 917      name: 'swarmdock',
 918      description: 'SwarmDock marketplace',
 919      commands: [
 920        cmd('browse', 'GET', '/swarmdock', 'Browse SwarmDock marketplace tasks and agents'),
 921      ],
 922    },
 923  ]
 924  
 925  const GROUP_MAP = new Map(COMMAND_GROUPS.map((group) => [group.name, group]))
 926  
 927  function resolveGroup(name) {
 928    const group = GROUP_MAP.get(name)
 929    if (!group) return null
 930    if (group.aliasFor) {
 931      return GROUP_MAP.get(group.aliasFor) || null
 932    }
 933    return group
 934  }
 935  
 936  const COMMANDS = COMMAND_GROUPS.flatMap((group) => {
 937    if (group.aliasFor) return []
 938    return group.commands.map((command) => ({ ...command, group: group.name }))
 939  })
 940  
 941  function getCommand(groupName, action) {
 942    const group = resolveGroup(groupName)
 943    if (!group) return null
 944    return group.commands.find((command) => command.action === action) || null
 945  }
 946  
 947  function extractPathParams(route) {
 948    return [...route.matchAll(/:([A-Za-z0-9_]+)/g)].map((match) => match[1])
 949  }
 950  
 951  function isPlainObject(value) {
 952    return value && typeof value === 'object' && !Array.isArray(value)
 953  }
 954  
 955  function parseKeyValue(raw, kind) {
 956    const idx = raw.indexOf('=')
 957    if (idx === -1) {
 958      throw new Error(`${kind} value must be key=value: ${raw}`)
 959    }
 960    const key = raw.slice(0, idx).trim()
 961    const value = raw.slice(idx + 1)
 962    if (!key) throw new Error(`${kind} key cannot be empty`)
 963    return [key, value]
 964  }
 965  
 966  function parseDataInput(raw, stdin) {
 967    if (raw === '-') {
 968      return parseJsonText(readStdin(stdin), 'stdin')
 969    }
 970    if (raw.startsWith('@')) {
 971      const filePath = raw.slice(1)
 972      if (!filePath) throw new Error('Expected file path after @ for --data')
 973      const fileText = fs.readFileSync(filePath, 'utf8')
 974      return parseJsonText(fileText, filePath)
 975    }
 976    return parseJsonText(raw, '--data')
 977  }
 978  
 979  function parseJsonText(text, sourceName) {
 980    try {
 981      return JSON.parse(text)
 982    } catch (err) {
 983      const msg = err instanceof Error ? err.message : String(err)
 984      throw new Error(`Invalid JSON from ${sourceName}: ${msg}`)
 985    }
 986  }
 987  
 988  function readStdin(stdin) {
 989    const fd = stdin && typeof stdin.fd === 'number' ? stdin.fd : 0
 990    return fs.readFileSync(fd, 'utf8')
 991  }
 992  
 993  function normalizeBaseUrl(raw) {
 994    const trimmed = String(raw || '').trim()
 995    const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
 996    return withProtocol.replace(/\/+$/, '')
 997  }
 998  
 999  function resolveAccessKey(opts, env, cwd) {
1000    if (opts.accessKey) return String(opts.accessKey).trim()
1001    const envKey = env.SWARMCLAW_API_KEY || env.SC_ACCESS_KEY || env.SWARMCLAW_ACCESS_KEY || ''
1002    if (envKey) return String(envKey).trim()
1003  
1004    const keyFile = path.join(cwd, 'platform-api-key.txt')
1005    if (fs.existsSync(keyFile)) {
1006      const content = fs.readFileSync(keyFile, 'utf8').trim()
1007      if (content) return content
1008    }
1009    return ''
1010  }
1011  
1012  function parseArgv(argv) {
1013    const result = {
1014      group: '',
1015      action: '',
1016      positionals: [],
1017      opts: {
1018        baseUrl: '',
1019        accessKey: '',
1020        jsonOutput: false,
1021        wait: false,
1022        timeoutMs: 300000,
1023        intervalMs: 2000,
1024        out: '',
1025        data: '',
1026        headers: [],
1027        query: [],
1028        key: '',
1029        text: '',
1030        file: '',
1031        filename: '',
1032        secret: '',
1033        event: '',
1034        help: false,
1035        version: false,
1036      },
1037    }
1038  
1039    const valueOptions = new Set([
1040      'base-url',
1041      'access-key',
1042      'timeout-ms',
1043      'interval-ms',
1044      'out',
1045      'data',
1046      'header',
1047      'query',
1048      'key',
1049      'text',
1050      'file',
1051      'filename',
1052      'secret',
1053      'event',
1054    ])
1055  
1056    const tokens = [...argv]
1057    for (let i = 0; i < tokens.length; i += 1) {
1058      const token = tokens[i]
1059      if (token === '--') {
1060        result.positionals.push(...tokens.slice(i + 1))
1061        break
1062      }
1063  
1064      if (token === '-h' || token === '--help') {
1065        result.opts.help = true
1066        continue
1067      }
1068  
1069      if (token === '--version') {
1070        result.opts.version = true
1071        continue
1072      }
1073  
1074      if (token === '--json') {
1075        result.opts.jsonOutput = true
1076        continue
1077      }
1078  
1079      if (token === '--wait') {
1080        result.opts.wait = true
1081        continue
1082      }
1083  
1084      if (token.startsWith('--')) {
1085        const eqIndex = token.indexOf('=')
1086        const hasInline = eqIndex > -1
1087        const rawName = hasInline ? token.slice(2, eqIndex) : token.slice(2)
1088        const rawValue = hasInline ? token.slice(eqIndex + 1) : ''
1089  
1090        if (!valueOptions.has(rawName)) {
1091          throw new Error(`Unknown option: --${rawName}`)
1092        }
1093  
1094        const value = hasInline ? rawValue : tokens[i + 1]
1095        if (!hasInline) i += 1
1096        if (value === undefined) {
1097          throw new Error(`Missing value for --${rawName}`)
1098        }
1099  
1100        switch (rawName) {
1101          case 'base-url':
1102            result.opts.baseUrl = value
1103            break
1104          case 'access-key':
1105            result.opts.accessKey = value
1106            break
1107          case 'timeout-ms':
1108            result.opts.timeoutMs = Number.parseInt(value, 10)
1109            if (!Number.isFinite(result.opts.timeoutMs) || result.opts.timeoutMs <= 0) {
1110              throw new Error(`Invalid --timeout-ms value: ${value}`)
1111            }
1112            break
1113          case 'interval-ms':
1114            result.opts.intervalMs = Number.parseInt(value, 10)
1115            if (!Number.isFinite(result.opts.intervalMs) || result.opts.intervalMs <= 0) {
1116              throw new Error(`Invalid --interval-ms value: ${value}`)
1117            }
1118            break
1119          case 'out':
1120            result.opts.out = value
1121            break
1122          case 'data':
1123            result.opts.data = value
1124            break
1125          case 'header':
1126            result.opts.headers.push(value)
1127            break
1128          case 'query':
1129            result.opts.query.push(value)
1130            break
1131          case 'key':
1132            result.opts.key = value
1133            break
1134          case 'text':
1135            result.opts.text = value
1136            break
1137          case 'file':
1138            result.opts.file = value
1139            break
1140          case 'filename':
1141            result.opts.filename = value
1142            break
1143          case 'secret':
1144            result.opts.secret = value
1145            break
1146          case 'event':
1147            result.opts.event = value
1148            break
1149          default:
1150            throw new Error(`Unhandled option parser branch: --${rawName}`)
1151        }
1152        continue
1153      }
1154  
1155      result.positionals.push(token)
1156    }
1157  
1158    if (result.positionals.length > 0) {
1159      result.group = result.positionals[0]
1160    }
1161    if (result.positionals.length > 1) {
1162      result.action = result.positionals[1]
1163    }
1164  
1165    return result
1166  }
1167  
1168  function buildRoute(routeTemplate, args) {
1169    const pathParams = extractPathParams(routeTemplate)
1170    if (args.length < pathParams.length) {
1171      throw new Error(`Missing required path args: ${pathParams.slice(args.length).join(', ')}`)
1172    }
1173  
1174    let route = routeTemplate
1175    for (let i = 0; i < pathParams.length; i += 1) {
1176      route = route.replace(`:${pathParams[i]}`, encodeURIComponent(String(args[i])))
1177    }
1178  
1179    const remaining = args.slice(pathParams.length)
1180    return { route, remaining, pathParams }
1181  }
1182  
1183  function buildApiUrl(baseUrl, route, queryEntries) {
1184    const normalizedBase = normalizeBaseUrl(baseUrl)
1185    const hasApiSuffix = normalizedBase.endsWith('/api')
1186    const url = new URL(`${normalizedBase}${hasApiSuffix ? '' : '/api'}${route}`)
1187    for (const [key, value] of queryEntries) {
1188      url.searchParams.set(key, value)
1189    }
1190    return url
1191  }
1192  
1193  async function parseResponse(res, forceType) {
1194    const ct = (res.headers.get('content-type') || '').toLowerCase()
1195  
1196    if (forceType === 'sse' || ct.includes('text/event-stream')) {
1197      return { type: 'sse', value: res.body }
1198    }
1199  
1200    if (forceType === 'binary') {
1201      const buf = Buffer.from(await res.arrayBuffer())
1202      return { type: 'binary', value: buf, contentType: ct }
1203    }
1204  
1205    if (ct.includes('application/json')) {
1206      const json = await res.json().catch(() => null)
1207      return { type: 'json', value: json }
1208    }
1209  
1210    if (ct.startsWith('text/') || ct.includes('xml') || ct.includes('javascript')) {
1211      const text = await res.text()
1212      return { type: 'text', value: text }
1213    }
1214  
1215    const buf = Buffer.from(await res.arrayBuffer())
1216    return { type: 'binary', value: buf, contentType: ct }
1217  }
1218  
1219  function writeJson(stdout, value, compact) {
1220    const text = compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)
1221    stdout.write(`${text}\n`)
1222  }
1223  
1224  function writeText(stdout, value) {
1225    stdout.write(String(value))
1226    if (!String(value).endsWith('\n')) stdout.write('\n')
1227  }
1228  
1229  function writeBinary(stdout, stderr, buffer, outPath, cwd) {
1230    if (outPath) {
1231      const resolved = path.isAbsolute(outPath) ? outPath : path.join(cwd, outPath)
1232      fs.writeFileSync(resolved, buffer)
1233      stderr.write(`Saved ${buffer.length} bytes to ${resolved}\n`)
1234      return
1235    }
1236  
1237    if (stdout.isTTY) {
1238      throw new Error('Binary response requires --out <file> when writing to a TTY')
1239    }
1240    stdout.write(buffer)
1241  }
1242  
1243  async function consumeSse(body, stdout, stderr, jsonOutput) {
1244    if (!body || typeof body.getReader !== 'function') {
1245      throw new Error('Streaming response does not expose a reader')
1246    }
1247  
1248    const reader = body.getReader()
1249    const decoder = new TextDecoder()
1250    let buffer = ''
1251    const eventBoundary = /\r?\n\r?\n/
1252  
1253    function flushChunk(rawChunk) {
1254      const lines = rawChunk
1255        .replace(/\r\n/g, '\n')
1256        .split('\n')
1257        .map((line) => line.trimEnd())
1258        .filter(Boolean)
1259  
1260      const dataLines = lines
1261        .filter((line) => line.startsWith('data:'))
1262        .map((line) => line.slice(5).trim())
1263  
1264      if (!dataLines.length) return
1265      const payload = dataLines.join('\n')
1266  
1267      let parsed
1268      try {
1269        parsed = JSON.parse(payload)
1270      } catch {
1271        writeText(stdout, payload)
1272        return
1273      }
1274  
1275      if (jsonOutput) {
1276        writeJson(stdout, parsed, true)
1277        return
1278      }
1279  
1280      if (isPlainObject(parsed) && parsed.t === 'md' && typeof parsed.text === 'string') {
1281        writeText(stdout, parsed.text)
1282        return
1283      }
1284  
1285      if (isPlainObject(parsed) && parsed.t === 'err' && typeof parsed.text === 'string') {
1286        writeText(stderr, parsed.text)
1287        return
1288      }
1289  
1290      writeJson(stdout, parsed, false)
1291    }
1292  
1293    while (true) {
1294      const { done, value } = await reader.read()
1295      if (done) break
1296      buffer += decoder.decode(value, { stream: true })
1297  
1298      let match = eventBoundary.exec(buffer)
1299      while (match) {
1300        const splitIndex = match.index
1301        const delimiterLength = match[0].length
1302        const chunk = buffer.slice(0, splitIndex)
1303        buffer = buffer.slice(splitIndex + delimiterLength)
1304        flushChunk(chunk)
1305        match = eventBoundary.exec(buffer)
1306      }
1307    }
1308  
1309    const finalText = decoder.decode()
1310    if (finalText) buffer += finalText
1311    if (buffer.trim()) flushChunk(buffer)
1312  }
1313  
1314  async function fetchJson(fetchImpl, url, headers, timeoutMs) {
1315    const res = await fetchImpl(url, {
1316      method: 'GET',
1317      headers,
1318      signal: AbortSignal.timeout(timeoutMs),
1319    })
1320  
1321    const parsed = await parseResponse(res)
1322    if (!res.ok) {
1323      throw new Error(`Request failed (${res.status}): ${serializePayload(parsed.value)}`)
1324    }
1325  
1326    if (parsed.type !== 'json') {
1327      throw new Error(`Expected JSON response from ${url}`)
1328    }
1329  
1330    return parsed.value
1331  }
1332  
1333  function serializePayload(value) {
1334    if (typeof value === 'string') return value
1335    try {
1336      return JSON.stringify(value)
1337    } catch {
1338      return String(value)
1339    }
1340  }
1341  
1342  function getWaitId(payload, command) {
1343    if (!isPlainObject(payload)) return null
1344  
1345    if (command.waitEntityFrom && typeof payload[command.waitEntityFrom] === 'string') {
1346      return { type: command.waitEntityFrom === 'taskId' ? 'task' : 'run', id: payload[command.waitEntityFrom] }
1347    }
1348  
1349    if (typeof payload.runId === 'string') return { type: 'run', id: payload.runId }
1350    if (isPlainObject(payload.run) && typeof payload.run.id === 'string') return { type: 'run', id: payload.run.id }
1351    if (typeof payload.taskId === 'string') return { type: 'task', id: payload.taskId }
1352  
1353    return null
1354  }
1355  
1356  function isTerminalStatus(status) {
1357    const terminal = new Set([
1358      'completed',
1359      'complete',
1360      'done',
1361      'failed',
1362      'error',
1363      'stopped',
1364      'cancelled',
1365      'canceled',
1366      'timeout',
1367      'timed_out',
1368    ])
1369    return terminal.has(String(status || '').toLowerCase())
1370  }
1371  
1372  async function waitForEntity(opts) {
1373    const {
1374      entityType,
1375      entityId,
1376      fetchImpl,
1377      baseUrl,
1378      headers,
1379      timeoutMs,
1380      intervalMs,
1381      stdout,
1382      jsonOutput,
1383    } = opts
1384  
1385    const route = entityType === 'run' ? `/runs/${encodeURIComponent(entityId)}` : `/tasks/${encodeURIComponent(entityId)}`
1386    const deadline = Date.now() + timeoutMs
1387  
1388    while (Date.now() <= deadline) {
1389      const url = buildApiUrl(baseUrl, route, [])
1390      const payload = await fetchJson(fetchImpl, url, headers, timeoutMs)
1391  
1392      const status = isPlainObject(payload) ? payload.status : undefined
1393      if (status !== undefined) {
1394        stdout.write(`[wait] ${entityType} ${entityId}: ${status}\n`)
1395      }
1396  
1397      if (status !== undefined && isTerminalStatus(status)) {
1398        if (jsonOutput) writeJson(stdout, payload, true)
1399        else writeJson(stdout, payload, false)
1400        return
1401      }
1402  
1403      await new Promise((resolve) => setTimeout(resolve, intervalMs))
1404    }
1405  
1406    throw new Error(`Timed out waiting for ${entityType} ${entityId}`)
1407  }
1408  
1409  function renderGeneralHelp() {
1410    const lines = [
1411      'SwarmClaw CLI',
1412      '',
1413      'Usage:',
1414      '  swarmclaw',
1415      '  swarmclaw help [command]',
1416      '  swarmclaw run|start|stop|status|doctor|update|version',
1417      '  swarmclaw <group> <command> [args] [options]',
1418      '',
1419      'Global options:',
1420      '  --base-url <url>       API base URL (default: http://localhost:3456)',
1421      '  --access-key <key>     Access key override (else SWARMCLAW_API_KEY/SWARMCLAW_ACCESS_KEY or platform-api-key.txt)',
1422      '  --data <json|@file|->  Request JSON body',
1423      '  --query key=value      Query parameter (repeatable)',
1424      '  --header key=value     Extra HTTP header (repeatable)',
1425      '  --json                 Compact JSON output',
1426      '  --wait                 Wait for run/task completion when runId/taskId is returned',
1427      '  --timeout-ms <ms>      Request/wait timeout (default: 300000)',
1428      '  --interval-ms <ms>     Poll interval for --wait (default: 2000)',
1429      '  --out <file>           Write binary response to file',
1430      '  --help                 Show help',
1431      '  --version              Show package version',
1432      '',
1433      'Top-level commands:',
1434      '  run, start             Start the SwarmClaw server',
1435      '  stop                   Stop the detached SwarmClaw server',
1436      '  status                 Show local server status',
1437      '  doctor                 Show local install/build diagnostics',
1438      '  help                   Show root or command help',
1439      '  update                 Update this SwarmClaw installation',
1440      '  version                Show package version',
1441      '',
1442      'Groups:',
1443    ]
1444  
1445    for (const group of COMMAND_GROUPS) {
1446      if (group.aliasFor) {
1447        lines.push(`  ${group.name} (alias for ${group.aliasFor})`)
1448      } else {
1449        lines.push(`  ${group.name}`)
1450      }
1451    }
1452  
1453    lines.push('', 'Use "swarmclaw help <command>" or "swarmclaw <group> --help" for more detail.')
1454    return lines.join('\n')
1455  }
1456  
1457  function renderGroupHelp(groupName) {
1458    const group = GROUP_MAP.get(groupName)
1459    if (!group) {
1460      throw new Error(`Unknown command group: ${groupName}`)
1461    }
1462  
1463    const resolved = resolveGroup(groupName)
1464    if (!resolved) throw new Error(`Unable to resolve command group: ${groupName}`)
1465  
1466    const lines = [
1467      `Group: ${groupName}${group.aliasFor ? ` (alias for ${group.aliasFor})` : ''}`,
1468      group.description ? `Description: ${group.description}` : '',
1469      '',
1470      'Commands:',
1471    ].filter(Boolean)
1472  
1473    for (const command of resolved.commands) {
1474      const params = extractPathParams(command.route).map((name) => `<${name}>`).join(' ')
1475      const suffix = params ? ` ${params}` : ''
1476      lines.push(`  ${command.action}${suffix}  ${command.description}`)
1477    }
1478  
1479    return lines.join('\n')
1480  }
1481  
1482  async function runCli(argv, deps = {}) {
1483    const stdout = deps.stdout || process.stdout
1484    const stderr = deps.stderr || process.stderr
1485    const stdin = deps.stdin || process.stdin
1486    const env = deps.env || process.env
1487    const cwd = deps.cwd || process.cwd()
1488    const fetchImpl = deps.fetchImpl || globalThis.fetch
1489  
1490    if (typeof fetchImpl !== 'function') {
1491      stderr.write('Global fetch is unavailable in this Node runtime. Use Node 18+ or provide a fetch implementation.\n')
1492      return 1
1493    }
1494  
1495    let parsed
1496    try {
1497      parsed = parseArgv(argv)
1498    } catch (err) {
1499      stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1500      return 1
1501    }
1502  
1503    if (parsed.opts.version) {
1504      const pkgPath = path.join(__dirname, '..', '..', 'package.json')
1505      const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
1506      stdout.write(`${pkg.name || 'swarmclaw'} ${pkg.version || '0.0.0'}\n`)
1507      return 0
1508    }
1509  
1510    if (!parsed.group || parsed.opts.help) {
1511      if (parsed.group) {
1512        try {
1513          stdout.write(`${renderGroupHelp(parsed.group)}\n`)
1514          return 0
1515        } catch {
1516          // Fall through to general help for unknown group
1517        }
1518      }
1519      stdout.write(`${renderGeneralHelp()}\n`)
1520      return 0
1521    }
1522  
1523    if (!parsed.action) {
1524      try {
1525        stdout.write(`${renderGroupHelp(parsed.group)}\n`)
1526        return 0
1527      } catch (err) {
1528        stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1529        return 1
1530      }
1531    }
1532  
1533    const command = getCommand(parsed.group, parsed.action)
1534    if (!command) {
1535      stderr.write(`Unknown command: ${parsed.group} ${parsed.action}\n`)
1536      const group = resolveGroup(parsed.group)
1537      if (group) {
1538        stderr.write(`${renderGroupHelp(parsed.group)}\n`)
1539      } else {
1540        stderr.write(`${renderGeneralHelp()}\n`)
1541      }
1542      return 1
1543    }
1544  
1545    const pathArgs = parsed.positionals.slice(2)
1546    let routeInfo
1547    try {
1548      routeInfo = buildRoute(command.route, pathArgs)
1549    } catch (err) {
1550      stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1551      return 1
1552    }
1553  
1554    const accessKey = resolveAccessKey(parsed.opts, env, cwd)
1555    const baseUrl = parsed.opts.baseUrl || env.SWARMCLAW_BASE_URL || env.SWARMCLAW_URL || 'http://localhost:3456'
1556  
1557    const headerEntries = []
1558    for (const raw of parsed.opts.headers) {
1559      try {
1560        headerEntries.push(parseKeyValue(raw, 'header'))
1561      } catch (err) {
1562        stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1563        return 1
1564      }
1565    }
1566  
1567    if (parsed.opts.secret) {
1568      headerEntries.push(['x-webhook-secret', parsed.opts.secret])
1569    }
1570  
1571    const queryEntries = []
1572    for (const raw of parsed.opts.query) {
1573      try {
1574        queryEntries.push(parseKeyValue(raw, 'query'))
1575      } catch (err) {
1576        stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1577        return 1
1578      }
1579    }
1580  
1581    if (parsed.opts.event) {
1582      queryEntries.push(['event', parsed.opts.event])
1583    }
1584  
1585    let url
1586    try {
1587      url = buildApiUrl(baseUrl, routeInfo.route, queryEntries)
1588    } catch (err) {
1589      stderr.write(`Invalid --base-url: ${err instanceof Error ? err.message : String(err)}\n`)
1590      return 1
1591    }
1592  
1593    const headers = {
1594      ...Object.fromEntries(headerEntries),
1595    }
1596    if (accessKey) headers['X-Access-Key'] = accessKey
1597  
1598    try {
1599      if (command.clientGetRoute) {
1600        const collectionUrl = buildApiUrl(baseUrl, command.clientGetRoute, queryEntries)
1601        const payload = await fetchJson(fetchImpl, collectionUrl, headers, parsed.opts.timeoutMs)
1602        const id = pathArgs[0]
1603        const entity = extractById(payload, id)
1604        if (!entity) {
1605          stderr.write(`Entity not found for id: ${id}\n`)
1606          return 1
1607        }
1608        if (parsed.opts.jsonOutput) writeJson(stdout, entity, true)
1609        else writeJson(stdout, entity, false)
1610        return 0
1611      }
1612  
1613      const init = {
1614        method: command.method,
1615        headers,
1616        signal: AbortSignal.timeout(parsed.opts.timeoutMs),
1617      }
1618  
1619      if (command.requestType === 'upload') {
1620        const uploadPath = parsed.opts.file || routeInfo.remaining[0]
1621        if (!uploadPath) {
1622          throw new Error(`Missing file path. Usage: ${parsed.group} ${parsed.action} <filePath>`) }
1623  
1624        const resolvedUploadPath = path.isAbsolute(uploadPath) ? uploadPath : path.join(cwd, uploadPath)
1625        const fileBuffer = fs.readFileSync(resolvedUploadPath)
1626        const filename = parsed.opts.filename || path.basename(resolvedUploadPath)
1627        init.body = fileBuffer
1628        init.headers['x-filename'] = filename
1629        if (!init.headers['Content-Type']) init.headers['Content-Type'] = 'application/octet-stream'
1630      } else if (command.method !== 'GET' && command.method !== 'HEAD') {
1631        let body = undefined
1632        if (parsed.opts.data) {
1633          body = parseDataInput(parsed.opts.data, stdin)
1634        }
1635  
1636        if (!isPlainObject(body) && command.expectsJsonBody) {
1637          body = {}
1638        }
1639  
1640        if (command.defaultBody) {
1641          body = { ...(command.defaultBody || {}), ...(isPlainObject(body) ? body : {}) }
1642        }
1643  
1644        if (command.bodyFlagMap) {
1645          const mapped = {}
1646          for (const [flagName, bodyKey] of Object.entries(command.bodyFlagMap)) {
1647            const val = parsed.opts[flagName]
1648            if (val !== undefined && val !== '') {
1649              mapped[bodyKey] = val
1650            }
1651          }
1652          body = { ...(isPlainObject(body) ? body : {}), ...mapped }
1653        }
1654  
1655        if (body !== undefined) {
1656          init.body = JSON.stringify(body)
1657          init.headers['Content-Type'] = 'application/json'
1658        }
1659      }
1660  
1661      const res = await fetchImpl(url, init)
1662      const parsedResponse = await parseResponse(res, command.responseType)
1663  
1664      if (!res.ok) {
1665        const serialized = serializePayload(parsedResponse.value)
1666        stderr.write(`Request failed (${res.status} ${res.statusText}): ${serialized}\n`)
1667        return 1
1668      }
1669  
1670      if (parsedResponse.type === 'sse') {
1671        await consumeSse(parsedResponse.value, stdout, stderr, parsed.opts.jsonOutput)
1672        return 0
1673      }
1674  
1675      if (parsedResponse.type === 'binary') {
1676        writeBinary(stdout, stderr, parsedResponse.value, parsed.opts.out, cwd)
1677        return 0
1678      }
1679  
1680      if (parsedResponse.type === 'json') {
1681        if (parsed.opts.jsonOutput) writeJson(stdout, parsedResponse.value, true)
1682        else writeJson(stdout, parsedResponse.value, false)
1683  
1684        if (parsed.opts.wait) {
1685          const waitMeta = getWaitId(parsedResponse.value, command)
1686          if (waitMeta) {
1687            await waitForEntity({
1688              entityType: waitMeta.type,
1689              entityId: waitMeta.id,
1690              fetchImpl,
1691              baseUrl,
1692              headers,
1693              timeoutMs: parsed.opts.timeoutMs,
1694              intervalMs: parsed.opts.intervalMs,
1695              stdout,
1696              jsonOutput: parsed.opts.jsonOutput,
1697            })
1698          } else {
1699            stderr.write('--wait requested, but response did not include runId/taskId\n')
1700          }
1701        }
1702        return 0
1703      }
1704  
1705      writeText(stdout, parsedResponse.value)
1706      return 0
1707    } catch (err) {
1708      stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1709      return 1
1710    }
1711  }
1712  
1713  function extractById(payload, id) {
1714    if (!id) return null
1715  
1716    if (Array.isArray(payload)) {
1717      return payload.find((entry) => entry && String(entry.id) === String(id)) || null
1718    }
1719  
1720    if (isPlainObject(payload)) {
1721      if (payload[id]) return payload[id]
1722      if (Array.isArray(payload.items)) {
1723        return payload.items.find((entry) => entry && String(entry.id) === String(id)) || null
1724      }
1725    }
1726  
1727    return null
1728  }
1729  
1730  function getApiCoveragePairs() {
1731    return COMMANDS
1732      .filter((command) => !command.virtual)
1733      .map((command) => `${command.method} ${command.route.split('?')[0]}`)
1734  }
1735  
1736  module.exports = {
1737    COMMAND_GROUPS,
1738    COMMANDS,
1739    parseArgv,
1740    runCli,
1741    getCommand,
1742    getApiCoveragePairs,
1743    buildApiUrl,
1744    extractPathParams,
1745    resolveGroup,
1746    renderGeneralHelp,
1747    renderGroupHelp,
1748  }