@beunec/rivine 0.1.0 → 0.1.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.
package/dist/index.js CHANGED
@@ -43,13 +43,13 @@ program
43
43
  .version(readPackageVersion());
44
44
  function resolveMode(options) {
45
45
  if (options.bypass)
46
- return 'bypass';
46
+ return { mode: 'bypass', explicit: true };
47
47
  if (options.plan)
48
- return 'plan';
48
+ return { mode: 'plan', explicit: true };
49
49
  const requested = options.mode;
50
50
  if (requested && PERMISSION_MODES.includes(requested))
51
- return requested;
52
- return 'default';
51
+ return { mode: requested, explicit: true };
52
+ return { mode: 'default', explicit: false };
53
53
  }
54
54
  program
55
55
  .command('login', { isDefault: false })
@@ -265,7 +265,7 @@ program
265
265
  .action(async (prompt, options) => {
266
266
  const cwd = options.cwd ? resolvePath(options.cwd) : process.cwd();
267
267
  const oneShot = options.print ?? (prompt.length ? prompt.join(' ') : undefined);
268
- const mode = resolveMode(options);
268
+ const { mode, explicit: modeExplicit } = resolveMode(options);
269
269
  const format = options.outputFormat === 'json' ? 'json' : 'text';
270
270
  if (!isInteractiveTerminal()) {
271
271
  if (!oneShot) {
@@ -284,6 +284,7 @@ program
284
284
  const { waitUntilExit } = render(React.createElement(App, {
285
285
  cwd,
286
286
  initialMode: mode,
287
+ askForMode: !modeExplicit && !oneShot,
287
288
  oneShot,
288
289
  }), { exitOnCtrlC: false });
289
290
  const removeTeardown = installTeardown(() => { });
@@ -91,12 +91,30 @@ export function isNotebookSeedCommand(command) {
91
91
  export function isSecretPath(relPath) {
92
92
  return SECRET_PATTERNS.some((re) => re.test(relPath));
93
93
  }
94
+ const OPAQUE_SHELL = /[<>]|\$\(|`/;
95
+ const COMMAND_SEPARATORS = /\|\||&&|[;|]/;
96
+ const MUTATING_FLAGS = {
97
+ sed: /(^|\s)-[a-zA-Z]*i/,
98
+ find: /(^|\s)-(delete|exec|execdir|ok|okdir|fls|fprint|fprintf)(\s|$)/,
99
+ env: /\S+=\S+/,
100
+ };
94
101
  export function isReadOnlyCommand(command) {
95
102
  const trimmed = command.trim();
96
- if (!trimmed || SHELL_CHAINING.test(trimmed))
103
+ if (!trimmed || OPAQUE_SHELL.test(trimmed))
97
104
  return false;
105
+ const segments = trimmed
106
+ .split(COMMAND_SEPARATORS)
107
+ .map((s) => s.trim())
108
+ .filter(Boolean);
109
+ if (segments.length === 0)
110
+ return false;
111
+ if (segments.length > 1)
112
+ return segments.every((s) => isReadOnlyCommand(s));
98
113
  const tokens = trimmed.split(/\s+/);
99
114
  const head = (tokens[0] ?? '').replace(/^.*\//, '');
115
+ const mutating = MUTATING_FLAGS[head];
116
+ if (mutating && mutating.test(trimmed.slice(head.length)))
117
+ return false;
100
118
  if (READ_ONLY_COMMANDS.has(head))
101
119
  return true;
102
120
  const subcommands = READ_ONLY_SUBCOMMANDS[head];
@@ -160,7 +178,7 @@ export function decide(input) {
160
178
  if (isNotebookSeedCommand(cmd) && mode !== 'plan') {
161
179
  return { verdict: 'allow' };
162
180
  }
163
- if (mode === 'acceptEdits' && isReadOnlyCommand(cmd)) {
181
+ if (mode !== 'plan' && isReadOnlyCommand(cmd)) {
164
182
  return { verdict: 'allow' };
165
183
  }
166
184
  if (mode === 'plan') {
package/dist/ui/App.js CHANGED
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
13
  import { Box, Static, Text, useApp, useInput } from 'ink';
14
14
  import { TextInput } from '@inkjs/ui';
15
- import { InsufficientCredits } from '../api/workspace.js';
15
+ import { fetchCredits, InsufficientCredits } from '../api/workspace.js';
16
16
  import { openBrowser } from '../auth/pkce.js';
17
17
  import { siteUrl } from '../config.js';
18
18
  import { TurnRunner } from '../protocol/runner.js';
@@ -20,8 +20,8 @@ import { describeMode, isBatchable, mcpServerOf, nextMode, } from '../protocol/p
20
20
  import { creditShortfall, startSession } from '../session.js';
21
21
  import { sanitizeForModel } from '../protocol/shell.js';
22
22
  import { diff as gitDiff, gitBranchLine, prCreate } from '../protocol/git.js';
23
- import { Activity, ApprovalPrompt, Banner, WORDMARK_MIN_COLUMNS, ChoicePrompt, Completions, HelpPanel, StatusBar, TranscriptLine, ShellPane, SHELL_PANE_LINES, } from './components.js';
24
- import { TokenBuffer, terminalHeight, terminalWidth } from './render.js';
23
+ import { Activity, ApprovalPrompt, Banner, ModePicker, WORDMARK_MIN_COLUMNS, ChoicePrompt, Completions, HelpPanel, StatusBar, TranscriptLine, ShellPane, SHELL_PANE_LINES, } from './components.js';
24
+ import { TokenBuffer, terminalWidth } from './render.js';
25
25
  import { color } from './theme.js';
26
26
  import { completionsFor } from './slashCommands.js';
27
27
  function trimToPane(text) {
@@ -35,9 +35,11 @@ function grantKey(category, request) {
35
35
  return category;
36
36
  return `mcp:${mcpServerOf(String(request.args?.tool ?? '')) ?? 'unknown'}`;
37
37
  }
38
- export function App({ cwd, initialMode, oneShot }) {
38
+ export function App({ cwd, initialMode, askForMode, oneShot }) {
39
39
  const { exit } = useApp();
40
- const [phase, setPhase] = useState('starting');
40
+ const [phase, setPhase] = useState(askForMode ? 'mode' : 'starting');
41
+ const sessionReadyRef = useRef(false);
42
+ const modeChosenRef = useRef(!askForMode);
41
43
  const [lines, setLines] = useState([]);
42
44
  const [live, setLive] = useState('');
43
45
  const [shellView, setShellView] = useState('');
@@ -68,6 +70,7 @@ export function App({ cwd, initialMode, oneShot }) {
68
70
  const shellDirty = useRef(false);
69
71
  const shellRef = useRef(null);
70
72
  const sessionFactsRef = useRef({ continuing: false, hasLocalMemory: false });
73
+ const creditsUrlRef = useRef(null);
71
74
  const alwaysAllow = useRef(new Set());
72
75
  const tokens = useMemo(() => new TokenBuffer((text) => {
73
76
  liveText.current += text;
@@ -77,7 +80,8 @@ export function App({ cwd, initialMode, oneShot }) {
77
80
  if (!text)
78
81
  return;
79
82
  lineId.current += 1;
80
- setLines((prev) => [...prev, { id: lineId.current, kind, text, detail }]);
83
+ const id = lineId.current;
84
+ setLines((prev) => [...prev, { id, kind, text, detail }]);
81
85
  }, []);
82
86
  const settleLive = useCallback(() => {
83
87
  tokens.flush();
@@ -87,6 +91,29 @@ export function App({ cwd, initialMode, oneShot }) {
87
91
  if (text)
88
92
  push('agent', text);
89
93
  }, [push, tokens]);
94
+ const refreshBalance = useCallback(async () => {
95
+ const url = creditsUrlRef.current;
96
+ if (!url)
97
+ return;
98
+ try {
99
+ const credits = await fetchCredits(url);
100
+ if (credits)
101
+ setStatus((s) => ({ ...s, balance: credits.balance }));
102
+ }
103
+ catch {
104
+ }
105
+ }, []);
106
+ const pickMode = useCallback((chosen) => {
107
+ setMode(chosen);
108
+ modeChosenRef.current = true;
109
+ runnerRef.current?.setMode(chosen);
110
+ setPhase(sessionReadyRef.current ? 'ready' : 'starting');
111
+ push('system', `Mode: ${describeMode(chosen)}`);
112
+ if (chosen === 'bypass') {
113
+ push('notice', 'Running without prompts. Files outside this project are still refused.');
114
+ }
115
+ push('system', 'Ask for anything. /help for commands.');
116
+ }, [push]);
90
117
  const quit = useCallback(() => {
91
118
  void runnerRef.current?.shutdown().finally(() => exit());
92
119
  }, [exit]);
@@ -148,6 +175,7 @@ export function App({ cwd, initialMode, oneShot }) {
148
175
  const branch = gitBranchLine(cwd);
149
176
  if (branch)
150
177
  push('system', branch);
178
+ void refreshBalance();
151
179
  return true;
152
180
  }
153
181
  case '/diff': {
@@ -170,7 +198,7 @@ export function App({ cwd, initialMode, oneShot }) {
170
198
  push('notice', `Unknown command ${cmd} — /help for the list.`);
171
199
  return true;
172
200
  }
173
- }, [cwd, cycleMode, mode, push, quit, status]);
201
+ }, [cwd, cycleMode, mode, push, quit, refreshBalance, status]);
174
202
  const submit = useCallback(async (message) => {
175
203
  const text = message.trim();
176
204
  setInput('');
@@ -219,10 +247,11 @@ export function App({ cwd, initialMode, oneShot }) {
219
247
  setStage('');
220
248
  setTool('');
221
249
  setPhase('ready');
250
+ void refreshBalance();
222
251
  if (oneShot)
223
252
  quit();
224
253
  }
225
- }, [oneShot, push, quit, runCommand, settleLive, status.balance]);
254
+ }, [oneShot, push, quit, refreshBalance, runCommand, settleLive, status.balance]);
226
255
  useEffect(() => {
227
256
  let cancelled = false;
228
257
  (async () => {
@@ -244,6 +273,7 @@ export function App({ cwd, initialMode, oneShot }) {
244
273
  continuing: session.continuing,
245
274
  hasLocalMemory: session.hasLocalMemory,
246
275
  };
276
+ creditsUrlRef.current = session.bootstrap.credits_url ?? null;
247
277
  void session.snapshot.then((snap) => {
248
278
  if (!cancelled)
249
279
  snapshotRef.current = snap;
@@ -329,16 +359,20 @@ export function App({ cwd, initialMode, oneShot }) {
329
359
  }
330
360
  runnerRef.current?.setMcpTools(session.mcp.listTools());
331
361
  });
332
- setPhase('ready');
333
- push('system', `${session.project}${session.bootstrap.created ? ' · new session' : ''} · ${describeMode(mode)}`);
362
+ sessionReadyRef.current = true;
363
+ setPhase(modeChosenRef.current ? 'ready' : 'mode');
364
+ runnerRef.current?.setMode(mode);
365
+ push('system', `${session.project}${session.bootstrap.created ? ' · new session' : ''}`);
334
366
  if (session.continuing) {
335
367
  push('notice', "Continuing this directory's thread — earlier turns are still in context.");
336
368
  }
337
369
  if (session.hasLocalMemory) {
338
370
  push('notice', 'Project notes found in .rivine/local_memory.md.');
339
371
  }
340
- if (!oneShot)
372
+ if (!oneShot && modeChosenRef.current && !askForMode) {
373
+ push('system', `Mode: ${describeMode(mode)}`);
341
374
  push('system', 'Ask for anything. /help for commands.');
375
+ }
342
376
  if (oneShot)
343
377
  void submit(oneShot);
344
378
  }
@@ -428,10 +462,10 @@ export function App({ cwd, initialMode, oneShot }) {
428
462
  if (phase === 'fatal') {
429
463
  return (_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: _jsx(Text, { color: color('danger'), children: fatal }) }));
430
464
  }
431
- const compactBanner = terminalHeight() < 20 || terminalWidth() < WORDMARK_MIN_COLUMNS || Boolean(oneShot);
465
+ const compactBanner = terminalWidth() < WORDMARK_MIN_COLUMNS || Boolean(oneShot);
432
466
  const matches = completionsFor(input);
433
467
  const busy = phase !== 'ready';
434
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [{ id: 'banner' }], children: (item) => _jsx(Banner, { compact: compactBanner }, item.id) }), _jsx(Static, { items: lines, children: (line) => _jsx(TranscriptLine, { line: line }, line.id) }), live ? (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: color('text'), children: live }) })) : null, shellView ? _jsx(ShellPane, { text: shellView, live: shellActive }) : null, phase === 'running' && stage ? _jsx(Activity, { stage: stage, tool: tool }) : null, phase === 'approval' && approval ? (_jsx(ApprovalPrompt, { approval: approval, allowAlways: isBatchable(approval.category), onChoice: answerApproval })) : null, phase === 'question' && question ? (question.options?.length ? (_jsx(ChoicePrompt, { prompt: question.prompt, options: question.options, onPick: answerQuestion })) : (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('accent'), bold: true, children: question.prompt }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: color('user'), children: '› ' }), _jsx(TextInput, { placeholder: "your answer", onSubmit: answerQuestion })] })] }))) : null, phase === 'hitl' && hitl ? (_jsx(ChoicePrompt, { prompt: hitl.tool ? `${hitl.tool} — ${hitl.message}` : hitl.message, options: hitl.options?.length ? hitl.options : ['Approve', 'Decline'], onPick: (value) => answerHitl(hitl.options?.length
468
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [{ id: 'banner' }], children: (item) => _jsx(Banner, { compact: compactBanner }, item.id) }), _jsx(Static, { items: lines, children: (line) => _jsx(TranscriptLine, { line: line }, line.id) }), live ? (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: color('text'), children: live }) })) : null, shellView ? _jsx(ShellPane, { text: shellView, live: shellActive }) : null, phase === 'running' && stage ? _jsx(Activity, { stage: stage, tool: tool }) : null, phase === 'mode' ? _jsx(ModePicker, { onPick: pickMode }) : null, phase === 'approval' && approval ? (_jsx(ApprovalPrompt, { approval: approval, allowAlways: isBatchable(approval.category), onChoice: answerApproval })) : null, phase === 'question' && question ? (question.options?.length ? (_jsx(ChoicePrompt, { prompt: question.prompt, options: question.options, onPick: answerQuestion })) : (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('accent'), bold: true, children: question.prompt }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: color('user'), children: '› ' }), _jsx(TextInput, { placeholder: "your answer", onSubmit: answerQuestion })] })] }))) : null, phase === 'hitl' && hitl ? (_jsx(ChoicePrompt, { prompt: hitl.tool ? `${hitl.tool} — ${hitl.message}` : hitl.message, options: hitl.options?.length ? hitl.options : ['Approve', 'Decline'], onPick: (value) => answerHitl(hitl.options?.length
435
469
  ? { approved: true, feedback: value }
436
470
  : { approved: value === 'Approve' }) })) : null, showHelp && phase === 'ready' ? _jsx(HelpPanel, {}) : null, phase === 'ready' && !oneShot ? (_jsxs(_Fragment, { children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: color('user'), children: '› ' }), _jsx(TextInput, { placeholder: "What should Rivine build?", onChange: setInput, onSubmit: (value) => void submit(value) })] }), _jsx(Completions, { matches: matches })] })) : null, phase === 'starting' ? _jsx(Activity, { stage: "Starting\u2026", tool: "" }) : null, _jsx(StatusBar, { info: {
437
471
  mode,
@@ -39,6 +39,25 @@ export function Activity({ stage, tool }) {
39
39
  const label = tool ? `${stage} · ${tool}` : stage;
40
40
  return (_jsx(Box, { paddingX: 1, children: _jsx(Spinner, { label: fit(label, width - 6) }) }));
41
41
  }
42
+ export function ModePicker({ onPick }) {
43
+ const width = terminalWidth();
44
+ const options = [
45
+ { key: '1', mode: 'plan', label: 'Plan', detail: 'reads and answers, changes nothing' },
46
+ { key: '2', mode: 'default', label: 'Ask me', detail: 'approve each write and command' },
47
+ { key: '3', mode: 'acceptEdits', label: 'Accept edits', detail: 'writes apply, commands still ask' },
48
+ { key: '4', mode: 'bypass', label: 'Full speed', detail: 'no prompts inside this project' },
49
+ ];
50
+ useInput((input, key) => {
51
+ if (key.return) {
52
+ onPick('acceptEdits');
53
+ return;
54
+ }
55
+ const found = options.find((o) => o.key === input);
56
+ if (found)
57
+ onPick(found.mode);
58
+ });
59
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: color('text'), bold: true, children: "How should Rivine work in this project?" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: options.map((o) => (_jsxs(Box, { children: [_jsx(Text, { color: color('accent'), children: ` ${o.key}` }), _jsx(Text, { color: color('faint'), children: " \u00B7 " }), _jsx(Text, { color: color('text'), children: o.label.padEnd(13) }), _jsx(Text, { color: color('muted'), children: fit(o.detail, Math.max(10, width - 24)) })] }, o.key))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: color('faint'), children: fit('press a number · enter for accept edits · shift+tab changes it later', width - 4) }) })] }));
60
+ }
42
61
  export const SHELL_PANE_LINES = 12;
43
62
  export function ShellPane({ text, live }) {
44
63
  if (!text)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beunec/rivine",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Rivine Code in your terminal — Beunec's software engineering runtime constructs, working on the files you already have open.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Beunec Technologies",