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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { HTTP_KEY_ENV_VARS, inspectHttpApiKeySource, providerHttpKeyEnvVars } from '../auth.js';
|
|
3
|
+
import { listBackends } from '../backends/index.js';
|
|
4
|
+
import { PROVIDERS } from '../providers.js';
|
|
5
|
+
import { inputError } from '../errors.js';
|
|
6
|
+
|
|
7
|
+
const MIN_NODE_MAJOR = 18;
|
|
8
|
+
|
|
9
|
+
export function runDoctor(args = [], { version } = {}) {
|
|
10
|
+
const parsed = parseDoctorArgs(args);
|
|
11
|
+
if (parsed.help) {
|
|
12
|
+
printDoctorHelp();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const report = buildDoctorReport({ version });
|
|
17
|
+
if (parsed.format === 'json') {
|
|
18
|
+
console.log(JSON.stringify(report, null, 2));
|
|
19
|
+
} else {
|
|
20
|
+
console.log(formatDoctorText(report));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!report.ok) {
|
|
24
|
+
process.exitCode = Math.max(Number(process.exitCode) || 0, 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildDoctorReport({ version } = {}) {
|
|
29
|
+
const checks = [];
|
|
30
|
+
const backends = listBackends();
|
|
31
|
+
const nodeVersion = process.versions.node;
|
|
32
|
+
const nodeMajor = Number(nodeVersion.split('.')[0]);
|
|
33
|
+
const nodeOk = Number.isFinite(nodeMajor) && nodeMajor >= MIN_NODE_MAJOR;
|
|
34
|
+
|
|
35
|
+
checks.push({
|
|
36
|
+
name: 'node',
|
|
37
|
+
status: nodeOk ? 'ok' : 'blocker',
|
|
38
|
+
summary: `Node ${nodeVersion}`,
|
|
39
|
+
detail: nodeOk
|
|
40
|
+
? `meets package engine >=${MIN_NODE_MAJOR}`
|
|
41
|
+
: `requires Node >=${MIN_NODE_MAJOR}`,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
checks.push({
|
|
45
|
+
name: 'cli-version',
|
|
46
|
+
status: version ? 'ok' : 'warning',
|
|
47
|
+
summary: `patina ${version || 'unknown'}`,
|
|
48
|
+
detail: 'read from package metadata',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const tmux = checkCommand('tmux', ['-V']);
|
|
52
|
+
checks.push({
|
|
53
|
+
name: 'tmux',
|
|
54
|
+
status: tmux.ok ? 'ok' : 'warning',
|
|
55
|
+
summary: tmux.ok ? tmux.stdout.trim() : 'tmux not found',
|
|
56
|
+
detail: tmux.ok
|
|
57
|
+
? 'available for MAX omc dispatch workflows'
|
|
58
|
+
: 'only needed for tmux-based MAX dispatch; direct/API modes still work',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const apiKeySource = inspectHttpApiKeySource();
|
|
62
|
+
const apiKeys = HTTP_KEY_ENV_VARS.map((name) => ({
|
|
63
|
+
name,
|
|
64
|
+
set: Boolean(process.env[name]),
|
|
65
|
+
}));
|
|
66
|
+
checks.push({
|
|
67
|
+
name: 'api-key-env',
|
|
68
|
+
status: apiKeySource.source === 'PATINA_API_KEY_FILE' && !apiKeySource.ok
|
|
69
|
+
? 'blocker'
|
|
70
|
+
: (apiKeySource.ok ? 'ok' : 'warning'),
|
|
71
|
+
summary: apiKeySource.ok
|
|
72
|
+
? 'default HTTP API key source detected'
|
|
73
|
+
: 'no default HTTP API key source detected',
|
|
74
|
+
detail: apiKeySource.detail,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const providerKeys = Object.values(PROVIDERS).map((provider) => ({
|
|
78
|
+
name: provider.name,
|
|
79
|
+
apiKeyEnv: provider.apiKeyEnv,
|
|
80
|
+
providerEnvSet: Boolean(process.env[provider.apiKeyEnv]),
|
|
81
|
+
keySource: getProviderKeySource(provider),
|
|
82
|
+
baseURL: provider.baseURL,
|
|
83
|
+
defaultModel: provider.defaultModel,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const usableBackends = backends.filter((b) => b.available && b.authenticated);
|
|
87
|
+
checks.push({
|
|
88
|
+
name: 'usable-backend',
|
|
89
|
+
status: usableBackends.length > 0 ? 'ok' : 'blocker',
|
|
90
|
+
summary: usableBackends.length > 0
|
|
91
|
+
? `${usableBackends.length} authenticated backend(s)`
|
|
92
|
+
: 'no authenticated backend',
|
|
93
|
+
detail: usableBackends.length > 0
|
|
94
|
+
? usableBackends.map((b) => b.name).join(', ')
|
|
95
|
+
: 'Set an API key or authenticate one local backend (`codex login`, `claude`, or `gemini`).',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const blockers = checks.filter((check) => check.status === 'blocker');
|
|
99
|
+
return {
|
|
100
|
+
ok: blockers.length === 0,
|
|
101
|
+
version: version || null,
|
|
102
|
+
node: {
|
|
103
|
+
version: nodeVersion,
|
|
104
|
+
required: `>=${MIN_NODE_MAJOR}.0.0`,
|
|
105
|
+
ok: nodeOk,
|
|
106
|
+
},
|
|
107
|
+
checks,
|
|
108
|
+
backends,
|
|
109
|
+
providers: providerKeys,
|
|
110
|
+
env: {
|
|
111
|
+
apiKeys,
|
|
112
|
+
PATINA_API_KEY_FILE: process.env.PATINA_API_KEY_FILE || null,
|
|
113
|
+
},
|
|
114
|
+
blockers: blockers.map((check) => ({
|
|
115
|
+
name: check.name,
|
|
116
|
+
summary: check.summary,
|
|
117
|
+
detail: check.detail,
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseDoctorArgs(args) {
|
|
123
|
+
const parsed = { format: 'text' };
|
|
124
|
+
for (let i = 0; i < args.length; i++) {
|
|
125
|
+
const arg = args[i];
|
|
126
|
+
switch (arg) {
|
|
127
|
+
case '--help':
|
|
128
|
+
case '-h':
|
|
129
|
+
parsed.help = true;
|
|
130
|
+
break;
|
|
131
|
+
case '--json':
|
|
132
|
+
parsed.format = 'json';
|
|
133
|
+
break;
|
|
134
|
+
case '--format': {
|
|
135
|
+
const value = args[++i];
|
|
136
|
+
if (!['json', 'text'].includes(value)) {
|
|
137
|
+
throw inputError(
|
|
138
|
+
'patina doctor --format expects json or text',
|
|
139
|
+
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
140
|
+
'Use `patina doctor --json` for CI-readable output.'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
parsed.format = value;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
throw inputError(
|
|
148
|
+
`unknown doctor option ${arg}`,
|
|
149
|
+
'The doctor command only accepts --json, --format, and --help.',
|
|
150
|
+
'Run `patina doctor --help` for usage.'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function checkCommand(cmd, args) {
|
|
158
|
+
try {
|
|
159
|
+
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
160
|
+
return {
|
|
161
|
+
ok: result.status === 0,
|
|
162
|
+
stdout: result.stdout || '',
|
|
163
|
+
stderr: result.stderr || '',
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return { ok: false, stdout: '', stderr: err.message };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getProviderKeySource(provider) {
|
|
171
|
+
const source = inspectHttpApiKeySource({
|
|
172
|
+
envVars: providerHttpKeyEnvVars(provider.apiKeyEnv),
|
|
173
|
+
});
|
|
174
|
+
return source.ok ? source.source : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatDoctorText(report) {
|
|
178
|
+
const icon = (status) => status === 'ok' ? '✓' : (status === 'warning' ? '!' : '✗');
|
|
179
|
+
const lines = [
|
|
180
|
+
`patina doctor — ${report.ok ? 'ok' : 'blockers found'}`,
|
|
181
|
+
'',
|
|
182
|
+
'Checks:',
|
|
183
|
+
];
|
|
184
|
+
for (const check of report.checks) {
|
|
185
|
+
lines.push(` ${icon(check.status)} ${check.summary}`);
|
|
186
|
+
if (check.detail) lines.push(` ${check.detail}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
lines.push('', 'Backends:');
|
|
190
|
+
for (const backend of report.backends) {
|
|
191
|
+
const ok = backend.available && backend.authenticated;
|
|
192
|
+
lines.push(
|
|
193
|
+
` ${ok ? '✓' : '!'} ${backend.name}: available=${yesNo(backend.available)}, authenticated=${yesNo(backend.authenticated)}`
|
|
194
|
+
);
|
|
195
|
+
if (!ok && backend.authHint) lines.push(` → ${backend.authHint}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lines.push('', 'Provider keys:');
|
|
199
|
+
for (const provider of report.providers) {
|
|
200
|
+
lines.push(
|
|
201
|
+
` ${provider.keySource ? '✓' : '!'} ${provider.name}: key=${provider.keySource || 'missing'} ` +
|
|
202
|
+
`(provider env ${provider.apiKeyEnv}=${provider.providerEnvSet ? 'set' : 'missing'})`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (report.blockers.length > 0) {
|
|
207
|
+
lines.push('', 'Blockers:');
|
|
208
|
+
for (const blocker of report.blockers) {
|
|
209
|
+
lines.push(` - ${blocker.summary}: ${blocker.detail}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function yesNo(value) {
|
|
217
|
+
return value ? 'yes' : 'no';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function printDoctorHelp() {
|
|
221
|
+
console.log(`patina doctor — check local CLI readiness
|
|
222
|
+
|
|
223
|
+
Usage: patina doctor [--json]
|
|
224
|
+
|
|
225
|
+
Checks Node version, patina CLI version, backend availability/authentication,
|
|
226
|
+
tmux, and PATINA/provider API key environment variables. Exits 0 when no
|
|
227
|
+
blockers are found, or 1 when a blocking setup issue is detected.
|
|
228
|
+
`);
|
|
229
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
import { listBackends } from '../backends/index.js';
|
|
8
|
+
import { inputError } from '../errors.js';
|
|
9
|
+
import { createLogger } from '../logger.js';
|
|
10
|
+
|
|
11
|
+
const LANGUAGES = ['ko', 'en', 'zh', 'ja'];
|
|
12
|
+
const PROFILES = [
|
|
13
|
+
'default',
|
|
14
|
+
'blog',
|
|
15
|
+
'academic',
|
|
16
|
+
'technical',
|
|
17
|
+
'formal',
|
|
18
|
+
'social',
|
|
19
|
+
'email',
|
|
20
|
+
'legal',
|
|
21
|
+
'medical',
|
|
22
|
+
'marketing',
|
|
23
|
+
'narrative',
|
|
24
|
+
'instructional',
|
|
25
|
+
'casual-conversation',
|
|
26
|
+
'code-comment',
|
|
27
|
+
'commit-message',
|
|
28
|
+
'release-notes',
|
|
29
|
+
];
|
|
30
|
+
const TONES = ['profile-only', 'casual', 'professional', 'academic', 'narrative', 'marketing', 'instructional', 'auto'];
|
|
31
|
+
const DISPATCH_MODES = ['omc', 'direct', 'api'];
|
|
32
|
+
|
|
33
|
+
export async function runInit(args = [], { logger = createLogger() } = {}) {
|
|
34
|
+
const parsed = parseInitArgs(args);
|
|
35
|
+
if (parsed.help) {
|
|
36
|
+
printInitHelp();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = resolve(process.cwd(), '.patina.yaml');
|
|
41
|
+
if (existsSync(target) && !parsed.force) {
|
|
42
|
+
if (parsed.defaults || !process.stdin.isTTY) {
|
|
43
|
+
throw inputError(
|
|
44
|
+
'.patina.yaml already exists',
|
|
45
|
+
'init will not overwrite an existing project config without confirmation.',
|
|
46
|
+
'Run `patina init --force` to replace it, or edit .patina.yaml manually.'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const detected = detectInitDefaults();
|
|
52
|
+
const answers = parsed.defaults
|
|
53
|
+
? detected
|
|
54
|
+
: await promptForConfig(detected, { target, force: parsed.force, logger });
|
|
55
|
+
|
|
56
|
+
if (!answers) {
|
|
57
|
+
logger.info('init.kept_existing', { message: '[patina] kept existing .patina.yaml' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const config = buildInitConfig(answers);
|
|
62
|
+
writeFileSync(target, `${yaml.dump(config, { lineWidth: 100 }).trimEnd()}\n`, 'utf8');
|
|
63
|
+
console.log(`[patina] wrote ${target}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function detectInitDefaults() {
|
|
67
|
+
const backends = listBackends();
|
|
68
|
+
const authenticated = backends.filter((b) => b.available && b.authenticated);
|
|
69
|
+
const preferredBackend = (
|
|
70
|
+
authenticated.find((b) => b.name !== 'openai-http') ||
|
|
71
|
+
authenticated[0] ||
|
|
72
|
+
backends.find((b) => b.name === 'openai-http')
|
|
73
|
+
)?.name || 'openai-http';
|
|
74
|
+
|
|
75
|
+
const maxModels = authenticated
|
|
76
|
+
.map((b) => MODEL_BY_BACKEND[b.name])
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
language: 'ko',
|
|
81
|
+
profile: 'default',
|
|
82
|
+
tone: 'profile-only',
|
|
83
|
+
backend: preferredBackend,
|
|
84
|
+
maxModels: maxModels.length > 0 ? maxModels : ['claude', 'gemini'],
|
|
85
|
+
dispatch: commandAvailable('tmux') ? 'omc' : 'direct',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseInitArgs(args) {
|
|
90
|
+
const parsed = {};
|
|
91
|
+
for (let i = 0; i < args.length; i++) {
|
|
92
|
+
const arg = args[i];
|
|
93
|
+
switch (arg) {
|
|
94
|
+
case '--help':
|
|
95
|
+
case '-h':
|
|
96
|
+
parsed.help = true;
|
|
97
|
+
break;
|
|
98
|
+
case '--defaults':
|
|
99
|
+
parsed.defaults = true;
|
|
100
|
+
break;
|
|
101
|
+
case '--force':
|
|
102
|
+
case '--yes':
|
|
103
|
+
case '-y':
|
|
104
|
+
parsed.force = true;
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
throw inputError(
|
|
108
|
+
`unknown init option ${arg}`,
|
|
109
|
+
'The init command only accepts --defaults, --force, and --help.',
|
|
110
|
+
'Run `patina init --help` for usage.'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return parsed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function promptForConfig(defaults, { target, force, logger = createLogger() }) {
|
|
118
|
+
if (!process.stdin.isTTY) {
|
|
119
|
+
throw inputError(
|
|
120
|
+
'init needs an interactive terminal',
|
|
121
|
+
'guided setup asks questions before writing .patina.yaml.',
|
|
122
|
+
'Run `patina init --defaults` for a non-interactive config.'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rl = createInterface({ input, output });
|
|
127
|
+
try {
|
|
128
|
+
if (existsSync(target) && !force) {
|
|
129
|
+
const overwrite = await ask(rl, `Overwrite existing .patina.yaml?`, 'no', ['yes', 'no'], logger);
|
|
130
|
+
if (overwrite !== 'yes') return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const language = await ask(rl, 'Language', defaults.language, LANGUAGES, logger);
|
|
134
|
+
const profile = await ask(rl, 'Profile', defaults.profile, PROFILES, logger);
|
|
135
|
+
const tone = await ask(rl, 'Tone', defaults.tone, TONES, logger);
|
|
136
|
+
const backendChoices = listBackends().map((b) => b.name);
|
|
137
|
+
const backend = await ask(rl, 'Backend', defaults.backend, backendChoices, logger);
|
|
138
|
+
const maxModels = await askMulti(rl, 'MAX models', defaults.maxModels, ['claude', 'codex', 'gemini'], logger);
|
|
139
|
+
const dispatch = await ask(rl, 'Dispatch mode', defaults.dispatch, DISPATCH_MODES, logger);
|
|
140
|
+
return { language, profile, tone, backend, maxModels, dispatch };
|
|
141
|
+
} finally {
|
|
142
|
+
rl.close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function ask(rl, label, defaultValue, choices, logger = createLogger()) {
|
|
147
|
+
const choiceHint = choices ? ` (${choices.join('/')})` : '';
|
|
148
|
+
const raw = (await rl.question(`${label}${choiceHint} [${defaultValue}]: `)).trim();
|
|
149
|
+
const value = raw || defaultValue;
|
|
150
|
+
if (choices && !choices.includes(value)) {
|
|
151
|
+
logger.warn('init.unknown_value', { message: `[patina] ${label}: unknown value "${value}", keeping ${defaultValue}` });
|
|
152
|
+
return defaultValue;
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function askMulti(rl, label, defaultValues, choices, logger = createLogger()) {
|
|
158
|
+
const raw = (await rl.question(`${label} (${choices.join(',')}) [${defaultValues.join(',')}]: `)).trim();
|
|
159
|
+
const values = (raw ? raw.split(',') : defaultValues)
|
|
160
|
+
.map((value) => value.trim())
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
const valid = values.filter((value) => choices.includes(value));
|
|
163
|
+
if (valid.length === 0) {
|
|
164
|
+
logger.warn('init.no_valid_values', { message: `[patina] ${label}: no valid values, keeping ${defaultValues.join(',')}` });
|
|
165
|
+
return defaultValues;
|
|
166
|
+
}
|
|
167
|
+
return [...new Set(valid)];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildInitConfig(answers) {
|
|
171
|
+
return {
|
|
172
|
+
language: answers.language,
|
|
173
|
+
profile: answers.profile,
|
|
174
|
+
tone: answers.tone === 'profile-only' ? null : answers.tone,
|
|
175
|
+
backend: answers.backend,
|
|
176
|
+
'max-models': answers.maxModels,
|
|
177
|
+
dispatch: answers.dispatch,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function commandAvailable(name) {
|
|
182
|
+
try {
|
|
183
|
+
const result = spawnSync(name, ['-V'], { stdio: 'ignore' });
|
|
184
|
+
return result.status === 0;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const MODEL_BY_BACKEND = {
|
|
191
|
+
'codex-cli': 'codex',
|
|
192
|
+
'claude-cli': 'claude',
|
|
193
|
+
'gemini-cli': 'gemini',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
function printInitHelp() {
|
|
197
|
+
console.log(`patina init — create a project .patina.yaml
|
|
198
|
+
|
|
199
|
+
Usage: patina init [--defaults] [--force]
|
|
200
|
+
|
|
201
|
+
Guided mode asks for language, profile, tone, backend, MAX models, and dispatch
|
|
202
|
+
mode. It preselects authenticated local backends when available.
|
|
203
|
+
|
|
204
|
+
Options:
|
|
205
|
+
--defaults Write detected defaults without prompts
|
|
206
|
+
--force Overwrite an existing .patina.yaml
|
|
207
|
+
`);
|
|
208
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const REPO_ROOT = resolve(__dirname, '..');
|
|
9
|
+
|
|
10
|
+
export function loadConfig(path = resolve(REPO_ROOT, '.patina.default.yaml')) {
|
|
11
|
+
const raw = readFileSync(path, 'utf8');
|
|
12
|
+
const parsed = yaml.load(raw);
|
|
13
|
+
if (!isPlainObject(parsed)) {
|
|
14
|
+
throw new Error(`Config at ${path} did not parse to a YAML mapping (got ${typeof parsed})`);
|
|
15
|
+
}
|
|
16
|
+
const config = parsed;
|
|
17
|
+
|
|
18
|
+
// User config: ~/.patina.yaml (global), then ./.patina.yaml (project, takes precedence).
|
|
19
|
+
for (const userPath of [resolve(homedir(), '.patina.yaml'), resolve(process.cwd(), '.patina.yaml')]) {
|
|
20
|
+
if (!existsSync(userPath)) continue;
|
|
21
|
+
const userRaw = readFileSync(userPath, 'utf8');
|
|
22
|
+
const userConfig = yaml.load(userRaw);
|
|
23
|
+
if (userConfig === null || userConfig === undefined) continue; // empty file
|
|
24
|
+
if (!isPlainObject(userConfig)) {
|
|
25
|
+
throw new Error(`User config at ${userPath} must be a YAML mapping (got ${Array.isArray(userConfig) ? 'array' : typeof userConfig})`);
|
|
26
|
+
}
|
|
27
|
+
deepMerge(config, userConfig);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPlainObject(v) {
|
|
34
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ADDITIVE_LIST_KEYS = new Set(['blocklist', 'allowlist', 'skip-patterns']);
|
|
38
|
+
|
|
39
|
+
function deepMerge(target, source) {
|
|
40
|
+
for (const key in source) {
|
|
41
|
+
if (isPlainObject(source[key])) {
|
|
42
|
+
if (!isPlainObject(target[key])) {
|
|
43
|
+
target[key] = {};
|
|
44
|
+
}
|
|
45
|
+
deepMerge(target[key], source[key]);
|
|
46
|
+
} else if (Array.isArray(source[key]) && ADDITIVE_LIST_KEYS.has(key)) {
|
|
47
|
+
const base = Array.isArray(target[key]) ? target[key] : [];
|
|
48
|
+
target[key] = [...new Set([...base, ...source[key]])];
|
|
49
|
+
} else if (Array.isArray(source[key])) {
|
|
50
|
+
target[key] = [...source[key]];
|
|
51
|
+
} else {
|
|
52
|
+
target[key] = source[key];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getRepoRoot() {
|
|
58
|
+
return REPO_ROOT;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const VALID_TONES = ['casual', 'professional', 'academic', 'narrative', 'marketing', 'instructional', 'auto'];
|
|
62
|
+
|
|
63
|
+
// Resolve the effective tone from CLI flag and config (v3.10).
|
|
64
|
+
// Priority: cliTone > configTone > unset. zh/ja + explicit tone → fallback path.
|
|
65
|
+
// Returns: { tone, tone_source, tone_evidence, tone_confidence, warning? }
|
|
66
|
+
export function resolveTone({ cliTone, configTone, lang }) {
|
|
67
|
+
if (cliTone !== undefined && cliTone !== null) {
|
|
68
|
+
if (!VALID_TONES.includes(cliTone)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Unknown tone '${cliTone}'. Valid tones: ${VALID_TONES.join(', ')}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (configTone !== undefined && configTone !== null && configTone !== '') {
|
|
75
|
+
if (!VALID_TONES.includes(configTone)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Invalid tone '${configTone}' in config. Valid tones: ${VALID_TONES.join(', ')}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const effective = cliTone || (configTone === '' ? null : configTone) || null;
|
|
83
|
+
|
|
84
|
+
// Profile-only mode: nothing specified at all.
|
|
85
|
+
if (!effective) {
|
|
86
|
+
return {
|
|
87
|
+
tone: null,
|
|
88
|
+
tone_source: 'profile_only',
|
|
89
|
+
tone_evidence: [],
|
|
90
|
+
tone_confidence: null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// zh/ja + any tone (including auto) → warning + fallback.
|
|
95
|
+
// Phase 4.5b heuristics only cover ko/en signals; auto on zh/ja would
|
|
96
|
+
// silently degrade to residual "professional" without useful evidence.
|
|
97
|
+
if ((lang === 'zh' || lang === 'ja') && effective) {
|
|
98
|
+
const label = effective === 'auto' ? 'auto-detection' : `tone "${effective}"`;
|
|
99
|
+
const warning = `${label} is en/ko-only in v1; falling back to default profile`;
|
|
100
|
+
return {
|
|
101
|
+
tone: null,
|
|
102
|
+
tone_source: 'unsupported_language_fallback',
|
|
103
|
+
tone_evidence: [warning],
|
|
104
|
+
tone_confidence: null,
|
|
105
|
+
warning,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (effective === 'auto') {
|
|
110
|
+
// Detection runs in-prompt at SKILL.md Phase 4.5b. Mark request only.
|
|
111
|
+
return {
|
|
112
|
+
tone: 'auto',
|
|
113
|
+
tone_source: 'auto',
|
|
114
|
+
tone_evidence: [],
|
|
115
|
+
tone_confidence: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// User-specified named tone.
|
|
120
|
+
return {
|
|
121
|
+
tone: effective,
|
|
122
|
+
tone_source: 'user',
|
|
123
|
+
tone_evidence: ['user-specified'],
|
|
124
|
+
tone_confidence: 'high',
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export class PatinaCliError extends Error {
|
|
2
|
+
constructor({ what, why, action, exitCode = 1 }) {
|
|
3
|
+
super([what, why, action].filter(Boolean).join('\n'));
|
|
4
|
+
this.name = 'PatinaCliError';
|
|
5
|
+
this.what = what;
|
|
6
|
+
this.why = why;
|
|
7
|
+
this.action = action;
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function inputError(what, why, action) {
|
|
13
|
+
return new PatinaCliError({ what, why, action, exitCode: 2 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runtimeError(what, why, action) {
|
|
17
|
+
return new PatinaCliError({ what, why, action, exitCode: 1 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderCliError(err) {
|
|
21
|
+
const normalized = normalizeError(err);
|
|
22
|
+
return [
|
|
23
|
+
`[patina] Error: ${normalized.what}`,
|
|
24
|
+
` ${normalized.why}`,
|
|
25
|
+
` → ${normalized.action}`,
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getExitCode(err, fallback = 1) {
|
|
30
|
+
const n = Number(err?.exitCode);
|
|
31
|
+
return Number.isInteger(n) && n >= 0 ? n : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeError(err) {
|
|
35
|
+
if (err instanceof PatinaCliError) {
|
|
36
|
+
return {
|
|
37
|
+
what: err.what || 'command failed',
|
|
38
|
+
why: err.why || 'patina could not complete the request.',
|
|
39
|
+
action: err.action || 'Run `patina --help` or `patina doctor` for next steps.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lines = String(err?.message || err || 'unknown error')
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.map((line) => line.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
what: lines[0] || 'command failed',
|
|
50
|
+
why: lines.slice(1).join(' ') || 'The command hit a runtime failure before it could finish.',
|
|
51
|
+
action: 'Run `patina doctor` to inspect your environment, or rerun with `--help` for usage.',
|
|
52
|
+
};
|
|
53
|
+
}
|