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/commands.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
3
5
|
import { logger } from './logger.js';
|
|
4
6
|
import { PROJECT_LOOKUP_DIRS, PROJECT_CONFIG_DIR } from './constants.js';
|
|
5
7
|
const COMMAND_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/commands`);
|
|
@@ -16,6 +18,9 @@ function parseFrontmatter(content) {
|
|
|
16
18
|
}
|
|
17
19
|
return { meta, body: match[2] };
|
|
18
20
|
}
|
|
21
|
+
function parseAllowedTools(spec) {
|
|
22
|
+
return spec.split(',').map(s => s.trim()).filter(Boolean);
|
|
23
|
+
}
|
|
19
24
|
export async function loadCustomCommands(cwd = process.cwd()) {
|
|
20
25
|
const commands = [];
|
|
21
26
|
for (const dir of COMMAND_DIRS) {
|
|
@@ -35,11 +40,17 @@ export async function loadCustomCommands(cwd = process.cwd()) {
|
|
|
35
40
|
const raw = await fs.readFile(filePath, 'utf-8');
|
|
36
41
|
const { meta, body } = parseFrontmatter(raw);
|
|
37
42
|
const name = entry.name.replace(/\.md$/, '');
|
|
43
|
+
const fm = meta;
|
|
38
44
|
commands.push({
|
|
39
45
|
name,
|
|
40
|
-
description:
|
|
46
|
+
description: fm.description || `Custom command: ${name}`,
|
|
41
47
|
template: body.trim(),
|
|
42
48
|
source: filePath,
|
|
49
|
+
frontmatter: fm,
|
|
50
|
+
allowedTools: fm['allowed-tools'] ? parseAllowedTools(fm['allowed-tools']) : undefined,
|
|
51
|
+
model: fm.model || undefined,
|
|
52
|
+
argumentHint: fm['argument-hint'] || undefined,
|
|
53
|
+
disableModelInvocation: fm['disable-model-invocation'] === 'true',
|
|
43
54
|
});
|
|
44
55
|
}
|
|
45
56
|
catch (err) {
|
|
@@ -49,15 +60,52 @@ export async function loadCustomCommands(cwd = process.cwd()) {
|
|
|
49
60
|
}
|
|
50
61
|
return commands;
|
|
51
62
|
}
|
|
63
|
+
function expandFileReferences(text, cwd) {
|
|
64
|
+
return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
|
|
65
|
+
const resolved = path.resolve(cwd, filePath);
|
|
66
|
+
if (!existsSync(resolved))
|
|
67
|
+
return `@${filePath} (file not found)`;
|
|
68
|
+
try {
|
|
69
|
+
const content = readFileSync(resolved, 'utf-8');
|
|
70
|
+
return `\n<file path="${filePath}">\n${content}\n</file>\n`;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return `@${filePath} (read error)`;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function expandInlineBash(text) {
|
|
78
|
+
return text.replace(/!`([^`]+)`/g, (_match, command) => {
|
|
79
|
+
try {
|
|
80
|
+
return execSync(command, { encoding: 'utf-8', timeout: 10_000 }).trim();
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return `(command failed: ${err.message})`;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function expandPositionalArgs(text, args) {
|
|
88
|
+
const parts = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
89
|
+
const cleaned = parts.map(p => p.replace(/^["']|["']$/g, ''));
|
|
90
|
+
let result = text;
|
|
91
|
+
for (let i = 0; i < 9; i++) {
|
|
92
|
+
const placeholder = `$${i + 1}`;
|
|
93
|
+
result = result.split(placeholder).join(cleaned[i] || '');
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
52
97
|
export function expandCommand(command, args) {
|
|
53
98
|
const safeArgs = args
|
|
54
99
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
55
100
|
.replace(/\$/g, '\\$')
|
|
56
101
|
.replace(/`/g, '\\`');
|
|
57
102
|
let prompt = command.template;
|
|
103
|
+
prompt = expandPositionalArgs(prompt, args);
|
|
58
104
|
prompt = prompt.replace(/\$ARGS/g, safeArgs);
|
|
59
105
|
prompt = prompt.replace(/\$CWD/g, process.cwd());
|
|
60
106
|
prompt = prompt.replace(/\$DATE/g, new Date().toISOString().split('T')[0]);
|
|
107
|
+
prompt = expandFileReferences(prompt, process.cwd());
|
|
108
|
+
prompt = expandInlineBash(prompt);
|
|
61
109
|
return prompt;
|
|
62
110
|
}
|
|
63
111
|
export function commandsSummary(commands) {
|
|
@@ -65,7 +113,10 @@ export function commandsSummary(commands) {
|
|
|
65
113
|
return `No custom commands. Add .md files to ${PROJECT_CONFIG_DIR}/commands/`;
|
|
66
114
|
const lines = ['Custom commands:'];
|
|
67
115
|
for (const cmd of commands) {
|
|
68
|
-
|
|
116
|
+
const hint = cmd.argumentHint ? ` <${cmd.argumentHint}>` : '';
|
|
117
|
+
const model = cmd.model ? ` [model: ${cmd.model}]` : '';
|
|
118
|
+
const tools = cmd.allowedTools ? ` [tools: ${cmd.allowedTools.length}]` : '';
|
|
119
|
+
lines.push(` /${cmd.name}${hint} — ${cmd.description}${model}${tools}`);
|
|
69
120
|
}
|
|
70
121
|
return lines.join('\n');
|
|
71
122
|
}
|
package/dist/constants.js
CHANGED
|
@@ -239,10 +239,10 @@ export const PROJECT_INDEX_DEFAULTS = {
|
|
|
239
239
|
symbolFileLimit: 1_000,
|
|
240
240
|
statusDisplayLimit: 50,
|
|
241
241
|
};
|
|
242
|
-
// ─── Git Identity
|
|
242
|
+
// ─── Git Identity ───
|
|
243
243
|
export const GIT_BOT_IDENTITY = {
|
|
244
|
-
name: '
|
|
245
|
-
email: '
|
|
244
|
+
name: 'Scalix Coder',
|
|
245
|
+
email: '283104927+scalix-coder@users.noreply.github.com',
|
|
246
246
|
};
|
|
247
247
|
// ─── Git Context Defaults ───
|
|
248
248
|
export const GIT_CONTEXT_DEFAULTS = {
|
package/dist/headless.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { execSync } from 'node:child_process';
|
|
4
5
|
import { createAgent } from './agent.js';
|
|
5
6
|
import { defaultTools } from './tools.js';
|
|
@@ -39,6 +40,8 @@ export function parseArgs() {
|
|
|
39
40
|
let format;
|
|
40
41
|
let permissionMode;
|
|
41
42
|
let outputSchema;
|
|
43
|
+
let worktree = false;
|
|
44
|
+
let worktreeName;
|
|
42
45
|
for (let i = 0; i < args.length; i++) {
|
|
43
46
|
if (args[i] === 'run' && i + 1 < args.length) {
|
|
44
47
|
mode = 'run';
|
|
@@ -72,6 +75,13 @@ export function parseArgs() {
|
|
|
72
75
|
else if (args[i] === '--ipc') {
|
|
73
76
|
mode = 'ipc';
|
|
74
77
|
}
|
|
78
|
+
else if (args[i] === '--worktree' || args[i] === '-w') {
|
|
79
|
+
worktree = true;
|
|
80
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-') && args[i + 1] !== 'run') {
|
|
81
|
+
worktreeName = args[i + 1];
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
75
85
|
else if (args[i] === '--help' || args[i] === '-h') {
|
|
76
86
|
console.log(`
|
|
77
87
|
Scalix - Headless Mode
|
|
@@ -91,6 +101,7 @@ Options:
|
|
|
91
101
|
--format <fmt> Output format: text (default), json, json-schema, patch, markdown
|
|
92
102
|
--output-format Alias for --format
|
|
93
103
|
--output-schema Path to JSON schema file (implies --format json-schema)
|
|
104
|
+
--worktree, -w Run in an isolated git worktree (optional: --worktree <name>)
|
|
94
105
|
--help, -h Show this help
|
|
95
106
|
`);
|
|
96
107
|
process.exit(0);
|
|
@@ -99,7 +110,7 @@ Options:
|
|
|
99
110
|
if (!process.stdin.isTTY && mode === 'interactive') {
|
|
100
111
|
mode = 'pipe';
|
|
101
112
|
}
|
|
102
|
-
return { mode, prompt, output, format, permissionMode, outputSchema };
|
|
113
|
+
return { mode, prompt, output, format, permissionMode, outputSchema, worktree, worktreeName };
|
|
103
114
|
}
|
|
104
115
|
async function buildEnhancedInstructions() {
|
|
105
116
|
let base = DEFAULT_INSTRUCTIONS;
|
|
@@ -713,10 +724,39 @@ async function runInteractive() {
|
|
|
713
724
|
}
|
|
714
725
|
async function main() {
|
|
715
726
|
setSandboxRoot(process.cwd());
|
|
716
|
-
const { mode, prompt, output, format, permissionMode: cliPermissionMode, outputSchema } = parseArgs();
|
|
727
|
+
const { mode, prompt, output, format, permissionMode: cliPermissionMode, outputSchema, worktree, worktreeName } = parseArgs();
|
|
717
728
|
if (cliPermissionMode) {
|
|
718
729
|
process.env.SCALIX_PERMISSION_MODE = cliPermissionMode;
|
|
719
730
|
}
|
|
731
|
+
// Set up worktree isolation if requested
|
|
732
|
+
let worktreeInfo = null;
|
|
733
|
+
if (worktree) {
|
|
734
|
+
const { createWorktree, removeWorktree } = await import('./worktree.js');
|
|
735
|
+
const name = worktreeName || `session-${Date.now().toString(36)}`;
|
|
736
|
+
try {
|
|
737
|
+
worktreeInfo = await createWorktree(name, process.cwd());
|
|
738
|
+
process.chdir(worktreeInfo.path);
|
|
739
|
+
setSandboxRoot(worktreeInfo.path);
|
|
740
|
+
console.log(`Working in isolated worktree: ${worktreeInfo.branch} → ${worktreeInfo.path}`);
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
console.error(`Failed to create worktree: ${err.message}`);
|
|
744
|
+
process.exit(1);
|
|
745
|
+
}
|
|
746
|
+
const cleanup = async () => {
|
|
747
|
+
if (worktreeInfo) {
|
|
748
|
+
try {
|
|
749
|
+
const origCwd = process.env.SCALIX_ORIGINAL_CWD || process.cwd();
|
|
750
|
+
process.chdir(path.dirname(worktreeInfo.path));
|
|
751
|
+
await removeWorktree(worktreeInfo, path.dirname(path.dirname(worktreeInfo.path)));
|
|
752
|
+
}
|
|
753
|
+
catch { }
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
process.on('exit', () => { cleanup().catch(() => { }); });
|
|
757
|
+
process.on('SIGINT', () => { cleanup().then(() => process.exit(0)).catch(() => process.exit(1)); });
|
|
758
|
+
process.env.SCALIX_ORIGINAL_CWD = process.cwd();
|
|
759
|
+
}
|
|
720
760
|
switch (mode) {
|
|
721
761
|
case 'run':
|
|
722
762
|
if (!prompt) {
|
package/dist/hooks.js
CHANGED
|
@@ -29,26 +29,112 @@ function matchesHook(hook, toolName) {
|
|
|
29
29
|
return true;
|
|
30
30
|
if (!toolName)
|
|
31
31
|
return false;
|
|
32
|
-
|
|
32
|
+
const patterns = hook.match.split('|').map(p => p.trim()).filter(Boolean);
|
|
33
|
+
return patterns.some(pattern => matchPattern(pattern, toolName));
|
|
34
|
+
}
|
|
35
|
+
function matchPattern(pattern, value) {
|
|
36
|
+
if (pattern === value)
|
|
37
|
+
return true;
|
|
38
|
+
if (pattern === '*')
|
|
33
39
|
return true;
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
40
|
+
if (pattern.endsWith('*') && !pattern.startsWith('/')) {
|
|
41
|
+
return value.startsWith(pattern.slice(0, -1));
|
|
42
|
+
}
|
|
43
|
+
if (pattern.startsWith('*') && !pattern.endsWith('*')) {
|
|
44
|
+
return value.endsWith(pattern.slice(1));
|
|
45
|
+
}
|
|
46
|
+
if (pattern.startsWith('/') && pattern.endsWith('/')) {
|
|
47
|
+
try {
|
|
48
|
+
return new RegExp(pattern.slice(1, -1)).test(value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (pattern.includes('*')) {
|
|
55
|
+
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
56
|
+
try {
|
|
57
|
+
return new RegExp(`^${regex}$`).test(value);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
36
62
|
}
|
|
37
63
|
return false;
|
|
38
64
|
}
|
|
39
|
-
|
|
65
|
+
function buildHookInput(event, context) {
|
|
66
|
+
const input = {
|
|
67
|
+
event,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
if (context.toolName)
|
|
71
|
+
input.tool_name = context.toolName;
|
|
72
|
+
if (context.toolArgs) {
|
|
73
|
+
try {
|
|
74
|
+
input.tool_args = JSON.parse(context.toolArgs);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
input.tool_args = context.toolArgs;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (context.toolResult !== undefined)
|
|
81
|
+
input.tool_result = context.toolResult;
|
|
82
|
+
if (context.error)
|
|
83
|
+
input.error = context.error;
|
|
84
|
+
if (context.prompt)
|
|
85
|
+
input.prompt = context.prompt.slice(0, 4096);
|
|
86
|
+
if (context.response)
|
|
87
|
+
input.response = context.response.slice(0, 4096);
|
|
88
|
+
if (context.notification)
|
|
89
|
+
input.notification = context.notification;
|
|
90
|
+
return JSON.stringify(input);
|
|
91
|
+
}
|
|
92
|
+
function parseHookOutput(stdout, exitCode) {
|
|
93
|
+
const trimmed = stdout.trim();
|
|
94
|
+
if (trimmed.startsWith('{')) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(trimmed);
|
|
97
|
+
return {
|
|
98
|
+
allowed: exitCode === 0 || exitCode === undefined,
|
|
99
|
+
output: parsed.output ?? trimmed,
|
|
100
|
+
message: parsed.message,
|
|
101
|
+
data: parsed,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch { }
|
|
105
|
+
}
|
|
106
|
+
const allowed = exitCode === 0 || exitCode === undefined;
|
|
107
|
+
return {
|
|
108
|
+
allowed,
|
|
109
|
+
output: trimmed || undefined,
|
|
110
|
+
message: exitCode === 2 ? trimmed : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export async function executeHook(hook, env, stdinData) {
|
|
40
114
|
const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
|
|
41
115
|
const hookEnv = { ...process.env, ...env };
|
|
42
116
|
const ac = new AbortController();
|
|
43
117
|
const hardKill = setTimeout(() => ac.abort(), timeout + 5000);
|
|
44
118
|
try {
|
|
45
|
-
const
|
|
119
|
+
const child = exec(hook.command, {
|
|
46
120
|
timeout,
|
|
47
121
|
env: hookEnv,
|
|
48
122
|
maxBuffer: HOOK_DEFAULTS.maxBuffer,
|
|
49
123
|
signal: ac.signal,
|
|
50
124
|
});
|
|
51
|
-
|
|
125
|
+
if (stdinData && child.stdin) {
|
|
126
|
+
child.stdin.write(stdinData);
|
|
127
|
+
child.stdin.end();
|
|
128
|
+
}
|
|
129
|
+
const result = await new Promise((resolve, reject) => {
|
|
130
|
+
let stdout = '';
|
|
131
|
+
let stderr = '';
|
|
132
|
+
child.stdout?.on('data', (d) => { stdout += d; });
|
|
133
|
+
child.stderr?.on('data', (d) => { stderr += d; });
|
|
134
|
+
child.on('error', reject);
|
|
135
|
+
child.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
136
|
+
});
|
|
137
|
+
return parseHookOutput(result.stdout, result.code);
|
|
52
138
|
}
|
|
53
139
|
catch (err) {
|
|
54
140
|
if (err.killed || err.code === 'ETIMEDOUT' || err.name === 'AbortError') {
|
|
@@ -65,7 +151,25 @@ export async function executeHook(hook, env) {
|
|
|
65
151
|
}
|
|
66
152
|
}
|
|
67
153
|
export async function runHooks(hooks, event, context) {
|
|
68
|
-
const matching = hooks.filter((h) =>
|
|
154
|
+
const matching = hooks.filter((h) => {
|
|
155
|
+
if (h.event !== event)
|
|
156
|
+
return false;
|
|
157
|
+
if (!matchesHook(h, context.toolName))
|
|
158
|
+
return false;
|
|
159
|
+
if (h.type === 'prompt')
|
|
160
|
+
return false;
|
|
161
|
+
if (h.if) {
|
|
162
|
+
try {
|
|
163
|
+
const conditionMet = evaluateCondition(h.if, context);
|
|
164
|
+
if (!conditionMet)
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
69
173
|
if (matching.length === 0)
|
|
70
174
|
return [];
|
|
71
175
|
const env = {};
|
|
@@ -77,10 +181,15 @@ export async function runHooks(hooks, event, context) {
|
|
|
77
181
|
env.SCALIX_ERROR = context.error;
|
|
78
182
|
if (context.prompt)
|
|
79
183
|
env.SCALIX_PROMPT = context.prompt.slice(0, 4096).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
184
|
+
if (context.response)
|
|
185
|
+
env.SCALIX_RESPONSE = context.response.slice(0, 4096);
|
|
186
|
+
if (context.notification)
|
|
187
|
+
env.SCALIX_NOTIFICATION = context.notification;
|
|
80
188
|
env.SCALIX_EVENT = event;
|
|
189
|
+
const stdinData = buildHookInput(event, context);
|
|
81
190
|
const results = [];
|
|
82
191
|
for (const hook of matching) {
|
|
83
|
-
const result = await executeHook(hook, env);
|
|
192
|
+
const result = await executeHook(hook, env, stdinData);
|
|
84
193
|
results.push(result);
|
|
85
194
|
logger.debug('Hook executed', { event, command: hook.command, allowed: result.allowed });
|
|
86
195
|
if (!result.allowed && event === 'PreToolUse')
|
|
@@ -88,13 +197,36 @@ export async function runHooks(hooks, event, context) {
|
|
|
88
197
|
}
|
|
89
198
|
return results;
|
|
90
199
|
}
|
|
200
|
+
function evaluateCondition(condition, context) {
|
|
201
|
+
if (condition.startsWith('tool_name=')) {
|
|
202
|
+
const expected = condition.slice('tool_name='.length);
|
|
203
|
+
return matchPattern(expected, context.toolName || '');
|
|
204
|
+
}
|
|
205
|
+
if (condition.startsWith('tool_args~')) {
|
|
206
|
+
const pattern = condition.slice('tool_args~'.length);
|
|
207
|
+
try {
|
|
208
|
+
return new RegExp(pattern).test(context.toolArgs || '');
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
export function getPromptHooks(hooks, event, toolName) {
|
|
217
|
+
return hooks.filter(h => h.event === event &&
|
|
218
|
+
h.type === 'prompt' &&
|
|
219
|
+
matchesHook(h, toolName));
|
|
220
|
+
}
|
|
91
221
|
export function hooksSummary(hooks) {
|
|
92
222
|
if (hooks.length === 0)
|
|
93
223
|
return 'No hooks configured.';
|
|
94
224
|
const lines = ['Configured hooks:'];
|
|
95
225
|
for (const h of hooks) {
|
|
96
226
|
const match = h.match ? ` (match: ${h.match})` : '';
|
|
97
|
-
|
|
227
|
+
const type = h.type === 'prompt' ? ' [prompt]' : '';
|
|
228
|
+
const cond = h.if ? ` [if: ${h.if}]` : '';
|
|
229
|
+
lines.push(` ${h.event}${match}${type}${cond}: ${h.command}`);
|
|
98
230
|
}
|
|
99
231
|
return lines.join('\n');
|
|
100
232
|
}
|
package/dist/keychain.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
const SERVICE = 'scalix-coder';
|
|
5
|
+
const ACCOUNT = 'api-key';
|
|
6
|
+
async function isMacOS() {
|
|
7
|
+
return process.platform === 'darwin';
|
|
8
|
+
}
|
|
9
|
+
async function isLinuxWithSecretTool() {
|
|
10
|
+
if (process.platform !== 'linux')
|
|
11
|
+
return false;
|
|
12
|
+
try {
|
|
13
|
+
await exec('which', ['secret-tool']);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function getApiKeyFromKeychain() {
|
|
21
|
+
try {
|
|
22
|
+
if (await isMacOS()) {
|
|
23
|
+
const { stdout } = await exec('security', [
|
|
24
|
+
'find-generic-password',
|
|
25
|
+
'-s', SERVICE,
|
|
26
|
+
'-a', ACCOUNT,
|
|
27
|
+
'-w',
|
|
28
|
+
]);
|
|
29
|
+
return stdout.trim() || null;
|
|
30
|
+
}
|
|
31
|
+
if (await isLinuxWithSecretTool()) {
|
|
32
|
+
const { stdout } = await exec('secret-tool', [
|
|
33
|
+
'lookup', 'service', SERVICE, 'account', ACCOUNT,
|
|
34
|
+
]);
|
|
35
|
+
return stdout.trim() || null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// keychain item not found or command failed
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export async function setApiKeyInKeychain(apiKey) {
|
|
44
|
+
try {
|
|
45
|
+
if (await isMacOS()) {
|
|
46
|
+
// Delete existing entry first (ignore errors if it doesn't exist)
|
|
47
|
+
try {
|
|
48
|
+
await exec('security', [
|
|
49
|
+
'delete-generic-password',
|
|
50
|
+
'-s', SERVICE,
|
|
51
|
+
'-a', ACCOUNT,
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// entry didn't exist
|
|
56
|
+
}
|
|
57
|
+
await exec('security', [
|
|
58
|
+
'add-generic-password',
|
|
59
|
+
'-s', SERVICE,
|
|
60
|
+
'-a', ACCOUNT,
|
|
61
|
+
'-w', apiKey,
|
|
62
|
+
'-U',
|
|
63
|
+
]);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (await isLinuxWithSecretTool()) {
|
|
67
|
+
const child = exec('secret-tool', [
|
|
68
|
+
'store',
|
|
69
|
+
'--label', 'Scalix Coder API Key',
|
|
70
|
+
'service', SERVICE,
|
|
71
|
+
'account', ACCOUNT,
|
|
72
|
+
]);
|
|
73
|
+
// secret-tool reads the secret from stdin
|
|
74
|
+
child.child?.stdin?.write(apiKey);
|
|
75
|
+
child.child?.stdin?.end();
|
|
76
|
+
await child;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// keychain operation failed
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
export async function deleteApiKeyFromKeychain() {
|
|
86
|
+
try {
|
|
87
|
+
if (await isMacOS()) {
|
|
88
|
+
await exec('security', [
|
|
89
|
+
'delete-generic-password',
|
|
90
|
+
'-s', SERVICE,
|
|
91
|
+
'-a', ACCOUNT,
|
|
92
|
+
]);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (await isLinuxWithSecretTool()) {
|
|
96
|
+
await exec('secret-tool', [
|
|
97
|
+
'clear', 'service', SERVICE, 'account', ACCOUNT,
|
|
98
|
+
]);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// entry didn't exist or command failed
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
export async function isKeychainAvailable() {
|
|
108
|
+
return await isMacOS() || await isLinuxWithSecretTool();
|
|
109
|
+
}
|
package/dist/markdown.js
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { Marked } from 'marked';
|
|
3
3
|
import { markedTerminal } from 'marked-terminal';
|
|
4
|
+
let highlightFn = null;
|
|
5
|
+
let highlightLoaded = false;
|
|
6
|
+
function getHighlighter() {
|
|
7
|
+
if (highlightLoaded)
|
|
8
|
+
return highlightFn;
|
|
9
|
+
highlightLoaded = true;
|
|
10
|
+
try {
|
|
11
|
+
// cli-highlight is a dependency — provides 100+ language highlighting
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
|
+
const mod = require('cli-highlight');
|
|
14
|
+
highlightFn = mod.highlight;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
highlightFn = null;
|
|
18
|
+
}
|
|
19
|
+
return highlightFn;
|
|
20
|
+
}
|
|
21
|
+
function highlightCode(code, language) {
|
|
22
|
+
const hl = getHighlighter();
|
|
23
|
+
if (!hl)
|
|
24
|
+
return chalk.yellow(code);
|
|
25
|
+
try {
|
|
26
|
+
return hl(code, { language: language || undefined });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Unknown language or highlight error — fall back to yellow
|
|
30
|
+
return chalk.yellow(code);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
4
33
|
function createMarked(width) {
|
|
5
34
|
const m = new Marked();
|
|
6
35
|
const mt = markedTerminal({
|
|
7
36
|
strong: chalk.bold,
|
|
8
37
|
em: chalk.italic,
|
|
9
38
|
codespan: chalk.cyan,
|
|
10
|
-
code:
|
|
39
|
+
code: highlightCode,
|
|
11
40
|
heading: chalk.green.bold,
|
|
12
41
|
firstHeading: chalk.magenta.bold,
|
|
13
42
|
hr: chalk.dim,
|
|
@@ -25,15 +54,29 @@ function createMarked(width) {
|
|
|
25
54
|
}
|
|
26
55
|
let cachedWidth = 0;
|
|
27
56
|
let cachedMarked = null;
|
|
57
|
+
// Content-hash render cache to avoid re-rendering identical content
|
|
58
|
+
const renderCache = new Map();
|
|
59
|
+
const MAX_CACHE_SIZE = 128;
|
|
28
60
|
export function renderMarkdown(text, cols) {
|
|
29
61
|
const width = Math.max((cols ?? process.stdout.columns ?? 100) - 6, 40);
|
|
62
|
+
const cacheKey = `${width}:${text}`;
|
|
63
|
+
const cached = renderCache.get(cacheKey);
|
|
64
|
+
if (cached)
|
|
65
|
+
return cached;
|
|
30
66
|
if (!cachedMarked || width !== cachedWidth) {
|
|
31
67
|
cachedWidth = width;
|
|
32
68
|
cachedMarked = createMarked(width);
|
|
33
69
|
}
|
|
34
70
|
try {
|
|
35
|
-
const result = cachedMarked.parse(text);
|
|
36
|
-
|
|
71
|
+
const result = cachedMarked.parse(text).replace(/\n{3,}/g, '\n\n').trim();
|
|
72
|
+
// Evict oldest entries when cache is full
|
|
73
|
+
if (renderCache.size >= MAX_CACHE_SIZE) {
|
|
74
|
+
const firstKey = renderCache.keys().next().value;
|
|
75
|
+
if (firstKey !== undefined)
|
|
76
|
+
renderCache.delete(firstKey);
|
|
77
|
+
}
|
|
78
|
+
renderCache.set(cacheKey, result);
|
|
79
|
+
return result;
|
|
37
80
|
}
|
|
38
81
|
catch {
|
|
39
82
|
return text;
|