loren-code 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/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/package.json +70 -0
- package/scripts/ClaudeWrapperLauncher.cs +78 -0
- package/scripts/claude-wrapper.js +216 -0
- package/scripts/install-claude-ollama.ps1 +184 -0
- package/scripts/loren.js +515 -0
- package/scripts/uninstall-claude-ollama.ps1 +73 -0
- package/src/bootstrap.js +30 -0
- package/src/cache.js +64 -0
- package/src/config-watcher.js +73 -0
- package/src/config.js +98 -0
- package/src/http-agents.js +80 -0
- package/src/key-manager.js +69 -0
- package/src/logger.js +46 -0
- package/src/metrics.js +210 -0
- package/src/schemas.js +66 -0
- package/src/server.js +1238 -0
- package/src/usage-tracker.js +346 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
$ErrorActionPreference = "Stop"
|
|
2
|
+
|
|
3
|
+
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
4
|
+
$userProfile = [Environment]::GetFolderPath("UserProfile")
|
|
5
|
+
$appData = [Environment]::GetFolderPath("ApplicationData")
|
|
6
|
+
$workspaceSettingsDir = Join-Path $appData "Code\\User"
|
|
7
|
+
$workspaceSettingsPath = Join-Path $workspaceSettingsDir "settings.json"
|
|
8
|
+
$claudeDir = Join-Path $userProfile ".claude"
|
|
9
|
+
$claudeSettingsPath = Join-Path $claudeDir "settings.json"
|
|
10
|
+
$launcherSourcePath = Join-Path $repoRoot "scripts\\ClaudeWrapperLauncher.cs"
|
|
11
|
+
$launcherExePath = Join-Path $repoRoot "scripts\\ClaudeWrapperLauncher.exe"
|
|
12
|
+
$envPath = Join-Path $repoRoot ".env.local"
|
|
13
|
+
|
|
14
|
+
if (-not (Test-Path $envPath)) {
|
|
15
|
+
throw ".env.local non trovato. Crea prima il file con OLLAMA_API_KEYS."
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
New-Item -ItemType Directory -Force -Path $workspaceSettingsDir | Out-Null
|
|
19
|
+
New-Item -ItemType Directory -Force -Path $claudeDir | Out-Null
|
|
20
|
+
|
|
21
|
+
function Read-JsonFile {
|
|
22
|
+
param([string]$Path)
|
|
23
|
+
|
|
24
|
+
if (-not (Test-Path $Path)) {
|
|
25
|
+
return @{}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
$raw = Get-Content -LiteralPath $Path -Raw
|
|
29
|
+
if ([string]::IsNullOrWhiteSpace($raw)) {
|
|
30
|
+
return @{}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$parsed = $raw | ConvertFrom-Json
|
|
34
|
+
if ($null -eq $parsed) {
|
|
35
|
+
return @{}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
$result = @{}
|
|
39
|
+
foreach ($property in $parsed.PSObject.Properties) {
|
|
40
|
+
$result[$property.Name] = $property.Value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return $result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Write-JsonFile {
|
|
47
|
+
param(
|
|
48
|
+
[string]$Path,
|
|
49
|
+
[hashtable]$Data
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
$json = $Data | ConvertTo-Json -Depth 20
|
|
53
|
+
Set-Content -LiteralPath $Path -Value ($json + "`n") -Encoding UTF8
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Get-EnvValue {
|
|
57
|
+
param(
|
|
58
|
+
[string]$Path,
|
|
59
|
+
[string]$Name
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
$lines = Get-Content -LiteralPath $Path
|
|
63
|
+
foreach ($line in $lines) {
|
|
64
|
+
if ($line -match "^\s*$Name=(.+)$") {
|
|
65
|
+
return $Matches[1].Trim()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return $null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Get-CSharpCompiler {
|
|
73
|
+
$candidates = @(
|
|
74
|
+
"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe",
|
|
75
|
+
"C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
foreach ($candidate in $candidates) {
|
|
79
|
+
if (Test-Path $candidate) {
|
|
80
|
+
return $candidate
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw "Compilatore C# non trovato. Impossibile generare il launcher .exe."
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Get-OllamaAvailableModels {
|
|
88
|
+
param(
|
|
89
|
+
[string]$EnvPath,
|
|
90
|
+
[hashtable]$Aliases
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
$models = [System.Collections.ArrayList]::new()
|
|
94
|
+
|
|
95
|
+
foreach ($alias in $Aliases.Keys) {
|
|
96
|
+
if (-not $models.Contains($alias)) {
|
|
97
|
+
[void]$models.Add($alias)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
foreach ($target in $Aliases.Values) {
|
|
102
|
+
if (-not [string]::IsNullOrWhiteSpace($target) -and -not $models.Contains($target)) {
|
|
103
|
+
[void]$models.Add($target)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
$apiKeysRaw = Get-EnvValue -Path $EnvPath -Name "OLLAMA_API_KEYS"
|
|
108
|
+
if (-not $apiKeysRaw) {
|
|
109
|
+
$apiKeysRaw = Get-EnvValue -Path $EnvPath -Name "OLLAMA_API_KEY"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (-not $apiKeysRaw) {
|
|
113
|
+
return $models
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
$apiKey = ($apiKeysRaw -split ",")[0].Trim()
|
|
117
|
+
if ([string]::IsNullOrWhiteSpace($apiKey)) {
|
|
118
|
+
return $models
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
$headers = @{ Authorization = "Bearer $apiKey" }
|
|
123
|
+
$response = Invoke-WebRequest -UseBasicParsing -Headers $headers "https://ollama.com/api/tags"
|
|
124
|
+
$payload = $response.Content | ConvertFrom-Json
|
|
125
|
+
foreach ($model in $payload.models) {
|
|
126
|
+
$name = [string]$model.model
|
|
127
|
+
if ([string]::IsNullOrWhiteSpace($name)) {
|
|
128
|
+
$name = [string]$model.name
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (-not [string]::IsNullOrWhiteSpace($name) -and -not $models.Contains($name)) {
|
|
132
|
+
[void]$models.Add($name)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
Write-Warning "Impossibile caricare la lista modelli da Ollama Cloud. Continuo con alias e target locali."
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return $models
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
$compilerPath = Get-CSharpCompiler
|
|
143
|
+
& $compilerPath "/nologo" "/target:exe" "/out:$launcherExePath" $launcherSourcePath | Out-Null
|
|
144
|
+
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $launcherExePath)) {
|
|
145
|
+
if (Test-Path $launcherExePath) {
|
|
146
|
+
Write-Warning "Compilazione launcher fallita, ma uso il launcher esistente."
|
|
147
|
+
} else {
|
|
148
|
+
throw "Compilazione del launcher fallita."
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
$workspaceSettings = Read-JsonFile -Path $workspaceSettingsPath
|
|
153
|
+
$workspaceSettings["claudeCode.claudeProcessWrapper"] = $launcherExePath
|
|
154
|
+
$workspaceSettings["claudeCode.disableLoginPrompt"] = $true
|
|
155
|
+
Write-JsonFile -Path $workspaceSettingsPath -Data $workspaceSettings
|
|
156
|
+
|
|
157
|
+
$claudeSettings = Read-JsonFile -Path $claudeSettingsPath
|
|
158
|
+
$aliasJson = Get-EnvValue -Path $envPath -Name "OLLAMA_MODEL_ALIASES"
|
|
159
|
+
if (-not $aliasJson) {
|
|
160
|
+
throw "OLLAMA_MODEL_ALIASES non trovato in .env.local"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
$parsedAliases = $aliasJson | ConvertFrom-Json
|
|
164
|
+
$aliases = @{}
|
|
165
|
+
foreach ($property in $parsedAliases.PSObject.Properties) {
|
|
166
|
+
$aliases[$property.Name] = [string]$property.Value
|
|
167
|
+
}
|
|
168
|
+
$availableModels = Get-OllamaAvailableModels -EnvPath $envPath -Aliases $aliases
|
|
169
|
+
|
|
170
|
+
if ($availableModels.Count -eq 0) {
|
|
171
|
+
throw "OLLAMA_MODEL_ALIASES non contiene modelli"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
$defaultModel = if ($aliases.ContainsKey("ollama-free-auto")) { "ollama-free-auto" } else { $availableModels[0] }
|
|
175
|
+
$claudeSettings["model"] = $defaultModel
|
|
176
|
+
$claudeSettings["availableModels"] = $availableModels
|
|
177
|
+
Write-JsonFile -Path $claudeSettingsPath -Data $claudeSettings
|
|
178
|
+
|
|
179
|
+
Write-Host "Installazione completata."
|
|
180
|
+
Write-Host "Launcher Claude:" $launcherExePath
|
|
181
|
+
Write-Host "VS Code user settings:" $workspaceSettingsPath
|
|
182
|
+
Write-Host "Claude user settings:" $claudeSettingsPath
|
|
183
|
+
Write-Host ""
|
|
184
|
+
Write-Host "Riavvia VS Code. Claude Code usera il bridge in qualsiasi progetto."
|
package/scripts/loren.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadConfig, loadEnvFile, saveEnvFile } from "../src/config.js";
|
|
7
|
+
import { ensureEnvLocal, ensureRuntimeDir, getBridgeBaseUrl } from "../src/bootstrap.js";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const projectRoot = path.resolve(__dirname, "..");
|
|
11
|
+
const envFilePath = path.join(projectRoot, ".env.local");
|
|
12
|
+
const runtimeDir = path.join(projectRoot, ".runtime");
|
|
13
|
+
const pidFilePath = path.join(runtimeDir, "loren.pid");
|
|
14
|
+
const logFilePath = path.join(runtimeDir, "bridge.log");
|
|
15
|
+
const errorLogFilePath = path.join(runtimeDir, "bridge.err.log");
|
|
16
|
+
|
|
17
|
+
// Force working directory to project root for config loading
|
|
18
|
+
process.chdir(projectRoot);
|
|
19
|
+
ensureRuntimeDir(projectRoot);
|
|
20
|
+
ensureEnvLocal(projectRoot);
|
|
21
|
+
|
|
22
|
+
const ASCII_LOGO = `
|
|
23
|
+
██╗ ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
24
|
+
██║ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
25
|
+
██║ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ ██║ ██║██║ ██║█████╗
|
|
26
|
+
██║ ██║ ██║██╔══██╗██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██║ ██║██╔══╝
|
|
27
|
+
███████╗╚██████╔╝██║ ██║███████╗██║ ╚████║ ╚██████╗╚██████╔╝██████╔╝███████╗
|
|
28
|
+
╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const COMMANDS = {
|
|
32
|
+
model: {
|
|
33
|
+
list: listModels,
|
|
34
|
+
set: setModel,
|
|
35
|
+
current: showCurrentModel,
|
|
36
|
+
refresh: refreshModels,
|
|
37
|
+
},
|
|
38
|
+
keys: {
|
|
39
|
+
list: listKeys,
|
|
40
|
+
add: addKey,
|
|
41
|
+
remove: removeKey,
|
|
42
|
+
rotate: rotateKeys,
|
|
43
|
+
},
|
|
44
|
+
config: {
|
|
45
|
+
show: showConfig,
|
|
46
|
+
},
|
|
47
|
+
server: {
|
|
48
|
+
start: startServer,
|
|
49
|
+
stop: stopServer,
|
|
50
|
+
status: showServerStatus,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function main() {
|
|
55
|
+
const args = process.argv.slice(2);
|
|
56
|
+
const [command, subcommand, ...rest] = args;
|
|
57
|
+
|
|
58
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
59
|
+
printHelp();
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const [category, action] = command.split(":");
|
|
64
|
+
|
|
65
|
+
if (command === "start") {
|
|
66
|
+
startServer();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (command === "stop") {
|
|
71
|
+
stopServer();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (command === "status") {
|
|
76
|
+
showServerStatus();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (category && action && COMMANDS[category] && COMMANDS[category][action]) {
|
|
81
|
+
COMMANDS[category][action](rest);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.error(`Unknown command: ${command}`);
|
|
86
|
+
printHelp();
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============== MODEL COMMANDS ==============
|
|
91
|
+
|
|
92
|
+
async function listModels() {
|
|
93
|
+
const config = loadConfig();
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(`${config.upstreamBaseUrl}/api/tags`, {
|
|
97
|
+
headers: { "accept": "application/json" },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const data = await response.json();
|
|
105
|
+
let models = Array.isArray(data.models) ? data.models : [];
|
|
106
|
+
|
|
107
|
+
// Sort by modified date (most recent first)
|
|
108
|
+
models = models.sort((a, b) => {
|
|
109
|
+
const dateA = a.modified_at ? new Date(a.modified_at).getTime() : 0;
|
|
110
|
+
const dateB = b.modified_at ? new Date(b.modified_at).getTime() : 0;
|
|
111
|
+
return dateB - dateA;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.log("\nAvailable models from Ollama Cloud:");
|
|
115
|
+
console.log("─".repeat(70));
|
|
116
|
+
console.log("MODEL".padEnd(30) + "SIZE".padStart(12) + "MODIFIED".padStart(12));
|
|
117
|
+
console.log("─".repeat(70));
|
|
118
|
+
|
|
119
|
+
for (const model of models) {
|
|
120
|
+
const modelId = model.model || model.name;
|
|
121
|
+
const size = formatSize(model.size);
|
|
122
|
+
const modified = model.modified_at ? new Date(model.modified_at).toLocaleDateString() : "unknown";
|
|
123
|
+
const marker = modelId === config.defaultModel ? "●" : "○";
|
|
124
|
+
console.log(
|
|
125
|
+
`${marker} ${modelId.padEnd(28)}${size.padStart(12)}${modified.padStart(12)}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log("");
|
|
130
|
+
console.log(`Total: ${models.length} model(s)`);
|
|
131
|
+
console.log(`Current default: ${config.defaultModel}`);
|
|
132
|
+
console.log("");
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error(`Error fetching models: ${error.message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatSize(bytes) {
|
|
140
|
+
if (!bytes) return "unknown";
|
|
141
|
+
const gb = bytes / (1024 ** 3);
|
|
142
|
+
return `${gb.toFixed(1)} GB`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function refreshModels() {
|
|
146
|
+
const config = loadConfig();
|
|
147
|
+
const url = `http://${config.host}:${config.port}/v1/refresh`;
|
|
148
|
+
|
|
149
|
+
console.log(`Sending refresh request to ${url}...`);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetch(url, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "accept": "application/json" },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = await response.json();
|
|
162
|
+
const models = Array.isArray(data.data) ? data.data : [];
|
|
163
|
+
|
|
164
|
+
console.log("\n✓ Models refreshed successfully!");
|
|
165
|
+
console.log(` Fetched ${models.length} model(s) from Ollama Cloud.`);
|
|
166
|
+
console.log("");
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error(`Error refreshing models: ${error.message}`);
|
|
169
|
+
console.error("Make sure the server is running: loren start");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function setModel(args) {
|
|
175
|
+
const requestedModel = args.join(" ").trim();
|
|
176
|
+
|
|
177
|
+
if (!requestedModel) {
|
|
178
|
+
console.error("Error: Specify a model name.");
|
|
179
|
+
console.error("Example: loren model:set qwen3.5:397b");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const config = loadConfig();
|
|
184
|
+
|
|
185
|
+
// Check if it's a valid alias or add it as a new direct model
|
|
186
|
+
const isValidAlias = Object.keys(config.aliases).includes(requestedModel);
|
|
187
|
+
|
|
188
|
+
if (!isValidAlias) {
|
|
189
|
+
console.warn(`Warning: '${requestedModel}' is not a configured alias.`);
|
|
190
|
+
console.warn("It will be used as a direct model name.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Update .env.local with new DEFAULT_MODEL_ALIAS
|
|
194
|
+
const envVars = loadEnvFile(envFilePath);
|
|
195
|
+
envVars.DEFAULT_MODEL_ALIAS = requestedModel;
|
|
196
|
+
saveEnvFile(envFilePath, envVars);
|
|
197
|
+
|
|
198
|
+
console.log(`\n✓ Default model set to: ${requestedModel}`);
|
|
199
|
+
console.log(" New requests will use this model immediately.");
|
|
200
|
+
console.log("");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function showCurrentModel() {
|
|
204
|
+
const config = loadConfig();
|
|
205
|
+
console.log(`\nCurrent default model: ${config.defaultModel}`);
|
|
206
|
+
console.log("");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============== API KEY COMMANDS ==============
|
|
210
|
+
|
|
211
|
+
function listKeys() {
|
|
212
|
+
const config = loadConfig();
|
|
213
|
+
|
|
214
|
+
console.log("\nConfigured API Keys:");
|
|
215
|
+
console.log("─".repeat(40));
|
|
216
|
+
|
|
217
|
+
if (config.apiKeys.length === 0) {
|
|
218
|
+
console.log(" (none configured)");
|
|
219
|
+
} else {
|
|
220
|
+
for (let i = 0; i < config.apiKeys.length; i++) {
|
|
221
|
+
const key = config.apiKeys[i];
|
|
222
|
+
const masked = `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
223
|
+
const marker = i === 0 ? "●" : "○";
|
|
224
|
+
console.log(` ${marker} [${i}] ${masked}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log("");
|
|
229
|
+
console.log(`Total: ${config.apiKeys.length} key(s)`);
|
|
230
|
+
console.log("");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function addKey(args) {
|
|
234
|
+
const newKey = args.join(" ").trim();
|
|
235
|
+
|
|
236
|
+
if (!newKey) {
|
|
237
|
+
console.error("Error: Specify an API key.");
|
|
238
|
+
console.error("Example: loren keys:add sk-your-key-here");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const envVars = loadEnvFile(envFilePath);
|
|
243
|
+
const existingKeys = (envVars.OLLAMA_API_KEYS || "")
|
|
244
|
+
.split(/[,\r?\n]+/)
|
|
245
|
+
.map((k) => k.trim())
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
|
|
248
|
+
if (existingKeys.includes(newKey)) {
|
|
249
|
+
console.log(" Key already exists, skipping.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
existingKeys.push(newKey);
|
|
254
|
+
envVars.OLLAMA_API_KEYS = existingKeys.join(",");
|
|
255
|
+
saveEnvFile(envFilePath, envVars);
|
|
256
|
+
|
|
257
|
+
console.log(`\n✓ API key added.`);
|
|
258
|
+
console.log(` Total keys: ${existingKeys.length}`);
|
|
259
|
+
console.log(" New key will be used for subsequent requests.");
|
|
260
|
+
console.log("");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function removeKey(args) {
|
|
264
|
+
const indexOrKey = args.join(" ").trim();
|
|
265
|
+
|
|
266
|
+
if (!indexOrKey) {
|
|
267
|
+
console.error("Error: Specify key index or the key itself.");
|
|
268
|
+
console.error("Example: loren keys:remove 0");
|
|
269
|
+
console.error(" loren keys:remove sk-xxx...");
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const envVars = loadEnvFile(envFilePath);
|
|
274
|
+
let existingKeys = (envVars.OLLAMA_API_KEYS || "")
|
|
275
|
+
.split(/[,\r?\n]+/)
|
|
276
|
+
.map((k) => k.trim())
|
|
277
|
+
.filter(Boolean);
|
|
278
|
+
|
|
279
|
+
let keyToRemove;
|
|
280
|
+
const index = parseInt(indexOrKey, 10);
|
|
281
|
+
|
|
282
|
+
if (!isNaN(index) && index >= 0 && index < existingKeys.length) {
|
|
283
|
+
keyToRemove = existingKeys[index];
|
|
284
|
+
} else {
|
|
285
|
+
keyToRemove = existingKeys.find((k) => k === indexOrKey);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!keyToRemove) {
|
|
289
|
+
console.error("Error: Key not found.");
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
existingKeys = existingKeys.filter((k) => k !== keyToRemove);
|
|
294
|
+
envVars.OLLAMA_API_KEYS = existingKeys.join(",");
|
|
295
|
+
saveEnvFile(envFilePath, envVars);
|
|
296
|
+
|
|
297
|
+
console.log(`\n✓ API key removed.`);
|
|
298
|
+
console.log(` Remaining keys: ${existingKeys.length}`);
|
|
299
|
+
console.log("");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function rotateKeys(args) {
|
|
303
|
+
const envVars = loadEnvFile(envFilePath);
|
|
304
|
+
let existingKeys = (envVars.OLLAMA_API_KEYS || "")
|
|
305
|
+
.split(/[,\r?\n]+/)
|
|
306
|
+
.map((k) => k.trim())
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
|
|
309
|
+
if (existingKeys.length < 2) {
|
|
310
|
+
console.log("Need at least 2 keys to rotate.");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Move first key to the end
|
|
315
|
+
const [first, ...rest] = existingKeys;
|
|
316
|
+
existingKeys = [...rest, first];
|
|
317
|
+
|
|
318
|
+
envVars.OLLAMA_API_KEYS = existingKeys.join(",");
|
|
319
|
+
saveEnvFile(envFilePath, envVars);
|
|
320
|
+
|
|
321
|
+
console.log("\n✓ API keys rotated.");
|
|
322
|
+
console.log(" First key moved to end of list.");
|
|
323
|
+
console.log("");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ============== CONFIG COMMANDS ==============
|
|
327
|
+
|
|
328
|
+
function showConfig() {
|
|
329
|
+
const config = loadConfig();
|
|
330
|
+
|
|
331
|
+
console.log("\nCurrent Configuration:");
|
|
332
|
+
console.log("─".repeat(40));
|
|
333
|
+
console.log(` Host: ${config.host}`);
|
|
334
|
+
console.log(` Port: ${config.port}`);
|
|
335
|
+
console.log(` Upstream: ${config.upstreamBaseUrl}`);
|
|
336
|
+
console.log(` API Keys: ${config.apiKeys.length}`);
|
|
337
|
+
console.log(` Aliases: ${Object.keys(config.aliases).length}`);
|
|
338
|
+
console.log(` Default: ${config.defaultModel}`);
|
|
339
|
+
console.log("");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ============== SERVER COMMANDS ==============
|
|
343
|
+
|
|
344
|
+
function startServer() {
|
|
345
|
+
const existingPid = readPidFile();
|
|
346
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
347
|
+
const config = loadConfig();
|
|
348
|
+
console.log(`\nLoren server is already running (PID ${existingPid}).`);
|
|
349
|
+
console.log(` URL: ${getBridgeBaseUrl(config)}`);
|
|
350
|
+
console.log("");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!fs.existsSync(runtimeDir)) {
|
|
355
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const child = spawn(process.execPath, [path.join(projectRoot, "src", "server.js")], {
|
|
359
|
+
cwd: projectRoot,
|
|
360
|
+
detached: true,
|
|
361
|
+
stdio: [
|
|
362
|
+
"ignore",
|
|
363
|
+
fs.openSync(logFilePath, "a"),
|
|
364
|
+
fs.openSync(errorLogFilePath, "a"),
|
|
365
|
+
],
|
|
366
|
+
windowsHide: true,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
child.unref();
|
|
370
|
+
const pid = child.pid;
|
|
371
|
+
|
|
372
|
+
fs.writeFileSync(pidFilePath, `${pid}\n`, "utf8");
|
|
373
|
+
|
|
374
|
+
const config = loadConfig();
|
|
375
|
+
console.log(`\n✓ Loren server started (PID ${pid}).`);
|
|
376
|
+
console.log(` URL: ${getBridgeBaseUrl(config)}`);
|
|
377
|
+
console.log("");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function stopServer() {
|
|
381
|
+
const pid = readPidFile();
|
|
382
|
+
if (!pid) {
|
|
383
|
+
console.log("\nLoren server is not running.");
|
|
384
|
+
console.log("");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!isProcessRunning(pid)) {
|
|
389
|
+
safeUnlink(pidFilePath);
|
|
390
|
+
console.log(`\nRemoved stale PID file for process ${pid}.`);
|
|
391
|
+
console.log("");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
if (process.platform === "win32") {
|
|
397
|
+
execFileSync("taskkill.exe", ["/PID", `${pid}`, "/T", "/F"], {
|
|
398
|
+
stdio: "ignore",
|
|
399
|
+
});
|
|
400
|
+
} else {
|
|
401
|
+
process.kill(pid, "SIGINT");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
safeUnlink(pidFilePath);
|
|
405
|
+
console.log(`\n✓ Loren server stopped (PID ${pid}).`);
|
|
406
|
+
console.log("");
|
|
407
|
+
} catch (error) {
|
|
408
|
+
console.error(`Error stopping server: ${error.message}`);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function showServerStatus() {
|
|
414
|
+
const config = loadConfig();
|
|
415
|
+
const pid = readPidFile();
|
|
416
|
+
const running = pid ? isProcessRunning(pid) : false;
|
|
417
|
+
|
|
418
|
+
console.log("\nServer Status:");
|
|
419
|
+
console.log("─".repeat(40));
|
|
420
|
+
console.log(` Running: ${running ? "yes" : "no"}`);
|
|
421
|
+
console.log(` Host: ${config.host}`);
|
|
422
|
+
console.log(` Port: ${config.port}`);
|
|
423
|
+
console.log(` URL: ${getBridgeBaseUrl(config)}`);
|
|
424
|
+
if (pid) {
|
|
425
|
+
console.log(` PID: ${pid}${running ? "" : " (stale)"}`);
|
|
426
|
+
}
|
|
427
|
+
console.log("");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function readPidFile() {
|
|
431
|
+
if (!fs.existsSync(pidFilePath)) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const raw = fs.readFileSync(pidFilePath, "utf8").trim();
|
|
436
|
+
const pid = Number.parseInt(raw, 10);
|
|
437
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isProcessRunning(pid) {
|
|
441
|
+
if (process.platform === "win32") {
|
|
442
|
+
try {
|
|
443
|
+
const output = execFileSync("powershell.exe", [
|
|
444
|
+
"-NoProfile",
|
|
445
|
+
"-Command",
|
|
446
|
+
`Get-Process -Id ${pid} -ErrorAction Stop | Select-Object -ExpandProperty Id`,
|
|
447
|
+
], {
|
|
448
|
+
encoding: "utf8",
|
|
449
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
450
|
+
}).trim();
|
|
451
|
+
|
|
452
|
+
return output === `${pid}`;
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
process.kill(pid, 0);
|
|
460
|
+
return true;
|
|
461
|
+
} catch {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function safeUnlink(filePath) {
|
|
467
|
+
if (fs.existsSync(filePath)) {
|
|
468
|
+
fs.unlinkSync(filePath);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ============== HELP ==============
|
|
473
|
+
|
|
474
|
+
function printHelp() {
|
|
475
|
+
console.log(ASCII_LOGO);
|
|
476
|
+
console.log(`
|
|
477
|
+
LOREN CODE - Ollama Cloud Model Manager
|
|
478
|
+
────────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
MODEL COMMANDS:
|
|
481
|
+
loren model:list Fetch & list models from Ollama Cloud
|
|
482
|
+
loren model:set <name> Set default model (immediate effect)
|
|
483
|
+
loren model:current Show current default model
|
|
484
|
+
loren model:refresh Force refresh models cache
|
|
485
|
+
|
|
486
|
+
KEY COMMANDS:
|
|
487
|
+
loren keys:list List configured API keys
|
|
488
|
+
loren keys:add <key> Add a new API key
|
|
489
|
+
loren keys:remove <idx|key> Remove a key by index or value
|
|
490
|
+
loren keys:rotate Rotate keys (move first to end)
|
|
491
|
+
|
|
492
|
+
CONFIG COMMANDS:
|
|
493
|
+
loren config:show Show current configuration
|
|
494
|
+
|
|
495
|
+
SERVER COMMANDS:
|
|
496
|
+
loren start Start bridge server (port 8788)
|
|
497
|
+
loren stop Stop bridge server
|
|
498
|
+
loren status Show bridge server status
|
|
499
|
+
|
|
500
|
+
EXAMPLES:
|
|
501
|
+
loren model:list
|
|
502
|
+
loren model:set gpt-oss:20b
|
|
503
|
+
loren model:refresh
|
|
504
|
+
loren keys:add sk-ollama-abc123...
|
|
505
|
+
loren keys:remove 0
|
|
506
|
+
loren config:show
|
|
507
|
+
|
|
508
|
+
TIPS:
|
|
509
|
+
- Model changes take effect immediately for new requests
|
|
510
|
+
- Use model:refresh after changing model to update Claude Code's list
|
|
511
|
+
- Models are sorted by modification date (most recent first)
|
|
512
|
+
`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
main();
|