@skroyc/librarian 0.1.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/CHANGELOG.md +176 -0
- package/LICENSE +210 -0
- package/README.md +614 -0
- package/biome.jsonc +9 -0
- package/dist/agents/context-schema.d.ts +17 -0
- package/dist/agents/context-schema.d.ts.map +1 -0
- package/dist/agents/context-schema.js +16 -0
- package/dist/agents/context-schema.js.map +1 -0
- package/dist/agents/react-agent.d.ts +38 -0
- package/dist/agents/react-agent.d.ts.map +1 -0
- package/dist/agents/react-agent.js +719 -0
- package/dist/agents/react-agent.js.map +1 -0
- package/dist/agents/tool-runtime.d.ts +7 -0
- package/dist/agents/tool-runtime.d.ts.map +1 -0
- package/dist/agents/tool-runtime.js +2 -0
- package/dist/agents/tool-runtime.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +172 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +243 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +470 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/file-finding.tool.d.ts +24 -0
- package/dist/tools/file-finding.tool.d.ts.map +1 -0
- package/dist/tools/file-finding.tool.js +198 -0
- package/dist/tools/file-finding.tool.js.map +1 -0
- package/dist/tools/file-listing.tool.d.ts +12 -0
- package/dist/tools/file-listing.tool.d.ts.map +1 -0
- package/dist/tools/file-listing.tool.js +132 -0
- package/dist/tools/file-listing.tool.js.map +1 -0
- package/dist/tools/file-reading.tool.d.ts +9 -0
- package/dist/tools/file-reading.tool.d.ts.map +1 -0
- package/dist/tools/file-reading.tool.js +112 -0
- package/dist/tools/file-reading.tool.js.map +1 -0
- package/dist/tools/grep-content.tool.d.ts +27 -0
- package/dist/tools/grep-content.tool.d.ts.map +1 -0
- package/dist/tools/grep-content.tool.js +229 -0
- package/dist/tools/grep-content.tool.js.map +1 -0
- package/dist/utils/file-utils.d.ts +2 -0
- package/dist/utils/file-utils.d.ts.map +1 -0
- package/dist/utils/file-utils.js +28 -0
- package/dist/utils/file-utils.js.map +1 -0
- package/dist/utils/logger.d.ts +32 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +177 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/path-utils.d.ts +2 -0
- package/dist/utils/path-utils.d.ts.map +1 -0
- package/dist/utils/path-utils.js +9 -0
- package/dist/utils/path-utils.js.map +1 -0
- package/package.json +84 -0
- package/src/agents/context-schema.ts +61 -0
- package/src/agents/react-agent.ts +928 -0
- package/src/agents/tool-runtime.ts +21 -0
- package/src/cli.ts +206 -0
- package/src/config.ts +309 -0
- package/src/index.ts +628 -0
- package/src/tools/file-finding.tool.ts +324 -0
- package/src/tools/file-listing.tool.ts +212 -0
- package/src/tools/file-reading.tool.ts +154 -0
- package/src/tools/grep-content.tool.ts +325 -0
- package/src/utils/file-utils.ts +39 -0
- package/src/utils/logger.ts +295 -0
- package/src/utils/path-utils.ts +17 -0
- package/tsconfig.json +37 -0
- package/tsconfig.test.json +17 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Context } from "./context-schema.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tool Runtime Context
|
|
5
|
+
*
|
|
6
|
+
* Provides the static runtime context passed to the agent during invocation.
|
|
7
|
+
* Tools can access this through the config parameter.
|
|
8
|
+
*/
|
|
9
|
+
export interface ToolRuntime {
|
|
10
|
+
/** The context object containing working directory and metadata */
|
|
11
|
+
context?: Context;
|
|
12
|
+
|
|
13
|
+
/** Additional runtime properties if needed for future extensions */
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Type for the config parameter passed to tool functions
|
|
19
|
+
* This aligns with LangChain's tool() function signature
|
|
20
|
+
*/
|
|
21
|
+
export type ToolConfig = ToolRuntime | undefined;
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { Librarian } from './index.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { logger } from './utils/logger.js';
|
|
7
|
+
import { expandTilde } from './utils/path-utils.js';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
|
|
11
|
+
async function handleStreamingOutput(stream: AsyncIterable<string>): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
for await (const chunk of stream) {
|
|
14
|
+
Bun.stdout.write(new TextEncoder().encode(chunk));
|
|
15
|
+
}
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error('\nStreaming interrupted or failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function handleTechnologyQuery(
|
|
23
|
+
librarian: Librarian,
|
|
24
|
+
techName: string,
|
|
25
|
+
query: string,
|
|
26
|
+
noStream: boolean,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
if (noStream) {
|
|
29
|
+
const result = await librarian.queryRepository(techName, query);
|
|
30
|
+
console.log(result);
|
|
31
|
+
logger.info('CLI', 'Explore query completed (non-streaming)');
|
|
32
|
+
} else {
|
|
33
|
+
await handleStreamingOutput(librarian.streamRepository(techName, query));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function handleGroupQuery(
|
|
38
|
+
librarian: Librarian,
|
|
39
|
+
groupName: string,
|
|
40
|
+
query: string,
|
|
41
|
+
noStream: boolean,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (noStream) {
|
|
44
|
+
const result = await librarian.queryGroup(groupName, query);
|
|
45
|
+
console.log(result);
|
|
46
|
+
logger.info('CLI', 'Explore group query completed (non-streaming)');
|
|
47
|
+
} else {
|
|
48
|
+
await handleStreamingOutput(librarian.streamGroup(groupName, query));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function displayTechnologies(groups: Record<string, Record<string, { description?: string; branch?: string }>>): void {
|
|
53
|
+
console.log('Available Technologies:');
|
|
54
|
+
for (const [groupName, techs] of Object.entries(groups)) {
|
|
55
|
+
if (!techs || Object.keys(techs).length === 0) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(`\n[${groupName}]`);
|
|
60
|
+
for (const [techName, details] of Object.entries(techs)) {
|
|
61
|
+
const desc = details.description ? ` - ${details.description}` : '';
|
|
62
|
+
const branch = details.branch ? ` (${details.branch})` : '';
|
|
63
|
+
console.log(` - ${techName}${branch}${desc}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createProgram() {
|
|
69
|
+
const program = new Command();
|
|
70
|
+
|
|
71
|
+
program
|
|
72
|
+
.name('librarian')
|
|
73
|
+
.description('CLI to interact with technology repositories using AI')
|
|
74
|
+
.version('0.1.0')
|
|
75
|
+
.option('--debug', 'Enable debug logging');
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command('explore')
|
|
79
|
+
.description('Explore technologies or groups')
|
|
80
|
+
.argument('<query>', 'The question or exploration query')
|
|
81
|
+
.option('-t, --tech <technology>', 'Specific technology to explore')
|
|
82
|
+
.option('-g, --group <group>', 'Technology group to explore')
|
|
83
|
+
.option('-c, --config <path>', 'Path to configuration file')
|
|
84
|
+
.option('--no-stream', 'Disable streaming output (streaming is enabled by default)')
|
|
85
|
+
.action(async (query, options) => {
|
|
86
|
+
try {
|
|
87
|
+
// Set debug mode from global options
|
|
88
|
+
const programOptions = program.opts();
|
|
89
|
+
logger.setDebugMode(programOptions.debug);
|
|
90
|
+
|
|
91
|
+
// Log command execution
|
|
92
|
+
logger.info('CLI', 'Starting explore command', {
|
|
93
|
+
queryLength: query.length,
|
|
94
|
+
tech: options.tech || null,
|
|
95
|
+
group: options.group || null,
|
|
96
|
+
noStream: options.noStream,
|
|
97
|
+
debug: programOptions.debug
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Validate: only one of --tech or --group should be specified
|
|
101
|
+
if (options.tech && options.group) {
|
|
102
|
+
logger.error('CLI', 'Cannot use both --tech and --group flags simultaneously');
|
|
103
|
+
console.error('Error: Cannot use both --tech and --group flags simultaneously');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const config = await loadConfig(options.config);
|
|
108
|
+
const librarian = new Librarian(config);
|
|
109
|
+
await librarian.initialize();
|
|
110
|
+
|
|
111
|
+
if (options.tech) {
|
|
112
|
+
const techDetails = librarian.resolveTechnology(options.tech);
|
|
113
|
+
if (!techDetails) {
|
|
114
|
+
throw new Error(`Technology ${options.tech} not found in configuration`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await handleTechnologyQuery(librarian, techDetails.name, query, options.noStream);
|
|
118
|
+
} else if (options.group) {
|
|
119
|
+
await handleGroupQuery(librarian, options.group, query, options.noStream);
|
|
120
|
+
} else {
|
|
121
|
+
console.error('Error: Either --tech or --group must be specified');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
} catch (error: unknown) {
|
|
125
|
+
if (error instanceof Error) {
|
|
126
|
+
console.error('Error:', error.message);
|
|
127
|
+
} else {
|
|
128
|
+
console.error('Error:', String(error));
|
|
129
|
+
}
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
program
|
|
135
|
+
.command('list')
|
|
136
|
+
.description('List available technologies')
|
|
137
|
+
.option('-g, --group <group>', 'Filter technologies by group')
|
|
138
|
+
.option('-c, --config <path>', 'Path to configuration file')
|
|
139
|
+
.action(async (options) => {
|
|
140
|
+
try {
|
|
141
|
+
// Set debug mode from global options
|
|
142
|
+
const programOptions = program.opts();
|
|
143
|
+
logger.setDebugMode(programOptions.debug);
|
|
144
|
+
|
|
145
|
+
logger.info('CLI', 'Starting list command', { group: options.group || null });
|
|
146
|
+
|
|
147
|
+
const config = await loadConfig(options.config);
|
|
148
|
+
|
|
149
|
+
const groupsToDisplay = options.group
|
|
150
|
+
? { [options.group]: config.technologies[options.group]! }
|
|
151
|
+
: config.technologies;
|
|
152
|
+
|
|
153
|
+
if (options.group && !config.technologies[options.group]) {
|
|
154
|
+
throw new Error(`Group ${options.group} not found in configuration`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
displayTechnologies(groupsToDisplay);
|
|
158
|
+
|
|
159
|
+
logger.info('CLI', 'List command completed');
|
|
160
|
+
} catch (error: unknown) {
|
|
161
|
+
logger.error('CLI', 'List command failed', error instanceof Error ? error : undefined);
|
|
162
|
+
if (error instanceof Error) {
|
|
163
|
+
console.error('Error:', error.message);
|
|
164
|
+
} else {
|
|
165
|
+
console.error('Error:', String(error));
|
|
166
|
+
}
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
program
|
|
172
|
+
.command('config')
|
|
173
|
+
.description('Display or update configuration')
|
|
174
|
+
.option('-p, --path', 'Show config file path')
|
|
175
|
+
.option('-c, --config <path>', 'Path to configuration file')
|
|
176
|
+
.action((options) => {
|
|
177
|
+
// Set debug mode from global options
|
|
178
|
+
const programOptions = program.opts();
|
|
179
|
+
logger.setDebugMode(programOptions.debug);
|
|
180
|
+
|
|
181
|
+
logger.info('CLI', 'Starting config command', { path: options.path });
|
|
182
|
+
|
|
183
|
+
if (options.path) {
|
|
184
|
+
const configPath = options.config
|
|
185
|
+
? path.resolve(expandTilde(options.config))
|
|
186
|
+
: path.join(os.homedir(), '.config', 'librarian', 'config.yaml');
|
|
187
|
+
console.log(`Config file path: ${configPath}`);
|
|
188
|
+
logger.info('CLI', 'Config path displayed', { configPath: configPath.replace(os.homedir(), '~') });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Handle uncaught errors globally for CLI
|
|
193
|
+
program.hook('preAction', (thisCommand) => {
|
|
194
|
+
// Set debug mode before each command
|
|
195
|
+
const opts = thisCommand.opts();
|
|
196
|
+
if (opts.debug !== undefined) {
|
|
197
|
+
logger.setDebugMode(opts.debug);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return program;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (import.meta.main) {
|
|
205
|
+
createProgram().parse();
|
|
206
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { LibrarianConfig } from './index.js';
|
|
6
|
+
import { logger } from './utils/logger.js';
|
|
7
|
+
import { expandTilde } from './utils/path-utils.js';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
const TechnologySchema = z.object({
|
|
11
|
+
repo: z.string().optional(),
|
|
12
|
+
name: z.string().optional(), // For README style
|
|
13
|
+
branch: z.string().default('main'),
|
|
14
|
+
description: z.string().optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const GroupSchema = z.record(z.string(), TechnologySchema);
|
|
18
|
+
|
|
19
|
+
const ConfigSchema = z.object({
|
|
20
|
+
technologies: z.record(z.string(), GroupSchema).optional(),
|
|
21
|
+
repositories: z.record(z.string(), z.string()).optional(), // For backward compatibility
|
|
22
|
+
aiProvider: z.object({
|
|
23
|
+
type: z.enum(['openai', 'anthropic', 'google', 'openai-compatible', 'anthropic-compatible', 'claude-code', 'gemini-cli']),
|
|
24
|
+
apiKey: z.string().optional(), // Optional - will be loaded from .env or not needed for claude-code/gemini-cli
|
|
25
|
+
model: z.string().optional(),
|
|
26
|
+
baseURL: z.string().optional(),
|
|
27
|
+
}).optional(),
|
|
28
|
+
// Support README style keys
|
|
29
|
+
llm_provider: z.enum(['openai', 'anthropic', 'google', 'openai-compatible', 'anthropic-compatible', 'claude-code', 'gemini-cli']).optional(),
|
|
30
|
+
llm_model: z.string().optional(),
|
|
31
|
+
base_url: z.string().optional(),
|
|
32
|
+
workingDir: z.string().default('./librarian_work'),
|
|
33
|
+
repos_path: z.string().optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load environment variables from a .env file using Bun native APIs
|
|
38
|
+
* Supports simple KEY=VALUE format, comments (#), and quoted values
|
|
39
|
+
*/
|
|
40
|
+
async function loadEnvFile(envPath: string): Promise<Record<string, string>> {
|
|
41
|
+
logger.debug('CONFIG', "Loading .env file", { envPath: envPath.replace(os.homedir(), '~') });
|
|
42
|
+
|
|
43
|
+
const envFile = Bun.file(envPath);
|
|
44
|
+
|
|
45
|
+
// Check if .env file exists
|
|
46
|
+
if (!(await envFile.exists())) {
|
|
47
|
+
logger.debug('CONFIG', '.env file not found, continuing without it');
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
logger.info('CONFIG', '.env file found and loading');
|
|
52
|
+
|
|
53
|
+
// Read file content
|
|
54
|
+
const content = await envFile.text();
|
|
55
|
+
const env: Record<string, string> = {};
|
|
56
|
+
|
|
57
|
+
// Parse line by line
|
|
58
|
+
for (const line of content.split('\n')) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
|
|
61
|
+
// Skip empty lines and comments
|
|
62
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Parse KEY=VALUE
|
|
67
|
+
const equalsIndex = trimmed.indexOf('=');
|
|
68
|
+
if (equalsIndex === -1) {
|
|
69
|
+
continue; // Skip malformed lines
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const key = trimmed.slice(0, equalsIndex).trim();
|
|
73
|
+
let value = trimmed.slice(equalsIndex + 1).trim();
|
|
74
|
+
|
|
75
|
+
// Remove quotes from value
|
|
76
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
77
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
78
|
+
value = value.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
env[key] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
logger.debug('CONFIG', `.env loaded: ${Object.keys(env).length} variables`);
|
|
85
|
+
return env;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateApiKey(config: LibrarianConfig, envPath: string, errors: string[]): void {
|
|
89
|
+
const isCliProvider = config.aiProvider.type === 'claude-code' || config.aiProvider.type === 'gemini-cli';
|
|
90
|
+
if (!isCliProvider && (!config.aiProvider.apiKey || config.aiProvider.apiKey.trim() === '')) {
|
|
91
|
+
const errorMsg = `API key is missing or empty. Please set LIBRARIAN_API_KEY in ${envPath}`;
|
|
92
|
+
errors.push(errorMsg);
|
|
93
|
+
logger.debug('CONFIG', 'Validation failed: API key missing', { envPath: envPath.replace(os.homedir(), '~') });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateBaseUrlForCompatibleProviders(config: LibrarianConfig, errors: string[]): void {
|
|
98
|
+
if (config.aiProvider.type === 'openai-compatible' && !config.aiProvider.baseURL) {
|
|
99
|
+
const errorMsg = 'base_url is required for openai-compatible providers';
|
|
100
|
+
errors.push(errorMsg);
|
|
101
|
+
logger.debug('CONFIG', 'Validation failed: base_url missing for openai-compatible provider');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (config.aiProvider.type === 'anthropic-compatible') {
|
|
105
|
+
if (!config.aiProvider.baseURL) {
|
|
106
|
+
const errorMsg = 'base_url is required for anthropic-compatible providers';
|
|
107
|
+
errors.push(errorMsg);
|
|
108
|
+
logger.debug('CONFIG', 'Validation failed: base_url missing for anthropic-compatible provider');
|
|
109
|
+
}
|
|
110
|
+
if (!config.aiProvider.model) {
|
|
111
|
+
const errorMsg = 'model is required for anthropic-compatible providers';
|
|
112
|
+
errors.push(errorMsg);
|
|
113
|
+
logger.debug('CONFIG', 'Validation failed: model missing for anthropic-compatible provider');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateReposPath(config: LibrarianConfig, errors: string[]): void {
|
|
119
|
+
if (!config.repos_path) {
|
|
120
|
+
errors.push('repos_path is required in configuration');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateTechnologies(config: LibrarianConfig, errors: string[]): void {
|
|
125
|
+
const hasTechnologies = config.technologies && Object.keys(config.technologies).length > 0;
|
|
126
|
+
if (!hasTechnologies) {
|
|
127
|
+
errors.push('No technologies defined in configuration');
|
|
128
|
+
// Continue to validate group names even if empty - this is intentional
|
|
129
|
+
// to catch path traversal in group names if any are defined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!config.technologies) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const [groupName, group] of Object.entries(config.technologies)) {
|
|
137
|
+
if (groupName.includes('..')) {
|
|
138
|
+
errors.push(`Group name "${groupName}" contains invalid path characters`);
|
|
139
|
+
logger.debug('CONFIG', 'Validation failed: group name contains path traversal', { groupName });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const [techName, tech] of Object.entries(group)) {
|
|
143
|
+
if (!tech.repo) {
|
|
144
|
+
const errorMsg = `Technology "${techName}" in group "${groupName}" is missing required "repo" field`;
|
|
145
|
+
errors.push(errorMsg);
|
|
146
|
+
logger.debug('CONFIG', 'Validation failed: missing repo field', { techName, groupName });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isRemoteUrl = tech.repo.startsWith('http://') || tech.repo.startsWith('https://');
|
|
151
|
+
const isFileUrl = tech.repo.startsWith('file://');
|
|
152
|
+
const hasProtocol = tech.repo.includes('://');
|
|
153
|
+
if (!(isRemoteUrl || isFileUrl) && hasProtocol) {
|
|
154
|
+
const errorMsg = `Technology "${techName}" has invalid repo URL: ${tech.repo}. Must be http://, https://, file://, or a local path`;
|
|
155
|
+
errors.push(errorMsg);
|
|
156
|
+
logger.debug('CONFIG', 'Validation failed: invalid repo URL', { techName, groupName, repoUrl: tech.repo });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function reportErrors(errors: string[]): void {
|
|
163
|
+
if (errors.length > 0) {
|
|
164
|
+
logger.error('CONFIG', 'Configuration validation failed', undefined, { errorCount: errors.length });
|
|
165
|
+
console.error('Configuration validation failed:');
|
|
166
|
+
for (const err of errors) {
|
|
167
|
+
console.error(` - ${err}`);
|
|
168
|
+
}
|
|
169
|
+
process.exit(1);
|
|
170
|
+
} else {
|
|
171
|
+
logger.info('CONFIG', 'Configuration validation passed');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Validate the configuration and exit with error if invalid
|
|
177
|
+
*/
|
|
178
|
+
function validateConfig(config: LibrarianConfig, envPath: string): void {
|
|
179
|
+
logger.debug('CONFIG', 'Validating configuration');
|
|
180
|
+
const errors: string[] = [];
|
|
181
|
+
|
|
182
|
+
validateApiKey(config, envPath, errors);
|
|
183
|
+
validateBaseUrlForCompatibleProviders(config, errors);
|
|
184
|
+
validateReposPath(config, errors);
|
|
185
|
+
validateTechnologies(config, errors);
|
|
186
|
+
|
|
187
|
+
reportErrors(errors);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type TechnologiesType = z.infer<typeof ConfigSchema>['technologies'];
|
|
191
|
+
|
|
192
|
+
function normalizeTechnologies(technologies: TechnologiesType): TechnologiesType {
|
|
193
|
+
if (!technologies) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const group of Object.values(technologies ?? {})) {
|
|
198
|
+
for (const tech of Object.values(group ?? {})) {
|
|
199
|
+
if (!tech.repo && tech.name) {
|
|
200
|
+
logger.debug('CONFIG', 'Normalizing technology: using "name" as "repo"', { name: tech.name });
|
|
201
|
+
tech.repo = tech.name;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
logger.debug('CONFIG', 'Technologies normalized', { groupCount: Object.keys(technologies).length });
|
|
206
|
+
return technologies;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildAiProvider(validatedConfig: z.infer<typeof ConfigSchema>, envVars: Record<string, string>): LibrarianConfig['aiProvider'] {
|
|
210
|
+
if (validatedConfig.aiProvider) {
|
|
211
|
+
const { type, model, baseURL } = validatedConfig.aiProvider;
|
|
212
|
+
return {
|
|
213
|
+
type,
|
|
214
|
+
apiKey: validatedConfig.aiProvider.apiKey || envVars.LIBRARIAN_API_KEY || '',
|
|
215
|
+
...(model && { model }),
|
|
216
|
+
...(baseURL && { baseURL })
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (validatedConfig.llm_provider) {
|
|
221
|
+
logger.debug('CONFIG', 'Using README-style llm_* keys for AI provider');
|
|
222
|
+
return {
|
|
223
|
+
type: validatedConfig.llm_provider,
|
|
224
|
+
apiKey: envVars.LIBRARIAN_API_KEY || '',
|
|
225
|
+
...(validatedConfig.llm_model && { model: validatedConfig.llm_model }),
|
|
226
|
+
...(validatedConfig.base_url && { baseURL: validatedConfig.base_url })
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
logger.error('CONFIG', 'AI provider is required in configuration');
|
|
231
|
+
console.error('Configuration error: llm_provider (or aiProvider) is required in config.yaml');
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function loadConfig(configPath?: string): Promise<LibrarianConfig> {
|
|
236
|
+
const defaultPath = path.join(os.homedir(), '.config', 'librarian', 'config.yaml');
|
|
237
|
+
const actualPath = configPath ? expandTilde(configPath) : defaultPath;
|
|
238
|
+
|
|
239
|
+
logger.info('CONFIG', 'Loading configuration', { configPath: actualPath.replace(os.homedir(), '~') });
|
|
240
|
+
|
|
241
|
+
const configDir = path.dirname(actualPath);
|
|
242
|
+
const envPath = path.join(configDir, '.env');
|
|
243
|
+
const envVars = await loadEnvFile(envPath);
|
|
244
|
+
|
|
245
|
+
if (!(await Bun.file(actualPath).exists())) {
|
|
246
|
+
logger.info('CONFIG', 'Config file not found, creating default config');
|
|
247
|
+
try {
|
|
248
|
+
await createDefaultConfig(actualPath);
|
|
249
|
+
logger.info('CONFIG', 'Default config created successfully');
|
|
250
|
+
} catch (error) {
|
|
251
|
+
logger.error('CONFIG', 'Failed to create default config', error instanceof Error ? error : new Error(String(error)), { actualPath: actualPath.replace(os.homedir(), '~') });
|
|
252
|
+
console.error(`Failed to create default config at ${actualPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const configFileContent = await Bun.file(actualPath).text();
|
|
258
|
+
logger.debug('CONFIG', 'Config file read successfully', { fileSize: configFileContent.length });
|
|
259
|
+
|
|
260
|
+
const parsedConfig = parse(configFileContent);
|
|
261
|
+
const validatedConfig = ConfigSchema.parse(parsedConfig);
|
|
262
|
+
logger.debug('CONFIG', 'Config schema validation passed');
|
|
263
|
+
|
|
264
|
+
const technologies = normalizeTechnologies(validatedConfig.technologies);
|
|
265
|
+
const aiProvider = buildAiProvider(validatedConfig, envVars);
|
|
266
|
+
|
|
267
|
+
logger.debug('CONFIG', 'API key source', {
|
|
268
|
+
fromEnv: !!envVars.LIBRARIAN_API_KEY,
|
|
269
|
+
fromConfig: !!validatedConfig.aiProvider?.apiKey
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const config = {
|
|
273
|
+
...validatedConfig,
|
|
274
|
+
technologies: technologies || { default: {} },
|
|
275
|
+
aiProvider,
|
|
276
|
+
repos_path: validatedConfig.repos_path ? expandTilde(validatedConfig.repos_path) : undefined,
|
|
277
|
+
workingDir: expandTilde(validatedConfig.workingDir)
|
|
278
|
+
} as LibrarianConfig;
|
|
279
|
+
|
|
280
|
+
validateConfig(config, envPath);
|
|
281
|
+
|
|
282
|
+
logger.info('CONFIG', 'Config loaded successfully', {
|
|
283
|
+
aiProviderType: config.aiProvider.type,
|
|
284
|
+
model: config.aiProvider.model,
|
|
285
|
+
techGroupsCount: Object.keys(config.technologies || {}).length,
|
|
286
|
+
reposPath: config.repos_path ? config.repos_path.replace(os.homedir(), '~') : 'workingDir'
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return config;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function createDefaultConfig(configPath: string): Promise<void> {
|
|
293
|
+
const defaultConfig = {
|
|
294
|
+
repos_path: "~/.local/share/librarian/repos",
|
|
295
|
+
aiProvider: {
|
|
296
|
+
type: "openai-compatible",
|
|
297
|
+
model: "grok-code",
|
|
298
|
+
baseURL: "https://opencode.ai/zen/v1"
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Ensure directory exists
|
|
303
|
+
const configDir = path.dirname(configPath);
|
|
304
|
+
await mkdir(configDir, { recursive: true });
|
|
305
|
+
|
|
306
|
+
// Write YAML file
|
|
307
|
+
const yamlString = stringify(defaultConfig);
|
|
308
|
+
await Bun.write(configPath, yamlString);
|
|
309
|
+
}
|