diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx
index 6576ff7..1ff9754 100644
--- a/docs/cli/overview.mdx
+++ b/docs/cli/overview.mdx
@@ -11,6 +11,29 @@ npm install -g timberlogs-cli
**Requirements:** Node.js 20 or later.
+## REPL
+
+Running `timberlogs` with no arguments launches an interactive REPL session — a persistent prompt where you can run any command without reinvoking the binary.
+
+```bash
+timberlogs
+```
+
+```
+▲ Timberlogs v1.0.0 ● Acme Corp
+logs stats flows flows show whoami login logout config list help exit
+────────────────────────────────────────────────────────────
+❯ logs --level error --from 24h
+❯ stats --group-by source
+❯ flows show checkout-abc-123
+```
+
+Commands work exactly like the CLI — same flags, same output. Commands that produce a table (`logs`, `stats`, `flows show`) take over the full screen with keyboard navigation; press `q` to return to the prompt.
+
+**Builtins:** `help`, `clear`, `exit`
+
+**History:** Use `↑` / `↓` to navigate previous commands. History is saved to `~/.config/timberlogs/history.json`.
+
## Authentication
The CLI uses OAuth device flow — no API keys needed.
@@ -106,8 +129,11 @@ timberlogs logs --level error | jq '.logs[].message'
## Config
```bash
-timberlogs config list # Show current config
-timberlogs config reset # Delete config file
+timberlogs config list # Show current config
+timberlogs config reset # Delete config file (prompts for confirmation)
+timberlogs config reset --force # Delete without confirmation
```
+When running inside the REPL, `config reset` requires `--force` — interactive confirmation prompts are not supported in the REPL session.
+
Session data is stored at `~/.config/timberlogs/config.json` with `600` permissions. Override the directory with the `TIMBERLOGS_CONFIG_DIR` environment variable.
diff --git a/src/commands/index.tsx b/src/commands/index.tsx
index a966f8a..b5522ab 100644
--- a/src/commands/index.tsx
+++ b/src/commands/index.tsx
@@ -1,28 +1,5 @@
-import {Text, Box} from 'ink';
+import ReplView from '../components/ReplView.js';
export default function Index() {
- return (
-
- Timberlogs CLI v{CLI_VERSION}
-
- Usage: timberlogs {''} [options]
-
- Commands:
- login Authenticate via browser
- logout Remove stored session
- whoami Show current auth status
- logs Query and search logs
- flows List all flows
- flows show {''} View a flow timeline
- stats Show log volume and distribution
- config list Show current config
- config reset Delete config file
-
- Global Flags:
- --json Force JSON output
- --verbose Show debug info
- --version, -v Show version
- --help, -h Show help
-
- );
+ return ;
}
diff --git a/src/commands/login.tsx b/src/commands/login.tsx
index 9e41d77..c87904e 100644
--- a/src/commands/login.tsx
+++ b/src/commands/login.tsx
@@ -1,7 +1,7 @@
import {Text, Box} from 'ink';
import Spinner from 'ink-spinner';
import {useState, useEffect} from 'react';
-import {exec} from 'node:child_process';
+import {spawn} from 'node:child_process';
import {z} from 'zod';
import {readConfig, writeConfig} from '../lib/config.js';
import {handleError} from '../lib/errors.js';
@@ -34,16 +34,13 @@ type TokenErrorResponse = {
const POLL_INTERVAL_MS = 5000;
function openBrowser(url: string): void {
- const command =
- process.platform === 'darwin'
- ? `open "${url}"`
- : process.platform === 'win32'
- ? `start "${url}"`
- : `xdg-open "${url}"`;
-
- exec(command, () => {
- // Ignore errors — user can open URL manually
- });
+ if (process.platform === 'win32') {
+ spawn('cmd', ['/c', 'start', '', url], {windowsHide: true, detached: true, stdio: 'ignore'});
+ } else if (process.platform === 'darwin') {
+ spawn('open', [url], {detached: true, stdio: 'ignore'});
+ } else {
+ spawn('xdg-open', [url], {detached: true, stdio: 'ignore'});
+ }
}
export default function Login({options}: Props) {
diff --git a/src/commands/repl.tsx b/src/commands/repl.tsx
new file mode 100644
index 0000000..e8bae09
--- /dev/null
+++ b/src/commands/repl.tsx
@@ -0,0 +1,5 @@
+import ReplView from '../components/ReplView.js';
+
+export default function Repl() {
+ return ;
+}
diff --git a/src/components/FlowTimeline.tsx b/src/components/FlowTimeline.tsx
index 43063f9..3b2d8a4 100644
--- a/src/components/FlowTimeline.tsx
+++ b/src/components/FlowTimeline.tsx
@@ -8,6 +8,7 @@ type Props = {
stepCount: number;
durationMs: number;
hasErrors: boolean;
+ onBack?: () => void;
};
function formatDuration(ms: number): string {
@@ -26,10 +27,11 @@ function formatDataInline(data: Record | undefined, maxWidth: n
return result.length > maxWidth ? result.slice(0, maxWidth - 1) + '…' : result;
}
-export default function FlowTimeline({flowId, logs, stepCount, durationMs, hasErrors}: Props) {
+export default function FlowTimeline({flowId, logs, stepCount, durationMs, hasErrors, onBack}: Props) {
useInput((input) => {
if (input === 'q') {
- process.exit(0);
+ if (onBack) onBack();
+ else process.exit(0);
}
});
diff --git a/src/components/LogTable.tsx b/src/components/LogTable.tsx
index 7501b9a..e3aea65 100644
--- a/src/components/LogTable.tsx
+++ b/src/components/LogTable.tsx
@@ -8,9 +8,10 @@ type Props = {
logs: LogEntry[];
pagination: LogsResponse['pagination'];
filterSummary?: string;
+ onBack?: () => void;
};
-export default function LogTable({logs, pagination, filterSummary}: Props) {
+export default function LogTable({logs, pagination, filterSummary, onBack}: Props) {
const [cursor, setCursor] = useState(0);
const [expanded, setExpanded] = useState(null);
@@ -33,7 +34,8 @@ export default function LogTable({logs, pagination, filterSummary}: Props) {
} else if (key.return) {
setExpanded(cursor);
} else if (input === 'q') {
- process.exit(0);
+ if (onBack) onBack();
+ else process.exit(0);
}
});
diff --git a/src/components/ReplOutput.tsx b/src/components/ReplOutput.tsx
new file mode 100644
index 0000000..96b8dcc
--- /dev/null
+++ b/src/components/ReplOutput.tsx
@@ -0,0 +1,19 @@
+import {Box, Text} from 'ink';
+import type {ReactNode} from 'react';
+
+export type ReplEntry =
+ | {kind: 'command'; input: string}
+ | {kind: 'output'; content: ReactNode}
+ | {kind: 'error'; message: string};
+
+export default function ReplOutput({entry}: {entry: ReplEntry}) {
+ if (entry.kind === 'command') {
+ return ❯ {entry.input};
+ }
+
+ if (entry.kind === 'error') {
+ return ✗ {entry.message};
+ }
+
+ return {entry.content as ReactNode};
+}
diff --git a/src/components/ReplPrompt.tsx b/src/components/ReplPrompt.tsx
new file mode 100644
index 0000000..b51ebf0
--- /dev/null
+++ b/src/components/ReplPrompt.tsx
@@ -0,0 +1,67 @@
+import {Box, Text, useInput} from 'ink';
+import TextInput from 'ink-text-input';
+import {useState} from 'react';
+
+type Props = {
+ history: string[];
+ onSubmit: (input: string) => void;
+ disabled: boolean;
+};
+
+export default function ReplPrompt({history, onSubmit, disabled}: Props) {
+ const [value, setValue] = useState('');
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const [draft, setDraft] = useState('');
+
+ useInput((_, key) => {
+ if (disabled) return;
+ if (key.upArrow) {
+ if (history.length === 0) return;
+ if (historyIndex === -1) {
+ setDraft(value);
+ const idx = history.length - 1;
+ setHistoryIndex(idx);
+ setValue(history[idx]!);
+ } else if (historyIndex > 0) {
+ const idx = historyIndex - 1;
+ setHistoryIndex(idx);
+ setValue(history[idx]!);
+ }
+ } else if (key.downArrow) {
+ if (historyIndex === -1) return;
+ if (historyIndex < history.length - 1) {
+ const idx = historyIndex + 1;
+ setHistoryIndex(idx);
+ setValue(history[idx]!);
+ } else {
+ setHistoryIndex(-1);
+ setValue(draft);
+ }
+ }
+ });
+
+ function handleChange(val: string) {
+ if (disabled) return;
+ setValue(val);
+ if (historyIndex !== -1) setHistoryIndex(-1);
+ }
+
+ function handleSubmit(val: string) {
+ if (disabled) return;
+ setValue('');
+ setHistoryIndex(-1);
+ setDraft('');
+ onSubmit(val);
+ }
+
+ return (
+
+ ❯
+ {disabled ? (
+ ...
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/ReplView.tsx b/src/components/ReplView.tsx
new file mode 100644
index 0000000..2b4ef2a
--- /dev/null
+++ b/src/components/ReplView.tsx
@@ -0,0 +1,287 @@
+import {Box, Text} from 'ink';
+import {useState, useEffect, useCallback} from 'react';
+import type {ReactNode} from 'react';
+import {loadHistory, saveHistory} from '../lib/history.js';
+import {parseReplInput} from '../lib/replParser.js';
+import {parseFlags} from '../lib/flagParser.js';
+import {resolveToken} from '../lib/auth.js';
+import {createApiClient} from '../lib/api.js';
+import {readConfig, writeConfig, deleteConfig} from '../lib/config.js';
+import {options as logsOptions} from '../commands/logs.js';
+import {options as statsOptions} from '../commands/stats.js';
+import {options as flowsOptions} from '../commands/flows/index.js';
+import {options as flowShowOptions} from '../commands/flows/show.js';
+import ReplOutput from './ReplOutput.js';
+import ReplPrompt from './ReplPrompt.js';
+import WhoamiView from './commandViews/WhoamiView.js';
+import FlowsView from './commandViews/FlowsView.js';
+import StatsViewCmd from './commandViews/StatsViewCmd.js';
+import LogsView from './commandViews/LogsView.js';
+import FlowShowView from './commandViews/FlowShowView.js';
+import LoginView from './commandViews/LoginView.js';
+import type {ReplEntry} from './ReplOutput.js';
+
+const COMMANDS_HINT = 'logs stats flows flows show whoami login logout config list help exit';
+
+function ReplHeader({token, orgName}: {token: string | null; orgName: string | null}) {
+ const cols = process.stdout.columns || 80;
+ return (
+
+
+
+ ▲
+ Timberlogs
+ v{CLI_VERSION}
+
+ {token ? (
+ ● {orgName ?? 'Authenticated'}
+ ) : (
+ ● Not logged in
+ )}
+
+ {COMMANDS_HINT}
+ {'─'.repeat(Math.min(cols, 60))}
+
+ );
+}
+
+type Phase =
+ | {tag: 'idle'}
+ | {tag: 'running'; node: ReactNode}
+ | {tag: 'interactive'; node: ReactNode};
+
+const MAX_ENTRIES = 200;
+
+const HELP_LINES = [
+ 'Commands:',
+ ' logs [flags] Query and search logs',
+ ' stats [flags] Show log volume and stats',
+ ' flows [flags] List flows',
+ ' flows show View a flow timeline',
+ ' whoami Show auth status',
+ ' login Authenticate via browser',
+ ' logout Remove stored session',
+ ' config list Show config',
+ ' config reset [--force] Reset config',
+ '',
+ 'Builtins: help, clear, exit',
+ 'History: ↑↓ navigate',
+];
+
+export default function ReplView() {
+ const [entries, setEntries] = useState([]);
+ const [phase, setPhase] = useState({tag: 'idle'});
+ const [history, setHistory] = useState([]);
+ const [token, setToken] = useState(null);
+ const [orgName, setOrgName] = useState(null);
+
+ const fetchOrgName = useCallback(async (t: string) => {
+ try {
+ const client = createApiClient({token: t});
+ const whoami = await client.get<{organizationName?: string}>('/v1/whoami');
+ if (whoami.organizationName) setOrgName(whoami.organizationName);
+ } catch {
+ // silently fail — header shows "Authenticated" fallback
+ }
+ }, []);
+
+ useEffect(() => {
+ setHistory(loadHistory());
+ setToken(resolveToken());
+ }, []);
+
+ useEffect(() => {
+ if (!token) { setOrgName(null); return; }
+ void fetchOrgName(token);
+ }, [token]);
+
+ function addEntry(entry: ReplEntry) {
+ setEntries(prev => [...prev, entry].slice(-MAX_ENTRIES));
+ }
+
+ function handleSubmit(input: string) {
+ const trimmed = input.trim();
+ if (!trimmed) return;
+
+ const newHistory = history[history.length - 1] === trimmed
+ ? history
+ : [...history, trimmed];
+ setHistory(newHistory);
+ saveHistory(newHistory);
+
+ const parsed = parseReplInput(trimmed);
+
+ if (parsed.type === 'empty') return;
+
+ addEntry({kind: 'command', input: trimmed});
+
+ if (parsed.type === 'builtin') {
+ handleBuiltin(parsed.name);
+ return;
+ }
+
+ if (parsed.type === 'unknown') {
+ addEntry({kind: 'error', message: `Unknown command: ${parsed.input}. Type "help" for available commands.`});
+ return;
+ }
+
+ dispatch(parsed.name, parsed.tokens);
+ }
+
+ function handleBuiltin(name: 'help' | 'exit' | 'clear') {
+ if (name === 'clear') {
+ setEntries([]);
+ return;
+ }
+
+ if (name === 'exit') {
+ process.exit(0);
+ }
+
+ addEntry({
+ kind: 'output',
+ content: (
+
+ {HELP_LINES.map((line, i) => (
+ {line}
+ ))}
+
+ ),
+ });
+ }
+
+ function dispatch(name: string, tokens: string[]) {
+ const onDone = (output: ReactNode, interactive: boolean) => {
+ const newToken = resolveToken();
+ setToken(newToken);
+ if (interactive) {
+ setPhase({tag: 'interactive', node: output});
+ } else {
+ if (output !== null) {
+ addEntry({kind: 'output', content: output});
+ }
+ setPhase({tag: 'idle'});
+ }
+ };
+
+ const onError = (message: string) => {
+ setToken(resolveToken());
+ addEntry({kind: 'error', message});
+ setPhase({tag: 'idle'});
+ };
+
+ const onBack = () => setPhase({tag: 'idle'});
+
+ if (name === 'login') {
+ setPhase({tag: 'running', node: });
+ return;
+ }
+
+ if (name === 'logout') {
+ const config = readConfig();
+ if (config.sessionToken) {
+ delete config.sessionToken;
+ writeConfig(config);
+ setToken(null);
+ addEntry({kind: 'output', content: ✓ Logged out});
+ } else {
+ addEntry({kind: 'output', content: Not logged in});
+ }
+
+ return;
+ }
+
+ if (name === 'config list') {
+ const config = readConfig();
+ addEntry({
+ kind: 'output',
+ content: (
+
+ {'Authenticated: '}
+
+ {config.sessionToken ? 'Yes' : 'No'}
+
+
+ ),
+ });
+ return;
+ }
+
+ if (name === 'config reset') {
+ if (!tokens.includes('--force')) {
+ addEntry({kind: 'error', message: 'Use `config reset --force` to confirm.'});
+ return;
+ }
+
+ deleteConfig();
+ setToken(null);
+ addEntry({kind: 'output', content: ✓ Config reset successfully});
+ return;
+ }
+
+ if (!token) {
+ addEntry({kind: 'error', message: 'Not authenticated. Run `login` to get started.'});
+ return;
+ }
+
+ if (name === 'whoami') {
+ setPhase({tag: 'running', node: });
+ return;
+ }
+
+ if (name === 'logs') {
+ const result = parseFlags(tokens, logsOptions);
+ if (result.error) { addEntry({kind: 'error', message: result.error}); return; }
+ const flags = result.flags as Parameters[0]['flags'];
+ setPhase({tag: 'running', node: });
+ return;
+ }
+
+ if (name === 'stats') {
+ const result = parseFlags(tokens, statsOptions);
+ if (result.error) { addEntry({kind: 'error', message: result.error}); return; }
+ const flags = result.flags as Parameters[0]['flags'];
+ setPhase({tag: 'running', node: });
+ return;
+ }
+
+ if (name === 'flows') {
+ const result = parseFlags(tokens, flowsOptions);
+ if (result.error) { addEntry({kind: 'error', message: result.error}); return; }
+ const flags = result.flags as Parameters[0]['flags'];
+ setPhase({tag: 'running', node: });
+ return;
+ }
+
+ if (name === 'flows show') {
+ const result = parseFlags(tokens, flowShowOptions);
+ if (result.error) { addEntry({kind: 'error', message: result.error}); return; }
+ const flowId = result.positional[0];
+ if (!flowId) {
+ addEntry({kind: 'error', message: 'Usage: flows show '});
+ return;
+ }
+
+ setPhase({tag: 'running', node: });
+ return;
+ }
+ }
+
+ if (phase.tag === 'interactive') {
+ return <>{phase.node}>;
+ }
+
+ const rows = process.stdout.rows || 24;
+ const visibleEntries = entries.slice(-(rows - 3));
+
+ return (
+
+
+ {visibleEntries.map((entry, i) => (
+
+ ))}
+ {phase.tag === 'running' && phase.node}
+
+
+ );
+}
diff --git a/src/components/StatsView.tsx b/src/components/StatsView.tsx
index 385babc..1a0f19d 100644
--- a/src/components/StatsView.tsx
+++ b/src/components/StatsView.tsx
@@ -25,6 +25,7 @@ type Props = {
warn: number;
error: number;
}>;
+ onBack?: () => void;
};
function formatNumber(n: number): string {
@@ -37,10 +38,11 @@ function renderBar(value: number, total: number, maxWidth: number): string {
return '█'.repeat(filled) + '░'.repeat(maxWidth - filled);
}
-export default function StatsView({totals, period, comparison, groupBySource}: Props) {
+export default function StatsView({totals, period, comparison, groupBySource, onBack}: Props) {
useInput((input) => {
if (input === 'q') {
- process.exit(0);
+ if (onBack) onBack();
+ else process.exit(0);
}
});
diff --git a/src/components/commandViews/FlowShowView.tsx b/src/components/commandViews/FlowShowView.tsx
new file mode 100644
index 0000000..5590d6d
--- /dev/null
+++ b/src/components/commandViews/FlowShowView.tsx
@@ -0,0 +1,55 @@
+import {useEffect} from 'react';
+import type {ReactNode} from 'react';
+import {createApiClient} from '../../lib/api.js';
+import {LogsResponseSchema} from '../../types/log.js';
+import FlowTimeline from '../FlowTimeline.js';
+
+type Props = {
+ flowId: string;
+ token: string;
+ onBack: () => void;
+ onDone: (output: ReactNode, interactive: boolean) => void;
+ onError: (message: string) => void;
+};
+
+export default function FlowShowView({flowId, token, onBack, onDone, onError}: Props) {
+ useEffect(() => {
+ void run();
+ }, []);
+
+ async function run() {
+ try {
+ const client = createApiClient({token});
+ const raw = await client.get('/v1/logs', {flowId, limit: 1000});
+ const response = LogsResponseSchema.parse(raw);
+ const logs = response.logs;
+
+ if (logs.length === 0) {
+ onError(`No logs found for flow ${flowId}`);
+ return;
+ }
+
+ const stepCount = logs.length;
+ const durationMs = logs.length > 1
+ ? new Date(logs[logs.length - 1]!.timestamp).getTime() - new Date(logs[0]!.timestamp).getTime()
+ : 0;
+ const hasErrors = logs.some(l => l.level === 'error');
+
+ onDone(
+ ,
+ true,
+ );
+ } catch (err) {
+ onError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ return null;
+}
diff --git a/src/components/commandViews/FlowsView.tsx b/src/components/commandViews/FlowsView.tsx
new file mode 100644
index 0000000..e5bf18b
--- /dev/null
+++ b/src/components/commandViews/FlowsView.tsx
@@ -0,0 +1,111 @@
+import {useEffect} from 'react';
+import type {ReactNode} from 'react';
+import {Text, Box} from 'ink';
+import {z} from 'zod';
+import {createApiClient} from '../../lib/api.js';
+import {parseRelativeTime} from '../../lib/time.js';
+
+const FlowSummarySchema = z.object({
+ flowId: z.string(),
+ source: z.string().nullish(),
+ logCount: z.number(),
+ firstSeen: z.number(),
+ lastSeen: z.number(),
+});
+
+const FlowsResponseSchema = z.object({
+ flows: z.array(FlowSummarySchema),
+ pagination: z.object({
+ total: z.number().optional(),
+ offset: z.number(),
+ limit: z.number(),
+ hasMore: z.boolean(),
+ }),
+});
+
+type FlowsFlags = {
+ from: string;
+ to?: string;
+ source?: string;
+ limit: number;
+ offset: number;
+};
+
+type Props = {
+ flags: FlowsFlags;
+ token: string;
+ onDone: (output: ReactNode, interactive: boolean) => void;
+ onError: (message: string) => void;
+};
+
+function formatRelativeTime(timestamp: number): string {
+ const diff = Date.now() - timestamp;
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+function getFlowName(flowId: string): string {
+ const lastDash = flowId.lastIndexOf('-');
+ return lastDash > 0 ? flowId.slice(0, lastDash) : flowId;
+}
+
+export default function FlowsView({flags, token, onDone, onError}: Props) {
+ useEffect(() => {
+ void run();
+ }, []);
+
+ async function run() {
+ try {
+ const client = createApiClient({token});
+ const from = parseRelativeTime(flags.from);
+ const to = flags.to ? parseRelativeTime(flags.to) : undefined;
+ const raw = await client.get('/v1/flows', {
+ from,
+ to,
+ source: flags.source,
+ limit: flags.limit,
+ offset: flags.offset > 0 ? flags.offset : undefined,
+ });
+ const data = FlowsResponseSchema.parse(raw);
+
+ if (data.flows.length === 0) {
+ onDone(No flows found, false);
+ return;
+ }
+
+ const nameW = 24;
+ const logsW = 6;
+ const sourceW = 16;
+ const timeW = 10;
+
+ onDone(
+
+ Flows{data.pagination.total != null ? ` (${data.pagination.total} total)` : ''}
+ {'NAME'.padEnd(nameW)}{'LOGS'.padEnd(logsW)}{'SOURCE'.padEnd(sourceW)}{'LAST SEEN'}
+ {data.flows.map(flow => (
+
+ {getFlowName(flow.flowId).slice(0, nameW - 1).padEnd(nameW)}
+ {String(flow.logCount).padEnd(logsW)}
+ {(flow.source ?? '-').slice(0, sourceW - 1).padEnd(sourceW)}
+ {formatRelativeTime(flow.lastSeen).padEnd(timeW)}
+
+ ))}
+ {data.pagination.hasMore ? (
+ Use --offset {flags.offset + flags.limit} to see more
+ ) : null}
+ ,
+ false,
+ );
+ } catch (err) {
+ onError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ return null;
+}
diff --git a/src/components/commandViews/LoginView.tsx b/src/components/commandViews/LoginView.tsx
new file mode 100644
index 0000000..5a31ee7
--- /dev/null
+++ b/src/components/commandViews/LoginView.tsx
@@ -0,0 +1,144 @@
+import {Text, Box} from 'ink';
+import Spinner from 'ink-spinner';
+import {useState, useEffect} from 'react';
+import type {ReactNode} from 'react';
+import {spawn} from 'node:child_process';
+import {readConfig, writeConfig} from '../../lib/config.js';
+import {API_URL} from '../../types/config.js';
+
+type DeviceResponse = {
+ device_code: string;
+ user_code: string;
+ verification_uri: string;
+};
+
+type TokenResponse = {
+ access_token: string;
+ organization_name?: string;
+};
+
+type TokenErrorResponse = {
+ error: string;
+ error_description?: string;
+};
+
+const POLL_INTERVAL_MS = 5000;
+
+function openBrowser(url: string): void {
+ if (process.platform === 'win32') {
+ spawn('cmd', ['/c', 'start', '', url], {windowsHide: true, detached: true, stdio: 'ignore'});
+ } else if (process.platform === 'darwin') {
+ spawn('open', [url], {detached: true, stdio: 'ignore'});
+ } else {
+ spawn('xdg-open', [url], {detached: true, stdio: 'ignore'});
+ }
+}
+
+type Props = {
+ onDone: (output: ReactNode, interactive: boolean) => void;
+ onError: (message: string) => void;
+};
+
+export default function LoginView({onDone, onError}: Props) {
+ const [status, setStatus] = useState<'init' | 'polling' | 'done'>('init');
+ const [userCode, setUserCode] = useState('');
+
+ useEffect(() => {
+ void startDeviceFlow();
+ }, []);
+
+ async function startDeviceFlow() {
+ let device: DeviceResponse;
+ try {
+ const response = await fetch(`${API_URL}/v1/auth/device`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ });
+ if (!response.ok) {
+ const body = await response.text();
+ throw new Error(`Failed to start device flow: ${response.status} ${body}`);
+ }
+
+ device = (await response.json()) as DeviceResponse;
+ } catch (err) {
+ onError(err instanceof Error ? err.message : String(err));
+ return;
+ }
+
+ setUserCode(device.user_code);
+ setStatus('polling');
+
+ const verificationUrl = `${device.verification_uri}?code=${device.user_code}`;
+ openBrowser(verificationUrl);
+
+ void pollForToken(device.device_code);
+ }
+
+ async function pollForToken(deviceCode: string) {
+ while (true) {
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
+
+ let response: Response;
+ try {
+ response = await fetch(`${API_URL}/v1/auth/device/token`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({device_code: deviceCode}),
+ });
+ } catch (err) {
+ onError(err instanceof Error ? err.message : String(err));
+ return;
+ }
+
+ if (response.ok) {
+ const token = (await response.json()) as TokenResponse;
+ const config = readConfig();
+ config.sessionToken = token.access_token;
+ writeConfig(config);
+ setStatus('done');
+ onDone(
+
+ ✓ Authenticated successfully
+ {token.organization_name ? (
+ ✓ Organization: {token.organization_name}
+ ) : null}
+ ,
+ false,
+ );
+ return;
+ }
+
+ let errorBody: TokenErrorResponse;
+ try {
+ errorBody = (await response.json()) as TokenErrorResponse;
+ } catch {
+ onError(`Unexpected response: ${response.status}`);
+ return;
+ }
+
+ if (errorBody.error === 'authorization_pending') continue;
+
+ if (errorBody.error === 'expired_token') {
+ onError('Device code expired. Run `login` again.');
+ return;
+ }
+
+ onError(errorBody.error_description ?? errorBody.error);
+ return;
+ }
+ }
+
+ if (status === 'init') return null;
+ if (status === 'done') return null;
+
+ return (
+
+
+
+ {' '}Waiting for browser authentication...
+
+ Your code: {userCode}
+ If the browser didn't open, visit the URL and enter the code above.
+
+ );
+}
diff --git a/src/components/commandViews/LogsView.tsx b/src/components/commandViews/LogsView.tsx
new file mode 100644
index 0000000..2c3be1a
--- /dev/null
+++ b/src/components/commandViews/LogsView.tsx
@@ -0,0 +1,88 @@
+import {useEffect} from 'react';
+import type {ReactNode} from 'react';
+import {createApiClient} from '../../lib/api.js';
+import {parseRelativeTime} from '../../lib/time.js';
+import {LogsResponseSchema} from '../../types/log.js';
+import LogTable from '../LogTable.js';
+
+type LogsFlags = {
+ level?: 'debug' | 'info' | 'warn' | 'error';
+ source?: string;
+ env?: string;
+ search?: string;
+ from: string;
+ to?: string;
+ limit: number;
+ offset: number;
+ 'user-id'?: string;
+ 'session-id'?: string;
+ 'flow-id'?: string;
+ dataset?: string;
+ verbose?: boolean;
+};
+
+type Props = {
+ flags: LogsFlags;
+ token: string;
+ onBack: () => void;
+ onDone: (output: ReactNode, interactive: boolean) => void;
+ onError: (message: string) => void;
+};
+
+export default function LogsView({flags, token, onBack, onDone, onError}: Props) {
+ useEffect(() => {
+ void run();
+ }, []);
+
+ async function run() {
+ try {
+ const client = createApiClient({token, verbose: flags.verbose});
+ const from = parseRelativeTime(flags.from);
+ const to = flags.to ? parseRelativeTime(flags.to) : undefined;
+
+ const params: Record = {
+ level: flags.level,
+ source: flags.source,
+ environment: flags.env,
+ from,
+ to,
+ limit: flags.limit,
+ offset: flags.offset > 0 ? flags.offset : undefined,
+ userId: flags['user-id'],
+ sessionId: flags['session-id'],
+ flowId: flags['flow-id'],
+ dataset: flags.dataset,
+ };
+
+ let raw: unknown;
+ if (flags.search) {
+ raw = await client.get('/v1/logs/search', {q: flags.search, ...params});
+ } else {
+ raw = await client.get('/v1/logs', params);
+ }
+
+ const response = LogsResponseSchema.parse(raw);
+
+ const filterParts: string[] = [];
+ if (flags.level) filterParts.push(`level=${flags.level}`);
+ if (flags.source) filterParts.push(`source=${flags.source}`);
+ if (flags.env) filterParts.push(`env=${flags.env}`);
+ if (flags.search) filterParts.push(`search="${flags.search}"`);
+ const filterSummary = filterParts.length > 0 ? `(${filterParts.join(', ')})` : undefined;
+
+ onDone(
+ ,
+ true,
+ );
+ } catch (err) {
+ onError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ return null;
+}
diff --git a/src/components/commandViews/StatsViewCmd.tsx b/src/components/commandViews/StatsViewCmd.tsx
new file mode 100644
index 0000000..1edb28c
--- /dev/null
+++ b/src/components/commandViews/StatsViewCmd.tsx
@@ -0,0 +1,96 @@
+import {useEffect} from 'react';
+import type {ReactNode} from 'react';
+import {createApiClient} from '../../lib/api.js';
+import {parseRelativeTime} from '../../lib/time.js';
+import {StatsResponseSchema, StatsSummaryResponseSchema} from '../../types/log.js';
+import StatsView from '../StatsView.js';
+
+type StatsFlags = {
+ from: string;
+ to?: string;
+ 'group-by': 'hour' | 'day' | 'source';
+ source?: string;
+ env?: string;
+ dataset?: string;
+};
+
+type Props = {
+ flags: StatsFlags;
+ token: string;
+ onBack: () => void;
+ onDone: (output: ReactNode, interactive: boolean) => void;
+ onError: (message: string) => void;
+};
+
+function toDateString(ms: number): string {
+ return new Date(ms).toISOString().slice(0, 10);
+}
+
+export default function StatsViewCmd({flags, token, onBack, onDone, onError}: Props) {
+ useEffect(() => {
+ void run();
+ }, []);
+
+ async function run() {
+ try {
+ const client = createApiClient({token});
+ const fromMs = parseRelativeTime(flags.from);
+ const toMs = flags.to ? parseRelativeTime(flags.to) : Date.now();
+ const fromDate = toDateString(fromMs);
+ const toDate = toDateString(toMs);
+
+ const [statsResponse, summaryResponse] = await Promise.all([
+ client.get('/v1/stats', {
+ from: fromDate,
+ to: toDate,
+ groupBy: flags['group-by'],
+ source: flags.source,
+ environment: flags.env,
+ dataset: flags.dataset,
+ }).then(raw => StatsResponseSchema.parse(raw)),
+ client.get('/v1/stats/summary')
+ .then(raw => StatsSummaryResponseSchema.parse(raw))
+ .catch(() => null),
+ ]);
+
+ const totals = {
+ debug: (statsResponse.totals?.['debug'] as number) ?? 0,
+ info: (statsResponse.totals?.['info'] as number) ?? 0,
+ warn: (statsResponse.totals?.['warn'] as number) ?? 0,
+ error: (statsResponse.totals?.['error'] as number) ?? 0,
+ total: (statsResponse.totals?.['total'] as number) ?? 0,
+ };
+
+ let comparison: {yesterdayTotal: number; changePercent: number; trend: 'up' | 'down' | 'stable'} | undefined;
+ if (summaryResponse?.today !== undefined && summaryResponse?.yesterday !== undefined) {
+ const yesterdayTotal = summaryResponse.yesterday;
+ const todayTotal = summaryResponse.today;
+ const changePercent = yesterdayTotal > 0
+ ? ((todayTotal - yesterdayTotal) / yesterdayTotal) * 100
+ : 0;
+ const trend = changePercent > 1 ? 'up' as const : changePercent < -1 ? 'down' as const : 'stable' as const;
+ comparison = {yesterdayTotal, changePercent, trend};
+ }
+
+ let groupBySource: Array<{source: string; total: number; debug: number; info: number; warn: number; error: number}> | undefined;
+ if (flags['group-by'] === 'source' && Array.isArray(statsResponse.stats)) {
+ groupBySource = statsResponse.stats as typeof groupBySource;
+ }
+
+ onDone(
+ ,
+ true,
+ );
+ } catch (err) {
+ onError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ return null;
+}
diff --git a/src/components/commandViews/WhoamiView.tsx b/src/components/commandViews/WhoamiView.tsx
new file mode 100644
index 0000000..569e5e5
--- /dev/null
+++ b/src/components/commandViews/WhoamiView.tsx
@@ -0,0 +1,36 @@
+import {useEffect} from 'react';
+import type {ReactNode} from 'react';
+import {Text, Box} from 'ink';
+import {createApiClient} from '../../lib/api.js';
+
+type Props = {
+ token: string;
+ onDone: (output: ReactNode, interactive: boolean) => void;
+ onError: (message: string) => void;
+};
+
+export default function WhoamiView({token, onDone, onError}: Props) {
+ useEffect(() => {
+ void run();
+ }, []);
+
+ async function run() {
+ try {
+ const client = createApiClient({token});
+ const whoami = await client.get<{organizationName?: string}>('/v1/whoami');
+ onDone(
+
+ {'Status: '}Authenticated
+ {whoami.organizationName ? (
+ {'Organization: '}{whoami.organizationName}
+ ) : null}
+ ,
+ false,
+ );
+ } catch (err) {
+ onError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ return null;
+}
diff --git a/src/lib/flagParser.ts b/src/lib/flagParser.ts
new file mode 100644
index 0000000..e59d2a5
--- /dev/null
+++ b/src/lib/flagParser.ts
@@ -0,0 +1,81 @@
+import {z} from 'zod';
+
+function unwrapType(t: z.ZodTypeAny): z.ZodTypeAny {
+ const def = t._def as Record;
+ if (def['innerType']) return unwrapType(def['innerType'] as z.ZodTypeAny);
+ if (def['schema']) return unwrapType(def['schema'] as z.ZodTypeAny);
+ return t;
+}
+
+function getFieldKind(t: z.ZodTypeAny): 'boolean' | 'number' | 'string' {
+ const inner = unwrapType(t);
+ const typeName = (inner._def as {typeName?: string}).typeName;
+ if (typeName === 'ZodBoolean') return 'boolean';
+ if (typeName === 'ZodNumber') return 'number';
+ return 'string';
+}
+
+export type ParseFlagsResult = {
+ flags: Record;
+ positional: string[];
+ error: string | null;
+};
+
+export function parseFlags(
+ tokens: string[],
+ schema: z.ZodObject,
+): ParseFlagsResult {
+ const shape = schema.shape;
+ const raw: Record = {};
+ const positional: string[] = [];
+ let i = 0;
+
+ while (i < tokens.length) {
+ const token = tokens[i]!;
+ if (token.startsWith('--')) {
+ const eqIdx = token.indexOf('=');
+ if (eqIdx !== -1) {
+ const key = token.slice(2, eqIdx);
+ const val = token.slice(eqIdx + 1);
+ const kind = shape[key] ? getFieldKind(shape[key]!) : 'string';
+ raw[key] = kind === 'number' ? Number(val) : val;
+ i++;
+ } else {
+ const key = token.slice(2);
+ const kind = shape[key] ? getFieldKind(shape[key]!) : 'string';
+ if (kind === 'boolean') {
+ raw[key] = true;
+ i++;
+ } else {
+ const next = tokens[i + 1];
+ if (next !== undefined && !next.startsWith('-')) {
+ raw[key] = kind === 'number' ? Number(next) : next;
+ i += 2;
+ } else {
+ raw[key] = true;
+ i++;
+ }
+ }
+ }
+ } else {
+ positional.push(token);
+ i++;
+ }
+ }
+
+ const unknownKeys = Object.keys(raw).filter(k => !shape[k]);
+ if (unknownKeys.length > 0) {
+ return {flags: schema.parse({}) as Record, positional, error: `Unknown flag(s): ${unknownKeys.map(k => `--${k}`).join(', ')}`};
+ }
+
+ try {
+ return {flags: schema.parse(raw) as Record, positional, error: null};
+ } catch (err) {
+ if (err instanceof z.ZodError) {
+ const msg = err.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
+ return {flags: schema.parse({}) as Record, positional, error: msg};
+ }
+
+ return {flags: schema.parse({}) as Record, positional, error: String(err)};
+ }
+}
diff --git a/src/lib/history.ts b/src/lib/history.ts
new file mode 100644
index 0000000..3498650
--- /dev/null
+++ b/src/lib/history.ts
@@ -0,0 +1,23 @@
+import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
+import {join} from 'node:path';
+import {getConfigDir} from './config.js';
+
+const MAX_HISTORY = 500;
+
+export function getHistoryPath(): string {
+ return join(getConfigDir(), 'history.json');
+}
+
+export function loadHistory(): string[] {
+ try {
+ return JSON.parse(readFileSync(getHistoryPath(), 'utf-8')) as string[];
+ } catch {
+ return [];
+ }
+}
+
+export function saveHistory(entries: string[]): void {
+ const trimmed = entries.slice(-MAX_HISTORY);
+ mkdirSync(getConfigDir(), {recursive: true, mode: 0o700});
+ writeFileSync(getHistoryPath(), JSON.stringify(trimmed) + '\n', {mode: 0o600});
+}
diff --git a/src/lib/replParser.ts b/src/lib/replParser.ts
new file mode 100644
index 0000000..f12b74b
--- /dev/null
+++ b/src/lib/replParser.ts
@@ -0,0 +1,59 @@
+export type ParsedCommand =
+ | {type: 'builtin'; name: 'help' | 'exit' | 'clear'}
+ | {type: 'command'; name: string; tokens: string[]}
+ | {type: 'empty'}
+ | {type: 'unknown'; input: string};
+
+const TWO_WORD_COMMANDS = new Set(['flows show', 'config list', 'config reset']);
+const ONE_WORD_COMMANDS = new Set(['logs', 'stats', 'flows', 'whoami', 'login', 'logout']);
+const BUILTINS = new Set(['help', 'exit', 'clear']);
+
+function shellSplit(input: string): string[] {
+ const tokens: string[] = [];
+ let current = '';
+ let inSingle = false;
+ let inDouble = false;
+
+ for (const ch of input) {
+ if (ch === "'" && !inDouble) {
+ inSingle = !inSingle;
+ } else if (ch === '"' && !inSingle) {
+ inDouble = !inDouble;
+ } else if (ch === ' ' && !inSingle && !inDouble) {
+ if (current.length > 0) {
+ tokens.push(current);
+ current = '';
+ }
+ } else {
+ current += ch;
+ }
+ }
+
+ if (current.length > 0) tokens.push(current);
+ return tokens;
+}
+
+export function parseReplInput(input: string): ParsedCommand {
+ const trimmed = input.trim();
+ if (!trimmed) return {type: 'empty'};
+
+ const tokens = shellSplit(trimmed);
+ const first = tokens[0]!.toLowerCase();
+
+ if (BUILTINS.has(first)) {
+ return {type: 'builtin', name: first as 'help' | 'exit' | 'clear'};
+ }
+
+ if (tokens.length >= 2) {
+ const twoWord = `${first} ${tokens[1]!.toLowerCase()}`;
+ if (TWO_WORD_COMMANDS.has(twoWord)) {
+ return {type: 'command', name: twoWord, tokens: tokens.slice(2)};
+ }
+ }
+
+ if (ONE_WORD_COMMANDS.has(first)) {
+ return {type: 'command', name: first, tokens: tokens.slice(1)};
+ }
+
+ return {type: 'unknown', input: trimmed};
+}