octo-dev 0.7.1 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octo-dev",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Build orchestration, semantic versioning, and local infrastructure management for repository workspaces",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,9 +43,12 @@
43
43
  "type-check": "tsc --noEmit"
44
44
  },
45
45
  "dependencies": {
46
+ "ai": "^6.0.194",
47
+ "@ai-sdk/openai": "^3.0.67",
48
+ "@ai-sdk/google": "^3.0.80",
49
+ "@ai-sdk/anthropic": "^3.0.81",
46
50
  "commander": "^13.1.0",
47
51
  "execa": "^9.6.1",
48
- "@huggingface/transformers": "^4.2.0",
49
52
  "pino": "^10.3.1",
50
53
  "pino-pretty": "^13.1.3",
51
54
  "semver": "^7.7.2",
@@ -1,10 +1,22 @@
1
1
  import { run } from '../shared/process-runner.js';
2
2
  import { logger } from '../shared/logger.js';
3
+ import { saveConfig, getConfigPath, type LlmConfig } from '../shared/config.js';
4
+ import { ask, askSecret } from '../shared/prompt.js';
5
+
6
+ const PROVIDERS: Record<string, { baseUrl: string; defaultModel: string }> = {
7
+ openai: { baseUrl: 'https://openai.com/v1', defaultModel: 'gpt-4o-mini' },
8
+ gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-2.0-flash' },
9
+ groq: { baseUrl: 'https://api.groq.com/openai/v1', defaultModel: 'llama-3.3-70b-versatile' },
10
+ anthropic: { baseUrl: 'https://api.anthropic.com/v1', defaultModel: 'claude-sonnet-4-20250514' },
11
+ custom: { baseUrl: '', defaultModel: '' },
12
+ };
3
13
 
4
14
  /**
5
- * octo config git-cache
15
+ * octo config git-cache [value]
16
+ *
17
+ * Sets git credential.helper locally.
6
18
  *
7
- * Enables git credential cache locally.
19
+ * @param value - The credential helper value (default: "cache").
8
20
  */
9
21
  export async function configGitCacheCommand(value?: string): Promise<void> {
10
22
  const helper = value || 'cache';
@@ -15,3 +27,38 @@ export async function configGitCacheCommand(value?: string): Promise<void> {
15
27
  }
16
28
  logger.info(`Git credential.helper set to "${helper}".`);
17
29
  }
30
+
31
+ /**
32
+ * octo config llm
33
+ *
34
+ * Configures the LLM provider interactively.
35
+ * Stores api key and provider settings in ~/.octo-config.json.
36
+ */
37
+ export async function configLlmCommand(): Promise<void> {
38
+ const providerNames = Object.keys(PROVIDERS);
39
+ logger.info(`Available providers: ${providerNames.join(', ')}`);
40
+
41
+ const provider = await ask('Provider: ');
42
+ if (!providerNames.includes(provider)) {
43
+ logger.error(`Unknown provider "${provider}". Available: ${providerNames.join(', ')}`);
44
+ return;
45
+ }
46
+
47
+ const preset = PROVIDERS[provider];
48
+ const baseUrl = provider === 'custom'
49
+ ? await ask('Base URL (OpenAI-compatible): ')
50
+ : preset.baseUrl;
51
+
52
+ const model = await ask(`Model [${preset.defaultModel}]: `) || preset.defaultModel;
53
+ const apiKey = await askSecret('API key: ');
54
+
55
+ if (!apiKey) {
56
+ logger.error('API key is required.');
57
+ return;
58
+ }
59
+
60
+ const llm: LlmConfig = { provider, baseUrl, model, apiKey };
61
+ saveConfig({ llm });
62
+
63
+ logger.info(`LLM configured (${provider}/${model}). Saved to ${getConfigPath()}`);
64
+ }
package/src/cli/index.ts CHANGED
@@ -11,7 +11,7 @@ const program = new Command();
11
11
  program
12
12
  .name('octo')
13
13
  .description('Build orchestration, semantic versioning, and local infrastructure management for repository workspaces')
14
- .version('0.7.1');
14
+ .version('0.8.0');
15
15
 
16
16
  program
17
17
  .command('init')
@@ -106,4 +106,12 @@ config
106
106
  await configGitCacheCommand(value);
107
107
  });
108
108
 
109
+ config
110
+ .command('llm')
111
+ .description('Configure LLM provider (openai, gemini, groq, anthropic, custom)')
112
+ .action(async () => {
113
+ const { configLlmCommand } = await import('./config.command.js');
114
+ await configLlmCommand();
115
+ });
116
+
109
117
  program.parse();
@@ -46,7 +46,7 @@ export function createComposeSmartMerger(): ComposeSmartMerger {
46
46
  }
47
47
 
48
48
  // Try LLM-based merge
49
- if (await isAvailable()) {
49
+ if (isAvailable()) {
50
50
  try {
51
51
  logger.info('Using local AI for smart compose merge...');
52
52
  const prompt = buildPrompt(composes);
@@ -0,0 +1,50 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const CONFIG_FILE = join(homedir(), '.octo-config.json');
6
+
7
+ export interface LlmConfig {
8
+ provider: string;
9
+ baseUrl: string;
10
+ model: string;
11
+ apiKey: string;
12
+ }
13
+
14
+ export interface OctoConfig {
15
+ llm?: LlmConfig;
16
+ }
17
+
18
+ /**
19
+ * Loads the octo configuration from ~/.octo-config.json.
20
+ * Returns an empty config object if the file doesn't exist or is invalid.
21
+ *
22
+ * @returns The parsed configuration.
23
+ */
24
+ export function loadConfig(): OctoConfig {
25
+ if (!existsSync(CONFIG_FILE)) return {};
26
+ try {
27
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as OctoConfig;
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Saves the octo configuration to ~/.octo-config.json.
35
+ * Merges with existing config (does not overwrite unrelated keys).
36
+ *
37
+ * @param partial - Partial config to merge and persist.
38
+ */
39
+ export function saveConfig(partial: Partial<OctoConfig>): void {
40
+ const existing = loadConfig();
41
+ const merged = { ...existing, ...partial };
42
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
43
+ }
44
+
45
+ /**
46
+ * Returns the path to the config file for display purposes.
47
+ */
48
+ export function getConfigPath(): string {
49
+ return CONFIG_FILE;
50
+ }
@@ -1,17 +1,17 @@
1
- import { ask } from './prompt.js';
1
+ import { askSecret } from './prompt.js';
2
2
 
3
3
  let cachedToken: string | undefined;
4
4
 
5
5
  /**
6
6
  * Acquires a GitHub Personal Access Token for git HTTPS operations.
7
- * Prompts the user once and caches in memory for the process lifetime.
8
- * The token is never persisted to disk.
7
+ * Prompts the user once (input hidden) and caches in memory for the process lifetime.
8
+ * The token is never persisted to disk or echoed to the terminal.
9
9
  *
10
10
  * @returns The PAT string, or undefined if the user provides empty input (skip).
11
11
  */
12
12
  export async function acquireGitToken(): Promise<string | undefined> {
13
13
  if (cachedToken) return cachedToken;
14
- const input = await ask('GitHub token (PAT): ');
14
+ const input = await askSecret('GitHub token (PAT): ');
15
15
  cachedToken = input || undefined;
16
16
  return cachedToken;
17
17
  }
package/src/shared/llm.ts CHANGED
@@ -1,69 +1,78 @@
1
- import { pipeline, type TextGenerationPipeline } from '@huggingface/transformers';
1
+ import { generateText } from 'ai';
2
+ import { createOpenAI } from '@ai-sdk/openai';
3
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
4
+ import { createAnthropic } from '@ai-sdk/anthropic';
2
5
  import { logger } from './logger.js';
3
-
4
- const MODEL_ID = 'onnx-community/Qwen2.5-0.5B-Instruct';
5
-
6
- let generator: TextGenerationPipeline | null = null;
7
- let initFailed = false;
6
+ import { loadConfig, type LlmConfig } from './config.js';
8
7
 
9
8
  /**
10
- * Lazily initializes the text generation pipeline.
11
- * Downloads the ONNX model on first use (~500MB, cached locally).
9
+ * Resolves the AI SDK language model from the stored config.
10
+ * Supports openai, gemini, groq, anthropic, and any OpenAI-compatible custom endpoint.
11
+ *
12
+ * @param config - The LLM configuration with provider, baseUrl, model, and apiKey.
13
+ * @returns An AI SDK language model instance.
12
14
  */
13
- async function getGenerator(): Promise<TextGenerationPipeline | null> {
14
- if (initFailed) return null;
15
- if (generator) return generator;
16
-
17
- try {
18
- logger.info('Loading local AI model (first run may take a while)...');
19
- generator = await pipeline('text-generation', MODEL_ID, {
20
- dtype: 'q4',
21
- }) as TextGenerationPipeline;
22
- return generator;
23
- } catch (err) {
24
- initFailed = true;
25
- const msg = err instanceof Error ? err.message : String(err);
26
- logger.info(`Local AI model unavailable: ${msg}. Using deterministic fallback.`);
27
- return null;
15
+ function resolveModel(config: LlmConfig) {
16
+ switch (config.provider) {
17
+ case 'google':
18
+ case 'gemini': {
19
+ const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
20
+ return google(config.model);
21
+ }
22
+ case 'anthropic': {
23
+ const anthropic = createAnthropic({ apiKey: config.apiKey });
24
+ return anthropic(config.model);
25
+ }
26
+ case 'openai':
27
+ case 'groq':
28
+ case 'custom':
29
+ default: {
30
+ const openai = createOpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl });
31
+ return openai(config.model);
32
+ }
28
33
  }
29
34
  }
30
35
 
31
36
  /**
32
- * Generates text from a prompt using the local ONNX model.
33
- * Returns null if the model is unavailable or generation fails.
37
+ * Generates text from a prompt using the configured LLM provider.
38
+ * Returns null if no LLM is configured or the request fails.
39
+ *
40
+ * @param prompt - The user prompt to send.
41
+ * @param maxTokens - Maximum tokens in the response.
42
+ * @returns The generated text, or null on failure.
34
43
  */
35
44
  export async function generate(prompt: string, maxTokens = 512): Promise<string | null> {
36
- const gen = await getGenerator();
37
- if (!gen) return null;
45
+ const config = loadConfig();
46
+ if (!config.llm?.apiKey) return null;
38
47
 
39
48
  try {
40
- const messages = [
41
- { role: 'user', content: prompt },
42
- ];
43
- const result = await gen(messages, {
44
- max_new_tokens: maxTokens,
45
- do_sample: false,
49
+ const model = resolveModel(config.llm);
50
+ const { text } = await generateText({
51
+ model,
52
+ prompt,
53
+ maxTokens,
54
+ temperature: 0,
46
55
  });
47
- const output = result[0]?.generated_text;
48
- if (Array.isArray(output)) {
49
- const last = output[output.length - 1];
50
- return typeof last === 'object' && 'content' in last ? String(last.content) : null;
51
- }
52
- return typeof output === 'string' ? output : null;
53
- } catch {
56
+ return text?.trim() || null;
57
+ } catch (err) {
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ logger.warn(`LLM request failed: ${msg}. Using deterministic fallback.`);
54
60
  return null;
55
61
  }
56
62
  }
57
63
 
58
64
  /**
59
65
  * Generates structured JSON output from a prompt.
60
- * Returns null if parsing fails or model is unavailable.
66
+ * Returns null if parsing fails or LLM is unavailable.
67
+ *
68
+ * @param prompt - The prompt requesting JSON output.
69
+ * @param maxTokens - Maximum tokens in the response.
70
+ * @returns Parsed JSON object, or null on failure.
61
71
  */
62
72
  export async function generateJSON<T = unknown>(prompt: string, maxTokens = 1024): Promise<T | null> {
63
73
  const text = await generate(prompt, maxTokens);
64
74
  if (!text) return null;
65
75
 
66
- // Extract JSON from the response (model may wrap in markdown code blocks)
67
76
  const jsonMatch = text.match(/\{[\s\S]*\}/);
68
77
  if (!jsonMatch) return null;
69
78
 
@@ -75,9 +84,11 @@ export async function generateJSON<T = unknown>(prompt: string, maxTokens = 1024
75
84
  }
76
85
 
77
86
  /**
78
- * Checks if the LLM is available (model loaded or loadable).
87
+ * Checks if an LLM provider is configured.
88
+ *
89
+ * @returns true if LLM config exists with a valid apiKey.
79
90
  */
80
- export async function isAvailable(): Promise<boolean> {
81
- const gen = await getGenerator();
82
- return gen !== null;
91
+ export function isAvailable(): boolean {
92
+ const config = loadConfig();
93
+ return !!config.llm?.apiKey;
83
94
  }
@@ -16,6 +16,47 @@ export function ask(message: string): Promise<string> {
16
16
  });
17
17
  }
18
18
 
19
+ /**
20
+ * Prompts the user for sensitive input (e.g. tokens, passwords).
21
+ * Input is not echoed to the terminal.
22
+ *
23
+ * @param message - The prompt message displayed to the user.
24
+ * @returns The trimmed secret string.
25
+ */
26
+ export function askSecret(message: string): Promise<string> {
27
+ return new Promise((resolve) => {
28
+ process.stdout.write(message);
29
+
30
+ const stdin = process.stdin;
31
+ const wasRaw = stdin.isRaw;
32
+ if (stdin.isTTY) stdin.setRawMode(true);
33
+ stdin.resume();
34
+ stdin.setEncoding('utf-8');
35
+
36
+ let input = '';
37
+
38
+ const onData = (char: string) => {
39
+ if (char === '\n' || char === '\r') {
40
+ stdin.removeListener('data', onData);
41
+ if (stdin.isTTY) stdin.setRawMode(wasRaw ?? false);
42
+ stdin.pause();
43
+ process.stdout.write('\n');
44
+ resolve(input.trim());
45
+ } else if (char === '\u0003') {
46
+ // Ctrl+C
47
+ process.exit(130);
48
+ } else if (char === '\u007F' || char === '\b') {
49
+ // Backspace
50
+ input = input.slice(0, -1);
51
+ } else {
52
+ input += char;
53
+ }
54
+ };
55
+
56
+ stdin.on('data', onData);
57
+ });
58
+ }
59
+
19
60
  /**
20
61
  * Prompts the user with a yes/no question via stdin.
21
62
  * Accepts 'y', 'Y', 's', 'S' as affirmative responses.
@@ -25,7 +25,7 @@ export async function ensureRepositories(manifest: OctoManifest, rootDir: string
25
25
 
26
26
  if (pending.length === 0) return 0;
27
27
 
28
- logger.info(`${pending.length} repository(ies) to clone.`);
28
+ logger.info(`${pending.length} repository(ies) to clone.\n`);
29
29
 
30
30
  // Acquire token once for all clones
31
31
  const token = await acquireGitToken();