patina-cli 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.patina.default.yaml +211 -0
  2. package/CHANGELOG.md +265 -0
  3. package/LICENSE +21 -0
  4. package/README.md +319 -0
  5. package/README_JA.md +254 -0
  6. package/README_KR.md +253 -0
  7. package/README_ZH.md +254 -0
  8. package/SKILL-MAX.md +455 -0
  9. package/SKILL.md +730 -0
  10. package/assets/brand/patina-icon.svg +9 -0
  11. package/assets/brand/patina-logo.svg +17 -0
  12. package/assets/social/patina-before-after.svg +46 -0
  13. package/assets/social/patina-og.svg +31 -0
  14. package/bin/patina.js +9 -0
  15. package/core/scoring.md +657 -0
  16. package/core/standalone-prompt.md +364 -0
  17. package/core/stylometry.md +754 -0
  18. package/core/voice.md +163 -0
  19. package/docs/AUTHENTICATION.md +105 -0
  20. package/docs/AUTHENTICATION_KR.md +105 -0
  21. package/docs/BRANDING.md +37 -0
  22. package/docs/CLI.md +80 -0
  23. package/docs/COMPARISON.md +38 -0
  24. package/docs/COOKBOOK.md +173 -0
  25. package/docs/DEMO.md +40 -0
  26. package/docs/ETHICS.md +27 -0
  27. package/docs/EXAMPLES.md +130 -0
  28. package/docs/EXAMPLES_KR.md +130 -0
  29. package/docs/EXIT-CODES.md +25 -0
  30. package/docs/FAQ.md +67 -0
  31. package/docs/FAQ_KR.md +65 -0
  32. package/docs/FLAG-PARITY.md +53 -0
  33. package/docs/GLOSSARY.md +123 -0
  34. package/docs/PATTERNS-EN.md +718 -0
  35. package/docs/PATTERNS-JA.md +706 -0
  36. package/docs/PATTERNS-KO.md +707 -0
  37. package/docs/PATTERNS-ZH.md +706 -0
  38. package/docs/PATTERNS.md +22 -0
  39. package/docs/ROADMAP.md +315 -0
  40. package/docs/audits/2026-05-deep-research.md +290 -0
  41. package/docs/benchmarks/detector-comparison.json +442 -0
  42. package/docs/benchmarks/detector-comparison.md +65 -0
  43. package/docs/benchmarks/latest.json +988 -0
  44. package/docs/benchmarks/latest.md +112 -0
  45. package/docs/integrations/docker.md +19 -0
  46. package/docs/integrations/github-action.md +59 -0
  47. package/docs/integrations/pre-commit.md +77 -0
  48. package/docs/integrations/release.md +43 -0
  49. package/docs/internal/HARNESS.md +14 -0
  50. package/docs/internal/README.md +14 -0
  51. package/docs/internal/WARP.md +23 -0
  52. package/docs/research/2025-rebaseline-plan.md +89 -0
  53. package/docs/research/ai-human-metrics.md +380 -0
  54. package/docs/social/gstack-cardnews.html +236 -0
  55. package/docs/social/gstack-cardnews.md +88 -0
  56. package/docs/social/gstack-thread.md +106 -0
  57. package/docs/social/patina-launch-copy.md +227 -0
  58. package/docs/superpowers/specs/2026-04-03-meaning-preservation-design.md +299 -0
  59. package/lexicon/ai-en.md +162 -0
  60. package/lexicon/ai-ko.md +159 -0
  61. package/package.json +100 -0
  62. package/patina-max/SKILL.md +523 -0
  63. package/patina-max/composite.py +457 -0
  64. package/patterns/en-communication.md +89 -0
  65. package/patterns/en-content.md +133 -0
  66. package/patterns/en-filler.md +113 -0
  67. package/patterns/en-language.md +163 -0
  68. package/patterns/en-structure.md +173 -0
  69. package/patterns/en-style.md +139 -0
  70. package/patterns/en-viral-hook.md +211 -0
  71. package/patterns/ja-communication.md +101 -0
  72. package/patterns/ja-content.md +153 -0
  73. package/patterns/ja-filler.md +123 -0
  74. package/patterns/ja-language.md +190 -0
  75. package/patterns/ja-structure.md +142 -0
  76. package/patterns/ja-style.md +147 -0
  77. package/patterns/ja-viral-hook.md +216 -0
  78. package/patterns/ko-communication.md +98 -0
  79. package/patterns/ko-content.md +154 -0
  80. package/patterns/ko-filler.md +105 -0
  81. package/patterns/ko-language.md +182 -0
  82. package/patterns/ko-structure.md +147 -0
  83. package/patterns/ko-style.md +146 -0
  84. package/patterns/ko-viral-hook.md +211 -0
  85. package/patterns/zh-communication.md +101 -0
  86. package/patterns/zh-content.md +153 -0
  87. package/patterns/zh-filler.md +118 -0
  88. package/patterns/zh-language.md +173 -0
  89. package/patterns/zh-structure.md +145 -0
  90. package/patterns/zh-style.md +159 -0
  91. package/patterns/zh-viral-hook.md +216 -0
  92. package/profiles/academic.md +53 -0
  93. package/profiles/blog.md +81 -0
  94. package/profiles/casual-conversation.md +105 -0
  95. package/profiles/code-comment.md +104 -0
  96. package/profiles/commit-message.md +99 -0
  97. package/profiles/default.md +62 -0
  98. package/profiles/email.md +52 -0
  99. package/profiles/formal.md +98 -0
  100. package/profiles/instructional.md +80 -0
  101. package/profiles/legal.md +57 -0
  102. package/profiles/marketing.md +56 -0
  103. package/profiles/medical.md +53 -0
  104. package/profiles/narrative.md +79 -0
  105. package/profiles/release-notes.md +98 -0
  106. package/profiles/social.md +56 -0
  107. package/profiles/technical.md +53 -0
  108. package/scripts/benchmark-report.mjs +252 -0
  109. package/scripts/check-release-metadata.mjs +48 -0
  110. package/scripts/detector-comparison.mjs +267 -0
  111. package/scripts/lint.mjs +40 -0
  112. package/scripts/precommit-score.mjs +31 -0
  113. package/scripts/prose-score.mjs +186 -0
  114. package/scripts/update-benchmark-ranges.mjs +108 -0
  115. package/src/api.js +330 -0
  116. package/src/auth.js +105 -0
  117. package/src/backends/claude-cli.js +112 -0
  118. package/src/backends/codex-cli.js +121 -0
  119. package/src/backends/contract.js +21 -0
  120. package/src/backends/gemini-cli.js +135 -0
  121. package/src/backends/index.js +159 -0
  122. package/src/cache.js +106 -0
  123. package/src/cli.js +1280 -0
  124. package/src/commands/doctor.js +229 -0
  125. package/src/commands/init.js +208 -0
  126. package/src/config.js +126 -0
  127. package/src/errors.js +53 -0
  128. package/src/features/index.js +96 -0
  129. package/src/features/lexicon.js +90 -0
  130. package/src/features/segment.js +49 -0
  131. package/src/features/stylometry.js +50 -0
  132. package/src/loader.js +103 -0
  133. package/src/logger.js +70 -0
  134. package/src/manifest.js +162 -0
  135. package/src/max-mode.js +207 -0
  136. package/src/ouroboros.js +233 -0
  137. package/src/output.js +480 -0
  138. package/src/prompt-builder.js +409 -0
  139. package/src/providers.js +100 -0
  140. package/src/scoring.js +531 -0
  141. package/src/security.js +133 -0
  142. package/tests/fixtures/suspect-zones/en/ai/en-ai-01.md +16 -0
  143. package/tests/fixtures/suspect-zones/en/ai/en-ai-02.md +16 -0
  144. package/tests/fixtures/suspect-zones/en/ai/en-ai-03.md +17 -0
  145. package/tests/fixtures/suspect-zones/en/ai/en-ai-04.md +15 -0
  146. package/tests/fixtures/suspect-zones/en/ai/en-ai-05.md +16 -0
  147. package/tests/fixtures/suspect-zones/en/ai/en-ai-06-chat-register.md +16 -0
  148. package/tests/fixtures/suspect-zones/en/natural/en-nat-01.md +15 -0
  149. package/tests/fixtures/suspect-zones/en/natural/en-nat-02.md +15 -0
  150. package/tests/fixtures/suspect-zones/en/natural/en-nat-03.md +15 -0
  151. package/tests/fixtures/suspect-zones/en/natural/en-nat-04.md +15 -0
  152. package/tests/fixtures/suspect-zones/en/natural/en-nat-05.md +15 -0
  153. package/tests/fixtures/suspect-zones/expected-ranges.json +939 -0
  154. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-01.md +11 -0
  155. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-02.md +11 -0
  156. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-03.md +11 -0
  157. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-01.md +11 -0
  158. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-02.md +11 -0
  159. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-03.md +11 -0
  160. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-01.md +14 -0
  161. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +16 -0
  162. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-03.md +15 -0
  163. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-04.md +15 -0
  164. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-05.md +16 -0
  165. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-06-chat-register.md +16 -0
  166. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-01.md +15 -0
  167. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-02.md +15 -0
  168. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-03.md +15 -0
  169. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-04.md +14 -0
  170. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-05.md +15 -0
  171. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-01.md +11 -0
  172. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-02.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-03.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-01.md +11 -0
  175. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-02.md +11 -0
  176. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-03.md +11 -0
  177. package/tests/quality/README.md +121 -0
  178. package/tests/quality/benchmark.mjs +306 -0
  179. package/tests/quality/detectors.manual.example.json +31 -0
  180. package/tests/quality/dogfood.mjs +44 -0
package/src/auth.js ADDED
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { inputError } from './errors.js';
3
+
4
+ export const HTTP_KEY_ENV_VARS = [
5
+ 'PATINA_API_KEY',
6
+ 'OPENAI_API_KEY',
7
+ 'GEMINI_API_KEY',
8
+ 'GROQ_API_KEY',
9
+ 'TOGETHER_API_KEY',
10
+ ];
11
+
12
+ // Default openai-http runs against the OpenAI-compatible default endpoint, so
13
+ // only generic/OpenAI keys make it authenticated without an explicit provider.
14
+ export const DEFAULT_HTTP_KEY_ENV_VARS = [
15
+ 'PATINA_API_KEY',
16
+ 'OPENAI_API_KEY',
17
+ ];
18
+
19
+ export function providerHttpKeyEnvVars(providerApiKeyEnv) {
20
+ if (!providerApiKeyEnv) return DEFAULT_HTTP_KEY_ENV_VARS;
21
+ return uniqueEnvVars([providerApiKeyEnv, 'PATINA_API_KEY']);
22
+ }
23
+
24
+ export function inspectHttpApiKeySource({
25
+ env = process.env,
26
+ readFile = readFileSync,
27
+ envVars = DEFAULT_HTTP_KEY_ENV_VARS,
28
+ } = {}) {
29
+ const filePath = env.PATINA_API_KEY_FILE;
30
+ if (filePath) {
31
+ const file = readApiKeyFile(filePath, readFile);
32
+ return file.ok
33
+ ? { ok: true, source: 'PATINA_API_KEY_FILE', envVars: [], filePath, detail: `Authenticated via PATINA_API_KEY_FILE (${filePath}).` }
34
+ : { ok: false, source: 'PATINA_API_KEY_FILE', envVars: [], filePath, detail: file.detail };
35
+ }
36
+
37
+ const present = uniqueEnvVars(envVars).filter((key) => env[key]);
38
+ if (present.length > 0) {
39
+ return { ok: true, source: present[0], envVars: present, filePath: null, detail: `Authenticated via ${present.join(', ')}.` };
40
+ }
41
+
42
+ return {
43
+ ok: false,
44
+ source: null,
45
+ envVars: [],
46
+ filePath: null,
47
+ detail: 'Set PATINA_API_KEY, PATINA_API_KEY_FILE, OPENAI_API_KEY, or select a provider with its key.',
48
+ };
49
+ }
50
+
51
+ export function resolveHttpApiKey({
52
+ explicitApiKey,
53
+ apiKeyFile,
54
+ env = process.env,
55
+ readFile = readFileSync,
56
+ envVars = DEFAULT_HTTP_KEY_ENV_VARS,
57
+ } = {}) {
58
+ const filePath = apiKeyFile || env.PATINA_API_KEY_FILE;
59
+ if (filePath) {
60
+ const file = readApiKeyFile(filePath, readFile);
61
+ if (!file.ok) {
62
+ throw inputError(
63
+ file.what,
64
+ file.detail,
65
+ 'Check the path, write the key into the file, or unset PATINA_API_KEY_FILE.'
66
+ );
67
+ }
68
+ return file.key;
69
+ }
70
+
71
+ if (explicitApiKey) return explicitApiKey;
72
+
73
+ const source = inspectHttpApiKeySource({ env, readFile, envVars });
74
+ return source.ok && source.source !== 'PATINA_API_KEY_FILE'
75
+ ? env[source.source]
76
+ : undefined;
77
+ }
78
+
79
+ function uniqueEnvVars(envVars) {
80
+ return [...new Set(envVars.filter(Boolean))];
81
+ }
82
+
83
+ function readApiKeyFile(filePath, readFile) {
84
+ let contents;
85
+ try {
86
+ contents = readFile(filePath, 'utf8');
87
+ } catch (err) {
88
+ return {
89
+ ok: false,
90
+ what: 'cannot read API key file',
91
+ detail: `${filePath}: ${err.message}`,
92
+ };
93
+ }
94
+
95
+ const key = contents.replace(/[\r\n]+$/, '').trim();
96
+ if (!key) {
97
+ return {
98
+ ok: false,
99
+ what: 'API key file is empty',
100
+ detail: filePath,
101
+ };
102
+ }
103
+
104
+ return { ok: true, key };
105
+ }
@@ -0,0 +1,112 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { DEFAULT_BACKEND_TIMEOUT_MS } from './contract.js';
6
+
7
+ export const name = 'claude-cli';
8
+
9
+ export function isAvailable() {
10
+ try {
11
+ const result = spawnSync('claude', ['--version'], { stdio: 'ignore' });
12
+ return result.status === 0;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function isAuthenticated() {
19
+ // Claude Code stores OAuth tokens in ~/.claude/.credentials.json after the
20
+ // first interactive login. The path is consistent across platforms when the
21
+ // CLI is installed via the standard installer.
22
+ return existsSync(join(homedir(), '.claude', '.credentials.json'));
23
+ }
24
+
25
+ export function authHint() {
26
+ return 'Run `claude` once interactively and follow the OAuth prompt to authenticate (uses your Claude subscription, no API key needed).';
27
+ }
28
+
29
+ export async function invoke({ prompt, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
30
+ if (!prompt || typeof prompt !== 'string') {
31
+ throw new Error('claude-cli backend: prompt must be a non-empty string');
32
+ }
33
+ throwIfAborted(signal);
34
+
35
+ // Spawn from a fresh temp directory so a prompt-injection in user text
36
+ // cannot read or write inside the caller's repo. claude -p prints to
37
+ // stdout, so no output file plumbing is needed (unlike codex-cli).
38
+ const dir = mkdtempSync(join(tmpdir(), 'patina-claude-'));
39
+
40
+ return new Promise((resolve, reject) => {
41
+ const proc = spawn('claude', ['-p'], { stdio: ['pipe', 'pipe', 'pipe'], cwd: dir });
42
+
43
+ let stdout = '';
44
+ let stderr = '';
45
+ proc.stdout.on('data', (chunk) => { stdout += chunk; });
46
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
47
+
48
+ let settled = false;
49
+ let cleanupSignal = () => {};
50
+ const timer = setTimeout(() => {
51
+ finishReject(new Error(`claude-cli backend: timed out after ${timeout}ms`), { kill: true });
52
+ }, timeout);
53
+ if (signal) {
54
+ const onAbort = () => finishReject(abortError('claude-cli backend: aborted'), { kill: true });
55
+ signal.addEventListener('abort', onAbort, { once: true });
56
+ cleanupSignal = () => signal.removeEventListener('abort', onAbort);
57
+ }
58
+
59
+ proc.on('error', (err) => {
60
+ if (err.code === 'ENOENT') {
61
+ finishReject(new Error('claude-cli backend: `claude` CLI not found. Install Claude Code first.'));
62
+ } else {
63
+ finishReject(new Error(`claude-cli backend: failed to spawn claude (${err.message})`));
64
+ }
65
+ });
66
+
67
+ proc.on('close', (code) => {
68
+ if (settled) return;
69
+ if (code !== 0) {
70
+ finishReject(new Error(`claude-cli backend: claude exited with code ${code}\n${stderr}`));
71
+ return;
72
+ }
73
+ finishResolve(stdout);
74
+ });
75
+
76
+ proc.stdin.write(prompt);
77
+ proc.stdin.end();
78
+
79
+ function cleanup() {
80
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
81
+ }
82
+
83
+ function finishReject(err, { kill = false } = {}) {
84
+ if (settled) return;
85
+ settled = true;
86
+ clearTimeout(timer);
87
+ cleanupSignal();
88
+ if (kill) proc.kill('SIGKILL');
89
+ cleanup();
90
+ reject(err);
91
+ }
92
+
93
+ function finishResolve(content) {
94
+ if (settled) return;
95
+ settled = true;
96
+ clearTimeout(timer);
97
+ cleanupSignal();
98
+ cleanup();
99
+ resolve(content);
100
+ }
101
+ });
102
+ }
103
+
104
+ function abortError(message) {
105
+ const err = new Error(message);
106
+ err.name = 'AbortError';
107
+ return err;
108
+ }
109
+
110
+ function throwIfAborted(signal) {
111
+ if (signal?.aborted) throw abortError('claude-cli backend: aborted');
112
+ }
@@ -0,0 +1,121 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { DEFAULT_BACKEND_TIMEOUT_MS } from './contract.js';
6
+
7
+ export const name = 'codex-cli';
8
+
9
+ export function isAvailable() {
10
+ try {
11
+ const result = spawnSync('codex', ['--version'], { stdio: 'ignore' });
12
+ return result.status === 0;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function isAuthenticated() {
19
+ return existsSync(join(homedir(), '.codex', 'auth.json'));
20
+ }
21
+
22
+ export function authHint() {
23
+ return 'Run `codex login` to authenticate (uses your ChatGPT Plus account, no API key needed).';
24
+ }
25
+
26
+ export async function invoke({ prompt, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
27
+ if (!prompt || typeof prompt !== 'string') {
28
+ throw new Error('codex-cli backend: prompt must be a non-empty string');
29
+ }
30
+ throwIfAborted(signal);
31
+
32
+ // Run codex from a fresh temp directory with the read-only sandbox so that
33
+ // a prompt-injection in user text cannot read the caller's repo or write
34
+ // arbitrary files. The output file lives inside the same temp dir so codex
35
+ // can still drop the last message there.
36
+ const dir = mkdtempSync(join(tmpdir(), 'patina-codex-'));
37
+ const outFile = join(dir, 'last-message.txt');
38
+
39
+ return new Promise((resolve, reject) => {
40
+ const proc = spawn('codex', [
41
+ 'exec',
42
+ '--skip-git-repo-check',
43
+ '--sandbox', 'read-only',
44
+ '-C', dir,
45
+ '--output-last-message', outFile,
46
+ ], { stdio: ['pipe', 'pipe', 'pipe'], cwd: dir });
47
+
48
+ let stderr = '';
49
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
50
+
51
+ let settled = false;
52
+ let cleanupSignal = () => {};
53
+ const timer = setTimeout(() => {
54
+ finishReject(new Error(`codex-cli backend: timed out after ${timeout}ms`), { kill: true });
55
+ }, timeout);
56
+ if (signal) {
57
+ const onAbort = () => finishReject(abortError('codex-cli backend: aborted'), { kill: true });
58
+ signal.addEventListener('abort', onAbort, { once: true });
59
+ cleanupSignal = () => signal.removeEventListener('abort', onAbort);
60
+ }
61
+
62
+ proc.on('error', (err) => {
63
+ if (err.code === 'ENOENT') {
64
+ finishReject(new Error('codex-cli backend: `codex` CLI not found. Install it from https://github.com/openai/codex'));
65
+ } else {
66
+ finishReject(new Error(`codex-cli backend: failed to spawn codex (${err.message})`));
67
+ }
68
+ });
69
+
70
+ proc.on('close', (code) => {
71
+ if (settled) return;
72
+ if (code !== 0) {
73
+ finishReject(new Error(`codex-cli backend: codex exited with code ${code}\n${stderr}`));
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const content = readFileSync(outFile, 'utf8');
79
+ finishResolve(content);
80
+ } catch (err) {
81
+ finishReject(new Error(`codex-cli backend: failed to read output file (${err.message})`));
82
+ }
83
+ });
84
+
85
+ proc.stdin.write(prompt);
86
+ proc.stdin.end();
87
+
88
+ function cleanup() {
89
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
90
+ }
91
+
92
+ function finishReject(err, { kill = false } = {}) {
93
+ if (settled) return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ cleanupSignal();
97
+ if (kill) proc.kill('SIGKILL');
98
+ cleanup();
99
+ reject(err);
100
+ }
101
+
102
+ function finishResolve(content) {
103
+ if (settled) return;
104
+ settled = true;
105
+ clearTimeout(timer);
106
+ cleanupSignal();
107
+ cleanup();
108
+ resolve(content);
109
+ }
110
+ });
111
+ }
112
+
113
+ function abortError(message) {
114
+ const err = new Error(message);
115
+ err.name = 'AbortError';
116
+ return err;
117
+ }
118
+
119
+ function throwIfAborted(signal) {
120
+ if (signal?.aborted) throw abortError('codex-cli backend: aborted');
121
+ }
@@ -0,0 +1,21 @@
1
+ export const DEFAULT_BACKEND_TIMEOUT_MS = 180_000;
2
+
3
+ export function isRetryableBackendError(err, { attemptIndex = 0, signal } = {}) {
4
+ if (signal?.aborted) return false;
5
+ const status = extractStatus(err);
6
+ if (status === 429 || status === 503) return true;
7
+ return err?.name === 'AbortError' && attemptIndex === 0;
8
+ }
9
+
10
+ export function describeBackendError(err) {
11
+ const status = extractStatus(err);
12
+ if (status) return `HTTP ${status}`;
13
+ return err?.name || 'error';
14
+ }
15
+
16
+ function extractStatus(err) {
17
+ const direct = Number(err?.status);
18
+ if (Number.isFinite(direct)) return direct;
19
+ const match = String(err?.message || '').match(/\bHTTP\s+(429|503)\b/);
20
+ return match ? Number(match[1]) : null;
21
+ }
@@ -0,0 +1,135 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { DEFAULT_BACKEND_TIMEOUT_MS } from './contract.js';
6
+
7
+ export const name = 'gemini-cli';
8
+
9
+ export function isAvailable() {
10
+ try {
11
+ const result = spawnSync('gemini', ['--version'], { stdio: 'ignore' });
12
+ return result.status === 0;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function isAuthenticated() {
19
+ // Two valid auth paths: OAuth (Code Assist) or API key. Either is enough
20
+ // for `gemini -p` to run; checking both avoids false negatives.
21
+ return (
22
+ existsSync(join(homedir(), '.gemini', 'gemini-credentials.json')) ||
23
+ !!process.env.GEMINI_API_KEY
24
+ );
25
+ }
26
+
27
+ export function authHint() {
28
+ if (process.env.GEMINI_API_KEY) {
29
+ return 'Authenticated via GEMINI_API_KEY env var.';
30
+ }
31
+ return 'Run `gemini` once interactively to log in via Google OAuth, or set GEMINI_API_KEY.';
32
+ }
33
+
34
+ export async function invoke({ prompt, model, signal, timeout = DEFAULT_BACKEND_TIMEOUT_MS } = {}) {
35
+ if (!prompt || typeof prompt !== 'string') {
36
+ throw new Error('gemini-cli backend: prompt must be a non-empty string');
37
+ }
38
+ throwIfAborted(signal);
39
+
40
+ // gemini -p '' reads the prompt from stdin (when -p arg is empty, stdin is
41
+ // appended). --output-format text avoids JSON wrapping. Spawn from a temp
42
+ // directory for the same prompt-injection containment reason as codex-cli;
43
+ // --skip-trust is required because the temp dir isn't in gemini's trusted
44
+ // workspace list (otherwise gemini exits 55).
45
+ const dir = mkdtempSync(join(tmpdir(), 'patina-gemini-'));
46
+ const args = ['-p', '', '--output-format', 'text', '--skip-trust'];
47
+ if (model) args.push('-m', model);
48
+
49
+ return new Promise((resolve, reject) => {
50
+ const proc = spawn('gemini', args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: dir });
51
+
52
+ let stdout = '';
53
+ let stderr = '';
54
+ proc.stdout.on('data', (chunk) => { stdout += chunk; });
55
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
56
+
57
+ let settled = false;
58
+ let cleanupSignal = () => {};
59
+ const timer = setTimeout(() => {
60
+ finishReject(new Error(`gemini-cli backend: timed out after ${timeout}ms`), { kill: true });
61
+ }, timeout);
62
+ if (signal) {
63
+ const onAbort = () => finishReject(abortError('gemini-cli backend: aborted'), { kill: true });
64
+ signal.addEventListener('abort', onAbort, { once: true });
65
+ cleanupSignal = () => signal.removeEventListener('abort', onAbort);
66
+ }
67
+
68
+ proc.on('error', (err) => {
69
+ if (err.code === 'ENOENT') {
70
+ finishReject(new Error('gemini-cli backend: `gemini` CLI not found. Install Gemini CLI first.'));
71
+ } else {
72
+ finishReject(new Error(`gemini-cli backend: failed to spawn gemini (${err.message})`));
73
+ }
74
+ });
75
+
76
+ proc.on('close', (code) => {
77
+ if (settled) return;
78
+ if (code !== 0) {
79
+ finishReject(new Error(`gemini-cli backend: gemini exited with code ${code}\n${stderr}`));
80
+ return;
81
+ }
82
+ finishResolve(stripGeminiNoise(stdout));
83
+ });
84
+
85
+ proc.stdin.write(prompt);
86
+ proc.stdin.end();
87
+
88
+ function cleanup() {
89
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
90
+ }
91
+
92
+ function finishReject(err, { kill = false } = {}) {
93
+ if (settled) return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ cleanupSignal();
97
+ if (kill) proc.kill('SIGKILL');
98
+ cleanup();
99
+ reject(err);
100
+ }
101
+
102
+ function finishResolve(content) {
103
+ if (settled) return;
104
+ settled = true;
105
+ clearTimeout(timer);
106
+ cleanupSignal();
107
+ cleanup();
108
+ resolve(content);
109
+ }
110
+ });
111
+ }
112
+
113
+ function abortError(message) {
114
+ const err = new Error(message);
115
+ err.name = 'AbortError';
116
+ return err;
117
+ }
118
+
119
+ function throwIfAborted(signal) {
120
+ if (signal?.aborted) throw abortError('gemini-cli backend: aborted');
121
+ }
122
+
123
+ // Gemini CLI prepends benign warnings to stdout (e.g. "Ripgrep is not
124
+ // available. Falling back to GrepTool.", "MCP issues detected..."). They
125
+ // aren't part of the model's response, so strip leading lines that match
126
+ // known noise patterns before returning.
127
+ function stripGeminiNoise(text) {
128
+ const lines = text.split(/\r?\n/);
129
+ const noiseRe = /^(Warning:|Ripgrep is not available|MCP issues detected|Loaded cached credentials)/i;
130
+ let i = 0;
131
+ while (i < lines.length && (noiseRe.test(lines[i]) || lines[i].trim() === '')) {
132
+ i++;
133
+ }
134
+ return lines.slice(i).join('\n');
135
+ }
@@ -0,0 +1,159 @@
1
+ import { callLLM } from '../api.js';
2
+ import * as codexCli from './codex-cli.js';
3
+ import * as claudeCli from './claude-cli.js';
4
+ import * as geminiCli from './gemini-cli.js';
5
+ import { inspectHttpApiKeySource } from '../auth.js';
6
+ import { inputError } from '../errors.js';
7
+ import {
8
+ DEFAULT_BACKEND_TIMEOUT_MS,
9
+ describeBackendError,
10
+ isRetryableBackendError,
11
+ } from './contract.js';
12
+
13
+ const openaiHttp = {
14
+ name: 'openai-http',
15
+ isAvailable: () => true,
16
+ isAuthenticated: () => inspectHttpApiKeySource().ok,
17
+ authHint: () => inspectHttpApiKeySource().detail,
18
+ invoke: ({
19
+ prompt,
20
+ apiKey,
21
+ baseURL,
22
+ model,
23
+ signal,
24
+ timeout = DEFAULT_BACKEND_TIMEOUT_MS,
25
+ temperature,
26
+ seed,
27
+ onResponse,
28
+ cache,
29
+ }) =>
30
+ callLLM({ prompt, apiKey, baseURL, model, signal, timeout, temperature, seed, onResponse, cache }),
31
+ };
32
+
33
+ const REGISTRY = {
34
+ 'openai-http': openaiHttp,
35
+ 'codex-cli': codexCli,
36
+ 'claude-cli': claudeCli,
37
+ 'gemini-cli': geminiCli,
38
+ };
39
+
40
+ export function listBackends() {
41
+ return Object.keys(REGISTRY).map((key) => {
42
+ const b = REGISTRY[key];
43
+ return {
44
+ name: key,
45
+ available: b.isAvailable(),
46
+ authenticated: b.isAuthenticated(),
47
+ authHint: b.authHint(),
48
+ };
49
+ });
50
+ }
51
+
52
+ export function listBackendNames() {
53
+ return Object.keys(REGISTRY);
54
+ }
55
+
56
+ export function selectBackend({ name, model } = {}) {
57
+ if (name) {
58
+ const backend = resolveBackend(name);
59
+ return { backend, autoSelected: false, reason: 'explicit' };
60
+ }
61
+
62
+ if (model && /^codex(-|$)/i.test(model)) {
63
+ return { backend: REGISTRY['codex-cli'], autoSelected: false, reason: 'model heuristic' };
64
+ }
65
+ if (model && /^claude(-|$)/i.test(model)) {
66
+ return { backend: REGISTRY['claude-cli'], autoSelected: false, reason: 'model heuristic' };
67
+ }
68
+ if (model && /^gemini(-|$)/i.test(model)) {
69
+ return { backend: REGISTRY['gemini-cli'], autoSelected: false, reason: 'model heuristic' };
70
+ }
71
+
72
+ // No silent auto-fallback to any CLI backend. Sending arbitrary text to a
73
+ // coding agent is a higher-trust action than calling a plain completion
74
+ // API, so require an explicit `--backend <name>` (or `--model <prefix>`).
75
+ // See issue #88.
76
+ return { backend: REGISTRY['openai-http'], autoSelected: false, reason: 'default' };
77
+ }
78
+
79
+ export function selectBackendChain({ name, model } = {}) {
80
+ if (name) {
81
+ const names = String(name)
82
+ .split(',')
83
+ .map((entry) => entry.trim())
84
+ .filter(Boolean);
85
+ if (names.length === 0) {
86
+ throw inputError(
87
+ '--backend expects at least one backend name',
88
+ 'The comma-separated backend list was empty.',
89
+ `Available backends are: ${Object.keys(REGISTRY).join(', ')}.`
90
+ );
91
+ }
92
+ return {
93
+ backends: names.map(resolveBackend),
94
+ autoSelected: false,
95
+ reason: names.length > 1 ? 'explicit chain' : 'explicit',
96
+ };
97
+ }
98
+
99
+ const selected = selectBackend({ model });
100
+ return {
101
+ backends: [selected.backend],
102
+ autoSelected: selected.autoSelected,
103
+ reason: selected.reason,
104
+ };
105
+ }
106
+
107
+ export async function invokeBackendChain({
108
+ backends,
109
+ prompt,
110
+ apiKey,
111
+ baseURL,
112
+ model,
113
+ signal,
114
+ timeout = DEFAULT_BACKEND_TIMEOUT_MS,
115
+ temperature,
116
+ seed,
117
+ onResponse,
118
+ cache,
119
+ logger,
120
+ }) {
121
+ if (!Array.isArray(backends) || backends.length === 0) {
122
+ throw inputError(
123
+ 'no backend selected',
124
+ 'patina could not resolve a backend to run.',
125
+ 'Pass --backend openai-http, codex-cli, claude-cli, or gemini-cli.'
126
+ );
127
+ }
128
+
129
+ let lastError = null;
130
+ for (let attemptIndex = 0; attemptIndex < backends.length; attemptIndex++) {
131
+ const backend = backends[attemptIndex];
132
+ try {
133
+ return await backend.invoke({ prompt, apiKey, baseURL, model, signal, timeout, temperature, seed, onResponse, cache });
134
+ } catch (err) {
135
+ lastError = err;
136
+ const next = backends[attemptIndex + 1];
137
+ if (!next || !isRetryableBackendError(err, { attemptIndex, signal })) {
138
+ throw err;
139
+ }
140
+ logger?.warn?.('backend.fallback', {
141
+ message: `[patina] ${backend.name} failed with ${describeBackendError(err)}; falling back to ${next.name}`,
142
+ });
143
+ }
144
+ }
145
+
146
+ throw lastError || new Error('backend fallback chain failed without an error');
147
+ }
148
+
149
+ function resolveBackend(name) {
150
+ const backend = REGISTRY[name];
151
+ if (!backend) {
152
+ throw inputError(
153
+ `Unknown backend: ${name}`,
154
+ `Available backends are: ${Object.keys(REGISTRY).join(', ')}.`,
155
+ 'Run `patina --list-backends` to inspect local availability.'
156
+ );
157
+ }
158
+ return backend;
159
+ }