@semalt-ai/code 1.7.0 → 1.8.1
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/.claude/settings.local.json +8 -0
- package/ARCHITECTURE.md +99 -0
- package/CLAUDE.md +349 -0
- package/index.js +69 -7
- package/lib/agent.js +577 -39
- package/lib/api.js +285 -79
- package/lib/args.js +31 -0
- package/lib/audit.js +31 -0
- package/lib/commands.js +1006 -307
- package/lib/config.js +51 -5
- package/lib/constants.js +72 -0
- package/lib/context.js +2 -6
- package/lib/metrics.js +94 -0
- package/lib/permissions.js +180 -49
- package/lib/prompts.js +96 -13
- package/lib/storage.js +96 -0
- package/lib/tools.js +1009 -35
- package/lib/ui/ansi.js +65 -0
- package/lib/ui/chat-history.js +217 -0
- package/lib/ui/create-ui.js +474 -0
- package/lib/ui/diff.js +243 -0
- package/lib/ui/input-field.js +1176 -0
- package/lib/ui/layout.js +53 -0
- package/lib/ui/legacy.js +130 -0
- package/lib/ui/status-bar.js +131 -0
- package/lib/ui/stream.js +158 -0
- package/lib/ui/utils.js +45 -0
- package/lib/ui.js +42 -598
- package/package.json +1 -1
- package/path +1 -0
package/lib/config.js
CHANGED
|
@@ -7,6 +7,12 @@ const { CONFIG_PATH, DEFAULT_CONFIG } = require('./constants');
|
|
|
7
7
|
|
|
8
8
|
function normalizeConfig(cfg = {}) {
|
|
9
9
|
const merged = { ...DEFAULT_CONFIG, ...cfg };
|
|
10
|
+
// Ensure every DEFAULT_CONFIG key is present without overwriting existing values
|
|
11
|
+
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
|
12
|
+
if (!(key in merged) || merged[key] === undefined) {
|
|
13
|
+
merged[key] = DEFAULT_CONFIG[key];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
10
16
|
const legacyDashboardUrl = typeof cfg.semalt_base_url === 'string' && cfg.semalt_base_url.trim()
|
|
11
17
|
? cfg.semalt_base_url.trim()
|
|
12
18
|
: '';
|
|
@@ -24,6 +30,9 @@ function normalizeConfig(cfg = {}) {
|
|
|
24
30
|
merged.default_model = typeof merged.default_model === 'string' && merged.default_model.trim()
|
|
25
31
|
? merged.default_model.trim()
|
|
26
32
|
: DEFAULT_CONFIG.default_model;
|
|
33
|
+
merged.dashboard_model_id = Number.isInteger(cfg.dashboard_model_id) && cfg.dashboard_model_id > 0
|
|
34
|
+
? cfg.dashboard_model_id
|
|
35
|
+
: null;
|
|
27
36
|
merged.models = Array.isArray(cfg.models)
|
|
28
37
|
? cfg.models
|
|
29
38
|
.filter((entry) => entry &&
|
|
@@ -32,16 +41,26 @@ function normalizeConfig(cfg = {}) {
|
|
|
32
41
|
typeof entry.model === 'string' &&
|
|
33
42
|
entry.api_base.trim() &&
|
|
34
43
|
entry.model.trim())
|
|
35
|
-
.map((entry) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
.map((entry) => {
|
|
45
|
+
const normalized = {
|
|
46
|
+
api_base: entry.api_base.trim(),
|
|
47
|
+
api_key: entry.api_key,
|
|
48
|
+
model: entry.model.trim(),
|
|
49
|
+
};
|
|
50
|
+
if (typeof entry.name === 'string' && entry.name.trim()) {
|
|
51
|
+
normalized.name = entry.name.trim();
|
|
52
|
+
}
|
|
53
|
+
if (Number.isInteger(entry.context_length) && entry.context_length > 0) {
|
|
54
|
+
normalized.context_length = entry.context_length;
|
|
55
|
+
}
|
|
56
|
+
return normalized;
|
|
57
|
+
})
|
|
40
58
|
: [];
|
|
41
59
|
return merged;
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
function loadConfig() {
|
|
63
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
45
64
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
46
65
|
try {
|
|
47
66
|
const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
@@ -56,7 +75,34 @@ function saveConfig(cfg) {
|
|
|
56
75
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(normalizeConfig(cfg), null, 2));
|
|
57
76
|
}
|
|
58
77
|
|
|
78
|
+
function configSet(key, value) {
|
|
79
|
+
const cfg = loadConfig();
|
|
80
|
+
cfg[key] = value;
|
|
81
|
+
saveConfig(cfg);
|
|
82
|
+
return cfg;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const REDACTED_KEYS = new Set(['api_key', 'auth_token']);
|
|
86
|
+
|
|
87
|
+
function configShow(systemPromptOverride = null) {
|
|
88
|
+
const cfg = loadConfig();
|
|
89
|
+
const lines = ['Config:'];
|
|
90
|
+
for (const [key, val] of Object.entries(cfg)) {
|
|
91
|
+
const display = REDACTED_KEYS.has(key) && val ? '****' : JSON.stringify(val);
|
|
92
|
+
lines.push(` ${key}: ${display}`);
|
|
93
|
+
}
|
|
94
|
+
if (systemPromptOverride) {
|
|
95
|
+
lines.push(` system_prompt: [override from ${systemPromptOverride}]`);
|
|
96
|
+
} else {
|
|
97
|
+
const { SYSTEM_PROMPT } = require('./prompts');
|
|
98
|
+
lines.push(` system_prompt: ${SYSTEM_PROMPT.slice(0, 80)}...`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
module.exports = {
|
|
104
|
+
configSet,
|
|
105
|
+
configShow,
|
|
60
106
|
loadConfig,
|
|
61
107
|
normalizeConfig,
|
|
62
108
|
saveConfig,
|
package/lib/constants.js
CHANGED
|
@@ -13,17 +13,89 @@ const DEFAULT_CONFIG = {
|
|
|
13
13
|
dashboard_url: 'https://cli.semalt.ai',
|
|
14
14
|
auth_token: '',
|
|
15
15
|
default_model: 'default',
|
|
16
|
+
dashboard_model_id: null,
|
|
16
17
|
temperature: 0.7,
|
|
17
18
|
request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
|
|
18
19
|
stream: true,
|
|
19
20
|
models: [],
|
|
21
|
+
theme: 'dark',
|
|
22
|
+
max_file_size_kb: 512,
|
|
23
|
+
command_timeout_ms: 30000,
|
|
24
|
+
max_output_lines: 50,
|
|
25
|
+
show_token_count: true,
|
|
26
|
+
show_cost: false,
|
|
27
|
+
system_prompt_mode: 'system_role',
|
|
20
28
|
};
|
|
21
29
|
|
|
22
30
|
const CONFIG_PATH = path.join(os.homedir(), '.semalt-ai', 'config.json');
|
|
23
31
|
|
|
32
|
+
const TAG_REGISTRY = {
|
|
33
|
+
// Rendered visually in chat, never shown as raw text
|
|
34
|
+
think: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
35
|
+
reasoning: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
36
|
+
reflection: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
37
|
+
plan: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
38
|
+
|
|
39
|
+
// Executed as tool calls
|
|
40
|
+
exec: { type: 'tool', streaming: false, label: 'Running command' },
|
|
41
|
+
shell: { type: 'tool', streaming: false, label: 'Running shell' },
|
|
42
|
+
read_file: { type: 'tool', streaming: false, label: 'Reading file' },
|
|
43
|
+
write_file: { type: 'tool', streaming: false, label: 'Writing file' },
|
|
44
|
+
create_file: { type: 'tool', streaming: false, label: 'Creating file' },
|
|
45
|
+
append_file: { type: 'tool', streaming: false, label: 'Appending to file' },
|
|
46
|
+
delete_file: { type: 'tool', streaming: false, label: 'Deleting file' },
|
|
47
|
+
list_dir: { type: 'tool', streaming: false, label: 'Listing directory' },
|
|
48
|
+
make_dir: { type: 'tool', streaming: false, label: 'Creating directory' },
|
|
49
|
+
remove_dir: { type: 'tool', streaming: false, label: 'Removing directory' },
|
|
50
|
+
move_file: { type: 'tool', streaming: false, label: 'Moving file' },
|
|
51
|
+
copy_file: { type: 'tool', streaming: false, label: 'Copying file' },
|
|
52
|
+
file_stat: { type: 'tool', streaming: false, label: 'Inspecting file' },
|
|
53
|
+
edit_file: { type: 'tool', streaming: false, label: 'Editing file' },
|
|
54
|
+
search_files: { type: 'tool', streaming: false, label: 'Searching files' },
|
|
55
|
+
search_in_file: { type: 'tool', streaming: false, label: 'Searching in file' },
|
|
56
|
+
replace_in_file: { type: 'tool', streaming: false, label: 'Replacing in file' },
|
|
57
|
+
get_env: { type: 'tool', streaming: false, label: 'Reading env var' },
|
|
58
|
+
set_env: { type: 'tool', streaming: false, label: 'Setting env var' },
|
|
59
|
+
download: { type: 'tool', streaming: false, label: 'Downloading' },
|
|
60
|
+
upload: { type: 'tool', streaming: false, label: 'Uploading' },
|
|
61
|
+
http_get: { type: 'tool', streaming: false, label: 'Fetching URL' },
|
|
62
|
+
http_get_next: { type: 'tool', streaming: false, label: 'Fetching next content chunk' },
|
|
63
|
+
ask_user: { type: 'tool', streaming: false, label: 'Asking user' },
|
|
64
|
+
store_memory: { type: 'tool', streaming: false, label: 'Storing memory' },
|
|
65
|
+
recall_memory: { type: 'tool', streaming: false, label: 'Recalling memory' },
|
|
66
|
+
list_memories: { type: 'tool', streaming: false, label: 'Listing memories' },
|
|
67
|
+
system_info: { type: 'tool', streaming: false, label: 'Reading system info' },
|
|
68
|
+
|
|
69
|
+
// MiniMax-M2 native tool-call wrappers. `extractToolCalls` parses them into
|
|
70
|
+
// internal calls; classifying them here keeps raw XML out of the UI stream.
|
|
71
|
+
'minimax:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
|
|
72
|
+
invoke: { type: 'strip' },
|
|
73
|
+
parameter: { type: 'strip' },
|
|
74
|
+
|
|
75
|
+
// Qwen / Hermes native tool-call wrappers. Qwen3-family models emit a
|
|
76
|
+
// JSON-shaped `<tool_call>{"name":"...","arguments":{...}}</tool_call>`
|
|
77
|
+
// block inline when the server's tool parser is not applied, and some
|
|
78
|
+
// finetunes also use the namespaced `<qwen:tool_call>` or the
|
|
79
|
+
// `<function_call>` spelling. `extractToolCalls` parses all three.
|
|
80
|
+
'qwen:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
|
|
81
|
+
tool_call: { type: 'tool', streaming: false, label: 'Using tool' },
|
|
82
|
+
function_call: { type: 'tool', streaming: false, label: 'Using tool' },
|
|
83
|
+
|
|
84
|
+
// Silently stripped — model wrapper artifacts
|
|
85
|
+
answer: { type: 'strip' },
|
|
86
|
+
response: { type: 'strip' },
|
|
87
|
+
output: { type: 'strip' },
|
|
88
|
+
assistant: { type: 'strip' },
|
|
89
|
+
message: { type: 'strip' },
|
|
90
|
+
text: { type: 'strip' },
|
|
91
|
+
result: { type: 'strip' },
|
|
92
|
+
code: { type: 'strip' },
|
|
93
|
+
};
|
|
94
|
+
|
|
24
95
|
module.exports = {
|
|
25
96
|
CONFIG_PATH,
|
|
26
97
|
DEFAULT_API_TIMEOUT_MS,
|
|
27
98
|
DEFAULT_CONFIG,
|
|
28
99
|
PACKAGE_JSON,
|
|
100
|
+
TAG_REGISTRY,
|
|
29
101
|
};
|
package/lib/context.js
CHANGED
|
@@ -3,13 +3,11 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
-
function readFileContext(filePaths
|
|
7
|
-
const { FG_GRAY, FG_GREEN, FG_RED, RST } = ui;
|
|
6
|
+
function readFileContext(filePaths) {
|
|
8
7
|
let context = '';
|
|
9
8
|
|
|
10
9
|
for (const fp of filePaths) {
|
|
11
10
|
if (!fs.existsSync(fp)) {
|
|
12
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not found: ${fp}${RST}`);
|
|
13
11
|
continue;
|
|
14
12
|
}
|
|
15
13
|
|
|
@@ -18,9 +16,8 @@ function readFileContext(filePaths, ui) {
|
|
|
18
16
|
try {
|
|
19
17
|
const content = fs.readFileSync(fp, 'utf8');
|
|
20
18
|
context += `\n--- File: ${fp} ---\n${content}\n`;
|
|
21
|
-
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Loaded ${fp} (${content.length} chars)${RST}`);
|
|
22
19
|
} catch (error) {
|
|
23
|
-
|
|
20
|
+
// silently skip unreadable files
|
|
24
21
|
}
|
|
25
22
|
continue;
|
|
26
23
|
}
|
|
@@ -59,7 +56,6 @@ function readFileContext(filePaths, ui) {
|
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
walkDir(fp);
|
|
62
|
-
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Loaded ${count} files from ${fp}${RST}`);
|
|
63
59
|
}
|
|
64
60
|
}
|
|
65
61
|
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { THEME } = require('./ui');
|
|
4
|
+
|
|
5
|
+
class Metrics {
|
|
6
|
+
constructor(modelTokenLimit = null) {
|
|
7
|
+
this.sessionStart = Date.now();
|
|
8
|
+
this.turns = [];
|
|
9
|
+
this.modelTokenLimit = modelTokenLimit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
startTurn() {
|
|
13
|
+
this.turns.push({ start: Date.now(), promptTokens: 0, completionTokens: 0 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
endTurn(usage, model) {
|
|
17
|
+
const last = this.turns[this.turns.length - 1];
|
|
18
|
+
if (!last) return;
|
|
19
|
+
last.end = Date.now();
|
|
20
|
+
last.promptTokens = (usage && usage.prompt_tokens) || 0;
|
|
21
|
+
last.completionTokens = (usage && usage.completion_tokens) || 0;
|
|
22
|
+
last.model = model;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
totalTokens() {
|
|
26
|
+
return this.turns.reduce((sum, t) => sum + t.promptTokens + t.completionTokens, 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
contextTokens() {
|
|
30
|
+
if (!this.turns.length) return 0;
|
|
31
|
+
return this.turns[this.turns.length - 1].promptTokens;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
tokenLimitStatus() {
|
|
35
|
+
if (this.modelTokenLimit === null) return null;
|
|
36
|
+
const used = this.contextTokens();
|
|
37
|
+
const pct = Math.round((used / this.modelTokenLimit) * 100);
|
|
38
|
+
const bar = this._buildBar(pct, 10);
|
|
39
|
+
return { used, limit: this.modelTokenLimit, pct, bar };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_buildBar(pct, width) {
|
|
43
|
+
const filled = Math.min(Math.round((pct / 100) * width), width);
|
|
44
|
+
const empty = Math.max(0, width - filled);
|
|
45
|
+
const bar = '█'.repeat(filled) + '░'.repeat(empty);
|
|
46
|
+
let color;
|
|
47
|
+
if (pct < 60) color = THEME.agent;
|
|
48
|
+
else if (pct < 85) color = THEME.warn;
|
|
49
|
+
else color = THEME.error;
|
|
50
|
+
return color + bar + THEME.reset;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
averageLatency() {
|
|
54
|
+
const completed = this.turns.filter((t) => t.end !== undefined);
|
|
55
|
+
if (!completed.length) return '0.0s';
|
|
56
|
+
const avgMs = completed.reduce((sum, t) => sum + (t.end - t.start), 0) / completed.length;
|
|
57
|
+
return (avgMs / 1000).toFixed(1) + 's';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
summary() {
|
|
61
|
+
const totalMs = Date.now() - this.sessionStart;
|
|
62
|
+
const minutes = Math.floor(totalMs / 60000);
|
|
63
|
+
const seconds = Math.floor((totalMs % 60000) / 1000);
|
|
64
|
+
const durationStr = `${minutes}m ${seconds}s`;
|
|
65
|
+
|
|
66
|
+
// Inner width between ║ chars = 39
|
|
67
|
+
// Header: '══ Session Summary ' = 19 chars, fill the rest with ═
|
|
68
|
+
const IW = 39;
|
|
69
|
+
const row = (content) => {
|
|
70
|
+
const pad = ' '.repeat(Math.max(0, IW - content.length));
|
|
71
|
+
return `║${content}${pad}║`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const lines = [
|
|
75
|
+
`╔══ Session Summary ${'═'.repeat(IW - 19)}╗`,
|
|
76
|
+
row(` Turns: ${this.turns.length}`),
|
|
77
|
+
row(` Total tokens: ${this.totalTokens()}`),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const status = this.tokenLimitStatus();
|
|
81
|
+
if (status !== null) {
|
|
82
|
+
lines.push(row(` Context used: ${this.contextTokens()}`));
|
|
83
|
+
lines.push(row(` Token limit: ${status.used}/${status.limit} (${status.pct}%)`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push(row(` Duration: ${durationStr}`));
|
|
87
|
+
lines.push(row(` Avg latency: ${this.averageLatency()} / turn`));
|
|
88
|
+
lines.push(`╚${'═'.repeat(IW)}╝`);
|
|
89
|
+
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { Metrics };
|
package/lib/permissions.js
CHANGED
|
@@ -1,72 +1,196 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
const TIER_FS = ['read_file', 'write_file', 'append_file', 'delete_file', 'list_dir', 'make_dir', 'move_file', 'copy_file', 'file_stat', 'search_files', 'store_memory', 'recall_memory'];
|
|
4
|
+
const TIER_EXEC = ['exec'];
|
|
5
|
+
const TIER_NET = ['http_get', 'download'];
|
|
6
|
+
const TIER_SYS = ['system_info', 'get_env', 'set_env'];
|
|
7
|
+
|
|
8
|
+
const TIER_MAP = { fs: TIER_FS, exec: TIER_EXEC, net: TIER_NET, sys: TIER_SYS };
|
|
9
|
+
const READONLY_BLOCKED = new Set(['write_file', 'append_file', 'delete_file', 'move_file', 'copy_file']);
|
|
10
|
+
|
|
11
|
+
let _permissionCounter = 0;
|
|
12
|
+
let _permissionQueueTail = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {}) {
|
|
15
|
+
const { BOLD, FG_CYAN, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST, interactiveSelect } = ui;
|
|
16
|
+
|
|
17
|
+
const autoApprovedTags = new Set();
|
|
18
|
+
for (const tierName of allowedTiers) {
|
|
19
|
+
const tags = TIER_MAP[tierName] || [];
|
|
20
|
+
tags.forEach((t) => autoApprovedTags.add(t));
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
const state = {
|
|
6
24
|
autoApproveShell: false,
|
|
7
25
|
autoApproveFile: false,
|
|
8
26
|
};
|
|
9
27
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
28
|
+
let uiCallbacks = null;
|
|
29
|
+
|
|
30
|
+
function setUICallbacks(callbacks) {
|
|
31
|
+
uiCallbacks = callbacks;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function captureSelect(menu) {
|
|
35
|
+
if (uiCallbacks && uiCallbacks.captureSelect) {
|
|
36
|
+
return uiCallbacks.captureSelect(menu);
|
|
37
|
+
}
|
|
38
|
+
return Array.isArray(menu.options) && menu.options.length > 0 ? menu.options[0] : null;
|
|
14
39
|
}
|
|
15
40
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
41
|
+
const MAX_DESC_LINES = 12;
|
|
42
|
+
|
|
43
|
+
function requestPermission(description, onAddMessage, onRerenderMessage, onCollapseMessage, onCaptureNavigation) {
|
|
44
|
+
// Serialize dialogs: each permission waits for the previous one to be answered
|
|
45
|
+
const myTurn = _permissionQueueTail;
|
|
46
|
+
let releaseQueue;
|
|
47
|
+
_permissionQueueTail = new Promise((r) => { releaseQueue = r; });
|
|
48
|
+
|
|
49
|
+
return myTurn.then(() => new Promise((resolve) => {
|
|
50
|
+
const uniqueId = `perm_${++_permissionCounter}`;
|
|
51
|
+
const options = ['Yes', 'Always', 'No'];
|
|
52
|
+
let selectedIdx = 0;
|
|
53
|
+
const descLines = description.split('\n');
|
|
54
|
+
const truncatable = descLines.length > MAX_DESC_LINES;
|
|
55
|
+
|
|
56
|
+
function buildContent() {
|
|
57
|
+
let visibleDesc;
|
|
58
|
+
if (truncatable) {
|
|
59
|
+
visibleDesc = descLines.slice(0, MAX_DESC_LINES).join('\n') +
|
|
60
|
+
`\n \x1b[2m… ${descLines.length - MAX_DESC_LINES} more lines\x1b[0m`;
|
|
61
|
+
} else {
|
|
62
|
+
visibleDesc = description;
|
|
63
|
+
}
|
|
64
|
+
const parts = [visibleDesc, ''];
|
|
65
|
+
for (let i = 0; i < options.length; i++) {
|
|
66
|
+
parts.push(i === selectedIdx
|
|
67
|
+
? `\x1b[1m\x1b[36m ► ${options[i]}\x1b[0m`
|
|
68
|
+
: ` ${options[i]}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return parts.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const permMsg = { role: 'system', id: uniqueId, content: buildContent() };
|
|
75
|
+
onAddMessage(permMsg);
|
|
76
|
+
|
|
77
|
+
const releaseNav = onCaptureNavigation((action) => {
|
|
78
|
+
if (action === 'next') {
|
|
79
|
+
selectedIdx = (selectedIdx + 1) % options.length;
|
|
80
|
+
permMsg.content = buildContent();
|
|
81
|
+
onRerenderMessage(uniqueId);
|
|
82
|
+
} else if (action === 'prev') {
|
|
83
|
+
selectedIdx = (selectedIdx - 1 + options.length) % options.length;
|
|
84
|
+
permMsg.content = buildContent();
|
|
85
|
+
onRerenderMessage(uniqueId);
|
|
86
|
+
} else if (action === 'select') {
|
|
87
|
+
const chosen = options[selectedIdx];
|
|
88
|
+
releaseNav();
|
|
89
|
+
permMsg.content = chosen === 'No' ? `✗ ${description}` : `✓ ${description}`;
|
|
90
|
+
onCollapseMessage(uniqueId);
|
|
91
|
+
releaseQueue();
|
|
92
|
+
resolve(chosen.toLowerCase());
|
|
93
|
+
} else if (action === 'cancel') {
|
|
94
|
+
releaseNav();
|
|
95
|
+
permMsg.content = `✗ ${description}`;
|
|
96
|
+
onCollapseMessage(uniqueId);
|
|
97
|
+
releaseQueue();
|
|
98
|
+
resolve('no');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}));
|
|
22
102
|
}
|
|
23
103
|
|
|
24
|
-
function askPermission(actionType, description) {
|
|
25
|
-
|
|
26
|
-
if (
|
|
104
|
+
async function askPermission(actionType, description, tag) {
|
|
105
|
+
if (tag && autoApprovedTags.has(tag)) {
|
|
106
|
+
if (uiCallbacks) {
|
|
107
|
+
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
|
|
108
|
+
} else {
|
|
27
109
|
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
28
|
-
resolve(true);
|
|
29
|
-
return;
|
|
30
110
|
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
31
113
|
|
|
32
|
-
|
|
114
|
+
if (actionType === 'shell' && state.autoApproveShell) {
|
|
115
|
+
if (uiCallbacks) {
|
|
116
|
+
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
|
|
117
|
+
} else {
|
|
33
118
|
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
34
|
-
resolve(true);
|
|
35
|
-
return;
|
|
36
119
|
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
37
122
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (result.type === 'sigint' || result.type === 'eof') {
|
|
47
|
-
console.log(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
|
|
48
|
-
resolve(false);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
123
|
+
if (actionType === 'file' && state.autoApproveFile) {
|
|
124
|
+
if (uiCallbacks) {
|
|
125
|
+
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
|
|
126
|
+
} else {
|
|
127
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
51
131
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
132
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
133
|
+
process.stdout.write(` [non-TTY] Auto-approving: ${description}\n`);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
57
136
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
137
|
+
if (uiCallbacks) {
|
|
138
|
+
const result = await requestPermission(
|
|
139
|
+
`${actionType}: ${description}`,
|
|
140
|
+
uiCallbacks.onAddMessage,
|
|
141
|
+
uiCallbacks.onRerenderMessage,
|
|
142
|
+
uiCallbacks.onCollapseMessage,
|
|
143
|
+
uiCallbacks.onCaptureNavigation,
|
|
144
|
+
);
|
|
65
145
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
146
|
+
if (result === 'no') return false;
|
|
147
|
+
|
|
148
|
+
if (result === 'always') {
|
|
149
|
+
if (actionType === 'shell') state.autoApproveShell = true;
|
|
150
|
+
else state.autoApproveFile = true;
|
|
151
|
+
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approve enabled for ${actionType} operations` });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Fallback: legacy TTY interactive select (used outside of chat UI)
|
|
158
|
+
const alwaysLabel = actionType === 'shell' ? 'Yes, always for shell' : 'Yes, always for files';
|
|
159
|
+
const choices = ['Yes', alwaysLabel, 'No'];
|
|
160
|
+
|
|
161
|
+
console.log();
|
|
162
|
+
console.log(` ${FG_YELLOW}${BOLD}⚠ Permission required${RST}`);
|
|
163
|
+
console.log(` ${FG_GRAY}${actionType}: ${description}${RST}`);
|
|
164
|
+
|
|
165
|
+
const selectedIndex = await interactiveSelect(
|
|
166
|
+
choices,
|
|
167
|
+
(label, isSelected) => {
|
|
168
|
+
const cursor = isSelected ? `${FG_YELLOW}❯${RST}` : ' ';
|
|
169
|
+
const style = isSelected ? `${FG_CYAN}` : FG_GRAY;
|
|
170
|
+
return ` ${cursor} ${style}${label}${RST}`;
|
|
171
|
+
},
|
|
172
|
+
{ initialIndex: 0 },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (selectedIndex === null || selectedIndex === 2) {
|
|
176
|
+
console.log(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (selectedIndex === 1) {
|
|
181
|
+
if (actionType === 'shell') state.autoApproveShell = true;
|
|
182
|
+
else state.autoApproveFile = true;
|
|
183
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for ${actionType} operations${RST}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readonlyBlock(tag) {
|
|
190
|
+
if (readonly && READONLY_BLOCKED.has(tag)) {
|
|
191
|
+
return { error: 'blocked by --readonly' };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
70
194
|
}
|
|
71
195
|
|
|
72
196
|
function clear() {
|
|
@@ -82,7 +206,10 @@ function createPermissionManager(ui) {
|
|
|
82
206
|
|
|
83
207
|
return {
|
|
84
208
|
askPermission,
|
|
209
|
+
captureSelect,
|
|
85
210
|
clear,
|
|
211
|
+
readonlyBlock,
|
|
212
|
+
setUICallbacks,
|
|
86
213
|
state,
|
|
87
214
|
toggleAll,
|
|
88
215
|
};
|
|
@@ -90,4 +217,8 @@ function createPermissionManager(ui) {
|
|
|
90
217
|
|
|
91
218
|
module.exports = {
|
|
92
219
|
createPermissionManager,
|
|
220
|
+
TIER_EXEC,
|
|
221
|
+
TIER_FS,
|
|
222
|
+
TIER_NET,
|
|
223
|
+
TIER_SYS,
|
|
93
224
|
};
|