quoxcli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Engagement commands: QUOX-SECURITY Phase 0 engagement/scope object.
3
+ *
4
+ * quox engagement create <name> --environment-tier <tier> [--risk-ceiling <tier>]
5
+ * quox engagement list
6
+ * quox engagement show <id>
7
+ * quox engagement add-target <id> <type> <value> [--verification-method <method>]
8
+ * quox engagement verify-target <id> <targetId>
9
+ *
10
+ * Backend: services/collector/server.js's /api/v1/security/engagements
11
+ * routes (collector-native, own security.db, not a proxy). No agent
12
+ * capability exists yet, this is pure engagement/scope CRUD plus the
13
+ * domain-verification challenge that every future security capability
14
+ * call must pass. See lib/securityEngagement.js for the enforcement
15
+ * itself (environment_tier/risk_ceiling validation, verification, and the
16
+ * policy gate wired into approvalGate.js).
17
+ */
18
+
19
+ import { writeFileSync } from 'node:fs';
20
+ import { formatOutput, formatDetail, printSuccess, printInfo } from '../core/output.js';
21
+ import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
22
+ import { resolveEndpoint } from '../api/endpoints.js';
23
+
24
+ export function registerEngagementCommands(program, ctx) {
25
+ const { client, auth } = ctx;
26
+
27
+ const engagement = program
28
+ .command('engagement')
29
+ .description('QUOX-SECURITY: engagements + scope targets, live capability scanners (run via scan/scan-async; see "engagement tools" for the list), and findings (report/verify/regrade/resolve)');
30
+
31
+ engagement
32
+ .command('create <name>')
33
+ .description('Create a new security engagement')
34
+ .requiredOption('--environment-tier <tier>', 'dev | staging | production')
35
+ .option('--risk-ceiling <tier>', 'passive | discovery | safe_active | intrusive | credential_sensitive | exploit_emulation (production caps at discovery in Phase 0)')
36
+ .option('--time-window-start <iso>', 'ISO 8601 timestamp')
37
+ .option('--time-window-end <iso>', 'ISO 8601 timestamp')
38
+ .option('--responsible-project <name>', 'the project/team this engagement belongs to')
39
+ .action(async (name, options) => {
40
+ auth.requireAuth();
41
+ const body = { name, environment_tier: options.environmentTier };
42
+ if (options.riskCeiling) body.risk_ceiling = options.riskCeiling;
43
+ if (options.timeWindowStart || options.timeWindowEnd) {
44
+ body.time_window = { start: options.timeWindowStart || null, end: options.timeWindowEnd || null };
45
+ }
46
+ if (options.responsibleProject) body.responsible_project = options.responsibleProject;
47
+
48
+ const ep = resolveEndpoint('engagement.create');
49
+ const data = await client.post(ep.path, { service: ep.service, body });
50
+ printEvent(events.created('quox', 'engagement', data.engagement.id));
51
+ formatDetail(data.engagement, { format: program.opts().output, title: 'Engagement' });
52
+ });
53
+
54
+ engagement
55
+ .command('list')
56
+ .description('List engagements for the current org')
57
+ .action(async () => {
58
+ auth.requireAuth();
59
+ const ep = resolveEndpoint('engagement.list');
60
+ const data = await client.get(ep.path, { service: ep.service });
61
+ formatOutput({
62
+ columns: ['id', 'name', 'status', 'environment_tier', 'risk_ceiling', 'created_at'],
63
+ headers: ['ID', 'Name', 'Status', 'Environment', 'Risk Ceiling', 'Created'],
64
+ rows: data.engagements || [],
65
+ }, { format: program.opts().output, emptyMessage: 'No engagements yet.' });
66
+ });
67
+
68
+ engagement
69
+ .command('show <id>')
70
+ .description('Show an engagement and its targets')
71
+ .action(async (id) => {
72
+ auth.requireAuth();
73
+ const ep = resolveEndpoint('engagement.get', { id });
74
+ const data = await client.get(ep.path, { service: ep.service });
75
+ formatDetail(data.engagement, { format: program.opts().output, title: 'Engagement' });
76
+ formatOutput({
77
+ columns: ['id', 'type', 'value', 'verification_status', 'verified_at'],
78
+ headers: ['ID', 'Type', 'Value', 'Verification', 'Verified At'],
79
+ rows: data.targets || [],
80
+ }, { format: program.opts().output, emptyMessage: 'No targets added yet.' });
81
+ });
82
+
83
+ engagement
84
+ .command('set-gripe-target <id>')
85
+ .description('Set which repo a finding filed from this engagement goes to, and which org GitHub connector token files it. Lets a customer-site engagement file into the customer repo with the customer org token. Pass --clear (or --repo "") to fall back to the platform default (quoxai/quox) and the engagement own org.')
86
+ .option('--repo <owner/name>', 'GitHub repo for findings from this engagement (owner/name). Pass an empty value to clear.')
87
+ .option('--connector-org <orgId>', 'org whose GitHub connector token files the issue')
88
+ .option('--clear', 'clear both overrides back to the platform default')
89
+ .action(async (id, options) => {
90
+ auth.requireAuth();
91
+ if (!options.clear && options.repo === undefined && options.connectorOrg === undefined) {
92
+ throw new Error('nothing to set: pass --repo <owner/name>, --connector-org <orgId>, or --clear');
93
+ }
94
+ let gripeRepo = null;
95
+ let connectorOrg = null;
96
+ if (!options.clear) {
97
+ gripeRepo = options.repo ? options.repo : null;
98
+ connectorOrg = options.connectorOrg ? options.connectorOrg : null;
99
+ }
100
+ const ep = resolveEndpoint('engagement.gripe_target.set', { id });
101
+ const data = await client.patch(ep.path, {
102
+ service: ep.service,
103
+ body: { gripe_repo: gripeRepo, gripe_connector_org_id: connectorOrg },
104
+ });
105
+ const eng = data.engagement;
106
+ printSuccess(`Gripe target updated for engagement ${eng.id}.`);
107
+ console.log(` Repo: ${eng.gripe_repo || 'platform default (quoxai/quox)'}`);
108
+ console.log(` Connector org: ${eng.gripe_connector_org_id || 'engagement own org'}`);
109
+ });
110
+
111
+ engagement
112
+ .command('add-target <id> <type> <value>')
113
+ .description('Add a target (type: domain | cidr | repo | cloud_account | url) to an engagement')
114
+ .option('--verification-method <method>', 'dns_txt | well_known_file | quoxagent_install (private-network targets, proven via real fleet membership; signed_attestation not implemented in Phase 0)')
115
+ .action(async (id, type, value, options) => {
116
+ auth.requireAuth();
117
+ const body = { type, value };
118
+ if (options.verificationMethod) body.verification_method = options.verificationMethod;
119
+
120
+ const ep = resolveEndpoint('engagement.target.create', { id });
121
+ const data = await client.post(ep.path, { service: ep.service, body });
122
+ printSuccess(`Target "${value}" added to engagement ${id}.`);
123
+ if (data.target.verification_token) {
124
+ if (data.target.verification_method === 'dns_txt') {
125
+ console.log(` Add a DNS TXT record on ${value}: quox-verify=${data.target.verification_token}`);
126
+ } else if (data.target.verification_method === 'well_known_file') {
127
+ console.log(` Serve this content at https://${value}/.well-known/quox-verification: ${data.target.verification_token}`);
128
+ }
129
+ console.log(` Then run: quox engagement verify-target ${id} ${data.target.id}`);
130
+ }
131
+ formatDetail(data.target, { format: program.opts().output, title: 'Target' });
132
+ });
133
+
134
+ engagement
135
+ .command('verify-target <id> <targetId>')
136
+ .description('Run the domain-verification challenge for a target')
137
+ .action(async (id, targetId) => {
138
+ auth.requireAuth();
139
+ const ep = resolveEndpoint('engagement.target.verify', { id, targetId });
140
+ const data = await client.post(ep.path, { service: ep.service, body: {} });
141
+ if (data.target.verification_status === 'verified') {
142
+ printEvent(glyphLine('quox', 'target verified', { result: data.target.value, status: 'ok' }));
143
+ } else {
144
+ console.log(` Verification status: ${data.target.verification_status} (challenge did not match yet).`);
145
+ }
146
+ formatDetail(data.target, { format: program.opts().output, title: 'Target' });
147
+ });
148
+
149
+ engagement
150
+ .command('check-policy <id> <targetValue> <capabilityRisk>')
151
+ .description('Check whether a capability of the given risk tier is currently allowed against a target (passive|discovery|safe_active|intrusive|credential_sensitive|exploit_emulation)')
152
+ .action(async (id, targetValue, capabilityRisk) => {
153
+ auth.requireAuth();
154
+ const ep = resolveEndpoint('engagement.check_policy', { id });
155
+ const data = await client.post(ep.path, { service: ep.service, body: { target_value: targetValue, capability_risk: capabilityRisk } });
156
+ if (data.allowed) {
157
+ printEvent(glyphLine('quox', 'policy check', { result: data.requiresApproval ? 'allowed · requires approval' : 'allowed', status: data.requiresApproval ? 'warn' : 'ok' }));
158
+ } else {
159
+ console.log(` Denied: ${data.reason}`);
160
+ }
161
+ });
162
+
163
+ engagement
164
+ .command('scan <id> <target> <tool>')
165
+ .description('Attempt a Phase 1 capability tool against a verified, in-scope target (tool: discover_hosts | probe_http | discover_app_ports | vuln_scan). Policy-gated; honestly fails if no scan host is configured.')
166
+ .action(async (id, target, tool) => {
167
+ auth.requireAuth();
168
+ const ep = resolveEndpoint('engagement.capability', { id, tool });
169
+ const data = await client.post(ep.path, { service: ep.service, body: { target } });
170
+ if (data.success) {
171
+ printEvent(glyphLine('quox', `${tool}`, { result: 'completed', status: 'ok' }));
172
+ console.log(JSON.stringify(data.output, null, 2));
173
+ } else {
174
+ console.log(` Failed (allowed: ${data.allowed}): ${data.error}`);
175
+ }
176
+ });
177
+
178
+ engagement
179
+ .command('discover-ports <id> <hostTarget>')
180
+ .description('Discover which app ports on a verified, in-scope host actually serve HTTP(S) (httpx over a curated port set), so a scan hits the real surface instead of only :80/:443. With --add, register each discovered endpoint as a verified url target (control re-proven via fleet membership).')
181
+ .option('--add', 'Add each discovered endpoint as a url target and verify it')
182
+ .action(async (id, hostTarget, options) => {
183
+ auth.requireAuth();
184
+ const ep = resolveEndpoint('engagement.discover_app_ports', { id });
185
+ const data = await client.post(ep.path, { service: ep.service, body: { target: hostTarget, add: options.add === true } });
186
+ if (!data.success) {
187
+ console.log(` Failed (allowed: ${data.allowed}): ${data.error}`);
188
+ return;
189
+ }
190
+ const discovered = data.discovered || [];
191
+ printSuccess(`Discovered ${discovered.length} serving endpoint(s) on ${hostTarget}.`);
192
+ formatOutput({
193
+ columns: ['url', 'status_code', 'title'],
194
+ headers: ['URL', 'Status', 'Title'],
195
+ rows: discovered,
196
+ }, { format: program.opts().output, emptyMessage: 'No serving app ports found.' });
197
+ if (options.add) {
198
+ const added = data.added || [];
199
+ const skipped = data.skipped || [];
200
+ printSuccess(`Added ${added.length} url target(s); skipped ${skipped.length} already in scope.`);
201
+ if (added.length) {
202
+ formatOutput({
203
+ columns: ['url', 'targetId', 'verified'],
204
+ headers: ['URL', 'Target', 'Verified'],
205
+ rows: added,
206
+ }, { format: program.opts().output, emptyMessage: 'None added.' });
207
+ }
208
+ }
209
+ });
210
+
211
+ engagement
212
+ .command('tools')
213
+ .description('List the real capability tools (name, risk tier, binary). Quoxcli-parity fix: this catalog backed the dashboard Tools tab but had no CLI verb.')
214
+ .action(async () => {
215
+ auth.requireAuth();
216
+ const ep = resolveEndpoint('engagement.capability_tools');
217
+ const data = await client.get(ep.path, { service: ep.service });
218
+ formatOutput({
219
+ columns: ['tool', 'capabilityRisk', 'binary', 'description'],
220
+ headers: ['Tool', 'Risk', 'Binary', 'Description'],
221
+ rows: data.tools || [],
222
+ }, { format: program.opts().output, emptyMessage: 'No capability tools registered.' });
223
+ });
224
+
225
+ engagement
226
+ .command('scan-async <id> <target> <tool>')
227
+ .description('Start a capability tool as a background job and return immediately (job_id). Use for tools that can outrun a single request, e.g. vuln_scan/nuclei or discover_app_ports over a wide host set. Poll with `quox engagement job <jobId>`.')
228
+ .action(async (id, target, tool) => {
229
+ auth.requireAuth();
230
+ const ep = resolveEndpoint('engagement.capability.start', { id, tool });
231
+ const data = await client.post(ep.path, { service: ep.service, body: { target } });
232
+ if (data.success && data.job_id) {
233
+ printSuccess(`${tool} started as job ${data.job_id} (status: ${data.status}).`);
234
+ console.log(` Poll: quox engagement job ${data.job_id}`);
235
+ } else {
236
+ console.log(` Failed (allowed: ${data.allowed}): ${data.error}`);
237
+ }
238
+ });
239
+
240
+ engagement
241
+ .command('job <jobId>')
242
+ .description('Check the status/result of an async scan job (queued | running | succeeded | failed)')
243
+ .action(async (jobId) => {
244
+ auth.requireAuth();
245
+ const ep = resolveEndpoint('engagement.job.get', { jobId });
246
+ const data = await client.get(ep.path, { service: ep.service });
247
+ formatDetail(data.job, { format: program.opts().output, title: 'Scan job' });
248
+ });
249
+
250
+ engagement
251
+ .command('jobs <id>')
252
+ .description('List async scan jobs for an engagement')
253
+ .action(async (id) => {
254
+ auth.requireAuth();
255
+ const ep = resolveEndpoint('engagement.jobs.list', { id });
256
+ const data = await client.get(ep.path, { service: ep.service });
257
+ formatOutput({
258
+ columns: ['id', 'tool', 'target', 'status', 'created_at', 'finished_at'],
259
+ headers: ['ID', 'Tool', 'Target', 'Status', 'Created', 'Finished'],
260
+ rows: data.jobs || [],
261
+ }, { format: program.opts().output, emptyMessage: 'No scan jobs yet.' });
262
+ });
263
+
264
+ engagement
265
+ .command('report-finding <id> <targetValue> <title> <description>')
266
+ .description('Report a candidate finding against a target within an engagement. Always starts as status "candidate"')
267
+ .option('--severity <level>', 'info | low | medium | high | critical', 'info')
268
+ .action(async (id, targetValue, title, description, options) => {
269
+ auth.requireAuth();
270
+ const ep = resolveEndpoint('engagement.findings.report', { id });
271
+ const data = await client.post(ep.path, {
272
+ service: ep.service,
273
+ body: { target_value: targetValue, title, description, severity: options.severity },
274
+ });
275
+ printSuccess(`Finding "${data.finding.id}" reported (status: candidate).`);
276
+ formatDetail(data.finding, { format: program.opts().output, title: 'Finding' });
277
+ // quox#601 report-time reminder: severity defaults to 'info', and a batch
278
+ // left there ships understated on the published /security projection
279
+ // (that is exactly what bit trust-proof cycle 3). Nudge, don't block: a
280
+ // genuinely-info finding is fine, but the default should be a choice.
281
+ if (data.finding?.severity === 'info') {
282
+ printInfo(`Severity is 'info' (the default). If that understates it, re-grade with a reason: quox engagement regrade-finding ${data.finding.id} <severity> --reason "<why>"`);
283
+ }
284
+ });
285
+
286
+ engagement
287
+ .command('findings <id>')
288
+ .description('List findings for an engagement')
289
+ .action(async (id) => {
290
+ auth.requireAuth();
291
+ const ep = resolveEndpoint('engagement.findings.list', { id });
292
+ const data = await client.get(ep.path, { service: ep.service });
293
+ const findings = data.findings || [];
294
+ // Surface gripe_url/quoxplan_ref columns only when at least one
295
+ // finding actually has a value — an all-empty column is just noise.
296
+ const hasGripe = findings.some((f) => f.gripe_url);
297
+ const hasQuoxplan = findings.some((f) => f.quoxplan_ref);
298
+ const columns = ['id', 'title', 'severity', 'status', 'target_value', 'created_at'];
299
+ const headers = ['ID', 'Title', 'Severity', 'Status', 'Target', 'Created'];
300
+ if (hasGripe) { columns.push('gripe_url'); headers.push('Issue'); }
301
+ if (hasQuoxplan) { columns.push('quoxplan_ref'); headers.push('QuoxPlan'); }
302
+ formatOutput({
303
+ columns,
304
+ headers,
305
+ rows: findings,
306
+ }, { format: program.opts().output, emptyMessage: 'No findings yet.' });
307
+ });
308
+
309
+ engagement
310
+ .command('verify-finding <findingId> <verdict>')
311
+ .description('Confirm or refute a candidate finding (verdict: confirmed | refuted). A reason is required, a bare verdict is refused')
312
+ .requiredOption('--reason <text>', 'why this verdict was reached')
313
+ .action(async (findingId, verdict, options) => {
314
+ auth.requireAuth();
315
+ const ep = resolveEndpoint('engagement.findings.verify', { findingId });
316
+ const data = await client.post(ep.path, { service: ep.service, body: { verdict, reason: options.reason } });
317
+ printSuccess(`Finding "${data.finding.id}" ${data.finding.status}.`);
318
+ formatDetail(data.finding, { format: program.opts().output, title: 'Finding' });
319
+ });
320
+
321
+ engagement
322
+ .command('resolve-finding <findingId>')
323
+ .description('Mark a finding remediated (status: resolved). Refuses a refuted finding')
324
+ .action(async (findingId) => {
325
+ auth.requireAuth();
326
+ const ep = resolveEndpoint('engagement.findings.resolve', { findingId });
327
+ const data = await client.post(ep.path, { service: ep.service, body: {} });
328
+ printSuccess(`Finding "${data.finding.id}" ${data.finding.status}.`);
329
+ formatDetail(data.finding, { format: program.opts().output, title: 'Finding' });
330
+ });
331
+
332
+ engagement
333
+ .command('regrade-finding <findingId> <severity>')
334
+ .description('Re-grade a reported finding\'s severity (info | low | medium | high | critical). A reason is required, the same discipline as verify-finding, because severity is a published field')
335
+ .requiredOption('--reason <text>', 'why the severity is being changed')
336
+ .action(async (findingId, severity, options) => {
337
+ auth.requireAuth();
338
+ const ep = resolveEndpoint('engagement.findings.regrade', { findingId });
339
+ const data = await client.post(ep.path, { service: ep.service, body: { severity, reason: options.reason } });
340
+ const f = data.finding;
341
+ printSuccess(`Finding "${f.id}" re-graded ${f.severity_from} -> ${f.severity}.`);
342
+ formatDetail(f, { format: program.opts().output, title: 'Finding' });
343
+ });
344
+
345
+ // Finding -> next-steps pipeline: lib/securityReportActions.js. Raise
346
+ // issue (GitHub issue) / Propose as QuoxPlan / Download PDF, mirroring the
347
+ // dashboard Findings tab's action bar so nothing ships dashboard-only
348
+ // (quoxcli-parity gate).
349
+
350
+ // Known output-format values understood by the global -o/--output flag
351
+ // (bin/quox.js). The `report` command below reuses -o/--output for a
352
+ // different purpose (destination file path), which collides with that
353
+ // global flag at the root parser — a real path passed after `report <id>`
354
+ // lands in `program.opts().output` rather than this command's own
355
+ // `options.output`. Anything that isn't a recognized format name is
356
+ // treated as the destination file rather than silently dropped.
357
+ const KNOWN_OUTPUT_FORMATS = new Set(['table', 'json', 'csv', 'quiet']);
358
+
359
+ engagement
360
+ .command('report <id>')
361
+ .description('Download the engagement report (targets, tools run, findings) as a PDF')
362
+ .option('--pdf', 'Format flag — PDF is the only format currently shipped, kept for forward compatibility')
363
+ .option('--output <file>', 'Output file path (default: quox-security-<id>.pdf)')
364
+ .action(async (id, options) => {
365
+ auth.requireAuth();
366
+ const ep = resolveEndpoint('engagement.report.pdf', { id });
367
+ const res = await client.get(ep.path, { service: ep.service, raw: true });
368
+ if (!res.ok) {
369
+ let data;
370
+ try { data = await res.json(); } catch { data = { error: res.statusText }; }
371
+ throw new Error(data.error || `Request failed with status ${res.status}`);
372
+ }
373
+ const buf = Buffer.from(await res.arrayBuffer());
374
+ const rootOutput = program.opts().output;
375
+ const outFile = options.output
376
+ || (rootOutput && !KNOWN_OUTPUT_FORMATS.has(rootOutput) ? rootOutput : null)
377
+ || `quox-security-${id}.pdf`;
378
+ writeFileSync(outFile, buf);
379
+ printSuccess(`Report saved to ${outFile} (${buf.length} bytes).`);
380
+ });
381
+
382
+ async function raiseIssue(findingId) {
383
+ auth.requireAuth();
384
+ const ep = resolveEndpoint('engagement.findings.gripe', { findingId });
385
+ const data = await client.post(ep.path, { service: ep.service, body: {} });
386
+ if (data.configured === false) {
387
+ console.log(` ${data.message || 'GitHub not configured.'}`);
388
+ return;
389
+ }
390
+ if (data.ok === false) {
391
+ console.log(` ${data.message || 'GitHub connection rejected.'}`);
392
+ return;
393
+ }
394
+ if (data.alreadyFiled) {
395
+ console.log(` Already filed: ${data.url}`);
396
+ return;
397
+ }
398
+ printSuccess(`Filed ${data.url} (repo: ${data.repo}).`);
399
+ }
400
+
401
+ engagement
402
+ .command('issue <findingId>')
403
+ .description('Raise a finding as a GitHub issue. Honest no-op if GitHub is not configured or the stored token is rejected, never a fake success. Skips re-filing if already raised.')
404
+ .action(raiseIssue);
405
+
406
+ // Deprecated alias, kept working (not advertised) so nothing that
407
+ // already scripted `quox engagement gripe` breaks.
408
+ engagement
409
+ .command('gripe <findingId>', { hidden: true })
410
+ .description('Deprecated alias for "quox engagement issue"')
411
+ .action(raiseIssue);
412
+
413
+ engagement
414
+ .command('propose <findingId>')
415
+ .description('Propose a finding as a QuoxPlan note (default stream: QUOX-SECURITY, #438)')
416
+ .option('--stream-id <id>', 'QuoxPlan stream id to append the note to (default QUOX-SECURITY)')
417
+ .action(async (findingId, options) => {
418
+ auth.requireAuth();
419
+ const ep = resolveEndpoint('engagement.findings.propose', { findingId });
420
+ const body = options.streamId ? { stream_id: options.streamId } : {};
421
+ const data = await client.post(ep.path, { service: ep.service, body });
422
+ printSuccess(`Proposed on ${data.streamId} (note ${data.note?.id ?? '—'}).`);
423
+ });
424
+
425
+ // QUOX-SECURITY-SCHED Phase 4: recurring scan schedules — a standing
426
+ // instruction to re-run a tool against a target on a fixed cadence,
427
+ // owned by an engagement. Backend: lib/securityEngagementSchedules.js
428
+ // (storage) + lib/securityScanScheduler.js (the server-side loop that
429
+ // actually fires due schedules). MIN_INTERVAL_SECONDS=300 is enforced
430
+ // server-side; a too-short --interval fails at create, not silently.
431
+ const schedule = engagement
432
+ .command('schedule')
433
+ .description('Recurring scan schedules (re-run a tool on a fixed cadence)');
434
+
435
+ schedule
436
+ .command('create <engagementId> <target> <tool>')
437
+ .description('Create a recurring schedule (tool: bare capability name, e.g. vuln_scan)')
438
+ .option('--interval <seconds>', 'interval cadence in seconds (minimum 300)')
439
+ .option('--cron <expr>', 'cron cadence, a 5-field UTC expression e.g. "0 3 * * 3" for weekly Wed 03:00')
440
+ .action(async (engagementId, target, tool, options) => {
441
+ auth.requireAuth();
442
+ if (options.interval && options.cron) {
443
+ throw new Error('--interval and --cron are mutually exclusive; pass exactly one cadence');
444
+ }
445
+ if (!options.interval && !options.cron) {
446
+ throw new Error('a cadence is required: pass --interval <seconds> or --cron "<expr>"');
447
+ }
448
+ const body = { target_value: target, tool };
449
+ if (options.cron) {
450
+ body.schedule_kind = 'cron';
451
+ body.cron_expression = options.cron;
452
+ } else {
453
+ const intervalSeconds = parseInt(options.interval, 10);
454
+ if (!Number.isInteger(intervalSeconds) || String(intervalSeconds) !== String(options.interval).trim()) {
455
+ throw new Error(`--interval must be an integer number of seconds, got "${options.interval}"`);
456
+ }
457
+ body.interval_seconds = intervalSeconds;
458
+ }
459
+ const ep = resolveEndpoint('engagement.schedule.create', { id: engagementId });
460
+ const data = await client.post(ep.path, {
461
+ service: ep.service,
462
+ body,
463
+ });
464
+ printSuccess(`Schedule "${data.schedule.id}" created (next run: ${data.schedule.next_run_at}).`);
465
+ formatDetail(data.schedule, { format: program.opts().output, title: 'Schedule' });
466
+ });
467
+
468
+ schedule
469
+ .command('list <engagementId>')
470
+ .description('List schedules for an engagement')
471
+ .action(async (engagementId) => {
472
+ auth.requireAuth();
473
+ const ep = resolveEndpoint('engagement.schedule.list', { id: engagementId });
474
+ const data = await client.get(ep.path, { service: ep.service });
475
+ formatOutput({
476
+ columns: ['id', 'target_value', 'tool', 'interval_seconds', 'enabled', 'next_run_at', 'last_status'],
477
+ headers: ['ID', 'Target', 'Tool', 'Interval (s)', 'Enabled', 'Next Run', 'Last Status'],
478
+ rows: data.schedules || [],
479
+ }, { format: program.opts().output, emptyMessage: 'No schedules yet.' });
480
+ });
481
+
482
+ schedule
483
+ .command('show <scheduleId>')
484
+ .description('Show a schedule, including its last diff summary')
485
+ .action(async (scheduleId) => {
486
+ auth.requireAuth();
487
+ const ep = resolveEndpoint('engagement.schedule.get', { scheduleId });
488
+ const data = await client.get(ep.path, { service: ep.service });
489
+ formatDetail(data.schedule, { format: program.opts().output, title: 'Schedule' });
490
+ if (data.schedule?.last_diff) {
491
+ console.log(` Last diff: +${data.schedule.last_diff.addedCount ?? 0} -${data.schedule.last_diff.removedCount ?? 0} (${data.schedule.last_diff.newHighSeverity?.length ?? 0} new high-severity)`);
492
+ }
493
+ });
494
+
495
+ schedule
496
+ .command('update <scheduleId>')
497
+ .description('Edit a schedule: change its cadence (--interval or --cron), target, tool, or enabled state')
498
+ .option('--interval <seconds>', 'switch to interval cadence in seconds (minimum 300)')
499
+ .option('--cron <expr>', 'switch to cron cadence, a 5-field UTC expression e.g. "0 3 * * 3"')
500
+ .option('--target <value>', 'change the target value (must be a verified, in-scope target)')
501
+ .option('--tool <name>', 'change the tool (bare capability name, e.g. vuln_scan)')
502
+ .option('--enable', 'enable the schedule')
503
+ .option('--disable', 'disable the schedule')
504
+ .action(async (scheduleId, options) => {
505
+ auth.requireAuth();
506
+ if (options.interval && options.cron) {
507
+ throw new Error('--interval and --cron are mutually exclusive; pass at most one cadence');
508
+ }
509
+ if (options.enable && options.disable) {
510
+ throw new Error('--enable and --disable are mutually exclusive');
511
+ }
512
+ const body = {};
513
+ if (options.cron) {
514
+ body.schedule_kind = 'cron';
515
+ body.cron_expression = options.cron;
516
+ } else if (options.interval) {
517
+ const intervalSeconds = parseInt(options.interval, 10);
518
+ if (!Number.isInteger(intervalSeconds) || String(intervalSeconds) !== String(options.interval).trim()) {
519
+ throw new Error(`--interval must be an integer number of seconds, got "${options.interval}"`);
520
+ }
521
+ body.schedule_kind = 'interval';
522
+ body.interval_seconds = intervalSeconds;
523
+ }
524
+ if (options.target) body.target_value = options.target;
525
+ if (options.tool) body.tool = options.tool;
526
+ if (options.enable) body.enabled = true;
527
+ if (options.disable) body.enabled = false;
528
+ if (Object.keys(body).length === 0) {
529
+ throw new Error('nothing to update: pass at least one of --interval, --cron, --target, --tool, --enable, --disable');
530
+ }
531
+ const ep = resolveEndpoint('engagement.schedule.update', { scheduleId });
532
+ const data = await client.patch(ep.path, { service: ep.service, body });
533
+ printSuccess(`Schedule "${data.schedule.id}" updated (next run: ${data.schedule.next_run_at}).`);
534
+ formatDetail(data.schedule, { format: program.opts().output, title: 'Schedule' });
535
+ });
536
+
537
+ schedule
538
+ .command('enable <scheduleId>')
539
+ .description('Re-enable a schedule')
540
+ .action(async (scheduleId) => {
541
+ auth.requireAuth();
542
+ const ep = resolveEndpoint('engagement.schedule.enable', { scheduleId });
543
+ const data = await client.post(ep.path, { service: ep.service, body: {} });
544
+ printSuccess(`Schedule "${data.schedule.id}" enabled (next run: ${data.schedule.next_run_at}).`);
545
+ });
546
+
547
+ schedule
548
+ .command('disable <scheduleId>')
549
+ .description('Disable a schedule (stops firing; existing history kept)')
550
+ .action(async (scheduleId) => {
551
+ auth.requireAuth();
552
+ const ep = resolveEndpoint('engagement.schedule.disable', { scheduleId });
553
+ const data = await client.post(ep.path, { service: ep.service, body: {} });
554
+ printSuccess(`Schedule "${data.schedule.id}" disabled.`);
555
+ });
556
+
557
+ schedule
558
+ .command('delete <scheduleId>')
559
+ .description('Delete a schedule')
560
+ .action(async (scheduleId) => {
561
+ auth.requireAuth();
562
+ const ep = resolveEndpoint('engagement.schedule.delete', { scheduleId });
563
+ await client.delete(ep.path, { service: ep.service });
564
+ printSuccess(`Schedule "${scheduleId}" deleted.`);
565
+ });
566
+
567
+ engagement
568
+ .command('sync-issues <id>')
569
+ .description('Reconcile this engagement\'s filed findings against their GitHub issue state. A closed issue auto-resolves its finding. Honest no-op if GitHub is not configured')
570
+ .action(async (id) => {
571
+ auth.requireAuth();
572
+ const ep = resolveEndpoint('engagement.sync_issue_states', { id });
573
+ const data = await client.post(ep.path, { service: ep.service, body: {} });
574
+ if (data.configured === false) {
575
+ console.log(` ${data.message || 'GitHub not configured.'}`);
576
+ return;
577
+ }
578
+ printSuccess(`Checked ${data.checked ?? 0}, resolved ${data.resolved?.length ?? 0}.`);
579
+ if (data.resolved?.length) {
580
+ console.log(` Resolved: ${data.resolved.join(', ')}`);
581
+ }
582
+ if (data.errors?.length) {
583
+ console.log(` Errors: ${data.errors.map((e) => `${e.findingId} (${e.reason})`).join(', ')}`);
584
+ }
585
+ });
586
+ }