@tianmucreations/jeeves 0.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/LICENSE +21 -0
- package/README.md +32 -0
- package/bin/jeeves +2 -0
- package/dist/agent/context.js +50 -0
- package/dist/agent/errors.js +41 -0
- package/dist/agent/loop.js +84 -0
- package/dist/agent/permissions.js +27 -0
- package/dist/app.js +68 -0
- package/dist/commands/clear.js +9 -0
- package/dist/commands/help.js +17 -0
- package/dist/commands/keys.js +15 -0
- package/dist/commands/model.js +4 -0
- package/dist/commands/verbose.js +8 -0
- package/dist/components/AlternateScreen.js +74 -0
- package/dist/components/Footer.js +114 -0
- package/dist/components/Header.js +6 -0
- package/dist/components/HelpView.js +14 -0
- package/dist/components/Input.js +76 -0
- package/dist/components/KeysManager.js +281 -0
- package/dist/components/ModelPicker.js +457 -0
- package/dist/components/ProjectPicker.js +334 -0
- package/dist/components/TrafficLight.js +116 -0
- package/dist/components/Transcript.js +23 -0
- package/dist/components/UsageBar.js +35 -0
- package/dist/components/transcript-layout.js +103 -0
- package/dist/index.js +53 -0
- package/dist/ink/AlternateScreen.js +106 -0
- package/dist/keys/store.js +58 -0
- package/dist/models/filter.js +4 -0
- package/dist/models/registry.js +112 -0
- package/dist/platform/config.js +60 -0
- package/dist/platform/paths.js +60 -0
- package/dist/platform/shell.js +9 -0
- package/dist/providers/index.js +165 -0
- package/dist/providers/ollama.js +103 -0
- package/dist/providers/openrouter.js +109 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/zai.js +104 -0
- package/dist/state/session.js +315 -0
- package/dist/tools/index.js +106 -0
- package/dist/tools/listDir.js +55 -0
- package/dist/tools/readFile.js +15 -0
- package/dist/tools/runBash.js +22 -0
- package/dist/tools/writeFile.js +15 -0
- package/package.json +62 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
// DO NOT CHANGE THE HOOK. THE TAKEOVER MUST STAY INSIDE useInsertionEffect.
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// This component implements Claude Code's alternate-screen takeover (published
|
|
6
|
+
// source: alejandrobalderas/claude-code-from-source, chapter 13). The
|
|
7
|
+
// ENTER_ALT_SCREEN escape sequence must reach the terminal BEFORE the first
|
|
8
|
+
// render frame is flushed. react-reconciler calls resetAfterCommit between the
|
|
9
|
+
// mutation and layout commit phases, and Ink's resetAfterCommit triggers the
|
|
10
|
+
// first onRender - the first frame write to the terminal.
|
|
11
|
+
//
|
|
12
|
+
// useLayoutEffect and useEffect both run AFTER that first onRender. "Upgrade"
|
|
13
|
+
// this to either hook and the first frame paints to the MAIN screen buffer,
|
|
14
|
+
// producing a visible flash before the switch - and macOS Terminal.app then
|
|
15
|
+
// archives that pre-app frame into its scrollback at the moment the app
|
|
16
|
+
// switches, so the shell history stays reachable by scrolling forever. That is
|
|
17
|
+
// the exact bug this file exists to prevent; it took days to diagnose and the
|
|
18
|
+
// answer was published all along. Only useInsertionEffect fires before
|
|
19
|
+
// resetAfterCommit. This is not a stylistic choice. Do not "improve" it.
|
|
20
|
+
//
|
|
21
|
+
// The escape order is equally deliberate: 1049h (take over the window) → 2J
|
|
22
|
+
// (clear the fresh alternate screen) → 3J (erase the scrollback the switch
|
|
23
|
+
// archived) → H (home the cursor), in one write. Entering first means the main
|
|
24
|
+
// screen is never wiped, so quitting restores the shell's own screen exactly.
|
|
25
|
+
// Ink's built-in alternateScreen render option is NOT used: it is not needed
|
|
26
|
+
// here and mixing the two mechanisms invites double switches.
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
import { useEffect, useInsertionEffect } from 'react';
|
|
29
|
+
import { Box, useStdout } from 'ink';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
// signal-exit covers the termination signals beyond exit and SIGINT
|
|
32
|
+
// (SIGTERM, SIGHUP) with correct exit codes. Already in the tree as Ink's own
|
|
33
|
+
// dependency; declared ours.
|
|
34
|
+
const onSignalExit = createRequire(import.meta.url)('signal-exit');
|
|
35
|
+
const ENTER_ALT_SCREEN = '\x1b[?1049h';
|
|
36
|
+
const CLEAR_SCREEN = '\x1b[2J';
|
|
37
|
+
const ERASE_SCROLLBACK = '\x1b[3J';
|
|
38
|
+
const HOME_CURSOR = '\x1b[H';
|
|
39
|
+
const LEAVE_ALT_SCREEN = '\x1b[?1049l';
|
|
40
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
41
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
42
|
+
let altScreenActive = false;
|
|
43
|
+
let cleanupRegistered = false;
|
|
44
|
+
function write(data) {
|
|
45
|
+
try {
|
|
46
|
+
process.stdout.write(data);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A closed stream must never crash the takeover or the exit path.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Claude Code's Ink fork exposes this on the render instance; stock Ink has no
|
|
53
|
+
// such method, so the notification lives here: the flag that says the
|
|
54
|
+
// alternate screen owns the terminal, consulted by every exit path below.
|
|
55
|
+
export function setAltScreenActive(active, mouseTracking) {
|
|
56
|
+
altScreenActive = active;
|
|
57
|
+
// Mouse tracking is deliberately left off: it would break click-drag text
|
|
58
|
+
// selection in Terminal.app, and the app already owns scrolling by key.
|
|
59
|
+
void mouseTracking;
|
|
60
|
+
}
|
|
61
|
+
// Hands the terminal back. Safe to call from anywhere, any number of times.
|
|
62
|
+
// The scrollback erase follows the switch back because macOS Terminal.app
|
|
63
|
+
// archives the app's own frames into the scrollback at hand-back (measured),
|
|
64
|
+
// and they must not linger; the cursor comes back on for the shell.
|
|
65
|
+
export function leaveAltScreen() {
|
|
66
|
+
if (!altScreenActive)
|
|
67
|
+
return;
|
|
68
|
+
altScreenActive = false;
|
|
69
|
+
write(LEAVE_ALT_SCREEN + ERASE_SCROLLBACK + SHOW_CURSOR);
|
|
70
|
+
}
|
|
71
|
+
// The terminal must always be restored. process handlers per the mechanism,
|
|
72
|
+
// plus signal-exit so kill signals re-raise with correct exit codes.
|
|
73
|
+
function registerCleanup() {
|
|
74
|
+
if (cleanupRegistered)
|
|
75
|
+
return;
|
|
76
|
+
cleanupRegistered = true;
|
|
77
|
+
process.on('exit', () => leaveAltScreen());
|
|
78
|
+
process.on('SIGINT', () => {
|
|
79
|
+
leaveAltScreen();
|
|
80
|
+
process.exit(130);
|
|
81
|
+
});
|
|
82
|
+
onSignalExit(() => leaveAltScreen(), { alwaysLast: false });
|
|
83
|
+
}
|
|
84
|
+
export function AlternateScreen({ children }) {
|
|
85
|
+
const { stdout } = useStdout();
|
|
86
|
+
// Entered once, before the first frame, in Claude Code's order: take over
|
|
87
|
+
// the window first, then clear the fresh alternate screen, erase the
|
|
88
|
+
// scrollback the switch archived, and home the cursor. Because the main
|
|
89
|
+
// screen is never wiped, the shell's own screen survives for a perfect
|
|
90
|
+
// restore on exit. The cursor is hidden for the same reason Ink's own mode
|
|
91
|
+
// hides it. Empty dependency array: this runs exactly once, on mount.
|
|
92
|
+
useInsertionEffect(() => {
|
|
93
|
+
write(ENTER_ALT_SCREEN + CLEAR_SCREEN + ERASE_SCROLLBACK + HOME_CURSOR + HIDE_CURSOR);
|
|
94
|
+
setAltScreenActive(true, false);
|
|
95
|
+
registerCleanup();
|
|
96
|
+
}, []);
|
|
97
|
+
// On unmount the terminal is handed back too (the app unmounts before the
|
|
98
|
+
// process exits on /exit and Ctrl+C).
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
return () => leaveAltScreen();
|
|
101
|
+
}, []);
|
|
102
|
+
const rows = Math.max(stdout.rows ?? 24, 8);
|
|
103
|
+
// The alternate screen has no native scrollback, so the app owns its own
|
|
104
|
+
// scrolling: everything is constrained to the terminal's row count.
|
|
105
|
+
return (_jsx(Box, { height: rows, flexDirection: "column", overflow: "hidden", children: children }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const SERVICE = 'jeeves';
|
|
2
|
+
let cached = null;
|
|
3
|
+
async function load() {
|
|
4
|
+
// Escape hatch for automated tests so they never touch the real credential store.
|
|
5
|
+
if (process.env.JEEVES_SKIP_KEYCHAIN === '1')
|
|
6
|
+
return null;
|
|
7
|
+
if (cached === null) {
|
|
8
|
+
const mod = await import('keytar');
|
|
9
|
+
cached = (mod.default ?? mod);
|
|
10
|
+
}
|
|
11
|
+
return cached;
|
|
12
|
+
}
|
|
13
|
+
export async function setKey(provider, key) {
|
|
14
|
+
try {
|
|
15
|
+
const keytar = await load();
|
|
16
|
+
if (!keytar)
|
|
17
|
+
return false;
|
|
18
|
+
await keytar.setPassword(SERVICE, provider, key);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function getKey(provider) {
|
|
26
|
+
try {
|
|
27
|
+
const keytar = await load();
|
|
28
|
+
if (!keytar)
|
|
29
|
+
return null;
|
|
30
|
+
return await keytar.getPassword(SERVICE, provider);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function deleteKey(provider) {
|
|
37
|
+
try {
|
|
38
|
+
const keytar = await load();
|
|
39
|
+
if (!keytar)
|
|
40
|
+
return false;
|
|
41
|
+
return await keytar.deletePassword(SERVICE, provider);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function listProviders() {
|
|
48
|
+
try {
|
|
49
|
+
const keytar = await load();
|
|
50
|
+
if (!keytar)
|
|
51
|
+
return [];
|
|
52
|
+
const credentials = await keytar.findCredentials(SERVICE);
|
|
53
|
+
return credentials.map((credential) => credential.account);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getModelCache, setModelCache } from '../platform/config.js';
|
|
2
|
+
const MODELS_URL = 'https://openrouter.ai/api/v1/models';
|
|
3
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
function toNumber(value) {
|
|
5
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
6
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
7
|
+
}
|
|
8
|
+
// Maps the public OpenRouter models payload into the picker's model records.
|
|
9
|
+
export function normalizeModels(body) {
|
|
10
|
+
if (typeof body !== 'object' || body === null)
|
|
11
|
+
return [];
|
|
12
|
+
const data = body.data;
|
|
13
|
+
if (!Array.isArray(data))
|
|
14
|
+
return [];
|
|
15
|
+
const models = [];
|
|
16
|
+
for (const raw of data) {
|
|
17
|
+
if (typeof raw !== 'object' || raw === null)
|
|
18
|
+
continue;
|
|
19
|
+
const entry = raw;
|
|
20
|
+
const id = typeof entry.id === 'string' ? entry.id : '';
|
|
21
|
+
if (!id)
|
|
22
|
+
continue;
|
|
23
|
+
const pricing = (typeof entry.pricing === 'object' && entry.pricing !== null ? entry.pricing : {});
|
|
24
|
+
models.push({
|
|
25
|
+
id,
|
|
26
|
+
name: typeof entry.name === 'string' && entry.name.length > 0 ? entry.name : id,
|
|
27
|
+
contextLength: typeof entry.context_length === 'number' ? entry.context_length : 0,
|
|
28
|
+
promptPrice: toNumber(pricing.prompt),
|
|
29
|
+
completionPrice: toNumber(pricing.completion),
|
|
30
|
+
supportedParameters: Array.isArray(entry.supported_parameters)
|
|
31
|
+
? entry.supported_parameters.filter((param) => typeof param === 'string')
|
|
32
|
+
: [],
|
|
33
|
+
provider: id.includes('/') ? id.slice(0, id.indexOf('/')) : 'openrouter',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return models;
|
|
37
|
+
}
|
|
38
|
+
// Loads the model list: fresh weekly fetch, cached in the config directory,
|
|
39
|
+
// with graceful fallback to the cache when the network fails.
|
|
40
|
+
export async function loadModels() {
|
|
41
|
+
const cache = getModelCache();
|
|
42
|
+
if (cache && Date.now() - cache.fetchedAt < WEEK_MS) {
|
|
43
|
+
return { models: normalizeModels(cache.raw), error: '' };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(MODELS_URL);
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error(`the models list is unavailable (${response.status})`);
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
setModelCache(body, Date.now());
|
|
51
|
+
return { models: normalizeModels(body), error: '' };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (cache) {
|
|
55
|
+
return { models: normalizeModels(cache.raw), error: 'Could not refresh the model list - showing the saved copy.' };
|
|
56
|
+
}
|
|
57
|
+
return { models: [], error: `Could not load the model list: ${error instanceof Error ? error.message : String(error)}` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function compactContext(tokens) {
|
|
61
|
+
if (tokens >= 1_000_000)
|
|
62
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
63
|
+
if (tokens >= 1000)
|
|
64
|
+
return `${Math.round(tokens / 1000)}k`;
|
|
65
|
+
return `${tokens}`;
|
|
66
|
+
}
|
|
67
|
+
// A short, human-first shortlist. Each entry lists candidate ids so the row
|
|
68
|
+
// survives catalog drift; the first id found in the catalog wins.
|
|
69
|
+
export const CURATED_MODELS = [
|
|
70
|
+
{ ids: ['z-ai/glm-5.3', 'z-ai/glm-5.3-flash', 'z-ai/glm-5.2'], blurb: 'best value' },
|
|
71
|
+
{ ids: ['deepseek/deepseek-v4-flash-0731', 'deepseek/deepseek-v4-flash', 'deepseek/deepseek-v3.2'], blurb: 'cheapest' },
|
|
72
|
+
{ ids: ['anthropic/claude-opus-5', 'anthropic/claude-opus-4.8', 'anthropic/claude-sonnet-5'], blurb: 'best quality' },
|
|
73
|
+
{ ids: ['openai/gpt-5.5', 'openai/gpt-5.4', 'openai/gpt-5.1'], blurb: 'strong all-rounder' },
|
|
74
|
+
{ ids: ['google/gemini-3.1-pro-preview', 'google/gemini-3-pro-preview', 'google/gemini-2.5-pro'], blurb: 'huge memory' },
|
|
75
|
+
{ ids: ['qwen/qwen3-coder-plus', 'qwen/qwen3-coder', 'qwen/qwen3-coder-flash'], blurb: 'good for code' },
|
|
76
|
+
];
|
|
77
|
+
export function resolveCurated(models) {
|
|
78
|
+
const byId = new Map(models.map((model) => [model.id, model]));
|
|
79
|
+
const picks = [];
|
|
80
|
+
for (const entry of CURATED_MODELS) {
|
|
81
|
+
for (const id of entry.ids) {
|
|
82
|
+
const model = byId.get(id);
|
|
83
|
+
if (model) {
|
|
84
|
+
picks.push({ model, blurb: entry.blurb });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return picks;
|
|
90
|
+
}
|
|
91
|
+
// Strips the "Provider: " prefix OpenRouter puts in display names.
|
|
92
|
+
export function cleanModelName(name) {
|
|
93
|
+
const stripped = name.replace(/^[A-Za-z][A-Za-z0-9 .-]*: /, '');
|
|
94
|
+
return stripped.length > 0 ? stripped : name;
|
|
95
|
+
}
|
|
96
|
+
export function compactPrice(prompt, completion, priceLabel) {
|
|
97
|
+
if (priceLabel)
|
|
98
|
+
return priceLabel;
|
|
99
|
+
if (prompt === 0 && completion === 0)
|
|
100
|
+
return 'free';
|
|
101
|
+
// OpenRouter marks router models with negative sentinels; their price varies by routed model.
|
|
102
|
+
if (prompt < 0 || completion < 0)
|
|
103
|
+
return 'varies';
|
|
104
|
+
const perMillion = (value) => `$${(value * 1_000_000).toFixed(2)}`;
|
|
105
|
+
return `${perMillion(prompt)}/${perMillion(completion)} per M`;
|
|
106
|
+
}
|
|
107
|
+
// Assumption: OpenRouter's models endpoint carries no speed data, so the indicator uses
|
|
108
|
+
// naming conventions (flash/turbo/mini/air/haiku/nano/instant) as a rough hint.
|
|
109
|
+
export function isFastModel(model) {
|
|
110
|
+
const name = `${model.name} ${model.id}`.toLowerCase();
|
|
111
|
+
return ['flash', 'turbo', 'mini', 'air', 'haiku', 'nano', 'instant'].some((hint) => name.includes(hint));
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
// Persistent settings. Phase 7 expands this into the full config surface
|
|
3
|
+
// (default model, favourites, recents, verbose flag). API keys are NEVER stored here (spec 5.3).
|
|
4
|
+
// Tests run against their own settings file so they never touch - or race on - the real one.
|
|
5
|
+
const config = new Conf({
|
|
6
|
+
projectName: process.env.NODE_ENV === 'test' ? 'jeeves-tests' : 'jeeves',
|
|
7
|
+
});
|
|
8
|
+
const VALID_METRICS = ['session', 'context', 'cache', 'today', 'credit', 'speed'];
|
|
9
|
+
// Metrics a power user has chosen to hide from the footer - hiding is opt-in, never required.
|
|
10
|
+
export function getHiddenMetrics() {
|
|
11
|
+
const stored = config.get('hiddenMetrics') ?? [];
|
|
12
|
+
return stored.filter((metric) => VALID_METRICS.includes(metric));
|
|
13
|
+
}
|
|
14
|
+
export function setHiddenMetrics(metrics) {
|
|
15
|
+
config.set('hiddenMetrics', metrics.filter((metric) => VALID_METRICS.includes(metric)));
|
|
16
|
+
}
|
|
17
|
+
export function getFavorites() {
|
|
18
|
+
return config.get('favorites') ?? [];
|
|
19
|
+
}
|
|
20
|
+
export function setFavorites(models) {
|
|
21
|
+
config.set('favorites', models);
|
|
22
|
+
}
|
|
23
|
+
export function getRecents() {
|
|
24
|
+
return config.get('recents') ?? [];
|
|
25
|
+
}
|
|
26
|
+
export function setRecents(models) {
|
|
27
|
+
config.set('recents', models.slice(0, 10));
|
|
28
|
+
}
|
|
29
|
+
// Recently chosen project folders, newest first; the list grows automatically.
|
|
30
|
+
export function getRecentProjects() {
|
|
31
|
+
return config.get('projects') ?? [];
|
|
32
|
+
}
|
|
33
|
+
export function setRecentProjects(projects) {
|
|
34
|
+
config.set('projects', projects.slice(0, 10));
|
|
35
|
+
}
|
|
36
|
+
export function getModelCache() {
|
|
37
|
+
return config.get('modelCache') ?? null;
|
|
38
|
+
}
|
|
39
|
+
export function setModelCache(raw, fetchedAt) {
|
|
40
|
+
config.set('modelCache', { raw, fetchedAt });
|
|
41
|
+
}
|
|
42
|
+
// The default model and provider persist between sessions; API keys never do (spec 7).
|
|
43
|
+
export function getDefaultModel() {
|
|
44
|
+
return config.get('defaultModel') ?? null;
|
|
45
|
+
}
|
|
46
|
+
export function setDefaultModel(model) {
|
|
47
|
+
config.set('defaultModel', model);
|
|
48
|
+
}
|
|
49
|
+
export function getDefaultProvider() {
|
|
50
|
+
return config.get('defaultProvider') ?? null;
|
|
51
|
+
}
|
|
52
|
+
export function setDefaultProvider(provider) {
|
|
53
|
+
config.set('defaultProvider', provider);
|
|
54
|
+
}
|
|
55
|
+
export function getVerbosePreference() {
|
|
56
|
+
return config.get('verbose') ?? false;
|
|
57
|
+
}
|
|
58
|
+
export function setVerbosePreference(value) {
|
|
59
|
+
config.set('verbose', value);
|
|
60
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
// Path helpers. Resolving against the current folder keeps behaviour identical on macOS, Windows, and Linux.
|
|
5
|
+
export function resolveFromCwd(p) {
|
|
6
|
+
return path.resolve(p);
|
|
7
|
+
}
|
|
8
|
+
// The standard user locations the folder browser offers first; only existing ones are shown.
|
|
9
|
+
export function homeLocations() {
|
|
10
|
+
const home = homedir();
|
|
11
|
+
const candidates = [
|
|
12
|
+
{ label: 'Desktop', folder: 'Desktop' },
|
|
13
|
+
{ label: 'Documents', folder: 'Documents' },
|
|
14
|
+
{ label: 'Downloads', folder: 'Downloads' },
|
|
15
|
+
{ label: 'Home', folder: '' },
|
|
16
|
+
{ label: 'Pictures', folder: 'Pictures' },
|
|
17
|
+
{ label: 'Music', folder: 'Music' },
|
|
18
|
+
{ label: 'Movies', folder: 'Movies' },
|
|
19
|
+
];
|
|
20
|
+
return candidates
|
|
21
|
+
.filter((candidate) => candidate.folder === '' || existsSync(path.join(home, candidate.folder)))
|
|
22
|
+
.map((candidate) => ({ name: candidate.label, path: candidate.folder === '' ? home : path.join(home, candidate.folder) }));
|
|
23
|
+
}
|
|
24
|
+
// Lists the subfolders of a directory, hiding dot-folders; failures are reported, never thrown.
|
|
25
|
+
export function listSubfolders(dir) {
|
|
26
|
+
try {
|
|
27
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
const folders = entries
|
|
29
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
30
|
+
.map((entry) => ({ name: entry.name, path: path.join(dir, entry.name) }))
|
|
31
|
+
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
32
|
+
return { folders, error: '' };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { folders: [], error: 'This folder could not be opened.' };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Short display form for paths: the home folder becomes ~.
|
|
39
|
+
export function displayPath(p) {
|
|
40
|
+
const home = homedir();
|
|
41
|
+
if (p === home)
|
|
42
|
+
return '~';
|
|
43
|
+
if (p.startsWith(home + path.sep)) {
|
|
44
|
+
return '~' + p.slice(home.length);
|
|
45
|
+
}
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
// Returns a plain-English problem with a proposed project name, or null when the name is fine.
|
|
49
|
+
export function projectNameProblem(name) {
|
|
50
|
+
const trimmed = name.trim();
|
|
51
|
+
if (!trimmed)
|
|
52
|
+
return 'Give the project a name first.';
|
|
53
|
+
if (/[/\\:*?"<>|]/.test(trimmed)) {
|
|
54
|
+
return 'Names cannot contain / \\ : * ? " < > or | - try another name.';
|
|
55
|
+
}
|
|
56
|
+
if (trimmed === '.' || trimmed === '..') {
|
|
57
|
+
return 'That is not a valid name - try another.';
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
// bash on macOS and Linux, PowerShell on Windows, chosen from process.platform
|
|
3
|
+
// (spec Phase 8). execa resolves the program through PATH on every platform.
|
|
4
|
+
export function getShell(platform = process.platform) {
|
|
5
|
+
if (platform === 'win32') {
|
|
6
|
+
return { program: 'powershell.exe', flag: '-Command' };
|
|
7
|
+
}
|
|
8
|
+
return { program: 'bash', flag: '-c' };
|
|
9
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createOpenRouterProvider, fetchCreditInfo } from './openrouter.js';
|
|
2
|
+
import { createOllamaProvider } from './ollama.js';
|
|
3
|
+
import { createZaiProvider } from './zai.js';
|
|
4
|
+
import { session } from '../state/session.js';
|
|
5
|
+
import { getKey, setKey, deleteKey, listProviders } from '../keys/store.js';
|
|
6
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
let active = null;
|
|
10
|
+
let resolvedKey = null;
|
|
11
|
+
let zaiKey = null;
|
|
12
|
+
let keySource = null;
|
|
13
|
+
// The calm provider list shared by the model picker and the key screens.
|
|
14
|
+
export const PROVIDER_ROWS = [
|
|
15
|
+
{ id: 'openrouter', label: 'OpenRouter', description: 'one key unlocks 400+ models - recommended' },
|
|
16
|
+
{ id: 'zai', label: 'Z.ai', description: 'GLM Coding Plan - $18/month flat - best for heavy daily use' },
|
|
17
|
+
{ id: 'anthropic', label: 'Anthropic', description: 'direct connection' },
|
|
18
|
+
{ id: 'openai', label: 'OpenAI', description: 'direct connection' },
|
|
19
|
+
{ id: 'google', label: 'Google', description: 'direct connection' },
|
|
20
|
+
{ id: 'xai', label: 'xAI', description: 'direct connection' },
|
|
21
|
+
{ id: 'groq', label: 'Groq', description: 'direct connection' },
|
|
22
|
+
{ id: 'mistral', label: 'Mistral', description: 'direct connection' },
|
|
23
|
+
{ id: 'ollama', label: 'Ollama', description: 'local models, no key needed' },
|
|
24
|
+
];
|
|
25
|
+
export function getKeySource() {
|
|
26
|
+
return keySource;
|
|
27
|
+
}
|
|
28
|
+
// Whether a stored key (or keyless local mode) makes a provider usable today.
|
|
29
|
+
export function hasCredentialsFor(providerId) {
|
|
30
|
+
if (providerId === 'openrouter')
|
|
31
|
+
return resolvedKey !== null;
|
|
32
|
+
if (providerId === 'zai')
|
|
33
|
+
return zaiKey !== null;
|
|
34
|
+
if (providerId === 'ollama')
|
|
35
|
+
return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function hasCredentials() {
|
|
39
|
+
return hasCredentialsFor(session.providerId);
|
|
40
|
+
}
|
|
41
|
+
// Startup key resolution: the Keychain wins; a .env file is a development fallback
|
|
42
|
+
// that gets migrated into the Keychain on first launch. The first access may pop a
|
|
43
|
+
// macOS permission dialog - that is expected and allowed once.
|
|
44
|
+
export async function initKeys() {
|
|
45
|
+
const storedZai = await getKey('zai');
|
|
46
|
+
if (storedZai && storedZai.length > 0) {
|
|
47
|
+
zaiKey = storedZai;
|
|
48
|
+
}
|
|
49
|
+
const stored = await getKey('openrouter');
|
|
50
|
+
if (stored && stored.length > 0) {
|
|
51
|
+
resolvedKey = stored;
|
|
52
|
+
keySource = 'keychain';
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
56
|
+
if (envKey && envKey.length > 0) {
|
|
57
|
+
const moved = await setKey('openrouter', envKey);
|
|
58
|
+
if (moved) {
|
|
59
|
+
removeEnvFile();
|
|
60
|
+
session.addNotice('Your API key was moved from the .env file into your Mac keychain. The .env file has been removed.');
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
session.addNotice('The Mac keychain was not reachable, so the .env development file is being used. Run /keys to store the key securely.');
|
|
64
|
+
}
|
|
65
|
+
resolvedKey = envKey;
|
|
66
|
+
keySource = moved ? 'keychain' : 'env';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Only removes the single-key development file this project created - never a custom one.
|
|
70
|
+
function removeEnvFile() {
|
|
71
|
+
const envPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.env');
|
|
72
|
+
if (!existsSync(envPath))
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
const lines = readFileSync(envPath, 'utf8')
|
|
76
|
+
.split('\n')
|
|
77
|
+
.map((line) => line.trim())
|
|
78
|
+
.filter((line) => line.length > 0);
|
|
79
|
+
if (lines.length > 0 && lines.every((line) => line.startsWith('OPENROUTER_API_KEY='))) {
|
|
80
|
+
rmSync(envPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Cleanup failures must never break startup.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Saves the OpenRouter key in the Keychain and activates it immediately.
|
|
88
|
+
export async function storeOpenRouterKey(key) {
|
|
89
|
+
const saved = await setKey('openrouter', key);
|
|
90
|
+
if (!saved)
|
|
91
|
+
return false;
|
|
92
|
+
resolvedKey = key;
|
|
93
|
+
keySource = 'keychain';
|
|
94
|
+
active = null;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// Saves the Z.ai key (GLM Coding Plan) in the Keychain and activates it immediately.
|
|
98
|
+
export async function storeZaiKey(key) {
|
|
99
|
+
const saved = await setKey('zai', key);
|
|
100
|
+
if (!saved)
|
|
101
|
+
return false;
|
|
102
|
+
zaiKey = key;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
export async function removeZaiKey() {
|
|
106
|
+
await deleteKey('zai');
|
|
107
|
+
zaiKey = null;
|
|
108
|
+
}
|
|
109
|
+
// Removes the stored OpenRouter key; an env key becomes the fallback again.
|
|
110
|
+
export async function removeOpenRouterKey() {
|
|
111
|
+
await deleteKey('openrouter');
|
|
112
|
+
active = null;
|
|
113
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
114
|
+
if (envKey && envKey.length > 0) {
|
|
115
|
+
resolvedKey = envKey;
|
|
116
|
+
keySource = 'env';
|
|
117
|
+
return 'env';
|
|
118
|
+
}
|
|
119
|
+
resolvedKey = null;
|
|
120
|
+
keySource = null;
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
export function getActiveProvider() {
|
|
124
|
+
if (session.providerId === 'ollama') {
|
|
125
|
+
return createOllamaProvider();
|
|
126
|
+
}
|
|
127
|
+
if (session.providerId === 'zai') {
|
|
128
|
+
if (!zaiKey) {
|
|
129
|
+
throw new Error('No Z.ai key found. Add one with /keys.');
|
|
130
|
+
}
|
|
131
|
+
return createZaiProvider(zaiKey);
|
|
132
|
+
}
|
|
133
|
+
if (!resolvedKey) {
|
|
134
|
+
throw new Error('No OpenRouter API key found. Add one with /keys.');
|
|
135
|
+
}
|
|
136
|
+
if (!active) {
|
|
137
|
+
active = createOpenRouterProvider(resolvedKey);
|
|
138
|
+
}
|
|
139
|
+
return active;
|
|
140
|
+
}
|
|
141
|
+
// Best-effort credit refresh. The management key sees the real account balance;
|
|
142
|
+
// the inference key only sees its own spending cap, which is labelled as such.
|
|
143
|
+
// Local Ollama has no credit balance, and failures are silent.
|
|
144
|
+
export async function refreshCredit() {
|
|
145
|
+
if (session.providerId !== 'openrouter')
|
|
146
|
+
return;
|
|
147
|
+
const managementKey = await getKey('openrouter-management');
|
|
148
|
+
if (managementKey) {
|
|
149
|
+
const info = await fetchCreditInfo(managementKey);
|
|
150
|
+
if (info) {
|
|
151
|
+
session.setCredit(info.used, info.limit, info.remaining, true);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!resolvedKey)
|
|
156
|
+
return;
|
|
157
|
+
const info = await fetchCreditInfo(resolvedKey);
|
|
158
|
+
if (info) {
|
|
159
|
+
session.setCredit(info.used, info.limit, info.remaining, false);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Which providers have a key in the OS credential store.
|
|
163
|
+
export async function storedKeyProviders() {
|
|
164
|
+
return listProviders();
|
|
165
|
+
}
|