ghost-dragon 4.2.1

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 (226) hide show
  1. package/.github/workflows/ci.yml +23 -0
  2. package/CHANGELOG.md +96 -0
  3. package/README.md +193 -0
  4. package/bootstrap.ps1 +83 -0
  5. package/bootstrap.sh +71 -0
  6. package/dist/agent/loop.d.ts +68 -0
  7. package/dist/agent/loop.d.ts.map +1 -0
  8. package/dist/agent/loop.js +135 -0
  9. package/dist/agent/mcp.d.ts +33 -0
  10. package/dist/agent/mcp.d.ts.map +1 -0
  11. package/dist/agent/mcp.js +107 -0
  12. package/dist/agent/session.d.ts +16 -0
  13. package/dist/agent/session.d.ts.map +1 -0
  14. package/dist/agent/session.js +55 -0
  15. package/dist/agent/skills.d.ts +36 -0
  16. package/dist/agent/skills.d.ts.map +1 -0
  17. package/dist/agent/skills.js +153 -0
  18. package/dist/agent/stack.d.ts +21 -0
  19. package/dist/agent/stack.d.ts.map +1 -0
  20. package/dist/agent/stack.js +158 -0
  21. package/dist/agent/task.d.ts +21 -0
  22. package/dist/agent/task.d.ts.map +1 -0
  23. package/dist/agent/task.js +45 -0
  24. package/dist/agent/tools.d.ts +44 -0
  25. package/dist/agent/tools.d.ts.map +1 -0
  26. package/dist/agent/tools.js +262 -0
  27. package/dist/agent/trace.d.ts +34 -0
  28. package/dist/agent/trace.d.ts.map +1 -0
  29. package/dist/agent/trace.js +72 -0
  30. package/dist/agent.d.ts +46 -0
  31. package/dist/agent.d.ts.map +1 -0
  32. package/dist/agent.js +103 -0
  33. package/dist/auth.d.ts +74 -0
  34. package/dist/auth.d.ts.map +1 -0
  35. package/dist/auth.js +116 -0
  36. package/dist/brain/anthropic.d.ts +19 -0
  37. package/dist/brain/anthropic.d.ts.map +1 -0
  38. package/dist/brain/anthropic.js +74 -0
  39. package/dist/brain/claude-cli.d.ts +20 -0
  40. package/dist/brain/claude-cli.d.ts.map +1 -0
  41. package/dist/brain/claude-cli.js +79 -0
  42. package/dist/brain/ghost-ember.d.ts +28 -0
  43. package/dist/brain/ghost-ember.d.ts.map +1 -0
  44. package/dist/brain/ghost-ember.js +97 -0
  45. package/dist/brain/index.d.ts +22 -0
  46. package/dist/brain/index.d.ts.map +1 -0
  47. package/dist/brain/index.js +95 -0
  48. package/dist/brain/openai-compat.d.ts +21 -0
  49. package/dist/brain/openai-compat.d.ts.map +1 -0
  50. package/dist/brain/openai-compat.js +119 -0
  51. package/dist/brain/router/classify.d.ts +23 -0
  52. package/dist/brain/router/classify.d.ts.map +1 -0
  53. package/dist/brain/router/classify.js +160 -0
  54. package/dist/brain/router/execute.d.ts +23 -0
  55. package/dist/brain/router/execute.d.ts.map +1 -0
  56. package/dist/brain/router/execute.js +84 -0
  57. package/dist/brain/router/index.d.ts +26 -0
  58. package/dist/brain/router/index.d.ts.map +1 -0
  59. package/dist/brain/router/index.js +118 -0
  60. package/dist/brain/router/routing-memory.d.ts +27 -0
  61. package/dist/brain/router/routing-memory.d.ts.map +1 -0
  62. package/dist/brain/router/routing-memory.js +77 -0
  63. package/dist/brain/router/select.d.ts +32 -0
  64. package/dist/brain/router/select.d.ts.map +1 -0
  65. package/dist/brain/router/select.js +146 -0
  66. package/dist/brain/router/two-hop.d.ts +23 -0
  67. package/dist/brain/router/two-hop.d.ts.map +1 -0
  68. package/dist/brain/router/two-hop.js +39 -0
  69. package/dist/brain/router/verify.d.ts +37 -0
  70. package/dist/brain/router/verify.d.ts.map +1 -0
  71. package/dist/brain/router/verify.js +111 -0
  72. package/dist/brain/types.d.ts +55 -0
  73. package/dist/brain/types.d.ts.map +1 -0
  74. package/dist/brain/types.js +16 -0
  75. package/dist/brain/worker.d.ts +27 -0
  76. package/dist/brain/worker.d.ts.map +1 -0
  77. package/dist/brain/worker.js +71 -0
  78. package/dist/commands/ai.d.ts +24 -0
  79. package/dist/commands/ai.d.ts.map +1 -0
  80. package/dist/commands/ai.js +137 -0
  81. package/dist/commands/alerts.d.ts +19 -0
  82. package/dist/commands/alerts.d.ts.map +1 -0
  83. package/dist/commands/alerts.js +114 -0
  84. package/dist/commands/billing.d.ts +13 -0
  85. package/dist/commands/billing.d.ts.map +1 -0
  86. package/dist/commands/billing.js +55 -0
  87. package/dist/commands/chat.d.ts +22 -0
  88. package/dist/commands/chat.d.ts.map +1 -0
  89. package/dist/commands/chat.js +422 -0
  90. package/dist/commands/config.d.ts +18 -0
  91. package/dist/commands/config.d.ts.map +1 -0
  92. package/dist/commands/config.js +136 -0
  93. package/dist/commands/doctor.d.ts +11 -0
  94. package/dist/commands/doctor.d.ts.map +1 -0
  95. package/dist/commands/doctor.js +73 -0
  96. package/dist/commands/global.d.ts +11 -0
  97. package/dist/commands/global.d.ts.map +1 -0
  98. package/dist/commands/global.js +253 -0
  99. package/dist/commands/keep.d.ts +12 -0
  100. package/dist/commands/keep.d.ts.map +1 -0
  101. package/dist/commands/keep.js +58 -0
  102. package/dist/commands/lifecycle.d.ts +17 -0
  103. package/dist/commands/lifecycle.d.ts.map +1 -0
  104. package/dist/commands/lifecycle.js +267 -0
  105. package/dist/commands/login.d.ts +16 -0
  106. package/dist/commands/login.d.ts.map +1 -0
  107. package/dist/commands/login.js +234 -0
  108. package/dist/commands/maintenance.d.ts +12 -0
  109. package/dist/commands/maintenance.d.ts.map +1 -0
  110. package/dist/commands/maintenance.js +76 -0
  111. package/dist/commands/mcp.d.ts +16 -0
  112. package/dist/commands/mcp.d.ts.map +1 -0
  113. package/dist/commands/mcp.js +56 -0
  114. package/dist/commands/memory.d.ts +13 -0
  115. package/dist/commands/memory.d.ts.map +1 -0
  116. package/dist/commands/memory.js +218 -0
  117. package/dist/commands/osint.d.ts +14 -0
  118. package/dist/commands/osint.d.ts.map +1 -0
  119. package/dist/commands/osint.js +161 -0
  120. package/dist/commands/pentest.d.ts +13 -0
  121. package/dist/commands/pentest.d.ts.map +1 -0
  122. package/dist/commands/pentest.js +131 -0
  123. package/dist/commands/scale.d.ts +14 -0
  124. package/dist/commands/scale.d.ts.map +1 -0
  125. package/dist/commands/scale.js +191 -0
  126. package/dist/commands/serve.d.ts +16 -0
  127. package/dist/commands/serve.d.ts.map +1 -0
  128. package/dist/commands/serve.js +167 -0
  129. package/dist/commands/tui.d.ts +17 -0
  130. package/dist/commands/tui.d.ts.map +1 -0
  131. package/dist/commands/tui.js +138 -0
  132. package/dist/commands/wyrm.d.ts +20 -0
  133. package/dist/commands/wyrm.d.ts.map +1 -0
  134. package/dist/commands/wyrm.js +274 -0
  135. package/dist/config.d.ts +67 -0
  136. package/dist/config.d.ts.map +1 -0
  137. package/dist/config.js +54 -0
  138. package/dist/index.d.ts +16 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +85 -0
  141. package/dist/manifest.d.ts +31 -0
  142. package/dist/manifest.d.ts.map +1 -0
  143. package/dist/manifest.js +83 -0
  144. package/dist/ui.d.ts +57 -0
  145. package/dist/ui.d.ts.map +1 -0
  146. package/dist/ui.js +174 -0
  147. package/dist/utils.d.ts +33 -0
  148. package/dist/utils.d.ts.map +1 -0
  149. package/dist/utils.js +155 -0
  150. package/dist/wyrm/mcp.d.ts +37 -0
  151. package/dist/wyrm/mcp.d.ts.map +1 -0
  152. package/dist/wyrm/mcp.js +137 -0
  153. package/docs/SYSTEM-PREMORTEM.md +397 -0
  154. package/dragon-manifest.toml +241 -0
  155. package/dragon.py +177 -0
  156. package/install/launchd/lk.ghosts.dragonkeep.plist +57 -0
  157. package/install/systemd/dragonkeep.service +40 -0
  158. package/media/dragon-silver-lockup.svg +931 -0
  159. package/media/dragon-silver-mark.svg +931 -0
  160. package/media/dragon-silver.png +0 -0
  161. package/package.json +45 -0
  162. package/specs/001-godmode/constitution.md +54 -0
  163. package/specs/001-godmode/plan.md +30 -0
  164. package/specs/001-godmode/spec.md +64 -0
  165. package/specs/001-godmode/tasks.md +35 -0
  166. package/specs/002-premortem-positioning/premortem.md +211 -0
  167. package/src/agent/loop.ts +165 -0
  168. package/src/agent/mcp.ts +92 -0
  169. package/src/agent/session.ts +48 -0
  170. package/src/agent/skills.ts +138 -0
  171. package/src/agent/stack.ts +154 -0
  172. package/src/agent/task.ts +55 -0
  173. package/src/agent/tools.ts +255 -0
  174. package/src/agent/trace.ts +76 -0
  175. package/src/agent.ts +114 -0
  176. package/src/auth.ts +133 -0
  177. package/src/brain/anthropic.ts +83 -0
  178. package/src/brain/claude-cli.ts +78 -0
  179. package/src/brain/ghost-ember.ts +94 -0
  180. package/src/brain/index.ts +99 -0
  181. package/src/brain/openai-compat.ts +115 -0
  182. package/src/brain/router/classify.ts +167 -0
  183. package/src/brain/router/execute.ts +80 -0
  184. package/src/brain/router/index.ts +125 -0
  185. package/src/brain/router/routing-memory.ts +71 -0
  186. package/src/brain/router/select.ts +156 -0
  187. package/src/brain/router/two-hop.ts +62 -0
  188. package/src/brain/router/verify.ts +123 -0
  189. package/src/brain/types.ts +61 -0
  190. package/src/brain/worker.ts +72 -0
  191. package/src/commands/ai.ts +144 -0
  192. package/src/commands/alerts.ts +131 -0
  193. package/src/commands/billing.ts +59 -0
  194. package/src/commands/chat.ts +318 -0
  195. package/src/commands/config.ts +137 -0
  196. package/src/commands/doctor.ts +71 -0
  197. package/src/commands/global.ts +256 -0
  198. package/src/commands/keep.ts +67 -0
  199. package/src/commands/lifecycle.ts +273 -0
  200. package/src/commands/login.ts +184 -0
  201. package/src/commands/maintenance.ts +54 -0
  202. package/src/commands/mcp.ts +57 -0
  203. package/src/commands/memory.ts +229 -0
  204. package/src/commands/osint.ts +171 -0
  205. package/src/commands/pentest.ts +140 -0
  206. package/src/commands/scale.ts +185 -0
  207. package/src/commands/serve.ts +171 -0
  208. package/src/commands/tui.ts +126 -0
  209. package/src/commands/wyrm.ts +269 -0
  210. package/src/config.ts +93 -0
  211. package/src/index.ts +92 -0
  212. package/src/manifest.ts +104 -0
  213. package/src/ui.ts +188 -0
  214. package/src/utils.ts +153 -0
  215. package/src/wyrm/mcp.ts +130 -0
  216. package/test/auth.test.ts +70 -0
  217. package/test/brain.test.ts +39 -0
  218. package/test/security.test.ts +104 -0
  219. package/test/skills.test.ts +38 -0
  220. package/test/ui.test.ts +46 -0
  221. package/tsconfig.json +19 -0
  222. package/worker/package-lock.json +1527 -0
  223. package/worker/package.json +17 -0
  224. package/worker/src/index.ts +76 -0
  225. package/worker/tsconfig.json +15 -0
  226. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * dragon ai — Bridge the dragon stack to AI clients (Claude Code,
3
+ * Codex, Copilot CLI, Aider, Cursor, etc.)
4
+ *
5
+ * ai context <target> — Print a compact context dump suitable
6
+ * for splicing into any LLM system prompt
7
+ * ai mcp-config — Print Claude Desktop MCP JSON config
8
+ * ai brief <target> — Print engagement brief Markdown
9
+ * ai prompt <target> — Print operator-ready prompt with
10
+ * memory context + suggested actions
11
+ *
12
+ * Designed so an operator running Claude Code in another window can do:
13
+ *
14
+ * $ dragon ai prompt upalis.com | claude
15
+ *
16
+ * and Claude picks up the engagement context immediately.
17
+ *
18
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
19
+ * Author: Ryan Sebastian <ryan@ghosts.lk>
20
+ */
21
+
22
+ import type { Command } from 'commander'
23
+ import type { DragonConfig } from '../config.js'
24
+ import { fetchJSON, label, error, info } from '../utils.js'
25
+ import chalk from 'chalk'
26
+
27
+ function api(config: DragonConfig, path: string): string {
28
+ const port = config.products.pentest.controlPort ?? 4091
29
+ return `http://localhost:${port}${path}`
30
+ }
31
+
32
+ interface Suggestion {
33
+ verb: string
34
+ title: string
35
+ rationale: string
36
+ command: string
37
+ }
38
+
39
+ export function registerAiCommands(program: Command, config: DragonConfig) {
40
+ const ai = program.command('ai').description('Bridge the dragon stack to AI clients')
41
+
42
+ // --- context ---
43
+ ai.command('context <target>')
44
+ .description('Print compact context for splicing into an LLM system prompt')
45
+ .action(async (target) => {
46
+ try {
47
+ const port = config.products.pentest.controlPort ?? 4091
48
+ const res = await fetch(`http://localhost:${port}/v1/memory/target/${encodeURIComponent(target)}/context`)
49
+ if (!res.ok) { error(`HTTP ${res.status}`); return }
50
+ process.stdout.write(await res.text())
51
+ } catch (e) {
52
+ error(String(e))
53
+ }
54
+ })
55
+
56
+ // --- prompt ---
57
+ ai.command('prompt <target>')
58
+ .description('Operator-ready prompt block: identity + memory + suggested actions')
59
+ .action(async (target) => {
60
+ try {
61
+ const port = config.products.pentest.controlPort ?? 4091
62
+ const [ctxR, sugR] = await Promise.all([
63
+ fetch(`http://localhost:${port}/v1/memory/target/${encodeURIComponent(target)}/context`),
64
+ fetchJSON<{ suggestions: Suggestion[] }>(api(config, `/v1/memory/target/${encodeURIComponent(target)}/copilot`)),
65
+ ])
66
+ const ctx = ctxR.ok ? await ctxR.text() : "# No prior memory."
67
+ process.stdout.write([
68
+ "You are operating inside Ghost Protocol's offensive-security stack.",
69
+ "Target context follows. Cite scan_ids when referring to prior findings.",
70
+ "",
71
+ "---",
72
+ "",
73
+ ctx,
74
+ "",
75
+ "## Suggested next actions",
76
+ "",
77
+ ...sugR.suggestions.map((s) => `- **${s.title}** — ${s.rationale}\n Command: \`${s.command}\``),
78
+ "",
79
+ "---",
80
+ "",
81
+ "When the operator asks a question, prefer concrete actions from above.",
82
+ "Never invent findings; if memory is empty, say so and propose a baseline scan.",
83
+ "",
84
+ ].join("\n"))
85
+ } catch (e) {
86
+ error(String(e))
87
+ }
88
+ })
89
+
90
+ // --- brief ---
91
+ ai.command('brief <target>')
92
+ .description('Print engagement-brief Markdown for paste into any client')
93
+ .action(async (target) => {
94
+ try {
95
+ const port = config.products.pentest.controlPort ?? 4091
96
+ const res = await fetch(`http://localhost:${port}/v1/memory/target/${encodeURIComponent(target)}/brief`)
97
+ if (!res.ok) { error(`HTTP ${res.status}`); return }
98
+ process.stdout.write(await res.text())
99
+ } catch (e) {
100
+ error(String(e))
101
+ }
102
+ })
103
+
104
+ // --- mcp-config ---
105
+ ai.command('mcp-config')
106
+ .description('Print Claude Desktop MCP server configuration')
107
+ .option('--name <name>', 'Server profile name', 'dragon-stack')
108
+ .action((opts) => {
109
+ const cfg = {
110
+ mcpServers: {
111
+ "phantom-memory": {
112
+ command: "phantom-memory-mcp",
113
+ env: { PD_CONTROL_API: `http://localhost:${config.products.pentest.controlPort ?? 4091}` },
114
+ },
115
+ "dragonkeep": {
116
+ command: "dragonkeep-mcp",
117
+ },
118
+ "dragonnet": {
119
+ command: "dragonnet-mcp",
120
+ env: { DRAGONNET_API: `http://localhost:${config.products.net.apiPort ?? 4080}` },
121
+ },
122
+ },
123
+ }
124
+ console.log(label('Dragon AI'), `MCP config for ${chalk.bold(opts.name)}:\n`)
125
+ console.log(JSON.stringify(cfg, null, 2))
126
+ console.log()
127
+ info('Linux: paste into ~/.config/Claude/claude_desktop_config.json')
128
+ info('macOS: ~/Library/Application Support/Claude/claude_desktop_config.json')
129
+ info('Restart Claude Desktop after editing.')
130
+ })
131
+
132
+ // --- skills ---
133
+ ai.command('skills')
134
+ .description('Print Claude Code skill paths and an install hint')
135
+ .action(() => {
136
+ const skills = [
137
+ "scan", "memory", "osint", "chronicle", "triage",
138
+ ]
139
+ console.log(label('Dragon AI'), 'Claude Code skills available:\n')
140
+ skills.forEach((s) => console.log(` /${s.padEnd(12)} ~/.claude/skills/dragon-${s}/SKILL.md`))
141
+ console.log()
142
+ info('These are pre-installed in ~/.copilot/skills/ — symlinked into ~/.claude/skills/.')
143
+ })
144
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * dragon alerts — Webhook alert pipeline (Slack / Discord / generic)
3
+ *
4
+ * alerts list
5
+ * alerts add <url> --label NAME --kind slack|discord|generic
6
+ * alerts remove <id>
7
+ * alerts test — fire a test event to every webhook
8
+ *
9
+ * Backed by PhantomDragon Control's /v1/alerts/webhooks endpoints. Any
10
+ * defensive Critical/High finding (DragonKeep → Phantom Memory) auto-
11
+ * fan-outs to every webhook that has on_critical / on_high enabled.
12
+ *
13
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
14
+ * Author: Ryan Sebastian <ryan@ghosts.lk>
15
+ */
16
+
17
+ import type { Command } from 'commander'
18
+ import type { DragonConfig } from '../config.js'
19
+ import { fetchJSON, label, success, error, info, table } from '../utils.js'
20
+ import chalk from 'chalk'
21
+
22
+ function api(config: DragonConfig, path: string): string {
23
+ const port = config.products.pentest.controlPort ?? 4091
24
+ return `http://localhost:${port}${path}`
25
+ }
26
+
27
+ interface Webhook {
28
+ id: string
29
+ label: string
30
+ url: string
31
+ kind: string
32
+ on_critical: boolean
33
+ on_high: boolean
34
+ on_scan_complete: boolean
35
+ enabled: boolean
36
+ }
37
+
38
+ async function postJSON<T>(url: string, body: unknown): Promise<T> {
39
+ const res = await fetch(url, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify(body),
43
+ })
44
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
45
+ return res.json() as Promise<T>
46
+ }
47
+
48
+ async function del(url: string): Promise<void> {
49
+ const res = await fetch(url, { method: 'DELETE' })
50
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
51
+ }
52
+
53
+ export function registerAlertsCommands(program: Command, config: DragonConfig) {
54
+ const alerts = program
55
+ .command('alerts')
56
+ .description('Webhook alert pipeline (Slack / Discord / generic)')
57
+
58
+ alerts
59
+ .command('list')
60
+ .description('List configured webhooks')
61
+ .action(async () => {
62
+ try {
63
+ const d = await fetchJSON<{ webhooks: Webhook[] }>(api(config, '/v1/alerts/webhooks'))
64
+ if (d.webhooks.length === 0) {
65
+ info('No webhooks configured. Try: dragon alerts add <url> --label slack')
66
+ return
67
+ }
68
+ console.log(label('Alerts'), 'Webhooks:\n')
69
+ table(d.webhooks.map((w) => ({
70
+ ID: w.id,
71
+ Label: w.label,
72
+ Kind: w.kind,
73
+ 'Crit/High/Scan': `${w.on_critical ? '✓' : '·'}${w.on_high ? '✓' : '·'}${w.on_scan_complete ? '✓' : '·'}`,
74
+ Enabled: w.enabled ? 'yes' : 'no',
75
+ URL: w.url.slice(0, 50),
76
+ })))
77
+ } catch (e) {
78
+ error(String(e))
79
+ }
80
+ })
81
+
82
+ alerts
83
+ .command('add <url>')
84
+ .description('Register a new webhook')
85
+ .option('--label <label>', 'Display label', 'webhook')
86
+ .option('--kind <kind>', 'slack | discord | generic', 'generic')
87
+ .option('--on-high', 'Also fire on HIGH (default: CRITICAL only)')
88
+ .option('--on-scan-complete', 'Fire on scan completion events')
89
+ .action(async (url, opts) => {
90
+ try {
91
+ const created = await postJSON<Webhook>(api(config, '/v1/alerts/webhooks'), {
92
+ label: opts.label,
93
+ url,
94
+ kind: opts.kind,
95
+ on_critical: true,
96
+ on_high: !!opts.onHigh,
97
+ on_scan_complete: !!opts.onScanComplete,
98
+ enabled: true,
99
+ })
100
+ success(`Registered webhook ${chalk.bold(created.id)} (${created.kind})`)
101
+ } catch (e) {
102
+ error(String(e))
103
+ }
104
+ })
105
+
106
+ alerts
107
+ .command('remove <id>')
108
+ .description('Delete a webhook')
109
+ .action(async (id) => {
110
+ try {
111
+ await del(api(config, `/v1/alerts/webhooks/${encodeURIComponent(id)}`))
112
+ success(`Removed webhook ${id}`)
113
+ } catch (e) {
114
+ error(String(e))
115
+ }
116
+ })
117
+
118
+ alerts
119
+ .command('test')
120
+ .description('Fire a test CRITICAL event to every webhook')
121
+ .action(async () => {
122
+ try {
123
+ const r = await postJSON<{ fired: number; webhooks_total: number }>(
124
+ api(config, '/v1/alerts/test'), {},
125
+ )
126
+ success(`Fired to ${r.fired} of ${r.webhooks_total} webhook(s)`)
127
+ } catch (e) {
128
+ error(String(e))
129
+ }
130
+ })
131
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * dragon billing — License management (Paddle / DragonSeal)
3
+ *
4
+ * billing licenses — list active product licences
5
+ * billing check <product> — quick check whether <product> is licensed
6
+ *
7
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
8
+ * Author: Ryan Sebastian <ryan@ghosts.lk>
9
+ */
10
+
11
+ import type { Command } from 'commander'
12
+ import type { DragonConfig } from '../config.js'
13
+ import { fetchJSON, label, error, info } from '../utils.js'
14
+ import chalk from 'chalk'
15
+
16
+ function api(config: DragonConfig, path: string): string {
17
+ const port = config.products.pentest.controlPort ?? 4091
18
+ return `http://localhost:${port}${path}`
19
+ }
20
+
21
+ export function registerBillingCommands(program: Command, config: DragonConfig) {
22
+ const billing = program.command('billing').description('License management — Paddle / DragonSeal')
23
+
24
+ billing.command('licenses')
25
+ .description('Active product licences on this host')
26
+ .option('--email <email>', 'Restrict to a specific operator email')
27
+ .action(async (opts) => {
28
+ try {
29
+ const params = new URLSearchParams()
30
+ if (opts.email) params.set('email', opts.email)
31
+ const d = await fetchJSON<{ licenses: string[] }>(api(config, `/v1/billing/licenses?${params}`))
32
+ if (d.licenses.length === 0) {
33
+ info('No active licences. Free tier active for every product.')
34
+ return
35
+ }
36
+ console.log(label('Licenses'), 'Active products:\n')
37
+ d.licenses.forEach((p) => console.log(` ${chalk.green('●')} ${p}`))
38
+ } catch (e) {
39
+ error(String(e))
40
+ }
41
+ })
42
+
43
+ billing.command('check <product>')
44
+ .description('Check whether <product> is licensed')
45
+ .option('--email <email>', 'Restrict to a specific operator email')
46
+ .action(async (product, opts) => {
47
+ try {
48
+ const params = new URLSearchParams({ product })
49
+ if (opts.email) params.set('email', opts.email)
50
+ const d = await fetchJSON<{ product: string; licensed: boolean }>(
51
+ api(config, `/v1/billing/check?${params}`),
52
+ )
53
+ const sym = d.licensed ? chalk.green('✓ licensed') : chalk.yellow('· free tier')
54
+ console.log(`${product.padEnd(28)} ${sym}`)
55
+ } catch (e) {
56
+ error(String(e))
57
+ }
58
+ })
59
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * dragon chat / dragon ask — the Dragon agent: a Claude-Code-style terminal
3
+ * coding agent that runs tools locally, reasons with a pluggable brain (Claude
4
+ * by default), and is wired into Wyrm long-term memory by default.
5
+ *
6
+ * dragon chat interactive agent — edits files, runs commands,
7
+ * remembers across sessions via Wyrm
8
+ * dragon ask "…" one-shot (read-only unless --auto); pipeable
9
+ *
10
+ * --brain claude|openai|local reasoning model (default: config/claude)
11
+ * --model <id> override the model
12
+ * --no-wyrm disable Wyrm memory for this run
13
+ * --no-portal don't expose the hosted account assistant tool
14
+ * --auto auto-approve file writes + shell (use with care)
15
+ * --cwd <dir> run the agent against another directory
16
+ *
17
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
18
+ */
19
+
20
+ import type { Command } from 'commander'
21
+ import { loadConfig, saveConfig, type DragonConfig } from '../config.js'
22
+ import { C, error, info, success } from '../utils.js'
23
+ import { saveSession, loadLastSession, exportMarkdown } from '../agent/session.js'
24
+ import { panel, chrome, kv, statusDot, tidyPath } from '../ui.js'
25
+ import { getBrain, BrainConfigError, type Provider } from '../brain/index.js'
26
+ import type { Brain, BrainMessage, ToolSpec } from '../brain/types.js'
27
+ import { Wyrm } from '../wyrm/mcp.js'
28
+ import { BWRAP, type ToolContext } from '../agent/tools.js'
29
+ import { runAgent, buildSystemPrompt, buildToolSpecs, type AgentDeps, type AgentRender, type PortalTool } from '../agent/loop.js'
30
+ import { recordTurn, traceEnabled } from '../agent/trace.js'
31
+ import { loadSkillLibrary, type SkillLibrary } from '../agent/skills.js'
32
+ import { makeStackTools } from '../agent/stack.js'
33
+ import { loadMcpHub } from '../agent/mcp.js'
34
+ import { makeTaskTool } from '../agent/task.js'
35
+ import { resolveAuth } from '../auth.js'
36
+ import { streamAgent, AgentError, SURFACES } from '../agent.js'
37
+ import { readFileSync, existsSync } from 'node:fs'
38
+ import { createInterface, type Interface } from 'node:readline/promises'
39
+ import { stdin, stdout } from 'node:process'
40
+
41
+ function expandFileRefs(text: string): string {
42
+ return text.replace(/@(\S+)/g, (match, path) => {
43
+ if (path === '-' || !existsSync(path)) return match
44
+ try { return `\n\n--- ${path} ---\n${readFileSync(path, 'utf-8')}\n--- end ${path} ---\n\n` } catch { return match }
45
+ })
46
+ }
47
+
48
+ async function readStdinAll(): Promise<string> {
49
+ const chunks: Buffer[] = []
50
+ for await (const c of stdin) chunks.push(c as Buffer)
51
+ return Buffer.concat(chunks).toString('utf-8')
52
+ }
53
+
54
+ /** The hosted account.ghosts.lk assistant, exposed to the agent as one tool. */
55
+ function makePortalTool(): PortalTool | null {
56
+ if (resolveAuth().mode === 'none') return null
57
+ const spec: ToolSpec = {
58
+ name: 'portal_ask',
59
+ description: "Ask the Ghost Protocol account portal assistant about the operator's licenses, product catalog, security services/pricing, or to book an engagement. Account/business questions only — not coding.",
60
+ parameters: { type: 'object', properties: { question: { type: 'string' }, surface: { type: 'string', enum: [...SURFACES] } }, required: ['question'] },
61
+ }
62
+ return {
63
+ spec,
64
+ async call(args) {
65
+ const surface = (SURFACES as readonly string[]).includes(String(args.surface)) ? String(args.surface) : 'dashboard'
66
+ try {
67
+ return await streamAgent({ messages: [{ role: 'user', content: String(args.question ?? '') }], surface, onDelta: () => {} })
68
+ } catch (e) {
69
+ return e instanceof AgentError ? `portal error: ${e.message}` : `portal error: ${String(e instanceof Error ? e.message : e)}`
70
+ }
71
+ },
72
+ }
73
+ }
74
+
75
+ interface SetupOpts { brain?: string; model?: string; wyrm?: boolean; portal?: boolean; cwd?: string; sovereign?: boolean }
76
+
77
+ function hasKey(p: 'anthropic' | 'openai'): boolean {
78
+ const cfg = loadConfig()
79
+ return p === 'anthropic' ? !!(process.env.ANTHROPIC_API_KEY || cfg.brain?.keys?.anthropic) : !!(process.env.OPENAI_API_KEY || cfg.brain?.keys?.openai)
80
+ }
81
+
82
+ /** First-launch model picker (interactive). Persists the choice to config. */
83
+ async function runPicker(): Promise<void> {
84
+ const signedIn = resolveAuth().mode !== 'none'
85
+ const ready = (ok: boolean, need: string) => (ok ? C.accent('● ready') : C.faint('○ ' + need))
86
+ console.log()
87
+ console.log(` ${chrome("CHOOSE DRAGON'S BRAIN")}`)
88
+ console.log(` ${C.faint('Tools always run locally — this only picks the reasoning model.')}`)
89
+ console.log()
90
+ console.log(` ${C.accent('1')} Cloudflare worker ${C.faint('· free · our infra')} ${ready(signedIn, 'needs `dragon login`')}`)
91
+ console.log(` ${C.accent('2')} Claude ${C.faint('· best for code')} ${ready(hasKey('anthropic'), 'needs ANTHROPIC_API_KEY')}`)
92
+ console.log(` ${C.accent('3')} Local Ollama ${C.faint('· private · free')}`)
93
+ console.log(` ${C.accent('4')} OpenAI ${C.faint('· hosted')} ${ready(hasKey('openai'), 'needs OPENAI_API_KEY')}`)
94
+ console.log()
95
+ const rl = createInterface({ input: stdin, output: stdout })
96
+ let ans = ''
97
+ try { ans = (await rl.question(` ${C.accent('▸')} pick ${C.faint('[1-4, default 1]')} `)).trim() } finally { rl.close() }
98
+ const provider: Provider = ({ '1': 'worker', '2': 'claude', '3': 'local', '4': 'openai' } as Record<string, Provider>)[ans] ?? 'worker'
99
+ const cfg = loadConfig(); cfg.brain = { ...(cfg.brain ?? {}), provider }; saveConfig(cfg)
100
+ info(`brain → ${C.accent(provider)}. Change anytime with ${C.info('dragon config brain <name>')}.`)
101
+ }
102
+
103
+ /** Resolve the brain: explicit --brain, else configured (with a first-launch picker),
104
+ * else gracefully fall back to the free Cloudflare worker / local Ollama. */
105
+ const HOSTED = ['claude', 'worker', 'openai']
106
+ async function chooseBrain(opts: SetupOpts): Promise<Brain | null> {
107
+ const tryGet = (provider?: string): Brain | null => {
108
+ try { return getBrain({ provider, model: opts.model }) }
109
+ catch (e) { error(e instanceof BrainConfigError ? e.message : String(e instanceof Error ? e.message : e)); return null }
110
+ }
111
+ if (opts.sovereign) {
112
+ // local brain only — nothing leaves the host
113
+ if (opts.brain && HOSTED.includes(opts.brain)) info(`sovereign mode — ignoring hosted brain '${opts.brain}', using local.`)
114
+ return tryGet(opts.brain && !HOSTED.includes(opts.brain) ? opts.brain : 'local')
115
+ }
116
+ if (opts.brain) return tryGet(opts.brain)
117
+ if (!loadConfig().brain?.provider && stdin.isTTY) await runPicker()
118
+ try {
119
+ return getBrain({ model: opts.model })
120
+ } catch (e) {
121
+ if (!(e instanceof BrainConfigError)) { error(String(e instanceof Error ? e.message : e)); return null }
122
+ if (resolveAuth().mode !== 'none') { info(`${e.provider} brain unavailable — using the free Cloudflare brain.`); return getBrain({ provider: 'worker' }) }
123
+ info(`${e.provider} brain unavailable — using local Ollama. (Run \`dragon login\` for the free Cloudflare brain.)`)
124
+ return tryGet('local')
125
+ }
126
+ }
127
+
128
+ /** Build brain + wyrm + portal + system prompt. Returns null after printing on a brain config error. */
129
+ async function setup(opts: SetupOpts, announce: boolean) {
130
+ const brain = await chooseBrain(opts)
131
+ if (!brain) return null
132
+ const cwd = opts.cwd ? (opts.cwd.startsWith('/') ? opts.cwd : `${process.cwd()}/${opts.cwd}`) : process.cwd()
133
+
134
+ let wyrm: Wyrm | null = null
135
+ let primed: string | null = null
136
+ if (opts.wyrm !== false) {
137
+ wyrm = new Wyrm()
138
+ const ok = await wyrm.connect()
139
+ if (ok) primed = await wyrm.prime(cwd)
140
+ else { if (announce) info('Wyrm memory unavailable — continuing without it.'); wyrm = null }
141
+ }
142
+
143
+ const portal = (opts.portal === false || opts.sovereign) ? null : makePortalTool()
144
+ const skills = loadSkillLibrary()
145
+ const stack = makeStackTools()
146
+ const mcp = await loadMcpHub(loadConfig().mcpServers)
147
+ if (mcp && announce) info(`MCP: ${mcp.serverCount} server(s) connected (${mcp.toolSpecs().length} tools).`)
148
+ const task = makeTaskTool({ brain, skills, cwd })
149
+ const system = buildSystemPrompt({ cwd, wyrm: !!wyrm, portal: !!portal, brainId: `${brain.id}:${brain.model}`, skills: skills.count || undefined, primed })
150
+ return { brain, wyrm, portal, skills, stack, mcp, task, cwd, system, primed }
151
+ }
152
+
153
+ function banner(s: { brain: { id: string; model: string }; wyrm: Wyrm | null; portal: PortalTool | null; skills?: SkillLibrary | null; cwd: string }, tools: ToolSpec[]): void {
154
+ const lines = [
155
+ kv('brain', C.info(`${s.brain.id}:${s.brain.model}`)),
156
+ kv('cwd', C.info(tidyPath(s.cwd))),
157
+ kv('state', `memory ${statusDot(!!s.wyrm)} ${s.wyrm ? C.info('wyrm') : C.faint('off')} portal ${statusDot(!!s.portal)} skills ${C.info(String(s.skills?.count ?? 0))} tools ${C.info(String(tools.length))}`),
158
+ ]
159
+ console.log()
160
+ console.log(panel(lines, { title: chrome('DRAGON AGENT') }))
161
+ console.log(` ${C.faint('/exit · /reset · /brain · /tools · /memory <q> · /auto · /plan · /save · @<file>')}`)
162
+ }
163
+
164
+ function makeRender(): AgentRender {
165
+ return {
166
+ onAssistantStart() { process.stdout.write(`\n ${C.hot('◆')} `) },
167
+ onDelta(s) { process.stdout.write(s) },
168
+ onToolStart(summary) { process.stdout.write(`\n ${C.faint('⚙ ' + summary)}`) },
169
+ onToolEnd(_summary, preview, ok) { process.stdout.write(` ${ok ? C.accent('✓') : C.critical('✗')} ${C.faint(preview)}\n`) },
170
+ }
171
+ }
172
+
173
+ function makeApprove(rl: Interface, state: { auto: boolean; plan: boolean }) {
174
+ return async (summary: string, opts?: { detail?: string; dangerous?: boolean }): Promise<boolean> => {
175
+ if (state.plan) { console.log(` ${C.faint('⊘ plan mode — declined: ' + summary)}`); return false } // read-only
176
+ const dangerous = !!opts?.dangerous
177
+ if (state.auto && !dangerous) return true // --auto covers safe in-cwd edits only — NEVER bash / outside-cwd
178
+ if (opts?.detail) console.log('\n' + C.faint(opts.detail.split('\n').map((l) => ' ' + l).join('\n')))
179
+ const tag = dangerous ? C.critical('?') : C.accent('?')
180
+ const hint = dangerous ? (state.auto ? '[y/N — auto-approve does NOT cover this]' : '[y/N]') : '[y/N/a=always-safe]'
181
+ const ans = (await rl.question(`\n ${tag} ${summary} ${C.faint(hint)} `)).trim().toLowerCase()
182
+ if (ans === 'a' && !dangerous) { state.auto = true; return true }
183
+ return ans === 'y' || ans === 'yes'
184
+ }
185
+ }
186
+
187
+ export function registerChatCommands(program: Command, _config: DragonConfig) {
188
+ program
189
+ .command('chat')
190
+ .description('Dragon agent — codes, runs tools, remembers via Wyrm (interactive)')
191
+ .option('--brain <provider>', 'reasoning brain: claude | worker | local | openai | ghost')
192
+ .option('--model <id>', 'override the model id')
193
+ .option('--no-wyrm', 'disable Wyrm long-term memory for this session')
194
+ .option('--no-portal', "don't expose the hosted account assistant as a tool")
195
+ .option('--auto', 'auto-approve file writes + shell commands', false)
196
+ .option('--plan', 'read-only: the agent explores but makes no writes/shell', false)
197
+ .option('--sovereign', 'local brain + Wyrm only — nothing leaves the host', false)
198
+ .option('--sandbox', 'run shell in a bwrap jail (cwd writable, rest read-only)', false)
199
+ .option('--no-trace', 'do not record tool-use traces for DragonSpark training')
200
+ .option('--resume', 'resume the previous session', false)
201
+ .option('--cwd <dir>', 'run against a different working directory')
202
+ .action(async (opts: SetupOpts & { auto?: boolean; plan?: boolean; sandbox?: boolean; trace?: boolean; resume?: boolean }) => {
203
+ const s = await setup(opts, true)
204
+ if (!s) { process.exitCode = 1; return }
205
+
206
+ const rl = createInterface({ input: stdin, output: stdout, terminal: stdin.isTTY === true })
207
+ const state = { auto: !!opts.auto, plan: !!opts.plan }
208
+ const toolCtx: ToolContext = { cwd: s.cwd, approve: makeApprove(rl, state), sandbox: !!opts.sandbox }
209
+ const messages: BrainMessage[] = []
210
+ if (opts.resume) {
211
+ const prior = loadLastSession()
212
+ if (prior) { messages.push(...prior); info(`resumed ${prior.length} messages from your last session`) }
213
+ else info('no previous session to resume')
214
+ }
215
+ const deps: AgentDeps = { ...s, toolCtx, messages }
216
+ const render = makeRender()
217
+
218
+ banner(s, buildToolSpecs(s))
219
+ if (state.plan) info('plan mode ON — read-only: the agent explores but makes no writes/shell (/plan to toggle).')
220
+ else if (state.auto) info('auto-approve ON — safe in-cwd writes run without asking (bash still prompts).')
221
+ if (opts.sovereign) info('sovereign mode — local brain + Wyrm only; nothing leaves the host.')
222
+ if (opts.sandbox) info(BWRAP ? 'sandbox ON — shell runs in bwrap (cwd writable, secrets masked, network shared).' : 'sandbox requested but bwrap not found — shell runs unsandboxed (approval still applies).')
223
+ if (traceEnabled(opts.trace === false)) info('flywheel ON — turns logged to ~/.dragon/traces for DragonSpark (--no-trace to disable).')
224
+
225
+ let aborter: AbortController | null = null
226
+ const onSigint = () => { if (aborter) { aborter.abort(); aborter = null } else { void cleanup().then(() => process.exit(0)) } }
227
+ const cleanup = async () => { process.off('SIGINT', onSigint); rl.close(); saveSession(messages, { cwd: s.cwd, brain: `${s.brain.id}:${s.brain.model}` }); await s.wyrm?.close(); await s.mcp?.close() }
228
+ process.on('SIGINT', onSigint)
229
+
230
+ for (;;) {
231
+ let userIn: string
232
+ try { userIn = (await rl.question(`${C.accent('▸')} `)).trim() } catch { break }
233
+ if (!userIn) continue
234
+
235
+ if (userIn === '/exit' || userIn === '/quit') break
236
+ if (userIn === '/reset') { messages.length = 0; state.auto = false; info('conversation reset · auto-approve re-armed'); continue }
237
+ if (userIn === '/auto') { state.auto = !state.auto; if (state.auto) state.plan = false; info(`auto-approve ${state.auto ? 'ON' : 'OFF'}`); continue }
238
+ if (userIn === '/plan') { state.plan = !state.plan; if (state.plan) state.auto = false; info(`plan mode ${state.plan ? 'ON — read-only' : 'OFF'}`); continue }
239
+ if (userIn === '/brain') { console.log(` ${C.faint('brain:')} ${C.info(s.brain.id + ':' + s.brain.model)}`); continue }
240
+ if (userIn === '/tools') { console.log(' ' + buildToolSpecs(s).map((t) => t.name).join(' ')); continue }
241
+ if (userIn === '/clear') { console.clear(); banner(s, buildToolSpecs(s)); continue }
242
+ if (userIn.startsWith('/save')) { success(`transcript → ${exportMarkdown(messages, userIn.slice(5).trim() || undefined)}`); continue }
243
+ if (userIn.startsWith('/memory')) {
244
+ const q = userIn.slice(7).trim()
245
+ if (!s.wyrm) { error('Wyrm is off this session'); continue }
246
+ if (!q) { info('usage: /memory <query>'); continue }
247
+ console.log(C.faint(await s.wyrm.call('wyrm_recall', { query: q })))
248
+ continue
249
+ }
250
+ if (userIn.startsWith('/')) { error(`unknown command ${userIn}`); continue }
251
+
252
+ aborter = new AbortController()
253
+ const before = messages.length
254
+ try {
255
+ await runAgent(deps, expandFileRefs(userIn), render, aborter.signal)
256
+ process.stdout.write('\n\n')
257
+ if (traceEnabled(opts.trace === false)) recordTurn(messages.slice(before), { cwd: s.cwd, brain: `${s.brain.id}:${s.brain.model}`, context: s.primed })
258
+ } catch (e) {
259
+ aborter = null
260
+ if ((e as { name?: string })?.name === 'AbortError') { process.stdout.write(`\n ${C.faint('⊘ interrupted')}\n\n`); continue }
261
+ process.stdout.write('\n')
262
+ if (e instanceof BrainConfigError) error(e.message)
263
+ else error(String(e instanceof Error ? e.message : e))
264
+ }
265
+ aborter = null
266
+ }
267
+ await cleanup()
268
+ })
269
+
270
+ program
271
+ .command('ask [prompt...]')
272
+ .description('One-shot Dragon agent question (read-only unless --auto; pipeable)')
273
+ .option('--brain <provider>', 'reasoning brain: claude | worker | local | openai | ghost')
274
+ .option('--model <id>', 'override the model id')
275
+ .option('--no-wyrm', 'disable Wyrm memory')
276
+ .option('--no-portal', "don't expose the hosted account assistant")
277
+ .option('--auto', 'allow file writes + shell (off = read-only)', false)
278
+ .option('--plan', 'read-only (deny all writes/shell)', false)
279
+ .option('--sovereign', 'local brain + Wyrm only — nothing leaves the host', false)
280
+ .option('--sandbox', 'run shell in a bwrap jail', false)
281
+ .option('--no-trace', 'do not record a trace for DragonSpark training')
282
+ .option('--cwd <dir>', 'working directory')
283
+ .action(async (parts: string[] = [], opts: SetupOpts & { auto?: boolean; plan?: boolean; sandbox?: boolean; trace?: boolean }) => {
284
+ let prompt = parts.join(' ').trim()
285
+ if (prompt === '-' || (!prompt && !stdin.isTTY)) prompt = (await readStdinAll()).trim()
286
+ if (!prompt) { error('no prompt. Pass args, pipe stdin, or use "-".'); process.exit(1) }
287
+
288
+ const s = await setup(opts, false)
289
+ if (!s) { process.exit(1) }
290
+
291
+ const denied: string[] = []
292
+ const toolCtx: ToolContext = {
293
+ cwd: s!.cwd,
294
+ sandbox: !!opts.sandbox,
295
+ approve: async (summary, o) => { if (opts.auto && !opts.plan && !o?.dangerous) return true; denied.push(summary); return false },
296
+ }
297
+ const messages: BrainMessage[] = []
298
+ const deps: AgentDeps = { ...s!, toolCtx, messages }
299
+ const render: AgentRender = {
300
+ onAssistantStart() {},
301
+ onDelta(t) { process.stdout.write(t) },
302
+ onToolStart(summary) { process.stderr.write(C.faint(`⚙ ${summary}\n`)) },
303
+ onToolEnd(_s, preview, ok) { process.stderr.write(C.faint(` ${ok ? '✓' : '✗'} ${preview}\n`)) },
304
+ }
305
+ try {
306
+ await runAgent(deps, expandFileRefs(prompt), render, new AbortController().signal)
307
+ process.stdout.write('\n')
308
+ if (traceEnabled(opts.trace === false)) recordTurn(messages, { cwd: s!.cwd, brain: `${s!.brain.id}:${s!.brain.model}`, context: s!.primed })
309
+ if (denied.length) process.stderr.write(C.faint(`\n(${denied.length} mutating action(s) skipped — re-run with --auto to allow)\n`))
310
+ } catch (e) {
311
+ if (e instanceof BrainConfigError) error(e.message)
312
+ else error(String(e instanceof Error ? e.message : e))
313
+ await s!.wyrm?.close(); await s!.mcp?.close()
314
+ process.exit(1)
315
+ }
316
+ await s!.wyrm?.close()
317
+ })
318
+ }