@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 +101 -9
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.js +74 -5
- package/dist/commands/completion.d.ts +35 -0
- package/dist/commands/completion.js +240 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +190 -0
- package/dist/commands/mcp.d.ts +2 -0
- package/dist/commands/mcp.js +83 -0
- package/dist/commands/orders.js +75 -1
- package/dist/commands/register.d.ts +25 -0
- package/dist/commands/register.js +78 -0
- package/dist/core/auth.d.ts +9 -0
- package/dist/core/auth.js +39 -0
- package/dist/core/client.js +8 -1
- package/dist/core/mcp.d.ts +56 -0
- package/dist/core/mcp.js +166 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/run.js +8 -45
- package/dist/safety.d.ts +8 -0
- package/dist/safety.js +9 -1
- package/package.json +8 -3
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
|
-
|
|
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
|
package/dist/commands/auth.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
|
154
|
-
io.note(` ${loginUrl}`);
|
|
159
|
+
io.warn('Could not open a browser automatically. Open the URL above manually.');
|
|
155
160
|
}
|
|
156
|
-
|
|
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
|
+
}
|