@nitra/cursor 1.41.0 → 2.0.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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Crash-safe сховище runtime-стану `flow` (spec §4, §4.1).
3
+ *
4
+ * Локація — **sibling-файл** `.worktrees/<sanitized-branch>.flow.json` поруч із
5
+ * checkout (НЕ всередині нього: файл усередині worktree = untracked у feature-
6
+ * гілці й ризикує потрапити в `git add -A`). Деривація шляху: для checkout-
7
+ * директорії `.worktrees/feat-x` стан → `.worktrees/feat-x.flow.json`.
8
+ *
9
+ * Crash-safety (§4.1):
10
+ * - **atomic write**: temp на тому ж FS → `fsync` файла → `rename` (атомарна
11
+ * заміна; частковий запис неможливий);
12
+ * - **fail-closed на corruption**: нечитабельний/невалідний JSON або несумісний
13
+ * `schema_version` → throw (не стартуємо новий flow над зіпсованим станом).
14
+ *
15
+ * Усі шляхи — абсолютні (вимога `no-relative-fs-path`).
16
+ */
17
+ import {
18
+ closeSync,
19
+ existsSync,
20
+ fsyncSync,
21
+ mkdirSync,
22
+ openSync,
23
+ readFileSync,
24
+ renameSync,
25
+ rmSync,
26
+ writeFileSync
27
+ } from 'node:fs'
28
+ import { basename, dirname, isAbsolute, join } from 'node:path'
29
+ import { randomBytes } from 'node:crypto'
30
+ import { pid } from 'node:process'
31
+
32
+ import { appendEvent } from './events.mjs'
33
+
34
+ export const SCHEMA_VERSION = 1
35
+
36
+ /**
37
+ * Шлях sibling-файла стану для заданого checkout-каталогу worktree.
38
+ * @param {string} worktreeDir абсолютний шлях checkout (напр. `…/.worktrees/feat-x`)
39
+ * @returns {string} абсолютний шлях `…/.worktrees/feat-x.flow.json`
40
+ */
41
+ export function flowStatePath(worktreeDir) {
42
+ if (!isAbsolute(worktreeDir)) {
43
+ throw new Error(`flowStatePath: очікується абсолютний шлях (отримано: ${worktreeDir})`)
44
+ }
45
+ return join(dirname(worktreeDir), `${basename(worktreeDir)}.flow.json`)
46
+ }
47
+
48
+ /**
49
+ * fsync файла за абсолютним шляхом (дані на диск до rename).
50
+ * @param {string} path абсолютний шлях
51
+ * @returns {void}
52
+ */
53
+ function fsyncPath(path) {
54
+ const fd = openSync(path, 'r')
55
+ try {
56
+ fsyncSync(fd)
57
+ } finally {
58
+ closeSync(fd)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Атомарно записує стан: temp(той самий каталог)+fsync+rename. Додає
64
+ * `schema_version`. Повертає фактично записаний об'єкт.
65
+ * @param {string} statePath абсолютний шлях `.flow.json`
66
+ * @param {object} state стан без `schema_version`
67
+ * @returns {object} записаний об'єкт (зі `schema_version`)
68
+ */
69
+ export function writeState(statePath, state) {
70
+ if (!isAbsolute(statePath)) {
71
+ throw new Error(`writeState: очікується абсолютний шлях (отримано: ${statePath})`)
72
+ }
73
+ const dir = dirname(statePath)
74
+ mkdirSync(dir, { recursive: true })
75
+ const payload = { schema_version: SCHEMA_VERSION, ...state }
76
+ const tmp = join(dir, `.${basename(statePath)}.${pid}.${randomBytes(6).toString('hex')}.tmp`)
77
+ writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
78
+ fsyncPath(tmp)
79
+ renameSync(tmp, statePath)
80
+ // best-effort fsync каталогу (durability rename). Не на всіх платформах
81
+ // (Windows кидає EISDIR/EPERM) — тому загорнуто й помилки ігноруємо.
82
+ try {
83
+ fsyncPath(dir)
84
+ } catch {
85
+ /* fsync каталогу недоступний на цій платформі — некритично */
86
+ }
87
+ return payload
88
+ }
89
+
90
+ /**
91
+ * Читає стан. Відсутній файл → null. Пошкоджений JSON або несумісний
92
+ * `schema_version` → throw (**fail-closed**, §4.1.6).
93
+ * @param {string} statePath абсолютний шлях `.flow.json`
94
+ * @returns {object | null} стан або null, якщо файлу нема
95
+ */
96
+ export function readState(statePath) {
97
+ if (!isAbsolute(statePath)) {
98
+ throw new Error(`readState: очікується абсолютний шлях (отримано: ${statePath})`)
99
+ }
100
+ if (!existsSync(statePath)) return null
101
+ const raw = readFileSync(statePath, 'utf8')
102
+ let parsed
103
+ try {
104
+ parsed = JSON.parse(raw)
105
+ } catch {
106
+ throw new Error(`readState: пошкоджений стан (невалідний JSON) у ${statePath} — fail-closed`)
107
+ }
108
+ if (typeof parsed !== 'object' || parsed === null || parsed.schema_version !== SCHEMA_VERSION) {
109
+ throw new Error(
110
+ `readState: несумісний або пошкоджений schema_version у ${statePath} ` +
111
+ `(очікується ${SCHEMA_VERSION}) — fail-closed`
112
+ )
113
+ }
114
+ return parsed
115
+ }
116
+
117
+ /**
118
+ * Читає стан, застосовує `fn` і атомарно записує результат. Якщо файлу нема —
119
+ * `fn` отримує `{}`.
120
+ * @param {string} statePath абсолютний шлях `.flow.json`
121
+ * @param {(state: object) => object} fn трансформер стану
122
+ * @returns {object} записаний об'єкт
123
+ */
124
+ export function updateState(statePath, fn) {
125
+ const current = readState(statePath)
126
+ return writeState(statePath, fn(current ?? {}))
127
+ }
128
+
129
+ /**
130
+ * Видаляє sibling-файл стану (cleanup при `worktree remove` / `flow cancel`).
131
+ * Ідемпотентно (відсутній файл — не помилка).
132
+ * @param {string} statePath абсолютний шлях `.flow.json`
133
+ * @returns {void}
134
+ */
135
+ export function removeState(statePath) {
136
+ if (!isAbsolute(statePath)) {
137
+ throw new Error(`removeState: очікується абсолютний шлях (отримано: ${statePath})`)
138
+ }
139
+ rmSync(statePath, { force: true })
140
+ }
141
+
142
+ /**
143
+ * WAL-перехід (§4.1.2): спершу дописує подію в журнал, ТОДІ атомарно змінює
144
+ * статус у snapshot. Якщо запис стану впаде — подія вже durable (журнал —
145
+ * джерело для reconcile при `resume`).
146
+ * @param {{ statePath: string, eventsPath: string }} paths шляхи стану й журналу
147
+ * @param {object} event подія переходу
148
+ * @param {(state: object) => object} stateFn трансформер стану
149
+ * @param {() => number} [now] фабрика часу (ms)
150
+ * @returns {object} записаний стан
151
+ */
152
+ export function recordTransition({ statePath, eventsPath }, event, stateFn, now = Date.now) {
153
+ appendEvent(eventsPath, event, now)
154
+ return updateState(statePath, stateFn)
155
+ }
156
+
157
+ /**
158
+ * Прибирає всі runtime-sibling-и worktree: `.flow.json`, `.events.jsonl`,
159
+ * лок-каталог `.flow-lock-<branch>/`. Ідемпотентно. Викликається `flow cancel`
160
+ * і `worktree remove` (інакше sibling-и осиротіють — git їх не чистить).
161
+ * @param {string} worktreeDir абсолютний шлях checkout (`…/.worktrees/feat-x`)
162
+ * @returns {void}
163
+ */
164
+ export function cleanupFlowSiblings(worktreeDir) {
165
+ if (!isAbsolute(worktreeDir)) {
166
+ throw new Error(`cleanupFlowSiblings: очікується абсолютний шлях (отримано: ${worktreeDir})`)
167
+ }
168
+ const base = basename(worktreeDir)
169
+ const dir = dirname(worktreeDir)
170
+ rmSync(join(dir, `${base}.flow.json`), { force: true })
171
+ rmSync(join(dir, `${base}.events.jsonl`), { force: true })
172
+ rmSync(join(dir, `.flow-lock-${base}`), { recursive: true, force: true })
173
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * SubagentRunner (spec §15.1) — абстракція спавну сфокусованого субагента для
3
+ * Активного Раннера (Ф3/Ф4). Backend обирається за доступністю:
4
+ * 1. `claude-agent-sdk` (програмний, потребує `ANTHROPIC_API_KEY`);
5
+ * 2. `claude -p` (CLI-auth користувача);
6
+ * 3. `cursor-agent -p` (CLI-auth).
7
+ * Нема жодного → throw (polyfill без runner-а не стартує, §2.2).
8
+ *
9
+ * pi.dev для inner-спавну НЕ використовується: у автономному режимі pi.dev —
10
+ * зовнішній драйвер, тож спавн ним внутрішніх субагентів = рекурсія (§9.1).
11
+ *
12
+ * Усі probe-залежності (`spawn`/`isInPath`/`canImportSdk`/`query`) ін'єктуються,
13
+ * щоб тестувати без реальних процесів і без SDK.
14
+ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { env as processEnv } from 'node:process'
17
+
18
+ const NO_BACKEND =
19
+ 'SubagentRunner: ні claude-agent-sdk (з ANTHROPIC_API_KEY), ні `claude`/`cursor-agent` у PATH — ' +
20
+ 'субагентів спавнити нічим. Встанови CLI-runner або задай ANTHROPIC_API_KEY.'
21
+
22
+ /**
23
+ * Чи є бінарник у PATH (через `command -v`).
24
+ * @param {string} name ім'я виконуваного
25
+ * @param {typeof import('node:child_process').spawnSync} [spawn] ін'єкція для тестів
26
+ * @returns {boolean} true, якщо знайдено
27
+ */
28
+ export function isBinaryInPath(name, spawn = spawnSync) {
29
+ const r = spawn('command', ['-v', name], { shell: true, encoding: 'utf8' })
30
+ return (r.status ?? 1) === 0
31
+ }
32
+
33
+ /**
34
+ * Обирає backend субагентів за пріоритетом sdk > claude > cursor.
35
+ * @param {{ hasApiKey: boolean, canImportSdk: boolean, isInPath: (name: string) => boolean }} probes доступність
36
+ * @returns {'sdk' | 'claude' | 'cursor' | null} backend або null
37
+ */
38
+ export function selectBackend({ hasApiKey, canImportSdk, isInPath }) {
39
+ if (hasApiKey && canImportSdk) return 'sdk'
40
+ if (isInPath('claude')) return 'claude'
41
+ if (isInPath('cursor-agent')) return 'cursor'
42
+ return null
43
+ }
44
+
45
+ /**
46
+ * CLI-runner (`claude -p` / `cursor-agent -p`) — CLI-auth, без API key.
47
+ * @param {'claude' | 'cursor-agent'} bin виконуваний
48
+ * @param {{ spawn?: typeof import('node:child_process').spawnSync }} [deps] ін'єкція
49
+ * @returns {{ backend: string, runStep: (prompt: string, opts?: { cwd?: string }) => { ok: boolean, output: string } }} runner
50
+ */
51
+ export function cliRunner(bin, deps = {}) {
52
+ const spawn = deps.spawn ?? spawnSync
53
+ return {
54
+ backend: bin,
55
+ runStep(prompt, { cwd } = {}) {
56
+ const r = spawn(bin, ['-p'], { input: prompt, cwd, encoding: 'utf8' })
57
+ return { ok: (r.status ?? 1) === 0, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * SDK-runner (`claude-agent-sdk`). `query` ін'єктується; за замовчуванням —
64
+ * динамічний import (optional dependency).
65
+ * @param {{ query?: (input: object) => object }} [deps] ін'єкція (query повертає async-iterable повідомлень)
66
+ * @returns {{ backend: string, runStep: (prompt: string, opts?: { cwd?: string }) => Promise<{ ok: boolean, output: string }> }} runner
67
+ */
68
+ export function sdkRunner(deps = {}) {
69
+ return {
70
+ backend: 'sdk',
71
+ async runStep(prompt, { cwd } = {}) {
72
+ let query = deps.query
73
+ if (!query) {
74
+ const mod = await import('@anthropic-ai/claude-agent-sdk')
75
+ query = mod.query
76
+ }
77
+ let output = ''
78
+ let ok = true
79
+ try {
80
+ for await (const msg of query({ prompt, options: { cwd, maxTurns: 20, allowedTools: ['Read', 'Edit', 'Bash'] } })) {
81
+ if (typeof msg?.text === 'string') output += msg.text
82
+ if (msg?.type === 'result') ok = msg.is_error !== true
83
+ }
84
+ } catch (error) {
85
+ return { ok: false, output: String(error?.message ?? error) }
86
+ }
87
+ return { ok, output }
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Створює runner за доступним backend-ом. `backend`/probe-и можна задати явно
94
+ * (тести); інакше визначаються з env/PATH/SDK.
95
+ * @param {{ backend?: string, env?: Record<string, string | undefined>, isInPath?: (name: string) => boolean, canImportSdk?: boolean, spawn?: (cmd: string, args: string[], opts: object) => object, query?: (input: object) => object }} [deps] ін'єкції
96
+ * @returns {Promise<{ backend: string, runStep: (prompt: string, opts?: object) => object }>} runner
97
+ */
98
+ export async function createRunner(deps = {}) {
99
+ const env = deps.env ?? processEnv
100
+ const isInPath = deps.isInPath ?? (name => isBinaryInPath(name, deps.spawn))
101
+ const canImportSdk = deps.canImportSdk ?? (await probeSdk())
102
+ const backend =
103
+ deps.backend ?? selectBackend({ hasApiKey: Boolean(env.ANTHROPIC_API_KEY), canImportSdk, isInPath })
104
+ if (!backend) throw new Error(NO_BACKEND)
105
+ if (backend === 'sdk') return sdkRunner(deps)
106
+ return cliRunner(backend === 'claude' ? 'claude' : 'cursor-agent', deps)
107
+ }
108
+
109
+ /**
110
+ * Чи імпортується `claude-agent-sdk` (optional dependency).
111
+ * @returns {Promise<boolean>} true, якщо доступний
112
+ */
113
+ async function probeSdk() {
114
+ try {
115
+ await import('@anthropic-ai/claude-agent-sdk')
116
+ return true
117
+ } catch {
118
+ return false
119
+ }
120
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `n-cursor trace` (spec §5.4/§7) — наскрізна простежуваність: читає front-matter
3
+ * артефактів у `docs/{tasks,specs,plans,adr}`, будує ланцюг за лінками
4
+ * (`adr`/`spec`/`plan`/`change`/`task`) і **флагує розриви** (лінк на неіснуючий
5
+ * файл). Read-only. `--json` для machine-readable.
6
+ *
7
+ * FS-доступ (`readdir`/`readFile`/`exists`) ін'єктується — тестується без диска.
8
+ */
9
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { cwd as processCwd } from 'node:process'
12
+
13
+ /** Поля-лінки у front-matter, що утворюють ланцюг. */
14
+ const LINK_FIELDS = ['adr', 'spec', 'plan', 'change', 'task']
15
+
16
+ /** Каталоги з traceable-артефактами. */
17
+ const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
18
+
19
+ /**
20
+ * Парсить плаский YAML-front-matter (key: value). Не обробляє вкладеність —
21
+ * достатньо для spec/plan/task-record полів. Інлайн-коментарі (` #…`) відрізає.
22
+ * @param {string} content вміст файла
23
+ * @returns {Record<string, string | null> | null} мапа полів або null, якщо немає front-matter
24
+ */
25
+ export function parseFrontMatter(content) {
26
+ if (!content.startsWith('---')) return null
27
+ const end = content.indexOf('\n---', 3)
28
+ if (end === -1) return null
29
+ const fm = {}
30
+ for (const line of content.slice(3, end).split('\n')) {
31
+ const ci = line.indexOf(':')
32
+ if (ci === -1) continue
33
+ const key = line.slice(0, ci).trim()
34
+ if (!isSimpleKey(key)) continue
35
+ let val = line.slice(ci + 1)
36
+ const hi = val.indexOf(' #')
37
+ if (hi !== -1) val = val.slice(0, hi)
38
+ val = val.trim().replace(/^["']/u, '').replace(/["']$/u, '')
39
+ fm[key] = val === '' || val === 'null' ? null : val
40
+ }
41
+ return fm
42
+ }
43
+
44
+ /**
45
+ * Чи `key` — простий ідентифікатор (літери/підкреслення).
46
+ * @param {string} key ключ
47
+ * @returns {boolean} true для простого ключа
48
+ */
49
+ function isSimpleKey(key) {
50
+ return key.length > 0 && [...key].every(c => /[a-z_]/iu.test(c))
51
+ }
52
+
53
+ /**
54
+ * Будує аналіз: для кожного артефакту — його лінки зі статусом ok/розрив.
55
+ * @param {{ file: string, fm: Record<string, string | null> }[]} artifacts артефакти з front-matter
56
+ * @param {(target: string) => boolean} exists чи існує цільовий файл лінка
57
+ * @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean }[] }[]} аналіз
58
+ */
59
+ export function analyze(artifacts, exists) {
60
+ return artifacts.map(({ file, fm }) => ({
61
+ file,
62
+ kind: fm.kind ?? null,
63
+ id: fm.id ?? null,
64
+ status: fm.status ?? null,
65
+ links: LINK_FIELDS.filter(f => fm[f]).map(f => ({ field: f, target: fm[f], ok: exists(fm[f]) }))
66
+ }))
67
+ }
68
+
69
+ /**
70
+ * Текстовий рендер аналізу.
71
+ * @param {object[]} analysis результат `analyze`
72
+ * @returns {string} людино-читабельний вивід
73
+ */
74
+ export function render(analysis) {
75
+ if (analysis.length === 0) return 'trace: артефактів із front-matter не знайдено'
76
+ const lines = []
77
+ for (const a of analysis) {
78
+ lines.push(`${a.kind ?? '?'} · ${a.id ?? a.file} [${a.status ?? '—'}]`)
79
+ for (const l of a.links) {
80
+ const mark = l.ok ? '→' : '✗'
81
+ const note = l.ok ? '' : ' (РОЗРИВ — файл відсутній)'
82
+ lines.push(` ${mark} ${l.field}: ${l.target}${note}`)
83
+ }
84
+ }
85
+ return lines.join('\n')
86
+ }
87
+
88
+ /**
89
+ * CLI `n-cursor trace [--json]`. Повертає 1, якщо є розриви ланцюга.
90
+ * @param {string[]} args аргументи
91
+ * @param {{ cwd?: string, readdir?: (dir: string) => string[], readFile?: (file: string) => string, exists?: (file: string) => boolean, log?: (m: string) => void }} [deps] ін'єкції
92
+ * @returns {number} exit code (0 — цілісно, 1 — є розриви)
93
+ */
94
+ export function runTraceCli(args, deps = {}) {
95
+ const root = deps.cwd ?? processCwd()
96
+ const readdir = deps.readdir ?? (dir => (existsSync(dir) ? readdirSync(dir) : []))
97
+ const readFile = deps.readFile ?? (file => readFileSync(file, 'utf8'))
98
+ const exists = deps.exists ?? (file => existsSync(file))
99
+ const log = deps.log ?? console.log
100
+
101
+ const artifacts = []
102
+ for (const dir of DIRS) {
103
+ for (const name of readdir(join(root, dir))) {
104
+ if (!name.endsWith('.md')) continue
105
+ const rel = `${dir}/${name}`
106
+ const fm = parseFrontMatter(readFile(join(root, rel)))
107
+ if (fm && (fm.id || fm.kind)) artifacts.push({ file: rel, fm })
108
+ }
109
+ }
110
+
111
+ const analysis = analyze(artifacts, target => exists(join(root, target)))
112
+ log(args.includes('--json') ? JSON.stringify(analysis, null, 2) : render(analysis))
113
+ return analysis.some(a => a.links.some(l => !l.ok)) ? 1 : 0
114
+ }
@@ -13,7 +13,8 @@ const DEFAULTS = {
13
13
  ttl: 600_000,
14
14
  staleThreshold: 1_800_000,
15
15
  waitTimeout: 1_200_000,
16
- pollInterval: 1500
16
+ pollInterval: 1500,
17
+ onWaitTimeout: 'run-unlocked'
17
18
  }
18
19
 
19
20
  /**
@@ -57,11 +58,11 @@ export function shouldDedup(result, fingerprint, ttl) {
57
58
  * Серіалізує важку команду через атомарний lock і dedup за fingerprint.
58
59
  * @param {string} key ключ локу (наприклад `lint-ga`, `fix-bun`)
59
60
  * @param {() => number | Promise<number>} runFn основна робота; повертає exit code
60
- * @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, cacheDir?:string, getFingerprint?:() => string | null}} [opts] TTL, шлях кешу та override fingerprint
61
+ * @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, onWaitTimeout?:'run-unlocked'|'fail', cacheDir?:string, getFingerprint?:() => string | null}} [opts] TTL, шлях кешу, поведінка на таймаут (default `run-unlocked`; `fail` = fail-closed) та override fingerprint
61
62
  * @returns {Promise<number>} exit code виконаної або дедуплікованої команди
62
63
  */
63
64
  export async function withLock(key, runFn, opts = {}) {
64
- const { ttl, staleThreshold, waitTimeout, pollInterval } = { ...DEFAULTS, ...opts }
65
+ const { ttl, staleThreshold, waitTimeout, pollInterval, onWaitTimeout } = { ...DEFAULTS, ...opts }
65
66
  const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
66
67
  const cacheDir = opts.cacheDir ?? resolveLockCacheDir(key)
67
68
  const lockDir = join(cacheDir, 'lock')
@@ -76,6 +77,9 @@ export async function withLock(key, runFn, opts = {}) {
76
77
 
77
78
  while (true) {
78
79
  if (Date.now() - loopStart >= waitTimeout) {
80
+ if (onWaitTimeout === 'fail') {
81
+ throw new Error(`${key}: не вдалося взяти лок за ${waitTimeout / 60_000} хв — fail-closed`)
82
+ }
79
83
  console.error(`⚠️ ${key}: чекав ${waitTimeout / 60_000} хв — запускаю без локу`)
80
84
  return await runFn()
81
85
  }
@@ -15,6 +15,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'no
15
15
  import { join } from 'node:path'
16
16
  import { cwd as processCwd } from 'node:process'
17
17
 
18
+ import { cleanupFlowSiblings } from './dispatcher/lib/state-store.mjs'
18
19
  import { buildDescription, findOrphanDescFiles, worktreePaths } from './lib/worktree.mjs'
19
20
 
20
21
  const USAGE = [
@@ -136,6 +137,7 @@ function cmdRemove(rest, ctx) {
136
137
  return 1
137
138
  }
138
139
  if (existsSync(paths.descFile)) rmSync(paths.descFile, { force: true })
140
+ cleanupFlowSiblings(paths.checkout) // flow-sibling-и (.flow.json/.events.jsonl/lock) — інакше осиротіють (§1.4)
139
141
  ctx.log(`✅ прибрано: ${paths.checkout} (гілку ${branch} лишено)`)
140
142
  return 0
141
143
  }
@@ -185,16 +187,21 @@ export function runWorktreeCli(argv, options = {}) {
185
187
  }
186
188
  const [sub, ...rest] = argv
187
189
  switch (sub) {
188
- case 'add':
190
+ case 'add': {
189
191
  return Promise.resolve(cmdAdd(rest, ctx))
190
- case 'remove':
192
+ }
193
+ case 'remove': {
191
194
  return Promise.resolve(cmdRemove(rest, ctx))
192
- case 'list':
195
+ }
196
+ case 'list': {
193
197
  return Promise.resolve(cmdList(ctx))
194
- case 'prune':
198
+ }
199
+ case 'prune': {
195
200
  return Promise.resolve(cmdPrune(ctx))
196
- default:
201
+ }
202
+ default: {
197
203
  ctx.logError(USAGE)
198
204
  return Promise.resolve(1)
205
+ }
199
206
  }
200
207
  }