opencastle 0.26.1 → 0.27.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/README.md +7 -1
  2. package/bin/cli.mjs +10 -0
  3. package/dist/cli/agents.d.ts +3 -0
  4. package/dist/cli/agents.d.ts.map +1 -0
  5. package/dist/cli/agents.js +161 -0
  6. package/dist/cli/agents.js.map +1 -0
  7. package/dist/cli/baselines.d.ts +3 -0
  8. package/dist/cli/baselines.d.ts.map +1 -0
  9. package/dist/cli/baselines.js +128 -0
  10. package/dist/cli/baselines.js.map +1 -0
  11. package/dist/cli/convoy/engine.d.ts +68 -2
  12. package/dist/cli/convoy/engine.d.ts.map +1 -1
  13. package/dist/cli/convoy/engine.js +2102 -26
  14. package/dist/cli/convoy/engine.js.map +1 -1
  15. package/dist/cli/convoy/engine.test.js +1572 -70
  16. package/dist/cli/convoy/engine.test.js.map +1 -1
  17. package/dist/cli/convoy/events.d.ts +4 -1
  18. package/dist/cli/convoy/events.d.ts.map +1 -1
  19. package/dist/cli/convoy/events.js +74 -13
  20. package/dist/cli/convoy/events.js.map +1 -1
  21. package/dist/cli/convoy/events.test.js +154 -27
  22. package/dist/cli/convoy/events.test.js.map +1 -1
  23. package/dist/cli/convoy/expertise.d.ts +16 -0
  24. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  25. package/dist/cli/convoy/expertise.js +121 -0
  26. package/dist/cli/convoy/expertise.js.map +1 -0
  27. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  28. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  29. package/dist/cli/convoy/expertise.test.js +96 -0
  30. package/dist/cli/convoy/expertise.test.js.map +1 -0
  31. package/dist/cli/convoy/export.test.js +1 -0
  32. package/dist/cli/convoy/export.test.js.map +1 -1
  33. package/dist/cli/convoy/formula.d.ts +19 -0
  34. package/dist/cli/convoy/formula.d.ts.map +1 -0
  35. package/dist/cli/convoy/formula.js +142 -0
  36. package/dist/cli/convoy/formula.js.map +1 -0
  37. package/dist/cli/convoy/formula.test.d.ts +2 -0
  38. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  39. package/dist/cli/convoy/formula.test.js +342 -0
  40. package/dist/cli/convoy/formula.test.js.map +1 -0
  41. package/dist/cli/convoy/gates.d.ts +128 -0
  42. package/dist/cli/convoy/gates.d.ts.map +1 -0
  43. package/dist/cli/convoy/gates.js +606 -0
  44. package/dist/cli/convoy/gates.js.map +1 -0
  45. package/dist/cli/convoy/gates.test.d.ts +2 -0
  46. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  47. package/dist/cli/convoy/gates.test.js +976 -0
  48. package/dist/cli/convoy/gates.test.js.map +1 -0
  49. package/dist/cli/convoy/health.d.ts +11 -0
  50. package/dist/cli/convoy/health.d.ts.map +1 -1
  51. package/dist/cli/convoy/health.js +54 -0
  52. package/dist/cli/convoy/health.js.map +1 -1
  53. package/dist/cli/convoy/health.test.js +56 -1
  54. package/dist/cli/convoy/health.test.js.map +1 -1
  55. package/dist/cli/convoy/issues.d.ts +8 -0
  56. package/dist/cli/convoy/issues.d.ts.map +1 -0
  57. package/dist/cli/convoy/issues.js +98 -0
  58. package/dist/cli/convoy/issues.js.map +1 -0
  59. package/dist/cli/convoy/issues.test.d.ts +2 -0
  60. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  61. package/dist/cli/convoy/issues.test.js +107 -0
  62. package/dist/cli/convoy/issues.test.js.map +1 -0
  63. package/dist/cli/convoy/knowledge.d.ts +5 -0
  64. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  65. package/dist/cli/convoy/knowledge.js +116 -0
  66. package/dist/cli/convoy/knowledge.js.map +1 -0
  67. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  68. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  69. package/dist/cli/convoy/knowledge.test.js +87 -0
  70. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  71. package/dist/cli/convoy/lessons.d.ts +17 -0
  72. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  73. package/dist/cli/convoy/lessons.js +149 -0
  74. package/dist/cli/convoy/lessons.js.map +1 -0
  75. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  76. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  77. package/dist/cli/convoy/lessons.test.js +135 -0
  78. package/dist/cli/convoy/lessons.test.js.map +1 -0
  79. package/dist/cli/convoy/lock.d.ts +13 -0
  80. package/dist/cli/convoy/lock.d.ts.map +1 -0
  81. package/dist/cli/convoy/lock.js +88 -0
  82. package/dist/cli/convoy/lock.js.map +1 -0
  83. package/dist/cli/convoy/lock.test.d.ts +2 -0
  84. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  85. package/dist/cli/convoy/lock.test.js +136 -0
  86. package/dist/cli/convoy/lock.test.js.map +1 -0
  87. package/dist/cli/convoy/merge.d.ts +4 -0
  88. package/dist/cli/convoy/merge.d.ts.map +1 -1
  89. package/dist/cli/convoy/merge.js +18 -1
  90. package/dist/cli/convoy/merge.js.map +1 -1
  91. package/dist/cli/convoy/merge.test.js +6 -7
  92. package/dist/cli/convoy/merge.test.js.map +1 -1
  93. package/dist/cli/convoy/partition.d.ts +51 -0
  94. package/dist/cli/convoy/partition.d.ts.map +1 -0
  95. package/dist/cli/convoy/partition.js +186 -0
  96. package/dist/cli/convoy/partition.js.map +1 -0
  97. package/dist/cli/convoy/partition.test.d.ts +2 -0
  98. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  99. package/dist/cli/convoy/partition.test.js +315 -0
  100. package/dist/cli/convoy/partition.test.js.map +1 -0
  101. package/dist/cli/convoy/pipeline.test.js +6 -0
  102. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  103. package/dist/cli/convoy/store.d.ts +47 -5
  104. package/dist/cli/convoy/store.d.ts.map +1 -1
  105. package/dist/cli/convoy/store.js +525 -19
  106. package/dist/cli/convoy/store.js.map +1 -1
  107. package/dist/cli/convoy/store.test.js +1345 -12
  108. package/dist/cli/convoy/store.test.js.map +1 -1
  109. package/dist/cli/convoy/types.d.ts +156 -2
  110. package/dist/cli/convoy/types.d.ts.map +1 -1
  111. package/dist/cli/destroy.d.ts +3 -0
  112. package/dist/cli/destroy.d.ts.map +1 -0
  113. package/dist/cli/destroy.js +69 -0
  114. package/dist/cli/destroy.js.map +1 -0
  115. package/dist/cli/destroy.test.d.ts +2 -0
  116. package/dist/cli/destroy.test.d.ts.map +1 -0
  117. package/dist/cli/destroy.test.js +116 -0
  118. package/dist/cli/destroy.test.js.map +1 -0
  119. package/dist/cli/gitignore.d.ts +9 -0
  120. package/dist/cli/gitignore.d.ts.map +1 -1
  121. package/dist/cli/gitignore.js +29 -0
  122. package/dist/cli/gitignore.js.map +1 -1
  123. package/dist/cli/plan.d.ts +3 -0
  124. package/dist/cli/plan.d.ts.map +1 -0
  125. package/dist/cli/plan.js +288 -0
  126. package/dist/cli/plan.js.map +1 -0
  127. package/dist/cli/run/adapters/claude.d.ts +2 -0
  128. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  129. package/dist/cli/run/adapters/claude.js +89 -49
  130. package/dist/cli/run/adapters/claude.js.map +1 -1
  131. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  132. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  133. package/dist/cli/run/adapters/claude.test.js +205 -0
  134. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  135. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  137. package/dist/cli/run/adapters/copilot.js +84 -46
  138. package/dist/cli/run/adapters/copilot.js.map +1 -1
  139. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  140. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  141. package/dist/cli/run/adapters/copilot.test.js +195 -0
  142. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  143. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  145. package/dist/cli/run/adapters/cursor.js +83 -47
  146. package/dist/cli/run/adapters/cursor.js.map +1 -1
  147. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  148. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  149. package/dist/cli/run/adapters/cursor.test.js +129 -0
  150. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  151. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  153. package/dist/cli/run/adapters/opencode.js +81 -47
  154. package/dist/cli/run/adapters/opencode.js.map +1 -1
  155. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  156. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  157. package/dist/cli/run/adapters/opencode.test.js +119 -0
  158. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  159. package/dist/cli/run/executor.js +1 -1
  160. package/dist/cli/run/executor.js.map +1 -1
  161. package/dist/cli/run/schema.d.ts.map +1 -1
  162. package/dist/cli/run/schema.js +245 -4
  163. package/dist/cli/run/schema.js.map +1 -1
  164. package/dist/cli/run/schema.test.js +669 -0
  165. package/dist/cli/run/schema.test.js.map +1 -1
  166. package/dist/cli/run.d.ts.map +1 -1
  167. package/dist/cli/run.js +362 -22
  168. package/dist/cli/run.js.map +1 -1
  169. package/dist/cli/types.d.ts +85 -2
  170. package/dist/cli/types.d.ts.map +1 -1
  171. package/dist/cli/types.js.map +1 -1
  172. package/dist/cli/watch.d.ts +15 -0
  173. package/dist/cli/watch.d.ts.map +1 -0
  174. package/dist/cli/watch.js +279 -0
  175. package/dist/cli/watch.js.map +1 -0
  176. package/package.json +1 -1
  177. package/src/cli/agents.ts +177 -0
  178. package/src/cli/baselines.ts +143 -0
  179. package/src/cli/convoy/engine.test.ts +1839 -70
  180. package/src/cli/convoy/engine.ts +2417 -38
  181. package/src/cli/convoy/events.test.ts +179 -38
  182. package/src/cli/convoy/events.ts +88 -16
  183. package/src/cli/convoy/expertise.test.ts +128 -0
  184. package/src/cli/convoy/expertise.ts +163 -0
  185. package/src/cli/convoy/export.test.ts +1 -0
  186. package/src/cli/convoy/formula.test.ts +405 -0
  187. package/src/cli/convoy/formula.ts +174 -0
  188. package/src/cli/convoy/gates.test.ts +1169 -0
  189. package/src/cli/convoy/gates.ts +774 -0
  190. package/src/cli/convoy/health.test.ts +64 -2
  191. package/src/cli/convoy/health.ts +80 -2
  192. package/src/cli/convoy/issues.test.ts +143 -0
  193. package/src/cli/convoy/issues.ts +136 -0
  194. package/src/cli/convoy/knowledge.test.ts +101 -0
  195. package/src/cli/convoy/knowledge.ts +132 -0
  196. package/src/cli/convoy/lessons.test.ts +188 -0
  197. package/src/cli/convoy/lessons.ts +164 -0
  198. package/src/cli/convoy/lock.test.ts +181 -0
  199. package/src/cli/convoy/lock.ts +103 -0
  200. package/src/cli/convoy/merge.test.ts +6 -7
  201. package/src/cli/convoy/merge.ts +19 -1
  202. package/src/cli/convoy/partition.test.ts +423 -0
  203. package/src/cli/convoy/partition.ts +232 -0
  204. package/src/cli/convoy/pipeline.test.ts +6 -0
  205. package/src/cli/convoy/store.test.ts +1512 -14
  206. package/src/cli/convoy/store.ts +676 -30
  207. package/src/cli/convoy/types.ts +170 -1
  208. package/src/cli/destroy.test.ts +141 -0
  209. package/src/cli/destroy.ts +88 -0
  210. package/src/cli/gitignore.ts +36 -0
  211. package/src/cli/plan.ts +316 -0
  212. package/src/cli/run/adapters/claude.test.ts +234 -0
  213. package/src/cli/run/adapters/claude.ts +45 -5
  214. package/src/cli/run/adapters/copilot.test.ts +224 -0
  215. package/src/cli/run/adapters/copilot.ts +34 -4
  216. package/src/cli/run/adapters/cursor.test.ts +144 -0
  217. package/src/cli/run/adapters/cursor.ts +33 -2
  218. package/src/cli/run/adapters/opencode.test.ts +135 -0
  219. package/src/cli/run/adapters/opencode.ts +30 -2
  220. package/src/cli/run/executor.ts +1 -1
  221. package/src/cli/run/schema.test.ts +758 -0
  222. package/src/cli/run/schema.ts +300 -25
  223. package/src/cli/run.ts +341 -21
  224. package/src/cli/types.ts +86 -1
  225. package/src/cli/watch.ts +298 -0
  226. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -0,0 +1,316 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { resolve, join, basename } from 'node:path'
4
+ import { mkdir, writeFile } from 'node:fs/promises'
5
+ import { getAdapter, detectAdapter } from './run/adapters/index.js'
6
+ import { parseTaskSpecText } from './run/schema.js'
7
+ import { c } from './prompt.js'
8
+ import type { CliContext, Task } from './types.js'
9
+
10
+ const HELP = `
11
+ opencastle plan [options]
12
+
13
+ Generate a convoy spec from a task description file by running it through the
14
+ generate-convoy prompt via an AI adapter.
15
+
16
+ Options:
17
+ --file, -f <path> Path to a text file with the task description (required)
18
+ --context <path> Optional path to an additional context file
19
+ --output, -o <path> Output path for the generated convoy spec
20
+ --adapter, -a <name> Override agent runtime adapter
21
+ --verbose Show full agent output
22
+ --dry-run Print the prompt that would be sent without executing
23
+ --help, -h Show this help
24
+ `
25
+
26
+ interface PlanOptions {
27
+ file: string | null
28
+ context: string | null
29
+ output: string | null
30
+ adapter: string | null
31
+ verbose: boolean
32
+ dryRun: boolean
33
+ help: boolean
34
+ }
35
+
36
+ function parseArgs(args: string[]): PlanOptions {
37
+ const opts: PlanOptions = {
38
+ file: null,
39
+ context: null,
40
+ output: null,
41
+ adapter: null,
42
+ verbose: false,
43
+ dryRun: false,
44
+ help: false,
45
+ }
46
+
47
+ for (let i = 0; i < args.length; i++) {
48
+ const arg = args[i]
49
+ switch (arg) {
50
+ case '--help':
51
+ case '-h':
52
+ opts.help = true
53
+ break
54
+ case '--file':
55
+ case '-f':
56
+ if (i + 1 >= args.length) { console.error(' ✗ --file requires a path'); process.exit(1) }
57
+ opts.file = args[++i]
58
+ break
59
+ case '--context':
60
+ if (i + 1 >= args.length) { console.error(' ✗ --context requires a path'); process.exit(1) }
61
+ opts.context = args[++i]
62
+ break
63
+ case '--output':
64
+ case '-o':
65
+ if (i + 1 >= args.length) { console.error(' ✗ --output requires a path'); process.exit(1) }
66
+ opts.output = args[++i]
67
+ break
68
+ case '--adapter':
69
+ case '-a':
70
+ if (i + 1 >= args.length) { console.error(' ✗ --adapter requires a name'); process.exit(1) }
71
+ opts.adapter = args[++i]
72
+ break
73
+ case '--verbose':
74
+ opts.verbose = true
75
+ break
76
+ case '--dry-run':
77
+ case '--dryRun':
78
+ opts.dryRun = true
79
+ break
80
+ default:
81
+ console.error(` ✗ Unknown option: ${arg}`)
82
+ console.log(HELP)
83
+ process.exit(1)
84
+ }
85
+ }
86
+
87
+ return opts
88
+ }
89
+
90
+ function printAdapterError(detectionFailed: boolean, adapterName: string): void {
91
+ if (detectionFailed) {
92
+ console.error(
93
+ ` ✗ No agent CLI found on your PATH.\n` +
94
+ ` Install one of the following adapters:\n` +
95
+ ` • copilot — https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli\n` +
96
+ ` • claude — npm install -g @anthropic-ai/claude-code\n` +
97
+ ` • cursor — https://cursor.com (Cursor > Install CLI)\n` +
98
+ ` • opencode — https://opencode.ai\n` +
99
+ `\n` +
100
+ ` Or specify an adapter explicitly: opencastle plan --adapter <name>`
101
+ )
102
+ } else {
103
+ const hints: Record<string, string> = {
104
+ 'claude':
105
+ ' Install: npm install -g @anthropic-ai/claude-code\n' +
106
+ ' Docs: https://docs.anthropic.com/en/docs/claude-code',
107
+ copilot:
108
+ ' Requires the Copilot CLI installed and authenticated:\n' +
109
+ ' https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli\n' +
110
+ ' Docs: https://docs.github.com/en/copilot',
111
+ cursor:
112
+ ' The Cursor agent CLI ships with the Cursor editor.\n' +
113
+ ' Install Cursor from https://cursor.com and ensure the\n' +
114
+ ' "agent" command is on your PATH (Cursor > Install CLI).',
115
+ opencode:
116
+ ' Install OpenCode from https://opencode.ai\n' +
117
+ ' Ensure the "opencode" command is on your PATH.',
118
+ }
119
+ const cliName = adapterName === 'cursor' ? 'agent' : adapterName
120
+ const hint = hints[adapterName] ?? ''
121
+ console.error(
122
+ ` ✗ Adapter "${adapterName}" is not available.\n` +
123
+ ` Make sure the "${cliName}" CLI is installed and on your PATH.\n` +
124
+ hint
125
+ )
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Strip YAML frontmatter (everything between first and second --- lines).
131
+ */
132
+ function stripFrontmatter(text: string): string {
133
+ const lines = text.split('\n')
134
+ if (lines[0]?.trim() !== '---') return text
135
+ const closingIdx = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
136
+ if (closingIdx === -1) return text
137
+ return lines.slice(closingIdx + 1).join('\n').trimStart()
138
+ }
139
+
140
+ /**
141
+ * Extract YAML content from a fenced code block (```yaml or ```yml).
142
+ */
143
+ function extractYamlBlock(text: string): string | null {
144
+ const match = text.match(/```ya?ml\s*\n([\s\S]*?)```/)
145
+ if (!match) return null
146
+ return match[1].trim()
147
+ }
148
+
149
+ /**
150
+ * Derive an output filename from YAML content.
151
+ * Checks for a comment on the first line, then falls back to the `name` field.
152
+ */
153
+ function deriveOutputFilename(yaml: string): string {
154
+ // First line comment: # .opencastle/convoys/some-name.convoy.yml
155
+ const firstLine = yaml.split('\n')[0] ?? ''
156
+ const commentMatch = firstLine.match(/^#\s*(.+\.convoy\.ya?ml)\s*$/)
157
+ if (commentMatch) {
158
+ return basename(commentMatch[1])
159
+ }
160
+
161
+ // Fall back to `name:` field
162
+ const nameMatch = yaml.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m)
163
+ if (nameMatch) {
164
+ const kebab = nameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
165
+ if (kebab) return `${kebab}.convoy.yml`
166
+ }
167
+
168
+ return 'convoy-plan.convoy.yml'
169
+ }
170
+
171
+ export default async function plan({ args, pkgRoot }: CliContext): Promise<void> {
172
+ const opts = parseArgs(args)
173
+
174
+ if (opts.help) {
175
+ console.log(HELP)
176
+ return
177
+ }
178
+
179
+ // ── Validate required --file arg ──────────────────────────────
180
+ if (!opts.file) {
181
+ console.error(` ✗ --file is required. Specify a text file with the task description.`)
182
+ console.log(HELP)
183
+ process.exit(1)
184
+ }
185
+
186
+ const filePath = resolve(process.cwd(), opts.file)
187
+ if (!existsSync(filePath)) {
188
+ console.error(` ✗ File not found: ${opts.file}`)
189
+ process.exit(1)
190
+ }
191
+
192
+ // ── Read task description ──────────────────────────────────────
193
+ const taskDescription = await readFile(filePath, 'utf8')
194
+
195
+ // ── Read optional context file ─────────────────────────────────
196
+ let contextContent = ''
197
+ if (opts.context) {
198
+ const contextPath = resolve(process.cwd(), opts.context)
199
+ if (!existsSync(contextPath)) {
200
+ console.error(` ✗ Context file not found: ${opts.context}`)
201
+ process.exit(1)
202
+ }
203
+ contextContent = await readFile(contextPath, 'utf8')
204
+ }
205
+
206
+ // ── Load and assemble the prompt template ─────────────────────
207
+ const promptTemplatePath = join(pkgRoot, 'src', 'orchestrator', 'prompts', 'generate-convoy.prompt.md')
208
+ if (!existsSync(promptTemplatePath)) {
209
+ console.error(` ✗ Prompt template not found: ${promptTemplatePath}`)
210
+ process.exit(1)
211
+ }
212
+
213
+ const rawTemplate = await readFile(promptTemplatePath, 'utf8')
214
+ const template = stripFrontmatter(rawTemplate)
215
+ const assembledPrompt = template
216
+ .replace('{{goal}}', taskDescription.trim())
217
+ .replace('{{context}}', contextContent.trim())
218
+
219
+ // ── Dry-run: print prompt and exit ────────────────────────────
220
+ if (opts.dryRun) {
221
+ console.log(c.bold(c.cyan(' Assembled prompt (dry-run):\n')))
222
+ console.log(assembledPrompt)
223
+ return
224
+ }
225
+
226
+ // ── Resolve adapter ───────────────────────────────────────────
227
+ let adapterName: string
228
+ if (opts.adapter) {
229
+ adapterName = opts.adapter
230
+ } else {
231
+ const detected = await detectAdapter()
232
+ if (!detected) {
233
+ printAdapterError(true, '')
234
+ process.exit(1)
235
+ }
236
+ adapterName = detected
237
+ }
238
+
239
+ let adapter
240
+ try {
241
+ adapter = await getAdapter(adapterName)
242
+ } catch {
243
+ printAdapterError(false, adapterName)
244
+ process.exit(1)
245
+ }
246
+
247
+ const available = await adapter.isAvailable()
248
+ if (!available) {
249
+ printAdapterError(false, adapterName)
250
+ process.exit(1)
251
+ }
252
+
253
+ console.log(c.dim(` Using adapter: ${adapterName}`))
254
+ console.log(c.dim(` Generating convoy spec from: ${opts.file}\n`))
255
+
256
+ // ── Execute the prompt through the adapter ────────────────────
257
+ const task: Task = {
258
+ id: 'generate-convoy',
259
+ prompt: assembledPrompt,
260
+ agent: 'team-lead',
261
+ timeout: '10m',
262
+ depends_on: [],
263
+ files: [],
264
+ description: 'Generate convoy spec from task description',
265
+ max_retries: 1,
266
+ }
267
+
268
+ const result = await adapter.execute(task, { verbose: opts.verbose })
269
+
270
+ // ── Extract YAML from the response ────────────────────────────
271
+ const yamlContent = extractYamlBlock(result.output)
272
+ if (!yamlContent) {
273
+ const preview = result.output.slice(0, 500)
274
+ console.error(` ✗ No YAML code block found in the agent response.\n`)
275
+ console.error(c.dim(` Raw output (truncated):\n${preview}`))
276
+ process.exit(1)
277
+ }
278
+
279
+ // ── Validate YAML ─────────────────────────────────────────────
280
+ let validationWarning = false
281
+ try {
282
+ parseTaskSpecText(yamlContent)
283
+ } catch (err) {
284
+ validationWarning = true
285
+ const msg = err instanceof Error ? err.message : String(err)
286
+ console.warn(c.yellow(` ⚠ YAML validation warning: ${msg}`))
287
+ console.warn(c.dim(` The file will still be written — you may need to edit it before running.\n`))
288
+ }
289
+
290
+ // ── Determine output path ─────────────────────────────────────
291
+ let outputPath: string
292
+ if (opts.output) {
293
+ outputPath = resolve(process.cwd(), opts.output)
294
+ } else {
295
+ const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
296
+ await mkdir(convoyDir, { recursive: true })
297
+ const filename = deriveOutputFilename(yamlContent)
298
+ outputPath = join(convoyDir, filename)
299
+ }
300
+
301
+ await mkdir(resolve(outputPath, '..'), { recursive: true })
302
+ await writeFile(outputPath, yamlContent + '\n', 'utf8')
303
+
304
+ const relPath = outputPath.startsWith(process.cwd())
305
+ ? outputPath.slice(process.cwd().length + 1)
306
+ : outputPath
307
+
308
+ console.log(c.green(` ✓ Convoy spec written to ${relPath}`))
309
+ if (validationWarning) {
310
+ console.log(c.yellow(` (contains validation warnings — review before running)`))
311
+ }
312
+ console.log(`
313
+ ${c.dim('Preview:')} npx opencastle run -f ${relPath} --dry-run
314
+ ${c.dim('Execute:')} npx opencastle run -f ${relPath}
315
+ `)
316
+ }
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync, realpathSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { EventEmitter } from 'node:events'
6
+ import type { Task } from '../../types.js'
7
+
8
+ // ── Helpers ───────────────────────────────────────────────────────────────────
9
+
10
+ function makeTask(): Task {
11
+ return {
12
+ id: 'test-task',
13
+ agent: 'developer',
14
+ prompt: 'Do something',
15
+ files: [],
16
+ timeout: '5m',
17
+ depends_on: [],
18
+ description: 'test task',
19
+ max_retries: 0,
20
+ } as unknown as Task
21
+ }
22
+
23
+ function makeMockProc(exitCode = 0, stdoutData = '{"result":"ok"}') {
24
+ const proc = new EventEmitter() as EventEmitter & {
25
+ stdout: EventEmitter
26
+ stderr: EventEmitter
27
+ killed: boolean
28
+ kill: ReturnType<typeof vi.fn>
29
+ }
30
+ proc.stdout = new EventEmitter()
31
+ proc.stderr = new EventEmitter()
32
+ proc.killed = false
33
+ proc.kill = vi.fn()
34
+ process.nextTick(() => {
35
+ if (stdoutData) proc.stdout.emit('data', Buffer.from(stdoutData))
36
+ proc.emit('close', exitCode)
37
+ })
38
+ return proc
39
+ }
40
+
41
+ // ── SDK mode ──────────────────────────────────────────────────────────────────
42
+
43
+ describe('claude adapter — SDK mode', () => {
44
+ let mockCreateSession: ReturnType<typeof vi.fn>
45
+ let mockSession: {
46
+ sendAndWait: ReturnType<typeof vi.fn>
47
+ on: ReturnType<typeof vi.fn>
48
+ destroy: ReturnType<typeof vi.fn>
49
+ abort: ReturnType<typeof vi.fn>
50
+ }
51
+
52
+ beforeEach(() => {
53
+ vi.resetModules()
54
+ mockSession = {
55
+ sendAndWait: vi.fn().mockResolvedValue({ data: { content: 'I did the task' } }),
56
+ on: vi.fn(),
57
+ destroy: vi.fn().mockResolvedValue(undefined),
58
+ abort: vi.fn().mockResolvedValue(undefined),
59
+ }
60
+ mockCreateSession = vi.fn().mockResolvedValue(mockSession)
61
+ vi.doMock('@anthropic-ai/agent-sdk', () => {
62
+ // Must use a regular function (not arrow) so `new AgentClient()` works
63
+ function MockAgentClient(this: Record<string, unknown>) {
64
+ this.start = vi.fn().mockResolvedValue(undefined)
65
+ this.createSession = mockCreateSession
66
+ }
67
+ return {
68
+ AgentClient: MockAgentClient,
69
+ approveAll: vi.fn(),
70
+ }
71
+ })
72
+ })
73
+
74
+ afterEach(() => {
75
+ vi.restoreAllMocks()
76
+ })
77
+
78
+ it('passes mcpServers to createSession when provided', async () => {
79
+ const { execute } = await import('./claude.js')
80
+ const mcpServers = [{ name: 'my-mcp', type: 'local', command: 'node', args: ['server.js'] }]
81
+ await execute(makeTask(), { mcpServers })
82
+ expect(mockCreateSession).toHaveBeenCalledWith(
83
+ expect.objectContaining({ mcpServers }),
84
+ )
85
+ })
86
+
87
+ it('does NOT include mcpServers in createSession when not provided', async () => {
88
+ const { execute } = await import('./claude.js')
89
+ await execute(makeTask(), {})
90
+ const callArg = mockCreateSession.mock.calls[0]?.[0] as Record<string, unknown>
91
+ expect(callArg).not.toHaveProperty('mcpServers')
92
+ })
93
+
94
+ it('does NOT include mcpServers when mcpServers is empty array', async () => {
95
+ const { execute } = await import('./claude.js')
96
+ await execute(makeTask(), { mcpServers: [] })
97
+ const callArg = mockCreateSession.mock.calls[0]?.[0] as Record<string, unknown>
98
+ expect(callArg).not.toHaveProperty('mcpServers')
99
+ })
100
+ })
101
+
102
+ // ── CLI mode ──────────────────────────────────────────────────────────────────
103
+
104
+ describe('claude adapter — CLI mode', () => {
105
+ let tmpDir: string
106
+ let mockSpawn: ReturnType<typeof vi.fn>
107
+
108
+ beforeEach(() => {
109
+ vi.resetModules()
110
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'claude-test-')))
111
+
112
+ mockSpawn = vi.fn().mockImplementation((cmd: string) => {
113
+ if (cmd === 'which') return makeMockProc(0, '')
114
+ return makeMockProc(0, '{"result":"ok"}')
115
+ })
116
+ vi.doMock('node:child_process', () => ({ spawn: mockSpawn }))
117
+ })
118
+
119
+ afterEach(() => {
120
+ rmSync(tmpDir, { recursive: true, force: true })
121
+ vi.restoreAllMocks()
122
+ })
123
+
124
+ it('writes mcp.json to cwd with correct format when mcpServers provided', async () => {
125
+ let capturedContent: string | null = null
126
+ mockSpawn.mockImplementation((cmd: string) => {
127
+ if (cmd === 'which') return makeMockProc(0, '')
128
+ const mcpPath = join(tmpDir, 'mcp.json')
129
+ if (existsSync(mcpPath)) {
130
+ capturedContent = readFileSync(mcpPath, 'utf8')
131
+ }
132
+ return makeMockProc(0, '{}')
133
+ })
134
+
135
+ const { executeViaCli } = await import('./claude.js')
136
+ const mcpServers = [{ name: 'my-mcp', type: 'local', command: 'node', args: ['server.js'] }]
137
+ await executeViaCli(makeTask(), { mcpServers, cwd: tmpDir })
138
+
139
+ expect(capturedContent).not.toBeNull()
140
+ expect(JSON.parse(capturedContent!)).toEqual({
141
+ mcpServers: { 'my-mcp': { command: 'node', args: ['server.js'] } },
142
+ })
143
+ })
144
+
145
+ it('passes --mcp-config flag pointing to mcp.json path', async () => {
146
+ const capturedArgs: string[] = []
147
+ mockSpawn.mockImplementation((cmd: string, args: string[]) => {
148
+ if (cmd === 'which') return makeMockProc(0, '')
149
+ capturedArgs.push(...args)
150
+ return makeMockProc(0, '{}')
151
+ })
152
+ const { executeViaCli } = await import('./claude.js')
153
+ const mcpServers = [{ name: 'my-mcp', type: 'local', command: 'node', args: ['server.js'] }]
154
+ await executeViaCli(makeTask(), { mcpServers, cwd: tmpDir })
155
+
156
+ const idx = capturedArgs.indexOf('--mcp-config')
157
+ expect(idx).toBeGreaterThanOrEqual(0)
158
+ expect(capturedArgs[idx + 1]).toBe(join(tmpDir, 'mcp.json'))
159
+ })
160
+
161
+ it('cleans up mcp.json after successful execution', async () => {
162
+ const { executeViaCli } = await import('./claude.js')
163
+ const mcpServers = [{ name: 'my-mcp', type: 'local', command: 'node', args: ['server.js'] }]
164
+ await executeViaCli(makeTask(), { mcpServers, cwd: tmpDir })
165
+ expect(existsSync(join(tmpDir, 'mcp.json'))).toBe(false)
166
+ })
167
+
168
+ it('cleans up mcp.json after failed execution (non-zero exit)', async () => {
169
+ mockSpawn.mockImplementation((cmd: string) => {
170
+ if (cmd === 'which') return makeMockProc(0, '')
171
+ return makeMockProc(1, '')
172
+ })
173
+ const { executeViaCli } = await import('./claude.js')
174
+ const mcpServers = [{ name: 'err-mcp', type: 'local', command: 'node', args: [] }]
175
+ await executeViaCli(makeTask(), { mcpServers, cwd: tmpDir })
176
+ expect(existsSync(join(tmpDir, 'mcp.json'))).toBe(false)
177
+ })
178
+
179
+ it('includes --approve-mcps flag when mcp_approve_all is true', async () => {
180
+ const capturedArgs: string[] = []
181
+ mockSpawn.mockImplementation((cmd: string, args: string[]) => {
182
+ if (cmd === 'which') return makeMockProc(0, '')
183
+ capturedArgs.push(...args)
184
+ return makeMockProc(0, '{}')
185
+ })
186
+ const { executeViaCli } = await import('./claude.js')
187
+ await executeViaCli(makeTask(), { mcp_approve_all: true, cwd: tmpDir })
188
+ expect(capturedArgs).toContain('--approve-mcps')
189
+ })
190
+
191
+ it('does NOT write mcp.json when mcpServers not configured', async () => {
192
+ const { executeViaCli } = await import('./claude.js')
193
+ await executeViaCli(makeTask(), { cwd: tmpDir })
194
+ expect(existsSync(join(tmpDir, 'mcp.json'))).toBe(false)
195
+ })
196
+
197
+ it('does NOT add --approve-mcps when mcp_approve_all is not set', async () => {
198
+ const capturedArgs: string[] = []
199
+ mockSpawn.mockImplementation((cmd: string, args: string[]) => {
200
+ if (cmd === 'which') return makeMockProc(0, '')
201
+ capturedArgs.push(...args)
202
+ return makeMockProc(0, '{}')
203
+ })
204
+ const { executeViaCli } = await import('./claude.js')
205
+ await executeViaCli(makeTask(), { cwd: tmpDir })
206
+ expect(capturedArgs).not.toContain('--approve-mcps')
207
+ })
208
+
209
+ it('maps mcpServers with url and config into mcp.json', async () => {
210
+ let capturedContent: string | null = null
211
+ mockSpawn.mockImplementation((cmd: string) => {
212
+ if (cmd === 'which') return makeMockProc(0, '')
213
+ const mcpPath = join(tmpDir, 'mcp.json')
214
+ if (existsSync(mcpPath)) capturedContent = readFileSync(mcpPath, 'utf8')
215
+ return makeMockProc(0, '{}')
216
+ })
217
+ const { executeViaCli } = await import('./claude.js')
218
+ const mcpServers = [
219
+ {
220
+ name: 'remote-mcp',
221
+ type: 'remote',
222
+ url: 'http://localhost:9000',
223
+ config: { token: 'abc' },
224
+ },
225
+ ]
226
+ await executeViaCli(makeTask(), { mcpServers, cwd: tmpDir })
227
+ expect(capturedContent).not.toBeNull()
228
+ const parsed = JSON.parse(capturedContent!) as { mcpServers: Record<string, Record<string, unknown>> }
229
+ expect(parsed.mcpServers['remote-mcp']).toMatchObject({
230
+ url: 'http://localhost:9000',
231
+ token: 'abc',
232
+ })
233
+ })
234
+ })
@@ -1,10 +1,13 @@
1
1
  import { spawn } from 'node:child_process'
2
+ import { writeFileSync, unlinkSync } from 'node:fs'
3
+ import { join } from 'node:path'
2
4
  import { parseTimeout } from '../schema.js'
3
5
  import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
4
6
 
5
7
  // Adapter name
6
8
  export const name = 'claude'
7
9
 
10
+ export function supportsSessionContinuity(): boolean { return false }
8
11
  // Module-level state for mode selection
9
12
  let mode: 'sdk' | 'cli' | null = null
10
13
 
@@ -100,6 +103,8 @@ async function executeViaSdk(task: Task, options: ExecuteOptions = {}): Promise<
100
103
  },
101
104
  infiniteSessions: { enabled: false },
102
105
  ...(options.verbose ? { streaming: true } : {}),
106
+ // mcpServers is forward-compatible: field will be recognised by future SDK versions
107
+ ...(options.mcpServers?.length ? { mcpServers: options.mcpServers } : {}),
103
108
  })
104
109
  activeSessions.set(task.id, session)
105
110
  if (options.verbose) {
@@ -108,12 +113,21 @@ async function executeViaSdk(task: Task, options: ExecuteOptions = {}): Promise<
108
113
  process.stdout.write(event.data.deltaContent)
109
114
  })
110
115
  }
116
+ interface SdkResponse {
117
+ data?: {
118
+ content?: string
119
+ usage?: Record<string, number>
120
+ }
121
+ usage?: Record<string, number>
122
+ }
123
+
111
124
  try {
112
125
  const timeoutMs = parseTimeout(task.timeout)
113
126
  const response = await session.sendAndWait({ prompt }, timeoutMs)
114
- const data = (response as any)?.data as Record<string, unknown> | undefined
127
+ const typed = response as SdkResponse
128
+ const data = typed?.data
115
129
  const output = (data?.content as string | undefined) ?? ''
116
- const rawUsage = data?.usage ?? (response as any)?.usage
130
+ const rawUsage = data?.usage ?? typed?.usage
117
131
  const u = rawUsage as Record<string, number> | undefined
118
132
  const usageResult = u
119
133
  ? {
@@ -150,7 +164,7 @@ function killSdk(task: Task): void {
150
164
  }
151
165
 
152
166
  // --- CLI implementation (from claude-code.ts) ---
153
- async function executeViaCli(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
167
+ export async function executeViaCli(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
154
168
  let prompt = `You are a ${task.agent}. ${task.prompt}`
155
169
  if (task.files && task.files.length > 0) {
156
170
  prompt += `\n\nOnly modify files under: ${task.files.join(', ')}`
@@ -163,11 +177,32 @@ async function executeViaCli(task: Task, options: ExecuteOptions = {}): Promise<
163
177
  '--max-turns',
164
178
  '50',
165
179
  ]
166
- return new Promise((resolve) => {
180
+ const cwd = options?.cwd ?? process.cwd()
181
+ const mcpJsonPath = join(cwd, 'mcp.json')
182
+ let wroteJson = false
183
+ if (options.mcpServers?.length) {
184
+ const mcpJson: Record<string, Record<string, unknown>> = {}
185
+ for (const server of options.mcpServers) {
186
+ const entry: Record<string, unknown> = {}
187
+ if (server.command) entry.command = server.command
188
+ if (server.args) entry.args = server.args
189
+ if (server.url) entry.url = server.url
190
+ if (server.config) Object.assign(entry, server.config)
191
+ mcpJson[server.name] = entry
192
+ }
193
+ writeFileSync(mcpJsonPath, JSON.stringify({ mcpServers: mcpJson }, null, 2), 'utf8')
194
+ args.push('--mcp-config', mcpJsonPath)
195
+ wroteJson = true
196
+ }
197
+ if (options.mcp_approve_all) {
198
+ args.push('--approve-mcps')
199
+ }
200
+ try {
201
+ return await new Promise<ExecuteResult>((resolve) => {
167
202
  const proc = spawn('claude', args, {
168
203
  stdio: ['ignore', 'pipe', 'pipe'],
169
204
  env: { ...process.env },
170
- cwd: options?.cwd ?? process.cwd(),
205
+ cwd,
171
206
  })
172
207
  let stdout = ''
173
208
  let stderr = ''
@@ -212,6 +247,11 @@ async function executeViaCli(task: Task, options: ExecuteOptions = {}): Promise<
212
247
  })
213
248
  task._process = proc
214
249
  })
250
+ } finally {
251
+ if (wroteJson) {
252
+ try { unlinkSync(mcpJsonPath) } catch { /* ignore */ }
253
+ }
254
+ }
215
255
  }
216
256
 
217
257
  function killCli(task: Task): void {