cuttlely 1.0.2 → 1.1.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.
package/README.md CHANGED
@@ -41,10 +41,65 @@ cuttlely --help
41
41
  cuttlely --version
42
42
  ```
43
43
 
44
+ ## Check a draft
45
+
46
+ `check` runs the Publish check on a flow's draft and waits for it. It reads the API key from `CUTTLELY_API_KEY` and calls the server you name. It does not start Docker.
47
+
48
+ ```bash
49
+ export CUTTLELY_API_KEY=<key>
50
+ npx cuttlely check --flow <flow id> --server http://127.0.0.1:43117 --timeout 300 --json check.json --fail-on-changed
51
+ ```
52
+
53
+ | Flag | Meaning |
54
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
55
+ | `--flow` | Flow id from the canvas address bar. Required. |
56
+ | `--server` | Server origin. Default `http://127.0.0.1:43117`. |
57
+ | `--timeout` | Seconds to wait for the check. Default 300. The final confirmation read after a `--gate` pass has its own window of at most 30 seconds. |
58
+ | `--json` | Write the full check JSON to this path. |
59
+ | `--fail-on-changed` | Exit 1 when any answer is Changed. Without it, a finished check exits 0 and the summary still lists them. |
60
+
61
+ `pnpm dev` serves the API on port 3000. `scripts/start-cuttlely.sh` and `npx cuttlely start` listen on port 43117.
62
+
63
+ | Code | When |
64
+ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | 0 | The check finished. No answer was Changed, or `--fail-on-changed` was off. |
66
+ | 1 | `--fail-on-changed` was set and at least one answer was Changed. |
67
+ | 2 | The check timed out, the server returned an error, `CUTTLELY_API_KEY` was missing, the flow has no draft, or the command was used wrong. |
68
+
69
+ A check the server marks stopped or failed also exits 2. If the draft is saved, published, or discarded while the check runs, the command exits 2, because the answers may be for a draft that is already gone. After the check has started, a timeout, a 403, or any other failure asks the server to stop it. Cancelling the command stops that server-side check too. That stop is best effort: if it fails, the exit code is still 2.
70
+
71
+ The key needs both permissions for the flow: `chatflows:view` and `chatflows:update`, or `agentflows:view` and `agentflows:update`. Update starts the check. View is what reading the result uses. The guide is [Fail a build when answers change](https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/ci-gate.md).
72
+
73
+ ## Score a test set
74
+
75
+ `--dataset` or `--gate` asks a dataset instead of the four recent questions. The command exits on the server verdict. It does not score the answers itself.
76
+
77
+ ```bash
78
+ npx cuttlely check --flow <flow id> --dataset <dataset id> --evaluator <evaluator id> --threshold 80
79
+ npx cuttlely check --flow <flow id> --gate
80
+ ```
81
+
82
+ | Flag | Meaning |
83
+ | ------------- | ----------------------------------------------------------------------------------------- |
84
+ | `--dataset` | Dataset id. Selects scored mode. |
85
+ | `--evaluator` | Evaluator id. Repeat for another evaluator. |
86
+ | `--threshold` | Pass mark from 0 to 100. Omit it and the server uses 100. |
87
+ | `--gate` | Dataset, evaluators, and threshold saved on the flow. Do not combine it with `--dataset`. |
88
+
89
+ Do not combine those flags with `--fail-on-changed`.
90
+
91
+ | Code | When |
92
+ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | 0 | The server verdict is passed. With `--gate`, a second read of the flow still has the passing test set on, no block, and this check's id stored as a passed result. |
94
+ | 1 | The server verdict is failed. |
95
+ | 2 | The same failures as the question check, a missing verdict, a usage error, a withheld dataset or evaluator id, or a `--gate` pass that is no longer the stored result. The message is the server's block text, or `The saved test set changed during the check. Run it again.` when there is none. |
96
+
97
+ A 403 names the flow view and update permissions, plus `datasets:view` and `evaluators:view`. The summary prints the pass rate, the threshold, and the cases whose draft scores are not Pass. `--json` writes the full check, including each score.
98
+
44
99
  The container name is `cuttlely`. `start` leaves it running when it is already on the same image and port. `upgrade` always pulls and starts it again. The volume is `cuttlely-data`, mounted at `/var/cuttlely`.
45
100
 
46
101
  Compose uses the volume name `cuttlely_cuttlely-data`. This launcher uses `cuttlely-data`.
47
102
 
48
103
  The first account is created from the one-time setup link in `cuttlely logs`.
49
104
 
50
- This package makes no network call of its own. Docker pulls the image, and the command waits on the local health check.
105
+ The start, stop, status, logs and upgrade commands make no network call of their own. Docker pulls the image, and start waits on the local health check. `check` calls the Cuttlely server named with `--server`.
package/bin/cuttlely.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { main } from '../lib/cli.js'
2
+ import { commandFailureCode, main } from '../lib/cli.js'
3
3
 
4
+ const argv = process.argv.slice(2)
4
5
  try {
5
- process.exitCode = await main(process.argv.slice(2))
6
+ process.exitCode = await main(argv)
6
7
  } catch (error) {
7
8
  console.error(error instanceof Error ? error.message : String(error))
8
- process.exitCode = 1
9
+ process.exitCode = commandFailureCode(argv)
9
10
  }
package/lib/check.js ADDED
@@ -0,0 +1,530 @@
1
+ // Runs the Publish check (POST /api/v1/flow-drafts/:id/check) and waits for it.
2
+ // Talks only to the server named with --server. The API key stays in the environment.
3
+ import { writeFileSync } from 'node:fs'
4
+
5
+ export const API_KEY_ENV = 'CUTTLELY_API_KEY'
6
+ export const DEFAULT_SERVER = 'http://127.0.0.1:43117'
7
+ export const DEFAULT_TIMEOUT_SECONDS = 300
8
+ export const DEFAULT_QUESTION_COUNT = 4
9
+ export const POLL_MS = 1000
10
+
11
+ export const EXIT_PASS = 0
12
+ export const EXIT_CHANGED = 1
13
+ export const EXIT_ERROR = 2
14
+
15
+ // Flow, dataset, and evaluator ids share this shape.
16
+ export const RESOURCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
17
+
18
+ const ANSWER_CLIP = 240
19
+
20
+ function redact(text, secret) {
21
+ if (!secret || typeof text !== 'string' || !text.includes(secret)) return text
22
+ return text.split(secret).join('[redacted]')
23
+ }
24
+
25
+ function redactValue(value, secret) {
26
+ if (typeof value === 'string') return redact(value, secret)
27
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, secret))
28
+ if (value && typeof value === 'object') {
29
+ const copy = {}
30
+ for (const [key, item] of Object.entries(value)) copy[key] = redactValue(item, secret)
31
+ return copy
32
+ }
33
+ return value
34
+ }
35
+
36
+ function oneLine(text) {
37
+ const flat = String(text ?? '')
38
+ .replace(/\s+/g, ' ')
39
+ .trim()
40
+ if (!flat) return '(empty)'
41
+ if (flat.length <= ANSWER_CLIP) return flat
42
+ return `${flat.slice(0, ANSWER_CLIP - 3)}...`
43
+ }
44
+
45
+ function formatMs(value) {
46
+ return value == null ? 'n/a' : `${value} ms`
47
+ }
48
+
49
+ function formatUsd(value) {
50
+ const n = Number(value)
51
+ if (!Number.isFinite(n)) return ''
52
+ if (n === 0) return '$0'
53
+ if (Math.abs(n) >= 0.01) return `$${n.toFixed(2)}`
54
+ return `$${n.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
55
+ }
56
+
57
+ function estimateLine(label, usd, price) {
58
+ if (price === 'no price') return `${label}: estimated cost no price`
59
+ if (price === 'partial') {
60
+ const money = formatUsd(usd)
61
+ return money ? `${label}: estimated ${money} (some models have no price)` : `${label}: estimated cost no price`
62
+ }
63
+ if (price === 'known') {
64
+ const money = formatUsd(usd)
65
+ return money ? `${label}: estimated ${money}` : ''
66
+ }
67
+ return ''
68
+ }
69
+
70
+ export function changedCases(check) {
71
+ return (check?.cases || []).filter((row) => row && row.same === false && row.live && row.draft && !row.live.error && !row.draft.error)
72
+ }
73
+
74
+ function errorCases(check) {
75
+ return (check?.cases || []).filter((row) => row && (row.live?.error || row.draft?.error))
76
+ }
77
+
78
+ function verdictLine(verdict) {
79
+ const rate = verdict.passRate == null ? 'no scored answers' : `${verdict.passRate}%`
80
+ const mark = verdict.threshold == null ? 'n/a' : `${verdict.threshold}%`
81
+ const passed = Number(verdict.passed ?? 0)
82
+ const failed = Number(verdict.failed ?? 0)
83
+ const errored = Number(verdict.errored ?? 0)
84
+ return `Pass rate: ${rate} (threshold ${mark}). ${passed} passed, ${failed} failed, ${errored} errored.`
85
+ }
86
+
87
+ function failingCases(check) {
88
+ return (check?.cases || []).filter((row) => {
89
+ const scores = row?.draft?.scores
90
+ if (!Array.isArray(scores) || !scores.length) return false
91
+ return scores.some((score) => score && score.result !== 'Pass')
92
+ })
93
+ }
94
+
95
+ function failingLines(check) {
96
+ const rows = failingCases(check)
97
+ if (!rows.length) return []
98
+ const lines = ['', 'Failing']
99
+ for (const row of rows) {
100
+ lines.push(` ${oneLine(row.question)}`)
101
+ for (const score of row.draft.scores) {
102
+ if (!score || score.result === 'Pass') continue
103
+ lines.push(` ${score.name ? `${score.name}: ${score.result}` : score.result}`)
104
+ }
105
+ if (row.expected) lines.push(` Expected: ${oneLine(row.expected)}`)
106
+ if (row.draft?.text) lines.push(` Draft: ${oneLine(row.draft.text)}`)
107
+ else if (row.draft?.error) lines.push(` ${oneLine(row.draft.error)}`)
108
+ }
109
+ return lines
110
+ }
111
+
112
+ export function summaryText(check) {
113
+ const summary = check.summary || {}
114
+ const lines = [
115
+ `Check ${check.id} ${check.status}`,
116
+ `Questions: ${summary.ran ?? 0} ran, ${summary.same ?? 0} same, ${summary.changed ?? 0} changed, ${summary.errors ?? 0} errors`
117
+ ]
118
+ // Present only when the server finished a dataset check. Diff mode has no verdict.
119
+ if (check.verdict && typeof check.verdict === 'object') lines.push(verdictLine(check.verdict))
120
+ lines.push(
121
+ `Live: ${summary.liveTokens ?? 0} tokens, median ${formatMs(summary.liveMedianMs)}`,
122
+ `Draft: ${summary.draftTokens ?? 0} tokens, median ${formatMs(summary.draftMedianMs)}`
123
+ )
124
+ // The check total, with each model priced once. It is not the sum of the per-answer figures.
125
+ const liveEstimate = estimateLine('Live', summary.liveEstimatedCostUsd, summary.livePrice)
126
+ const draftEstimate = estimateLine('Draft', summary.draftEstimatedCostUsd, summary.draftPrice)
127
+ if (liveEstimate) lines.push(liveEstimate)
128
+ if (draftEstimate) lines.push(draftEstimate)
129
+ if (check.liveEmpty) lines.push('Live has no cards, so only the draft was asked.')
130
+ if (check.stopReason) lines.push(`Stopped: ${check.stopReason}`)
131
+ if (check.error) lines.push(`Error: ${check.error}`)
132
+ const changed = changedCases(check)
133
+ if (changed.length) {
134
+ lines.push('', 'Changed')
135
+ for (const row of changed) {
136
+ lines.push(` ${oneLine(row.question)}`)
137
+ lines.push(` Live: ${oneLine(row.live.text)}`)
138
+ lines.push(` Draft: ${oneLine(row.draft.text)}`)
139
+ }
140
+ }
141
+ const errors = errorCases(check)
142
+ if (errors.length) {
143
+ lines.push('', 'Errors')
144
+ for (const row of errors) {
145
+ const why = [row.live?.error, row.draft?.error].filter(Boolean).join('; ')
146
+ lines.push(` ${oneLine(row.question)}`)
147
+ lines.push(` ${oneLine(why)}`)
148
+ }
149
+ }
150
+ if (check.verdict && typeof check.verdict === 'object') lines.push(...failingLines(check))
151
+ return lines.join('\n')
152
+ }
153
+
154
+ // Replay is the question check. Scored mode reads the server verdict and does not rescore.
155
+ export function replayVerdict(check, { failOnChanged }) {
156
+ if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
157
+ if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
158
+ if (check.status === 'stopped') return { code: EXIT_ERROR, label: 'stopped' }
159
+ if (check.status !== 'done') return { code: EXIT_ERROR, label: 'incomplete' }
160
+ if (check.draftChanged) return { code: EXIT_ERROR, label: 'draft-changed' }
161
+ const changed = Number(check.summary?.changed ?? 0)
162
+ if (!Number.isFinite(changed)) return { code: EXIT_ERROR, label: 'error' }
163
+ if (failOnChanged && changed > 0) return { code: EXIT_CHANGED, label: 'changed' }
164
+ return { code: EXIT_PASS, label: 'pass' }
165
+ }
166
+
167
+ // Exit 1 is the server's failed verdict. A missing verdict is an error, not a guess from the pass rate.
168
+ export function scoredVerdict(check) {
169
+ if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
170
+ if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
171
+ if (check.status === 'stopped') return { code: EXIT_ERROR, label: 'stopped' }
172
+ if (check.status !== 'done') return { code: EXIT_ERROR, label: 'incomplete' }
173
+ if (check.draftChanged) return { code: EXIT_ERROR, label: 'draft-changed' }
174
+ const result = check.verdict && check.verdict.result
175
+ if (result === 'passed') return { code: EXIT_PASS, label: 'passed' }
176
+ if (result === 'failed') return { code: EXIT_CHANGED, label: 'score-failed' }
177
+ return { code: EXIT_ERROR, label: 'no-verdict' }
178
+ }
179
+
180
+ export function selectVerdict(check, options) {
181
+ if (options && options.scored) return scoredVerdict(check)
182
+ return replayVerdict(check, options)
183
+ }
184
+
185
+ function deadlineSignal(deadline, now) {
186
+ const remaining = Math.max(1, deadline - now())
187
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(remaining)
188
+ return undefined
189
+ }
190
+
191
+ async function callApi(fetchImpl, url, { method, headers, body, signal }) {
192
+ let response
193
+ try {
194
+ response = await fetchImpl(url, { method, headers, body, redirect: 'manual', signal })
195
+ } catch (error) {
196
+ const name = error && error.name
197
+ if (name === 'AbortError' || name === 'TimeoutError') return { timeout: true }
198
+ return { network: true, message: error instanceof Error ? error.message : String(error) }
199
+ }
200
+ const status = Number(response.status)
201
+ if (status >= 300 && status < 400) {
202
+ return { ok: false, status, message: 'The server sent a redirect, which this command will not follow.' }
203
+ }
204
+ let text = ''
205
+ try {
206
+ text = typeof response.text === 'function' ? await response.text() : ''
207
+ } catch (error) {
208
+ return { network: true, message: error instanceof Error ? error.message : String(error) }
209
+ }
210
+ let json = null
211
+ if (text) {
212
+ try {
213
+ json = JSON.parse(text)
214
+ } catch {
215
+ json = null
216
+ }
217
+ }
218
+ const raw = json && (json.message || json.error)
219
+ const message = typeof raw === 'string' && raw ? raw : text ? text.slice(0, 300) : `The server returned HTTP ${status}.`
220
+ return { ok: status >= 200 && status < 300, status, message, json }
221
+ }
222
+
223
+ function isNoDraft(result) {
224
+ return result.status === 409 && typeof result.message === 'string' && /no unpublished changes/i.test(result.message)
225
+ }
226
+
227
+ // Starting a check needs update. Reading it needs view. A key with only one of those gets 403,
228
+ // and the server message does not say which permission is missing.
229
+ function permissionDeniedMessage(scored) {
230
+ const flow = 'The API key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.'
231
+ if (!scored) return flow
232
+ return `${flow} Scored mode also needs datasets:view and evaluators:view.`
233
+ }
234
+
235
+ function scoreFailedLine(verdict) {
236
+ const rate = !verdict || verdict.passRate == null ? 'no scored answers' : `${verdict.passRate}%`
237
+ const mark = !verdict || verdict.threshold == null ? 'n/a' : `${verdict.threshold}%`
238
+ return `The test set failed at ${rate} (threshold ${mark}).`
239
+ }
240
+
241
+ const STALE_GATE = 'The saved test set changed during the check. Run it again.'
242
+ // The check can use the whole --timeout. The confirmation read gets its own window, capped so a hung read cannot double a 3600s wait.
243
+ const CONFIRM_READ_CAP_SECONDS = 30
244
+
245
+ function confirmReadSeconds(timeoutSeconds) {
246
+ return Math.min(timeoutSeconds, CONFIRM_READ_CAP_SECONDS)
247
+ }
248
+
249
+ // An enabled gate always stores a dataset and at least one evaluator. A read that
250
+ // omits them withheld the ids: datasets:view and evaluators:view are what reveal them.
251
+ function withheldIdsMessage(datasetMissing, evaluatorsMissing) {
252
+ if (datasetMissing && evaluatorsMissing) {
253
+ return 'The response did not include the dataset id or the evaluator ids. The API key needs datasets:view and evaluators:view.'
254
+ }
255
+ if (datasetMissing) return 'The response did not include the dataset id. The API key needs datasets:view.'
256
+ return 'The response did not include the evaluator ids. The API key needs evaluators:view.'
257
+ }
258
+
259
+ function datasetIdMissing(gate) {
260
+ return typeof gate.datasetId !== 'string' || gate.datasetId.trim() === ''
261
+ }
262
+
263
+ function evaluatorIdsMissing(gate) {
264
+ return !Array.isArray(gate.evaluatorIds) || gate.evaluatorIds.length === 0
265
+ }
266
+
267
+ // --gate copies the dataset, evaluators, and threshold the flow already saved.
268
+ function gateSettings(body) {
269
+ const gate = body && body.publishGate
270
+ if (!gate || gate.enabled !== true) {
271
+ return { ok: false, message: 'This flow has no saved test set. Turn the passing test set on, or pass --dataset.' }
272
+ }
273
+ const datasetMissing = datasetIdMissing(gate)
274
+ const evaluatorsMissing = evaluatorIdsMissing(gate)
275
+ if (datasetMissing || evaluatorsMissing) return { ok: false, message: withheldIdsMessage(datasetMissing, evaluatorsMissing) }
276
+ if (!RESOURCE_ID_RE.test(gate.datasetId)) {
277
+ return { ok: false, message: 'The saved test set has no dataset id.' }
278
+ }
279
+ const evaluatorIds = []
280
+ const seen = new Set()
281
+ for (const id of gate.evaluatorIds) {
282
+ if (typeof id !== 'string' || !RESOURCE_ID_RE.test(id)) {
283
+ return { ok: false, message: 'The saved test set has an evaluator id this command cannot use.' }
284
+ }
285
+ if (seen.has(id)) continue
286
+ seen.add(id)
287
+ evaluatorIds.push(id)
288
+ }
289
+ let threshold = null
290
+ if (gate.threshold != null) {
291
+ if (typeof gate.threshold !== 'number' || !Number.isInteger(gate.threshold) || gate.threshold < 0 || gate.threshold > 100) {
292
+ return { ok: false, message: 'The saved pass mark is not a whole number from 0 to 100.' }
293
+ }
294
+ threshold = gate.threshold
295
+ }
296
+ return { ok: true, datasetId: gate.datasetId, evaluatorIds, threshold }
297
+ }
298
+
299
+ // A passed --gate check is current only when Publish would still accept this run.
300
+ function gateBlockMessage(gate) {
301
+ if (gate && typeof gate.block === 'string' && gate.block.trim()) return gate.block
302
+ return STALE_GATE
303
+ }
304
+
305
+ function gatePassStillCurrent(body, checkId) {
306
+ const gate = body && body.publishGate
307
+ const message = gateBlockMessage(gate)
308
+ if (!gate || gate.enabled !== true) return { ok: false, message }
309
+ if (typeof gate.block === 'string' && gate.block.trim()) return { ok: false, message }
310
+ const result = gate.result
311
+ if (!result || result.checkId !== checkId || result.verdict !== 'passed') return { ok: false, message }
312
+ return { ok: true }
313
+ }
314
+
315
+ function requestBody({ scored, datasetId, evaluatorIds, threshold }) {
316
+ if (!scored) return { count: DEFAULT_QUESTION_COUNT }
317
+ const body = { datasetId }
318
+ if (evaluatorIds && evaluatorIds.length) body.evaluatorIds = evaluatorIds
319
+ if (threshold != null) body.threshold = threshold
320
+ return body
321
+ }
322
+
323
+ function writeResult(writeFile, jsonPath, check, secret) {
324
+ if (!jsonPath || !check) return
325
+ const safe = redactValue(check, secret)
326
+ writeFile(jsonPath, `${JSON.stringify(safe, null, 4)}\n`)
327
+ }
328
+
329
+ async function stopCheck(fetchImpl, url, headers) {
330
+ try {
331
+ const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined
332
+ await fetchImpl(url, { method: 'DELETE', headers, redirect: 'manual', signal })
333
+ } catch {
334
+ // The timeout result stands either way. A stuck stop must not hide it.
335
+ }
336
+ }
337
+
338
+ export async function runCheck(options, io = {}) {
339
+ const out = io.out || ((line) => console.info(line))
340
+ const err = io.err || ((line) => console.error(line))
341
+ const env = io.env || process.env
342
+ const fetchImpl = io.fetch || globalThis.fetch.bind(globalThis)
343
+ const sleep = io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
344
+ const now = io.now || Date.now
345
+ const writeFile = io.writeFile || ((file, text) => writeFileSync(file, text))
346
+ const secret = typeof env[API_KEY_ENV] === 'string' ? env[API_KEY_ENV].trim() : ''
347
+ if (!secret) {
348
+ err(`${API_KEY_ENV} is not set.`)
349
+ return EXIT_ERROR
350
+ }
351
+
352
+ const server = options.server || DEFAULT_SERVER
353
+ const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS
354
+ const flow = options.flow
355
+ const failOnChanged = Boolean(options.failOnChanged)
356
+ const scored = Boolean(options.scored || options.gate)
357
+ let datasetId = options.datasetId
358
+ let evaluatorIds = options.evaluatorIds || []
359
+ let threshold = options.threshold ?? null
360
+ const flowUrl = `${server}/api/v1/flow-drafts/${encodeURIComponent(flow)}`
361
+ const base = `${flowUrl}/check`
362
+ const headers = { Authorization: `Bearer ${secret}`, Accept: 'application/json' }
363
+ const deadline = now() + timeoutSeconds * 1000
364
+
365
+ const say = (text) => redact(text, secret)
366
+
367
+ const fail = (text) => {
368
+ err(say(text))
369
+ return EXIT_ERROR
370
+ }
371
+
372
+ // Set once the server has accepted the check. Every later failure tries to stop it.
373
+ let check = null
374
+ const stopIfStarted = async () => {
375
+ if (!check?.id) return
376
+ await stopCheck(fetchImpl, `${base}/${encodeURIComponent(check.id)}`, headers)
377
+ }
378
+ // The stop is best effort. A failed DELETE does not replace the exit code.
379
+ const failRunning = async (text) => {
380
+ await stopIfStarted()
381
+ return fail(text)
382
+ }
383
+ // Signals are armed only after the id is known, and removed on a normal return so a
384
+ // finished check is left alone. process.exit skips the finally, so the handler disarms first.
385
+ const signalTarget = io.signals || process
386
+ let signalExit = false
387
+ const onSignal = () => {
388
+ if (signalExit) return
389
+ signalExit = true
390
+ disarmSignals()
391
+ void (async () => {
392
+ await stopIfStarted()
393
+ err('The check was cancelled.')
394
+ const exit = io.exit || ((code) => process.exit(code))
395
+ exit(EXIT_ERROR)
396
+ })()
397
+ }
398
+ const disarmSignals = () => {
399
+ signalTarget.removeListener('SIGTERM', onSignal)
400
+ signalTarget.removeListener('SIGINT', onSignal)
401
+ }
402
+ const armSignals = () => {
403
+ signalTarget.on('SIGTERM', onSignal)
404
+ signalTarget.on('SIGINT', onSignal)
405
+ }
406
+
407
+ try {
408
+ if (options.gate) {
409
+ const loaded = await callApi(fetchImpl, flowUrl, {
410
+ method: 'GET',
411
+ headers,
412
+ signal: deadlineSignal(deadline, now)
413
+ })
414
+ if (loaded.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
415
+ if (loaded.network) return fail(`Could not reach ${server}. ${loaded.message}`)
416
+ if (!loaded.ok) {
417
+ if (loaded.status === 403) return fail(permissionDeniedMessage(true))
418
+ return fail(`Check failed (${loaded.status}): ${loaded.message}`)
419
+ }
420
+ const parsed = gateSettings(loaded.json)
421
+ if (!parsed.ok) return fail(parsed.message)
422
+ datasetId = parsed.datasetId
423
+ evaluatorIds = parsed.evaluatorIds
424
+ threshold = parsed.threshold
425
+ }
426
+ const started = await callApi(fetchImpl, base, {
427
+ method: 'POST',
428
+ headers: { ...headers, 'Content-Type': 'application/json' },
429
+ body: JSON.stringify(requestBody({ scored, datasetId, evaluatorIds, threshold })),
430
+ signal: deadlineSignal(deadline, now)
431
+ })
432
+ if (started.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
433
+ if (started.network) return fail(`Could not reach ${server}. ${started.message}`)
434
+ if (!started.ok) {
435
+ if (started.status === 403) return fail(permissionDeniedMessage(scored))
436
+ if (isNoDraft(started)) return fail(`No draft to check. ${started.message}`)
437
+ return fail(`Check failed (${started.status}): ${started.message}`)
438
+ }
439
+ check = started.json
440
+ if (!check || typeof check !== 'object' || !check.id) return fail('The server did not return a check.')
441
+ armSignals()
442
+ out(`Started check ${check.id} for flow ${flow} (${check.total ?? 0} questions).`)
443
+
444
+ const giveUp = async () => {
445
+ await stopIfStarted()
446
+ try {
447
+ writeResult(writeFile, options.jsonPath, check, secret)
448
+ } catch (error) {
449
+ err(say(`Could not write ${options.jsonPath}. ${error instanceof Error ? error.message : String(error)}`))
450
+ }
451
+ return fail(`Timed out after ${timeoutSeconds}s waiting for check ${check.id}.`)
452
+ }
453
+
454
+ while (check.status === 'running') {
455
+ // Read before sleeping, and once more at the deadline, so a check that
456
+ // finishes in the last poll interval is not reported from a stale body.
457
+ const polled = await callApi(fetchImpl, `${base}/${encodeURIComponent(check.id)}`, {
458
+ method: 'GET',
459
+ headers,
460
+ signal: deadlineSignal(deadline + POLL_MS, now)
461
+ })
462
+ if (polled.timeout) return giveUp()
463
+ if (polled.network) return failRunning(`Could not reach ${server}. ${polled.message}`)
464
+ if (polled.status === 403) return failRunning(permissionDeniedMessage(scored))
465
+ if (!polled.ok) return failRunning(`Check failed (${polled.status}): ${polled.message}`)
466
+ if (!polled.json || typeof polled.json !== 'object') return failRunning('The server did not return a check.')
467
+ const previous = check.completed
468
+ check = polled.json
469
+ if (check.status === 'running' && check.completed !== previous && check.completed != null) {
470
+ out(`Asked ${check.completed} of ${check.total}.`)
471
+ }
472
+ if (check.status !== 'running') break
473
+ const remaining = deadline - now()
474
+ if (remaining <= 0) return giveUp()
475
+ await sleep(Math.min(POLL_MS, remaining))
476
+ }
477
+
478
+ try {
479
+ writeResult(writeFile, options.jsonPath, check, secret)
480
+ } catch (error) {
481
+ err(say(`Could not write ${options.jsonPath}. ${error instanceof Error ? error.message : String(error)}`))
482
+ out(say(summaryText(check)))
483
+ await stopIfStarted()
484
+ return EXIT_ERROR
485
+ }
486
+ out(say(summaryText(check)))
487
+ const verdict = selectVerdict(check, { failOnChanged, scored })
488
+ if (verdict.label === 'draft-changed') {
489
+ err('The draft changed while the check was running, so the result is out of date.')
490
+ } else if (verdict.label === 'failed') {
491
+ err(say(check.error ? `The check failed. ${check.error}` : 'The check failed.'))
492
+ } else if (verdict.label === 'stopped') {
493
+ err(`The check stopped${check.stopReason ? ` (${check.stopReason})` : ''} before it finished.`)
494
+ } else if (verdict.label === 'changed') {
495
+ const count = Number(check.summary?.changed ?? 0)
496
+ err(count === 1 ? '1 answer changed.' : `${count} answers changed.`)
497
+ } else if (verdict.label === 'score-failed') {
498
+ err(scoreFailedLine(check.verdict))
499
+ } else if (verdict.label === 'no-verdict') {
500
+ err('The server did not return a verdict for this test set.')
501
+ } else if (verdict.code === EXIT_ERROR) {
502
+ err('The check did not finish.')
503
+ }
504
+ if (verdict.code === EXIT_ERROR) await stopIfStarted()
505
+ // --gate can exit 0 only when this run is still the stored pass. A threshold,
506
+ // row, or evaluator change during the check makes Publish refuse it.
507
+ if (options.gate && verdict.code === EXIT_PASS) {
508
+ const confirmSeconds = confirmReadSeconds(timeoutSeconds)
509
+ const again = await callApi(fetchImpl, flowUrl, {
510
+ method: 'GET',
511
+ headers,
512
+ signal: deadlineSignal(now() + confirmSeconds * 1000, now)
513
+ })
514
+ if (again.timeout) return fail(`Timed out after ${confirmSeconds}s before the saved test set could be read again.`)
515
+ if (again.network) return fail(`Could not reach ${server}. ${again.message}`)
516
+ if (!again.ok) {
517
+ if (again.status === 403) return fail(permissionDeniedMessage(true))
518
+ return fail(`Check failed (${again.status}): ${again.message}`)
519
+ }
520
+ const current = gatePassStillCurrent(again.json, check.id)
521
+ if (!current.ok) return fail(current.message)
522
+ }
523
+ return verdict.code
524
+ } catch (error) {
525
+ await stopIfStarted()
526
+ return fail(error instanceof Error ? error.message : String(error))
527
+ } finally {
528
+ if (!signalExit) disarmSignals()
529
+ }
530
+ }
package/lib/cli.js CHANGED
@@ -4,6 +4,7 @@ import { readFileSync, statSync } from 'node:fs'
4
4
  import { spawn } from 'node:child_process'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { RESOURCE_ID_RE, runCheck } from './check.js'
7
8
  import { spawnDocker } from './docker.js'
8
9
 
9
10
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
@@ -20,7 +21,12 @@ export const HEALTH_TIMEOUT_MS = 6 * 60 * 1000
20
21
  const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/
21
22
  const INSPECT_FORMAT = '{{.State.Status}}|{{.Config.Image}}|{{.Image}}|{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}'
22
23
 
23
- export class UsageError extends Error {}
24
+ export class UsageError extends Error {
25
+ constructor(message, exitCode = 1) {
26
+ super(message)
27
+ this.exitCode = exitCode
28
+ }
29
+ }
24
30
 
25
31
  export function helpText() {
26
32
  return [
@@ -32,6 +38,9 @@ export function helpText() {
32
38
  ' cuttlely status',
33
39
  ' cuttlely logs [--follow]',
34
40
  ' cuttlely upgrade [--port <n>] [--tag <tag>] [--env-file <path>] [--no-open]',
41
+ ' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] [--fail-on-changed]',
42
+ ' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] --dataset <id> [--evaluator <id> ...] [--threshold <0-100>]',
43
+ ' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] --gate',
35
44
  ' cuttlely --help',
36
45
  ' cuttlely --version',
37
46
  '',
@@ -45,30 +54,124 @@ export function helpText() {
45
54
  ' status Show whether Cuttlely is running.',
46
55
  ' logs Show container logs. --follow keeps streaming.',
47
56
  ' upgrade Pull the image and start it again.',
57
+ ' check Run the Publish check on a flow draft. Reads CUTTLELY_API_KEY.',
58
+ ' The key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.',
59
+ ' --dataset or --gate also needs datasets:view and evaluators:view.',
60
+ '',
61
+ 'Exit codes for check:',
62
+ ' 0 The check finished. No answer was Changed, or --fail-on-changed was off. A scored check exits 0 when the server verdict is passed.',
63
+ ' 1 --fail-on-changed was set and at least one answer was Changed. A scored check exits 1 when the server verdict is failed.',
64
+ ' 2 The check timed out, the server returned an error, CUTTLELY_API_KEY was missing, the flow has no draft, or the command was used wrong.',
65
+ 'A check the server marks stopped or failed also exits 2. If the draft is saved, published, or discarded while the check runs, the command exits 2, because the answers may be for a draft that is already gone. After the check has started, a timeout, a 403, or any other failure asks the server to stop it. That stop is best effort: if it fails, the exit code is still 2.',
66
+ 'Cancelling the command stops the server-side check.',
67
+ 'A scored check (--dataset or --gate) exits on the server verdict and does not score the answers itself. --gate reads the dataset, evaluators, and threshold saved on the flow, and reads the flow again after a pass. Exit 0 only when that second read still stores this check as passed with no block. Otherwise exit 2. Do not combine --gate with --dataset, or those flags with --fail-on-changed.',
48
68
  '',
49
69
  `Install guide: ${INSTALL_DOCS}`
50
70
  ].join('\n')
51
71
  }
52
72
 
53
- function parsePort(value) {
73
+ function parsePort(value, exitCode = 1) {
54
74
  if (!value || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 65535) {
55
- throw new UsageError(`Port must be a number from 1 to 65535, got ${value || '(missing)'}.`)
75
+ throw new UsageError(`Port must be a number from 1 to 65535, got ${value || '(missing)'}.`, exitCode)
56
76
  }
57
77
  return Number(value)
58
78
  }
59
79
 
60
- function parseTag(value) {
80
+ function parseTag(value, exitCode = 1) {
61
81
  if (!value || !TAG_RE.test(value)) {
62
- throw new UsageError(`Not a valid image tag: ${value || '(missing)'}. Example: 1.0.0 or latest.`)
82
+ throw new UsageError(`Not a valid image tag: ${value || '(missing)'}. Example: 1.0.0 or latest.`, exitCode)
83
+ }
84
+ return value
85
+ }
86
+
87
+ // These options take the next argv token as their value. A following `check` is that value, not the command.
88
+ // Any other previous token, including an unknown flag, leaves `check` as the command.
89
+ const VALUE_OPTIONS = new Set([
90
+ '--flow',
91
+ '--server',
92
+ '--timeout',
93
+ '--json',
94
+ '--dataset',
95
+ '--evaluator',
96
+ '--threshold',
97
+ '--port',
98
+ '-p',
99
+ '--tag',
100
+ '--env-file'
101
+ ])
102
+
103
+ export function isCheckCommand(argv) {
104
+ return argv.some((item, index) => {
105
+ if (item !== 'check') return false
106
+ if (index === 0) return true
107
+ return !VALUE_OPTIONS.has(argv[index - 1])
108
+ })
109
+ }
110
+
111
+ // An unexpected failure while running check is not a Changed answer.
112
+ export function commandFailureCode(argv) {
113
+ return isCheckCommand(argv) ? 2 : 1
114
+ }
115
+
116
+ function parseFlow(value) {
117
+ if (!value || !RESOURCE_ID_RE.test(value)) {
118
+ throw new UsageError('--flow needs the flow id from the canvas address bar.', 2)
119
+ }
120
+ return value
121
+ }
122
+
123
+ function parseResourceId(value, flag) {
124
+ if (!value || !RESOURCE_ID_RE.test(value)) {
125
+ throw new UsageError(`${flag} needs ${flag === '--dataset' ? 'a dataset id' : 'an evaluator id'}.`, 2)
126
+ }
127
+ return value
128
+ }
129
+
130
+ function parseThreshold(value) {
131
+ if (!/^(?:0|[1-9]\d?|100)$/.test(value || '')) {
132
+ throw new UsageError(`--threshold must be a whole number from 0 to 100, got ${value || '(missing)'}.`, 2)
133
+ }
134
+ return Number(value)
135
+ }
136
+
137
+ function parseServer(value) {
138
+ let url
139
+ try {
140
+ url = new URL(value)
141
+ } catch {
142
+ throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', 2)
143
+ }
144
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
145
+ throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', 2)
146
+ }
147
+ if (url.username || url.password) {
148
+ throw new UsageError(`Put the API key in CUTTLELY_API_KEY, not in the server URL.`, 2)
63
149
  }
150
+ if (url.pathname !== '/' && url.pathname !== '') {
151
+ throw new UsageError('--server is the server origin, for example http://127.0.0.1:43117.', 2)
152
+ }
153
+ return url.origin
154
+ }
155
+
156
+ function parseTimeout(value) {
157
+ if (!value || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 3600) {
158
+ throw new UsageError(`Timeout must be a whole number of seconds from 1 to 3600, got ${value || '(missing)'}.`, 2)
159
+ }
160
+ return Number(value)
161
+ }
162
+
163
+ function parseJsonPath(value) {
164
+ if (!value || value.includes('\0')) throw new UsageError('--json needs a file path.', 2)
64
165
  return value
65
166
  }
66
167
 
67
- function takeValue(argv, index, flag) {
168
+ function takeValue(argv, index, flag, allowSigned = false) {
68
169
  const inline = argv[index].indexOf('=')
69
170
  if (inline !== -1) return { value: argv[index].slice(inline + 1), next: index }
70
171
  const value = argv[index + 1]
71
- if (!value || value.startsWith('-')) throw new UsageError(`${flag} needs a value. Run cuttlely --help.`)
172
+ if (!value || (!allowSigned && value.startsWith('-'))) {
173
+ throw new UsageError(`${flag} needs a value. Run cuttlely --help.`, commandFailureCode(argv))
174
+ }
72
175
  return { value, next: index + 1 }
73
176
  }
74
177
 
@@ -81,37 +184,156 @@ export function parseArgs(argv) {
81
184
  tag: VERSION,
82
185
  envFile: null,
83
186
  open: true,
84
- follow: false
187
+ follow: false,
188
+ flow: null,
189
+ server: null,
190
+ timeoutSeconds: null,
191
+ jsonPath: null,
192
+ failOnChanged: false,
193
+ datasetId: null,
194
+ evaluatorIds: [],
195
+ threshold: null,
196
+ gate: false,
197
+ scored: false
85
198
  }
86
199
  const words = []
200
+ const dockerFlags = []
201
+ const checkFlags = []
87
202
  for (let i = 0; i < argv.length; i++) {
88
203
  const arg = argv[i]
89
204
  if (arg === '--help' || arg === '-h') opts.help = true
90
205
  else if (arg === '--version' || arg === '-V') opts.version = true
91
- else if (arg === '--no-open') opts.open = false
92
- else if (arg === '--follow' || arg === '-f') opts.follow = true
93
- else if (arg === '--port' || arg === '-p' || arg.startsWith('--port=')) {
206
+ else if (arg === '--no-open') {
207
+ opts.open = false
208
+ dockerFlags.push('--no-open')
209
+ } else if (arg === '--follow' || arg === '-f') {
210
+ opts.follow = true
211
+ dockerFlags.push(arg === '-f' ? '-f' : '--follow')
212
+ } else if (arg === '--port' || arg === '-p' || arg.startsWith('--port=')) {
94
213
  const taken = takeValue(argv, i, '--port')
95
- opts.port = parsePort(taken.value)
214
+ opts.port = parsePort(taken.value, commandFailureCode(argv))
96
215
  i = taken.next
216
+ dockerFlags.push('--port')
97
217
  } else if (arg === '--tag' || arg.startsWith('--tag=')) {
98
218
  const taken = takeValue(argv, i, '--tag')
99
- opts.tag = parseTag(taken.value)
219
+ opts.tag = parseTag(taken.value, commandFailureCode(argv))
100
220
  i = taken.next
221
+ dockerFlags.push('--tag')
101
222
  } else if (arg === '--env-file' || arg.startsWith('--env-file=')) {
102
223
  const taken = takeValue(argv, i, '--env-file')
103
224
  opts.envFile = taken.value
104
225
  i = taken.next
105
- } else if (arg.startsWith('-')) throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`)
106
- else words.push(arg)
226
+ dockerFlags.push('--env-file')
227
+ } else if (arg === '--flow' || arg.startsWith('--flow=')) {
228
+ const taken = takeValue(argv, i, '--flow')
229
+ opts.flow = taken.value
230
+ i = taken.next
231
+ checkFlags.push('--flow')
232
+ } else if (arg === '--server' || arg.startsWith('--server=')) {
233
+ const taken = takeValue(argv, i, '--server')
234
+ opts.server = taken.value
235
+ i = taken.next
236
+ checkFlags.push('--server')
237
+ } else if (arg === '--timeout' || arg.startsWith('--timeout=')) {
238
+ const taken = takeValue(argv, i, '--timeout')
239
+ opts.timeoutSeconds = taken.value
240
+ i = taken.next
241
+ checkFlags.push('--timeout')
242
+ } else if (arg === '--json' || arg.startsWith('--json=')) {
243
+ const taken = takeValue(argv, i, '--json')
244
+ opts.jsonPath = taken.value
245
+ i = taken.next
246
+ checkFlags.push('--json')
247
+ } else if (arg === '--fail-on-changed') {
248
+ opts.failOnChanged = true
249
+ checkFlags.push('--fail-on-changed')
250
+ } else if (arg === '--dataset' || arg.startsWith('--dataset=')) {
251
+ const taken = takeValue(argv, i, '--dataset')
252
+ opts.datasetId = taken.value
253
+ i = taken.next
254
+ checkFlags.push('--dataset')
255
+ } else if (arg === '--evaluator' || arg.startsWith('--evaluator=')) {
256
+ const taken = takeValue(argv, i, '--evaluator')
257
+ opts.evaluatorIds.push(taken.value)
258
+ i = taken.next
259
+ checkFlags.push('--evaluator')
260
+ } else if (arg === '--threshold' || arg.startsWith('--threshold=')) {
261
+ const taken = takeValue(argv, i, '--threshold', true)
262
+ opts.threshold = taken.value
263
+ i = taken.next
264
+ checkFlags.push('--threshold')
265
+ } else if (arg === '--gate') {
266
+ opts.gate = true
267
+ checkFlags.push('--gate')
268
+ } else if (arg.startsWith('-')) {
269
+ throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`, commandFailureCode(argv))
270
+ } else words.push(arg)
107
271
  }
108
- if (words.length > 1) throw new UsageError(`Unknown argument ${words[1]}. Run cuttlely --help.`)
272
+ if (words.length > 1) throw new UsageError(`Unknown argument ${words[1]}. Run cuttlely --help.`, words[0] === 'check' ? 2 : 1)
109
273
  if (words.length === 1) {
110
- if (!['start', 'stop', 'status', 'logs', 'upgrade', 'help'].includes(words[0])) {
274
+ if (!['start', 'stop', 'status', 'logs', 'upgrade', 'help', 'check'].includes(words[0])) {
111
275
  throw new UsageError(`Unknown command ${words[0]}. Run cuttlely --help.`)
112
276
  }
113
277
  opts.command = words[0]
114
278
  }
279
+ if (opts.command === 'check') {
280
+ const checkExit = 2
281
+ if (dockerFlags.length) {
282
+ throw new UsageError(`${dockerFlags[0]} is not a check option. Run cuttlely check --help.`, checkExit)
283
+ }
284
+ if (!opts.help && !opts.version) {
285
+ if (!opts.flow) throw new UsageError('check needs --flow <id>. Run cuttlely check --help.', checkExit)
286
+ opts.flow = parseFlow(opts.flow)
287
+ if (checkFlags.includes('--server')) {
288
+ if (!opts.server)
289
+ throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', checkExit)
290
+ opts.server = parseServer(opts.server)
291
+ }
292
+ if (checkFlags.includes('--timeout')) {
293
+ if (!opts.timeoutSeconds)
294
+ throw new UsageError('Timeout must be a whole number of seconds from 1 to 3600, got (missing).', checkExit)
295
+ opts.timeoutSeconds = parseTimeout(opts.timeoutSeconds)
296
+ }
297
+ // An empty --json= is still the flag. Skipping it would run the check and write nothing.
298
+ if (checkFlags.includes('--json')) opts.jsonPath = parseJsonPath(opts.jsonPath)
299
+ if (opts.gate && checkFlags.includes('--dataset')) {
300
+ throw new UsageError('--gate uses the saved test set. Do not pass --dataset with it.', checkExit)
301
+ }
302
+ if (opts.gate && (checkFlags.includes('--evaluator') || checkFlags.includes('--threshold'))) {
303
+ throw new UsageError(
304
+ '--gate uses the saved dataset, evaluators, and threshold. Do not pass --evaluator or --threshold with it.',
305
+ checkExit
306
+ )
307
+ }
308
+ const scoredFlag = ['--dataset', '--evaluator', '--threshold', '--gate'].some((flag) => checkFlags.includes(flag))
309
+ if (opts.failOnChanged && scoredFlag) {
310
+ throw new UsageError('--fail-on-changed cannot be combined with --dataset, --evaluator, --threshold, or --gate.', checkExit)
311
+ }
312
+ if (checkFlags.includes('--dataset')) opts.datasetId = parseResourceId(opts.datasetId, '--dataset')
313
+ if (checkFlags.includes('--evaluator')) {
314
+ const seen = new Set()
315
+ const unique = []
316
+ for (const id of opts.evaluatorIds) {
317
+ const parsed = parseResourceId(id, '--evaluator')
318
+ if (seen.has(parsed)) continue
319
+ seen.add(parsed)
320
+ unique.push(parsed)
321
+ }
322
+ opts.evaluatorIds = unique
323
+ }
324
+ if (checkFlags.includes('--threshold')) opts.threshold = parseThreshold(opts.threshold)
325
+ if (
326
+ !opts.gate &&
327
+ !checkFlags.includes('--dataset') &&
328
+ (checkFlags.includes('--evaluator') || checkFlags.includes('--threshold'))
329
+ ) {
330
+ throw new UsageError('--evaluator and --threshold need --dataset.', checkExit)
331
+ }
332
+ opts.scored = Boolean(opts.gate || checkFlags.includes('--dataset'))
333
+ }
334
+ } else if (checkFlags.length) {
335
+ throw new UsageError(`Unknown option ${checkFlags[0]}. Run cuttlely --help.`)
336
+ }
115
337
  return opts
116
338
  }
117
339
 
@@ -407,7 +629,7 @@ export async function main(argv, io = {}) {
407
629
  } catch (error) {
408
630
  if (error instanceof UsageError) {
409
631
  err(error.message)
410
- return 1
632
+ return error.exitCode
411
633
  }
412
634
  throw error
413
635
  }
@@ -424,6 +646,9 @@ export async function main(argv, io = {}) {
424
646
  out,
425
647
  err,
426
648
  docker: io.docker || spawnDocker,
649
+ env: io.env || process.env,
650
+ fetch: io.fetch || globalThis.fetch.bind(globalThis),
651
+ writeFile: io.writeFile,
427
652
  fetchImpl: io.fetch || globalThis.fetch.bind(globalThis),
428
653
  sleep: io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
429
654
  open: io.open || openBrowser,
@@ -431,6 +656,23 @@ export async function main(argv, io = {}) {
431
656
  timeoutMs: io.healthTimeoutMs ?? HEALTH_TIMEOUT_MS
432
657
  }
433
658
  try {
659
+ if (opts.command === 'check') {
660
+ return await runCheck(
661
+ {
662
+ flow: opts.flow,
663
+ server: opts.server,
664
+ timeoutSeconds: opts.timeoutSeconds,
665
+ jsonPath: opts.jsonPath,
666
+ failOnChanged: opts.failOnChanged,
667
+ scored: opts.scored,
668
+ gate: opts.gate,
669
+ datasetId: opts.datasetId,
670
+ evaluatorIds: opts.evaluatorIds,
671
+ threshold: opts.threshold
672
+ },
673
+ ctx
674
+ )
675
+ }
434
676
  if (opts.command === 'start') return await launch(ctx, { restart: false })
435
677
  if (opts.command === 'upgrade') return await launch(ctx, { restart: true })
436
678
  if (opts.command === 'stop') return await stop(ctx)
@@ -439,7 +681,7 @@ export async function main(argv, io = {}) {
439
681
  } catch (error) {
440
682
  if (error instanceof UsageError) {
441
683
  err(error.message)
442
- return 1
684
+ return error.exitCode
443
685
  }
444
686
  throw error
445
687
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuttlely",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Start Cuttlely in Docker. The open-source agent platform for applications.",
5
5
  "type": "module",
6
6
  "bin": {