@giovannijecha/jecode 0.3.2 → 0.5.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/README.md +63 -23
- package/dist/batch.js +22 -2
- package/dist/cli-info.js +3 -0
- package/dist/command-settings.js +19 -0
- package/dist/commands.js +11 -14
- package/dist/config.js +2 -0
- package/dist/controller.js +3 -0
- package/dist/conversation.js +208 -0
- package/dist/credential-commands.js +63 -80
- package/dist/credentials.js +7 -1
- package/dist/launch.js +19 -0
- package/dist/model-command.js +171 -0
- package/dist/permission-command.js +52 -53
- package/dist/provider-commands.js +71 -228
- package/dist/provider-errors.js +4 -3
- package/dist/provider-label.js +2 -2
- package/dist/providers/ollama.js +8 -3
- package/dist/sessions/codec.js +344 -0
- package/dist/sessions/lease.js +76 -0
- package/dist/sessions/runtime.js +73 -0
- package/dist/sessions/store.js +368 -0
- package/dist/settings-command.js +43 -98
- package/dist/start.js +67 -4
- package/dist/transcript-types.js +6 -0
- package/dist/tui/activity.js +3 -0
- package/dist/tui/app-input.js +9 -6
- package/dist/tui/app-workflows.js +37 -8
- package/dist/tui/app.js +72 -20
- package/dist/tui/components/composer.js +1 -1
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +27 -15
- package/dist/tui/components/messages.js +1 -15
- package/dist/tui/components/prompt.js +2 -2
- package/dist/tui/components/status.js +5 -2
- package/dist/tui/components/tool.js +22 -5
- package/dist/tui/editor.js +54 -7
- package/dist/tui/feedback.js +5 -2
- package/dist/tui/field.js +1 -1
- package/dist/tui/help.js +4 -1
- package/dist/tui/input.js +4 -0
- package/dist/tui/keys.js +14 -3
- package/dist/tui/overlay.js +6 -0
- package/dist/tui/picker.js +26 -10
- package/dist/tui/resume.js +24 -0
- package/dist/tui/session-view.js +2 -2
- package/dist/tui/turn.js +2 -3
- package/dist/tui/view.js +1 -1
- package/dist/ui/diff.js +2 -0
- package/dist/ui/inline.js +2 -2
- package/dist/ui/markdown.js +7 -7
- package/dist/ui/render.js +2 -0
- package/dist/ui/theme.js +4 -4
- package/dist/usage.js +9 -0
- package/package.json +1 -1
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
import {
|
|
1
|
+
// Masked API-key interaction shared by provider connection flows.
|
|
2
|
+
import { credentialSource, forgetSaved, forgetSession, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
|
|
3
3
|
import { EMPTY } from "./tui/editor.js";
|
|
4
|
-
import {
|
|
5
|
-
import { openAIAccountHint } from "./openai-account.js";
|
|
6
|
-
import { ensureOpenAIAccount, openAIAccountCommand, } from "./openai-account-command.js";
|
|
7
|
-
import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
|
|
4
|
+
import { heading } from "./tui/picker.js";
|
|
8
5
|
/** Ask for a key, then ask separately whether it may be written to disk. */
|
|
9
6
|
export async function askForKey(name, host, pal) {
|
|
10
7
|
if (host.type === undefined || host.choose === undefined)
|
|
@@ -43,109 +40,95 @@ export async function askForKey(name, host, pal) {
|
|
|
43
40
|
return true;
|
|
44
41
|
}
|
|
45
42
|
catch (error) {
|
|
46
|
-
host.emit({
|
|
43
|
+
host.emit({
|
|
44
|
+
kind: "notice",
|
|
45
|
+
text: `could not save API key · ${error.message}`,
|
|
46
|
+
tone: "error",
|
|
47
|
+
});
|
|
47
48
|
return false;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/** Manage one provider key without ever placing its value on screen. */
|
|
54
|
+
export async function apiKeyCommand(name, label, session, host) {
|
|
53
55
|
const choose = chooser(host);
|
|
54
56
|
if (choose === undefined)
|
|
55
57
|
return;
|
|
56
|
-
const index = await choose({
|
|
57
|
-
title: heading("authentication", "secrets are never shown", session.palette),
|
|
58
|
-
options: PROVIDERS.map((provider) => ({
|
|
59
|
-
label: provider.auth.kind === "api-key" ? provider.auth.keyVar : `${provider.auth.label} account`,
|
|
60
|
-
hint: provider.auth.kind === "api-key"
|
|
61
|
-
? credentialSource(provider.auth.keyVar) ?? "missing"
|
|
62
|
-
: openAIAccountHint(),
|
|
63
|
-
})),
|
|
64
|
-
index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
|
|
65
|
-
});
|
|
66
|
-
if (index === undefined)
|
|
67
|
-
return;
|
|
68
|
-
const provider = PROVIDERS[index];
|
|
69
|
-
if (provider === undefined)
|
|
70
|
-
return;
|
|
71
|
-
if (provider.auth.kind === "oauth") {
|
|
72
|
-
await openAIAccountCommand(session, host);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const name = provider.auth.keyVar;
|
|
76
58
|
const source = credentialSource(name);
|
|
77
59
|
if (source === "environment") {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
60
|
+
if (!hasSaved(name)) {
|
|
61
|
+
host.emit({
|
|
62
|
+
kind: "notice",
|
|
63
|
+
text: `${label} API key comes from the environment · restart after changing it`,
|
|
64
|
+
tone: "info",
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const action = await choose({
|
|
69
|
+
title: heading(`${label} API key`, `${name} · environment`, session.palette),
|
|
70
|
+
description: "The environment value is read-only. A saved copy is currently shadowed.",
|
|
71
|
+
options: [{ label: "forget saved copy", hint: storeLabel(), key: "f" }],
|
|
72
|
+
index: 0,
|
|
82
73
|
});
|
|
83
|
-
if (
|
|
84
|
-
await
|
|
74
|
+
if (action === 0)
|
|
75
|
+
await forgetSavedKey(name, host);
|
|
85
76
|
return;
|
|
86
77
|
}
|
|
87
78
|
const actions = [
|
|
88
|
-
{
|
|
89
|
-
|
|
79
|
+
{
|
|
80
|
+
label: source === undefined ? "add API key" : "replace API key",
|
|
81
|
+
hint: source === undefined ? "session or owner-only file" : `currently ${source}`,
|
|
82
|
+
key: "r",
|
|
83
|
+
},
|
|
84
|
+
...(source === "session"
|
|
85
|
+
? [{ label: "clear session key", hint: "this process only", key: "c" }]
|
|
86
|
+
: []),
|
|
87
|
+
...(hasSaved(name)
|
|
88
|
+
? [{ label: "forget saved copy", hint: storeLabel(), key: "f" }]
|
|
89
|
+
: []),
|
|
90
90
|
];
|
|
91
|
-
const
|
|
92
|
-
title: heading(name
|
|
91
|
+
const index = await choose({
|
|
92
|
+
title: heading(`${label} API key`, `${name} · ${source ?? "missing"}`, session.palette),
|
|
93
93
|
options: actions,
|
|
94
94
|
index: 0,
|
|
95
95
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
const action = index === undefined ? undefined : actions[index]?.key;
|
|
97
|
+
if (action === "r")
|
|
98
|
+
await askForKey(name, host, session.palette);
|
|
99
|
+
else if (action === "c")
|
|
100
|
+
clearSessionKey(name, host);
|
|
101
|
+
else if (action === "f")
|
|
102
|
+
await forgetSavedKey(name, host);
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
await askForKey(provider.auth.keyVar, host, session.palette);
|
|
119
|
-
return provider.blocked() === undefined;
|
|
120
|
-
}
|
|
121
|
-
export function authenticationNeed(provider) {
|
|
122
|
-
return provider.auth.kind === "oauth" ? `${provider.auth.label} sign-in` : "an API key";
|
|
123
|
-
}
|
|
124
|
-
async function offerForget(name, session, host, hint) {
|
|
125
|
-
if (host.choose === undefined)
|
|
126
|
-
return;
|
|
127
|
-
const index = await host.choose({
|
|
128
|
-
title: heading(name, hint, session.palette),
|
|
129
|
-
options: [
|
|
130
|
-
{ label: "keep saved copy", key: "k" },
|
|
131
|
-
{ label: "forget saved copy", hint: storeLabel(), key: "f" },
|
|
132
|
-
],
|
|
133
|
-
index: 0,
|
|
104
|
+
function clearSessionKey(name, host) {
|
|
105
|
+
const removed = forgetSession(name);
|
|
106
|
+
const fallback = credentialSource(name);
|
|
107
|
+
host.emit({
|
|
108
|
+
kind: "notice",
|
|
109
|
+
text: removed
|
|
110
|
+
? fallback === undefined
|
|
111
|
+
? "session API key removed"
|
|
112
|
+
: `session API key removed · ${fallback} copy now active`
|
|
113
|
+
: "no session API key",
|
|
114
|
+
tone: removed ? "info" : "warn",
|
|
134
115
|
});
|
|
135
|
-
if (index === 1)
|
|
136
|
-
await forget(name, host);
|
|
137
116
|
}
|
|
138
|
-
async function
|
|
117
|
+
async function forgetSavedKey(name, host) {
|
|
139
118
|
try {
|
|
140
119
|
const removed = await forgetSaved(name);
|
|
141
120
|
host.emit({
|
|
142
121
|
kind: "notice",
|
|
143
|
-
text: removed ? "API key removed" : "no saved API key",
|
|
122
|
+
text: removed ? "saved API key removed" : "no saved API key",
|
|
144
123
|
tone: removed ? "info" : "warn",
|
|
145
124
|
});
|
|
146
125
|
}
|
|
147
126
|
catch (error) {
|
|
148
|
-
host.emit({
|
|
127
|
+
host.emit({
|
|
128
|
+
kind: "notice",
|
|
129
|
+
text: `could not remove API key · ${error.message}`,
|
|
130
|
+
tone: "error",
|
|
131
|
+
});
|
|
149
132
|
}
|
|
150
133
|
}
|
|
151
134
|
function chooser(host) {
|
package/dist/credentials.js
CHANGED
|
@@ -60,6 +60,10 @@ export function hasSaved(name) {
|
|
|
60
60
|
export function hold(name, value) {
|
|
61
61
|
held.set(name, value);
|
|
62
62
|
}
|
|
63
|
+
/** Remove only the value held by this process. Saved and environment values remain. */
|
|
64
|
+
export function forgetSession(name) {
|
|
65
|
+
return held.delete(name);
|
|
66
|
+
}
|
|
63
67
|
/**
|
|
64
68
|
* Take a key and write it down, returning the path it went to.
|
|
65
69
|
*
|
|
@@ -74,8 +78,10 @@ export async function keep(name, value) {
|
|
|
74
78
|
return withStoreLock(file, async () => {
|
|
75
79
|
const all = { ...readSavedStore(), [name]: value };
|
|
76
80
|
await persist(file, all);
|
|
77
|
-
hold(name, value);
|
|
78
81
|
saved = all;
|
|
82
|
+
// A newly saved replacement must become active immediately. Otherwise an
|
|
83
|
+
// older session-only value would keep shadowing the value just written.
|
|
84
|
+
held.delete(name);
|
|
79
85
|
return file;
|
|
80
86
|
});
|
|
81
87
|
}
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function parseLaunch(argv) {
|
|
2
|
+
const first = argv[0];
|
|
3
|
+
if (first !== undefined && !first.startsWith("--") && first !== "-h" && first !== "-v") {
|
|
4
|
+
if (first !== "resume")
|
|
5
|
+
throw new Error(`unknown command ${first}`);
|
|
6
|
+
const rest = argv.slice(1);
|
|
7
|
+
const latest = rest.filter((value) => value === "--latest").length;
|
|
8
|
+
if (latest > 1)
|
|
9
|
+
throw new Error("--latest may be passed only once");
|
|
10
|
+
return {
|
|
11
|
+
kind: "resume",
|
|
12
|
+
latest: latest === 1,
|
|
13
|
+
configArgs: rest.filter((value) => value !== "--latest"),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (argv.includes("--latest"))
|
|
17
|
+
throw new Error("--latest requires `jecode resume`");
|
|
18
|
+
return { kind: "new", latest: false, configArgs: [...argv] };
|
|
19
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Model-first runtime selection across every provider that can answer now.
|
|
2
|
+
import { saveCommandSettings } from "./command-settings.js";
|
|
3
|
+
import { compatibleEffort } from "./effort.js";
|
|
4
|
+
import { providerFailure } from "./provider-errors.js";
|
|
5
|
+
import { providerLabel } from "./provider-label.js";
|
|
6
|
+
import { PROVIDERS } from "./providers/index.js";
|
|
7
|
+
import { readSettings } from "./settings.js";
|
|
8
|
+
/** Build one searchable catalogue from every provider that is usable now. */
|
|
9
|
+
export async function modelsCommand(session, host, behavior = {}, providers = PROVIDERS) {
|
|
10
|
+
const choose = chooser(host);
|
|
11
|
+
if (choose === undefined)
|
|
12
|
+
return false;
|
|
13
|
+
const availability = providers.map((provider) => ({
|
|
14
|
+
provider,
|
|
15
|
+
blocked: provider.blocked(),
|
|
16
|
+
}));
|
|
17
|
+
const connected = availability
|
|
18
|
+
.filter((entry) => entry.blocked === undefined)
|
|
19
|
+
.map((entry) => entry.provider);
|
|
20
|
+
const disconnected = availability
|
|
21
|
+
.filter((entry) => entry.blocked !== undefined)
|
|
22
|
+
.map((entry) => entry.provider);
|
|
23
|
+
if (connected.length === 0) {
|
|
24
|
+
host.emit({
|
|
25
|
+
kind: "notice",
|
|
26
|
+
text: "no providers are connected · use /providers",
|
|
27
|
+
tone: "warn",
|
|
28
|
+
});
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
throwIfAborted(host.signal);
|
|
32
|
+
host.status?.("Loading model catalogs");
|
|
33
|
+
let settled;
|
|
34
|
+
try {
|
|
35
|
+
settled = await Promise.allSettled(connected.map(async (provider) => ({
|
|
36
|
+
provider,
|
|
37
|
+
models: await provider.models(host.signal, (status) => host.status?.(`${providerLabel(provider.id)} · ${status}`)),
|
|
38
|
+
})));
|
|
39
|
+
// allSettled deliberately preserves partial provider failures, but an
|
|
40
|
+
// explicit command cancellation is not a catalogue failure.
|
|
41
|
+
throwIfAborted(host.signal);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
host.status?.(undefined);
|
|
45
|
+
}
|
|
46
|
+
const choices = [];
|
|
47
|
+
const failed = [];
|
|
48
|
+
for (let index = 0; index < settled.length; index++) {
|
|
49
|
+
const result = settled[index];
|
|
50
|
+
const provider = connected[index];
|
|
51
|
+
if (result === undefined || provider === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
if (result.status === "rejected") {
|
|
54
|
+
failed.push({
|
|
55
|
+
provider,
|
|
56
|
+
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const model of result.value.models)
|
|
61
|
+
choices.push({ provider, model });
|
|
62
|
+
}
|
|
63
|
+
if (choices.length === 0) {
|
|
64
|
+
host.emit({
|
|
65
|
+
kind: "notice",
|
|
66
|
+
text: emptyCatalogMessage(connected, failed),
|
|
67
|
+
tone: failed.length === connected.length ? "error" : "warn",
|
|
68
|
+
});
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const description = catalogDescription(disconnected, failed);
|
|
72
|
+
const index = await choose({
|
|
73
|
+
title: [],
|
|
74
|
+
...(description === undefined ? {} : { description }),
|
|
75
|
+
options: choices.map((choice) => ({
|
|
76
|
+
label: choice.model,
|
|
77
|
+
// Provider identity is part of the choice, not optional help: keep it
|
|
78
|
+
// visible even when the terminal is only at the supported minimum.
|
|
79
|
+
value: providerLabel(choice.provider.id),
|
|
80
|
+
})),
|
|
81
|
+
searchable: true,
|
|
82
|
+
query: "",
|
|
83
|
+
index: Math.max(0, choices.findIndex((choice) => choice.provider.id === session.provider.id && choice.model === session.model)),
|
|
84
|
+
});
|
|
85
|
+
throwIfAborted(host.signal);
|
|
86
|
+
const chosen = index === undefined ? undefined : choices[index];
|
|
87
|
+
if (chosen === undefined)
|
|
88
|
+
return false;
|
|
89
|
+
const beforeEffort = session.config.effort;
|
|
90
|
+
const alignment = await alignEffort(chosen.provider, chosen.model, beforeEffort, host);
|
|
91
|
+
if (!alignment.ok)
|
|
92
|
+
return false;
|
|
93
|
+
if (behavior.save !== false) {
|
|
94
|
+
const saved = readSettings();
|
|
95
|
+
const patch = {
|
|
96
|
+
provider: chosen.provider.id,
|
|
97
|
+
models: { ...saved.models, [chosen.provider.id]: chosen.model },
|
|
98
|
+
...(alignment.effort === beforeEffort ? {} : { effort: alignment.effort }),
|
|
99
|
+
};
|
|
100
|
+
if (!(await saveCommandSettings(host, patch)))
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
// Commit the runtime selection only after both provider validation and
|
|
104
|
+
// persistence succeed, so neither the footer nor a later turn can observe a
|
|
105
|
+
// half-applied provider/model pair.
|
|
106
|
+
session.provider = chosen.provider;
|
|
107
|
+
session.model = chosen.model;
|
|
108
|
+
session.config.providerId = chosen.provider.id;
|
|
109
|
+
session.config.model = chosen.model;
|
|
110
|
+
session.config.effort = alignment.effort;
|
|
111
|
+
if (behavior.announce !== false) {
|
|
112
|
+
host.emit({
|
|
113
|
+
kind: "notice",
|
|
114
|
+
text: `model · ${providerLabel(chosen.provider.id)} · ${chosen.model}`,
|
|
115
|
+
tone: "info",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
async function alignEffort(provider, model, effort, host) {
|
|
121
|
+
if (provider.efforts === undefined)
|
|
122
|
+
return { ok: true, effort };
|
|
123
|
+
try {
|
|
124
|
+
throwIfAborted(host.signal);
|
|
125
|
+
const supported = await provider.efforts(model, host.signal, (status) => host.status?.(status));
|
|
126
|
+
throwIfAborted(host.signal);
|
|
127
|
+
return { ok: true, effort: compatibleEffort(effort, supported) ?? effort };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throwIfAborted(host.signal);
|
|
131
|
+
host.emit({
|
|
132
|
+
kind: "notice",
|
|
133
|
+
text: providerFailure(provider, error, true),
|
|
134
|
+
tone: "error",
|
|
135
|
+
});
|
|
136
|
+
return { ok: false };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function catalogDescription(disconnected, failed) {
|
|
140
|
+
const parts = [
|
|
141
|
+
disconnected.length === 0
|
|
142
|
+
? undefined
|
|
143
|
+
: `Not connected: ${disconnected.map((provider) => providerLabel(provider.id)).join(", ")}`,
|
|
144
|
+
failed.length === 0
|
|
145
|
+
? undefined
|
|
146
|
+
: `Unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
|
|
147
|
+
].filter((part) => part !== undefined);
|
|
148
|
+
return parts.length === 0 ? undefined : `${parts.join(" · ")} · manage in /providers`;
|
|
149
|
+
}
|
|
150
|
+
function emptyCatalogMessage(connected, failed) {
|
|
151
|
+
if (failed.length === 1 && connected.length === 1) {
|
|
152
|
+
const one = failed[0];
|
|
153
|
+
if (one !== undefined)
|
|
154
|
+
return providerFailure(one.provider, one.error, true);
|
|
155
|
+
}
|
|
156
|
+
if (failed.length > 0) {
|
|
157
|
+
return `model catalogs unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")} · /providers`;
|
|
158
|
+
}
|
|
159
|
+
return "connected providers offer no models · check /providers";
|
|
160
|
+
}
|
|
161
|
+
function chooser(host) {
|
|
162
|
+
if (host.choose === undefined) {
|
|
163
|
+
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
164
|
+
}
|
|
165
|
+
return host.choose;
|
|
166
|
+
}
|
|
167
|
+
function throwIfAborted(signal) {
|
|
168
|
+
if (signal?.aborted !== true)
|
|
169
|
+
return;
|
|
170
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
171
|
+
}
|
|
@@ -9,59 +9,55 @@ export async function permissionsCommand(session, host) {
|
|
|
9
9
|
}
|
|
10
10
|
let selected = 0;
|
|
11
11
|
while (true) {
|
|
12
|
-
const
|
|
13
|
-
const index = await choose(permissionsPicker(tools, session.palette, selected));
|
|
12
|
+
const index = await choose(permissionControlPicker(control, host, selected));
|
|
14
13
|
if (index === undefined)
|
|
15
14
|
return;
|
|
16
|
-
const tool =
|
|
15
|
+
const tool = control.listTools()[index];
|
|
17
16
|
if (tool === undefined)
|
|
18
17
|
return;
|
|
19
18
|
selected = index;
|
|
20
|
-
|
|
19
|
+
if (tool.locked) {
|
|
20
|
+
lockedNotice(tool.name, host);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (tool.remembered > 0) {
|
|
24
|
+
await reviewGrants(tool.name, control, choose, session.palette);
|
|
25
|
+
}
|
|
21
26
|
}
|
|
22
27
|
}
|
|
23
|
-
export function permissionsPicker(tools,
|
|
24
|
-
const launchOverride = tools.some((tool) => tool.locked);
|
|
28
|
+
export function permissionsPicker(tools, index = 0, adjust) {
|
|
25
29
|
return {
|
|
26
|
-
title:
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
title: [],
|
|
31
|
+
options: tools.map((tool) => {
|
|
32
|
+
const description = toolDescription(tool);
|
|
33
|
+
return {
|
|
34
|
+
label: tool.name,
|
|
35
|
+
...(description === undefined ? {} : { description }),
|
|
36
|
+
value: tool.locked ? `${tool.mode} · locked` : tool.mode,
|
|
37
|
+
adjustable: !tool.locked,
|
|
38
|
+
};
|
|
39
|
+
}),
|
|
40
|
+
visible: tools.length,
|
|
41
|
+
...(adjust === undefined ? {} : { adjust }),
|
|
29
42
|
index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
|
|
30
43
|
};
|
|
31
44
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
? "Session only · ask is the safe default"
|
|
48
|
-
: "Session only · deny hides this tool from the model",
|
|
49
|
-
options: [
|
|
50
|
-
...modes.map((mode) => ({ label: mode, hint: modeHint(mode, tool.dangerous) })),
|
|
51
|
-
...(grants.length === 0
|
|
52
|
-
? []
|
|
53
|
-
: [{ label: "remembered approvals", hint: String(grants.length) }]),
|
|
54
|
-
],
|
|
55
|
-
index: Math.max(0, modes.indexOf(tool.mode)),
|
|
45
|
+
function permissionControlPicker(control, host, selected) {
|
|
46
|
+
return permissionsPicker(control.listTools(), selected, (index, step) => {
|
|
47
|
+
const tool = control.listTools()[index];
|
|
48
|
+
if (tool === undefined)
|
|
49
|
+
return permissionControlPicker(control, host, selected);
|
|
50
|
+
if (tool.locked) {
|
|
51
|
+
lockedNotice(tool.name, host);
|
|
52
|
+
return permissionControlPicker(control, host, index);
|
|
53
|
+
}
|
|
54
|
+
const modes = modesFor(tool);
|
|
55
|
+
const at = Math.max(0, modes.indexOf(tool.mode));
|
|
56
|
+
const next = modes[(at + step + modes.length) % modes.length];
|
|
57
|
+
if (next !== undefined)
|
|
58
|
+
control.set(tool.name, next);
|
|
59
|
+
return permissionControlPicker(control, host, index);
|
|
56
60
|
});
|
|
57
|
-
if (index === undefined)
|
|
58
|
-
return;
|
|
59
|
-
const mode = modes[index];
|
|
60
|
-
if (mode !== undefined) {
|
|
61
|
-
control.set(tool.name, mode);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
await reviewGrants(tool.name, control, choose, pal);
|
|
65
61
|
}
|
|
66
62
|
async function reviewGrants(tool, control, choose, pal) {
|
|
67
63
|
let selected = 0;
|
|
@@ -75,7 +71,6 @@ async function reviewGrants(tool, control, choose, pal) {
|
|
|
75
71
|
];
|
|
76
72
|
const index = await choose({
|
|
77
73
|
title: heading("remembered", tool, pal),
|
|
78
|
-
description: "Allowed without asking · this session",
|
|
79
74
|
options,
|
|
80
75
|
index: Math.min(selected, options.length - 1),
|
|
81
76
|
});
|
|
@@ -92,16 +87,20 @@ async function reviewGrants(tool, control, choose, pal) {
|
|
|
92
87
|
selected = index;
|
|
93
88
|
}
|
|
94
89
|
}
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
function modesFor(tool) {
|
|
91
|
+
return tool.dangerous ? ["ask", "allow", "deny"] : ["allow", "deny"];
|
|
92
|
+
}
|
|
93
|
+
function toolDescription(tool) {
|
|
94
|
+
const parts = [
|
|
95
|
+
tool.dangerous ? undefined : "read only",
|
|
96
|
+
tool.remembered === 0 ? undefined : `${tool.remembered} remembered`,
|
|
97
|
+
].filter((part) => part !== undefined);
|
|
98
|
+
return parts.length === 0 ? undefined : parts.join(" · ");
|
|
100
99
|
}
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
function lockedNotice(tool, host) {
|
|
101
|
+
host.emit({
|
|
102
|
+
kind: "notice",
|
|
103
|
+
text: `restart without --auto-approve to change ${tool}`,
|
|
104
|
+
tone: "warn",
|
|
105
|
+
});
|
|
107
106
|
}
|