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.
- package/.patina.default.yaml +211 -0
- package/CHANGELOG.md +265 -0
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/README_JA.md +254 -0
- package/README_KR.md +253 -0
- package/README_ZH.md +254 -0
- package/SKILL-MAX.md +455 -0
- package/SKILL.md +730 -0
- package/assets/brand/patina-icon.svg +9 -0
- package/assets/brand/patina-logo.svg +17 -0
- package/assets/social/patina-before-after.svg +46 -0
- package/assets/social/patina-og.svg +31 -0
- package/bin/patina.js +9 -0
- package/core/scoring.md +657 -0
- package/core/standalone-prompt.md +364 -0
- package/core/stylometry.md +754 -0
- package/core/voice.md +163 -0
- package/docs/AUTHENTICATION.md +105 -0
- package/docs/AUTHENTICATION_KR.md +105 -0
- package/docs/BRANDING.md +37 -0
- package/docs/CLI.md +80 -0
- package/docs/COMPARISON.md +38 -0
- package/docs/COOKBOOK.md +173 -0
- package/docs/DEMO.md +40 -0
- package/docs/ETHICS.md +27 -0
- package/docs/EXAMPLES.md +130 -0
- package/docs/EXAMPLES_KR.md +130 -0
- package/docs/EXIT-CODES.md +25 -0
- package/docs/FAQ.md +67 -0
- package/docs/FAQ_KR.md +65 -0
- package/docs/FLAG-PARITY.md +53 -0
- package/docs/GLOSSARY.md +123 -0
- package/docs/PATTERNS-EN.md +718 -0
- package/docs/PATTERNS-JA.md +706 -0
- package/docs/PATTERNS-KO.md +707 -0
- package/docs/PATTERNS-ZH.md +706 -0
- package/docs/PATTERNS.md +22 -0
- package/docs/ROADMAP.md +315 -0
- package/docs/audits/2026-05-deep-research.md +290 -0
- package/docs/benchmarks/detector-comparison.json +442 -0
- package/docs/benchmarks/detector-comparison.md +65 -0
- package/docs/benchmarks/latest.json +988 -0
- package/docs/benchmarks/latest.md +112 -0
- package/docs/integrations/docker.md +19 -0
- package/docs/integrations/github-action.md +59 -0
- package/docs/integrations/pre-commit.md +77 -0
- package/docs/integrations/release.md +43 -0
- package/docs/internal/HARNESS.md +14 -0
- package/docs/internal/README.md +14 -0
- package/docs/internal/WARP.md +23 -0
- package/docs/research/2025-rebaseline-plan.md +89 -0
- package/docs/research/ai-human-metrics.md +380 -0
- package/docs/social/gstack-cardnews.html +236 -0
- package/docs/social/gstack-cardnews.md +88 -0
- package/docs/social/gstack-thread.md +106 -0
- package/docs/social/patina-launch-copy.md +227 -0
- package/docs/superpowers/specs/2026-04-03-meaning-preservation-design.md +299 -0
- package/lexicon/ai-en.md +162 -0
- package/lexicon/ai-ko.md +159 -0
- package/package.json +100 -0
- package/patina-max/SKILL.md +523 -0
- package/patina-max/composite.py +457 -0
- package/patterns/en-communication.md +89 -0
- package/patterns/en-content.md +133 -0
- package/patterns/en-filler.md +113 -0
- package/patterns/en-language.md +163 -0
- package/patterns/en-structure.md +173 -0
- package/patterns/en-style.md +139 -0
- package/patterns/en-viral-hook.md +211 -0
- package/patterns/ja-communication.md +101 -0
- package/patterns/ja-content.md +153 -0
- package/patterns/ja-filler.md +123 -0
- package/patterns/ja-language.md +190 -0
- package/patterns/ja-structure.md +142 -0
- package/patterns/ja-style.md +147 -0
- package/patterns/ja-viral-hook.md +216 -0
- package/patterns/ko-communication.md +98 -0
- package/patterns/ko-content.md +154 -0
- package/patterns/ko-filler.md +105 -0
- package/patterns/ko-language.md +182 -0
- package/patterns/ko-structure.md +147 -0
- package/patterns/ko-style.md +146 -0
- package/patterns/ko-viral-hook.md +211 -0
- package/patterns/zh-communication.md +101 -0
- package/patterns/zh-content.md +153 -0
- package/patterns/zh-filler.md +118 -0
- package/patterns/zh-language.md +173 -0
- package/patterns/zh-structure.md +145 -0
- package/patterns/zh-style.md +159 -0
- package/patterns/zh-viral-hook.md +216 -0
- package/profiles/academic.md +53 -0
- package/profiles/blog.md +81 -0
- package/profiles/casual-conversation.md +105 -0
- package/profiles/code-comment.md +104 -0
- package/profiles/commit-message.md +99 -0
- package/profiles/default.md +62 -0
- package/profiles/email.md +52 -0
- package/profiles/formal.md +98 -0
- package/profiles/instructional.md +80 -0
- package/profiles/legal.md +57 -0
- package/profiles/marketing.md +56 -0
- package/profiles/medical.md +53 -0
- package/profiles/narrative.md +79 -0
- package/profiles/release-notes.md +98 -0
- package/profiles/social.md +56 -0
- package/profiles/technical.md +53 -0
- package/scripts/benchmark-report.mjs +252 -0
- package/scripts/check-release-metadata.mjs +48 -0
- package/scripts/detector-comparison.mjs +267 -0
- package/scripts/lint.mjs +40 -0
- package/scripts/precommit-score.mjs +31 -0
- package/scripts/prose-score.mjs +186 -0
- package/scripts/update-benchmark-ranges.mjs +108 -0
- package/src/api.js +330 -0
- package/src/auth.js +105 -0
- package/src/backends/claude-cli.js +112 -0
- package/src/backends/codex-cli.js +121 -0
- package/src/backends/contract.js +21 -0
- package/src/backends/gemini-cli.js +135 -0
- package/src/backends/index.js +159 -0
- package/src/cache.js +106 -0
- package/src/cli.js +1280 -0
- package/src/commands/doctor.js +229 -0
- package/src/commands/init.js +208 -0
- package/src/config.js +126 -0
- package/src/errors.js +53 -0
- package/src/features/index.js +96 -0
- package/src/features/lexicon.js +90 -0
- package/src/features/segment.js +49 -0
- package/src/features/stylometry.js +50 -0
- package/src/loader.js +103 -0
- package/src/logger.js +70 -0
- package/src/manifest.js +162 -0
- package/src/max-mode.js +207 -0
- package/src/ouroboros.js +233 -0
- package/src/output.js +480 -0
- package/src/prompt-builder.js +409 -0
- package/src/providers.js +100 -0
- package/src/scoring.js +531 -0
- package/src/security.js +133 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-01.md +16 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-02.md +16 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-03.md +17 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-04.md +15 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-05.md +16 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-06-chat-register.md +16 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-01.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-02.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-03.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-04.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-05.md +15 -0
- package/tests/fixtures/suspect-zones/expected-ranges.json +939 -0
- package/tests/fixtures/suspect-zones/ja/ai/ja-ai-01.md +11 -0
- package/tests/fixtures/suspect-zones/ja/ai/ja-ai-02.md +11 -0
- package/tests/fixtures/suspect-zones/ja/ai/ja-ai-03.md +11 -0
- package/tests/fixtures/suspect-zones/ja/natural/ja-nat-01.md +11 -0
- package/tests/fixtures/suspect-zones/ja/natural/ja-nat-02.md +11 -0
- package/tests/fixtures/suspect-zones/ja/natural/ja-nat-03.md +11 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-01.md +14 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +16 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-03.md +15 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-04.md +15 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-05.md +16 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-06-chat-register.md +16 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-01.md +15 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-02.md +15 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-03.md +15 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-04.md +14 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-05.md +15 -0
- package/tests/fixtures/suspect-zones/zh/ai/zh-ai-01.md +11 -0
- package/tests/fixtures/suspect-zones/zh/ai/zh-ai-02.md +11 -0
- package/tests/fixtures/suspect-zones/zh/ai/zh-ai-03.md +11 -0
- package/tests/fixtures/suspect-zones/zh/natural/zh-nat-01.md +11 -0
- package/tests/fixtures/suspect-zones/zh/natural/zh-nat-02.md +11 -0
- package/tests/fixtures/suspect-zones/zh/natural/zh-nat-03.md +11 -0
- package/tests/quality/README.md +121 -0
- package/tests/quality/benchmark.mjs +306 -0
- package/tests/quality/detectors.manual.example.json +31 -0
- 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
|
+
}
|