patina-cli 3.11.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 (180) hide show
  1. package/.patina.default.yaml +211 -0
  2. package/CHANGELOG.md +265 -0
  3. package/LICENSE +21 -0
  4. package/README.md +319 -0
  5. package/README_JA.md +254 -0
  6. package/README_KR.md +253 -0
  7. package/README_ZH.md +254 -0
  8. package/SKILL-MAX.md +455 -0
  9. package/SKILL.md +730 -0
  10. package/assets/brand/patina-icon.svg +9 -0
  11. package/assets/brand/patina-logo.svg +17 -0
  12. package/assets/social/patina-before-after.svg +46 -0
  13. package/assets/social/patina-og.svg +31 -0
  14. package/bin/patina.js +9 -0
  15. package/core/scoring.md +657 -0
  16. package/core/standalone-prompt.md +364 -0
  17. package/core/stylometry.md +754 -0
  18. package/core/voice.md +163 -0
  19. package/docs/AUTHENTICATION.md +105 -0
  20. package/docs/AUTHENTICATION_KR.md +105 -0
  21. package/docs/BRANDING.md +37 -0
  22. package/docs/CLI.md +80 -0
  23. package/docs/COMPARISON.md +38 -0
  24. package/docs/COOKBOOK.md +173 -0
  25. package/docs/DEMO.md +40 -0
  26. package/docs/ETHICS.md +27 -0
  27. package/docs/EXAMPLES.md +130 -0
  28. package/docs/EXAMPLES_KR.md +130 -0
  29. package/docs/EXIT-CODES.md +25 -0
  30. package/docs/FAQ.md +67 -0
  31. package/docs/FAQ_KR.md +65 -0
  32. package/docs/FLAG-PARITY.md +53 -0
  33. package/docs/GLOSSARY.md +123 -0
  34. package/docs/PATTERNS-EN.md +718 -0
  35. package/docs/PATTERNS-JA.md +706 -0
  36. package/docs/PATTERNS-KO.md +707 -0
  37. package/docs/PATTERNS-ZH.md +706 -0
  38. package/docs/PATTERNS.md +22 -0
  39. package/docs/ROADMAP.md +315 -0
  40. package/docs/audits/2026-05-deep-research.md +290 -0
  41. package/docs/benchmarks/detector-comparison.json +442 -0
  42. package/docs/benchmarks/detector-comparison.md +65 -0
  43. package/docs/benchmarks/latest.json +988 -0
  44. package/docs/benchmarks/latest.md +112 -0
  45. package/docs/integrations/docker.md +19 -0
  46. package/docs/integrations/github-action.md +59 -0
  47. package/docs/integrations/pre-commit.md +77 -0
  48. package/docs/integrations/release.md +43 -0
  49. package/docs/internal/HARNESS.md +14 -0
  50. package/docs/internal/README.md +14 -0
  51. package/docs/internal/WARP.md +23 -0
  52. package/docs/research/2025-rebaseline-plan.md +89 -0
  53. package/docs/research/ai-human-metrics.md +380 -0
  54. package/docs/social/gstack-cardnews.html +236 -0
  55. package/docs/social/gstack-cardnews.md +88 -0
  56. package/docs/social/gstack-thread.md +106 -0
  57. package/docs/social/patina-launch-copy.md +227 -0
  58. package/docs/superpowers/specs/2026-04-03-meaning-preservation-design.md +299 -0
  59. package/lexicon/ai-en.md +162 -0
  60. package/lexicon/ai-ko.md +159 -0
  61. package/package.json +100 -0
  62. package/patina-max/SKILL.md +523 -0
  63. package/patina-max/composite.py +457 -0
  64. package/patterns/en-communication.md +89 -0
  65. package/patterns/en-content.md +133 -0
  66. package/patterns/en-filler.md +113 -0
  67. package/patterns/en-language.md +163 -0
  68. package/patterns/en-structure.md +173 -0
  69. package/patterns/en-style.md +139 -0
  70. package/patterns/en-viral-hook.md +211 -0
  71. package/patterns/ja-communication.md +101 -0
  72. package/patterns/ja-content.md +153 -0
  73. package/patterns/ja-filler.md +123 -0
  74. package/patterns/ja-language.md +190 -0
  75. package/patterns/ja-structure.md +142 -0
  76. package/patterns/ja-style.md +147 -0
  77. package/patterns/ja-viral-hook.md +216 -0
  78. package/patterns/ko-communication.md +98 -0
  79. package/patterns/ko-content.md +154 -0
  80. package/patterns/ko-filler.md +105 -0
  81. package/patterns/ko-language.md +182 -0
  82. package/patterns/ko-structure.md +147 -0
  83. package/patterns/ko-style.md +146 -0
  84. package/patterns/ko-viral-hook.md +211 -0
  85. package/patterns/zh-communication.md +101 -0
  86. package/patterns/zh-content.md +153 -0
  87. package/patterns/zh-filler.md +118 -0
  88. package/patterns/zh-language.md +173 -0
  89. package/patterns/zh-structure.md +145 -0
  90. package/patterns/zh-style.md +159 -0
  91. package/patterns/zh-viral-hook.md +216 -0
  92. package/profiles/academic.md +53 -0
  93. package/profiles/blog.md +81 -0
  94. package/profiles/casual-conversation.md +105 -0
  95. package/profiles/code-comment.md +104 -0
  96. package/profiles/commit-message.md +99 -0
  97. package/profiles/default.md +62 -0
  98. package/profiles/email.md +52 -0
  99. package/profiles/formal.md +98 -0
  100. package/profiles/instructional.md +80 -0
  101. package/profiles/legal.md +57 -0
  102. package/profiles/marketing.md +56 -0
  103. package/profiles/medical.md +53 -0
  104. package/profiles/narrative.md +79 -0
  105. package/profiles/release-notes.md +98 -0
  106. package/profiles/social.md +56 -0
  107. package/profiles/technical.md +53 -0
  108. package/scripts/benchmark-report.mjs +252 -0
  109. package/scripts/check-release-metadata.mjs +48 -0
  110. package/scripts/detector-comparison.mjs +267 -0
  111. package/scripts/lint.mjs +40 -0
  112. package/scripts/precommit-score.mjs +31 -0
  113. package/scripts/prose-score.mjs +186 -0
  114. package/scripts/update-benchmark-ranges.mjs +108 -0
  115. package/src/api.js +330 -0
  116. package/src/auth.js +105 -0
  117. package/src/backends/claude-cli.js +112 -0
  118. package/src/backends/codex-cli.js +121 -0
  119. package/src/backends/contract.js +21 -0
  120. package/src/backends/gemini-cli.js +135 -0
  121. package/src/backends/index.js +159 -0
  122. package/src/cache.js +106 -0
  123. package/src/cli.js +1280 -0
  124. package/src/commands/doctor.js +229 -0
  125. package/src/commands/init.js +208 -0
  126. package/src/config.js +126 -0
  127. package/src/errors.js +53 -0
  128. package/src/features/index.js +96 -0
  129. package/src/features/lexicon.js +90 -0
  130. package/src/features/segment.js +49 -0
  131. package/src/features/stylometry.js +50 -0
  132. package/src/loader.js +103 -0
  133. package/src/logger.js +70 -0
  134. package/src/manifest.js +162 -0
  135. package/src/max-mode.js +207 -0
  136. package/src/ouroboros.js +233 -0
  137. package/src/output.js +480 -0
  138. package/src/prompt-builder.js +409 -0
  139. package/src/providers.js +100 -0
  140. package/src/scoring.js +531 -0
  141. package/src/security.js +133 -0
  142. package/tests/fixtures/suspect-zones/en/ai/en-ai-01.md +16 -0
  143. package/tests/fixtures/suspect-zones/en/ai/en-ai-02.md +16 -0
  144. package/tests/fixtures/suspect-zones/en/ai/en-ai-03.md +17 -0
  145. package/tests/fixtures/suspect-zones/en/ai/en-ai-04.md +15 -0
  146. package/tests/fixtures/suspect-zones/en/ai/en-ai-05.md +16 -0
  147. package/tests/fixtures/suspect-zones/en/ai/en-ai-06-chat-register.md +16 -0
  148. package/tests/fixtures/suspect-zones/en/natural/en-nat-01.md +15 -0
  149. package/tests/fixtures/suspect-zones/en/natural/en-nat-02.md +15 -0
  150. package/tests/fixtures/suspect-zones/en/natural/en-nat-03.md +15 -0
  151. package/tests/fixtures/suspect-zones/en/natural/en-nat-04.md +15 -0
  152. package/tests/fixtures/suspect-zones/en/natural/en-nat-05.md +15 -0
  153. package/tests/fixtures/suspect-zones/expected-ranges.json +939 -0
  154. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-01.md +11 -0
  155. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-02.md +11 -0
  156. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-03.md +11 -0
  157. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-01.md +11 -0
  158. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-02.md +11 -0
  159. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-03.md +11 -0
  160. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-01.md +14 -0
  161. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +16 -0
  162. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-03.md +15 -0
  163. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-04.md +15 -0
  164. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-05.md +16 -0
  165. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-06-chat-register.md +16 -0
  166. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-01.md +15 -0
  167. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-02.md +15 -0
  168. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-03.md +15 -0
  169. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-04.md +14 -0
  170. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-05.md +15 -0
  171. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-01.md +11 -0
  172. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-02.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-03.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-01.md +11 -0
  175. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-02.md +11 -0
  176. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-03.md +11 -0
  177. package/tests/quality/README.md +121 -0
  178. package/tests/quality/benchmark.mjs +306 -0
  179. package/tests/quality/detectors.manual.example.json +31 -0
  180. package/tests/quality/dogfood.mjs +44 -0
package/src/cli.js ADDED
@@ -0,0 +1,1280 @@
1
+ import { loadConfig, getRepoRoot, resolveTone } from './config.js';
2
+ import {
3
+ loadPatterns,
4
+ loadProfile,
5
+ loadCoreFile,
6
+ loadInputText,
7
+ loadVoiceSample,
8
+ toneToBackboneProfile,
9
+ } from './loader.js';
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';
13
+ import { validateBaseURL, applyInsecureBaseURLOptIn, applyPrivateBaseURLOptIn } from './security.js';
14
+ import { formatOutput, validateScoreWeights } from './output.js';
15
+ import { runMaxMode } from './max-mode.js';
16
+ import { runOuroboros } from './ouroboros.js';
17
+ 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
+ import { runDoctor } from './commands/doctor.js';
22
+ import { runInit } from './commands/init.js';
23
+ import { PatinaCliError, inputError, runtimeError, renderCliError, getExitCode } from './errors.js';
24
+ import { inspectHttpApiKeySource, providerHttpKeyEnvVars, resolveHttpApiKey } from './auth.js';
25
+ import { createLogger } from './logger.js';
26
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
27
+ import { resolve, basename, extname } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const PACKAGE_VERSION = JSON.parse(
31
+ readFileSync(resolve(getRepoRoot(), 'package.json'), 'utf8')
32
+ ).version;
33
+
34
+ export async function main(args) {
35
+ if (args[0] === 'auth') {
36
+ return handleAuth(args.slice(1));
37
+ }
38
+ if (args[0] === 'doctor') {
39
+ return runDoctor(args.slice(1), { version: PACKAGE_VERSION });
40
+ }
41
+ if (args[0] === 'init') {
42
+ return runInit(args.slice(1));
43
+ }
44
+ if (args[0] === 'help') {
45
+ printHelp();
46
+ return;
47
+ }
48
+
49
+ const parsed = parseArgs(args);
50
+ const logger = createLogger({ quiet: parsed.quiet, json: parsed.jsonLogs });
51
+
52
+ if (parsed.help) {
53
+ printHelp();
54
+ return;
55
+ }
56
+
57
+ if (parsed.version) {
58
+ console.log(`patina ${PACKAGE_VERSION}`);
59
+ return;
60
+ }
61
+
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
+
70
+ if (parsed.gate !== undefined && !parsed.score) {
71
+ throw inputError(
72
+ `${parsed.gateOption || '--gate'} can only be used with --score`,
73
+ 'Score gates need a parsed overall score.',
74
+ 'Run `patina --score --exit-on 30 <file>`.'
75
+ );
76
+ }
77
+
78
+ if (parsed.listBackends) {
79
+ printBackendStatus();
80
+ return;
81
+ }
82
+
83
+ if (parsed.listProviders) {
84
+ printProviderStatus();
85
+ return;
86
+ }
87
+
88
+ const configPath = parsed.config ? resolve(process.cwd(), parsed.config) : undefined;
89
+ const config = loadConfig(configPath);
90
+
91
+ if (parsed.lang) config.language = parsed.lang;
92
+ if (parsed.profile) config.profile = parsed.profile;
93
+
94
+ const provider = selectProvider(parsed.provider ?? config.provider);
95
+ const apiKey = resolveApiKey(parsed, provider, logger);
96
+ const resolved = resolveProviderConfig({
97
+ provider,
98
+ apiKey,
99
+ baseURL: parsed.baseURL ?? config.baseURL ?? config['base-url'],
100
+ model: parsed.model ?? config.model,
101
+ });
102
+ applyInsecureBaseURLOptIn(parsed);
103
+ applyPrivateBaseURLOptIn(parsed);
104
+ validateBaseURL(resolved.baseURL);
105
+
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
+
113
+ const repoRoot = getRepoRoot();
114
+ const lang = config.language || 'ko';
115
+
116
+ // Tone resolution (v3.10): CLI --tone > config tone > null.
117
+ // null + config.profile → profile-only mode (regression-safe; v3.9 behavior).
118
+ // zh/ja + explicit tone → unsupported_language_fallback (warn + profile-only path).
119
+ const toneResolution = resolveTone({ cliTone: parsed.tone, configTone: config.tone, lang });
120
+ if (toneResolution.warning) {
121
+ logger.warn('tone.warning', { message: `[patina] ${toneResolution.warning}` });
122
+ }
123
+
124
+ // Backbone profile mapping: explicit user tone (not auto, not fallback) maps to a
125
+ // backbone profile. CLI --profile still wins on conflict (per Phase 1 spec — backbone
126
+ // is applied "after --profile override"). Only auto-map when user didn't specify --profile.
127
+ let profileName = config.profile || 'default';
128
+ if (
129
+ !parsed.profile &&
130
+ toneResolution.tone_source === 'user' &&
131
+ toneResolution.tone &&
132
+ toneResolution.tone !== 'auto'
133
+ ) {
134
+ const backbone = toneToBackboneProfile(toneResolution.tone);
135
+ if (backbone) profileName = backbone;
136
+ }
137
+
138
+ const patterns = loadPatterns(repoRoot, lang, config['skip-patterns'] || []);
139
+ const profile = loadProfile(repoRoot, profileName);
140
+ const voice = loadCoreFile(repoRoot, 'voice.md');
141
+ const scoring = loadCoreFile(repoRoot, 'scoring.md');
142
+ const mode = parsed.diff ? 'diff'
143
+ : parsed.audit ? 'audit'
144
+ : parsed.score ? 'score'
145
+ : parsed.ouroboros ? 'ouroboros'
146
+ : 'rewrite';
147
+ const voiceSamplePath = (mode === 'rewrite' || mode === 'ouroboros')
148
+ ? (parsed.voiceSample ?? config['voice-sample'])
149
+ : null;
150
+ const voiceSample = voiceSamplePath
151
+ ? loadVoiceSample(resolve(process.cwd(), voiceSamplePath))
152
+ : null;
153
+ if (voiceSample?.truncated) {
154
+ logger.warn('voice_sample.truncated', {
155
+ message: '[patina] voice sample has more than 3 paragraphs; using the first 3 as anchors',
156
+ });
157
+ }
158
+
159
+ const inputTexts = await loadInputs(parsed, logger);
160
+ const cancellation = createCancellationController({ logger });
161
+
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,
193
+ });
194
+
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];
234
+
235
+ if (autoSelected) {
236
+ logger.info('backend.selected', {
237
+ message: `[patina] Using ${backend.name} backend (${reason}). Run \`patina auth status\` for details.`,
238
+ });
239
+ }
240
+ if (backends.length > 1) {
241
+ logger.info('backend.chain', {
242
+ message: `[patina] Backend fallback chain: ${backends.map((b) => b.name).join(' → ')}`,
243
+ });
244
+ }
245
+
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`.');
258
+ }
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
+ }
265
+
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 });
299
+ if (mode === 'score') {
300
+ scoreValidationOutput = formatOutput(result, mode, { ...parsed, format: 'markdown' }, { logger });
301
+ }
302
+ }
303
+
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}` });
311
+ }
312
+
313
+ if (parsed.gate !== undefined) {
314
+ applyScoreGate(result, output, parsed.gate, logger);
315
+ }
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,
338
+ });
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);
346
+ }
347
+ }
348
+
349
+ if (responseCache) {
350
+ logger.info('cache.stats', { message: formatCacheStats(responseCache.stats) });
351
+ }
352
+ } catch (err) {
353
+ if (cancellation.signal.aborted) throw cancellationError();
354
+ throw err;
355
+ } finally {
356
+ cancellation.cleanup();
357
+ logger.closeProgress();
358
+ }
359
+
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
+ }
385
+
386
+ function parseArgs(args) {
387
+ const parsed = {
388
+ files: [],
389
+ format: 'markdown',
390
+ };
391
+
392
+ for (let i = 0; i < args.length; i++) {
393
+ const arg = args[i];
394
+ switch (arg) {
395
+ case '--help':
396
+ case '-h':
397
+ parsed.help = true;
398
+ break;
399
+ case '--version':
400
+ case '-v':
401
+ parsed.version = true;
402
+ break;
403
+ case '--lang':
404
+ parsed.lang = readOptionValue(args, i, arg);
405
+ i++;
406
+ break;
407
+ case '--profile':
408
+ parsed.profile = readOptionValue(args, i, arg);
409
+ i++;
410
+ break;
411
+ case '--tone': {
412
+ const t = readOptionValue(args, i, arg);
413
+ i++;
414
+ const valid = ['casual', 'professional', 'academic', 'narrative', 'marketing', 'instructional', 'auto'];
415
+ if (!valid.includes(t)) {
416
+ throw inputError(
417
+ `unknown tone ${t}`,
418
+ `Valid tones are: ${valid.join(', ')}.`,
419
+ 'Use `--tone auto` to let patina infer tone from the text.'
420
+ );
421
+ }
422
+ parsed.tone = t;
423
+ break;
424
+ }
425
+ case '--voice-sample':
426
+ parsed.voiceSample = readOptionValue(args, i, arg);
427
+ i++;
428
+ break;
429
+ case '--diff':
430
+ parsed.diff = true;
431
+ break;
432
+ case '--no-color':
433
+ parsed.noColor = true;
434
+ break;
435
+ case '--audit':
436
+ parsed.audit = true;
437
+ break;
438
+ case '--score':
439
+ parsed.score = true;
440
+ break;
441
+ case '--format': {
442
+ const value = readOptionValue(args, i, arg);
443
+ i++;
444
+ if (!['json', 'text', 'markdown'].includes(value)) {
445
+ throw inputError(
446
+ '--format expects json, text, or markdown',
447
+ `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
448
+ 'Use `--format json`, `--format text`, or `--format markdown`.'
449
+ );
450
+ }
451
+ parsed.format = value;
452
+ break;
453
+ }
454
+ case '--json':
455
+ parsed.format = 'json';
456
+ break;
457
+ case '--quiet':
458
+ parsed.quiet = true;
459
+ 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
+ case '--exit-on': {
479
+ const value = readOptionValue(args, i, arg, { allowFlagLike: true });
480
+ i++;
481
+ const n = Number(value);
482
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
483
+ throw inputError(
484
+ '--exit-on expects a number from 0 to 100',
485
+ `Received ${value === undefined ? 'no value' : `"${value}"`}.`,
486
+ 'Use `patina --score --exit-on 30 <file>` for CI gates.'
487
+ );
488
+ }
489
+ parsed.gate = n;
490
+ parsed.gateOption = '--exit-on';
491
+ break;
492
+ }
493
+ case '--ouroboros':
494
+ parsed.ouroboros = true;
495
+ break;
496
+ case '--batch':
497
+ parsed.batch = true;
498
+ break;
499
+ case '--in-place':
500
+ parsed.inPlace = true;
501
+ break;
502
+ case '--suffix':
503
+ parsed.suffix = readOptionValue(args, i, arg, { allowFlagLike: true });
504
+ i++;
505
+ break;
506
+ case '--outdir':
507
+ parsed.outdir = readOptionValue(args, i, arg);
508
+ i++;
509
+ 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
+ case '--model':
553
+ parsed.model = readOptionValue(args, i, arg);
554
+ i++;
555
+ break;
556
+ case '--api-key':
557
+ parsed.apiKey = readOptionValue(args, i, arg);
558
+ i++;
559
+ break;
560
+ case '--api-key-file':
561
+ parsed.apiKeyFile = readOptionValue(args, i, arg);
562
+ i++;
563
+ break;
564
+ case '--allow-private-base-url':
565
+ parsed.allowPrivateBaseURL = true;
566
+ break;
567
+ case '--base-url':
568
+ parsed.baseURL = readOptionValue(args, i, arg);
569
+ i++;
570
+ break;
571
+ case '--backend':
572
+ parsed.backend = readOptionValue(args, i, arg);
573
+ i++;
574
+ break;
575
+ case '--list-backends':
576
+ parsed.listBackends = true;
577
+ break;
578
+ case '--provider':
579
+ parsed.provider = readOptionValue(args, i, arg);
580
+ i++;
581
+ 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);
590
+ i++;
591
+ break;
592
+ case '--save-run':
593
+ parsed.saveRun = readOptionValue(args, i, arg);
594
+ i++;
595
+ break;
596
+ case '--cache':
597
+ parsed.cacheDir = readOptionValue(args, i, arg);
598
+ i++;
599
+ break;
600
+ case '--cache-ttl': {
601
+ const value = readOptionValue(args, i, arg, { allowFlagLike: true });
602
+ 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;
612
+ break;
613
+ }
614
+ case '--no-cache':
615
+ parsed.noCache = true;
616
+ break;
617
+ case '--prompt-mode': {
618
+ const m = readOptionValue(args, i, arg);
619
+ 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
+ break;
629
+ }
630
+ case '--variants': {
631
+ const value = readOptionValue(args, i, arg, { allowFlagLike: true });
632
+ 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
+ break;
643
+ }
644
+ case '--no-interactive':
645
+ parsed.noInteractive = true;
646
+ break;
647
+ default:
648
+ if (!arg.startsWith('-')) {
649
+ parsed.files.push(arg);
650
+ } else {
651
+ throw inputError(
652
+ `unknown option ${arg}`,
653
+ 'patina does not recognize this CLI flag.',
654
+ 'Run `patina --help` to see supported options.'
655
+ );
656
+ }
657
+ break;
658
+ }
659
+ }
660
+
661
+ return parsed;
662
+ }
663
+
664
+ function cancellationError() {
665
+ return new PatinaCliError({
666
+ what: 'interrupted',
667
+ why: 'Ctrl-C canceled the in-flight patina request.',
668
+ action: 'Any running backend process or HTTP request was asked to stop.',
669
+ exitCode: 130,
670
+ });
671
+ }
672
+
673
+ export function createCancellationController({
674
+ processObj = process,
675
+ stderr = process.stderr,
676
+ logger = null,
677
+ } = {}) {
678
+ const controller = new AbortController();
679
+ let sigintCount = 0;
680
+ let installed = false;
681
+
682
+ const writeStatus = (message) => {
683
+ if (logger) {
684
+ logger.warn('cli.cancel', { message: message.trimEnd() });
685
+ return;
686
+ }
687
+ if (stderr && typeof stderr.write === 'function') stderr.write(message);
688
+ };
689
+
690
+ const onSigint = () => {
691
+ sigintCount++;
692
+ if (sigintCount === 1) {
693
+ processObj.exitCode = 130;
694
+ writeStatus('[patina] cancelling… press Ctrl-C again to exit immediately\n');
695
+ controller.abort();
696
+ return;
697
+ }
698
+
699
+ cleanup();
700
+ processObj.exit(130);
701
+ };
702
+
703
+ function install() {
704
+ if (!installed && typeof processObj.on === 'function') {
705
+ processObj.on('SIGINT', onSigint);
706
+ installed = true;
707
+ }
708
+ }
709
+
710
+ function cleanup() {
711
+ if (installed && typeof processObj.removeListener === 'function') {
712
+ processObj.removeListener('SIGINT', onSigint);
713
+ installed = false;
714
+ }
715
+ }
716
+
717
+ return {
718
+ signal: controller.signal,
719
+ install,
720
+ cleanup,
721
+ throwIfCanceled() {
722
+ if (controller.signal.aborted) throw cancellationError();
723
+ },
724
+ };
725
+ }
726
+
727
+ function readOptionValue(args, index, option, { allowFlagLike = false } = {}) {
728
+ const value = args[index + 1];
729
+ if (value === undefined || (!allowFlagLike && value.startsWith('-'))) {
730
+ throw inputError(
731
+ `${option} requires a value`,
732
+ 'The option was provided without the value it needs.',
733
+ `Run \`patina --help\` to see the expected ${option} syntax.`
734
+ );
735
+ }
736
+ return value;
737
+ }
738
+
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;
745
+ const backendStr = (backend || '').toLowerCase();
746
+ 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).
751
+ return 'strict';
752
+ }
753
+
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,
762
+ apiKeyFile: parsed.apiKeyFile,
763
+ envVars: providerHttpKeyEnvVars(provider?.apiKeyEnv),
764
+ });
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',
768
+ });
769
+ }
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.',
774
+ });
775
+ }
776
+ return apiKey;
777
+ }
778
+
779
+ async function loadInputs(parsed, logger = createLogger()) {
780
+ if (parsed.files.length === 0) {
781
+ if (process.stdin.isTTY) {
782
+ if (parsed.noInteractive) {
783
+ throw inputError(
784
+ 'no input provided',
785
+ 'No file path or piped stdin was available.',
786
+ 'Pass a file path, pipe text via stdin, or omit --no-interactive to paste text and press Ctrl-D.'
787
+ );
788
+ }
789
+ logger.info('stdin.prompt', { message: '[patina] Paste text, then press Ctrl-D to run (Ctrl-C to cancel).' });
790
+ }
791
+ const stdin = await readStdin({ interactive: Boolean(process.stdin.isTTY) });
792
+ if (!stdin.trim()) {
793
+ throw inputError(
794
+ 'empty input on stdin',
795
+ 'patina received stdin, but it contained no non-whitespace text.',
796
+ 'Try `echo "This is a draft." | patina --lang en` or pass a file path.'
797
+ );
798
+ }
799
+ return [{ path: '-', text: stdin }];
800
+ }
801
+
802
+ const inputs = [];
803
+ for (const file of parsed.files) {
804
+ const text = loadInputText(file);
805
+ inputs.push({ path: file, text });
806
+ }
807
+ return inputs;
808
+ }
809
+
810
+ function readStdin({ interactive = false } = {}) {
811
+ return new Promise((resolve, reject) => {
812
+ let data = '';
813
+ let cleanupSigint = () => {};
814
+ if (interactive) {
815
+ const onSigint = () => {
816
+ cleanupSigint();
817
+ const err = inputError(
818
+ 'interrupted',
819
+ 'Ctrl-C canceled interactive stdin before patina could process text.',
820
+ 'Run the command again, or pass --no-interactive in scripts.'
821
+ );
822
+ err.exitCode = 130;
823
+ reject(err);
824
+ process.exitCode = 130;
825
+ };
826
+ process.once('SIGINT', onSigint);
827
+ cleanupSigint = () => process.removeListener('SIGINT', onSigint);
828
+ }
829
+ process.stdin.setEncoding('utf8');
830
+ process.stdin.on('data', (chunk) => {
831
+ data += chunk;
832
+ });
833
+ process.stdin.on('end', () => {
834
+ cleanupSigint();
835
+ resolve(data);
836
+ });
837
+ process.stdin.on('error', (err) => {
838
+ cleanupSigint();
839
+ reject(err);
840
+ });
841
+ if (interactive) process.stdin.resume();
842
+ });
843
+ }
844
+
845
+ async function writeBatchOutput(parsed, inputPath, output) {
846
+ if (inputPath === '-') {
847
+ console.log(output);
848
+ return;
849
+ }
850
+
851
+ let outPath;
852
+ if (parsed.inPlace) {
853
+ outPath = inputPath;
854
+ } else if (parsed.suffix) {
855
+ const ext = extname(inputPath);
856
+ const base = basename(inputPath, ext);
857
+ const dir = inputPath.slice(0, -basename(inputPath).length);
858
+ outPath = resolve(dir, `${base}${parsed.suffix}${ext}`);
859
+ } else if (parsed.outdir) {
860
+ mkdirSync(parsed.outdir, { recursive: true });
861
+ outPath = resolve(parsed.outdir, basename(inputPath));
862
+ } else {
863
+ console.log(output);
864
+ return;
865
+ }
866
+
867
+ writeFileSync(outPath, output, 'utf8');
868
+ console.log(`Written: ${outPath}`);
869
+ }
870
+
871
+ function formatOuroborosOutput(result) {
872
+ let output = '## Ouroboros Iteration Log\n\n';
873
+ output += '| Iter | Before | After | Improvement | Reason |\n';
874
+ output += '|------|--------|-------|-------------|--------|\n';
875
+
876
+ for (const entry of result.log) {
877
+ output += `| ${entry.iteration} | ${entry.before ?? '—'} | ${entry.after} | ${entry.improvement ?? '—'} | ${entry.reason} |\n`;
878
+ }
879
+
880
+ output += `\nFinal score: ${result.finalScore}/100 (±10)\n`;
881
+ output += `Iterations: ${result.iterations}/${result.log.length > 0 ? result.log[result.log.length - 1].iteration : 0}\n`;
882
+ output += `Reason: ${result.reason}\n\n`;
883
+ output += '## Final Text\n\n';
884
+ output += result.finalText.trim();
885
+ output += '\n';
886
+
887
+ return output;
888
+ }
889
+
890
+ function printHelp() {
891
+ const backendChoices = listBackendNames().join(', ');
892
+ console.log(`patina — AI text humanizer CLI
893
+
894
+ Usage: patina [command] [options] [file...]
895
+
896
+ COMMANDS
897
+ patina init Create a project .patina.yaml
898
+ patina doctor [--json] Check Node, backends, tmux, and auth setup
899
+ patina auth status Show backend availability and authentication status
900
+ patina auth login Print per-backend authentication instructions
901
+
902
+ MODES
903
+ --diff Show changes pattern by pattern
904
+ --no-color Disable ANSI colors in --diff output
905
+ --audit Detect patterns only (no rewrite)
906
+ --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
909
+ --ouroboros Iterative self-improvement loop
910
+
911
+ OUTPUT & BATCH
912
+ --format <fmt> Output format: markdown (default), text, json
913
+ --json Alias for --format json
914
+ --quiet Suppress patina status/warning logs on stderr
915
+ --json-logs Emit stderr logs as NDJSON objects
916
+ --batch Process multiple files
917
+ --in-place Overwrite original files (with --batch)
918
+ --suffix <ext> Save as {name}{ext}{extname}
919
+ --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
924
+ --no-interactive Do not wait for TTY stdin; exit 2 when no input is given
925
+
926
+ LANGUAGE & PROFILE
927
+ --lang <code> Language: ko, en, zh, ja (default: ko)
928
+ --profile <name> Profile: default, blog, academic, technical, formal,
929
+ social, email, legal, medical, marketing,
930
+ narrative, instructional, casual-conversation,
931
+ code-comment, commit-message, release-notes
932
+ --tone <name> Tone: casual, professional, academic, narrative,
933
+ marketing, instructional, auto. Resolution:
934
+ --tone > config tone > config profile.
935
+ --voice-sample <path> Use 1-3 user paragraphs as style-only voice anchors
936
+
937
+ 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)
941
+ --api-key-file <path> Read API key from file (recommended)
942
+ --base-url <url> API base URL (or PATINA_API_BASE env)
943
+ --backend <name[,name]> Backend or explicit fallback chain:
944
+ ${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
+
953
+ ADVANCED
954
+ --variants <n> Generate N rewrite variants (1-5; rewrite mode only)
955
+ --config <path> Load config from <path> instead of .patina.default.yaml
956
+ --prompt-mode <m> strict | minimal | auto. auto picks per backend.
957
+ --allow-insecure-base-url Permit plaintext http:// to non-localhost endpoints
958
+ --allow-private-base-url Permit private/IMDS base URLs
959
+ -h, --help Show this help message
960
+ -v, --version Show version
961
+
962
+ EXAMPLES
963
+ echo "This is a draft." | patina --lang en --backend codex-cli
964
+ patina --score --exit-on 30 --format json draft.md
965
+ patina init --defaults
966
+ patina doctor --json
967
+
968
+ ENVIRONMENT
969
+ 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
972
+
973
+ EXIT CODES
974
+ 0 success · 1 runtime/backend · 2 input/usage · 3 score gate exceeded · 4 MAX MPS fallback/all candidates failed · 130 interrupted
975
+
976
+ If no API key is set, pass --backend codex-cli to use a logged-in codex CLI
977
+ (no key required). Auto-fallback was removed in v3.9 to keep agent-mode
978
+ backends opt-in (issue #88).
979
+ `);
980
+ }
981
+
982
+ function withDeterministicScore(rawResult, { text, config, repoRoot, logger }) {
983
+ const deterministicScore = scoreDeterministicSignals({ text, config, repoRoot });
984
+ const llmOverall = extractScoreOverall(rawResult, rawResult);
985
+ const reconciliation = reconcileScoreOverall({
986
+ llmOverall,
987
+ deterministicScore,
988
+ config,
989
+ logger,
990
+ });
991
+ const overall = reconciliation.overall ?? llmOverall;
992
+ return {
993
+ raw: String(rawResult || '').trim(),
994
+ overall,
995
+ llmScore: {
996
+ overall: llmOverall,
997
+ interpretation: llmOverall === null ? null : interpretScore(llmOverall),
998
+ },
999
+ deterministicScore,
1000
+ ...(reconciliation.scorePreference ? { scorePreference: reconciliation.scorePreference } : {}),
1001
+ };
1002
+ }
1003
+
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
+
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
+
1138
+ function applyScoreGate(result, output, gate, logger = createLogger()) {
1139
+ const overall = extractScoreOverall(result, output);
1140
+ if (overall === null) {
1141
+ throw new Error('--gate could not find a numeric `overall` value in --score output.');
1142
+ }
1143
+ if (overall > gate) {
1144
+ logger.warn('score.gate_failed', { message: `[patina] score gate failed: overall ${overall} > ${gate}` });
1145
+ process.exitCode = Math.max(Number(process.exitCode) || 0, 3);
1146
+ }
1147
+ }
1148
+
1149
+ function extractScoreOverall(result, output) {
1150
+ const resultOverall = toFiniteScore(result?.overall);
1151
+ if (resultOverall !== null) return resultOverall;
1152
+
1153
+ const text = String(output ?? result ?? '');
1154
+ const parsed = parseJsonScore(text);
1155
+ const parsedOverall = toFiniteScore(parsed?.overall);
1156
+ if (parsedOverall !== null) return parsedOverall;
1157
+
1158
+ const table = text.match(/(?:^|\n)\|\s*(?:\*\*)?Overall(?:\*\*)?\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(?:\*\*)?([0-9]+(?:\.[0-9]+)?)/i);
1159
+ if (table) return Number(table[1]);
1160
+
1161
+ const match = text.match(/(?:^|[\s|{,"])overall(?:["\s]*[:|]|\s+score\s*[:|]?)\s*(\d+(?:\.\d+)?)/i);
1162
+ if (!match) return null;
1163
+ return Number(match[1]);
1164
+ }
1165
+
1166
+ function toFiniteScore(value) {
1167
+ if (value === null || value === undefined || value === '') return null;
1168
+ const n = Number(value);
1169
+ return Number.isFinite(n) ? n : null;
1170
+ }
1171
+
1172
+ function parseJsonScore(text) {
1173
+ const trimmed = text.trim();
1174
+ const candidates = [
1175
+ trimmed,
1176
+ trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1],
1177
+ trimmed.match(/\{[\s\S]*\}/)?.[0],
1178
+ ].filter(Boolean);
1179
+
1180
+ for (const candidate of candidates) {
1181
+ try {
1182
+ return JSON.parse(candidate);
1183
+ } catch {}
1184
+ }
1185
+ return null;
1186
+ }
1187
+
1188
+ function printBackendStatus() {
1189
+ const list = listBackends();
1190
+ const rows = list.map((b) => ({
1191
+ name: b.name,
1192
+ available: b.available ? 'yes' : 'no',
1193
+ authenticated: b.authenticated ? 'yes' : 'no',
1194
+ note: b.authenticated ? '' : b.authHint,
1195
+ }));
1196
+ const widths = {
1197
+ name: Math.max('Backend'.length, ...rows.map((r) => r.name.length)),
1198
+ available: Math.max('Available'.length, ...rows.map((r) => r.available.length)),
1199
+ authenticated: Math.max('Authenticated'.length, ...rows.map((r) => r.authenticated.length)),
1200
+ };
1201
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1202
+ console.log(
1203
+ `${pad('Backend', widths.name)} ${pad('Available', widths.available)} ${pad('Authenticated', widths.authenticated)} Notes`
1204
+ );
1205
+ console.log(
1206
+ `${'-'.repeat(widths.name)} ${'-'.repeat(widths.available)} ${'-'.repeat(widths.authenticated)} -----`
1207
+ );
1208
+ for (const r of rows) {
1209
+ console.log(
1210
+ `${pad(r.name, widths.name)} ${pad(r.available, widths.available)} ${pad(r.authenticated, widths.authenticated)} ${r.note}`
1211
+ );
1212
+ }
1213
+ }
1214
+
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
+ );
1240
+ }
1241
+ }
1242
+
1243
+ function providerKeySource(provider) {
1244
+ const source = inspectHttpApiKeySource({
1245
+ envVars: providerHttpKeyEnvVars(provider.apiKeyEnv),
1246
+ });
1247
+ return source.ok ? source.source : 'missing';
1248
+ }
1249
+
1250
+ function handleAuth(subArgs) {
1251
+ const sub = subArgs[0] || 'status';
1252
+ if (sub === 'status') {
1253
+ printBackendStatus();
1254
+ return;
1255
+ }
1256
+ if (sub === 'login') {
1257
+ console.log('To authenticate a backend, follow the per-backend instructions:\n');
1258
+ for (const b of listBackends()) {
1259
+ const status = b.authenticated ? '✓ already authenticated' : '✗ not authenticated';
1260
+ console.log(` ${b.name}: ${status}`);
1261
+ if (!b.authenticated) console.log(` → ${b.authHint}`);
1262
+ }
1263
+ return;
1264
+ }
1265
+ throw inputError(
1266
+ `unknown auth subcommand ${sub}`,
1267
+ 'Supported auth subcommands are status and login.',
1268
+ 'Try `patina auth status` or `patina auth login`.'
1269
+ );
1270
+ }
1271
+
1272
+ // Self-invocation guard (#113): when run directly via `node src/cli.js ...`,
1273
+ // run main(). When imported (e.g. by bin/patina.js or tests), just expose
1274
+ // the exports.
1275
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
1276
+ main(process.argv.slice(2)).catch((err) => {
1277
+ createLogger().error('cli.error', { message: renderCliError(err) });
1278
+ process.exit(getExitCode(err));
1279
+ });
1280
+ }