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/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
+ }
@@ -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 ignorePattern = IGNORE_DIRS.map((d) => `**/${d}/**`);
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
- const config = { ...DEFAULTS, ...validated };
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
- return { ...DEFAULTS };
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
- await fs.writeFile(getConfigFile(), JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
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;