@thetanuts-finance/odette-cli 0.0.0-stage → 0.1.1

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/config.js ADDED
@@ -0,0 +1,146 @@
1
+ // Adapted from thetanuts-sdk cli/src/config.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ // Changes: path ~/.config/odette; no referrer or rfqKeysDir fields; a config
4
+ // that holds a `referrer` key is an error (exit 4), not ignored. `--dry-run`
5
+ // never writes or deletes the file (stopForDryRun). saveConfig chmods the
6
+ // directory only when it created it.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ /**
12
+ * `--dry-run` never writes or deletes the config file. Every command that
13
+ * changes it calls this first and returns when it gives true. Upstream checked
14
+ * dry-run only inside confirm(), which `--force` skips: `wallet create --force
15
+ * --dry-run` replaced the key and `config unset privateKey --dry-run` deleted it.
16
+ * Output goes to stderr so `-o json` stdout stays empty.
17
+ */
18
+ export function stopForDryRun(dryRun, action, configPath) {
19
+ if (!dryRun)
20
+ return false;
21
+ process.stderr.write(`Dry-run: ${action} would change ${configPath}. Nothing was written.\n`);
22
+ return true;
23
+ }
24
+ /** Thrown for a config file the CLI must not use. Commands exit with `exitCode` (4). */
25
+ export class ConfigError extends Error {
26
+ exitCode = 4;
27
+ }
28
+ export function defaultConfigPath() {
29
+ // Honor XDG_CONFIG_HOME if set, else ~/.config.
30
+ const xdg = process.env.XDG_CONFIG_HOME;
31
+ const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
32
+ return path.join(base, 'odette', 'config.json');
33
+ }
34
+ export function loadConfig(configPath) {
35
+ const p = configPath ?? defaultConfigPath();
36
+ if (!fs.existsSync(p))
37
+ return null;
38
+ // Open with O_NOFOLLOW so a pre-planted symlink cannot redirect the read to
39
+ // an attacker-controlled path (TNU-AUDIT-0078). O_NOFOLLOW is unsupported
40
+ // on Windows — fall back to the previous read path there.
41
+ let raw;
42
+ if (process.platform !== 'win32') {
43
+ const NOFOLLOW = fs.constants.O_NOFOLLOW;
44
+ if (typeof NOFOLLOW === 'number') {
45
+ const fd = fs.openSync(p, fs.constants.O_RDONLY | NOFOLLOW);
46
+ try {
47
+ raw = fs.readFileSync(fd, 'utf8');
48
+ }
49
+ finally {
50
+ fs.closeSync(fd);
51
+ }
52
+ }
53
+ else {
54
+ raw = fs.readFileSync(p, 'utf8');
55
+ }
56
+ }
57
+ else {
58
+ raw = fs.readFileSync(p, 'utf8');
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ throw new ConfigError(`Config file ${p} is not valid JSON. Fix it, or run \`odette setup\`.`);
66
+ }
67
+ // The Odette referrer is a constant. A `referrer` key means the file was
68
+ // written for another tool (or edited by hand); refuse it rather than
69
+ // silently ignore an address the user thinks is in effect.
70
+ if (parsed !== null && typeof parsed === 'object' && 'referrer' in parsed) {
71
+ throw new ConfigError(`Config file ${p} has a "referrer" key. The Odette referrer is fixed and cannot be configured. Remove the key.`);
72
+ }
73
+ // Auto-tighten loose perms rather than just warning to stderr — CI piping
74
+ // stderr to /dev/null silently swallowed the warning (TNU-AUDIT-0079).
75
+ if (process.platform !== 'win32') {
76
+ try {
77
+ const stat = fs.statSync(p);
78
+ if ((stat.mode & 0o077) !== 0) {
79
+ const oct = (stat.mode & 0o777).toString(8);
80
+ try {
81
+ fs.chmodSync(p, 0o600);
82
+ process.stderr.write(`notice: tightened ${p} from mode ${oct} → 600 (was world/group-readable).\n`);
83
+ }
84
+ catch {
85
+ // chmod may fail on non-owned file or unusual FS — keep the warning.
86
+ process.stderr.write(`warning: ${p} has loose permissions (mode ${oct}); run 'chmod 600 <path>' to tighten\n`);
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ // best-effort; never block load on a stat failure
92
+ }
93
+ }
94
+ return parsed;
95
+ }
96
+ export function saveConfig(cfg, configPath) {
97
+ const p = configPath ?? defaultConfigPath();
98
+ const dir = path.dirname(p);
99
+ // Tighten the directory only when we create it: `--config ./config.json`
100
+ // must not chmod the user's current directory (upstream chmod'ed any parent).
101
+ const createdDir = !fs.existsSync(dir);
102
+ if (createdDir) {
103
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
104
+ }
105
+ // Write atomically: temp file in the same directory, then rename(2).
106
+ //
107
+ // `wallet create` discards the BIP-39 mnemonic and tells the user this file
108
+ // is now the ONLY copy of their key. A crash, SIGINT, or ENOSPC partway
109
+ // through an in-place writeFileSync would leave a truncated config and
110
+ // permanently destroy that key. rename(2) within a filesystem is atomic, so
111
+ // a reader sees either the old file or the new one — never a partial write.
112
+ //
113
+ // Writing to a fresh temp path also means we never follow a symlink planted
114
+ // at `p`: rename replaces the link itself rather than writing through it,
115
+ // which closes the write-side gap left by the O_NOFOLLOW read in loadConfig.
116
+ const tempPath = `${p}.${randomBytes(8).toString('hex')}.tmp`;
117
+ try {
118
+ fs.writeFileSync(tempPath, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
119
+ // `mode` on writeFileSync is honored only at creation and is masked by
120
+ // umask. Force it before the rename so the key is never briefly readable.
121
+ try {
122
+ fs.chmodSync(tempPath, 0o600);
123
+ }
124
+ catch {
125
+ // ignore (Windows / unsupported FS)
126
+ }
127
+ fs.renameSync(tempPath, p);
128
+ }
129
+ catch (err) {
130
+ try {
131
+ fs.unlinkSync(tempPath);
132
+ }
133
+ catch {
134
+ // ignore cleanup errors — the original file is still intact
135
+ }
136
+ throw err;
137
+ }
138
+ if (createdDir) {
139
+ try {
140
+ fs.chmodSync(dir, 0o700);
141
+ }
142
+ catch {
143
+ // ignore (Windows / unsupported FS)
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,39 @@
1
+ // Adapted from thetanuts-sdk cli/src/confirm.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ import prompts from 'prompts';
4
+ /**
5
+ * Ask the user to confirm a write operation.
6
+ *
7
+ * Returns:
8
+ * - `true` → proceed
9
+ * - `false` → abort (caller should `process.exit(3)` to signal user cancel)
10
+ *
11
+ * Semantics (precedence order — money is on the line, safer behavior wins):
12
+ * - `--dry-run` ALWAYS wins. If set, prints "Dry-run: aborted" and returns
13
+ * `false` without prompting, even when `--yes` is also passed. This is
14
+ * defense-in-depth: if any caller forgets to short-circuit on `dryRun`
15
+ * upstream and falls through to `confirm()`, we must not broadcast.
16
+ * - `--yes` short-circuits to `true` only when `--dry-run` is absent.
17
+ * - Otherwise prompts via `prompts` with the supplied message.
18
+ * - If stdin is not a TTY and no `--yes`, returns `false` (we never block
19
+ * non-interactive sessions on a prompt that nobody can answer).
20
+ */
21
+ export async function confirm(message, opts = {}) {
22
+ if (opts.dryRun) {
23
+ process.stderr.write('Dry-run: aborted\n');
24
+ return false;
25
+ }
26
+ if (opts.yes)
27
+ return true;
28
+ if (!process.stdin.isTTY) {
29
+ process.stderr.write('Refusing to prompt for confirmation on a non-TTY stdin. Re-run with --yes to bypass.\n');
30
+ return false;
31
+ }
32
+ const response = await prompts({
33
+ type: 'confirm',
34
+ name: 'ok',
35
+ message,
36
+ initial: false,
37
+ });
38
+ return Boolean(response.ok);
39
+ }
@@ -0,0 +1,17 @@
1
+ // Adapted from thetanuts-sdk cli/src/defaults.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ // Changes: comments.
4
+ /**
5
+ * Single source of truth for CLI defaults. Bump these here, not at call sites.
6
+ *
7
+ * The CLI is Base-only by design (chainId 8453). Keep this aligned with the
8
+ * SDK (`@thetanuts-finance/odette-sdk`) — see `src/chain.ts` there for
9
+ * addresses and tokens.
10
+ *
11
+ * The default RPC is PUBLIC and shared — fine for read-only smoke and
12
+ * onboarding, but will rate-limit under load. Users should override via
13
+ * ODETTE_RPC_URL or `odette config set rpcUrl <url>` for any serious
14
+ * usage.
15
+ */
16
+ export const DEFAULT_CHAIN_ID = 8453; // Base mainnet
17
+ export const DEFAULT_RPC_URL = 'https://mainnet.base.org';
package/dist/format.js ADDED
@@ -0,0 +1,130 @@
1
+ // Adapted from thetanuts-sdk cli/src/format.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ /**
4
+ * Shared formatting helpers for humanized CLI output.
5
+ *
6
+ * These render values for table-mode display only; JSON/CSV/YAML output paths
7
+ * must continue to emit the raw bigint strings the SDK returns so that
8
+ * scripting consumers stay byte-stable across patch releases.
9
+ *
10
+ * Centralized here (not inlined per-command) so `book` and `rfq` agree on the
11
+ * exact ticker string and money formatting — users compare them across
12
+ * commands and any drift is confusing.
13
+ */
14
+ const MONTHS = [
15
+ 'JAN',
16
+ 'FEB',
17
+ 'MAR',
18
+ 'APR',
19
+ 'MAY',
20
+ 'JUN',
21
+ 'JUL',
22
+ 'AUG',
23
+ 'SEP',
24
+ 'OCT',
25
+ 'NOV',
26
+ 'DEC',
27
+ ];
28
+ /**
29
+ * Format an option ticker in the dApp-canonical style:
30
+ * ETH-19MAY26-2100-P
31
+ * ETH-19MAY26-2050/2100-P (multi-leg)
32
+ * `strikesUsd` are USD floats (already divided by 1e8 if you started from raw).
33
+ */
34
+ export function formatTicker(underlying, expirySec, strikesUsd, type) {
35
+ const d = new Date(expirySec * 1000);
36
+ const day = d.getUTCDate();
37
+ const month = MONTHS[d.getUTCMonth()];
38
+ const year = d.getUTCFullYear().toString().slice(-2);
39
+ const strikeStr = strikesUsd.length === 1 ? `${strikesUsd[0]}` : strikesUsd.join('/');
40
+ return `${underlying}-${day}${month}${year}-${strikeStr}-${type === 'PUT' ? 'P' : 'C'}`;
41
+ }
42
+ /**
43
+ * Render a strike (or comma-separated multi-leg strikes) in `$2100` form.
44
+ * Inputs are USD floats.
45
+ */
46
+ export function formatStrikeUsd(strikesUsd) {
47
+ if (strikesUsd.length === 0)
48
+ return '';
49
+ if (strikesUsd.length === 1)
50
+ return `$${formatNumber(strikesUsd[0])}`;
51
+ return strikesUsd.map((s) => `$${formatNumber(s)}`).join(',');
52
+ }
53
+ /**
54
+ * Render a USD dollar amount with up to 2 decimals and thousands separators.
55
+ * Used for premium / available / collateral surfacing in table mode.
56
+ */
57
+ export function formatUsd(amount, maxFractionDigits = 2) {
58
+ return `$${formatNumber(amount, maxFractionDigits)}`;
59
+ }
60
+ function formatNumber(n, maxFractionDigits = 2) {
61
+ // Drop trailing zeros: $2100 not $2,100.00 for clean strikes, but $1.77
62
+ // still renders.
63
+ const opts = {
64
+ minimumFractionDigits: 0,
65
+ maximumFractionDigits: maxFractionDigits,
66
+ };
67
+ return new Intl.NumberFormat('en-US', opts).format(n);
68
+ }
69
+ /**
70
+ * Format an expiry timestamp as `<unix> (<iso-date>)`. ISO is UTC-trimmed to
71
+ * minutes — the seconds are always zero for option expiries and the user
72
+ * rarely cares about exact-second granularity at the orderbook level.
73
+ */
74
+ export function formatExpiry(expirySec) {
75
+ const d = new Date(expirySec * 1000);
76
+ const iso = d.toISOString().slice(0, 16) + 'Z'; // YYYY-MM-DDTHH:mmZ
77
+ return `${expirySec} (${iso})`;
78
+ }
79
+ /**
80
+ * Reverse-lookup helper: given a token address and a chain config tokens map,
81
+ * return the symbol (USDC, WETH, ...). Returns undefined when unmatched.
82
+ *
83
+ * Typed loosely on purpose — different SDK consumer shapes have slightly
84
+ * different tokens map types; we only need `{ address, ... }`.
85
+ */
86
+ export function tokenSymbolByAddress(address, tokens) {
87
+ if (!address)
88
+ return undefined;
89
+ const lower = address.toLowerCase();
90
+ for (const [sym, cfg] of Object.entries(tokens)) {
91
+ if (cfg.address.toLowerCase() === lower)
92
+ return sym;
93
+ }
94
+ return undefined;
95
+ }
96
+ /**
97
+ * Reverse-lookup helper: given a price-feed address and a price-feeds map
98
+ * (symbol -> feed address), return the underlying symbol ('ETH', 'BTC', ...).
99
+ */
100
+ export function underlyingSymbolByPriceFeed(priceFeed, priceFeeds) {
101
+ if (!priceFeed)
102
+ return undefined;
103
+ const lower = priceFeed.toLowerCase();
104
+ for (const [sym, addr] of Object.entries(priceFeeds)) {
105
+ if (addr.toLowerCase() === lower)
106
+ return sym;
107
+ }
108
+ return undefined;
109
+ }
110
+ /**
111
+ * Ticker for a structure (spread / fly / condor / ranger). Multi-leg orders
112
+ * must never render with a bare `-C` / `-P` suffix: `book check` used to label
113
+ * a 4-strike RANGER `ETH-28AUG26-2150-C`, which reads as a vanilla call and
114
+ * misled callers into pricing it as one.
115
+ *
116
+ * Falls back to `formatTicker` for single-leg orders, or when the structure
117
+ * name is unknown (an implementation address absent from chain config).
118
+ * Strikes are rendered in the order the order stores them — that same order is
119
+ * what `book preview --strikes` expects back.
120
+ */
121
+ export function formatStructureTicker(underlying, expirySec, strikesUsd, type, structureName) {
122
+ if (strikesUsd.length <= 1 || !structureName) {
123
+ return formatTicker(underlying, expirySec, strikesUsd, type);
124
+ }
125
+ const d = new Date(expirySec * 1000);
126
+ const day = d.getUTCDate();
127
+ const month = MONTHS[d.getUTCMonth()];
128
+ const year = d.getUTCFullYear().toString().slice(-2);
129
+ return `${underlying}-${day}${month}${year}-${strikesUsd.join('/')}-${structureName}`;
130
+ }
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // Adapted from thetanuts-sdk cli/src/index.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
3
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
4
+ // Changes: name and description. The fallback error print is redacted.
5
+ import { Command } from 'commander';
6
+ import { createRequire } from 'node:module';
7
+ import { registerCommands } from './commands/registry.js';
8
+ import { addGlobalOptions } from './options.js';
9
+ import { renderError, redactSecrets } from './output.js';
10
+ // Resolve the CLI version from package.json at runtime so bumping
11
+ // `cli/package.json` is the only edit needed for a release. `createRequire`
12
+ // keeps package.json outside the TypeScript `rootDir` (./src) while still
13
+ // resolving relative to either `cli/src/index.ts` (dev via tsx) or
14
+ // `cli/dist/index.js` (production bin) — both reach `cli/package.json`.
15
+ const requireFromHere = createRequire(import.meta.url);
16
+ const pkg = requireFromHere('../package.json');
17
+ // Exit cleanly when downstream (e.g. `| head`) closes the pipe early.
18
+ process.stdout.on('error', (err) => {
19
+ if (err.code === 'EPIPE')
20
+ process.exit(0);
21
+ throw err;
22
+ });
23
+ const program = new Command();
24
+ program
25
+ .name('odette')
26
+ .description('Odette command-line interface: trade Odette orders on the Thetanuts OptionBook (Base)')
27
+ .version(pkg.version);
28
+ // Global flags live on the root command, so `odette fill --help`
29
+ // would not list `--dry-run` / `--output` at all — making a
30
+ // present flag look missing from the help path people actually use.
31
+ program.configureHelp({ showGlobalOptions: true });
32
+ addGlobalOptions(program);
33
+ registerCommands(program);
34
+ // Commander exits 1 for every parse failure, but the CLI documents exit 2 as
35
+ // "usage error (bad flags, missing required arg)" and reserves 1 for generic
36
+ // runtime failures — network, RPC, contract revert. Without this mapping,
37
+ // automation cannot tell a typo'd flag apart from a reverted transaction.
38
+ //
39
+ // The set is Commander's own error codes for input the user got wrong. Anything
40
+ // unrecognised keeps Commander's exit code rather than being coerced, so a new
41
+ // error code in a future Commander release fails visibly instead of silently
42
+ // reporting itself as a usage error.
43
+ const USAGE_ERROR_CODES = new Set([
44
+ 'commander.missingArgument',
45
+ 'commander.missingMandatoryOptionValue',
46
+ 'commander.optionMissingArgument',
47
+ 'commander.unknownOption',
48
+ 'commander.unknownCommand',
49
+ 'commander.invalidArgument',
50
+ 'commander.excessArguments',
51
+ 'commander.conflictingOption',
52
+ // A group command invoked with no subcommand (`odette wallet`, or bare
53
+ // `odette`) prints help via help({ error: true }), which throws
54
+ // `commander.help` with exitCode 1. That is an incomplete command — a usage
55
+ // error by the README's own definition — not a runtime failure. Safe to map
56
+ // here because successful `--help` uses the distinct `commander.helpDisplayed`
57
+ // code with exitCode 0, which the guard below returns on before reaching this.
58
+ 'commander.help',
59
+ ]);
60
+ // exitOverride is per-Command and is NOT inherited by subcommands, so walk the
61
+ // whole tree — `odette wallet approve` is a grandchild of the root command.
62
+ function mapUsageExitCodes(cmd) {
63
+ cmd.exitOverride((err) => {
64
+ // --help and --version are successful terminations, not failures.
65
+ if (err.exitCode === 0)
66
+ process.exit(0);
67
+ process.exit(USAGE_ERROR_CODES.has(err.code) ? 2 : err.exitCode);
68
+ });
69
+ for (const sub of cmd.commands)
70
+ mapUsageExitCodes(sub);
71
+ }
72
+ mapUsageExitCodes(program);
73
+ program.parseAsync(process.argv).catch((err) => {
74
+ // Try to use the structured error renderer if we can detect --json-errors,
75
+ // otherwise print just the message — never the stack
76
+ const jsonErrors = process.argv.includes('--json-errors');
77
+ try {
78
+ renderError(err, { jsonErrors });
79
+ }
80
+ catch {
81
+ process.stderr.write(redactSecrets(err?.message ?? String(err)) + '\n');
82
+ }
83
+ process.exit(1);
84
+ });
@@ -0,0 +1,50 @@
1
+ // Adapted from thetanuts-sdk cli/src/options.ts at 9e72aef1ab5c008ccc92352fa5048bd08c452ede.
2
+ // MIT License, Copyright (c) 2026 Thetanuts Finance.
3
+ // Changes: --chain dropped (Base only); --referrer dropped (the Odette referrer is fixed);
4
+ // --private-key dropped (keys come from ODETTE_PRIVATE_KEY or the 0600 config only);
5
+ // env var and path names. --output accepts only its listed formats.
6
+ import { Option } from 'commander';
7
+ /**
8
+ * Global option spec for the root `odette` command.
9
+ * implementer agents extend per-command options but should NOT add new global
10
+ * flags without coordination.
11
+ *
12
+ * Note: commander emits `--no-color` automatically as the negation of
13
+ * `--color` (default true). We declare it that way so users still see
14
+ * `--no-color` in `--help`.
15
+ */
16
+ export function addGlobalOptions(program) {
17
+ program
18
+ .addOption(new Option('-o, --output <fmt>', 'output format').choices(['table', 'json', 'csv', 'yaml']).default('table'))
19
+ .option('--dry-run', 'simulate without sending transactions', false)
20
+ .option('--yes', 'skip interactive confirmation prompts', false)
21
+ .option('--rpc-url <url>', 'override the RPC endpoint for the active chain')
22
+ .option('--config <path>', 'path to a config file (default: ~/.config/odette/config.json)')
23
+ .option('--json-errors', 'emit errors as JSON on stderr', false)
24
+ .option('--color', 'enable ANSI color output', true)
25
+ .option('--no-color', 'disable ANSI color output');
26
+ }
27
+ /**
28
+ * Walk up `cmd.parent` to collect options from every ancestor command, so a
29
+ * subcommand action can read both its own opts and the root global opts.
30
+ *
31
+ * Commander does this implicitly with `inheritedOptionValues`, but we wrap it
32
+ * here so implementer code has a single helper to reach for and a clearer
33
+ * call site.
34
+ */
35
+ export function getGlobalOpts(cmd) {
36
+ // commander supports cmd.optsWithGlobals() in v12; prefer that when
37
+ // available, fall back to manual ancestor walk otherwise.
38
+ const withGlobals = cmd
39
+ .optsWithGlobals;
40
+ if (typeof withGlobals === 'function') {
41
+ return withGlobals.call(cmd);
42
+ }
43
+ const merged = {};
44
+ let cursor = cmd;
45
+ while (cursor) {
46
+ Object.assign(merged, cursor.opts());
47
+ cursor = cursor.parent;
48
+ }
49
+ return merged;
50
+ }