recoder-code 2.4.7 → 2.5.1

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.
@@ -6,6 +6,17 @@ import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
8
8
  import { getOllamaProvider, getOpenRouterProvider, getAnthropicProvider, getOpenAIProvider, getGroqProvider, parseModelId, } from '../providers/index.js';
9
+ import { selectModel } from './models/select.js';
10
+ import { compareModels } from './models/compare.js';
11
+ import { RecoderAuthService } from '../services/RecoderAuthService.js';
12
+ async function requireAuth() {
13
+ const authService = new RecoderAuthService();
14
+ const session = await authService.getSession();
15
+ if (!session) {
16
+ console.error('āŒ Please login first: recoder auth login');
17
+ process.exit(1);
18
+ }
19
+ }
9
20
  const CONFIG_DIR = path.join(os.homedir(), '.recoder-code');
10
21
  const CUSTOM_MODELS_FILE = path.join(CONFIG_DIR, 'custom-models.json');
11
22
  const DEFAULT_MODEL_FILE = path.join(CONFIG_DIR, 'default-model.json');
@@ -45,6 +56,7 @@ const listCommand = {
45
56
  type: 'string',
46
57
  }),
47
58
  handler: async (argv) => {
59
+ await requireAuth();
48
60
  console.log(chalk.bold.cyan('\nšŸ¤– Available Models\n'));
49
61
  const defaultModel = getDefaultModel();
50
62
  if (defaultModel) {
@@ -120,6 +132,7 @@ const addCommand = {
120
132
  .option('name', { describe: 'Display name', type: 'string' })
121
133
  .option('provider', { describe: 'Provider', type: 'string' }),
122
134
  handler: async (argv) => {
135
+ await requireAuth();
123
136
  const parsed = parseModelId(argv.model);
124
137
  const models = loadCustomModels();
125
138
  if (models.some((m) => m.id === argv.model)) {
@@ -141,6 +154,7 @@ const removeCommand = {
141
154
  describe: 'Remove a custom model',
142
155
  builder: (yargs) => yargs.positional('model', { describe: 'Model ID', type: 'string', demandOption: true }),
143
156
  handler: async (argv) => {
157
+ await requireAuth();
144
158
  const models = loadCustomModels();
145
159
  const filtered = models.filter((m) => m.id !== argv.model);
146
160
  if (filtered.length === models.length) {
@@ -157,16 +171,37 @@ const setDefaultCommand = {
157
171
  describe: 'Set default model',
158
172
  builder: (yargs) => yargs.positional('model', { describe: 'Model ID', type: 'string', demandOption: true }),
159
173
  handler: async (argv) => {
174
+ await requireAuth();
160
175
  setDefaultModel(argv.model);
161
176
  console.log(chalk.green(`\nāœ“ Default model set to ${argv.model}\n`));
162
177
  process.exit(0);
163
178
  },
164
179
  };
180
+ const selectCommand = {
181
+ command: 'select',
182
+ describe: 'Interactive model selection with fuzzy search',
183
+ handler: async () => {
184
+ await requireAuth();
185
+ await selectModel();
186
+ process.exit(0);
187
+ },
188
+ };
189
+ const compareCommand = {
190
+ command: 'compare',
191
+ describe: 'Compare multiple models side-by-side',
192
+ handler: async () => {
193
+ await requireAuth();
194
+ await compareModels();
195
+ process.exit(0);
196
+ },
197
+ };
165
198
  export const modelsCommand = {
166
199
  command: 'models',
167
200
  describe: 'Manage AI models',
168
201
  builder: (yargs) => yargs
169
202
  .command(listCommand)
203
+ .command(selectCommand)
204
+ .command(compareCommand)
170
205
  .command(addCommand)
171
206
  .command(removeCommand)
172
207
  .command(setDefaultCommand)
@@ -1,4 +1,4 @@
1
1
  /**
2
- * Providers config command - Configure API keys
2
+ * Providers config command - Configure API keys with secure storage
3
3
  */
4
4
  export declare function configureProvider(provider?: string): Promise<void>;
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Providers config command - Configure API keys
2
+ * Providers config command - Configure API keys with secure storage
3
3
  */
4
4
  import chalk from 'chalk';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
8
8
  import * as readline from 'readline';
9
+ import { SecureKeyStorage } from '../../utils/secure-storage.js';
9
10
  const CONFIG_DIR = path.join(os.homedir(), '.recoder-code');
10
11
  const ENV_FILE = path.join(CONFIG_DIR, '.env');
11
12
  export async function configureProvider(provider) {
@@ -19,6 +20,13 @@ export async function configureProvider(provider) {
19
20
  console.log();
20
21
  console.log(chalk.cyan('Usage: recoder providers config <provider>'));
21
22
  console.log();
23
+ // Show stored keys
24
+ const stored = await SecureKeyStorage.list();
25
+ if (stored.length > 0) {
26
+ console.log(chalk.green('Configured providers:'));
27
+ stored.forEach(p => console.log(chalk.gray(` āœ“ ${p}`)));
28
+ console.log();
29
+ }
22
30
  return;
23
31
  }
24
32
  const envVars = {
@@ -36,30 +44,40 @@ export async function configureProvider(provider) {
36
44
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
37
45
  const question = (q) => new Promise((resolve) => rl.question(q, resolve));
38
46
  console.log(chalk.bold.cyan(`\nāš™ļø Configure ${provider}\n`));
47
+ console.log(chalk.gray('API key will be stored securely in keychain\n'));
39
48
  const apiKey = await question(chalk.white(`Enter ${envVar}: `));
40
49
  rl.close();
41
50
  if (!apiKey.trim()) {
42
51
  console.log(chalk.yellow('\nNo key provided, skipping.\n'));
43
52
  return;
44
53
  }
45
- // Save to env file
46
- if (!fs.existsSync(CONFIG_DIR)) {
47
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
48
- }
49
- let envContent = '';
50
- if (fs.existsSync(ENV_FILE)) {
51
- envContent = fs.readFileSync(ENV_FILE, 'utf-8');
52
- }
53
- // Update or add the key
54
- const regex = new RegExp(`^${envVar}=.*$`, 'm');
55
- if (regex.test(envContent)) {
56
- envContent = envContent.replace(regex, `${envVar}=${apiKey.trim()}`);
54
+ try {
55
+ // Store in secure keychain
56
+ await SecureKeyStorage.set(provider.toLowerCase(), apiKey.trim());
57
+ // Also save to env file for backward compatibility
58
+ if (!fs.existsSync(CONFIG_DIR)) {
59
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
60
+ }
61
+ let envContent = '';
62
+ if (fs.existsSync(ENV_FILE)) {
63
+ envContent = fs.readFileSync(ENV_FILE, 'utf-8');
64
+ }
65
+ // Update or add the key
66
+ const regex = new RegExp(`^${envVar}=.*$`, 'm');
67
+ if (regex.test(envContent)) {
68
+ envContent = envContent.replace(regex, `${envVar}=${apiKey.trim()}`);
69
+ }
70
+ else {
71
+ envContent += `\n${envVar}=${apiKey.trim()}`;
72
+ }
73
+ fs.writeFileSync(ENV_FILE, envContent.trim() + '\n');
74
+ console.log(chalk.green(`\nāœ“ API key stored securely`));
75
+ console.log(chalk.gray(` Keychain: ${process.platform === 'darwin' ? 'macOS Keychain' : 'Encrypted file'}`));
76
+ console.log(chalk.gray(` Fallback: ${ENV_FILE}`));
77
+ console.log();
57
78
  }
58
- else {
59
- envContent += `\n${envVar}=${apiKey.trim()}`;
79
+ catch (err) {
80
+ console.log(chalk.red(`\nāŒ Error: ${err.message}`));
81
+ console.log();
60
82
  }
61
- fs.writeFileSync(ENV_FILE, envContent.trim() + '\n');
62
- console.log(chalk.green(`\nāœ“ Saved to ${ENV_FILE}`));
63
- console.log(chalk.gray(` Add to shell: export ${envVar}="${apiKey.trim()}"`));
64
- console.log();
65
83
  }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Provider health monitoring
3
+ */
4
+ export declare function monitorProviders(): Promise<void>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Provider health monitoring
3
+ */
4
+ import chalk from 'chalk';
5
+ import { getProviderRegistry } from '../../providers/registry.js';
6
+ async function checkProviderHealth(provider) {
7
+ const start = Date.now();
8
+ try {
9
+ const controller = new AbortController();
10
+ const timeout = setTimeout(() => controller.abort(), 5000);
11
+ let endpoint = provider.baseUrl;
12
+ if (provider.engine === 'openai') {
13
+ endpoint = `${provider.baseUrl}/models`;
14
+ }
15
+ else if (provider.engine === 'ollama') {
16
+ endpoint = `${provider.baseUrl}/api/tags`;
17
+ }
18
+ const response = await fetch(endpoint, {
19
+ method: 'GET',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ ...provider.headers,
23
+ },
24
+ signal: controller.signal,
25
+ });
26
+ clearTimeout(timeout);
27
+ const responseTime = Date.now() - start;
28
+ if (response.ok) {
29
+ return {
30
+ provider: provider.id,
31
+ name: provider.name,
32
+ status: 'online',
33
+ responseTime,
34
+ };
35
+ }
36
+ else {
37
+ return {
38
+ provider: provider.id,
39
+ name: provider.name,
40
+ status: 'error',
41
+ error: `HTTP ${response.status}`,
42
+ };
43
+ }
44
+ }
45
+ catch (err) {
46
+ return {
47
+ provider: provider.id,
48
+ name: provider.name,
49
+ status: 'offline',
50
+ error: err.message || 'Connection failed',
51
+ };
52
+ }
53
+ }
54
+ export async function monitorProviders() {
55
+ console.log(chalk.bold.cyan('\nšŸ„ Provider Health Monitor\n'));
56
+ console.log(chalk.gray('Checking all providers...\n'));
57
+ const registry = getProviderRegistry();
58
+ const providers = registry.getAllProviders();
59
+ const results = [];
60
+ for (const provider of providers) {
61
+ const status = await checkProviderHealth(provider);
62
+ results.push(status);
63
+ }
64
+ // Display results
65
+ console.log(chalk.bold('Status Report:'));
66
+ console.log(chalk.gray('─'.repeat(60)));
67
+ const online = results.filter(r => r.status === 'online');
68
+ const offline = results.filter(r => r.status === 'offline');
69
+ const errors = results.filter(r => r.status === 'error');
70
+ online.forEach(r => {
71
+ console.log(chalk.green('āœ“') + ' ' +
72
+ chalk.bold(r.name) + ' ' +
73
+ chalk.gray(`(${r.responseTime}ms)`));
74
+ });
75
+ errors.forEach(r => {
76
+ console.log(chalk.yellow('⚠') + ' ' +
77
+ chalk.bold(r.name) + ' ' +
78
+ chalk.gray(`- ${r.error}`));
79
+ });
80
+ offline.forEach(r => {
81
+ console.log(chalk.red('āœ—') + ' ' +
82
+ chalk.bold(r.name) + ' ' +
83
+ chalk.gray(`- ${r.error}`));
84
+ });
85
+ console.log(chalk.gray('─'.repeat(60)));
86
+ console.log(chalk.green(`${online.length} online`) + ' | ' +
87
+ chalk.yellow(`${errors.length} errors`) + ' | ' +
88
+ chalk.red(`${offline.length} offline`));
89
+ console.log();
90
+ }
@@ -6,6 +6,8 @@ import { listProviderModels } from './providers/models.js';
6
6
  import { pullModel } from './providers/pull.js';
7
7
  import { configureProvider } from './providers/config.js';
8
8
  import { RecoderAuthService } from '../services/RecoderAuthService.js';
9
+ import { detectAndReport } from '../providers/local-detection.js';
10
+ import { monitorProviders } from './providers/health.js';
9
11
  const listCommand = {
10
12
  command: 'list',
11
13
  describe: 'List all available providers',
@@ -20,6 +22,22 @@ const listCommand = {
20
22
  process.exit(0);
21
23
  },
22
24
  };
25
+ const detectCommand = {
26
+ command: 'detect',
27
+ describe: 'Detect running local AI servers',
28
+ handler: async () => {
29
+ await detectAndReport();
30
+ process.exit(0);
31
+ },
32
+ };
33
+ const healthCommand = {
34
+ command: 'health',
35
+ describe: 'Monitor provider health status',
36
+ handler: async () => {
37
+ await monitorProviders();
38
+ process.exit(0);
39
+ },
40
+ };
23
41
  const modelsCommand = {
24
42
  command: 'models [provider]',
25
43
  describe: 'List models from all providers',
@@ -80,6 +98,8 @@ export const providersCommand = {
80
98
  describe: 'Manage AI providers (Ollama, OpenRouter, etc.)',
81
99
  builder: (yargs) => yargs
82
100
  .command(listCommand)
101
+ .command(detectCommand)
102
+ .command(healthCommand)
83
103
  .command(modelsCommand)
84
104
  .command(pullCommand)
85
105
  .command(configCommand)
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Local Provider Detection - Auto-detect running local AI servers
3
+ */
4
+ import type { AIProvider } from './types.js';
5
+ export declare function detectLocalProviders(): Promise<AIProvider[]>;
6
+ export declare function detectAndReport(): Promise<void>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Local Provider Detection - Auto-detect running local AI servers
3
+ */
4
+ const LOCAL_PROVIDERS = [
5
+ {
6
+ id: 'lmstudio',
7
+ name: 'LM Studio',
8
+ port: 1234,
9
+ baseUrl: 'http://localhost:1234/v1',
10
+ engine: 'openai',
11
+ },
12
+ {
13
+ id: 'ollama',
14
+ name: 'Ollama',
15
+ port: 11434,
16
+ baseUrl: 'http://localhost:11434',
17
+ engine: 'ollama',
18
+ },
19
+ {
20
+ id: 'llamacpp',
21
+ name: 'llama.cpp',
22
+ port: 8080,
23
+ baseUrl: 'http://localhost:8080/v1',
24
+ engine: 'openai',
25
+ },
26
+ {
27
+ id: 'vllm',
28
+ name: 'vLLM',
29
+ port: 8000,
30
+ baseUrl: 'http://localhost:8000/v1',
31
+ engine: 'openai',
32
+ },
33
+ ];
34
+ async function checkServer(url) {
35
+ try {
36
+ const controller = new AbortController();
37
+ const timeout = setTimeout(() => controller.abort(), 1000);
38
+ const response = await fetch(url, {
39
+ method: 'GET',
40
+ signal: controller.signal,
41
+ });
42
+ clearTimeout(timeout);
43
+ return response.ok;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ export async function detectLocalProviders() {
50
+ const detected = [];
51
+ for (const provider of LOCAL_PROVIDERS) {
52
+ const isRunning = await checkServer(provider.baseUrl);
53
+ if (isRunning) {
54
+ detected.push({
55
+ id: provider.id,
56
+ name: provider.name,
57
+ engine: provider.engine,
58
+ baseUrl: provider.baseUrl,
59
+ isLocal: true,
60
+ isEnabled: true,
61
+ isBuiltin: false,
62
+ supportsStreaming: true,
63
+ });
64
+ }
65
+ }
66
+ return detected;
67
+ }
68
+ export async function detectAndReport() {
69
+ console.log('šŸ” Detecting local AI servers...\n');
70
+ const detected = await detectLocalProviders();
71
+ if (detected.length === 0) {
72
+ console.log('āŒ No local AI servers detected');
73
+ console.log('\nšŸ’” Start one of these:');
74
+ LOCAL_PROVIDERS.forEach(p => {
75
+ console.log(` • ${p.name} (port ${p.port})`);
76
+ });
77
+ }
78
+ else {
79
+ console.log('āœ… Detected local servers:\n');
80
+ detected.forEach(p => {
81
+ console.log(` • ${p.name} - ${p.baseUrl}`);
82
+ });
83
+ console.log('\nšŸ’” Use with: recoder --provider ' + detected[0].id);
84
+ }
85
+ console.log();
86
+ }
@@ -11,8 +11,8 @@ const PROVIDERS_FILE = path.join(CONFIG_DIR, 'providers.json');
11
11
  const CUSTOM_PROVIDERS_DIR = path.join(CONFIG_DIR, 'custom_providers');
12
12
  const DEFAULT_CONFIG = {
13
13
  version: '1.0',
14
- defaultProvider: 'anthropic',
15
- defaultModel: 'claude-sonnet-4-20250514',
14
+ defaultProvider: 'openrouter',
15
+ defaultModel: 'anthropic/claude-sonnet-4-20250514',
16
16
  customProviders: [],
17
17
  lastUpdated: new Date().toISOString(),
18
18
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Secure keychain storage for API keys
3
+ * Uses macOS Keychain on macOS, encrypted file on other platforms
4
+ */
5
+ export declare class SecureKeyStorage {
6
+ /**
7
+ * Store API key securely
8
+ */
9
+ static set(provider: string, apiKey: string): Promise<void>;
10
+ /**
11
+ * Retrieve API key
12
+ */
13
+ static get(provider: string): Promise<string | null>;
14
+ /**
15
+ * Delete API key
16
+ */
17
+ static delete(provider: string): Promise<void>;
18
+ /**
19
+ * List all stored providers
20
+ */
21
+ static list(): Promise<string[]>;
22
+ private static loadEncryptedFile;
23
+ private static saveEncryptedFile;
24
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Secure keychain storage for API keys
3
+ * Uses macOS Keychain on macOS, encrypted file on other platforms
4
+ */
5
+ import { execSync } from 'child_process';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import os from 'os';
9
+ import crypto from 'crypto';
10
+ const KEYCHAIN_SERVICE = 'xyz.recoder.cli';
11
+ const ENCRYPTED_FILE = path.join(os.homedir(), '.recoder-code', '.keys.enc');
12
+ const ALGORITHM = 'aes-256-gcm';
13
+ function isMacOS() {
14
+ return process.platform === 'darwin';
15
+ }
16
+ function getMachineId() {
17
+ try {
18
+ if (isMacOS()) {
19
+ return execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', { encoding: 'utf-8' })
20
+ .split('=')[1]
21
+ .trim()
22
+ .replace(/"/g, '');
23
+ }
24
+ return os.hostname() + os.userInfo().username;
25
+ }
26
+ catch {
27
+ return os.hostname();
28
+ }
29
+ }
30
+ function getEncryptionKey() {
31
+ const machineId = getMachineId();
32
+ return crypto.scryptSync(machineId, 'recoder-salt', 32);
33
+ }
34
+ export class SecureKeyStorage {
35
+ /**
36
+ * Store API key securely
37
+ */
38
+ static async set(provider, apiKey) {
39
+ if (isMacOS()) {
40
+ try {
41
+ // Try to delete existing first
42
+ try {
43
+ execSync(`security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}" 2>/dev/null`);
44
+ }
45
+ catch {
46
+ // Ignore if doesn't exist
47
+ }
48
+ // Add new key
49
+ execSync(`security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}" -w "${apiKey}"`);
50
+ }
51
+ catch (err) {
52
+ throw new Error(`Failed to store key in keychain: ${err}`);
53
+ }
54
+ }
55
+ else {
56
+ // Encrypted file storage for non-macOS
57
+ const keys = this.loadEncryptedFile();
58
+ keys[provider] = apiKey;
59
+ this.saveEncryptedFile(keys);
60
+ }
61
+ }
62
+ /**
63
+ * Retrieve API key
64
+ */
65
+ static async get(provider) {
66
+ if (isMacOS()) {
67
+ try {
68
+ const result = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}" -w`, { encoding: 'utf-8' });
69
+ return result.trim();
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ else {
76
+ const keys = this.loadEncryptedFile();
77
+ return keys[provider] || null;
78
+ }
79
+ }
80
+ /**
81
+ * Delete API key
82
+ */
83
+ static async delete(provider) {
84
+ if (isMacOS()) {
85
+ try {
86
+ execSync(`security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${provider}"`);
87
+ }
88
+ catch {
89
+ // Ignore if doesn't exist
90
+ }
91
+ }
92
+ else {
93
+ const keys = this.loadEncryptedFile();
94
+ delete keys[provider];
95
+ this.saveEncryptedFile(keys);
96
+ }
97
+ }
98
+ /**
99
+ * List all stored providers
100
+ */
101
+ static async list() {
102
+ if (isMacOS()) {
103
+ try {
104
+ const result = execSync(`security dump-keychain | grep -A 1 "${KEYCHAIN_SERVICE}" | grep "acct" | cut -d'"' -f4`, { encoding: 'utf-8' });
105
+ return result.trim().split('\n').filter(Boolean);
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ else {
112
+ const keys = this.loadEncryptedFile();
113
+ return Object.keys(keys);
114
+ }
115
+ }
116
+ static loadEncryptedFile() {
117
+ try {
118
+ if (!fs.existsSync(ENCRYPTED_FILE))
119
+ return {};
120
+ const encrypted = fs.readFileSync(ENCRYPTED_FILE, 'utf-8');
121
+ const [ivHex, authTagHex, encryptedData] = encrypted.split(':');
122
+ const key = getEncryptionKey();
123
+ const iv = Buffer.from(ivHex, 'hex');
124
+ const authTag = Buffer.from(authTagHex, 'hex');
125
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
126
+ decipher.setAuthTag(authTag);
127
+ let decrypted = decipher.update(encryptedData, 'hex', 'utf-8');
128
+ decrypted += decipher.final('utf-8');
129
+ return JSON.parse(decrypted);
130
+ }
131
+ catch {
132
+ return {};
133
+ }
134
+ }
135
+ static saveEncryptedFile(keys) {
136
+ const dir = path.dirname(ENCRYPTED_FILE);
137
+ if (!fs.existsSync(dir)) {
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ }
140
+ const key = getEncryptionKey();
141
+ const iv = crypto.randomBytes(16);
142
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
143
+ let encrypted = cipher.update(JSON.stringify(keys), 'utf-8', 'hex');
144
+ encrypted += cipher.final('hex');
145
+ const authTag = cipher.getAuthTag();
146
+ const output = `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
147
+ fs.writeFileSync(ENCRYPTED_FILE, output, 'utf-8');
148
+ fs.chmodSync(ENCRYPTED_FILE, 0o600); // Owner read/write only
149
+ }
150
+ }