blyuecode 0.1.10 → 0.1.12
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/core/localCheck.js +14 -0
- package/dist/core/provider.js +39 -1
- package/dist/core/redact.js +42 -0
- package/dist/core/sandbox.js +35 -0
- package/dist/core/tools.js +11 -4
- package/dist/index.js +6 -2
- package/dist/ui/Chat.js +40 -6
- package/dist/ui/Consent.js +26 -0
- package/dist/ui/Settings.js +28 -22
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function detectOllama(url = 'http://localhost:11434') {
|
|
2
|
+
try {
|
|
3
|
+
const res = await fetch(`${url}/api/tags`, {
|
|
4
|
+
signal: AbortSignal.timeout(1500),
|
|
5
|
+
});
|
|
6
|
+
if (!res.ok)
|
|
7
|
+
return [];
|
|
8
|
+
const json = await res.json();
|
|
9
|
+
return (json.models || []).map((m) => m.name);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/core/provider.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { redact } from './redact.js';
|
|
6
|
+
const DEBUG_DIR = path.join(os.homedir(), '.blyue');
|
|
7
|
+
const LAST_REQUEST = path.join(DEBUG_DIR, 'last-request.json');
|
|
2
8
|
export class Provider {
|
|
3
9
|
client;
|
|
4
10
|
model;
|
|
@@ -12,14 +18,46 @@ export class Provider {
|
|
|
12
18
|
baseURL: process.env.OPENAI_BASE_URL,
|
|
13
19
|
});
|
|
14
20
|
}
|
|
21
|
+
sanitize(messages) {
|
|
22
|
+
let redactedCount = 0;
|
|
23
|
+
const out = messages.map((m) => {
|
|
24
|
+
if (typeof m.content !== 'string')
|
|
25
|
+
return m;
|
|
26
|
+
const { text, count } = redact(m.content);
|
|
27
|
+
redactedCount += count;
|
|
28
|
+
return { ...m, content: text };
|
|
29
|
+
});
|
|
30
|
+
return { messages: out, redactedCount };
|
|
31
|
+
}
|
|
32
|
+
writeDebug(payload) {
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.existsSync(DEBUG_DIR))
|
|
35
|
+
fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
|
36
|
+
fs.writeFileSync(LAST_REQUEST, JSON.stringify(payload, null, 2));
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
15
40
|
async chat(messages, tools) {
|
|
41
|
+
const { messages: safeMessages, redactedCount } = this.sanitize(messages);
|
|
16
42
|
try {
|
|
17
43
|
const res = await this.client.chat.completions.create({
|
|
18
44
|
model: this.model,
|
|
19
|
-
messages:
|
|
45
|
+
messages: safeMessages,
|
|
20
46
|
tools,
|
|
21
47
|
tool_choice: 'auto',
|
|
22
48
|
});
|
|
49
|
+
// Log debug info after successful call
|
|
50
|
+
const totalBytes = JSON.stringify(safeMessages).length;
|
|
51
|
+
this.writeDebug({
|
|
52
|
+
model: this.model,
|
|
53
|
+
baseUrl: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
|
|
54
|
+
messages: safeMessages,
|
|
55
|
+
approxTokens: Math.round(totalBytes / 4),
|
|
56
|
+
approxBytes: totalBytes,
|
|
57
|
+
messageCount: safeMessages.length,
|
|
58
|
+
redactedCount,
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
});
|
|
23
61
|
const msg = res.choices[0].message;
|
|
24
62
|
return {
|
|
25
63
|
content: msg.content,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const PATTERNS = [
|
|
2
|
+
// OpenAI / OpenRouter
|
|
3
|
+
[/sk-or-v1-[a-zA-Z0-9]{32,}/g, 'sk-or-v1-***REDACTED***'],
|
|
4
|
+
[/sk-[a-zA-Z0-9]{20,}/g, 'sk-***REDACTED***'],
|
|
5
|
+
// Anthropic
|
|
6
|
+
[/sk-ant-[a-zA-Z0-9\-_]{20,}/g, 'sk-ant-***REDACTED***'],
|
|
7
|
+
// GitHub
|
|
8
|
+
[/ghp_[a-zA-Z0-9]{36,}/g, 'ghp_***REDACTED***'],
|
|
9
|
+
[/gho_[a-zA-Z0-9]{36,}/g, 'gho_***REDACTED***'],
|
|
10
|
+
[/ghs_[a-zA-Z0-9]{36,}/g, 'ghs_***REDACTED***'],
|
|
11
|
+
[/github_pat_[a-zA-Z0-9_]{60,}/g, 'github_pat_***REDACTED***'],
|
|
12
|
+
// AWS
|
|
13
|
+
[/AKIA[0-9A-Z]{16}/g, 'AKIA***REDACTED***'],
|
|
14
|
+
[/aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}/gi, 'aws_secret_access_key=***REDACTED***'],
|
|
15
|
+
// Google
|
|
16
|
+
[/AIza[0-9A-Za-z_\-]{35}/g, 'AIza***REDACTED***'],
|
|
17
|
+
// Slack
|
|
18
|
+
[/xox[baprs]-[a-zA-Z0-9-]{10,}/g, 'xox***REDACTED***'],
|
|
19
|
+
// Stripe
|
|
20
|
+
[/sk_live_[a-zA-Z0-9]{24,}/g, 'sk_live_***REDACTED***'],
|
|
21
|
+
[/pk_live_[a-zA-Z0-9]{24,}/g, 'pk_live_***REDACTED***'],
|
|
22
|
+
// JWT
|
|
23
|
+
[/eyJ[a-zA-Z0-9_\-]{10,}\.eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}/g, '***REDACTED JWT***'],
|
|
24
|
+
// Bearer tokens
|
|
25
|
+
[/Bearer\s+[a-zA-Z0-9_\-\.]{20,}/g, 'Bearer ***REDACTED***'],
|
|
26
|
+
// PEM keys
|
|
27
|
+
[/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '-----***REDACTED KEY***-----'],
|
|
28
|
+
// Generic env-style secrets (password=, token=, secret=)
|
|
29
|
+
[/(password|passwd|pwd|secret|token|api_key|apikey)\s*[:=]\s*["']?([A-Za-z0-9_\-\.]{16,})["']?/gi,
|
|
30
|
+
'$1=***REDACTED***'],
|
|
31
|
+
];
|
|
32
|
+
export function redact(text) {
|
|
33
|
+
let out = text;
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (const [re, replacement] of PATTERNS) {
|
|
36
|
+
const before = out;
|
|
37
|
+
out = out.replace(re, replacement);
|
|
38
|
+
if (out !== before)
|
|
39
|
+
count++;
|
|
40
|
+
}
|
|
41
|
+
return { text: out, count };
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
const BLOCKED_NAMES = [
|
|
4
|
+
'.env', '.env.local', '.env.production', '.env.staging',
|
|
5
|
+
'id_rsa', 'id_ed25519', 'id_dsa', 'id_ecdsa',
|
|
6
|
+
'credentials', 'secrets.json', 'secrets.yml', 'secrets.yaml',
|
|
7
|
+
'.git-credentials', '.npmrc', '.pypirc', '.netrc', '.htpasswd',
|
|
8
|
+
'known_hosts', 'authorized_keys',
|
|
9
|
+
];
|
|
10
|
+
const BLOCKED_EXTENSIONS = [
|
|
11
|
+
'.pem', '.key', '.p12', '.pfx', '.keystore', '.jks',
|
|
12
|
+
];
|
|
13
|
+
const BLOCKED_DIRS = [
|
|
14
|
+
'.ssh', '.aws', '.gnupg', '.gpg', '.kube',
|
|
15
|
+
'.docker', '.config/gcloud', '.password-store',
|
|
16
|
+
];
|
|
17
|
+
export function isBlocked(p) {
|
|
18
|
+
const abs = path.resolve(p);
|
|
19
|
+
const base = path.basename(abs).toLowerCase();
|
|
20
|
+
if (BLOCKED_NAMES.some((n) => base === n.toLowerCase())) {
|
|
21
|
+
return `"${path.basename(abs)}" is a sensitive file (blocked)`;
|
|
22
|
+
}
|
|
23
|
+
const ext = path.extname(abs).toLowerCase();
|
|
24
|
+
if (BLOCKED_EXTENSIONS.includes(ext)) {
|
|
25
|
+
return `"${path.basename(abs)}" has a sensitive extension (blocked)`;
|
|
26
|
+
}
|
|
27
|
+
const home = os.homedir();
|
|
28
|
+
for (const d of BLOCKED_DIRS) {
|
|
29
|
+
const target = path.join(home, d);
|
|
30
|
+
if (abs === target || abs.startsWith(target + path.sep)) {
|
|
31
|
+
return `Path is inside ~/${d} (blocked)`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
package/dist/core/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ import { promisify } from 'util';
|
|
|
5
5
|
import { hasN8n } from './config.js';
|
|
6
6
|
import { permissions } from './permission.js';
|
|
7
7
|
import { lineDiff, compactDiff } from './diff.js';
|
|
8
|
+
import { isBlocked } from './sandbox.js';
|
|
8
9
|
import { n8nListWorkflows, n8nTriggerWorkflow, n8nListExecutions, n8nGetWorkflow, n8nActivateWorkflow, n8nDeactivateWorkflow, } from './n8n.js';
|
|
9
10
|
const execAsync = promisify(exec);
|
|
10
11
|
const BASE_TOOLS = [
|
|
@@ -12,7 +13,7 @@ const BASE_TOOLS = [
|
|
|
12
13
|
type: 'function',
|
|
13
14
|
function: {
|
|
14
15
|
name: 'read_file',
|
|
15
|
-
description: 'Read a file from disk',
|
|
16
|
+
description: 'Read a file from disk. Sensitive files (.env, .ssh, keys) are blocked.',
|
|
16
17
|
parameters: {
|
|
17
18
|
type: 'object',
|
|
18
19
|
properties: { path: { type: 'string' } },
|
|
@@ -71,13 +72,12 @@ export function getToolDefs() {
|
|
|
71
72
|
return hasN8n() ? [...BASE_TOOLS, ...N8N_TOOLS] : [...BASE_TOOLS];
|
|
72
73
|
}
|
|
73
74
|
export const toolDefs = BASE_TOOLS;
|
|
74
|
-
// Dangerous command patterns
|
|
75
75
|
const DANGER_PATTERNS = [
|
|
76
76
|
{ re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)?/, level: 'high' },
|
|
77
77
|
{ re: /\bsudo\b/, level: 'high' },
|
|
78
78
|
{ re: /\bmkfs\b|\bdd\s+if=/, level: 'high' },
|
|
79
79
|
{ re: />\s*\/dev\//, level: 'high' },
|
|
80
|
-
{ re: /:\(\)\s*\{/, level: 'high' },
|
|
80
|
+
{ re: /:\(\)\s*\{/, level: 'high' },
|
|
81
81
|
{ re: /\bchmod\s+(-R\s+)?777\b/, level: 'medium' },
|
|
82
82
|
{ re: /\bgit\s+push\b/, level: 'medium' },
|
|
83
83
|
{ re: /\bnpm\s+(publish|unpublish)\b/, level: 'medium' },
|
|
@@ -93,11 +93,18 @@ function classify(cmd) {
|
|
|
93
93
|
export async function executeTool(name, args) {
|
|
94
94
|
try {
|
|
95
95
|
switch (name) {
|
|
96
|
-
case 'read_file':
|
|
96
|
+
case 'read_file': {
|
|
97
|
+
const blocked = isBlocked(args.path);
|
|
98
|
+
if (blocked)
|
|
99
|
+
return `Blocked by sandbox: ${blocked}`;
|
|
97
100
|
return await fs.readFile(args.path, 'utf8');
|
|
101
|
+
}
|
|
98
102
|
case 'write_file': {
|
|
99
103
|
const path = args.path;
|
|
100
104
|
const content = args.content;
|
|
105
|
+
const blockedWrite = isBlocked(path);
|
|
106
|
+
if (blockedWrite)
|
|
107
|
+
return `Blocked by sandbox: ${blockedWrite}`;
|
|
101
108
|
const existed = existsSync(path);
|
|
102
109
|
let oldText = '';
|
|
103
110
|
if (existed) {
|
package/dist/index.js
CHANGED
|
@@ -5,12 +5,16 @@ import { render, Box, Static } from 'ink';
|
|
|
5
5
|
import { Setup } from './ui/Setup.js';
|
|
6
6
|
import { Chat } from './ui/Chat.js';
|
|
7
7
|
import { Banner } from './ui/Banner.js';
|
|
8
|
+
import { Consent, hasConsented } from './ui/Consent.js';
|
|
8
9
|
import { loadConfig, applyConfig } from './core/config.js';
|
|
9
10
|
import { loadSession } from './core/session.js';
|
|
10
11
|
const App = () => {
|
|
11
12
|
const [ready, setReady] = useState(false);
|
|
12
13
|
const [resumedCount, setResumedCount] = useState(0);
|
|
14
|
+
const [consented, setConsented] = useState(hasConsented());
|
|
13
15
|
useEffect(() => {
|
|
16
|
+
if (!consented)
|
|
17
|
+
return;
|
|
14
18
|
const existing = loadConfig();
|
|
15
19
|
if (existing) {
|
|
16
20
|
applyConfig(existing);
|
|
@@ -18,8 +22,8 @@ const App = () => {
|
|
|
18
22
|
setResumedCount(hist ? hist.length : 0);
|
|
19
23
|
setReady(true);
|
|
20
24
|
}
|
|
21
|
-
}, []);
|
|
22
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Static, { items: ['banner'], children: () => _jsx(Banner, {}) }), ready ? (_jsx(Chat, { resumedCount: resumedCount })) : (_jsx(Setup, { onDone: () => setReady(true) }))] }));
|
|
25
|
+
}, [consented]);
|
|
26
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Static, { items: ['banner'], children: () => _jsx(Banner, {}) }), !consented ? (_jsx(Consent, { onAccept: () => setConsented(true) })) : ready ? (_jsx(Chat, { resumedCount: resumedCount })) : (_jsx(Setup, { onDone: () => setReady(true) }))] }));
|
|
23
27
|
};
|
|
24
28
|
render(_jsx(App, {}), {
|
|
25
29
|
patchConsole: false,
|
package/dist/ui/Chat.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useStdout, Static } from 'ink';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
4
7
|
import { Agent } from '../core/agent.js';
|
|
5
8
|
import { permissions } from '../core/permission.js';
|
|
6
9
|
import { InputBar } from './InputBar.js';
|
|
@@ -22,6 +25,28 @@ const HistoryLine = ({ item }) => {
|
|
|
22
25
|
}
|
|
23
26
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.text, children: item.text }) }));
|
|
24
27
|
};
|
|
28
|
+
const DEBUG_FILE = path.join(os.homedir(), '.blyue', 'last-request.json');
|
|
29
|
+
function readDebug() {
|
|
30
|
+
if (!fs.existsSync(DEBUG_FILE)) {
|
|
31
|
+
return ['No request sent yet in this session.'];
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const d = JSON.parse(fs.readFileSync(DEBUG_FILE, 'utf8'));
|
|
35
|
+
const kb = (d.approxBytes / 1024).toFixed(1);
|
|
36
|
+
return [
|
|
37
|
+
`Model: ${d.model}`,
|
|
38
|
+
`Provider: ${d.baseUrl}`,
|
|
39
|
+
`Messages: ${d.messageCount}`,
|
|
40
|
+
`Approx size: ~${d.approxTokens} tokens · ${kb} KB`,
|
|
41
|
+
`Redactions: ${d.redactedCount || 0} secrets scrubbed`,
|
|
42
|
+
`Timestamp: ${d.timestamp}`,
|
|
43
|
+
`Full payload: ${DEBUG_FILE}`,
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
return ['Debug file unreadable: ' + e.message];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
25
50
|
export const Chat = ({ resumedCount = 0 }) => {
|
|
26
51
|
const { stdout } = useStdout();
|
|
27
52
|
const width = stdout?.columns ?? 80;
|
|
@@ -37,14 +62,13 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
37
62
|
const [permReq, setPermReq] = useState(null);
|
|
38
63
|
const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
|
|
39
64
|
const [agent, setAgent] = useState(() => new Agent());
|
|
40
|
-
// Subscribe to permission requests
|
|
41
65
|
useEffect(() => {
|
|
42
66
|
const unsub = permissions.subscribe((req) => setPermReq(req));
|
|
43
67
|
return unsub;
|
|
44
68
|
}, []);
|
|
45
69
|
useInput((inputChar, key) => {
|
|
46
70
|
if (permReq)
|
|
47
|
-
return;
|
|
71
|
+
return;
|
|
48
72
|
if (key.ctrl && inputChar === 'p')
|
|
49
73
|
setOverlay('model');
|
|
50
74
|
if (key.ctrl && inputChar === 's')
|
|
@@ -64,6 +88,16 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
64
88
|
setOverlay('settings');
|
|
65
89
|
return;
|
|
66
90
|
}
|
|
91
|
+
if (trimmed === '/debug') {
|
|
92
|
+
setInput('');
|
|
93
|
+
const lines = readDebug();
|
|
94
|
+
setHistory((h) => [
|
|
95
|
+
...h,
|
|
96
|
+
mkItem('tool', 'Last request to provider:'),
|
|
97
|
+
...lines.map((l) => mkItem('tool', ' ' + l)),
|
|
98
|
+
]);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
67
101
|
if (trimmed === '/clear') {
|
|
68
102
|
setInput('');
|
|
69
103
|
agent.clear();
|
|
@@ -76,8 +110,9 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
76
110
|
setHistory((h) => [
|
|
77
111
|
...h,
|
|
78
112
|
mkItem('tool', 'Commands:'),
|
|
79
|
-
mkItem('tool', ' /model switch model
|
|
80
|
-
mkItem('tool', ' /settings open settings
|
|
113
|
+
mkItem('tool', ' /model switch model (Ctrl+P)'),
|
|
114
|
+
mkItem('tool', ' /settings open settings (Ctrl+S)'),
|
|
115
|
+
mkItem('tool', ' /debug show last request to provider'),
|
|
81
116
|
mkItem('tool', ' /clear clear session and permissions'),
|
|
82
117
|
mkItem('tool', ' /help show this list'),
|
|
83
118
|
]);
|
|
@@ -104,7 +139,6 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
104
139
|
setAgent(new Agent());
|
|
105
140
|
setHistory((h) => [...h, mkItem('tool', `model → ${id}`)]);
|
|
106
141
|
};
|
|
107
|
-
// Permission prompt takes priority over everything
|
|
108
142
|
if (permReq) {
|
|
109
143
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsx(Box, { marginTop: 1, children: _jsx(PermissionPrompt, { req: permReq }) })] }));
|
|
110
144
|
}
|
|
@@ -121,5 +155,5 @@ export const Chat = ({ resumedCount = 0 }) => {
|
|
|
121
155
|
const shortModel = currentModel.length > maxModelLen
|
|
122
156
|
? '…' + currentModel.slice(-(maxModelLen - 1))
|
|
123
157
|
: currentModel || 'unset';
|
|
124
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/settings" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
|
|
158
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (item) => _jsx(HistoryLine, { item: item }, item.id) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBar, { value: input, onChange: setInput, onSubmit: handleSubmit, loading: loading }), _jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: " /model" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/settings" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.accent, children: "/debug" }), _jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: theme.text, children: shortModel })] })] })] }));
|
|
125
159
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { theme } from './theme.js';
|
|
7
|
+
const FLAG = path.join(os.homedir(), '.blyue', '.consent');
|
|
8
|
+
export function hasConsented() {
|
|
9
|
+
return fs.existsSync(FLAG);
|
|
10
|
+
}
|
|
11
|
+
export function markConsented() {
|
|
12
|
+
fs.mkdirSync(path.dirname(FLAG), { recursive: true });
|
|
13
|
+
fs.writeFileSync(FLAG, new Date().toISOString());
|
|
14
|
+
}
|
|
15
|
+
export const Consent = ({ onAccept }) => {
|
|
16
|
+
useInput((input) => {
|
|
17
|
+
if (input === 'y' || input === 'Y') {
|
|
18
|
+
markConsented();
|
|
19
|
+
onAccept();
|
|
20
|
+
}
|
|
21
|
+
else if (input === 'n' || input === 'N') {
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 Privacy notice" }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.text, children: "BLYUE sends your prompts, file contents, and command output to your configured LLM provider so it can help you." }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.muted, children: "Do NOT run this on directories containing secrets, keys, or private data unless you trust the provider." }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.muted, children: "Built-in protections:" }), _jsx(Text, { color: theme.muted, children: ' · .env, .ssh, .aws, *.pem, *.key are blocked from reading' }), _jsx(Text, { color: theme.muted, children: ' · API keys and tokens are redacted before sending' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.text, children: " I understand, continue" })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.error, bold: true, children: " [n]" }), _jsx(Text, { color: theme.text, children: " Exit" })] })] }));
|
|
26
|
+
};
|
package/dist/ui/Settings.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
4
|
import { KeyInput } from './KeyInput.js';
|
|
5
5
|
import { getConfig, saveConfig, applyConfig } from '../core/config.js';
|
|
6
|
+
import { detectOllama } from '../core/localCheck.js';
|
|
6
7
|
import { theme } from './theme.js';
|
|
7
8
|
export const Settings = ({ onClose, onModelChange }) => {
|
|
8
9
|
const { stdout } = useStdout();
|
|
@@ -12,6 +13,15 @@ export const Settings = ({ onClose, onModelChange }) => {
|
|
|
12
13
|
const [cfg, setCfg] = useState(() => getConfig());
|
|
13
14
|
const [draft, setDraft] = useState('');
|
|
14
15
|
const [testResult, setTestResult] = useState(null);
|
|
16
|
+
const [ollamaModels, setOllamaModels] = useState([]);
|
|
17
|
+
const [ollamaChecked, setOllamaChecked] = useState(false);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
(async () => {
|
|
20
|
+
const models = await detectOllama();
|
|
21
|
+
setOllamaModels(models);
|
|
22
|
+
setOllamaChecked(true);
|
|
23
|
+
})();
|
|
24
|
+
}, []);
|
|
15
25
|
const persist = (next) => {
|
|
16
26
|
saveConfig(next);
|
|
17
27
|
applyConfig(next);
|
|
@@ -28,7 +38,6 @@ export const Settings = ({ onClose, onModelChange }) => {
|
|
|
28
38
|
: 'not configured',
|
|
29
39
|
action: () => setScreen('n8n'),
|
|
30
40
|
},
|
|
31
|
-
{ label: 'Reset all config', hint: 'delete ~/.blyue/.env', action: () => setScreen('reset') },
|
|
32
41
|
{ label: 'Close', hint: 'esc', action: onClose },
|
|
33
42
|
];
|
|
34
43
|
// ---- Main menu navigation ----
|
|
@@ -42,7 +51,7 @@ export const Settings = ({ onClose, onModelChange }) => {
|
|
|
42
51
|
if (key.return)
|
|
43
52
|
mainItems[cursor].action();
|
|
44
53
|
}, { isActive: screen === 'main' });
|
|
45
|
-
// ---- n8n submenu
|
|
54
|
+
// ---- n8n submenu ----
|
|
46
55
|
const n8nItems = [
|
|
47
56
|
{ label: 'URL', hint: cfg.n8nUrl || 'not set', action: () => { setDraft(cfg.n8nUrl || ''); setScreen('n8n-url'); } },
|
|
48
57
|
{ label: 'API key', hint: cfg.n8nApiKey ? '•••' + cfg.n8nApiKey.slice(-4) : 'not set', action: () => { setDraft(cfg.n8nApiKey || ''); setScreen('n8n-key'); } },
|
|
@@ -77,18 +86,23 @@ export const Settings = ({ onClose, onModelChange }) => {
|
|
|
77
86
|
if (key.return)
|
|
78
87
|
n8nItems[n8nCursor].action();
|
|
79
88
|
}, { isActive: screen === 'n8n' });
|
|
89
|
+
// ---- n8n test ----
|
|
90
|
+
useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
|
|
80
91
|
// ---- Frame helpers ----
|
|
81
92
|
const inner = width - 4;
|
|
82
93
|
const top = (label) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(Math.max(1, inner - label.length - 3)) + '╮' })] }));
|
|
83
94
|
const bot = (_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }));
|
|
84
95
|
const row = (content) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
|
|
85
|
-
// ----
|
|
96
|
+
// ---- Main screen ----
|
|
86
97
|
if (screen === 'main') {
|
|
87
|
-
|
|
98
|
+
const baseUrlIsLocal = (cfg.baseUrl || '').includes('localhost') ||
|
|
99
|
+
(cfg.baseUrl || '').includes('127.0.0.1');
|
|
100
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('settings'), ollamaChecked && ollamaModels.length > 0 && (row(_jsxs(Text, { children: [_jsx(Text, { color: theme.success, children: "\u25CF " }), _jsx(Text, { color: theme.muted, children: "Ollama detected \u00B7 " }), _jsxs(Text, { color: theme.text, children: [ollamaModels.length, " model(s)"] })] }))), ollamaChecked && ollamaModels.length === 0 && baseUrlIsLocal && (row(_jsxs(Text, { children: [_jsx(Text, { color: theme.error, children: "\u25CF " }), _jsx(Text, { color: theme.muted, children: "Ollama not running at localhost:11434" })] }))), mainItems.map((it, i) => {
|
|
88
101
|
const active = i === cursor;
|
|
89
102
|
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
|
|
90
103
|
}), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc close" })] }));
|
|
91
104
|
}
|
|
105
|
+
// ---- API key / Base URL editors ----
|
|
92
106
|
if (screen === 'apikey' || screen === 'baseurl') {
|
|
93
107
|
const isKey = screen === 'apikey';
|
|
94
108
|
const title = isKey ? 'api key' : 'base url';
|
|
@@ -97,21 +111,26 @@ export const Settings = ({ onClose, onModelChange }) => {
|
|
|
97
111
|
const next = { ...cfg, [isKey ? 'apiKey' : 'baseUrl']: v };
|
|
98
112
|
persist(next);
|
|
99
113
|
};
|
|
100
|
-
return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Paste your new API key' : 'e.g.
|
|
114
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Paste your new API key' : 'e.g. http://localhost:11434/v1' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'sk-...' : 'https://...', masked: isKey, onSubmit: () => setScreen('main') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
101
115
|
}
|
|
116
|
+
// ---- Model editor ----
|
|
102
117
|
if (screen === 'model') {
|
|
103
|
-
|
|
104
|
-
return (_jsxs(Box, { flexDirection: "column", children: [top('model'), row(_jsx(Text, { color: theme.muted, children:
|
|
118
|
+
const isLocal = (cfg.baseUrl || '').includes('localhost');
|
|
119
|
+
return (_jsxs(Box, { flexDirection: "column", children: [top('model'), row(_jsx(Text, { color: theme.muted, children: isLocal && ollamaModels.length > 0
|
|
120
|
+
? 'Available: ' + ollamaModels.slice(0, 3).join(', ')
|
|
121
|
+
: 'Type model name' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: cfg.model, onChange: (v) => persist({ ...cfg, model: v }), placeholder: "qwen2.5-coder:1.5b", onSubmit: () => {
|
|
105
122
|
onModelChange?.(cfg.model);
|
|
106
123
|
setScreen('main');
|
|
107
124
|
} })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
108
125
|
}
|
|
126
|
+
// ---- n8n submenu ----
|
|
109
127
|
if (screen === 'n8n') {
|
|
110
128
|
return (_jsxs(Box, { flexDirection: "column", children: [top('n8n integration'), n8nItems.map((it, i) => {
|
|
111
129
|
const active = i === n8nCursor;
|
|
112
130
|
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
|
|
113
131
|
}), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc back" })] }));
|
|
114
132
|
}
|
|
133
|
+
// ---- n8n URL / key editors ----
|
|
115
134
|
if (screen === 'n8n-url' || screen === 'n8n-key') {
|
|
116
135
|
const isKey = screen === 'n8n-key';
|
|
117
136
|
const title = isKey ? 'n8n api key' : 'n8n url';
|
|
@@ -122,22 +141,9 @@ export const Settings = ({ onClose, onModelChange }) => {
|
|
|
122
141
|
};
|
|
123
142
|
return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Settings → n8n API → create key' : 'e.g. http://localhost:5678' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'n8n_api_...' : 'http://localhost:5678', masked: isKey, onSubmit: () => setScreen('n8n') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
|
|
124
143
|
}
|
|
144
|
+
// ---- n8n test result ----
|
|
125
145
|
if (screen === 'n8n-test') {
|
|
126
|
-
useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
|
|
127
146
|
return (_jsxs(Box, { flexDirection: "column", children: [top('n8n test'), row(_jsx(Text, { color: testResult?.startsWith('✓') ? theme.success : theme.muted, children: testResult || 'testing…' })), bot, _jsx(Text, { color: theme.muted, children: "press any key to go back" })] }));
|
|
128
147
|
}
|
|
129
|
-
if (screen === 'reset') {
|
|
130
|
-
return (_jsxs(Box, { flexDirection: "column", children: [top('reset config'), row(_jsx(Text, { color: theme.error, children: "Delete ~/.blyue/.env and reconfigure?" })), row(_jsx(Text, { color: theme.muted, children: "Press Y to confirm, N to cancel." })), bot] }));
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
};
|
|
134
|
-
// Handle Y/N for reset outside component main body
|
|
135
|
-
export const SettingsResetHandler = ({ onConfirm, onCancel, }) => {
|
|
136
|
-
useInput((input) => {
|
|
137
|
-
if (input === 'y' || input === 'Y')
|
|
138
|
-
onConfirm();
|
|
139
|
-
if (input === 'n' || input === 'N')
|
|
140
|
-
onCancel();
|
|
141
|
-
});
|
|
142
148
|
return null;
|
|
143
149
|
};
|