patina-cli 3.11.0 → 4.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 (193) hide show
  1. package/.patina.default.yaml +29 -29
  2. package/CHANGELOG.md +53 -0
  3. package/NOTICE +21 -0
  4. package/README.md +117 -224
  5. package/README_JA.md +134 -77
  6. package/README_KR.md +132 -74
  7. package/README_ZH.md +137 -80
  8. package/SKILL.md +11 -20
  9. package/artifacts/rebaseline-2025/README.md +147 -0
  10. package/artifacts/rebaseline-2025/human-controls.public.jsonl +250 -0
  11. package/artifacts/rebaseline-2025/intake.example.jsonl +2 -0
  12. package/artifacts/rebaseline-2025/intake.local.example.jsonl +25 -0
  13. package/artifacts/rebaseline-2025/prompts.template.jsonl +7 -0
  14. package/artifacts/rebaseline-2025/sources.ko-public.jsonl +39 -0
  15. package/assets/brand/patina-badge.svg +18 -0
  16. package/assets/brand/patina-mark.svg +8 -0
  17. package/assets/demo/README.md +79 -0
  18. package/core/scoring.md +12 -12
  19. package/core/standalone-prompt.md +3 -1
  20. package/core/stylometry.md +93 -22
  21. package/docs/API.md +1554 -0
  22. package/docs/AUTHENTICATION.md +50 -26
  23. package/docs/AUTHENTICATION_KR.md +54 -29
  24. package/docs/BRANDING.md +9 -8
  25. package/docs/CLI.md +55 -14
  26. package/docs/COOKBOOK.md +8 -21
  27. package/docs/DEMO.md +32 -5
  28. package/docs/EXIT-CODES.md +2 -3
  29. package/docs/FALSE-POSITIVES.md +63 -0
  30. package/docs/FAQ.md +9 -1
  31. package/docs/FAQ_KR.md +3 -1
  32. package/docs/FLAG-PARITY.md +33 -47
  33. package/docs/ISSUE-WAVES.md +57 -0
  34. package/docs/PATTERNS-EN.md +67 -3
  35. package/docs/PATTERNS-JA.md +68 -2
  36. package/docs/PATTERNS-KO.md +70 -7
  37. package/docs/PATTERNS-ZH.md +67 -3
  38. package/docs/PATTERNS.md +5 -5
  39. package/docs/RESEARCH-DOCS-PLATFORM.md +54 -0
  40. package/docs/ROADMAP.md +46 -66
  41. package/docs/TRANSLATIONESE-KO.md +51 -0
  42. package/docs/audits/2026-05-deep-research.md +3 -1
  43. package/docs/benchmarks/README.md +51 -0
  44. package/docs/benchmarks/detector-comparison.json +69 -9
  45. package/docs/benchmarks/detector-comparison.md +10 -5
  46. package/docs/benchmarks/katfish-ko-latest.json +657 -0
  47. package/docs/benchmarks/katfish-ko-latest.md +77 -0
  48. package/docs/benchmarks/latest.json +1183 -108
  49. package/docs/benchmarks/latest.md +84 -60
  50. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.json +1121 -0
  51. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.md +136 -0
  52. package/docs/benchmarks/rebaseline-latest.json +381 -0
  53. package/docs/benchmarks/rebaseline-latest.md +121 -0
  54. package/docs/benchmarks/register-stratified-latest.json +164 -0
  55. package/docs/benchmarks/register-stratified-latest.md +99 -0
  56. package/docs/benchmarks/register-stratified.md +43 -0
  57. package/docs/integrations/github-action.md +44 -11
  58. package/docs/integrations/playground.md +58 -0
  59. package/docs/integrations/pre-commit.md +5 -5
  60. package/docs/integrations/release.md +5 -3
  61. package/docs/integrations/static-sites.md +83 -0
  62. package/docs/research/2025-rebaseline-plan.md +71 -2
  63. package/docs/research/2026-rebaseline.md +102 -0
  64. package/docs/research/adversarial-mps.md +41 -0
  65. package/docs/research/ai-human-metrics.md +35 -23
  66. package/docs/research/human-eval-panel.md +42 -0
  67. package/docs/research/judge-agreement.md +24 -0
  68. package/docs/research/ko-2025-corpus-sources.md +135 -0
  69. package/docs/research/lexicon-freshness-audit.md +64 -0
  70. package/docs/research/zh-ja-lexicon-calibration.md +60 -0
  71. package/docs/social/patina-launch-copy.md +173 -100
  72. package/docs/social/patina-launch-execution.md +94 -0
  73. package/docs/social/patina-launch-korean-first.md +83 -0
  74. package/docs/social/signs-of-ai-writing.md +26 -0
  75. package/docs/social/signs-of-ai-writing_KR.md +26 -0
  76. package/lexicon/ai-en.md +21 -24
  77. package/lexicon/ai-ja.md +158 -0
  78. package/lexicon/ai-ko.md +9 -9
  79. package/lexicon/ai-zh.md +158 -0
  80. package/lexicon/provenance/ai-en.json +970 -0
  81. package/lexicon/provenance/ai-ja.json +542 -0
  82. package/lexicon/provenance/ai-ko.json +866 -0
  83. package/lexicon/provenance/ai-zh.json +542 -0
  84. package/package.json +49 -8
  85. package/patterns/en-communication.md +5 -0
  86. package/patterns/en-content.md +5 -0
  87. package/patterns/en-filler.md +5 -0
  88. package/patterns/en-language.md +29 -1
  89. package/patterns/en-structure.md +5 -0
  90. package/patterns/en-style.md +5 -0
  91. package/patterns/en-viral-hook.md +42 -2
  92. package/patterns/ja-communication.md +5 -0
  93. package/patterns/ja-content.md +5 -0
  94. package/patterns/ja-filler.md +5 -0
  95. package/patterns/ja-language.md +33 -1
  96. package/patterns/ja-structure.md +12 -0
  97. package/patterns/ja-style.md +5 -0
  98. package/patterns/ja-viral-hook.md +41 -2
  99. package/patterns/ko-communication.md +5 -0
  100. package/patterns/ko-content.md +5 -0
  101. package/patterns/ko-filler.md +5 -0
  102. package/patterns/ko-language.md +33 -1
  103. package/patterns/ko-structure.md +25 -6
  104. package/patterns/ko-style.md +5 -0
  105. package/patterns/ko-viral-hook.md +38 -2
  106. package/patterns/zh-communication.md +5 -0
  107. package/patterns/zh-content.md +5 -0
  108. package/patterns/zh-filler.md +5 -0
  109. package/patterns/zh-language.md +37 -1
  110. package/patterns/zh-structure.md +12 -0
  111. package/patterns/zh-style.md +5 -0
  112. package/patterns/zh-viral-hook.md +38 -2
  113. package/playground/README.md +55 -0
  114. package/playground/analytics.js +4 -0
  115. package/playground/analyzer.js +883 -0
  116. package/playground/app.js +157 -0
  117. package/playground/data/lexicons.js +343 -0
  118. package/playground/index.html +138 -0
  119. package/playground/styles.css +267 -0
  120. package/profiles/namuwiki.md +111 -0
  121. package/scripts/adversarial-mps-report.mjs +201 -0
  122. package/scripts/badge-json.mjs +79 -0
  123. package/scripts/benchmark-report.mjs +56 -9
  124. package/scripts/check-release-metadata.mjs +0 -2
  125. package/scripts/detector-comparison.mjs +7 -7
  126. package/scripts/generate-playground-data.mjs +77 -0
  127. package/scripts/katfish-calibration.mjs +464 -0
  128. package/scripts/lexicon-freshness.mjs +485 -0
  129. package/scripts/lint.mjs +1 -1
  130. package/scripts/precommit-score.mjs +4 -3
  131. package/scripts/prose-score.mjs +81 -5
  132. package/scripts/rebaseline-intake.mjs +242 -0
  133. package/scripts/rebaseline-score.mjs +268 -0
  134. package/scripts/rebaseline-summary.mjs +773 -0
  135. package/scripts/rebaseline-web-collect.mjs +410 -0
  136. package/scripts/update-benchmark-ranges.mjs +1 -0
  137. package/src/api.js +69 -105
  138. package/src/auth.js +50 -2
  139. package/src/backends/claude-cli.js +19 -4
  140. package/src/backends/codex-cli.js +19 -3
  141. package/src/backends/contract.js +230 -1
  142. package/src/backends/gemini-cli.js +18 -5
  143. package/src/backends/index.js +87 -12
  144. package/src/backends/kimi-cli.js +161 -0
  145. package/src/cli.js +577 -567
  146. package/src/commands/doctor.js +2 -2
  147. package/src/config.js +29 -0
  148. package/src/errors.js +53 -1
  149. package/src/features/discourse-tells.js +68 -0
  150. package/src/features/index.js +82 -8
  151. package/src/features/lexicon.js +40 -6
  152. package/src/features/markup-leakage.js +69 -0
  153. package/src/features/segment.js +41 -0
  154. package/src/features/signal-strength.js +81 -0
  155. package/src/features/stylometry.js +231 -1
  156. package/src/features/translationese.js +127 -0
  157. package/src/loader.js +76 -0
  158. package/src/logger.js +22 -23
  159. package/src/model-defaults.js +55 -0
  160. package/src/ouroboros.js +31 -0
  161. package/src/output.js +102 -90
  162. package/src/prompt-builder.js +103 -68
  163. package/src/providers.js +51 -4
  164. package/src/scoring.js +210 -2
  165. package/src/security.js +75 -0
  166. package/tests/fixtures/live-quality/en/public-docs-01.md +26 -0
  167. package/tests/fixtures/live-quality/ko/public-docs-01.md +26 -0
  168. package/tests/fixtures/suspect-zones/expected-ranges.json +207 -16
  169. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-04-lexicon.md +11 -0
  170. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-04-lexicon-cold.md +11 -0
  171. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +4 -5
  172. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-07-ko-diagnostic.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-04-lexicon.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-04-lexicon-cold.md +11 -0
  175. package/tests/quality/README.md +188 -11
  176. package/tests/quality/adversarial-mps/fixtures.jsonl +10 -0
  177. package/tests/quality/benchmark.mjs +39 -1
  178. package/tests/quality/dogfood.mjs +5 -3
  179. package/tests/quality/live-fixtures.jsonl +2 -0
  180. package/tests/quality/live-quality.mjs +596 -0
  181. package/tests/quality/ranking-metrics.mjs +136 -0
  182. package/tests/quality/rebaseline-manifest.example.jsonl +5 -0
  183. package/vercel.json +53 -0
  184. package/SKILL-MAX.md +0 -455
  185. package/docs/internal/HARNESS.md +0 -14
  186. package/docs/internal/README.md +0 -14
  187. package/docs/internal/WARP.md +0 -23
  188. package/patina-max/SKILL.md +0 -523
  189. package/patina-max/composite.py +0 -457
  190. package/src/cache.js +0 -106
  191. package/src/commands/init.js +0 -208
  192. package/src/manifest.js +0 -162
  193. package/src/max-mode.js +0 -207
package/src/cli.js CHANGED
@@ -8,29 +8,41 @@ import {
8
8
  toneToBackboneProfile,
9
9
  } from './loader.js';
10
10
  import { buildPrompt } from './prompt-builder.js';
11
- import { invokeBackendChain, selectBackendChain, listBackends, listBackendNames } from './backends/index.js';
12
- import { selectProvider, resolveProviderConfig, PROVIDERS } from './providers.js';
11
+ import { invokeBackendChain, selectBackendChain, listBackends, listBackendNames, resolveBackend } from './backends/index.js';
12
+ import { selectProvider, resolveProviderConfig } from './providers.js';
13
13
  import { validateBaseURL, applyInsecureBaseURLOptIn, applyPrivateBaseURLOptIn } from './security.js';
14
- import { formatOutput, validateScoreWeights } from './output.js';
15
- import { runMaxMode } from './max-mode.js';
14
+ import { formatOutput, validateScoreWeights, buildDeterministicAuditBackstop } from './output.js';
16
15
  import { runOuroboros } from './ouroboros.js';
17
16
  import { interpretScore, reconcileScoreOverall, scoreDeterministicSignals } from './scoring.js';
18
- import { callLLM, DEFAULT_TEMPERATURE } from './api.js';
19
- import { createResponseCache, DEFAULT_CACHE_TTL_SECONDS } from './cache.js';
20
- import { buildManifest, appendResult, writeManifest, hashSha256 } from './manifest.js';
21
17
  import { runDoctor } from './commands/doctor.js';
22
- import { runInit } from './commands/init.js';
23
18
  import { PatinaCliError, inputError, runtimeError, renderCliError, getExitCode } from './errors.js';
24
- import { inspectHttpApiKeySource, providerHttpKeyEnvVars, resolveHttpApiKey } from './auth.js';
19
+ import { providerHttpKeyEnvVars, resolveHttpApiKey } from './auth.js';
25
20
  import { createLogger } from './logger.js';
21
+ import {
22
+ DEFAULT_BACKEND_TIMEOUT_MS,
23
+ PROMPT_SIZE_WARNING_CHARS,
24
+ getBackendSafety,
25
+ resolveBackendMaxConcurrency,
26
+ resolveBackendMaxRetries,
27
+ } from './backends/contract.js';
26
28
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
27
29
  import { resolve, basename, extname } from 'node:path';
28
30
  import { fileURLToPath } from 'node:url';
31
+ import { createInterface } from 'node:readline/promises';
29
32
 
30
33
  const PACKAGE_VERSION = JSON.parse(
31
34
  readFileSync(resolve(getRepoRoot(), 'package.json'), 'utf8')
32
35
  ).version;
33
36
 
37
+ /**
38
+ * Run the patina CLI command dispatcher.
39
+ *
40
+ * @param {string[]} args Command-line arguments excluding node and script path.
41
+ * @returns {Promise<void>} Resolves after command output is written.
42
+ * @throws {Error} For validation, provider, file, or runtime failures.
43
+ * @example
44
+ * await main(['--help']);
45
+ */
34
46
  export async function main(args) {
35
47
  if (args[0] === 'auth') {
36
48
  return handleAuth(args.slice(1));
@@ -39,7 +51,11 @@ export async function main(args) {
39
51
  return runDoctor(args.slice(1), { version: PACKAGE_VERSION });
40
52
  }
41
53
  if (args[0] === 'init') {
42
- return runInit(args.slice(1));
54
+ throw inputError(
55
+ 'patina init was removed',
56
+ 'patina is zero-config; use CLI flags for one-off runs or add .patina.yaml only when project defaults are needed.',
57
+ 'Copy .patina.default.yaml to .patina.yaml and edit it manually, or pass --config <path>.'
58
+ );
43
59
  }
44
60
  if (args[0] === 'help') {
45
61
  printHelp();
@@ -47,7 +63,7 @@ export async function main(args) {
47
63
  }
48
64
 
49
65
  const parsed = parseArgs(args);
50
- const logger = createLogger({ quiet: parsed.quiet, json: parsed.jsonLogs });
66
+ const logger = createLogger({ quiet: parsed.quiet });
51
67
 
52
68
  if (parsed.help) {
53
69
  printHelp();
@@ -59,17 +75,10 @@ export async function main(args) {
59
75
  return;
60
76
  }
61
77
 
62
- if (parsed.models && parsed.variants && parsed.variants > 1) {
63
- throw inputError(
64
- '--variants is not supported with --models/MAX mode yet',
65
- 'MAX mode already fans out across models, so variant fan-out is ambiguous.',
66
- 'Omit --variants or run one model at a time.'
67
- );
68
- }
69
78
 
70
79
  if (parsed.gate !== undefined && !parsed.score) {
71
80
  throw inputError(
72
- `${parsed.gateOption || '--gate'} can only be used with --score`,
81
+ '--exit-on can only be used with --score',
73
82
  'Score gates need a parsed overall score.',
74
83
  'Run `patina --score --exit-on 30 <file>`.'
75
84
  );
@@ -80,11 +89,6 @@ export async function main(args) {
80
89
  return;
81
90
  }
82
91
 
83
- if (parsed.listProviders) {
84
- printProviderStatus();
85
- return;
86
- }
87
-
88
92
  const configPath = parsed.config ? resolve(process.cwd(), parsed.config) : undefined;
89
93
  const config = loadConfig(configPath);
90
94
 
@@ -92,7 +96,7 @@ export async function main(args) {
92
96
  if (parsed.profile) config.profile = parsed.profile;
93
97
 
94
98
  const provider = selectProvider(parsed.provider ?? config.provider);
95
- const apiKey = resolveApiKey(parsed, provider, logger);
99
+ const apiKey = resolveApiKey(parsed, provider);
96
100
  const resolved = resolveProviderConfig({
97
101
  provider,
98
102
  apiKey,
@@ -103,12 +107,6 @@ export async function main(args) {
103
107
  applyPrivateBaseURLOptIn(parsed);
104
108
  validateBaseURL(resolved.baseURL);
105
109
 
106
- const startedAt = new Date().toISOString();
107
- const manifestResults = [];
108
- const manifestOutputs = [];
109
- const manifestTemperature = DEFAULT_TEMPERATURE;
110
- const manifestSeed = null;
111
- const responseCache = resolveResponseCache(parsed);
112
110
 
113
111
  const repoRoot = getRepoRoot();
114
112
  const lang = config.language || 'ko';
@@ -134,6 +132,11 @@ export async function main(args) {
134
132
  const backbone = toneToBackboneProfile(toneResolution.tone);
135
133
  if (backbone) profileName = backbone;
136
134
  }
135
+ const resolvedProfileName = resolveProfileForLanguage(profileName, lang, logger);
136
+ if (resolvedProfileName !== profileName) {
137
+ profileName = resolvedProfileName;
138
+ config.profile = 'default';
139
+ }
137
140
 
138
141
  const patterns = loadPatterns(repoRoot, lang, config['skip-patterns'] || []);
139
142
  const profile = loadProfile(repoRoot, profileName);
@@ -157,197 +160,181 @@ export async function main(args) {
157
160
  }
158
161
 
159
162
  const inputTexts = await loadInputs(parsed, logger);
160
- const cancellation = createCancellationController({ logger });
163
+ const timeoutMs = parsed.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS;
164
+ const backendSelection = parsed.ouroboros
165
+ ? null
166
+ : selectBackendChain({
167
+ name: parsed.backend ?? config.backend ?? (resolved.baseURLSource !== 'default' ? 'openai-http' : undefined),
168
+ model: resolved.model,
169
+ modelSource: resolved.modelSource,
170
+ });
171
+ const backends = backendSelection?.backends || [];
172
+ const backend = backends[0] || null;
161
173
 
162
- cancellation.install();
163
- try {
164
- for (const { path, text } of inputTexts) {
165
- cancellation.throwIfCanceled();
166
- const manifestCalls = [];
167
- const recordManifestCall = parsed.saveRun ? createManifestCallRecorder(manifestCalls) : null;
168
- const trackedCallLLM = (parsed.saveRun || responseCache)
169
- ? (args) => callLLM({
170
- ...args,
171
- cache: responseCache,
172
- onResponse: (metadata) => {
173
- args.onResponse?.(metadata);
174
- recordManifestCall?.(metadata);
175
- },
176
- })
177
- : undefined;
178
- const prompt = buildPrompt({
179
- config,
180
- patterns,
181
- profile: profile.body ? profile : null,
182
- voice: voice.body ? voice : null,
183
- voiceSample,
184
- scoring: scoring.body ? scoring : null,
185
- text,
186
- mode,
187
- tone: toneResolution,
188
- promptMode: resolvePromptMode(
189
- parsed.promptMode || config['prompt-mode'] || 'strict',
190
- { backend: parsed.backend ?? config.backend, model: resolved.model }
191
- ),
192
- variants: parsed.variants || 1,
174
+ if (backendSelection) {
175
+ if (backendSelection.autoSelected) {
176
+ logger.info('backend.selected', {
177
+ message: `[patina] Using ${backend.name} backend (${backendSelection.reason}). Run \`patina auth status\` for details.`,
193
178
  });
179
+ }
180
+ if (backends.length > 1) {
181
+ logger.info('backend.chain', {
182
+ message: `[patina] Backend fallback chain: ${backends.map((b) => b.name).join(' → ')}`,
183
+ });
184
+ }
185
+ if (backend.name === 'openai-http' && !resolved.apiKey) {
186
+ const msg = ['No API key found. Set PATINA_API_KEY, PATINA_API_KEY_FILE, OPENAI_API_KEY, or use --api-key-file.'];
187
+ if (provider) {
188
+ msg.push(`(--provider ${provider.name} expects ${provider.apiKeyEnv} or PATINA_API_KEY.)`);
189
+ }
190
+ const codex = listBackends().find((b) => b.name === 'codex-cli');
191
+ if (codex && codex.available && codex.authenticated) {
192
+ msg.push('Or pass `--backend codex-cli` to use the codex-cli backend (no key needed).');
193
+ } else if (codex && codex.available && !codex.authenticated) {
194
+ msg.push('Or run `codex login`, then pass `--backend codex-cli`.');
195
+ } else if (codex && !codex.available) {
196
+ msg.push('Or install `codex` from https://github.com/openai/codex and pass `--backend codex-cli`.');
197
+ }
198
+ throw runtimeError(
199
+ 'no API key found',
200
+ msg[0],
201
+ msg.slice(1).join(' ') || 'Set PATINA_API_KEY or pass --backend codex-cli after logging in.'
202
+ );
203
+ }
204
+ }
194
205
 
195
- let result;
196
-
197
- if (parsed.models) {
198
- result = await runMaxMode({
199
- prompt,
200
- sourceText: text,
201
- models: parsed.models,
202
- apiKey: resolved.apiKey,
203
- baseURL: resolved.baseURL,
204
- config,
205
- patterns,
206
- maxConcurrency: parsed.maxConcurrency,
207
- wallClockBudgetMs: parsed.maxTimeoutSeconds === undefined ? undefined : parsed.maxTimeoutSeconds * 1000,
208
- callLLM: trackedCallLLM,
209
- signal: cancellation.signal,
210
- logger,
211
- });
212
- } else if (parsed.ouroboros) {
213
- result = await runOuroboros({
214
- config,
215
- patterns,
216
- profile: profile.body ? profile : null,
217
- voice: voice.body ? voice : null,
218
- voiceSample,
219
- scoring: scoring.body ? scoring : null,
220
- text,
221
- apiKey: resolved.apiKey,
222
- baseURL: resolved.baseURL,
223
- model: resolved.model,
224
- callLLM: trackedCallLLM,
225
- signal: cancellation.signal,
226
- logger,
227
- });
228
- } else {
229
- const { backends, autoSelected, reason } = selectBackendChain({
230
- name: parsed.backend ?? config.backend,
231
- model: resolved.model,
232
- });
233
- const backend = backends[0];
206
+ const promptMode = backendSelection
207
+ ? resolvePromptMode({ backend: backend.name, model: resolved.model })
208
+ : 'strict';
209
+ const jobs = inputTexts.map(({ path, text }) => ({
210
+ path,
211
+ text,
212
+ prompt: parsed.ouroboros ? null : buildPrompt({
213
+ config,
214
+ patterns,
215
+ profile: profile.body ? profile : null,
216
+ voice: voice.body ? voice : null,
217
+ voiceSample,
218
+ scoring: scoring.body ? scoring : null,
219
+ text,
220
+ mode,
221
+ tone: toneResolution,
222
+ promptMode,
223
+ }),
224
+ }));
234
225
 
235
- if (autoSelected) {
236
- logger.info('backend.selected', {
237
- message: `[patina] Using ${backend.name} backend (${reason}). Run \`patina auth status\` for details.`,
226
+ if (backendSelection) {
227
+ logBatchSafetyPlan({
228
+ jobs,
229
+ backends,
230
+ parsed,
231
+ promptMode,
232
+ timeoutMs,
233
+ logger,
234
+ });
235
+ }
236
+
237
+ const cancellation = createCancellationController({ logger });
238
+ const batchState = createBatchCircuitBreaker({ parsed, total: jobs.length });
239
+
240
+ cancellation.install();
241
+ try {
242
+ for (const { path, text, prompt } of jobs) {
243
+ try {
244
+ cancellation.throwIfCanceled();
245
+ let result;
246
+
247
+ if (parsed.ouroboros) {
248
+ result = await runOuroboros({
249
+ config,
250
+ patterns,
251
+ profile: profile.body ? profile : null,
252
+ voice: voice.body ? voice : null,
253
+ voiceSample,
254
+ scoring: scoring.body ? scoring : null,
255
+ text,
256
+ apiKey: resolved.apiKey,
257
+ baseURL: resolved.baseURL,
258
+ model: resolved.model,
259
+ timeout: timeoutMs,
260
+ signal: cancellation.signal,
261
+ logger,
262
+ });
263
+ } else {
264
+ result = await invokeBackendChain({
265
+ backends,
266
+ prompt,
267
+ apiKey: resolved.apiKey,
268
+ baseURL: resolved.baseURL,
269
+ model: resolved.model,
270
+ modelSource: resolved.modelSource,
271
+ signal: cancellation.signal,
272
+ timeout: timeoutMs,
273
+ maxConcurrency: parsed.maxConcurrency,
274
+ maxRetries: parsed.maxRetries,
275
+ logger,
238
276
  });
239
277
  }
240
- if (backends.length > 1) {
241
- logger.info('backend.chain', {
242
- message: `[patina] Backend fallback chain: ${backends.map((b) => b.name).join(' → ')}`,
278
+ cancellation.throwIfCanceled();
279
+
280
+ if (mode === 'score' && !parsed.ouroboros) {
281
+ result = withDeterministicScore(result, {
282
+ text,
283
+ config,
284
+ repoRoot,
285
+ logger,
243
286
  });
244
287
  }
245
288
 
246
- if (backend.name === 'openai-http' && !resolved.apiKey) {
247
- const msg = ['No API key found. Set PATINA_API_KEY, PATINA_API_KEY_FILE, OPENAI_API_KEY, or pass --api-key.'];
248
- if (provider) {
249
- msg.push(`(--provider ${provider.name} expects ${provider.apiKeyEnv} or PATINA_API_KEY.)`);
250
- }
251
- const codex = listBackends().find((b) => b.name === 'codex-cli');
252
- if (codex && codex.available && codex.authenticated) {
253
- msg.push('Or pass `--backend codex-cli` to use the codex-cli backend (no key needed).');
254
- } else if (codex && codex.available && !codex.authenticated) {
255
- msg.push('Or run `codex login`, then pass `--backend codex-cli`.');
256
- } else if (codex && !codex.available) {
257
- msg.push('Or install `codex` from https://github.com/openai/codex and pass `--backend codex-cli`.');
289
+ const auditBackstop =
290
+ mode === 'audit' && (parsed.format ?? 'markdown') !== 'json' && !parsed.batch
291
+ ? buildDeterministicAuditBackstop(text, { lang, repoRoot })
292
+ : '';
293
+ let output;
294
+ let scoreValidationOutput = null;
295
+ if (parsed.ouroboros) {
296
+ const ouroborosBody = formatOuroborosOutput(result);
297
+ output = formatOutput(ouroborosBody, mode, parsed, { tone: toneResolution, logger, auditBackstop });
298
+ scoreValidationOutput = ouroborosBody;
299
+ } else {
300
+ output = formatOutput(result, mode, parsed, { tone: toneResolution, logger, auditBackstop });
301
+ if (mode === 'score') {
302
+ scoreValidationOutput = formatOutput(result, mode, { ...parsed, format: 'markdown' }, { logger });
258
303
  }
259
- throw runtimeError(
260
- 'no API key found',
261
- msg[0],
262
- msg.slice(1).join(' ') || 'Set PATINA_API_KEY or pass --backend codex-cli after logging in.'
263
- );
264
304
  }
265
305
 
266
- result = await invokeBackendChain({
267
- backends,
268
- prompt,
269
- apiKey: resolved.apiKey,
270
- baseURL: resolved.baseURL,
271
- model: resolved.model,
272
- signal: cancellation.signal,
273
- temperature: manifestTemperature,
274
- seed: manifestSeed,
275
- onResponse: recordManifestCall,
276
- cache: responseCache,
277
- logger,
278
- });
279
- }
280
- cancellation.throwIfCanceled();
281
-
282
- if (mode === 'score' && !parsed.models && !parsed.ouroboros) {
283
- result = withDeterministicScore(result, {
284
- text,
285
- config,
286
- repoRoot,
287
- logger,
288
- });
289
- }
290
-
291
- let output;
292
- let scoreValidationOutput = null;
293
- if (parsed.ouroboros) {
294
- const ouroborosBody = formatOuroborosOutput(result);
295
- output = formatOutput(ouroborosBody, mode, parsed, { tone: toneResolution, logger });
296
- scoreValidationOutput = ouroborosBody;
297
- } else {
298
- output = formatOutput(result, mode, parsed, { tone: toneResolution, logger });
306
+ // v3.11 Phase 1.3: surface weight drift between config and the score
307
+ // table the model emitted. Warnings only — does not alter the output.
299
308
  if (mode === 'score') {
300
- scoreValidationOutput = formatOutput(result, mode, { ...parsed, format: 'markdown' }, { logger });
301
- }
302
- }
309
+ const configWeights = config.ouroboros?.['category-weights']?.[lang] || {};
310
+ const warnings = validateScoreWeights(scoreValidationOutput || output, configWeights);
311
+ for (const w of warnings) {
312
+ logger.warn('score.weight_check', { message: `[patina] ${w}` });
313
+ }
303
314
 
304
- // v3.11 Phase 1.3: surface weight drift between config and the score
305
- // table the model emitted. Warnings only — does not alter the output.
306
- if (mode === 'score') {
307
- const configWeights = config.ouroboros?.['category-weights']?.[lang] || {};
308
- const warnings = validateScoreWeights(scoreValidationOutput || output, configWeights);
309
- for (const w of warnings) {
310
- logger.warn('score.weight_check', { message: `[patina] ${w}` });
315
+ if (parsed.gate !== undefined) {
316
+ applyScoreGate(result, output, parsed.gate, logger);
317
+ }
311
318
  }
312
319
 
313
- if (parsed.gate !== undefined) {
314
- applyScoreGate(result, output, parsed.gate, logger);
320
+ if (parsed.batch) {
321
+ await writeBatchOutput(parsed, path, output);
322
+ } else {
323
+ console.log(output);
315
324
  }
316
- }
317
-
318
- if (result?.type === 'max-mode' && (result.allFailed || result.mpsFallback)) {
319
- process.exitCode = Math.max(Number(process.exitCode) || 0, 4);
320
- }
321
-
322
- if (parsed.saveRun) {
323
- const idx = manifestResults.length + 1;
324
- const outputName = `output-${idx}.txt`;
325
- appendResult(manifestResults, {
326
- inputPath: path,
327
- prompt,
328
- response: manifestResponseText(result, output),
329
- outputRef: { kind: 'file', name: outputName },
330
- tokensIn: sumManifestCalls(manifestCalls, 'tokensIn'),
331
- tokensOut: sumManifestCalls(manifestCalls, 'tokensOut'),
332
- temperature: manifestTemperature,
333
- seed: manifestSeed,
334
- cost: sumManifestCallCost(manifestCalls),
335
- scores: manifestScoreDetails(result),
336
- iterationLog: manifestIterationLog(result),
337
- calls: manifestCalls.length > 0 ? manifestCalls : undefined,
325
+ batchState.recordSuccess();
326
+ } catch (err) {
327
+ if (!shouldHandleBatchFailure(parsed, jobs.length)) throw err;
328
+ batchState.recordFailure({ path, err });
329
+ logger.warn('batch.file_failed', {
330
+ message: `[patina] batch file failed: ${path} (${batchState.failures.length}/${batchState.maxFailures} failures): ${err.message}`,
338
331
  });
339
- manifestOutputs.push({ name: outputName, content: output });
340
- }
341
-
342
- if (parsed.batch) {
343
- await writeBatchOutput(parsed, path, output);
344
- } else {
345
- console.log(output);
332
+ if (batchState.shouldStop()) throw batchState.toError();
346
333
  }
347
334
  }
348
335
 
349
- if (responseCache) {
350
- logger.info('cache.stats', { message: formatCacheStats(responseCache.stats) });
336
+ if (batchState.hasFailures()) {
337
+ throw batchState.toError({ completed: true });
351
338
  }
352
339
  } catch (err) {
353
340
  if (cancellation.signal.aborted) throw cancellationError();
@@ -357,30 +344,6 @@ export async function main(args) {
357
344
  logger.closeProgress();
358
345
  }
359
346
 
360
- if (parsed.saveRun) {
361
- const manifest = buildManifest({
362
- patinaVersion: PACKAGE_VERSION,
363
- mode,
364
- lang,
365
- profile: profileName,
366
- provider: provider?.name,
367
- backend: parsed.backend ?? config.backend ?? 'openai-http',
368
- model: resolved.model,
369
- configPath: configPath ?? null,
370
- config,
371
- patterns,
372
- results: manifestResults,
373
- startedAt,
374
- temperature: manifestTemperature,
375
- seed: manifestSeed,
376
- });
377
- const manifestPath = writeManifest(
378
- resolve(process.cwd(), parsed.saveRun),
379
- manifest,
380
- manifestOutputs
381
- );
382
- logger.info('manifest.written', { message: `[patina] wrote manifest to ${manifestPath}` });
383
- }
384
347
  }
385
348
 
386
349
  function parseArgs(args) {
@@ -451,30 +414,9 @@ function parseArgs(args) {
451
414
  parsed.format = value;
452
415
  break;
453
416
  }
454
- case '--json':
455
- parsed.format = 'json';
456
- break;
457
417
  case '--quiet':
458
418
  parsed.quiet = true;
459
419
  break;
460
- case '--json-logs':
461
- parsed.jsonLogs = true;
462
- break;
463
- case '--gate': {
464
- const value = readOptionValue(args, i, arg, { allowFlagLike: true });
465
- i++;
466
- const n = Number(value);
467
- if (!Number.isFinite(n) || n < 0 || n > 100) {
468
- throw inputError(
469
- '--gate expects a number from 0 to 100',
470
- `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
471
- 'Use `patina --score --gate 30 <file>` for CI gates.'
472
- );
473
- }
474
- parsed.gate = n;
475
- parsed.gateOption = '--gate';
476
- break;
477
- }
478
420
  case '--exit-on': {
479
421
  const value = readOptionValue(args, i, arg, { allowFlagLike: true });
480
422
  i++;
@@ -487,7 +429,6 @@ function parseArgs(args) {
487
429
  );
488
430
  }
489
431
  parsed.gate = n;
490
- parsed.gateOption = '--exit-on';
491
432
  break;
492
433
  }
493
434
  case '--ouroboros':
@@ -507,56 +448,10 @@ function parseArgs(args) {
507
448
  parsed.outdir = readOptionValue(args, i, arg);
508
449
  i++;
509
450
  break;
510
- case '--models':
511
- parsed.models = readOptionValue(args, i, arg)
512
- .split(',')
513
- .map((m) => m.trim())
514
- .filter(Boolean);
515
- i++;
516
- if (parsed.models.length === 0) {
517
- throw inputError(
518
- '--models expects at least one model id',
519
- 'The comma-separated model list was empty.',
520
- 'Use `--models gpt-4o,claude-3-5-sonnet`.'
521
- );
522
- }
523
- break;
524
- case '--max-concurrency': {
525
- const value = readOptionValue(args, i, arg, { allowFlagLike: true });
526
- i++;
527
- const n = Number(value);
528
- if (!Number.isInteger(n) || n < 0) {
529
- throw inputError(
530
- '--max-concurrency expects a non-negative integer',
531
- `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
532
- 'Use `--max-concurrency 2`, omit it for the safe default, or pass 0 for unlimited concurrency.'
533
- );
534
- }
535
- parsed.maxConcurrency = n;
536
- break;
537
- }
538
- case '--max-timeout': {
539
- const value = readOptionValue(args, i, arg, { allowFlagLike: true });
540
- i++;
541
- const n = Number(value);
542
- if (!Number.isFinite(n) || n <= 0) {
543
- throw inputError(
544
- '--max-timeout expects a positive number of seconds',
545
- `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
546
- 'Use `--max-timeout 300`, or omit it for the default 300 seconds.'
547
- );
548
- }
549
- parsed.maxTimeoutSeconds = n;
550
- break;
551
- }
552
451
  case '--model':
553
452
  parsed.model = readOptionValue(args, i, arg);
554
453
  i++;
555
454
  break;
556
- case '--api-key':
557
- parsed.apiKey = readOptionValue(args, i, arg);
558
- i++;
559
- break;
560
455
  case '--api-key-file':
561
456
  parsed.apiKeyFile = readOptionValue(args, i, arg);
562
457
  i++;
@@ -572,75 +467,53 @@ function parseArgs(args) {
572
467
  parsed.backend = readOptionValue(args, i, arg);
573
468
  i++;
574
469
  break;
575
- case '--list-backends':
576
- parsed.listBackends = true;
577
- break;
578
- case '--provider':
579
- parsed.provider = readOptionValue(args, i, arg);
470
+ case '--timeout-ms': {
471
+ const value = readOptionValue(args, i, arg, { allowFlagLike: true });
580
472
  i++;
473
+ parsed.timeoutMs = parsePositiveIntegerOption(value, arg);
581
474
  break;
582
- case '--list-providers':
583
- parsed.listProviders = true;
584
- break;
585
- case '--allow-insecure-base-url':
586
- parsed.allowInsecureBaseURL = true;
587
- break;
588
- case '--config':
589
- parsed.config = readOptionValue(args, i, arg);
475
+ }
476
+ case '--max-concurrency': {
477
+ const value = readOptionValue(args, i, arg, { allowFlagLike: true });
590
478
  i++;
479
+ parsed.maxConcurrency = parsePositiveIntegerOption(value, arg);
591
480
  break;
592
- case '--save-run':
593
- parsed.saveRun = readOptionValue(args, i, arg);
481
+ }
482
+ case '--max-retries': {
483
+ const value = readOptionValue(args, i, arg, { allowFlagLike: true });
594
484
  i++;
485
+ parsed.maxRetries = parseNonNegativeIntegerOption(value, arg);
595
486
  break;
596
- case '--cache':
597
- parsed.cacheDir = readOptionValue(args, i, arg);
487
+ }
488
+ case '--max-failures': {
489
+ const value = readOptionValue(args, i, arg, { allowFlagLike: true });
598
490
  i++;
491
+ parsed.maxFailures = parsePositiveIntegerOption(value, arg);
599
492
  break;
600
- case '--cache-ttl': {
493
+ }
494
+ case '--max-failure-rate': {
601
495
  const value = readOptionValue(args, i, arg, { allowFlagLike: true });
602
496
  i++;
603
- const n = Number(value);
604
- if (!Number.isFinite(n) || n <= 0) {
605
- throw inputError(
606
- '--cache-ttl expects a positive number of seconds',
607
- `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
608
- 'Use `--cache-ttl 86400` for a one-day response cache.'
609
- );
610
- }
611
- parsed.cacheTtlSeconds = n;
497
+ parsed.maxFailureRate = parseFailureRateOption(value, arg);
612
498
  break;
613
499
  }
614
- case '--no-cache':
615
- parsed.noCache = true;
500
+ case '--stop-on-retryable-storm':
501
+ parsed.stopOnRetryableStorm = true;
502
+ break;
503
+ case '--list-backends':
504
+ parsed.listBackends = true;
616
505
  break;
617
- case '--prompt-mode': {
618
- const m = readOptionValue(args, i, arg);
506
+ case '--provider':
507
+ parsed.provider = readOptionValue(args, i, arg);
619
508
  i++;
620
- if (!m || !['strict', 'minimal', 'auto'].includes(m)) {
621
- throw inputError(
622
- '--prompt-mode expects strict, minimal, or auto',
623
- `Received ${m === undefined ? 'no value' : `"${m}"`}.`,
624
- 'Use `--prompt-mode auto` unless you need a specific prompt style.'
625
- );
626
- }
627
- parsed.promptMode = m;
628
509
  break;
629
- }
630
- case '--variants': {
631
- const value = readOptionValue(args, i, arg, { allowFlagLike: true });
510
+ case '--allow-insecure-base-url':
511
+ parsed.allowInsecureBaseURL = true;
512
+ break;
513
+ case '--config':
514
+ parsed.config = readOptionValue(args, i, arg);
632
515
  i++;
633
- const n = Number(value);
634
- if (!Number.isInteger(n) || n < 1 || n > 5) {
635
- throw inputError(
636
- '--variants expects an integer from 1 to 5',
637
- `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
638
- 'Use `--variants 2` for alternate rewrite drafts.'
639
- );
640
- }
641
- parsed.variants = n;
642
516
  break;
643
- }
644
517
  case '--no-interactive':
645
518
  parsed.noInteractive = true;
646
519
  break;
@@ -670,6 +543,19 @@ function cancellationError() {
670
543
  });
671
544
  }
672
545
 
546
+ /**
547
+ * Create a SIGINT-aware cancellation controller for long-running CLI operations.
548
+ *
549
+ * @param {object} [options] Cancellation integration points.
550
+ * @param {NodeJS.Process} [options.processObj=process] Process-like object used for signal listeners.
551
+ * @param {NodeJS.WritableStream} [options.stderr=process.stderr] Stream for fallback cancel messages.
552
+ * @param {object|null} [options.logger] Optional patina logger.
553
+ * @returns {{signal: AbortSignal, install: Function, uninstall: Function, throwIfCanceled: Function}} Controller facade.
554
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
555
+ * @example
556
+ * const cancellation = createCancellationController();
557
+ * cancellation.install();
558
+ */
673
559
  export function createCancellationController({
674
560
  processObj = process,
675
561
  stderr = process.stderr,
@@ -736,44 +622,223 @@ function readOptionValue(args, index, option, { allowFlagLike = false } = {}) {
736
622
  return value;
737
623
  }
738
624
 
739
- // v3.11: case-05 found that prompt-mode preference is per-backend.
740
- // auto resolves to strict for codex-cli/claude (instruction-rich) and
741
- // minimal for gemini (voice-rich, over-constrained by long prompts).
742
- // Explicit strict/minimal pass through unchanged.
743
- export function resolvePromptMode(mode, { backend, model }) {
744
- if (mode !== 'auto') return mode;
625
+ function parsePositiveIntegerOption(value, option) {
626
+ const n = Number(value);
627
+ if (!Number.isInteger(n) || n <= 0) {
628
+ throw inputError(
629
+ `${option} expects a positive integer`,
630
+ `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
631
+ `Use ${option} 1 or another whole number greater than zero.`
632
+ );
633
+ }
634
+ return n;
635
+ }
636
+
637
+ function parseNonNegativeIntegerOption(value, option) {
638
+ const n = Number(value);
639
+ if (!Number.isInteger(n) || n < 0) {
640
+ throw inputError(
641
+ `${option} expects a non-negative integer`,
642
+ `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
643
+ `Use ${option} 0 to disable retries, or another whole number.`
644
+ );
645
+ }
646
+ return n;
647
+ }
648
+
649
+ function parseFailureRateOption(value, option) {
650
+ const n = Number(value);
651
+ if (!Number.isFinite(n) || n < 0) {
652
+ throw inputError(
653
+ `${option} expects a ratio or percent`,
654
+ `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
655
+ `Use ${option} 0.25 for 25%, or ${option} 25.`
656
+ );
657
+ }
658
+ const ratio = n > 1 ? n / 100 : n;
659
+ if (ratio > 1) {
660
+ throw inputError(
661
+ `${option} expects a value from 0 to 1 or 0 to 100`,
662
+ `Received "${value}".`,
663
+ `Use ${option} 0.25 for 25%, or ${option} 25.`
664
+ );
665
+ }
666
+ return ratio;
667
+ }
668
+
669
+ // Internal prompt style is selected from backend safety metadata. Local agent
670
+ // CLIs use the compact rewrite prompt by default to avoid feeding large pattern
671
+ // packs into batch-oriented agent runtimes.
672
+ export function resolvePromptMode({ backend, model }) {
745
673
  const backendStr = (backend || '').toLowerCase();
746
674
  const modelStr = (model || '').toLowerCase();
747
- if (backendStr.includes('gemini') || modelStr.includes('gemini')) return 'minimal';
748
- if (modelStr.includes('claude')) return 'strict';
749
- // Default for codex-cli, openai-http with gpt-* models, and anything we
750
- // can't classify strict is the conservative choice (full pattern packs).
675
+ if (backendStr && backendStr !== 'openai-http') return getBackendSafety(backendStr).promptMode;
676
+ if (modelStr.includes('gemini')) return 'minimal';
677
+ if (backendStr) return getBackendSafety(backendStr).promptMode;
678
+ if (modelStr.includes('kimi') || modelStr.includes('claude') || modelStr.includes('codex')) return 'minimal';
751
679
  return 'strict';
752
680
  }
753
681
 
754
- // Resolve the API key, preferring file-based sources to keep the secret out
755
- // of argv and shell history (CWE-214). Precedence: --api-key-file >
756
- // PATINA_API_KEY_FILE > --api-key (with deprecation warning) > provider/default
757
- // env vars.
758
- function resolveApiKey(parsed, provider, logger = createLogger()) {
759
- const hasApiKeyFile = Boolean(parsed.apiKeyFile || process.env.PATINA_API_KEY_FILE);
760
- const apiKey = resolveHttpApiKey({
761
- explicitApiKey: parsed.apiKey,
682
+ /**
683
+ * Resolve a profile name against language-specific profile limits.
684
+ *
685
+ * @param {string} profileName Requested profile name.
686
+ * @param {string} lang Active language code.
687
+ * @param {object} [logger] Logger with warn(event, payload).
688
+ * @returns {string} Effective profile name.
689
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
690
+ * @example
691
+ * resolveProfileForLanguage('namuwiki', 'en') // 'default'
692
+ */
693
+ export function resolveProfileForLanguage(profileName, lang, logger = null) {
694
+ const effective = profileName || 'default';
695
+ if (effective === 'namuwiki' && lang !== 'ko') {
696
+ logger?.warn?.('profile.unsupported_language', {
697
+ message: `[patina] profile "namuwiki" is ko-only; falling back to default profile for --lang ${lang}`,
698
+ });
699
+ return 'default';
700
+ }
701
+ return effective;
702
+ }
703
+
704
+
705
+ // Resolve the API key from file or environment. Precedence: --api-key-file >
706
+ // PATINA_API_KEY_FILE > provider/default env vars.
707
+ function resolveApiKey(parsed, provider) {
708
+ return resolveHttpApiKey({
762
709
  apiKeyFile: parsed.apiKeyFile,
763
710
  envVars: providerHttpKeyEnvVars(provider?.apiKeyEnv),
764
711
  });
765
- if (hasApiKeyFile && parsed.apiKey) {
766
- logger.warn('auth.api_key_file_precedence', {
767
- message: '[patina] both --api-key-file and --api-key were provided; using --api-key-file',
712
+ }
713
+
714
+ function logBatchSafetyPlan({ jobs, backends, parsed, promptMode, timeoutMs, logger }) {
715
+ if (!parsed.batch || jobs.length <= 1) return;
716
+
717
+ const primary = backends[0];
718
+ const promptSizes = jobs
719
+ .map((job) => typeof job.prompt === 'string' ? job.prompt.length : 0)
720
+ .filter((size) => size > 0);
721
+ const maxPromptChars = promptSizes.length > 0 ? Math.max(...promptSizes) : 0;
722
+ const avgPromptChars = promptSizes.length > 0
723
+ ? Math.round(promptSizes.reduce((sum, size) => sum + size, 0) / promptSizes.length)
724
+ : 0;
725
+ const maxConcurrency = resolveBackendMaxConcurrency(primary?.name, parsed.maxConcurrency);
726
+ const perFileRequests = backends.reduce(
727
+ (sum, item) => sum + resolveBackendMaxRetries(item.name, parsed.maxRetries) + 1,
728
+ 0
729
+ );
730
+
731
+ logger.info('batch.safety_plan', {
732
+ message: `[patina] batch safety: files=${jobs.length}, backend=${backends.map((b) => b.name).join('→')}, prompt_mode=${promptMode}, max_concurrency=${formatLimit(maxConcurrency)}, max_retries=${resolveBackendMaxRetries(primary?.name, parsed.maxRetries)}, timeout_ms=${timeoutMs}, worst_case_requests=${jobs.length * perFileRequests}, max_prompt_chars=${maxPromptChars}, avg_prompt_chars=${avgPromptChars}`,
733
+ });
734
+
735
+ if (primary && getBackendSafety(primary.name).agentRuntime) {
736
+ logger.warn('batch.local_cli_caveat', {
737
+ message: `[patina] ${primary.name} is a local agent CLI, not a stateless batch completion API. Large batches should prefer an OpenAI-compatible HTTP provider when possible.`,
768
738
  });
769
739
  }
770
- if (parsed.apiKey && !hasApiKeyFile) {
771
- logger.warn('auth.argv_secret_warning', {
772
- message: '[patina] warning: --api-key exposes the secret in shell history and `ps` output.\n' +
773
- ' Prefer PATINA_API_KEY env var, --api-key-file <path>, or PATINA_API_KEY_FILE.',
740
+ if (maxPromptChars >= PROMPT_SIZE_WARNING_CHARS) {
741
+ logger.warn('batch.prompt_size', {
742
+ message: `[patina] largest prompt is ~${maxPromptChars.toLocaleString()} chars; failed attempts still send the full prompt.`,
774
743
  });
775
744
  }
776
- return apiKey;
745
+ }
746
+
747
+ function createBatchCircuitBreaker({ parsed, total }) {
748
+ const active = parsed.batch && total > 1;
749
+ const maxFailures = active
750
+ ? (parsed.maxFailures ?? Math.min(10, Math.max(3, Math.ceil(total * 0.1))))
751
+ : Infinity;
752
+ const maxFailureRate = active ? (parsed.maxFailureRate ?? 0.25) : Infinity;
753
+ const stormEnabled = active && (parsed.stopOnRetryableStorm ?? true);
754
+ const stormLimit = 3;
755
+ const failures = [];
756
+ const retryableBuckets = new Map();
757
+ let successes = 0;
758
+ let processed = 0;
759
+ let stopReason = null;
760
+
761
+ return {
762
+ get failures() {
763
+ return failures;
764
+ },
765
+ get maxFailures() {
766
+ return maxFailures;
767
+ },
768
+ recordSuccess() {
769
+ processed++;
770
+ successes++;
771
+ },
772
+ recordFailure({ path, err }) {
773
+ processed++;
774
+ failures.push({ path, err });
775
+ const bucket = classifyRetryableStorm(err);
776
+ if (bucket) {
777
+ retryableBuckets.set(bucket, (retryableBuckets.get(bucket) || 0) + 1);
778
+ }
779
+ },
780
+ hasFailures() {
781
+ return failures.length > 0;
782
+ },
783
+ shouldStop() {
784
+ if (!active) return false;
785
+ if (failures.length >= maxFailures) {
786
+ stopReason = `max failures reached (${failures.length}/${maxFailures})`;
787
+ return true;
788
+ }
789
+ if (
790
+ Number.isFinite(maxFailureRate) &&
791
+ processed >= (parsed.maxFailureRate === undefined ? Math.min(total, 4) : 1) &&
792
+ failures.length / processed > maxFailureRate
793
+ ) {
794
+ stopReason = `failure rate ${(failures.length / processed * 100).toFixed(1)}% exceeded ${(maxFailureRate * 100).toFixed(1)}%`;
795
+ return true;
796
+ }
797
+ if (stormEnabled) {
798
+ for (const [bucket, count] of retryableBuckets) {
799
+ if (count >= stormLimit) {
800
+ stopReason = `retryable storm detected (${count} × ${bucket})`;
801
+ return true;
802
+ }
803
+ }
804
+ }
805
+ return false;
806
+ },
807
+ toError({ completed = false } = {}) {
808
+ const summary = failures
809
+ .slice(0, 5)
810
+ .map((failure) => `${failure.path}: ${failure.err.message}`)
811
+ .join(' | ');
812
+ const why = stopReason || (completed
813
+ ? `Batch completed with ${failures.length} failed file(s).`
814
+ : `Batch stopped after ${failures.length} failed file(s).`);
815
+ return runtimeError(
816
+ completed ? 'batch completed with failures' : 'batch circuit breaker stopped the run',
817
+ `${why} Successes: ${successes}/${total}. Failures: ${failures.length}/${total}.`,
818
+ summary || 'Fix the backend failure, lower concurrency/retries, or rerun with a smaller batch.'
819
+ );
820
+ },
821
+ };
822
+ }
823
+
824
+ function shouldHandleBatchFailure(parsed, total) {
825
+ return parsed.batch && total > 1;
826
+ }
827
+
828
+ function classifyRetryableStorm(err) {
829
+ const message = String(err?.message || err || '');
830
+ if (/\bHTTP\s+429\b/i.test(message) || err?.status === 429) return 'HTTP 429';
831
+ if (/\bHTTP\s+503\b/i.test(message) || err?.status === 503) return 'HTTP 503';
832
+ if (/Provider stream timed out/i.test(message)) return 'provider stream timeout';
833
+ if (/timed out/i.test(message) || err?.name === 'AbortError') return 'timeout';
834
+ const exit = message.match(/\bexited with code\s+(75|1)\b/i);
835
+ if (exit) return `exit ${exit[1]}`;
836
+ if (/no final response body|empty response|final-message-only/i.test(message)) return 'empty response';
837
+ return null;
838
+ }
839
+
840
+ function formatLimit(value) {
841
+ return Number.isFinite(value) ? String(value) : 'unbounded';
777
842
  }
778
843
 
779
844
  async function loadInputs(parsed, logger = createLogger()) {
@@ -868,6 +933,7 @@ async function writeBatchOutput(parsed, inputPath, output) {
868
933
  console.log(`Written: ${outPath}`);
869
934
  }
870
935
 
936
+
871
937
  function formatOuroborosOutput(result) {
872
938
  let output = '## Ouroboros Iteration Log\n\n';
873
939
  output += '| Iter | Before | After | Improvement | Reason |\n';
@@ -894,33 +960,31 @@ function printHelp() {
894
960
  Usage: patina [command] [options] [file...]
895
961
 
896
962
  COMMANDS
897
- patina init Create a project .patina.yaml
898
963
  patina doctor [--json] Check Node, backends, tmux, and auth setup
899
964
  patina auth status Show backend availability and authentication status
900
965
  patina auth login Print per-backend authentication instructions
966
+ patina auth login <backend> [--yes]
967
+ Launch a backend login flow after confirmation
901
968
 
902
969
  MODES
903
970
  --diff Show changes pattern by pattern
904
971
  --no-color Disable ANSI colors in --diff output
905
972
  --audit Detect patterns only (no rewrite)
906
973
  --score Output AI-likeness score (0-100)
907
- --gate <n> With --score, exit 3 when overall score > n
908
- --exit-on <n> Alias for --gate, intended for CI scripts
974
+ --exit-on <n> With --score, exit 3 when overall score > n
909
975
  --ouroboros Iterative self-improvement loop
910
976
 
911
977
  OUTPUT & BATCH
912
978
  --format <fmt> Output format: markdown (default), text, json
913
- --json Alias for --format json
914
979
  --quiet Suppress patina status/warning logs on stderr
915
- --json-logs Emit stderr logs as NDJSON objects
916
980
  --batch Process multiple files
917
981
  --in-place Overwrite original files (with --batch)
918
982
  --suffix <ext> Save as {name}{ext}{extname}
919
983
  --outdir <dir> Save results to directory
920
- --save-run <dir> Write manifest.json + output-N.txt for reproducibility
921
- --cache <dir> Opt into persistent HTTP response cache
922
- --cache-ttl <sec> Cache TTL in seconds (default: ${DEFAULT_CACHE_TTL_SECONDS})
923
- --no-cache Bypass PATINA_CACHE_DIR / --cache for a fresh run
984
+ --max-failures <n> Stop batch after n failed files
985
+ --max-failure-rate <r> Stop batch when failure ratio exceeds r (0.25 or 25)
986
+ --stop-on-retryable-storm
987
+ Stop batch after repeated 429/timeouts/empty local-CLI exits
924
988
  --no-interactive Do not wait for TTY stdin; exit 2 when no input is given
925
989
 
926
990
  LANGUAGE & PROFILE
@@ -928,32 +992,28 @@ LANGUAGE & PROFILE
928
992
  --profile <name> Profile: default, blog, academic, technical, formal,
929
993
  social, email, legal, medical, marketing,
930
994
  narrative, instructional, casual-conversation,
931
- code-comment, commit-message, release-notes
995
+ code-comment, commit-message, release-notes, namuwiki
932
996
  --tone <name> Tone: casual, professional, academic, narrative,
933
997
  marketing, instructional, auto. Resolution:
934
998
  --tone > config tone > config profile.
935
999
  --voice-sample <path> Use 1-3 user paragraphs as style-only voice anchors
936
1000
 
937
1001
  MODEL & AUTH
938
- --model <id> Single model ID (default: gpt-4o)
939
- --api-key <key> API key (DEPRECATED: leaks via ps/shell history; prefer
940
- PATINA_API_KEY env or --api-key-file)
1002
+ --model <id> Single model ID. Defaults use the strongest
1003
+ documented model per backend: openai/codex gpt-5.5,
1004
+ claude-sonnet-4-6, gemini-2.5-pro,
1005
+ kimi-code/kimi-for-coding.
941
1006
  --api-key-file <path> Read API key from file (recommended)
942
1007
  --base-url <url> API base URL (or PATINA_API_BASE env)
943
1008
  --backend <name[,name]> Backend or explicit fallback chain:
944
1009
  ${backendChoices} (default: openai-http)
945
- --list-backends List available backends and their availability
946
- --provider <name> Provider preset: openai, gemini, groq, together
947
- --list-providers List provider presets and which keys are set
948
- --models <list> MAX mode: comma-separated model list
949
- --max-concurrency <n> Cap parallel MAX-mode requests (default: min(models, 3);
950
- use 0 for unlimited, which can hit free-tier quotas)
951
- --max-timeout <sec> Wall-clock budget for standalone MAX mode (default: 300)
952
-
1010
+ --list-backends List backends, selectors, default models, and auth status
1011
+ --timeout-ms <n> Per-request/backend timeout in milliseconds
1012
+ --max-concurrency <n> Cross-process backend cap (safe defaults per backend)
1013
+ --max-retries <n> Retry budget per backend (local CLIs default to 0)
1014
+ --provider <name> Provider preset: openai, gemini, groq, kimi, moonshot, together
953
1015
  ADVANCED
954
- --variants <n> Generate N rewrite variants (1-5; rewrite mode only)
955
1016
  --config <path> Load config from <path> instead of .patina.default.yaml
956
- --prompt-mode <m> strict | minimal | auto. auto picks per backend.
957
1017
  --allow-insecure-base-url Permit plaintext http:// to non-localhost endpoints
958
1018
  --allow-private-base-url Permit private/IMDS base URLs
959
1019
  -h, --help Show this help message
@@ -962,16 +1022,15 @@ ADVANCED
962
1022
  EXAMPLES
963
1023
  echo "This is a draft." | patina --lang en --backend codex-cli
964
1024
  patina --score --exit-on 30 --format json draft.md
965
- patina init --defaults
966
1025
  patina doctor --json
967
1026
 
968
1027
  ENVIRONMENT
969
1028
  PATINA_API_KEY, PATINA_API_KEY_FILE, PATINA_API_BASE, PATINA_MODEL
970
- PATINA_CACHE_DIR, PATINA_CACHE_TTL_SECONDS
971
- OPENAI_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, TOGETHER_API_KEY
1029
+ OPENAI_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, TOGETHER_API_KEY,
1030
+ KIMI_API_KEY, MOONSHOT_API_KEY
972
1031
 
973
1032
  EXIT CODES
974
- 0 success · 1 runtime/backend · 2 input/usage · 3 score gate exceeded · 4 MAX MPS fallback/all candidates failed · 130 interrupted
1033
+ 0 success · 1 runtime/backend · 2 input/usage · 3 score gate exceeded · 130 interrupted
975
1034
 
976
1035
  If no API key is set, pass --backend codex-cli to use a logged-in codex CLI
977
1036
  (no key required). Auto-fallback was removed in v3.9 to keep agent-mode
@@ -1001,144 +1060,12 @@ function withDeterministicScore(rawResult, { text, config, repoRoot, logger }) {
1001
1060
  };
1002
1061
  }
1003
1062
 
1004
- function manifestScoreDetails(result) {
1005
- if (!result || typeof result !== 'object') return null;
1006
- if (!result.llmScore && !result.deterministicScore && !result.scorePreference) return null;
1007
- return {
1008
- llm: result.llmScore ?? null,
1009
- deterministic: result.deterministicScore ?? null,
1010
- preference: result.scorePreference ?? null,
1011
- };
1012
- }
1013
-
1014
- function resolveResponseCache(parsed) {
1015
- if (parsed.noCache) return null;
1016
- const dir = parsed.cacheDir ?? process.env.PATINA_CACHE_DIR;
1017
- if (!dir) return null;
1018
1063
 
1019
- const ttlSeconds =
1020
- parsed.cacheTtlSeconds ??
1021
- parseOptionalPositiveNumber(process.env.PATINA_CACHE_TTL_SECONDS, 'PATINA_CACHE_TTL_SECONDS') ??
1022
- DEFAULT_CACHE_TTL_SECONDS;
1023
-
1024
- return createResponseCache({
1025
- dir: resolve(process.cwd(), dir),
1026
- ttlSeconds,
1027
- });
1028
- }
1029
-
1030
- function parseOptionalPositiveNumber(value, name) {
1031
- if (value === undefined || value === null || value === '') return null;
1032
- const n = Number(value);
1033
- if (!Number.isFinite(n) || n <= 0) {
1034
- throw inputError(
1035
- `${name} expects a positive number of seconds`,
1036
- `Received "${value}".`,
1037
- `Set ${name}=86400 or omit it for the default.`
1038
- );
1039
- }
1040
- return n;
1041
- }
1042
-
1043
- function formatCacheStats(stats) {
1044
- const expired = stats.expired ? `, expired ${stats.expired}` : '';
1045
- const errors = stats.errors ? `, errors ${stats.errors}` : '';
1046
- return `[patina] cache hits ${stats.hits}, misses ${stats.misses}, writes ${stats.writes}${expired}${errors}`;
1047
- }
1048
-
1049
- function createManifestCallRecorder(calls) {
1050
- return (metadata = {}) => {
1051
- calls.push({
1052
- provider: metadata.provider ?? null,
1053
- model: metadata.model ?? null,
1054
- requestedModel: metadata.requestedModel ?? null,
1055
- temperature: metadata.temperature ?? null,
1056
- seed: metadata.seed ?? null,
1057
- responseHash: hashSha256(metadata.content),
1058
- tokensIn: extractUsageToken(metadata.usage, ['prompt_tokens', 'input_tokens', 'tokens_in']),
1059
- tokensOut: extractUsageToken(metadata.usage, ['completion_tokens', 'output_tokens', 'tokens_out']),
1060
- cost: extractResponseCost(metadata.rawResponse, metadata.usage),
1061
- cache: metadata.cache ?? null,
1062
- });
1063
- };
1064
- }
1065
-
1066
- function extractUsageToken(usage, keys) {
1067
- if (!usage || typeof usage !== 'object') return null;
1068
- for (const key of keys) {
1069
- const value = Number(usage[key]);
1070
- if (Number.isFinite(value)) return value;
1071
- }
1072
- return null;
1073
- }
1074
-
1075
- function extractResponseCost(rawResponse, fallbackUsage) {
1076
- const usage = rawResponse?.usage && typeof rawResponse.usage === 'object'
1077
- ? rawResponse.usage
1078
- : (fallbackUsage && typeof fallbackUsage === 'object' ? fallbackUsage : {});
1079
- const candidates = [
1080
- ['usage.cost_usd', usage.cost_usd, 'USD'],
1081
- ['usage.total_cost_usd', usage.total_cost_usd, 'USD'],
1082
- ['usage.cost', usage.cost, usage.currency],
1083
- ['usage.total_cost', usage.total_cost, usage.currency],
1084
- ['cost_usd', rawResponse?.cost_usd, 'USD'],
1085
- ['cost', rawResponse?.cost, rawResponse?.currency],
1086
- ];
1087
-
1088
- for (const [source, value, currency] of candidates) {
1089
- const amount = Number(value);
1090
- if (Number.isFinite(amount)) {
1091
- return {
1092
- amount,
1093
- currency: currency || 'USD',
1094
- source,
1095
- };
1096
- }
1097
- }
1098
- return null;
1099
- }
1100
-
1101
- function manifestResponseText(result, output) {
1102
- if (result?.type === 'max-mode') return result.best?.result ?? output;
1103
- if (typeof result?.finalText === 'string') return result.finalText;
1104
- if (typeof result?.raw === 'string') return result.raw;
1105
- if (typeof result === 'string') return result;
1106
- return output;
1107
- }
1108
-
1109
- function manifestIterationLog(result) {
1110
- return Array.isArray(result?.log) ? result.log : null;
1111
- }
1112
-
1113
- function sumManifestCalls(calls, key) {
1114
- const values = calls
1115
- .map((call) => call[key])
1116
- .filter((value) => value !== null && value !== undefined)
1117
- .map((value) => Number(value))
1118
- .filter((value) => Number.isFinite(value));
1119
- if (values.length === 0) return null;
1120
- return values.reduce((sum, value) => sum + value, 0);
1121
- }
1122
-
1123
- function sumManifestCallCost(calls) {
1124
- const costs = calls
1125
- .map((call) => call.cost)
1126
- .filter((cost) => cost && Number.isFinite(Number(cost.amount)));
1127
- if (costs.length === 0) return null;
1128
-
1129
- const currency = costs[0].currency || 'USD';
1130
- if (!costs.every((cost) => (cost.currency || 'USD') === currency)) return null;
1131
- return {
1132
- amount: costs.reduce((sum, cost) => sum + Number(cost.amount), 0),
1133
- currency,
1134
- source: 'sum',
1135
- };
1136
- }
1137
1064
 
1138
1065
  function applyScoreGate(result, output, gate, logger = createLogger()) {
1139
1066
  const overall = extractScoreOverall(result, output);
1140
1067
  if (overall === null) {
1141
- throw new Error('--gate could not find a numeric `overall` value in --score output.');
1068
+ throw new Error('score gate could not find a numeric `overall` value in --score output.');
1142
1069
  }
1143
1070
  if (overall > gate) {
1144
1071
  logger.warn('score.gate_failed', { message: `[patina] score gate failed: overall ${overall} > ${gate}` });
@@ -1189,71 +1116,63 @@ function printBackendStatus() {
1189
1116
  const list = listBackends();
1190
1117
  const rows = list.map((b) => ({
1191
1118
  name: b.name,
1119
+ kind: b.kind,
1120
+ selectWith: b.selectWith,
1121
+ defaultModel: b.defaultModel || '-',
1192
1122
  available: b.available ? 'yes' : 'no',
1193
1123
  authenticated: b.authenticated ? 'yes' : 'no',
1194
- note: b.authenticated ? '' : b.authHint,
1124
+ note: `${backendSafetyNote(b)} ${backendStatusNote(b)}`.trim(),
1195
1125
  }));
1196
1126
  const widths = {
1197
1127
  name: Math.max('Backend'.length, ...rows.map((r) => r.name.length)),
1128
+ kind: Math.max('Kind'.length, ...rows.map((r) => r.kind.length)),
1129
+ selectWith: Math.max('Select with'.length, ...rows.map((r) => r.selectWith.length)),
1130
+ defaultModel: Math.max('Default model'.length, ...rows.map((r) => r.defaultModel.length)),
1198
1131
  available: Math.max('Available'.length, ...rows.map((r) => r.available.length)),
1199
1132
  authenticated: Math.max('Authenticated'.length, ...rows.map((r) => r.authenticated.length)),
1200
1133
  };
1201
1134
  const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1202
1135
  console.log(
1203
- `${pad('Backend', widths.name)} ${pad('Available', widths.available)} ${pad('Authenticated', widths.authenticated)} Notes`
1136
+ `${pad('Backend', widths.name)} ${pad('Kind', widths.kind)} ${pad('Select with', widths.selectWith)} ${pad('Default model', widths.defaultModel)} ${pad('Available', widths.available)} ${pad('Authenticated', widths.authenticated)} Notes`
1204
1137
  );
1205
1138
  console.log(
1206
- `${'-'.repeat(widths.name)} ${'-'.repeat(widths.available)} ${'-'.repeat(widths.authenticated)} -----`
1139
+ `${'-'.repeat(widths.name)} ${'-'.repeat(widths.kind)} ${'-'.repeat(widths.selectWith)} ${'-'.repeat(widths.defaultModel)} ${'-'.repeat(widths.available)} ${'-'.repeat(widths.authenticated)} -----`
1207
1140
  );
1208
1141
  for (const r of rows) {
1209
1142
  console.log(
1210
- `${pad(r.name, widths.name)} ${pad(r.available, widths.available)} ${pad(r.authenticated, widths.authenticated)} ${r.note}`
1143
+ `${pad(r.name, widths.name)} ${pad(r.kind, widths.kind)} ${pad(r.selectWith, widths.selectWith)} ${pad(r.defaultModel, widths.defaultModel)} ${pad(r.available, widths.available)} ${pad(r.authenticated, widths.authenticated)} ${r.note}`
1211
1144
  );
1212
1145
  }
1213
1146
  }
1214
1147
 
1215
- function printProviderStatus() {
1216
- const rows = Object.values(PROVIDERS).map((p) => ({
1217
- name: p.name,
1218
- free: p.freeTier ? 'yes' : 'no',
1219
- keySource: providerKeySource(p),
1220
- providerEnv: process.env[p.apiKeyEnv] ? 'set' : 'missing',
1221
- note: `${p.apiKeyEnv} → ${p.baseURL}`,
1222
- }));
1223
- const widths = {
1224
- name: Math.max('Provider'.length, ...rows.map((r) => r.name.length)),
1225
- free: Math.max('Free tier'.length, ...rows.map((r) => r.free.length)),
1226
- keySource: Math.max('Key source'.length, ...rows.map((r) => r.keySource.length)),
1227
- providerEnv: Math.max('Provider env'.length, ...rows.map((r) => r.providerEnv.length)),
1228
- };
1229
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1230
- console.log(
1231
- `${pad('Provider', widths.name)} ${pad('Free tier', widths.free)} ${pad('Key source', widths.keySource)} ${pad('Provider env', widths.providerEnv)} Notes`
1232
- );
1233
- console.log(
1234
- `${'-'.repeat(widths.name)} ${'-'.repeat(widths.free)} ${'-'.repeat(widths.keySource)} ${'-'.repeat(widths.providerEnv)} -----`
1235
- );
1236
- for (const r of rows) {
1237
- console.log(
1238
- `${pad(r.name, widths.name)} ${pad(r.free, widths.free)} ${pad(r.keySource, widths.keySource)} ${pad(r.providerEnv, widths.providerEnv)} ${r.note}`
1239
- );
1148
+ function backendStatusNote(backend) {
1149
+ if (!backend.available) return backend.installHint || backend.authHint;
1150
+ if (backend.name === 'openai-http') return backend.authHint;
1151
+ if (backend.authenticated && backend.name === 'gemini-cli' && backend.authHint.startsWith('Authenticated via ')) {
1152
+ return backend.authHint;
1240
1153
  }
1154
+ if (backend.authenticated) return 'ready';
1155
+ if (backend.loginCommand) return `${backend.authHint} Use \`patina auth login ${backend.name}\` for the guided flow.`;
1156
+ return backend.authHint;
1241
1157
  }
1242
1158
 
1243
- function providerKeySource(provider) {
1244
- const source = inspectHttpApiKeySource({
1245
- envVars: providerHttpKeyEnvVars(provider.apiKeyEnv),
1246
- });
1247
- return source.ok ? source.source : 'missing';
1159
+ function backendSafetyNote(backend) {
1160
+ return `cap=${formatLimit(backend.maxConcurrency)}, retries=${backend.maxRetries}, prompt=${backend.promptMode};`;
1248
1161
  }
1249
1162
 
1250
- function handleAuth(subArgs) {
1163
+ async function handleAuth(subArgs) {
1251
1164
  const sub = subArgs[0] || 'status';
1252
1165
  if (sub === 'status') {
1253
1166
  printBackendStatus();
1254
1167
  return;
1255
1168
  }
1256
1169
  if (sub === 'login') {
1170
+ const parsed = parseAuthLoginArgs(subArgs.slice(1));
1171
+ if (parsed.backendName) {
1172
+ await runAuthLogin(parsed);
1173
+ return;
1174
+ }
1175
+
1257
1176
  console.log('To authenticate a backend, follow the per-backend instructions:\n');
1258
1177
  for (const b of listBackends()) {
1259
1178
  const status = b.authenticated ? '✓ already authenticated' : '✗ not authenticated';
@@ -1265,10 +1184,101 @@ function handleAuth(subArgs) {
1265
1184
  throw inputError(
1266
1185
  `unknown auth subcommand ${sub}`,
1267
1186
  'Supported auth subcommands are status and login.',
1268
- 'Try `patina auth status` or `patina auth login`.'
1187
+ 'Try `patina auth status`, `patina auth login`, or `patina auth login codex-cli`.'
1269
1188
  );
1270
1189
  }
1271
1190
 
1191
+ function parseAuthLoginArgs(args) {
1192
+ let assumeYes = false;
1193
+ let backendName = null;
1194
+
1195
+ for (const arg of args) {
1196
+ if (arg === '--yes' || arg === '-y') {
1197
+ assumeYes = true;
1198
+ continue;
1199
+ }
1200
+ if (arg.startsWith('-')) {
1201
+ throw inputError(
1202
+ `unknown auth login option ${arg}`,
1203
+ 'Only --yes/-y is supported for non-interactive confirmation.',
1204
+ 'Run `patina auth login <backend> --yes` or omit --yes to confirm interactively.'
1205
+ );
1206
+ }
1207
+ if (backendName) {
1208
+ throw inputError(
1209
+ 'auth login expects at most one backend',
1210
+ `Received both ${backendName} and ${arg}.`,
1211
+ `Available backends are: ${listBackendNames().join(', ')}.`
1212
+ );
1213
+ }
1214
+ backendName = arg;
1215
+ }
1216
+
1217
+ return { backendName, assumeYes };
1218
+ }
1219
+
1220
+ async function runAuthLogin({ backendName, assumeYes }) {
1221
+ const backend = resolveBackend(backendName);
1222
+ if (typeof backend.login !== 'function') {
1223
+ throw inputError(
1224
+ `${backend.name} does not support interactive login`,
1225
+ backend.authHint ? backend.authHint() : 'This backend authenticates outside local CLI OAuth.',
1226
+ 'Set PATINA_API_KEY, PATINA_API_KEY_FILE, or the provider-specific API key env var for HTTP backends.'
1227
+ );
1228
+ }
1229
+
1230
+ if (!backend.isAvailable()) {
1231
+ throw runtimeError(
1232
+ `${backend.name} CLI is not installed or not on PATH`,
1233
+ backend.installHint || backend.authHint(),
1234
+ 'Install the CLI named above, then rerun this command.'
1235
+ );
1236
+ }
1237
+
1238
+ const commandLabel = backend.loginCommand || backend.name;
1239
+ const confirmed = await confirmAuthLogin(commandLabel, { assumeYes });
1240
+ if (!confirmed) {
1241
+ console.log('Cancelled.');
1242
+ return;
1243
+ }
1244
+
1245
+ const wasAuthenticated = backend.isAuthenticated();
1246
+ await backend.login();
1247
+ const authenticated = backend.isAuthenticated();
1248
+
1249
+ if (authenticated) {
1250
+ console.log(`${backend.name}: authenticated.`);
1251
+ } else if (wasAuthenticated) {
1252
+ console.log(`${backend.name}: login command completed; previous authentication is still present.`);
1253
+ } else {
1254
+ console.log(`${backend.name}: login command completed, but patina could not confirm authentication yet.`);
1255
+ console.log(`→ ${backend.authHint()}`);
1256
+ }
1257
+ }
1258
+
1259
+ async function confirmAuthLogin(commandLabel, { assumeYes = false } = {}) {
1260
+ if (assumeYes) {
1261
+ console.log(`Run ${commandLabel}? yes (--yes)`);
1262
+ return true;
1263
+ }
1264
+
1265
+ if (!process.stdin.isTTY) {
1266
+ throw inputError(
1267
+ `cannot confirm ${commandLabel} in a non-interactive session`,
1268
+ 'patina will not launch an interactive OAuth flow without explicit confirmation.',
1269
+ `Rerun with \`patina auth login <backend> --yes\` if you intentionally want to start ${commandLabel}.`
1270
+ );
1271
+ }
1272
+
1273
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1274
+ try {
1275
+ const answer = await rl.question(`Run ${commandLabel}? [Y/n] `);
1276
+ return answer.trim() === '' || /^y(es)?$/i.test(answer.trim());
1277
+ } finally {
1278
+ rl.close();
1279
+ }
1280
+ }
1281
+
1272
1282
  // Self-invocation guard (#113): when run directly via `node src/cli.js ...`,
1273
1283
  // run main(). When imported (e.g. by bin/patina.js or tests), just expose
1274
1284
  // the exports.