scalix 1.1.0 → 1.2.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/PARITY-PLAN.md +116 -0
- package/bin/scalix.js +1 -0
- package/dist/agent.js +12 -1
- package/dist/cli.js +567 -44
- package/dist/commands.js +53 -2
- package/dist/constants.js +3 -3
- package/dist/headless.js +42 -2
- package/dist/hooks.js +141 -9
- package/dist/keychain.js +109 -0
- package/dist/markdown.js +46 -3
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +20 -1
- package/dist/sandbox.js +66 -0
- package/dist/sessions.js +59 -0
- package/dist/settings.js +85 -4
- package/dist/skills.js +269 -0
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/tools-extra.js +2 -8
- package/package.json +3 -2
package/dist/plugins.js
CHANGED
|
@@ -130,3 +130,135 @@ export function pluginsSummary(plugins) {
|
|
|
130
130
|
}
|
|
131
131
|
return lines.join('\n');
|
|
132
132
|
}
|
|
133
|
+
export async function installPlugin(projectRoot, source) {
|
|
134
|
+
const pluginsDir = path.join(projectRoot, PLUGINS_DIR);
|
|
135
|
+
await fs.mkdir(pluginsDir, { recursive: true });
|
|
136
|
+
// Source can be a local directory path or a git repo URL
|
|
137
|
+
if (source.startsWith('http') || source.includes('github.com') || source.includes('.git')) {
|
|
138
|
+
const { execFile } = await import('node:child_process');
|
|
139
|
+
const { promisify } = await import('node:util');
|
|
140
|
+
const execFileAsync = promisify(execFile);
|
|
141
|
+
const name = path.basename(source.replace(/\.git$/, ''));
|
|
142
|
+
const targetDir = path.join(pluginsDir, name);
|
|
143
|
+
try {
|
|
144
|
+
await fs.access(targetDir);
|
|
145
|
+
return null; // already installed
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// directory doesn't exist, proceed
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await execFileAsync('git', ['clone', '--depth', '1', source, targetDir], { timeout: 30_000 });
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
throw new Error(`Failed to clone plugin: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
const plugin = await loadPlugin(targetDir);
|
|
157
|
+
if (!plugin) {
|
|
158
|
+
await fs.rm(targetDir, { recursive: true, force: true });
|
|
159
|
+
throw new Error(`Cloned repo is not a valid plugin (no plugin.json found)`);
|
|
160
|
+
}
|
|
161
|
+
_pluginCache.delete(projectRoot);
|
|
162
|
+
return { name: plugin.manifest.name, version: plugin.manifest.version };
|
|
163
|
+
}
|
|
164
|
+
// Local directory: copy or symlink
|
|
165
|
+
const sourcePath = path.resolve(source);
|
|
166
|
+
try {
|
|
167
|
+
await fs.access(path.join(sourcePath, 'plugin.json'));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
throw new Error(`Source directory does not contain plugin.json: ${source}`);
|
|
171
|
+
}
|
|
172
|
+
const raw = await fs.readFile(path.join(sourcePath, 'plugin.json'), 'utf-8');
|
|
173
|
+
const manifest = JSON.parse(raw);
|
|
174
|
+
const name = manifest.name || path.basename(sourcePath);
|
|
175
|
+
const targetDir = path.join(pluginsDir, name);
|
|
176
|
+
try {
|
|
177
|
+
await fs.access(targetDir);
|
|
178
|
+
return null; // already installed
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// proceed
|
|
182
|
+
}
|
|
183
|
+
await fs.symlink(sourcePath, targetDir, 'dir');
|
|
184
|
+
_pluginCache.delete(projectRoot);
|
|
185
|
+
return { name: manifest.name, version: manifest.version || '0.0.0' };
|
|
186
|
+
}
|
|
187
|
+
export async function uninstallPlugin(projectRoot, pluginName) {
|
|
188
|
+
const pluginsDir = path.join(projectRoot, PLUGINS_DIR);
|
|
189
|
+
const targetDir = path.join(pluginsDir, pluginName);
|
|
190
|
+
try {
|
|
191
|
+
await fs.access(targetDir);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
await fs.rm(targetDir, { recursive: true, force: true });
|
|
197
|
+
_pluginCache.delete(projectRoot);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
export async function listInstalledPlugins(projectRoot) {
|
|
201
|
+
const pluginsDir = path.join(projectRoot, PLUGINS_DIR);
|
|
202
|
+
const results = [];
|
|
203
|
+
try {
|
|
204
|
+
await fs.access(pluginsDir);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
if (!entry.isDirectory())
|
|
212
|
+
continue;
|
|
213
|
+
const pluginDir = path.join(pluginsDir, entry.name);
|
|
214
|
+
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
215
|
+
try {
|
|
216
|
+
const raw = await fs.readFile(manifestPath, 'utf-8');
|
|
217
|
+
const manifest = JSON.parse(raw);
|
|
218
|
+
const disabledMarker = path.join(pluginDir, '.disabled');
|
|
219
|
+
let enabled = true;
|
|
220
|
+
try {
|
|
221
|
+
await fs.access(disabledMarker);
|
|
222
|
+
enabled = false;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// not disabled
|
|
226
|
+
}
|
|
227
|
+
results.push({
|
|
228
|
+
name: manifest.name || entry.name,
|
|
229
|
+
version: manifest.version || '0.0.0',
|
|
230
|
+
path: pluginDir,
|
|
231
|
+
enabled,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// not a valid plugin, skip
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
export async function enablePlugin(projectRoot, pluginName) {
|
|
241
|
+
const pluginsDir = path.join(projectRoot, PLUGINS_DIR);
|
|
242
|
+
const disabledMarker = path.join(pluginsDir, pluginName, '.disabled');
|
|
243
|
+
try {
|
|
244
|
+
await fs.unlink(disabledMarker);
|
|
245
|
+
_pluginCache.delete(projectRoot);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export async function disablePlugin(projectRoot, pluginName) {
|
|
253
|
+
const pluginsDir = path.join(projectRoot, PLUGINS_DIR);
|
|
254
|
+
const pluginDir = path.join(pluginsDir, pluginName);
|
|
255
|
+
try {
|
|
256
|
+
await fs.access(pluginDir);
|
|
257
|
+
await fs.writeFile(path.join(pluginDir, '.disabled'), '', { mode: 0o644 });
|
|
258
|
+
_pluginCache.delete(projectRoot);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/dist/project-index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import fg from 'fast-glob';
|
|
4
5
|
import { PROJECT_INDEX_DEFAULTS } from './constants.js';
|
|
@@ -243,8 +244,26 @@ function extractImports(content, relativePath, ext) {
|
|
|
243
244
|
}
|
|
244
245
|
return imports;
|
|
245
246
|
}
|
|
247
|
+
function loadScalixIgnore(root) {
|
|
248
|
+
const ignorePaths = ['.scalixignore', '.scalix/.ignore'];
|
|
249
|
+
for (const relPath of ignorePaths) {
|
|
250
|
+
try {
|
|
251
|
+
const fullPath = path.join(root, relPath);
|
|
252
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
253
|
+
return content.split('\n')
|
|
254
|
+
.map(l => l.trim())
|
|
255
|
+
.filter(l => l && !l.startsWith('#'));
|
|
256
|
+
}
|
|
257
|
+
catch { }
|
|
258
|
+
}
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
246
261
|
async function buildProjectIndexFresh(root) {
|
|
247
|
-
const
|
|
262
|
+
const customIgnores = loadScalixIgnore(root);
|
|
263
|
+
const ignorePattern = [
|
|
264
|
+
...IGNORE_DIRS.map((d) => `**/${d}/**`),
|
|
265
|
+
...customIgnores,
|
|
266
|
+
];
|
|
248
267
|
const globbed = await fg('**/*', {
|
|
249
268
|
cwd: root,
|
|
250
269
|
ignore: ignorePattern,
|
package/dist/sandbox.js
CHANGED
|
@@ -45,6 +45,64 @@ export async function sandboxPathAsync(filePath) {
|
|
|
45
45
|
throw err;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
let _scopedRules = [];
|
|
49
|
+
export function setScopedPermissionRules(rules) {
|
|
50
|
+
_scopedRules = rules;
|
|
51
|
+
}
|
|
52
|
+
export function getScopedPermissionRules() {
|
|
53
|
+
return _scopedRules;
|
|
54
|
+
}
|
|
55
|
+
function parseScopedToolSpec(spec) {
|
|
56
|
+
const match = spec.match(/^(\w+)\(([^)]+)\)$/);
|
|
57
|
+
if (match)
|
|
58
|
+
return { tool: match[1], prefix: match[2].replace(/\*$/, '') };
|
|
59
|
+
return { tool: spec };
|
|
60
|
+
}
|
|
61
|
+
export function checkScopedPermission(toolName, args) {
|
|
62
|
+
if (_scopedRules.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
const argStr = extractPrimaryArg(toolName, args);
|
|
65
|
+
for (const rule of _scopedRules) {
|
|
66
|
+
const parsed = parseScopedToolSpec(rule.tool);
|
|
67
|
+
if (!parsed)
|
|
68
|
+
continue;
|
|
69
|
+
if (parsed.tool !== toolName)
|
|
70
|
+
continue;
|
|
71
|
+
if (!parsed.prefix)
|
|
72
|
+
return rule.action;
|
|
73
|
+
if (argStr && argStr.startsWith(parsed.prefix))
|
|
74
|
+
return rule.action;
|
|
75
|
+
if (rule.regex) {
|
|
76
|
+
try {
|
|
77
|
+
if (new RegExp(rule.regex).test(argStr || ''))
|
|
78
|
+
return rule.action;
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function extractPrimaryArg(toolName, args) {
|
|
86
|
+
if (!args || typeof args !== 'object')
|
|
87
|
+
return null;
|
|
88
|
+
const a = args;
|
|
89
|
+
if (toolName === 'run_command' && typeof a.command === 'string')
|
|
90
|
+
return a.command;
|
|
91
|
+
if (typeof a.filePath === 'string')
|
|
92
|
+
return a.filePath;
|
|
93
|
+
if (typeof a.file_path === 'string')
|
|
94
|
+
return a.file_path;
|
|
95
|
+
if (typeof a.path === 'string')
|
|
96
|
+
return a.path;
|
|
97
|
+
if (typeof a.pattern === 'string')
|
|
98
|
+
return a.pattern;
|
|
99
|
+
if (typeof a.query === 'string')
|
|
100
|
+
return a.query;
|
|
101
|
+
if (typeof a.url === 'string')
|
|
102
|
+
return a.url;
|
|
103
|
+
const first = Object.values(a).find(v => typeof v === 'string');
|
|
104
|
+
return typeof first === 'string' ? first : null;
|
|
105
|
+
}
|
|
48
106
|
// ─── Security Configuration ───
|
|
49
107
|
let _securityConfig = {};
|
|
50
108
|
export function setSecurityConfig(config) {
|
|
@@ -147,6 +205,14 @@ export function getToolPermission(toolName, mode) {
|
|
|
147
205
|
return 'ask';
|
|
148
206
|
}
|
|
149
207
|
}
|
|
208
|
+
export function getToolPermissionWithArgs(toolName, mode, args) {
|
|
209
|
+
const scoped = checkScopedPermission(toolName, args);
|
|
210
|
+
if (scoped === 'allow')
|
|
211
|
+
return 'allow';
|
|
212
|
+
if (scoped === 'deny')
|
|
213
|
+
return 'block';
|
|
214
|
+
return getToolPermission(toolName, mode);
|
|
215
|
+
}
|
|
150
216
|
export function isDangerousTool(toolName) {
|
|
151
217
|
const sets = getEffectiveToolSets();
|
|
152
218
|
return sets.dangerous.has(toolName) || sets.write.has(toolName);
|
package/dist/sessions.js
CHANGED
|
@@ -104,3 +104,62 @@ export async function deleteSession(id) {
|
|
|
104
104
|
export function generateSessionId() {
|
|
105
105
|
return randomUUID();
|
|
106
106
|
}
|
|
107
|
+
const CRASH_FILE = path.join(SESSIONS_DIR, '.crash-recovery.json');
|
|
108
|
+
export async function saveCrashState(sessionId, messages, model, costState) {
|
|
109
|
+
try {
|
|
110
|
+
await ensureDir();
|
|
111
|
+
const data = {
|
|
112
|
+
sessionId,
|
|
113
|
+
messages,
|
|
114
|
+
model,
|
|
115
|
+
costState,
|
|
116
|
+
savedAt: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
await fs.writeFile(CRASH_FILE, JSON.stringify(data), { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
}
|
|
122
|
+
export async function loadCrashState() {
|
|
123
|
+
try {
|
|
124
|
+
const raw = await fs.readFile(CRASH_FILE, 'utf-8');
|
|
125
|
+
const data = JSON.parse(raw);
|
|
126
|
+
if (data?.sessionId && Array.isArray(data?.messages)) {
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
export async function clearCrashState() {
|
|
134
|
+
try {
|
|
135
|
+
await fs.unlink(CRASH_FILE);
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
}
|
|
139
|
+
export async function forkSession(sourceId, forkPoint) {
|
|
140
|
+
const source = await loadSession(sourceId);
|
|
141
|
+
const forkedMessages = source.messages.slice(0, forkPoint);
|
|
142
|
+
const newId = generateSessionId();
|
|
143
|
+
const meta = await saveSession(newId, forkedMessages, source.meta.model, `Fork of ${source.meta.name} (at msg ${forkPoint})`, source.costState);
|
|
144
|
+
return { id: newId, meta };
|
|
145
|
+
}
|
|
146
|
+
export async function getSessionDiff(id) {
|
|
147
|
+
const session = await loadSession(id);
|
|
148
|
+
const diffs = [];
|
|
149
|
+
for (const msg of session.messages) {
|
|
150
|
+
if (msg.role !== 'assistant' || !msg.tool_calls)
|
|
151
|
+
continue;
|
|
152
|
+
for (const tc of msg.tool_calls) {
|
|
153
|
+
if (tc.function.name === 'edit_file' || tc.function.name === 'write_file') {
|
|
154
|
+
try {
|
|
155
|
+
const args = JSON.parse(tc.function.arguments);
|
|
156
|
+
diffs.push(`${tc.function.name}: ${args.file_path || args.filePath || 'unknown'}`);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
diffs.push(`${tc.function.name}: (parse error)`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return diffs;
|
|
165
|
+
}
|
package/dist/settings.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { setSecurityConfig } from './sandbox.js';
|
|
3
|
+
import { setSecurityConfig, setScopedPermissionRules } from './sandbox.js';
|
|
4
4
|
import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, MULTI_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, setNetworkSecurityOverrides, } from './constants.js';
|
|
5
|
+
import { getApiKeyFromKeychain, setApiKeyInKeychain, isKeychainAvailable } from './keychain.js';
|
|
5
6
|
function getConfigDir() {
|
|
6
7
|
return path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR);
|
|
7
8
|
}
|
|
@@ -233,27 +234,107 @@ export function applySecurityConfig(security) {
|
|
|
233
234
|
allowedHosts: security.allowedPrivateHosts,
|
|
234
235
|
});
|
|
235
236
|
}
|
|
237
|
+
async function loadLocalOverrides(config) {
|
|
238
|
+
const localPaths = [
|
|
239
|
+
path.join(process.cwd(), '.scalix', 'settings.local.json'),
|
|
240
|
+
path.join(process.cwd(), '.scalix-coder', 'settings.local.json'),
|
|
241
|
+
];
|
|
242
|
+
for (const localPath of localPaths) {
|
|
243
|
+
try {
|
|
244
|
+
const raw = await fs.readFile(localPath, 'utf-8');
|
|
245
|
+
const overrides = JSON.parse(raw);
|
|
246
|
+
if (overrides.models)
|
|
247
|
+
config.models = overrides.models;
|
|
248
|
+
if (overrides.permissionMode)
|
|
249
|
+
config.permissionMode = overrides.permissionMode;
|
|
250
|
+
if (overrides.thinkingMode)
|
|
251
|
+
config.thinkingMode = overrides.thinkingMode;
|
|
252
|
+
if (overrides.limits)
|
|
253
|
+
config.limits = { ...config.limits, ...overrides.limits };
|
|
254
|
+
if (overrides.security)
|
|
255
|
+
config.security = { ...config.security, ...overrides.security };
|
|
256
|
+
if (overrides.scopedPermissions)
|
|
257
|
+
config.scopedPermissions = overrides.scopedPermissions;
|
|
258
|
+
if (overrides.customModels)
|
|
259
|
+
config.customModels = { ...config.customModels, ...overrides.customModels };
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
catch { }
|
|
263
|
+
}
|
|
264
|
+
return config;
|
|
265
|
+
}
|
|
266
|
+
async function loadManagedSettings(config) {
|
|
267
|
+
const managedPaths = [
|
|
268
|
+
path.join(getConfigDir(), 'managed-settings.json'),
|
|
269
|
+
];
|
|
270
|
+
for (const managedPath of managedPaths) {
|
|
271
|
+
try {
|
|
272
|
+
const raw = await fs.readFile(managedPath, 'utf-8');
|
|
273
|
+
const managed = JSON.parse(raw);
|
|
274
|
+
if (managed.permissionMode)
|
|
275
|
+
config.permissionMode = managed.permissionMode;
|
|
276
|
+
if (managed.security)
|
|
277
|
+
config.security = { ...config.security, ...managed.security };
|
|
278
|
+
if (managed.scopedPermissions)
|
|
279
|
+
config.scopedPermissions = [
|
|
280
|
+
...(config.scopedPermissions || []),
|
|
281
|
+
...managed.scopedPermissions,
|
|
282
|
+
];
|
|
283
|
+
if (managed.disableUpdateCheck !== undefined)
|
|
284
|
+
config.disableUpdateCheck = managed.disableUpdateCheck;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
catch { }
|
|
288
|
+
}
|
|
289
|
+
return config;
|
|
290
|
+
}
|
|
236
291
|
export async function loadConfig() {
|
|
237
292
|
try {
|
|
238
293
|
const raw = await fs.readFile(getConfigFile(), 'utf-8');
|
|
239
294
|
const parsed = JSON.parse(raw);
|
|
240
295
|
const validated = validateConfig(parsed);
|
|
241
|
-
|
|
296
|
+
let config = { ...DEFAULTS, ...validated };
|
|
297
|
+
// Try keychain for API key (preferred over plaintext config)
|
|
298
|
+
if (!config.apiKey || config.apiKey === '') {
|
|
299
|
+
const keychainKey = await getApiKeyFromKeychain().catch(() => null);
|
|
300
|
+
if (keychainKey)
|
|
301
|
+
config.apiKey = keychainKey;
|
|
302
|
+
}
|
|
303
|
+
// Apply local overrides (project-level, gitignored)
|
|
304
|
+
config = await loadLocalOverrides(config);
|
|
305
|
+
// Apply managed/organization settings (enterprise policy)
|
|
306
|
+
config = await loadManagedSettings(config);
|
|
242
307
|
if (config.customModels && Object.keys(config.customModels).length > 0) {
|
|
243
308
|
registerUserModels(config.customModels);
|
|
244
309
|
}
|
|
245
310
|
if (config.security && Object.keys(config.security).length > 0) {
|
|
246
311
|
applySecurityConfig(config.security);
|
|
247
312
|
}
|
|
313
|
+
if (config.scopedPermissions && config.scopedPermissions.length > 0) {
|
|
314
|
+
setScopedPermissionRules(config.scopedPermissions);
|
|
315
|
+
}
|
|
248
316
|
return config;
|
|
249
317
|
}
|
|
250
318
|
catch {
|
|
251
|
-
|
|
319
|
+
// Even if config file fails, try keychain
|
|
320
|
+
const config = { ...DEFAULTS };
|
|
321
|
+
const keychainKey = await getApiKeyFromKeychain().catch(() => null);
|
|
322
|
+
if (keychainKey)
|
|
323
|
+
config.apiKey = keychainKey;
|
|
324
|
+
return config;
|
|
252
325
|
}
|
|
253
326
|
}
|
|
254
327
|
export async function saveConfig(config) {
|
|
255
328
|
await ensureDir();
|
|
256
|
-
|
|
329
|
+
// Store API key in keychain if available, strip from config file
|
|
330
|
+
const configToWrite = { ...config };
|
|
331
|
+
if (config.apiKey && await isKeychainAvailable()) {
|
|
332
|
+
const stored = await setApiKeyInKeychain(config.apiKey).catch(() => false);
|
|
333
|
+
if (stored) {
|
|
334
|
+
configToWrite.apiKey = '';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
await fs.writeFile(getConfigFile(), JSON.stringify(configToWrite, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
257
338
|
}
|
|
258
339
|
export function isConfigured(config) {
|
|
259
340
|
return config.apiKey.length > 0 && config.models.length > 0;
|