mohdel 0.124.0 → 1.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.
Files changed (45) hide show
  1. package/README.md +155 -30
  2. package/config/curated.schema.json +32 -10
  3. package/js/client/call.js +76 -19
  4. package/js/client/gate-binary.js +5 -0
  5. package/js/client/index.js +1 -0
  6. package/js/core/envelope.js +5 -1
  7. package/js/factory/bridge.js +2 -2
  8. package/js/session/adapters/_cancelled.js +0 -6
  9. package/js/session/adapters/_chat_completions.js +25 -0
  10. package/js/session/adapters/_output_cap.js +30 -0
  11. package/js/session/adapters/_registry.js +44 -0
  12. package/js/session/adapters/anthropic.js +8 -5
  13. package/js/session/adapters/gemini.js +4 -0
  14. package/js/session/adapters/openai.js +2 -1
  15. package/js/session/run.js +14 -4
  16. package/js/session/run_image.js +12 -3
  17. package/package.json +49 -19
  18. package/src/cli/aliases.js +20 -0
  19. package/src/cli/ask.js +58 -12
  20. package/src/cli/backup.js +2 -1
  21. package/src/cli/check.js +15 -86
  22. package/src/cli/complete.js +130 -0
  23. package/src/cli/default.js +34 -13
  24. package/src/cli/doctor.js +33 -14
  25. package/src/cli/entry.js +173 -0
  26. package/src/cli/index.js +77 -66
  27. package/src/cli/instructions.js +349 -0
  28. package/src/cli/local.js +14 -0
  29. package/src/cli/model.js +184 -37
  30. package/src/cli/onboard.js +186 -121
  31. package/src/cli/rank.js +2 -1
  32. package/src/cli/ratelimit.js +3 -3
  33. package/src/cli/tag.js +2 -0
  34. package/src/lib/assistants.js +93 -0
  35. package/src/lib/catalog/openrouter.js +6 -1
  36. package/src/lib/catalog-review.js +195 -0
  37. package/src/lib/common.js +14 -1
  38. package/src/lib/creators.js +35 -0
  39. package/src/lib/index.js +17 -3
  40. package/src/lib/local-conventions.js +120 -0
  41. package/src/lib/provider-info.js +98 -0
  42. package/src/lib/providers.js +69 -14
  43. package/src/lib/schema.js +15 -3
  44. package/src/lib/select.js +125 -67
  45. package/js/session/adapters/image/index.js +0 -40
@@ -0,0 +1,130 @@
1
+ import providerDefs from '../lib/providers.js'
2
+ import { fieldDefs } from '../lib/schema.js'
3
+ import { getCuratedModels, catalogEntries } from '../lib/common.js'
4
+ import { ALIASES } from './aliases.js'
5
+
6
+ // Completion must never import the factory (`src/lib/index.js`), which pulls
7
+ // every adapter, provider SDK and the OTel stack — 350ms that would be paid on
8
+ // every press of the tab key. Reading the catalog costs about 30ms.
9
+
10
+ const NOUNS = ['model', 'provider', 'creator', 'tag', 'ratelimit', 'ask', 'transcribe', 'default', 'doctor']
11
+
12
+ const VERBS = {
13
+ model: ['list', 'search', 'stats', 'show', 'get', 'set', 'rm', 'add', 'instructions',
14
+ 'check', 'apply', 'rank', 'bench', 'curate', 'backup'],
15
+ provider: ['list', 'show', 'models', 'setup', 'rm'],
16
+ creator: ['list', 'show'],
17
+ tag: ['list', 'show', 'add', 'rm'],
18
+ ratelimit: ['show', 'set', 'rm', 'provider']
19
+ }
20
+
21
+ // What the argument at a given offset after `<noun> <verb>` names. `model` is
22
+ // an id already in the catalog, so `model add` — which takes a new one — is
23
+ // deliberately absent.
24
+ const ARGUMENT = {
25
+ ask: ['model'],
26
+ transcribe: ['model'],
27
+ 'model show': ['model'],
28
+ 'model get': ['model', 'field'],
29
+ 'model set': ['model', 'field'],
30
+ 'model rm': ['model', 'field'],
31
+ 'model bench': ['model'],
32
+ 'model curate': ['provider'],
33
+ 'model instructions': ['provider'],
34
+ 'provider list': ['provider'],
35
+ 'provider show': ['provider'],
36
+ 'provider models': ['provider'],
37
+ 'provider setup': ['provider'],
38
+ 'provider rm': ['provider'],
39
+ 'creator show': ['creator'],
40
+ 'tag list': ['model'],
41
+ 'tag show': ['tag'],
42
+ 'tag add': ['model', 'tag'],
43
+ 'tag rm': ['model', 'tag'],
44
+ 'ratelimit show': ['model'],
45
+ 'ratelimit set': ['model'],
46
+ 'ratelimit rm': ['model']
47
+ }
48
+
49
+ const fromCatalog = async (pick) => {
50
+ const catalog = await getCuratedModels()
51
+ const out = new Set()
52
+ for (const [key, spec] of catalogEntries(catalog)) {
53
+ if (spec.deprecated) continue
54
+ pick(key, spec, out)
55
+ }
56
+ return [...out]
57
+ }
58
+
59
+ const kinds = {
60
+ model: () => fromCatalog((key, _spec, out) => out.add(key)),
61
+ creator: () => fromCatalog((_key, spec, out) => spec.creator && out.add(spec.creator)),
62
+ tag: () => fromCatalog((_key, spec, out) => (spec.tags || []).forEach(t => out.add(t))),
63
+ provider: async () => Object.keys(providerDefs),
64
+ field: async () => Object.keys(fieldDefs)
65
+ }
66
+
67
+ /**
68
+ * @param {number} cword Index of the word being completed (bash COMP_CWORD).
69
+ * @param {string[]} words The full command line, split (bash COMP_WORDS).
70
+ * @returns {Promise<string[]>}
71
+ */
72
+ export const candidates = async (cword, words) => {
73
+ const current = words[cword] ?? ''
74
+ const before = words.slice(1, cword).filter(w => !w.startsWith('-'))
75
+
76
+ const offer = (list) => list.filter(c => c.startsWith(current)).sort()
77
+
78
+ if (!before.length) {
79
+ return offer([...NOUNS, ...Object.keys(ALIASES)])
80
+ }
81
+
82
+ // Resolve the alias exactly as the router does, so completion and dispatch
83
+ // never disagree about what a word means.
84
+ const alias = ALIASES[before[0]]
85
+ const chain = alias ? [alias.noun, ...alias.inject, ...before.slice(1)] : before
86
+ const [noun, verb, ...rest] = chain
87
+
88
+ if (chain.length === 1 && VERBS[noun]) return offer(VERBS[noun])
89
+
90
+ // `model show <id>` keys on two words; `ask <id>` on one. Whichever matched
91
+ // decides where the argument list starts.
92
+ const paired = ARGUMENT[`${noun} ${verb}`]
93
+ const shape = paired ?? ARGUMENT[noun]
94
+ if (!shape) return []
95
+ const typed = paired ? rest : [verb, ...rest].filter(Boolean)
96
+
97
+ const kind = shape[typed.length]
98
+ if (!kind || !kinds[kind]) return []
99
+ return offer(await kinds[kind]())
100
+ }
101
+
102
+ export const BASH_SCRIPT = `# mohdel completion for bash. Add to ~/.bashrc:
103
+ # source <(mo completion bash)
104
+ _mo_completion() {
105
+ local IFS=$'\\n'
106
+ COMPREPLY=($(mo __complete "$COMP_CWORD" "\${COMP_WORDS[@]}" 2>/dev/null))
107
+ }
108
+ complete -o default -F _mo_completion mo
109
+ `
110
+
111
+ export async function runComplete (args) {
112
+ // A crash here would break the user's tab key, so an unreadable or corrupt
113
+ // catalog offers nothing rather than an error.
114
+ try {
115
+ const cword = Number.parseInt(args[0], 10)
116
+ if (!Number.isFinite(cword)) return
117
+ const list = await candidates(cword, args.slice(1))
118
+ if (list.length) process.stdout.write(`${list.join('\n')}\n`)
119
+ } catch {}
120
+ }
121
+
122
+ export function runCompletionScript (args) {
123
+ const shell = args[0]
124
+ if (shell === 'bash') {
125
+ process.stdout.write(BASH_SCRIPT)
126
+ return
127
+ }
128
+ console.error(`Usage: mo completion bash${shell ? ` (got "${shell}")` : ''}`)
129
+ process.exit(1)
130
+ }
@@ -1,11 +1,37 @@
1
1
  import { intro, outro, select, isCancel, cancel } from '@clack/prompts'
2
- import { getCuratedModels, CONFIG_PATH, saveConfig, catalogEntries } from '../lib/common.js'
3
- import providers from '../lib/providers.js'
2
+ import { getConfig, getCuratedModels, saveConfig, catalogEntries } from '../lib/common.js'
4
3
 
5
- export async function runDefault () {
6
- intro('Mohdel — Set Default Model')
4
+ // CLI-only. The default model is a convenience for someone typing `mo ask`;
5
+ // the library and the gate never read it. A program picks a model by id or by
6
+ // tag, where the choice is explicit and reviewable — a machine inheriting a
7
+ // human's terminal preference is how a call ends up on a model nobody chose.
8
+ export async function runDefault (args = []) {
9
+ if (args.includes('-h') || args.includes('--help')) {
10
+ console.log(`mohdel default — the model "mo ask" uses when you name none
11
+
12
+ Usage:
13
+ default Pick one from your catalog (interactive)
14
+ default <model> Set it directly
15
+
16
+ Stored in ~/.config/mohdel/default.json. The library and the gate never
17
+ read it: a program names its model, or selects one by tag.`)
18
+ process.exit(0)
19
+ }
7
20
 
8
21
  const curated = await getCuratedModels()
22
+
23
+ const named = args.find(a => !a.startsWith('-'))
24
+ if (named) {
25
+ if (!curated[named] || curated[named].deprecated) {
26
+ console.error(`'${named}' is not a model in your catalog. "mo ls" lists them.`)
27
+ process.exit(1)
28
+ }
29
+ await saveConfig({ ...(await getConfig()), defaultModel: named })
30
+ console.log(`Default set to ${named} — "mo ask" uses it when you give no model.`)
31
+ return
32
+ }
33
+
34
+ intro('mohdel — set the default model')
9
35
  const modelOptions = catalogEntries(curated).map(([modelId, info]) => ({
10
36
  value: modelId,
11
37
  label: `${info.label} (${modelId})`
@@ -22,16 +48,11 @@ export async function runDefault () {
22
48
  process.exit(0)
23
49
  }
24
50
 
25
- const [providerName] = selectedModelId.split('/')
26
-
27
51
  try {
28
- const config = { defaultModel: selectedModelId }
29
- const apiKeyEnv = providers[providerName]?.apiKeyEnv
30
- if (apiKeyEnv) {
31
- config.apiKeyInfo = `Set ${apiKeyEnv} environment variable for this provider`
32
- }
33
- await saveConfig(config)
34
- outro(`Default set to ${selectedModelId} — saved to ${CONFIG_PATH}`)
52
+ // Merge: the file also holds the chosen coding agent, and replacing the
53
+ // object wholesale would drop it.
54
+ await saveConfig({ ...(await getConfig()), defaultModel: selectedModelId })
55
+ outro(`Default set to ${selectedModelId} — "mo ask" uses it when you give no model.`)
35
56
  } catch (err) {
36
57
  cancel(`Error: ${err.message}`)
37
58
  process.exit(1)
package/src/cli/doctor.js CHANGED
@@ -3,7 +3,7 @@ import { label, meta, ok, warn, err, inactive } from './colors.js'
3
3
  import providers from '../lib/providers.js'
4
4
  import { validate, isValidTag } from '../lib/schema.js'
5
5
  import {
6
- CONFIG_DIR, CURATED_PATH, CONFIG_PATH, ENV_PATH,
6
+ CONFIG_DIR, CURATED_PATH, CONFIG_PATH, ENV_PATH, tildePath,
7
7
  getCuratedModels, getConfig, loadDefaultEnv, getAPIKey, catalogEntries
8
8
  } from '../lib/common.js'
9
9
 
@@ -11,11 +11,15 @@ const row = (status, name, detail = '') =>
11
11
  ` ${status} ${name.padEnd(20)} ${meta(detail)}`
12
12
 
13
13
  export async function runDoctor (args) {
14
+ const all = args.includes('--all')
14
15
  if (args.includes('-h') || args.includes('--help')) {
15
16
  console.log(`mohdel doctor — check that your install is wired up
16
17
 
17
18
  Usage:
18
- mo doctor [--json]
19
+ mo doctor [--all] [--json]
20
+
21
+ Options:
22
+ --all List every provider's API key variable, set or not
19
23
 
20
24
  What it checks:
21
25
  - Config directory and environment file exist
@@ -45,9 +49,10 @@ Exit code:
45
49
 
46
50
  // 1. Config dir + env file
47
51
  report.configDir.ok = existsSync(CONFIG_DIR)
52
+ report.assistant = (await getConfig()).assistant || null
48
53
  report.envFile.ok = existsSync(ENV_PATH)
49
54
  if (!report.configDir.ok) report.warnings.push('config directory does not exist (will be created on first save)')
50
- if (!report.envFile.ok) report.warnings.push(`no ${ENV_PATH} — set API keys there or via shell env`)
55
+ if (!report.envFile.ok) report.warnings.push(`no ${tildePath(ENV_PATH)} — set API keys there or via shell env`)
51
56
 
52
57
  // 2. API keys per provider
53
58
  for (const [name, def] of Object.entries(providers)) {
@@ -70,6 +75,9 @@ Exit code:
70
75
  const entries = catalogEntries(curated)
71
76
  report.curatedFile.active = entries.filter(([, s]) => !s.deprecated).length
72
77
  report.curatedFile.deprecated = entries.length - report.curatedFile.active
78
+ if (report.curatedFile.active === 0) {
79
+ report.warnings.push('catalog is empty — "mo curate <provider>" adds a provider\'s models, then "mo model instructions <provider>" fills in the prices')
80
+ }
73
81
 
74
82
  // Schema validation (same logic as 'mo check', condensed)
75
83
  const knownProviders = new Set(Object.keys(providers))
@@ -126,13 +134,15 @@ Exit code:
126
134
  }
127
135
 
128
136
  // Pretty output
129
- console.log(label('Mohdel doctor\n'))
137
+ console.log(label('mohdel doctor\n'))
130
138
 
131
139
  console.log(label('Configuration'))
132
- console.log(row(report.configDir.ok ? ok('✓') : warn('!'), 'Config dir', report.configDir.path))
133
- console.log(row(report.envFile.ok ? ok('✓') : warn('!'), 'Env file', report.envFile.ok ? report.envFile.path : `${report.envFile.path} (missing)`))
140
+ console.log(row(report.configDir.ok ? ok('✓') : warn('!'), 'Config dir', tildePath(report.configDir.path)))
141
+ if (report.assistant) console.log(row(ok('✓'), 'Coding agent', report.assistant))
142
+ console.log(row(report.envFile.ok ? ok('✓') : warn('!'), 'Env file', report.envFile.ok ? tildePath(report.envFile.path) : `${tildePath(report.envFile.path)} (missing)`))
134
143
  if (report.curatedFile.ok) {
135
- console.log(row(ok('✓'), 'curated.json', `${report.curatedFile.active} active, ${report.curatedFile.deprecated} deprecated`))
144
+ const mark = report.curatedFile.active === 0 ? warn('!') : ok('✓')
145
+ console.log(row(mark, 'curated.json', `${report.curatedFile.active} active, ${report.curatedFile.deprecated} deprecated`))
136
146
  } else {
137
147
  console.log(row(err('✗'), 'curated.json', 'failed to load'))
138
148
  }
@@ -142,13 +152,22 @@ Exit code:
142
152
  for (const k of report.keys.configured) {
143
153
  console.log(row(ok('✓'), k.provider, k.envVar))
144
154
  }
145
- for (const k of report.keys.missing) {
146
- console.log(row(inactive('○'), k.provider, `${k.envVar} (unset)`))
155
+ // A row per unset provider is a dozen lines of nothing on a fresh install,
156
+ // but the variable names are what you came for when a key isn't picked up.
157
+ if (all) {
158
+ for (const k of report.keys.missing) {
159
+ console.log(row(inactive('○'), k.provider, `${k.envVar} (unset)`))
160
+ }
161
+ } else if (report.keys.missing.length) {
162
+ console.log(row(inactive('○'), `${report.keys.missing.length} unset`,
163
+ meta('"mo providers" lists them, "mo doctor --all" their variables')))
147
164
  }
148
165
 
149
166
  console.log()
150
167
  console.log(label('Catalog validation'))
151
- if (report.schema.errors.length) {
168
+ if (report.curatedFile.ok && report.curatedFile.active === 0 && report.curatedFile.deprecated === 0) {
169
+ console.log(` ${inactive('○')} nothing to validate`)
170
+ } else if (report.schema.errors.length) {
152
171
  console.log(` ${err('✗')} ${report.schema.errors.length} error(s) ${meta('— run "mo check" for details')}`)
153
172
  } else {
154
173
  console.log(` ${ok('✓')} no errors`)
@@ -171,11 +190,11 @@ Exit code:
171
190
  if (report.errors.length) {
172
191
  console.log(`${err('✗')} ${report.errors.length} error(s):`)
173
192
  for (const e of report.errors) console.log(` ${err('✗')} ${e}`)
174
- process.exit(1)
175
- } else if (report.warnings.length) {
193
+ }
194
+ if (report.warnings.length) {
176
195
  console.log(`${warn('!')} ${report.warnings.length} warning(s) — install is usable but not fully configured`)
177
196
  for (const w of report.warnings) console.log(` ${warn('!')} ${w}`)
178
- } else {
179
- console.log(`${ok('✓')} ready`)
180
197
  }
198
+ if (report.errors.length) process.exit(1)
199
+ if (!report.warnings.length) console.log(`${ok('✓')} ready`)
181
200
  }
@@ -0,0 +1,173 @@
1
+ import { readFile, rm } from 'node:fs/promises'
2
+ import { CURATED_PATH, getCuratedModels, saveCuratedModels, tildePath } from '../lib/common.js'
3
+ import { parseCandidates, reviewCandidates } from '../lib/catalog-review.js'
4
+ import { localConventionsOrExit } from './local.js'
5
+ import { id, label, meta, err, warn, ok } from './colors.js'
6
+
7
+ const readStdin = async () => {
8
+ const chunks = []
9
+ for await (const chunk of process.stdin) chunks.push(chunk)
10
+ return Buffer.concat(chunks).toString('utf8')
11
+ }
12
+
13
+ const readCandidateFile = async (path) => {
14
+ if (path === '-') return readStdin()
15
+ return readFile(path, 'utf8')
16
+ }
17
+
18
+ const show = (value) => JSON.stringify(value)
19
+
20
+ const renderChange = ({ field, from, to }) => {
21
+ const name = field.padEnd(22)
22
+ if (from === undefined) return ` ${ok('+')} ${name} ${show(to)}`
23
+ if (to === undefined) return ` ${err('-')} ${name} ${show(from)} ${meta('→ (removed)')}`
24
+ return ` ${warn('~')} ${name} ${show(from)} ${meta('→')} ${show(to)}`
25
+ }
26
+
27
+ export const printReview = (reviews, ignored) => {
28
+ for (const key of ignored) {
29
+ console.log(meta(`skipping meta key ${key}`))
30
+ }
31
+ for (const review of reviews) {
32
+ console.log(`\n${id(review.key)} ${label(review.status)}`)
33
+ for (const change of review.changes) console.log(renderChange(change))
34
+ for (const e of review.errors) console.log(` ${err('✗')} ${e}`)
35
+ for (const w of review.warnings) console.log(` ${warn('!')} ${w}`)
36
+ }
37
+ }
38
+
39
+ export const summarize = (reviews) => ({
40
+ candidates: reviews.length,
41
+ new: reviews.filter(r => r.status === 'new').length,
42
+ changed: reviews.filter(r => r.status === 'changed').length,
43
+ unchanged: reviews.filter(r => r.status === 'unchanged').length,
44
+ errors: reviews.reduce((n, r) => n + r.errors.length, 0),
45
+ warnings: reviews.reduce((n, r) => n + r.warnings.length, 0)
46
+ })
47
+
48
+ const printSummary = (counts) => {
49
+ const parts = [`${counts.new} new`, `${counts.changed} changed`, `${counts.unchanged} unchanged`]
50
+ console.log(`\n${label(`${counts.candidates} candidate(s):`)} ${parts.join(', ')} ${meta('│')} ` +
51
+ `${counts.errors ? err(`${counts.errors} error(s)`) : ok('0 errors')}, ${counts.warnings} warning(s)`)
52
+ }
53
+
54
+ const loadReview = async (path) => {
55
+ const { entries, ignored } = parseCandidates(await readCandidateFile(path))
56
+ const curated = await getCuratedModels()
57
+ const local = await localConventionsOrExit()
58
+ return { curated, entries, ignored, reviews: reviewCandidates(curated, entries, { local }) }
59
+ }
60
+
61
+ export async function runCheckEntry (args) {
62
+ const path = args[args.indexOf('--entry') + 1]
63
+ if (!path) {
64
+ console.error('Usage: mo model check --entry <file|->')
65
+ process.exit(1)
66
+ }
67
+ const json = args.includes('--json')
68
+
69
+ let review
70
+ try {
71
+ review = await loadReview(path)
72
+ } catch (e) {
73
+ if (json) console.log(JSON.stringify({ error: e.message }, null, 2))
74
+ else console.error(err(e.message))
75
+ process.exit(1)
76
+ }
77
+
78
+ const counts = summarize(review.reviews)
79
+ if (json) {
80
+ console.log(JSON.stringify({ counts, ignored: review.ignored, candidates: review.reviews }, null, 2))
81
+ } else {
82
+ printReview(review.reviews, review.ignored)
83
+ printSummary(counts)
84
+ if (!counts.errors) {
85
+ console.log(`${meta('Next:')} mo model apply ${path === '-' ? '<file>' : path}`)
86
+ }
87
+ }
88
+ if (counts.errors) process.exit(1)
89
+ }
90
+
91
+ export async function runApply (args) {
92
+ if (args.includes('-h') || args.includes('--help')) {
93
+ console.log(`mohdel model apply — write reviewed catalog entries
94
+
95
+ Usage:
96
+ model apply <file|-> [--yes] [--rm]
97
+
98
+ Reads entries in curated.json shape, validates them, prints the diff
99
+ against ${tildePath(CURATED_PATH)}, and writes after confirmation. Nothing is
100
+ written when validation reports an error.
101
+
102
+ The candidate file is left alone unless you say otherwise — at a terminal
103
+ it asks, and --rm removes it without asking. Undo uses the catalog's own
104
+ backups, not the candidate.
105
+
106
+ Options:
107
+ --yes Skip the confirmation prompt
108
+ --rm Delete the candidate file once it has been applied
109
+
110
+ Write an entry with your coding agent: mo model instructions <provider>`)
111
+ process.exit(0)
112
+ }
113
+
114
+ const path = args.find(a => !a.startsWith('--'))
115
+ if (!path) {
116
+ console.error('Usage: mo model apply <file|-> [--yes] [--rm]')
117
+ process.exit(1)
118
+ }
119
+
120
+ let review
121
+ try {
122
+ review = await loadReview(path)
123
+ } catch (e) {
124
+ console.error(err(e.message))
125
+ process.exit(1)
126
+ }
127
+
128
+ const counts = summarize(review.reviews)
129
+ printReview(review.reviews, review.ignored)
130
+ printSummary(counts)
131
+
132
+ if (counts.errors) {
133
+ console.error(`\n${err('Not written')} — fix the errors above and run again.`)
134
+ process.exit(1)
135
+ }
136
+ const nothingToDo = !counts.new && !counts.changed
137
+ if (nothingToDo) console.log(`\n${ok('Nothing to write')} — the catalog already matches.`)
138
+
139
+ if (!nothingToDo && !args.includes('--yes')) {
140
+ if (!process.stdout.isTTY || path === '-') {
141
+ console.error(`\n${err('Not written')} — no terminal to confirm on. Re-run in a terminal, or pass --yes.`)
142
+ process.exit(1)
143
+ }
144
+ const { confirm, isCancel } = await import('@clack/prompts')
145
+ const go = await confirm({ message: `Write ${counts.new + counts.changed} entry(ies) to ${tildePath(CURATED_PATH)}?` })
146
+ if (isCancel(go) || !go) {
147
+ console.log(meta('Not written.'))
148
+ return
149
+ }
150
+ }
151
+
152
+ if (!nothingToDo) {
153
+ await saveCuratedModels({ ...review.curated, ...review.entries })
154
+ console.log(`${ok('✓')} ${counts.new + counts.changed} entry(ies) written to ${tildePath(CURATED_PATH)}`)
155
+ console.log(`${meta('Undo:')} mo model backup diff prev ${meta('│')} mo model backup restore prev`)
156
+ }
157
+
158
+ // The candidate was written by the agent, not by mohdel, so it is never
159
+ // removed unasked. It has served its purpose though, and the catalog's own
160
+ // backups are what an undo uses — not this file.
161
+ if (path === '-') return
162
+ if (args.includes('--rm')) {
163
+ await rm(path, { force: true })
164
+ console.log(`${ok('✓')} removed ${path}`)
165
+ return
166
+ }
167
+ if (!process.stdout.isTTY) return
168
+ const { confirm, isCancel } = await import('@clack/prompts')
169
+ const drop = await confirm({ message: `Delete ${path}?`, initialValue: false })
170
+ if (isCancel(drop) || !drop) return
171
+ await rm(path, { force: true })
172
+ console.log(`${ok('✓')} removed ${path}`)
173
+ }