@pungoyal/kite-cli 0.3.0 → 0.5.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
@@ -28,6 +28,52 @@ $ kite holdings
28
28
  Day's change +₹287.40
29
29
  ```
30
30
 
31
+ ## Why you can trust it
32
+
33
+ It places real orders with real money, under an unofficial banner — so the safety
34
+ is built into the architecture, and every claim below is verifiable rather than
35
+ aspirational:
36
+
37
+ - **Try it risk-free first.** Zerodha runs a public sandbox with fake money and no
38
+ subscription. `kite login --env sandbox` gives you a full session to rehearse
39
+ every command against before you ever point it at your own account.
40
+ - **It never silently moves money.** Every order previews the *resolved* order —
41
+ the actual contract, lot size and computed value, not an echo of your flags —
42
+ and waits for confirmation. There is deliberately no config key that turns that
43
+ off ([Safety](#safety)).
44
+ - **It never blindly retries a write.** Kite has no idempotency key, so a
45
+ timed-out order is genuinely ambiguous. Rather than retry, the CLI tags every
46
+ order and reconciles against the orderbook to tell you what actually happened
47
+ ([Safety](#safety)).
48
+ - **Your secrets stay put.** The API secret lives in your OS keyring (or an
49
+ encrypted file), is never accepted as a command-line argument, and is scrubbed
50
+ from every log, error and stack trace — with [tests](https://github.com/pungoyal/kite-cli/blob/main/test/redact.test.ts)
51
+ that prove it ([Security](#security)).
52
+ - **Verifiable builds.** Published only from CI via [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/)
53
+ (OIDC, no long-lived token). Check the provenance yourself with `npm audit signatures`.
54
+ - **A small, auditable surface.** ~10 direct dependencies, most of them
55
+ zero-dependency, enforced by a dependency budget in CI.
56
+
57
+ ## How it compares
58
+
59
+ Zerodha maintains excellent official SDKs — [`pykiteconnect`](https://github.com/zerodha/pykiteconnect)
60
+ and [`kiteconnectjs`](https://github.com/zerodha/kiteconnectjs). If you're building an
61
+ application, reach for those: they give you the full API, programmatically, and they're
62
+ the foundation the whole ecosystem is built on. `kite-cli` is complementary — the same
63
+ API as a ready-to-use tool, for when you'd rather not write code:
64
+
65
+ - **Zero code for everyday use.** `kite holdings`, `kite watch --holdings`,
66
+ `kite orders place …` run straight from the shell — and from any language that can
67
+ shell out.
68
+ - **An opinionated safety layer.** A kill switch, per-order value cap, resolved-order
69
+ confirmation, and unique-tag reconciliation for ambiguous writes come built in —
70
+ decisions the official SDKs deliberately leave open so each application can make its
71
+ own.
72
+ - **Composable output.** Every command speaks `--json` on stdout, so it drops
73
+ straight into `jq`, cron jobs, and pipelines.
74
+ - **A library too, when you need one.** The same client is [exported](#library-use),
75
+ so you can start in the shell and drop into code without switching tools.
76
+
31
77
  ## Install
32
78
 
33
79
  ```bash
@@ -38,7 +84,21 @@ Requires **Node 22.12 or newer**.
38
84
 
39
85
  ## Getting started
40
86
 
41
- You need a [Kite Connect](https://developers.kite.trade) app to get an API key and secret. Set your app's redirect URL to:
87
+ **Kick the tyres first no account, no subscription, no risk.** Zerodha runs a
88
+ public sandbox with fake money, and every command behaves exactly as it does
89
+ against a real account:
90
+
91
+ ```bash
92
+ kite login --env sandbox
93
+ kite --env sandbox holdings
94
+ kite --env sandbox orders place NSE:INFY --side BUY --quantity 1 --dry-run
95
+ ```
96
+
97
+ Learn the commands and see the confirmations there before any real money is
98
+ involved.
99
+
100
+ **Then connect your own account.** You need a [Kite Connect](https://developers.kite.trade)
101
+ app to get an API key and secret. Set your app's redirect URL to:
42
102
 
43
103
  ```
44
104
  http://127.0.0.1:51101/callback
@@ -50,14 +110,7 @@ Then:
50
110
  kite login
51
111
  ```
52
112
 
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.
54
-
55
- **Want to try it without a subscription or real money?** Zerodha runs a public sandbox:
56
-
57
- ```bash
58
- kite login --env sandbox
59
- kite --env sandbox holdings
60
- ```
113
+ 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.
61
114
 
62
115
  **Running more than one Zerodha account?** See [Multiple accounts](#multiple-accounts).
63
116
 
@@ -147,6 +200,7 @@ kite orders list --open # working orders only
147
200
  kite orders get 250720000123456 # full state history and fills
148
201
  kite orders modify <id> --price 1520
149
202
  kite orders cancel <id>
203
+ kite orders reconcile <tag> # did an order that seemed to fail actually reach Kite?
150
204
  kite trades # today's fills
151
205
 
152
206
  kite gtt list
@@ -245,6 +299,31 @@ kite whoami --json || kite login # exit code 3 means "no session"
245
299
 
246
300
  `NO_COLOR` is honoured, and colour is disabled automatically when stdout is not a TTY.
247
301
 
302
+ ### Agents (MCP)
303
+
304
+ `kite mcp` exposes Kite's **read-only** endpoints to an LLM agent over the
305
+ [Model Context Protocol](https://modelcontextprotocol.io), so Claude — or any MCP
306
+ client — can answer "how's my portfolio doing?" against live data. It can read
307
+ your profile, holdings, positions, funds, orders, trades, quotes and instruments;
308
+ it **cannot** place, modify or cancel anything. Trading stays at a
309
+ human-confirmed terminal, by design.
310
+
311
+ Point an MCP client at it — try the sandbox first:
312
+
313
+ ```json
314
+ {
315
+ "mcpServers": {
316
+ "kite": {
317
+ "command": "kite",
318
+ "args": ["mcp", "--env", "sandbox"]
319
+ }
320
+ }
321
+ }
322
+ ```
323
+
324
+ Drop the `--env sandbox` args to run against your real account (still read-only).
325
+ The server needs a live session, so `kite login` first.
326
+
248
327
  ## Multiple accounts
249
328
 
250
329
  If you run more than one Zerodha account — your own, a family member's, an HUF — each
@@ -329,6 +408,17 @@ $ kite orders place NSE:INFY -s BUY -q 10 --type LIMIT --price 1500 --yes
329
408
  · It was not placed twice. Do not re-run this command.
330
409
  ```
331
410
 
411
+ That reconciliation happens automatically, but only while the placing process is alive. If you lose it — a killed shell, a crashed script, a slept laptop — run it again on demand:
412
+
413
+ ```console
414
+ $ kite orders reconcile kcmrt88o648c1bce
415
+ ✓ Found an order for tag kcmrt88o648c1bce:
416
+ … COMPLETE …
417
+ · If placing this order looked like it failed, it went through — do not place it again.
418
+ ```
419
+
420
+ With no tag, `kite orders reconcile` lists the orders this CLI placed today, and `--json` carries a `placed` boolean for scripts.
421
+
332
422
  Automatic retries are restricted to `GET`/`HEAD` at the transport layer. `POST`, `PUT` and `DELETE` are never retried — in this API those are place, modify and cancel.
333
423
 
334
424
  ## Security
@@ -394,6 +484,8 @@ searchable site at **[pungoyal.github.io/kite-cli](https://pungoyal.github.io/ki
394
484
 
395
485
  - [Safety model](https://pungoyal.github.io/kite-cli/safety) — the full layered safety model (kill
396
486
  switch, value cap, confirmation escalation, order-tag reconciliation).
487
+ - [MCP server](https://pungoyal.github.io/kite-cli/mcp) — the read-only Model Context Protocol
488
+ server for LLM agents: its tools, setup, and why it exposes no writes.
397
489
  - [Configuration](https://pungoyal.github.io/kite-cli/configuration) — every config key and
398
490
  environment variable, with precedence.
399
491
  - [Troubleshooting](https://pungoyal.github.io/kite-cli/troubleshooting) — symptom-first fixes
@@ -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;