Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions docs/cli/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
27 changes: 2 additions & 25 deletions src/commands/index.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,5 @@
import {Text, Box} from 'ink';
import ReplView from '../components/ReplView.js';

export default function Index() {
return (
<Box flexDirection="column">
<Text bold>Timberlogs CLI v{CLI_VERSION}</Text>
<Text> </Text>
<Text>Usage: timberlogs {'<command>'} [options]</Text>
<Text> </Text>
<Text bold>Commands:</Text>
<Text> login Authenticate via browser</Text>
<Text> logout Remove stored session</Text>
<Text> whoami Show current auth status</Text>
<Text> logs Query and search logs</Text>
<Text> flows List all flows</Text>
<Text> flows show {'<id>'} View a flow timeline</Text>
<Text> stats Show log volume and distribution</Text>
<Text> config list Show current config</Text>
<Text> config reset Delete config file</Text>
<Text> </Text>
<Text bold>Global Flags:</Text>
<Text> --json Force JSON output</Text>
<Text> --verbose Show debug info</Text>
<Text> --version, -v Show version</Text>
<Text> --help, -h Show help</Text>
</Box>
);
return <ReplView />;
}
19 changes: 8 additions & 11 deletions src/commands/login.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/commands/repl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ReplView from '../components/ReplView.js';

export default function Repl() {
return <ReplView />;
}
6 changes: 4 additions & 2 deletions src/components/FlowTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Props = {
stepCount: number;
durationMs: number;
hasErrors: boolean;
onBack?: () => void;
};

function formatDuration(ms: number): string {
Expand All @@ -26,10 +27,11 @@ function formatDataInline(data: Record<string, unknown> | 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);
}
});

Expand Down
6 changes: 4 additions & 2 deletions src/components/LogTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);

Expand All @@ -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);
}
});

Expand Down
19 changes: 19 additions & 0 deletions src/components/ReplOutput.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text dimColor>❯ {entry.input}</Text>;
}

if (entry.kind === 'error') {
return <Text color="red">✗ {entry.message}</Text>;
}

return <Box>{entry.content as ReactNode}</Box>;
}
67 changes: 67 additions & 0 deletions src/components/ReplPrompt.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box>
<Text color="green">❯ </Text>
{disabled ? (
<Text dimColor>...</Text>
) : (
<TextInput value={value} onChange={handleChange} onSubmit={handleSubmit} />
)}
</Box>
);
}
Loading