@pungoyal/kite-cli 0.3.0 → 0.4.0

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/README.md CHANGED
@@ -50,7 +50,7 @@ Then:
50
50
  kite login
51
51
  ```
52
52
 
53
- This opens your browser, you log in to Zerodha normally (including your TOTP), and the CLI captures the callback on loopback. Your API secret goes into your OS keyring; the daily access token is stored alongside it.
53
+ This opens your browser, you log in to Zerodha normally (including your TOTP), and the CLI captures the callback on loopback. The login URL is also printed to the terminal — press `c` while it's waiting to copy it to your clipboard (handy if the browser didn't open, or you want to log in on another device). Your API secret goes into your OS keyring; the daily access token is stored alongside it.
54
54
 
55
55
  **Want to try it without a subscription or real money?** Zerodha runs a public sandbox:
56
56
 
@@ -1,2 +1,11 @@
1
+ import type { Context } from '../context.js';
1
2
  import type { CommandFactory } from './types.js';
2
3
  export declare const authCommands: CommandFactory;
4
+ /**
5
+ * While waiting for the browser callback, listen for a `c` keypress to copy the
6
+ * login URL, and for Ctrl-C to abort. Entering raw mode makes us responsible for
7
+ * both restoring the terminal and re-handling Ctrl-C (raw mode no longer raises
8
+ * SIGINT). Returns a no-op when stdin is not a TTY, and an idempotent cleanup
9
+ * that both the caller and the Ctrl-C path can safely call.
10
+ */
11
+ export declare function listenForKeys(ctx: Context, loginUrl: string, onInterrupt: () => void): () => void;
@@ -1,5 +1,5 @@
1
1
  import { isCancel, note, password, text } from '@clack/prompts';
2
- import { buildLoginUrl, computeChecksum, generateState, openBrowser, redirectUrlFor, waitForCallback, } from '../core/auth.js';
2
+ import { buildLoginUrl, computeChecksum, copyToClipboard, generateState, openBrowser, redirectUrlFor, waitForCallback, } from '../core/auth.js';
3
3
  import { loadConfig, SANDBOX_CREDENTIALS, saveConfig } from '../core/config.js';
4
4
  import { deleteAllSecrets, getSecret, keyringAvailable, setSecret } from '../core/credentials.js';
5
5
  import { AbortedError, ExitCode, KiteCliError } from '../core/errors.js';
@@ -145,23 +145,92 @@ async function callbackFlow(ctx, loginUrl) {
145
145
  io.info(`Your Kite app's redirect URL must be exactly: ${io.bold(redirectUrl)}`);
146
146
  io.info('Set it at https://developers.kite.trade if login fails.');
147
147
  io.note('');
148
+ // Show the URL unconditionally: the browser may not have opened, may have
149
+ // landed in the wrong profile, or the user may want to log in on another
150
+ // device. The URL carries only the api_key and CSRF state — no token.
151
+ io.info('Login URL:');
152
+ io.note(` ${loginUrl}`);
153
+ io.note('');
148
154
  const opened = await openBrowser(loginUrl);
149
155
  if (opened) {
150
156
  io.info('Opened your browser to complete login…');
151
157
  }
152
158
  else {
153
- io.warn('Could not open a browser automatically. Open this URL manually:');
154
- io.note(` ${loginUrl}`);
159
+ io.warn('Could not open a browser automatically. Open the URL above manually.');
155
160
  }
156
- io.info('Waiting for the callback (Ctrl-C to abort)…');
161
+ // Let the user grab the URL without selecting it in the terminal. Copy-on-`c`
162
+ // and Ctrl-C both funnel through `interrupted`, which loses the race against a
163
+ // successful callback. A single Ctrl-C aborts the wait — raw mode swallows the
164
+ // SIGINT, and the loopback promise never observes ctx.signal on its own.
165
+ let interrupt = () => { };
166
+ const interrupted = new Promise((_, reject) => {
167
+ interrupt = () => reject(new AbortedError('Interrupted.'));
168
+ });
169
+ const onAbort = () => interrupt();
170
+ if (ctx.signal.aborted)
171
+ onAbort();
172
+ else
173
+ ctx.signal.addEventListener('abort', onAbort, { once: true });
174
+ const stopKeys = listenForKeys(ctx, loginUrl, interrupt);
175
+ io.info(`Waiting for the callback (press ${io.bold('c')} to copy the login URL, Ctrl-C to abort)…`);
157
176
  try {
158
- const result = await server.promise;
177
+ const result = await Promise.race([server.promise, interrupted]);
159
178
  return result.requestToken;
160
179
  }
161
180
  finally {
181
+ // Always restore the terminal and detach listeners, even on abort — the
182
+ // loopback promise may never settle, so this cannot hang off it.
183
+ stopKeys();
184
+ ctx.signal.removeEventListener('abort', onAbort);
162
185
  server.close();
163
186
  }
164
187
  }
188
+ /**
189
+ * While waiting for the browser callback, listen for a `c` keypress to copy the
190
+ * login URL, and for Ctrl-C to abort. Entering raw mode makes us responsible for
191
+ * both restoring the terminal and re-handling Ctrl-C (raw mode no longer raises
192
+ * SIGINT). Returns a no-op when stdin is not a TTY, and an idempotent cleanup
193
+ * that both the caller and the Ctrl-C path can safely call.
194
+ */
195
+ // Exported for tests: the raw-mode lifecycle (enter, restore, detach) and the
196
+ // Ctrl-C byte path are the risky parts, and a real TTY can't be driven in CI.
197
+ export function listenForKeys(ctx, loginUrl, onInterrupt) {
198
+ const { io } = ctx;
199
+ const stdin = process.stdin;
200
+ if (!stdin.isTTY)
201
+ return () => { };
202
+ const wasRaw = stdin.isRaw;
203
+ let stopped = false;
204
+ const cleanup = () => {
205
+ if (stopped)
206
+ return;
207
+ stopped = true;
208
+ stdin.off('data', onData);
209
+ stdin.setRawMode(wasRaw);
210
+ stdin.pause();
211
+ };
212
+ const onData = (chunk) => {
213
+ // 0x03 is Ctrl-C, which raw mode delivers as a byte instead of a SIGINT.
214
+ if (chunk.length === 1 && chunk[0] === 0x03) {
215
+ cleanup();
216
+ onInterrupt();
217
+ return;
218
+ }
219
+ const key = chunk.toString('utf8');
220
+ if (key === 'c' || key === 'C') {
221
+ void copyToClipboard(loginUrl).then((ok) => {
222
+ if (ok)
223
+ io.success('Login URL copied to clipboard.');
224
+ else
225
+ io.warn('Could not copy to the clipboard (no clipboard tool found).');
226
+ });
227
+ }
228
+ };
229
+ stdin.setRawMode(true);
230
+ stdin.resume();
231
+ stdin.on('data', onData);
232
+ return cleanup;
233
+ }
165
234
  /** Fallback for remote shells, where no browser can reach 127.0.0.1. */
166
235
  async function manualFlow(ctx, loginUrl) {
167
236
  const { io } = ctx;
@@ -0,0 +1,35 @@
1
+ import type { CommandFactory } from './types.js';
2
+ /**
3
+ * Shell completion scripts, generated from the live command tree.
4
+ *
5
+ * Rather than hand-maintain a completion file per shell — the exact docs-rot
6
+ * trap a CLI that places real orders cannot afford — this walks the same
7
+ * registration path run() uses (with a no-op runner) and emits command names,
8
+ * subcommand names, and long flags straight from it. A new command or flag is
9
+ * completable as soon as it is added; the only user step is to regenerate after
10
+ * upgrading, the same contract gh, rustup, and kubectl ship.
11
+ *
12
+ * Depth is intentionally capped at command → subcommand + flags, which covers
13
+ * every path this CLI has (`config set`, `margins order`, `orders place`).
14
+ */
15
+ declare const SHELLS: readonly ['bash', 'zsh', 'fish'];
16
+ type Shell = (typeof SHELLS)[number];
17
+ export declare const completionCommands: CommandFactory;
18
+ export interface CompletionModel {
19
+ /** Top-level command names (excluding commander's built-in `help`). */
20
+ commands: string[];
21
+ /** Subcommand names keyed by their parent command name. */
22
+ subcommands: Record<string, string[]>;
23
+ /** Long flags valid on every command. */
24
+ globalFlags: string[];
25
+ /** Human descriptions for the shells that render them (zsh, fish). */
26
+ descriptions: Record<string, string>;
27
+ }
28
+ /**
29
+ * Build the completion model by registering the real command tree onto a
30
+ * throwaway program. The runner is a no-op: completion needs each command's
31
+ * name, options, and subcommands, never its behaviour.
32
+ */
33
+ export declare function buildModel(): Promise<CompletionModel>;
34
+ export declare function renderScript(shell: Shell, model: CompletionModel): string;
35
+ export {};
@@ -0,0 +1,240 @@
1
+ import { Command } from 'commander';
2
+ import { UsageError } from '../core/errors.js';
3
+ /**
4
+ * Shell completion scripts, generated from the live command tree.
5
+ *
6
+ * Rather than hand-maintain a completion file per shell — the exact docs-rot
7
+ * trap a CLI that places real orders cannot afford — this walks the same
8
+ * registration path run() uses (with a no-op runner) and emits command names,
9
+ * subcommand names, and long flags straight from it. A new command or flag is
10
+ * completable as soon as it is added; the only user step is to regenerate after
11
+ * upgrading, the same contract gh, rustup, and kubectl ship.
12
+ *
13
+ * Depth is intentionally capped at command → subcommand + flags, which covers
14
+ * every path this CLI has (`config set`, `margins order`, `orders place`).
15
+ */
16
+ const SHELLS = ['bash', 'zsh', 'fish'];
17
+ export const completionCommands = (program, run) => {
18
+ program
19
+ .command('completion')
20
+ .summary('Print a shell completion script')
21
+ .description('Print a shell completion script for bash, zsh, or fish.\n\n' +
22
+ 'The shell is auto-detected from $SHELL when omitted. Install with, e.g.:\n' +
23
+ ' bash kite completion bash >> ~/.bash_completion\n' +
24
+ ' zsh kite completion zsh > ~/.zfunc/_kite (with ~/.zfunc on your fpath)\n' +
25
+ ' fish kite completion fish > ~/.config/fish/completions/kite.fish')
26
+ .argument('[shell]', 'Target shell: bash, zsh, or fish')
27
+ .action(run(completion));
28
+ };
29
+ async function completion(ctx, _opts, command) {
30
+ const requested = (command.args[0] ?? detectShell())?.toLowerCase();
31
+ if (!requested) {
32
+ throw new UsageError('Could not detect your shell from $SHELL.', `Name it explicitly: kite completion <${SHELLS.join('|')}>.`);
33
+ }
34
+ if (!isShell(requested)) {
35
+ throw new UsageError(`Unsupported shell "${requested}".`, `Supported shells: ${SHELLS.join(', ')}.`);
36
+ }
37
+ const model = await buildModel();
38
+ // The script is data: it goes to stdout so `kite completion bash > file`
39
+ // captures exactly the script and nothing else.
40
+ ctx.io.line(renderScript(requested, model));
41
+ }
42
+ /**
43
+ * Build the completion model by registering the real command tree onto a
44
+ * throwaway program. The runner is a no-op: completion needs each command's
45
+ * name, options, and subcommands, never its behaviour.
46
+ */
47
+ export async function buildModel() {
48
+ const { applyGlobalOptions, registerCommands } = await import('./register.js');
49
+ const program = new Command('kite');
50
+ applyGlobalOptions(program);
51
+ const noop = () => async () => { };
52
+ await registerCommands(program, noop);
53
+ const descriptions = {};
54
+ const subcommands = {};
55
+ const commands = [];
56
+ for (const cmd of realCommands(program)) {
57
+ commands.push(cmd.name());
58
+ descriptions[cmd.name()] = summaryOf(cmd);
59
+ const subs = realCommands(cmd);
60
+ if (subs.length > 0) {
61
+ subcommands[cmd.name()] = subs.map((s) => s.name());
62
+ for (const sub of subs)
63
+ descriptions[`${cmd.name()} ${sub.name()}`] = summaryOf(sub);
64
+ }
65
+ }
66
+ return { commands, subcommands, globalFlags: longFlags(program), descriptions };
67
+ }
68
+ /** Child commands, minus commander's auto-generated `help` command. */
69
+ function realCommands(cmd) {
70
+ return cmd.commands.filter((c) => c.name() !== 'help');
71
+ }
72
+ function longFlags(cmd) {
73
+ return cmd.options.map((o) => o.long).filter((long) => Boolean(long));
74
+ }
75
+ function summaryOf(cmd) {
76
+ // summary() is the short one-liner; description() may be a multi-line block.
77
+ const text = cmd.summary() || cmd.description().split('\n')[0] || '';
78
+ return sanitize(text);
79
+ }
80
+ /**
81
+ * Strip characters that would break a quoted description in a shell script.
82
+ * Backslash is included: fish treats it as an escape inside single quotes, so a
83
+ * description ending in `\` would escape the closing quote and corrupt the line.
84
+ */
85
+ function sanitize(text) {
86
+ return text
87
+ .replace(/['\\\n\r]/g, ' ')
88
+ .replace(/\s+/g, ' ')
89
+ .trim();
90
+ }
91
+ // --- rendering -------------------------------------------------------------
92
+ export function renderScript(shell, model) {
93
+ // Sanitise at the rendering boundary, not only in buildModel(), so renderScript
94
+ // can never emit a script broken by a stray quote or backslash regardless of
95
+ // how its model was built.
96
+ const safe = {
97
+ ...model,
98
+ descriptions: Object.fromEntries(Object.entries(model.descriptions).map(([key, value]) => [key, sanitize(value)])),
99
+ };
100
+ switch (shell) {
101
+ case 'bash':
102
+ return renderBash(safe);
103
+ case 'zsh':
104
+ return renderZsh(safe);
105
+ case 'fish':
106
+ return renderFish(safe);
107
+ }
108
+ }
109
+ function renderBash(model) {
110
+ // A `case` for subcommands rather than an associative array, so this works on
111
+ // the bash 3.2 that macOS still ships (declare -A is bash 4+).
112
+ const subCases = Object.entries(model.subcommands)
113
+ .map(([cmd, subs]) => ` ${cmd}) echo "${subs.join(' ')}" ;;`)
114
+ .join('\n');
115
+ return `# kite bash completion. Source it, or install with:
116
+ # kite completion bash >> ~/.bash_completion
117
+ _kite_subcommands() {
118
+ case "$1" in
119
+ ${subCases}
120
+ esac
121
+ }
122
+
123
+ _kite() {
124
+ local cur cmd sub i w
125
+ COMPREPLY=()
126
+ cur="\${COMP_WORDS[COMP_CWORD]}"
127
+
128
+ # First two non-flag words after "kite" are the command and its subcommand.
129
+ cmd=""; sub=""
130
+ for (( i=1; i < COMP_CWORD; i++ )); do
131
+ w="\${COMP_WORDS[i]}"
132
+ [[ "$w" == -* ]] && continue
133
+ if [[ -z "$cmd" ]]; then cmd="$w"; else sub="$w"; break; fi
134
+ done
135
+
136
+ if [[ "$cur" == -* ]]; then
137
+ COMPREPLY=( $(compgen -W "${model.globalFlags.join(' ')}" -- "$cur") )
138
+ return
139
+ fi
140
+
141
+ if [[ -z "$cmd" ]]; then
142
+ COMPREPLY=( $(compgen -W "${model.commands.join(' ')}" -- "$cur") )
143
+ return
144
+ fi
145
+
146
+ if [[ -z "$sub" ]]; then
147
+ local subs
148
+ subs="$(_kite_subcommands "$cmd")"
149
+ [[ -n "$subs" ]] && COMPREPLY=( $(compgen -W "$subs" -- "$cur") )
150
+ fi
151
+ }
152
+ complete -F _kite kite
153
+ `;
154
+ }
155
+ function renderZsh(model) {
156
+ const topDescribe = model.commands.map((name) => ` '${name}:${model.descriptions[name] ?? ''}'`).join('\n');
157
+ // Build the sub arrays inline per command so descriptions survive.
158
+ const subBlocks = Object.entries(model.subcommands)
159
+ .map(([cmd, subs]) => {
160
+ const described = subs.map((s) => `'${s}:${model.descriptions[`${cmd} ${s}`] ?? ''}'`).join(' ');
161
+ return ` ${cmd}) local -a subs=(${described}); _describe 'subcommand' subs ;;`;
162
+ })
163
+ .join('\n');
164
+ return `#compdef kite
165
+ # kite zsh completion. Install with:
166
+ # kite completion zsh > "\${fpath[1]}/_kite"
167
+ _kite() {
168
+ local -a commands
169
+ commands=(
170
+ ${topDescribe}
171
+ )
172
+
173
+ # Flags complete anywhere the current word starts with a dash. \`compadd --\`
174
+ # is required: a bare \`compadd --json\` makes zsh parse the flags as its own
175
+ # options instead of as candidates.
176
+ if [[ \${words[CURRENT]} == -* ]]; then
177
+ compadd -- ${model.globalFlags.join(' ')}
178
+ return
179
+ fi
180
+
181
+ # Top-level command name.
182
+ if (( CURRENT == 2 )); then
183
+ _describe 'command' commands
184
+ return
185
+ fi
186
+
187
+ # Subcommands only at the subcommand position; deeper positions offer nothing,
188
+ # which caps depth at command -> subcommand as documented.
189
+ if (( CURRENT == 3 )); then
190
+ case \${words[2]} in
191
+ ${subBlocks}
192
+ esac
193
+ fi
194
+ }
195
+ compdef _kite kite
196
+ `;
197
+ }
198
+ function renderFish(model) {
199
+ const lines = [
200
+ '# kite fish completion. Install with:',
201
+ '# kite completion fish > ~/.config/fish/completions/kite.fish',
202
+ '',
203
+ '# No file completion by default; kite takes symbols and subcommands, not paths.',
204
+ 'complete -c kite -f',
205
+ '',
206
+ '# Top-level commands (only before a subcommand is typed).',
207
+ ];
208
+ for (const name of model.commands) {
209
+ lines.push(`complete -c kite -n __fish_use_subcommand -a ${name} -d '${model.descriptions[name] ?? ''}'`);
210
+ }
211
+ lines.push('', '# Subcommands (only until one is chosen, capping depth at command → subcommand).');
212
+ for (const [cmd, subs] of Object.entries(model.subcommands)) {
213
+ // __fish_seen_subcommand_from is true anywhere the token appears on the
214
+ // line, so without the `and not …` guard `kite config set <TAB>` would keep
215
+ // re-offering config's subcommands. Excluding the subcommands themselves
216
+ // stops that once one has been typed.
217
+ const guard = `__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(' ')}`;
218
+ for (const sub of subs) {
219
+ const desc = model.descriptions[`${cmd} ${sub}`] ?? '';
220
+ lines.push(`complete -c kite -n '${guard}' -a ${sub} -d '${desc}'`);
221
+ }
222
+ }
223
+ lines.push('', '# Global flags.');
224
+ for (const flag of model.globalFlags) {
225
+ lines.push(`complete -c kite -l ${flag.replace(/^--/, '')}`);
226
+ }
227
+ return `${lines.join('\n')}\n`;
228
+ }
229
+ // --- helpers ---------------------------------------------------------------
230
+ function isShell(value) {
231
+ return SHELLS.includes(value);
232
+ }
233
+ /** Best-effort shell detection from $SHELL, e.g. "/usr/bin/fish" -> "fish". */
234
+ function detectShell() {
235
+ const shell = process.env['SHELL'];
236
+ if (!shell)
237
+ return undefined;
238
+ const base = shell.split('/').pop();
239
+ return base && isShell(base) ? base : undefined;
240
+ }
@@ -0,0 +1,4 @@
1
+ import type { CommandFactory } from './types.js';
2
+ export declare const doctorCommands: CommandFactory;
3
+ /** True when `current` is >= `floor`, comparing major.minor.patch numerically. */
4
+ export declare function meetsFloor(current: string, floor: string): boolean;
@@ -0,0 +1,190 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { createServer } from 'node:net';
3
+ import { getSecret, keyringAvailable } from '../core/credentials.js';
4
+ import { ExitCode } from '../core/errors.js';
5
+ import { configFile } from '../core/paths.js';
6
+ import { isExpired, timeUntilExpiry } from '../core/session.js';
7
+ import { renderKeyValue } from '../output/table.js';
8
+ export const doctorCommands = (program, run) => {
9
+ program
10
+ .command('doctor')
11
+ .description('Run offline health checks on your configuration, credentials, and session')
12
+ .action(run(doctor));
13
+ };
14
+ async function doctor(ctx) {
15
+ const checks = [
16
+ checkNode(),
17
+ await checkConfigFile(),
18
+ await checkKeyring(),
19
+ await checkCredentials(ctx),
20
+ checkSession(ctx),
21
+ await checkCallbackPort(ctx),
22
+ ];
23
+ const worst = checks.some((c) => c.status === 'fail')
24
+ ? 'fail'
25
+ : checks.some((c) => c.status === 'warn')
26
+ ? 'warn'
27
+ : 'ok';
28
+ if (ctx.io.json) {
29
+ ctx.io.writeJson({
30
+ ok: worst !== 'fail',
31
+ profile: ctx.profile.name,
32
+ env: ctx.env,
33
+ checks: checks.map((c) => ({
34
+ name: c.name,
35
+ status: c.status,
36
+ detail: c.detail,
37
+ ...(c.hint ? { hint: c.hint } : {}),
38
+ })),
39
+ });
40
+ if (worst === 'fail')
41
+ process.exitCode = ExitCode.Failure;
42
+ return;
43
+ }
44
+ const { io } = ctx;
45
+ const rows = [];
46
+ for (const check of checks) {
47
+ rows.push([`${icon(io, check.status)} ${check.name}`, check.detail]);
48
+ if (check.hint)
49
+ rows.push(['', io.dim(check.hint)]);
50
+ }
51
+ io.line(renderKeyValue(io, rows));
52
+ io.note('');
53
+ if (worst === 'fail') {
54
+ io.error('Some checks failed. See the hints above.');
55
+ process.exitCode = ExitCode.Failure;
56
+ }
57
+ else if (worst === 'warn') {
58
+ io.warn('All essential checks passed, with warnings.');
59
+ io.info('Run `kite whoami` to confirm the session is live on Kite (doctor is offline).');
60
+ }
61
+ else {
62
+ io.success('Everything looks healthy.');
63
+ io.info('Run `kite whoami` to confirm the session against Kite (doctor is offline).');
64
+ }
65
+ }
66
+ // --- individual checks -----------------------------------------------------
67
+ /** Node must meet the floor declared in package.json `engines`. */
68
+ function checkNode() {
69
+ const current = process.versions.node;
70
+ const floor = '22.12.0';
71
+ return meetsFloor(current, floor)
72
+ ? { name: 'Node runtime', status: 'ok', detail: `v${current}` }
73
+ : {
74
+ name: 'Node runtime',
75
+ status: 'fail',
76
+ detail: `v${current} is below the required v${floor}`,
77
+ hint: `Upgrade to Node ${floor} or newer.`,
78
+ };
79
+ }
80
+ async function checkConfigFile() {
81
+ const path = configFile();
82
+ let info;
83
+ try {
84
+ info = await stat(path);
85
+ }
86
+ catch (err) {
87
+ if (err.code === 'ENOENT') {
88
+ // No file is fine — defaults apply until the first `config set`.
89
+ return { name: 'Config file', status: 'ok', detail: 'none yet (using built-in defaults)' };
90
+ }
91
+ return { name: 'Config file', status: 'fail', detail: `cannot read ${path}`, hint: err.message };
92
+ }
93
+ // Windows models permissions through ACLs, not the POSIX mode bits, so the
94
+ // 0600 assertion only makes sense off Windows.
95
+ if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
96
+ const mode = (info.mode & 0o777).toString(8).padStart(3, '0');
97
+ return {
98
+ name: 'Config file',
99
+ status: 'warn',
100
+ detail: `${path} is mode ${mode} (world- or group-readable)`,
101
+ hint: `Tighten it: chmod 600 ${path}`,
102
+ };
103
+ }
104
+ return { name: 'Config file', status: 'ok', detail: path };
105
+ }
106
+ async function checkKeyring() {
107
+ return (await keyringAvailable())
108
+ ? { name: 'Credential store', status: 'ok', detail: 'OS keyring available' }
109
+ : {
110
+ name: 'Credential store',
111
+ status: 'warn',
112
+ detail: 'no OS keyring — falling back to an encrypted file',
113
+ hint: 'Set KITE_CREDENTIALS_PASSPHRASE to enable the encrypted file store on this machine.',
114
+ };
115
+ }
116
+ async function checkCredentials(ctx) {
117
+ if (ctx.env === 'sandbox') {
118
+ return { name: 'API secret', status: 'ok', detail: 'using the public sandbox credentials' };
119
+ }
120
+ const secret = await getSecret('api_secret', { scope: ctx.credentialScope });
121
+ return secret
122
+ ? { name: 'API secret', status: 'ok', detail: `stored (${secret.backend})` }
123
+ : {
124
+ name: 'API secret',
125
+ status: 'warn',
126
+ detail: 'not stored',
127
+ hint: 'Run `kite login` (or set KITE_API_SECRET) so re-login and signing work.',
128
+ };
129
+ }
130
+ /** Cached session metadata only — liveness against Kite is `kite whoami`'s job. */
131
+ function checkSession(ctx) {
132
+ const loginHint = ctx.profile.name === 'default' ? 'Run `kite login`.' : `Run \`kite --profile ${ctx.profile.name} login\`.`;
133
+ if (!ctx.session) {
134
+ return { name: 'Session', status: 'warn', detail: 'not logged in', hint: loginHint };
135
+ }
136
+ if (isExpired(ctx.session)) {
137
+ return {
138
+ name: 'Session',
139
+ status: 'warn',
140
+ detail: 'expired (Kite invalidates tokens at 06:00 IST daily)',
141
+ hint: loginHint,
142
+ };
143
+ }
144
+ const who = ctx.session.userName ?? ctx.session.userId;
145
+ return { name: 'Session', status: 'ok', detail: `${who}, expires in ${timeUntilExpiry(ctx.session)}` };
146
+ }
147
+ async function checkCallbackPort(ctx) {
148
+ const port = ctx.config.redirectPort;
149
+ const free = await portIsFree(port);
150
+ return free
151
+ ? { name: 'Login callback port', status: 'ok', detail: `127.0.0.1:${port} is free` }
152
+ : {
153
+ name: 'Login callback port',
154
+ status: 'warn',
155
+ detail: `127.0.0.1:${port} is in use`,
156
+ hint: 'Another process holds the callback port; `kite login` may fail until it frees, or change redirectPort.',
157
+ };
158
+ }
159
+ // --- helpers ---------------------------------------------------------------
160
+ function icon(io, status) {
161
+ switch (status) {
162
+ case 'ok':
163
+ return io.green('✓');
164
+ case 'warn':
165
+ return io.yellow('!');
166
+ case 'fail':
167
+ return io.red('✗');
168
+ }
169
+ }
170
+ /** True when `current` is >= `floor`, comparing major.minor.patch numerically. */
171
+ export function meetsFloor(current, floor) {
172
+ const c = current.split('.').map((n) => Number.parseInt(n, 10));
173
+ const f = floor.split('.').map((n) => Number.parseInt(n, 10));
174
+ for (let i = 0; i < 3; i++) {
175
+ const a = c[i] ?? 0;
176
+ const b = f[i] ?? 0;
177
+ if (a !== b)
178
+ return a > b;
179
+ }
180
+ return true;
181
+ }
182
+ /** Bind-and-release probe: can `kite login` open its loopback callback server? */
183
+ function portIsFree(port, host = '127.0.0.1') {
184
+ return new Promise((resolve) => {
185
+ const server = createServer();
186
+ server.once('error', () => resolve(false));
187
+ server.once('listening', () => server.close(() => resolve(true)));
188
+ server.listen(port, host);
189
+ });
190
+ }
@@ -0,0 +1,25 @@
1
+ import type { Command } from 'commander';
2
+ import type { Runner } from './types.js';
3
+ /**
4
+ * Command wiring, factored out of run.ts so there is a single source of truth
5
+ * for the command tree.
6
+ *
7
+ * run() consumes this to build the live program; `kite completion` consumes it
8
+ * with a no-op runner to introspect command and flag names for shell
9
+ * completions. Keeping both on the same registration path means a new command
10
+ * or flag is completable the moment it is added, with nothing to keep in sync.
11
+ */
12
+ /**
13
+ * Attach the global options every command inherits. Split from the name /
14
+ * version / output wiring in run.ts, which is invocation-specific, so an
15
+ * introspection-only program can still enumerate the same global flags.
16
+ */
17
+ export declare function applyGlobalOptions(program: Command): Command;
18
+ /**
19
+ * Register every command group on `program`, grouped for a readable `--help`.
20
+ *
21
+ * The `run` runner wraps each handler with context construction and error
22
+ * reporting in the real CLI; completion passes a no-op that only needs the
23
+ * command definitions, not their behaviour.
24
+ */
25
+ export declare function registerCommands(program: Command, run: Runner): Promise<void>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Command wiring, factored out of run.ts so there is a single source of truth
3
+ * for the command tree.
4
+ *
5
+ * run() consumes this to build the live program; `kite completion` consumes it
6
+ * with a no-op runner to introspect command and flag names for shell
7
+ * completions. Keeping both on the same registration path means a new command
8
+ * or flag is completable the moment it is added, with nothing to keep in sync.
9
+ */
10
+ /**
11
+ * Attach the global options every command inherits. Split from the name /
12
+ * version / output wiring in run.ts, which is invocation-specific, so an
13
+ * introspection-only program can still enumerate the same global flags.
14
+ */
15
+ export function applyGlobalOptions(program) {
16
+ return (program
17
+ .option('--json', 'Emit JSON instead of formatted tables')
18
+ .option('--color <when>', 'Colour output: auto, always, or never')
19
+ // Deliberately no `-q` short form: it would shadow `-q, --quantity` on the
20
+ // order and GTT subcommands, which is typed far more often than --quiet.
21
+ .option('--quiet', 'Suppress informational messages')
22
+ .option('--debug', 'Print redacted request diagnostics to stderr')
23
+ .option('--env <env>', 'Environment: production or sandbox')
24
+ .option('--profile <name>', 'Account profile to use (see `kite profiles`)')
25
+ .option('-y, --yes', 'Skip confirmation prompts (use with care)')
26
+ .option('--dry-run', 'Show what would happen without sending anything to Kite'));
27
+ }
28
+ /**
29
+ * Register every command group on `program`, grouped for a readable `--help`.
30
+ *
31
+ * The `run` runner wraps each handler with context construction and error
32
+ * reporting in the real CLI; completion passes a no-op that only needs the
33
+ * command definitions, not their behaviour.
34
+ */
35
+ export async function registerCommands(program, run) {
36
+ // Imported here rather than at module top so a single invocation only pays for
37
+ // parsing the command modules once run() (or completion) actually asks for
38
+ // them, not on every `import` of this file.
39
+ const { authCommands } = await import('./auth.js');
40
+ const { profileCommands } = await import('./profiles.js');
41
+ const { portfolioCommands } = await import('./portfolio.js');
42
+ const { marketCommands } = await import('./market.js');
43
+ const { orderCommands } = await import('./orders.js');
44
+ const { gttCommands } = await import('./gtt.js');
45
+ const { alertCommands } = await import('./alerts.js');
46
+ const { marginCommands } = await import('./margins.js');
47
+ const { mfCommands } = await import('./mf.js');
48
+ const { watchCommands } = await import('./watch.js');
49
+ const { configCommands } = await import('./config.js');
50
+ const { doctorCommands } = await import('./doctor.js');
51
+ const { completionCommands } = await import('./completion.js');
52
+ // commandsGroup applies to every command registered after it, so the group
53
+ // is set immediately before each block. With ~25 commands this is the
54
+ // difference between a readable --help and a wall of text.
55
+ program.commandsGroup('Account:');
56
+ authCommands(program, run);
57
+ profileCommands(program, run);
58
+ program.commandsGroup('Portfolio:');
59
+ portfolioCommands(program, run);
60
+ program.commandsGroup('Mutual funds:');
61
+ mfCommands(program, run);
62
+ program.commandsGroup('Market data:');
63
+ marketCommands(program, run);
64
+ program.commandsGroup('Trading:');
65
+ orderCommands(program, run);
66
+ gttCommands(program, run);
67
+ alertCommands(program, run);
68
+ marginCommands(program, run);
69
+ program.commandsGroup('Streaming:');
70
+ watchCommands(program, run);
71
+ program.commandsGroup('Settings:');
72
+ configCommands(program, run);
73
+ doctorCommands(program, run);
74
+ completionCommands(program, run);
75
+ }
@@ -59,6 +59,15 @@ export declare function waitForCallback(opts: CallbackServerOptions): {
59
59
  close: () => void;
60
60
  };
61
61
  export declare function redirectUrlFor(port: number, path: string): string;
62
+ /**
63
+ * Copy text to the OS clipboard, returning whether it succeeded.
64
+ *
65
+ * Like {@link openBrowser}, the text is piped to a fixed binary's stdin — never
66
+ * through a shell and never as an argv element — so a URL's `&`/`=` cannot
67
+ * become word-splitting or command injection. On Linux there is no single
68
+ * clipboard tool, so we try the common ones in turn (Wayland first, then X11).
69
+ */
70
+ export declare function copyToClipboard(text: string): Promise<boolean>;
62
71
  /**
63
72
  * Open a URL in the user's default browser.
64
73
  *
package/dist/core/auth.js CHANGED
@@ -175,6 +175,45 @@ function escapeHtml(value) {
175
175
  export function redirectUrlFor(port, path) {
176
176
  return `http://127.0.0.1:${port}${path}`;
177
177
  }
178
+ /**
179
+ * Copy text to the OS clipboard, returning whether it succeeded.
180
+ *
181
+ * Like {@link openBrowser}, the text is piped to a fixed binary's stdin — never
182
+ * through a shell and never as an argv element — so a URL's `&`/`=` cannot
183
+ * become word-splitting or command injection. On Linux there is no single
184
+ * clipboard tool, so we try the common ones in turn (Wayland first, then X11).
185
+ */
186
+ export async function copyToClipboard(text) {
187
+ const candidates = process.platform === 'darwin'
188
+ ? [['pbcopy', []]]
189
+ : process.platform === 'win32'
190
+ ? [['clip', []]]
191
+ : [
192
+ ['wl-copy', []],
193
+ ['xclip', ['-selection', 'clipboard']],
194
+ ['xsel', ['--clipboard', '--input']],
195
+ ];
196
+ for (const [command, args] of candidates) {
197
+ if (await pipeToCommand(command, args, text))
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+ /** Spawn `command`, write `text` to its stdin, resolve true on a clean exit. */
203
+ function pipeToCommand(command, args, text) {
204
+ return import('node:child_process').then(({ spawn }) => new Promise((resolve) => {
205
+ try {
206
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'], shell: false });
207
+ child.on('error', () => resolve(false));
208
+ child.on('close', (code) => resolve(code === 0));
209
+ child.stdin.on('error', () => resolve(false));
210
+ child.stdin.end(text);
211
+ }
212
+ catch {
213
+ resolve(false);
214
+ }
215
+ }));
216
+ }
178
217
  /**
179
218
  * Open a URL in the user's default browser.
180
219
  *
@@ -177,7 +177,14 @@ export class KiteClient {
177
177
  return new KiteCliError('Cancelled.', ExitCode.Aborted);
178
178
  }
179
179
  const message = err instanceof Error ? err.message : String(err);
180
- return new NetworkError(`Could not reach Kite: ${redactString(message)}`, 'Check your network connection.');
180
+ // undici's fetch wraps the actual failure (ECONNRESET, ENOTFOUND, a TLS
181
+ // error, ...) in `cause` and sets `message` to the unhelpful constant
182
+ // "fetch failed" — surface the cause too, or every transient network
183
+ // problem looks identical and undebuggable.
184
+ const cause = err instanceof Error ? err.cause : undefined;
185
+ const causeMessage = cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : undefined;
186
+ const detail = causeMessage && causeMessage !== message ? `${message}: ${causeMessage}` : message;
187
+ return new NetworkError(`Could not reach Kite: ${redactString(detail)}`, 'Check your network connection.');
181
188
  }
182
189
  handleResponse(response, text, schema, url) {
183
190
  let payload;
package/dist/run.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { Command, CommanderError } from 'commander';
3
+ import { applyGlobalOptions, registerCommands } from './commands/register.js';
3
4
  import { createContext } from './context.js';
4
5
  import { AbortedError, ExitCode, KiteCliError } from './core/errors.js';
5
6
  import { redactString } from './core/redact.js';
@@ -36,16 +37,6 @@ export async function run(opts = {}) {
36
37
  .name('kite')
37
38
  .description('Unofficial command-line interface for the Zerodha Kite Connect API (not affiliated with Zerodha)')
38
39
  .version(VERSION, '-v, --version')
39
- .option('--json', 'Emit JSON instead of formatted tables')
40
- .option('--color <when>', 'Colour output: auto, always, or never')
41
- // Deliberately no `-q` short form: it would shadow `-q, --quantity` on the
42
- // order and GTT subcommands, which is typed far more often than --quiet.
43
- .option('--quiet', 'Suppress informational messages')
44
- .option('--debug', 'Print redacted request diagnostics to stderr')
45
- .option('--env <env>', 'Environment: production or sandbox')
46
- .option('--profile <name>', 'Account profile to use (see `kite profiles`)')
47
- .option('-y, --yes', 'Skip confirmation prompts (use with care)')
48
- .option('--dry-run', 'Show what would happen without sending anything to Kite')
49
40
  .showHelpAfterError('(run `kite --help` for usage)')
50
41
  .configureOutput({
51
42
  writeOut: (str) => (opts.streams?.stdout ?? process.stdout).write(str),
@@ -54,6 +45,7 @@ export async function run(opts = {}) {
54
45
  // Throw instead of calling process.exit, so `run()` stays testable and
55
46
  // always returns its exit code.
56
47
  .exitOverride();
48
+ applyGlobalOptions(program);
57
49
  /** Wraps a handler with context construction and error reporting. */
58
50
  const withContext = (handler) => async (...args) => {
59
51
  // Commander passes (…arguments, options, command).
@@ -68,40 +60,11 @@ export async function run(opts = {}) {
68
60
  exitCode = reportError(err, ctx.io);
69
61
  }
70
62
  };
71
- // Registered lazily so a bare `kite quote` never pays to import the
72
- // dashboard renderer, the ticker, or the prompt library.
73
- const { authCommands } = await import('./commands/auth.js');
74
- const { profileCommands } = await import('./commands/profiles.js');
75
- const { portfolioCommands } = await import('./commands/portfolio.js');
76
- const { marketCommands } = await import('./commands/market.js');
77
- const { orderCommands } = await import('./commands/orders.js');
78
- const { gttCommands } = await import('./commands/gtt.js');
79
- const { alertCommands } = await import('./commands/alerts.js');
80
- const { marginCommands } = await import('./commands/margins.js');
81
- const { mfCommands } = await import('./commands/mf.js');
82
- const { watchCommands } = await import('./commands/watch.js');
83
- const { configCommands } = await import('./commands/config.js');
84
- // commandsGroup applies to every command registered after it, so the group
85
- // is set immediately before each block. With ~25 commands this is the
86
- // difference between a readable --help and a wall of text.
87
- program.commandsGroup('Account:');
88
- authCommands(program, withContext);
89
- profileCommands(program, withContext);
90
- program.commandsGroup('Portfolio:');
91
- portfolioCommands(program, withContext);
92
- program.commandsGroup('Mutual funds:');
93
- mfCommands(program, withContext);
94
- program.commandsGroup('Market data:');
95
- marketCommands(program, withContext);
96
- program.commandsGroup('Trading:');
97
- orderCommands(program, withContext);
98
- gttCommands(program, withContext);
99
- alertCommands(program, withContext);
100
- marginCommands(program, withContext);
101
- program.commandsGroup('Streaming:');
102
- watchCommands(program, withContext);
103
- program.commandsGroup('Settings:');
104
- configCommands(program, withContext);
63
+ // Registered through the shared registration path (see commands/register.ts)
64
+ // so run() and `kite completion` build the exact same command tree. Modules
65
+ // are still imported lazily inside registerCommands, so a bare `kite quote`
66
+ // never pays to parse the dashboard renderer or the ticker up front.
67
+ await registerCommands(program, withContext);
105
68
  program.addHelpText('after', `
106
69
  Examples:
107
70
  $ kite login Authenticate and store a session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pungoyal/kite-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Unofficial command-line interface for the Zerodha Kite Connect API — not affiliated with or endorsed by Zerodha",
5
5
  "keywords": [
6
6
  "zerodha",