recoder-code 2.5.0 ā 2.5.2
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/dist/src/commands/agents/marketplace.d.ts +5 -0
- package/dist/src/commands/agents/marketplace.js +151 -0
- package/dist/src/commands/agents.js +34 -0
- package/dist/src/commands/configure.js +8 -0
- package/dist/src/commands/connect-cmd.js +8 -0
- package/dist/src/commands/hints.js +12 -0
- package/dist/src/commands/models-cmd.js +15 -0
- package/dist/src/commands/providers/config.d.ts +1 -1
- package/dist/src/commands/providers/config.js +37 -19
- package/dist/src/ui/commands/docsCommand.js +1 -1
- package/dist/src/utils/secure-storage.d.ts +24 -0
- package/dist/src/utils/secure-storage.js +150 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -3
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent marketplace - Share and discover custom agents
|
|
3
|
+
*/
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
const AGENTS_DIR_PROJECT = '.recoder/agents';
|
|
10
|
+
const AGENTS_DIR_USER = path.join(os.homedir(), '.recoder-code', 'agents');
|
|
11
|
+
const MARKETPLACE_URL = 'https://recoder.xyz/api/agents';
|
|
12
|
+
export async function browseMarketplace() {
|
|
13
|
+
console.log(chalk.bold.cyan('\nšŖ Agent Marketplace\n'));
|
|
14
|
+
console.log(chalk.gray('Discover and install community agents\n'));
|
|
15
|
+
try {
|
|
16
|
+
// Fetch agents from marketplace
|
|
17
|
+
const response = await fetch(`${MARKETPLACE_URL}/list`);
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error('Failed to fetch agents');
|
|
20
|
+
}
|
|
21
|
+
const agents = await response.json();
|
|
22
|
+
if (agents.length === 0) {
|
|
23
|
+
console.log(chalk.yellow('No agents available yet'));
|
|
24
|
+
console.log(chalk.gray('Be the first to share: recoder agents share'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const choices = agents.map(a => ({
|
|
28
|
+
name: `${a.name} - ${a.description} ${chalk.gray(`(by ${a.author})`)}`,
|
|
29
|
+
value: a.id,
|
|
30
|
+
}));
|
|
31
|
+
const { agentId } = await inquirer.prompt([
|
|
32
|
+
{
|
|
33
|
+
type: 'list',
|
|
34
|
+
name: 'agentId',
|
|
35
|
+
message: 'Select an agent to install:',
|
|
36
|
+
choices,
|
|
37
|
+
pageSize: 10,
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
const agent = agents.find(a => a.id === agentId);
|
|
41
|
+
if (!agent)
|
|
42
|
+
return;
|
|
43
|
+
const { location } = await inquirer.prompt([
|
|
44
|
+
{
|
|
45
|
+
type: 'list',
|
|
46
|
+
name: 'location',
|
|
47
|
+
message: 'Install location:',
|
|
48
|
+
choices: [
|
|
49
|
+
{ name: 'Project (.recoder/agents/)', value: 'project' },
|
|
50
|
+
{ name: 'User (~/.recoder-code/agents/)', value: 'user' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
await installAgent(agent, location);
|
|
55
|
+
console.log(chalk.green(`\nā
Installed: ${agent.name}`));
|
|
56
|
+
console.log(chalk.cyan('š” Usage:'));
|
|
57
|
+
console.log(chalk.gray(` In chat: "Let the ${agent.name} agent help"`));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.log(chalk.red(`\nā Error: ${err.message}`));
|
|
61
|
+
console.log(chalk.gray('Marketplace coming soon!'));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function shareAgent() {
|
|
65
|
+
console.log(chalk.bold.cyan('\nš¤ Share Agent to Marketplace\n'));
|
|
66
|
+
// List local agents
|
|
67
|
+
const localAgents = [];
|
|
68
|
+
if (fs.existsSync(AGENTS_DIR_PROJECT)) {
|
|
69
|
+
fs.readdirSync(AGENTS_DIR_PROJECT)
|
|
70
|
+
.filter(f => f.endsWith('.md'))
|
|
71
|
+
.forEach(f => localAgents.push(`project:${f.replace('.md', '')}`));
|
|
72
|
+
}
|
|
73
|
+
if (fs.existsSync(AGENTS_DIR_USER)) {
|
|
74
|
+
fs.readdirSync(AGENTS_DIR_USER)
|
|
75
|
+
.filter(f => f.endsWith('.md'))
|
|
76
|
+
.forEach(f => localAgents.push(`user:${f.replace('.md', '')}`));
|
|
77
|
+
}
|
|
78
|
+
if (localAgents.length === 0) {
|
|
79
|
+
console.log(chalk.yellow('No custom agents found'));
|
|
80
|
+
console.log(chalk.gray('Create one first: recoder agents new'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const { agent } = await inquirer.prompt([
|
|
84
|
+
{
|
|
85
|
+
type: 'list',
|
|
86
|
+
name: 'agent',
|
|
87
|
+
message: 'Select agent to share:',
|
|
88
|
+
choices: localAgents,
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
const [location, name] = agent.split(':');
|
|
92
|
+
const dir = location === 'project' ? AGENTS_DIR_PROJECT : AGENTS_DIR_USER;
|
|
93
|
+
const filePath = path.join(dir, `${name}.md`);
|
|
94
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
95
|
+
// Parse frontmatter
|
|
96
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
97
|
+
if (!match) {
|
|
98
|
+
console.log(chalk.red('Invalid agent format'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const frontmatter = match[1];
|
|
102
|
+
const description = frontmatter.match(/description: (.+)/)?.[1] || 'No description';
|
|
103
|
+
const { author, confirm } = await inquirer.prompt([
|
|
104
|
+
{
|
|
105
|
+
type: 'input',
|
|
106
|
+
name: 'author',
|
|
107
|
+
message: 'Your name:',
|
|
108
|
+
validate: (input) => input.length > 0 || 'Name required',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'confirm',
|
|
112
|
+
name: 'confirm',
|
|
113
|
+
message: `Share "${name}" to marketplace?`,
|
|
114
|
+
default: true,
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
if (!confirm) {
|
|
118
|
+
console.log(chalk.yellow('\nā Cancelled'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(`${MARKETPLACE_URL}/share`, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
name,
|
|
127
|
+
description,
|
|
128
|
+
author,
|
|
129
|
+
content,
|
|
130
|
+
version: '1.0.0',
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error('Failed to share agent');
|
|
135
|
+
}
|
|
136
|
+
console.log(chalk.green(`\nā
Agent shared successfully!`));
|
|
137
|
+
console.log(chalk.gray(' View at: https://recoder.xyz/agents'));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
console.log(chalk.red(`\nā Error: ${err.message}`));
|
|
141
|
+
console.log(chalk.gray('Marketplace coming soon!'));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function installAgent(agent, location) {
|
|
145
|
+
const dir = location === 'project' ? AGENTS_DIR_PROJECT : AGENTS_DIR_USER;
|
|
146
|
+
if (!fs.existsSync(dir)) {
|
|
147
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
148
|
+
}
|
|
149
|
+
const filePath = path.join(dir, `${agent.name}.md`);
|
|
150
|
+
fs.writeFileSync(filePath, agent.content, 'utf-8');
|
|
151
|
+
}
|
|
@@ -4,10 +4,21 @@
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { listAgents, createAgentFromTemplate } from './agents/list.js';
|
|
6
6
|
import { createAgent } from './agents/create.js';
|
|
7
|
+
import { browseMarketplace, shareAgent } from './agents/marketplace.js';
|
|
8
|
+
import { RecoderAuthService } from '../services/RecoderAuthService.js';
|
|
9
|
+
async function requireAuth() {
|
|
10
|
+
const authService = new RecoderAuthService();
|
|
11
|
+
const session = await authService.getSession();
|
|
12
|
+
if (!session) {
|
|
13
|
+
console.error('ā Please login first: recoder auth login');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
7
17
|
const listCommand = {
|
|
8
18
|
command: 'list',
|
|
9
19
|
describe: 'List all available agents',
|
|
10
20
|
handler: async () => {
|
|
21
|
+
await requireAuth();
|
|
11
22
|
await listAgents();
|
|
12
23
|
process.exit(0);
|
|
13
24
|
},
|
|
@@ -16,10 +27,29 @@ const createInteractiveCommand = {
|
|
|
16
27
|
command: 'new',
|
|
17
28
|
describe: 'Create a custom agent interactively',
|
|
18
29
|
handler: async () => {
|
|
30
|
+
await requireAuth();
|
|
19
31
|
await createAgent();
|
|
20
32
|
process.exit(0);
|
|
21
33
|
},
|
|
22
34
|
};
|
|
35
|
+
const marketplaceCommand = {
|
|
36
|
+
command: 'browse',
|
|
37
|
+
describe: 'Browse agent marketplace',
|
|
38
|
+
handler: async () => {
|
|
39
|
+
await requireAuth();
|
|
40
|
+
await browseMarketplace();
|
|
41
|
+
process.exit(0);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const shareCommand = {
|
|
45
|
+
command: 'share',
|
|
46
|
+
describe: 'Share your agent to marketplace',
|
|
47
|
+
handler: async () => {
|
|
48
|
+
await requireAuth();
|
|
49
|
+
await shareAgent();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
23
53
|
const createCommand = {
|
|
24
54
|
command: 'create <name>',
|
|
25
55
|
describe: 'Create a new agent from template',
|
|
@@ -38,6 +68,7 @@ const createCommand = {
|
|
|
38
68
|
default: 'project',
|
|
39
69
|
}),
|
|
40
70
|
handler: async (argv) => {
|
|
71
|
+
await requireAuth();
|
|
41
72
|
const location = argv.location === 'user' ? 'user' : 'project';
|
|
42
73
|
const filePath = createAgentFromTemplate(argv.template || 'coder', argv.name, location);
|
|
43
74
|
if (filePath) {
|
|
@@ -59,10 +90,13 @@ export const agentsCommand = {
|
|
|
59
90
|
builder: (yargs) => yargs
|
|
60
91
|
.command(listCommand)
|
|
61
92
|
.command(createInteractiveCommand)
|
|
93
|
+
.command(marketplaceCommand)
|
|
94
|
+
.command(shareCommand)
|
|
62
95
|
.command(createCommand)
|
|
63
96
|
.demandCommand(0)
|
|
64
97
|
.version(false),
|
|
65
98
|
handler: async () => {
|
|
99
|
+
await requireAuth();
|
|
66
100
|
await listAgents();
|
|
67
101
|
process.exit(0);
|
|
68
102
|
},
|
|
@@ -4,10 +4,18 @@
|
|
|
4
4
|
import * as readline from 'readline';
|
|
5
5
|
import { getProviderRegistry } from '../providers/registry.js';
|
|
6
6
|
import { BUILTIN_PROVIDERS } from '../providers/types.js';
|
|
7
|
+
import { RecoderAuthService } from '../services/RecoderAuthService.js';
|
|
7
8
|
export const configureCommand = {
|
|
8
9
|
command: 'configure',
|
|
9
10
|
describe: 'Interactive configuration wizard',
|
|
10
11
|
handler: async () => {
|
|
12
|
+
// Require authentication
|
|
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
|
+
}
|
|
11
19
|
const rl = readline.createInterface({
|
|
12
20
|
input: process.stdin,
|
|
13
21
|
output: process.stdout,
|
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
* Connect command module - Add custom providers interactively
|
|
3
3
|
*/
|
|
4
4
|
import { connectProvider } from './connect.js';
|
|
5
|
+
import { RecoderAuthService } from '../services/RecoderAuthService.js';
|
|
5
6
|
export const connectCommand = {
|
|
6
7
|
command: 'connect',
|
|
7
8
|
describe: 'Add a custom AI provider (LM Studio, vLLM, etc.)',
|
|
8
9
|
handler: async () => {
|
|
10
|
+
// Require authentication
|
|
11
|
+
const authService = new RecoderAuthService();
|
|
12
|
+
const session = await authService.getSession();
|
|
13
|
+
if (!session) {
|
|
14
|
+
console.error('ā Please login first: recoder auth login');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
9
17
|
await connectProvider();
|
|
10
18
|
process.exit(0);
|
|
11
19
|
},
|
|
@@ -3,10 +3,20 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { loadHints, createHintsFile, hasHints } from '../utils/hints.js';
|
|
6
|
+
import { RecoderAuthService } from '../services/RecoderAuthService.js';
|
|
7
|
+
async function requireAuth() {
|
|
8
|
+
const authService = new RecoderAuthService();
|
|
9
|
+
const session = await authService.getSession();
|
|
10
|
+
if (!session) {
|
|
11
|
+
console.error('ā Please login first: recoder auth login');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
6
15
|
const showCommand = {
|
|
7
16
|
command: 'show',
|
|
8
17
|
describe: 'Show current project hints',
|
|
9
18
|
handler: async () => {
|
|
19
|
+
await requireAuth();
|
|
10
20
|
const hints = loadHints();
|
|
11
21
|
if (hints) {
|
|
12
22
|
console.log(chalk.cyan(`\nš Hints from ${hints.filePath}\n`));
|
|
@@ -24,6 +34,7 @@ const initCommand = {
|
|
|
24
34
|
command: 'init',
|
|
25
35
|
describe: 'Create a .recoderhints file',
|
|
26
36
|
handler: async () => {
|
|
37
|
+
await requireAuth();
|
|
27
38
|
if (hasHints()) {
|
|
28
39
|
console.log(chalk.yellow('\nā ļø Hints file already exists'));
|
|
29
40
|
const hints = loadHints();
|
|
@@ -44,6 +55,7 @@ export const hintsCommand = {
|
|
|
44
55
|
describe: 'Manage project hints (.recoderhints)',
|
|
45
56
|
builder: (yargs) => yargs.command(showCommand).command(initCommand).demandCommand(0).version(false),
|
|
46
57
|
handler: async () => {
|
|
58
|
+
await requireAuth();
|
|
47
59
|
const hints = loadHints();
|
|
48
60
|
if (hints) {
|
|
49
61
|
console.log(chalk.cyan(`\nš Project hints: ${hints.filePath}`));
|
|
@@ -8,6 +8,15 @@ import * as os from 'os';
|
|
|
8
8
|
import { getOllamaProvider, getOpenRouterProvider, getAnthropicProvider, getOpenAIProvider, getGroqProvider, parseModelId, } from '../providers/index.js';
|
|
9
9
|
import { selectModel } from './models/select.js';
|
|
10
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
|
+
}
|
|
11
20
|
const CONFIG_DIR = path.join(os.homedir(), '.recoder-code');
|
|
12
21
|
const CUSTOM_MODELS_FILE = path.join(CONFIG_DIR, 'custom-models.json');
|
|
13
22
|
const DEFAULT_MODEL_FILE = path.join(CONFIG_DIR, 'default-model.json');
|
|
@@ -47,6 +56,7 @@ const listCommand = {
|
|
|
47
56
|
type: 'string',
|
|
48
57
|
}),
|
|
49
58
|
handler: async (argv) => {
|
|
59
|
+
await requireAuth();
|
|
50
60
|
console.log(chalk.bold.cyan('\nš¤ Available Models\n'));
|
|
51
61
|
const defaultModel = getDefaultModel();
|
|
52
62
|
if (defaultModel) {
|
|
@@ -122,6 +132,7 @@ const addCommand = {
|
|
|
122
132
|
.option('name', { describe: 'Display name', type: 'string' })
|
|
123
133
|
.option('provider', { describe: 'Provider', type: 'string' }),
|
|
124
134
|
handler: async (argv) => {
|
|
135
|
+
await requireAuth();
|
|
125
136
|
const parsed = parseModelId(argv.model);
|
|
126
137
|
const models = loadCustomModels();
|
|
127
138
|
if (models.some((m) => m.id === argv.model)) {
|
|
@@ -143,6 +154,7 @@ const removeCommand = {
|
|
|
143
154
|
describe: 'Remove a custom model',
|
|
144
155
|
builder: (yargs) => yargs.positional('model', { describe: 'Model ID', type: 'string', demandOption: true }),
|
|
145
156
|
handler: async (argv) => {
|
|
157
|
+
await requireAuth();
|
|
146
158
|
const models = loadCustomModels();
|
|
147
159
|
const filtered = models.filter((m) => m.id !== argv.model);
|
|
148
160
|
if (filtered.length === models.length) {
|
|
@@ -159,6 +171,7 @@ const setDefaultCommand = {
|
|
|
159
171
|
describe: 'Set default model',
|
|
160
172
|
builder: (yargs) => yargs.positional('model', { describe: 'Model ID', type: 'string', demandOption: true }),
|
|
161
173
|
handler: async (argv) => {
|
|
174
|
+
await requireAuth();
|
|
162
175
|
setDefaultModel(argv.model);
|
|
163
176
|
console.log(chalk.green(`\nā Default model set to ${argv.model}\n`));
|
|
164
177
|
process.exit(0);
|
|
@@ -168,6 +181,7 @@ const selectCommand = {
|
|
|
168
181
|
command: 'select',
|
|
169
182
|
describe: 'Interactive model selection with fuzzy search',
|
|
170
183
|
handler: async () => {
|
|
184
|
+
await requireAuth();
|
|
171
185
|
await selectModel();
|
|
172
186
|
process.exit(0);
|
|
173
187
|
},
|
|
@@ -176,6 +190,7 @@ const compareCommand = {
|
|
|
176
190
|
command: 'compare',
|
|
177
191
|
describe: 'Compare multiple models side-by-side',
|
|
178
192
|
handler: async () => {
|
|
193
|
+
await requireAuth();
|
|
179
194
|
await compareModels();
|
|
180
195
|
process.exit(0);
|
|
181
196
|
},
|
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
}
|
|
@@ -12,7 +12,7 @@ export const docsCommand = {
|
|
|
12
12
|
description: 'open full Recoder Code documentation in your browser',
|
|
13
13
|
kind: CommandKind.BUILT_IN,
|
|
14
14
|
action: async (context) => {
|
|
15
|
-
const docsUrl = 'https://github.com/
|
|
15
|
+
const docsUrl = 'https://github.com/recoderxyz/recoder-code#readme';
|
|
16
16
|
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
|
17
17
|
context.ui.addItem({
|
|
18
18
|
type: MessageType.INFO,
|
|
@@ -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
|
+
}
|