@giovannijecha/jecode 0.1.5-rc.2

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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/bin/jecode.js +11 -0
  4. package/dist/atomic.js +78 -0
  5. package/dist/batch-view.js +17 -0
  6. package/dist/batch.js +100 -0
  7. package/dist/cli-info.js +40 -0
  8. package/dist/commands.js +171 -0
  9. package/dist/config.js +97 -0
  10. package/dist/controller.js +90 -0
  11. package/dist/credential-commands.js +134 -0
  12. package/dist/credential-safety.js +86 -0
  13. package/dist/credentials.js +124 -0
  14. package/dist/main.js +7 -0
  15. package/dist/ollama-settings-command.js +75 -0
  16. package/dist/prompt.js +29 -0
  17. package/dist/provider-commands.js +236 -0
  18. package/dist/provider-errors.js +10 -0
  19. package/dist/providers/anthropic-stream.js +111 -0
  20. package/dist/providers/anthropic-wire.js +79 -0
  21. package/dist/providers/anthropic.js +77 -0
  22. package/dist/providers/catalog.js +37 -0
  23. package/dist/providers/http.js +219 -0
  24. package/dist/providers/index.js +18 -0
  25. package/dist/providers/ollama-endpoint.js +40 -0
  26. package/dist/providers/ollama-stream.js +60 -0
  27. package/dist/providers/ollama-wire.js +95 -0
  28. package/dist/providers/ollama.js +87 -0
  29. package/dist/providers/openai-stream.js +48 -0
  30. package/dist/providers/openai-wire.js +112 -0
  31. package/dist/providers/openai.js +70 -0
  32. package/dist/providers/sse.js +81 -0
  33. package/dist/providers/stream-limits.js +11 -0
  34. package/dist/session.js +3 -0
  35. package/dist/settings-command.js +202 -0
  36. package/dist/settings.js +98 -0
  37. package/dist/start.js +47 -0
  38. package/dist/tools/args.js +38 -0
  39. package/dist/tools/fs.js +277 -0
  40. package/dist/tools/index.js +39 -0
  41. package/dist/tools/paths.js +122 -0
  42. package/dist/tools/search.js +213 -0
  43. package/dist/tools/shell.js +139 -0
  44. package/dist/tools/text-boundary.js +102 -0
  45. package/dist/tools/types.js +1 -0
  46. package/dist/transcript-export.js +11 -0
  47. package/dist/transcript.js +49 -0
  48. package/dist/tui/activity.js +11 -0
  49. package/dist/tui/app-input.js +155 -0
  50. package/dist/tui/app-state.js +17 -0
  51. package/dist/tui/app-workflows.js +105 -0
  52. package/dist/tui/app.js +215 -0
  53. package/dist/tui/approve.js +67 -0
  54. package/dist/tui/blocks.js +23 -0
  55. package/dist/tui/complete.js +54 -0
  56. package/dist/tui/components/command-menu.js +17 -0
  57. package/dist/tui/components/composer.js +47 -0
  58. package/dist/tui/components/dock.js +10 -0
  59. package/dist/tui/components/footer.js +21 -0
  60. package/dist/tui/components/menu.js +38 -0
  61. package/dist/tui/components/messages.js +43 -0
  62. package/dist/tui/components/misc.js +22 -0
  63. package/dist/tui/components/status.js +45 -0
  64. package/dist/tui/components/tool.js +85 -0
  65. package/dist/tui/components/types.js +1 -0
  66. package/dist/tui/editor.js +105 -0
  67. package/dist/tui/feedback.js +74 -0
  68. package/dist/tui/field.js +60 -0
  69. package/dist/tui/frame.js +33 -0
  70. package/dist/tui/input.js +52 -0
  71. package/dist/tui/keys.js +177 -0
  72. package/dist/tui/modal.js +24 -0
  73. package/dist/tui/overlay.js +93 -0
  74. package/dist/tui/picker.js +99 -0
  75. package/dist/tui/screen.js +94 -0
  76. package/dist/tui/scroll.js +7 -0
  77. package/dist/tui/session-view.js +49 -0
  78. package/dist/tui/transcript-view.js +134 -0
  79. package/dist/tui/turn.js +255 -0
  80. package/dist/tui/view.js +88 -0
  81. package/dist/tui/workspace.js +59 -0
  82. package/dist/types.js +9 -0
  83. package/dist/ui/diff.js +109 -0
  84. package/dist/ui/highlight.js +158 -0
  85. package/dist/ui/inline.js +39 -0
  86. package/dist/ui/markdown.js +147 -0
  87. package/dist/ui/render.js +232 -0
  88. package/dist/ui/table.js +127 -0
  89. package/dist/ui/terminal-text.js +42 -0
  90. package/dist/ui/theme.js +25 -0
  91. package/dist/ui/width.js +196 -0
  92. package/dist/usage.js +30 -0
  93. package/dist/user-data.js +29 -0
  94. package/package.json +56 -0
@@ -0,0 +1,171 @@
1
+ // Slash-command registry and dispatcher.
2
+ //
3
+ // One of them does reach the network — a menu of models cannot be built
4
+ // without asking the provider what it has — but none of them ever sends a
5
+ // message. Provider and credential interaction lives in provider-commands.ts;
6
+ // this file keeps command discovery, dispatch, and local session operations.
7
+ import { heading } from "./tui/picker.js";
8
+ import { modelsCommand, providersCommand, setupCommand } from "./provider-commands.js";
9
+ import { credentialsCommand } from "./credential-commands.js";
10
+ import { effortCommand, settingsCommand } from "./settings-command.js";
11
+ import { emptyUsage, formatTokens } from "./usage.js";
12
+ /**
13
+ * The commands, declared once.
14
+ *
15
+ * `/help` and the completion menu both read this list, so a command cannot
16
+ * exist in the switch below and be missing from the two places that tell the
17
+ * user it exists.
18
+ *
19
+ * Deliberately short. `/settings` owns persistent defaults; the narrower
20
+ * provider, model, effort, credential, and setup commands remain useful direct
21
+ * paths into the same interactions.
22
+ */
23
+ export const COMMANDS = [
24
+ { name: "help", blurb: "this list" },
25
+ { name: "exit", blurb: "exit and restore the terminal" },
26
+ { name: "new", blurb: "start a clean in-memory session" },
27
+ { name: "usage", blurb: "show token usage for this session" },
28
+ { name: "export", blurb: "save this transcript as Markdown" },
29
+ { name: "permissions", blurb: "review or revoke remembered approvals" },
30
+ { name: "settings", blurb: "change and save jecode defaults" },
31
+ { name: "effort", blurb: "set the reasoning effort" },
32
+ { name: "credentials", blurb: "inspect, replace, or forget API keys" },
33
+ { name: "setup", blurb: "make the current provider ready" },
34
+ { name: "models", blurb: "pick a model, from what the provider offers" },
35
+ { name: "providers", blurb: "pick a provider" },
36
+ ];
37
+ export async function handleCommand(line, session, host) {
38
+ const [name] = line.slice(1).trim().split(/\s+/);
39
+ switch (name) {
40
+ case "help":
41
+ host.emit(help());
42
+ return "handled";
43
+ case "exit":
44
+ return "exit";
45
+ case "new":
46
+ session.history.length = 0;
47
+ session.usage = emptyUsage();
48
+ host.reset?.();
49
+ host.emit({ kind: "notice", text: "new session · history, usage, and approvals cleared", tone: "info" });
50
+ return "handled";
51
+ case "usage":
52
+ host.emit(usage(session));
53
+ return "handled";
54
+ case "export":
55
+ if (host.exportTranscript === undefined) {
56
+ host.emit({ kind: "notice", text: "export needs the interactive screen", tone: "warn" });
57
+ }
58
+ else {
59
+ const saved = await host.exportTranscript();
60
+ host.emit({ kind: "notice", text: `transcript saved to ${saved}`, tone: "info" });
61
+ }
62
+ return "handled";
63
+ case "permissions":
64
+ await permissions(session, host);
65
+ return "handled";
66
+ case "settings":
67
+ await settingsCommand(session, host);
68
+ return "handled";
69
+ case "effort":
70
+ await effortCommand(session, host);
71
+ return "handled";
72
+ case "credentials":
73
+ await credentialsCommand(session, host);
74
+ return "handled";
75
+ case "setup":
76
+ await setupCommand(session, host);
77
+ return "handled";
78
+ case "models":
79
+ await modelsCommand(session, host);
80
+ return "handled";
81
+ case "providers":
82
+ await providersCommand(session, host);
83
+ return "handled";
84
+ default:
85
+ host.emit({ kind: "notice", text: `unknown command /${name ?? ""} — try /help`, tone: "warn" });
86
+ return "handled";
87
+ }
88
+ }
89
+ function usage(session) {
90
+ const value = session.usage;
91
+ const rows = [
92
+ ` requests${String(value.requests).padStart(18)}`,
93
+ ` latest context${formatTokens(value.lastInputTokens).padStart(12)}`,
94
+ ` input${formatTokens(value.inputTokens).padStart(21)}`,
95
+ ` output${formatTokens(value.outputTokens).padStart(20)}`,
96
+ ];
97
+ if (value.cachedInputTokens > 0)
98
+ rows.push(` cached input${formatTokens(value.cachedInputTokens).padStart(14)}`);
99
+ if (value.cacheWriteInputTokens > 0) {
100
+ rows.push(` cache writes${formatTokens(value.cacheWriteInputTokens).padStart(14)}`);
101
+ }
102
+ if (value.reasoningTokens > 0)
103
+ rows.push(` reasoning${formatTokens(value.reasoningTokens).padStart(17)}`);
104
+ return { kind: "list", items: rows.map((text) => ({ text, dim: false })) };
105
+ }
106
+ async function permissions(session, host) {
107
+ const choose = chooser(host);
108
+ if (choose === undefined)
109
+ return;
110
+ if (session.config.autoApprove) {
111
+ host.emit({
112
+ kind: "notice",
113
+ text: "--auto-approve is active · every dangerous call is allowed for this process",
114
+ tone: "warn",
115
+ });
116
+ }
117
+ const entries = host.permissions?.() ?? [];
118
+ if (entries.length === 0) {
119
+ host.emit({ kind: "notice", text: "no remembered session permissions", tone: "info" });
120
+ return;
121
+ }
122
+ const index = await choose({
123
+ title: heading("revoke permission", "applies only to this session", session.palette),
124
+ right: "↑↓ enter · esc close",
125
+ options: [
126
+ ...entries.map((entry) => ({ label: entry.label, hint: "revoke" })),
127
+ { label: "all remembered permissions", hint: "revoke all" },
128
+ ],
129
+ index: 0,
130
+ });
131
+ if (index === undefined)
132
+ return;
133
+ if (index === entries.length) {
134
+ host.revokePermission?.();
135
+ host.emit({ kind: "notice", text: "all remembered permissions revoked", tone: "info" });
136
+ return;
137
+ }
138
+ const entry = entries[index];
139
+ if (entry === undefined)
140
+ return;
141
+ host.revokePermission?.(entry.key);
142
+ host.emit({ kind: "notice", text: `revoked · ${entry.label}`, tone: "info" });
143
+ }
144
+ function chooser(host) {
145
+ if (host.choose === undefined) {
146
+ host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
147
+ }
148
+ return host.choose;
149
+ }
150
+ function help() {
151
+ const width = Math.max(...COMMANDS.map((c) => label(c).length));
152
+ return {
153
+ kind: "list",
154
+ items: [
155
+ ...COMMANDS.map((command) => ({
156
+ text: ` ${label(command).padEnd(width + 4)}${command.blurb}`,
157
+ dim: false,
158
+ })),
159
+ { text: "", dim: true },
160
+ { text: " esc interrupts a running turn · ctrl+c exits", dim: true },
161
+ { text: " wheel or pgup / pgdn scrolls · ctrl+l redraws", dim: true },
162
+ { text: " ctrl+o expands the latest reasoning or tool details", dim: true },
163
+ { text: " type in the model picker to filter · home/end jumps", dim: true },
164
+ { text: " ↑↓ selects an open menu · tab completes · esc closes it", dim: true },
165
+ { text: " enter selects or sends · alt+enter starts a new line", dim: true },
166
+ ],
167
+ };
168
+ }
169
+ function label(command) {
170
+ return `/${command.name}`;
171
+ }
package/dist/config.js ADDED
@@ -0,0 +1,97 @@
1
+ // Runtime configuration: flags, environment, saved defaults, built-ins.
2
+ import * as path from "node:path";
3
+ import { EFFORTS, readSettings } from "./settings.js";
4
+ import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
5
+ const FLAGS = [
6
+ "provider",
7
+ "model",
8
+ "ollama-host",
9
+ "reduced-motion",
10
+ "effort",
11
+ "max-tokens",
12
+ "max-steps",
13
+ "root",
14
+ "auto-approve",
15
+ ];
16
+ export function loadConfig(argv, saved = readSettings()) {
17
+ const flags = parseFlags(argv);
18
+ // A flag nobody declared is a typo. Swallowed in silence it becomes a
19
+ // setting the user believes is on, and the run that ignores it looks like
20
+ // the feature is broken rather than misspelled.
21
+ for (const name of Object.keys(flags)) {
22
+ if (!FLAGS.includes(name)) {
23
+ throw new Error(`unknown flag --${name} (known: ${FLAGS.map((f) => `--${f}`).join(", ")})`);
24
+ }
25
+ }
26
+ const providerId = pick(flags.provider, process.env.JECODE_PROVIDER, saved.provider ?? "anthropic");
27
+ const ollamaHost = optional(flags["ollama-host"], process.env.OLLAMA_HOST, saved.ollamaHost);
28
+ const effort = pick(flags.effort, process.env.JECODE_EFFORT, saved.effort ?? "high");
29
+ if (!EFFORTS.includes(effort)) {
30
+ throw new Error(`unknown effort "${effort}" (expected one of: ${EFFORTS.join(", ")})`);
31
+ }
32
+ return {
33
+ providerId,
34
+ model: pick(flags.model, process.env.JECODE_MODEL, saved.models?.[providerId] ?? ""),
35
+ ...(ollamaHost === undefined
36
+ ? {}
37
+ : { ollamaHost: parseOllamaEndpoint(ollamaHost).baseUrl }),
38
+ reducedMotion: bool(flags["reduced-motion"], process.env.JECODE_REDUCED_MOTION, saved.reducedMotion ?? false),
39
+ effort,
40
+ // Every request streams, so a large ceiling costs nothing in timeout risk.
41
+ maxTokens: toInt(pick(flags["max-tokens"], process.env.JECODE_MAX_TOKENS, String(saved.maxTokens ?? 64000)), "max-tokens"),
42
+ maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
43
+ root: path.resolve(pick(flags.root, undefined, process.cwd())),
44
+ autoApprove: flags["auto-approve"] === "true" || process.env.JECODE_AUTO_APPROVE === "1",
45
+ };
46
+ }
47
+ function bool(flag, env, fallback) {
48
+ if (flag !== undefined)
49
+ return flag === "true" || flag === "1";
50
+ if (env !== undefined && env !== "")
51
+ return env === "true" || env === "1";
52
+ return fallback;
53
+ }
54
+ function pick(flag, env, fallback) {
55
+ if (flag !== undefined && flag !== "")
56
+ return flag;
57
+ if (env !== undefined && env !== "")
58
+ return env;
59
+ return fallback;
60
+ }
61
+ function optional(flag, env, fallback) {
62
+ if (flag !== undefined && flag !== "")
63
+ return flag;
64
+ if (env !== undefined && env !== "")
65
+ return env;
66
+ return fallback;
67
+ }
68
+ function toInt(value, name) {
69
+ const n = Number(value);
70
+ if (!Number.isInteger(n) || n <= 0)
71
+ throw new Error(`--${name} must be a positive integer`);
72
+ return n;
73
+ }
74
+ // Accepts --key value, --key=value, and bare --flag (which reads as "true").
75
+ function parseFlags(argv) {
76
+ const flags = {};
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const arg = argv[i];
79
+ if (arg === undefined || !arg.startsWith("--"))
80
+ continue;
81
+ const body = arg.slice(2);
82
+ const eq = body.indexOf("=");
83
+ if (eq !== -1) {
84
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
85
+ continue;
86
+ }
87
+ const next = argv[i + 1];
88
+ if (next !== undefined && !next.startsWith("--")) {
89
+ flags[body] = next;
90
+ i++;
91
+ }
92
+ else {
93
+ flags[body] = "true";
94
+ }
95
+ }
96
+ return flags;
97
+ }
@@ -0,0 +1,90 @@
1
+ // The single controller.
2
+ //
3
+ // One loop, in dialogue with the user. It never delegates: no subagent, no
4
+ // worker, no orchestrator split, no second loop. When a task is too big, this
5
+ // loop iterates. That constraint is implemented here, not left as a product claim.
6
+ import { isToolCall } from "./types.js";
7
+ import { findTool, runTool, toolSpecs } from "./tools/index.js";
8
+ export const MAX_TOOL_CALLS_PER_STEP = 32;
9
+ /**
10
+ * Run one user turn to completion: keep exchanging with the model until it
11
+ * stops asking for tools. `history` is mutated in place, so an aborted turn
12
+ * still leaves the conversation in a consistent state.
13
+ */
14
+ export async function runTurn(history, options, events, signal) {
15
+ const specs = toolSpecs(options.tools);
16
+ for (let step = 0; step < options.maxSteps; step++) {
17
+ events.onStep?.(step + 1, options.maxSteps);
18
+ // The message is displayed as it streams; what comes back here is the
19
+ // assembled version, which exists to be appended to the history.
20
+ const assistant = await options.provider.send({
21
+ model: options.model,
22
+ system: options.system,
23
+ messages: history,
24
+ tools: specs,
25
+ maxTokens: options.maxTokens,
26
+ effort: options.effort,
27
+ signal,
28
+ onStream: (event) => events.onStream(event),
29
+ onStatus: (status) => events.onStatus?.(status),
30
+ });
31
+ const calls = assistant.content.filter(isToolCall);
32
+ if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
33
+ throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
34
+ }
35
+ history.push(assistant);
36
+ if (assistant.usage !== undefined)
37
+ events.onUsage?.(assistant.usage);
38
+ if (calls.length === 0)
39
+ return; // the model is done — hand back to the user
40
+ // Calls run one after another because approval prompts serialise anyway,
41
+ // but every result from this step goes back in a SINGLE message. Splitting
42
+ // them teaches the model to stop batching its calls.
43
+ const results = [];
44
+ for (let index = 0; index < calls.length; index++) {
45
+ const call = calls[index];
46
+ events.onToolProgress?.(index + 1, calls.length);
47
+ const preview = await look(call, options);
48
+ events.onToolCall(call, preview);
49
+ const { result, summary } = await settle(call, options, events, signal, preview);
50
+ events.onToolResult(call, result, summary);
51
+ results.push(result);
52
+ }
53
+ history.push({ role: "user", content: results });
54
+ }
55
+ throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
56
+ }
57
+ async function settle(call, options, events, signal, preview) {
58
+ const tool = findTool(options.tools, call.name);
59
+ if (tool === undefined) {
60
+ return refuse(call, `no such tool: ${call.name}`, "unknown tool");
61
+ }
62
+ if (tool.dangerous && !(await events.approve(call))) {
63
+ return refuse(call, "the user declined this call — ask them how to proceed", "declined");
64
+ }
65
+ return runTool(tool, call, { ...options.toolContext, signal, preview });
66
+ }
67
+ function refuse(call, reason, summary) {
68
+ return {
69
+ result: { kind: "tool_result", id: call.id, output: reason, isError: true },
70
+ summary,
71
+ };
72
+ }
73
+ /**
74
+ * What the call would change, asked of the tool that would change it.
75
+ *
76
+ * Read-only and best-effort by construction: a preview that throws — a missing
77
+ * file, a match that is not there — is simply no preview. Nothing about the
78
+ * turn depends on it, because it exists for the user, not for the model.
79
+ */
80
+ async function look(call, options) {
81
+ const tool = findTool(options.tools, call.name);
82
+ if (tool?.preview === undefined)
83
+ return undefined;
84
+ try {
85
+ return await tool.preview(call.input, options.toolContext);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ }
@@ -0,0 +1,134 @@
1
+ // Credential command flows shared by setup and provider selection.
2
+ import { heading } from "./tui/picker.js";
3
+ import { EMPTY } from "./tui/editor.js";
4
+ import { PROVIDERS } from "./providers/index.js";
5
+ import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
6
+ /** Ask for a key, then ask separately whether it may be written to disk. */
7
+ export async function askForKey(name, host, pal) {
8
+ if (host.type === undefined || host.choose === undefined)
9
+ return false;
10
+ const field = {
11
+ title: [
12
+ { text: "paste key ", fg: pal.ink.attention, bold: true },
13
+ { text: name, fg: pal.ink.bright, bold: true },
14
+ ],
15
+ right: "enter ok · esc skip",
16
+ editor: EMPTY,
17
+ secret: true,
18
+ note: "read from the environment first — an exported value still wins",
19
+ };
20
+ const value = await host.type(field);
21
+ if (value === undefined)
22
+ return false;
23
+ const index = await host.choose({
24
+ title: heading("remember it?", name, pal),
25
+ right: "↑↓ enter",
26
+ options: [
27
+ { label: "just this session", hint: "nothing is written", key: "s" },
28
+ { label: `save it to ${storeLabel()}`, hint: "owner-only file", key: "w" },
29
+ { label: "discard it", key: "d" },
30
+ ],
31
+ index: 0,
32
+ });
33
+ if (index === 0) {
34
+ hold(name, value);
35
+ host.emit({ kind: "notice", text: "credential available for this session", tone: "info" });
36
+ return true;
37
+ }
38
+ if (index === 1) {
39
+ try {
40
+ await keep(name, value);
41
+ host.emit({ kind: "notice", text: `credential saved · ${storeLabel()}`, tone: "info" });
42
+ return true;
43
+ }
44
+ catch (error) {
45
+ host.emit({ kind: "notice", text: `could not save credential · ${error.message}`, tone: "error" });
46
+ return false;
47
+ }
48
+ }
49
+ host.emit({ kind: "notice", text: "credential discarded", tone: "warn" });
50
+ return false;
51
+ }
52
+ export async function credentialsCommand(session, host) {
53
+ const choose = chooser(host);
54
+ if (choose === undefined)
55
+ return;
56
+ const index = await choose({
57
+ title: heading("credential", "values are never shown", session.palette),
58
+ right: "↑↓ enter · esc close",
59
+ options: PROVIDERS.map((provider) => ({
60
+ label: provider.keyVar,
61
+ hint: credentialSource(provider.keyVar) ?? "missing",
62
+ })),
63
+ index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
64
+ });
65
+ if (index === undefined)
66
+ return;
67
+ const provider = PROVIDERS[index];
68
+ if (provider === undefined)
69
+ return;
70
+ const name = provider.keyVar;
71
+ const source = credentialSource(name);
72
+ if (source === "environment") {
73
+ host.emit({
74
+ kind: "notice",
75
+ text: `${name} comes from the environment · update it outside jecode and restart`,
76
+ tone: "info",
77
+ });
78
+ if (hasSaved(name))
79
+ await offerForget(name, session, host, "a saved copy is currently shadowed");
80
+ return;
81
+ }
82
+ const actions = [
83
+ { label: source === undefined ? "add credential" : "replace credential", key: "r" },
84
+ ...(hasSaved(name) ? [{ label: "forget saved copy", hint: storeLabel(), key: "f" }] : []),
85
+ { label: "close", key: "c" },
86
+ ];
87
+ const action = await choose({
88
+ title: heading(name, source ?? "missing", session.palette),
89
+ right: "↑↓ enter",
90
+ options: actions,
91
+ index: 0,
92
+ });
93
+ if (action === undefined || action === actions.length - 1)
94
+ return;
95
+ if (actions[action]?.key === "f") {
96
+ await forget(name, host);
97
+ return;
98
+ }
99
+ await askForKey(name, host, session.palette);
100
+ }
101
+ async function offerForget(name, session, host, hint) {
102
+ if (host.choose === undefined)
103
+ return;
104
+ const index = await host.choose({
105
+ title: heading(name, hint, session.palette),
106
+ right: "↑↓ enter",
107
+ options: [
108
+ { label: "keep saved copy", key: "k" },
109
+ { label: "forget saved copy", hint: storeLabel(), key: "f" },
110
+ ],
111
+ index: 0,
112
+ });
113
+ if (index === 1)
114
+ await forget(name, host);
115
+ }
116
+ async function forget(name, host) {
117
+ try {
118
+ const removed = await forgetSaved(name);
119
+ host.emit({
120
+ kind: "notice",
121
+ text: removed ? "saved credential removed" : "no saved credential to remove",
122
+ tone: removed ? "info" : "warn",
123
+ });
124
+ }
125
+ catch (error) {
126
+ host.emit({ kind: "notice", text: `could not forget: ${error.message}`, tone: "error" });
127
+ }
128
+ }
129
+ function chooser(host) {
130
+ if (host.choose === undefined) {
131
+ host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
132
+ }
133
+ return host.choose;
134
+ }
@@ -0,0 +1,86 @@
1
+ // The shell needs a useful process environment, not the application's secrets.
2
+ import { credentialValues } from "./credentials.js";
3
+ const REDACTED = "[credential redacted]";
4
+ const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
5
+ const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
6
+ /** Preserve ordinary tool configuration while withholding credential-like values. */
7
+ export function shellEnvironment(source = process.env) {
8
+ const environment = {};
9
+ for (const [name, value] of Object.entries(source)) {
10
+ if (value !== undefined && !sensitiveEnvironment(name, value))
11
+ environment[name] = value;
12
+ }
13
+ return environment;
14
+ }
15
+ /** Remove values Jecode recognizes as credentials before tool output leaves the shell boundary. */
16
+ export function redactCredentials(text, source = process.env) {
17
+ return redact(text, secrets(source));
18
+ }
19
+ /** Redact before bounded capture, retaining enough raw overlap for split values. */
20
+ export function credentialRedactor(source = process.env) {
21
+ const values = secrets(source);
22
+ const longest = Math.max(0, ...values.map((value) => value.length));
23
+ let pending = "";
24
+ return {
25
+ write(chunk) {
26
+ const combined = `${pending}${chunk}`;
27
+ const ready = [];
28
+ let at = 0;
29
+ while (at < combined.length) {
30
+ const complete = values.find((value) => combined.startsWith(value, at));
31
+ if (complete !== undefined) {
32
+ ready.push(REDACTED);
33
+ at += complete.length;
34
+ continue;
35
+ }
36
+ const rest = combined.slice(at);
37
+ if (rest.length < longest && values.some((value) => value.startsWith(rest))) {
38
+ pending = rest;
39
+ return ready.join("");
40
+ }
41
+ ready.push(combined[at]);
42
+ at++;
43
+ }
44
+ pending = "";
45
+ return ready.join("");
46
+ },
47
+ end() {
48
+ const last = redact(pending, values);
49
+ pending = "";
50
+ return last;
51
+ },
52
+ };
53
+ }
54
+ function sensitiveEnvironmentName(name) {
55
+ const normalized = name
56
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
57
+ .replace(/[^a-z0-9]+/gi, "_")
58
+ .replace(/^_+|_+$/g, "");
59
+ return (SENSITIVE_ENVIRONMENT_NAME.test(normalized) ||
60
+ COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
61
+ }
62
+ function secrets(source) {
63
+ const values = new Set(credentialValues());
64
+ for (const [name, value] of Object.entries(source)) {
65
+ if (value !== undefined && value !== "" && sensitiveEnvironment(name, value))
66
+ values.add(value);
67
+ }
68
+ return [...values].filter((value) => value !== "").sort((left, right) => right.length - left.length);
69
+ }
70
+ function redact(text, values) {
71
+ let redacted = text;
72
+ for (const value of values)
73
+ redacted = redacted.replaceAll(value, REDACTED);
74
+ return redacted;
75
+ }
76
+ function sensitiveEnvironment(name, value) {
77
+ if (sensitiveEnvironmentName(name))
78
+ return true;
79
+ try {
80
+ const url = new URL(value);
81
+ return url.username !== "" || url.password !== "";
82
+ }
83
+ catch {
84
+ return /(?:^|[;\s])(?:password|pwd|token|secret)\s*=\s*[^;\s]+/i.test(value);
85
+ }
86
+ }