@pungoyal/kite-cli 0.1.1 → 0.2.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.
@@ -1,12 +1,13 @@
1
1
  import { isCancel, note, password, text } from '@clack/prompts';
2
2
  import { buildLoginUrl, computeChecksum, generateState, openBrowser, redirectUrlFor, waitForCallback, } from '../core/auth.js';
3
- import { SANDBOX_CREDENTIALS, saveConfig } from '../core/config.js';
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';
6
+ import { getProfile, listProfileNames } from '../core/profiles.js';
6
7
  import { maskSecret, registerSecret } from '../core/redact.js';
7
8
  import { clearSessionMeta, isExpired, loadSessionMeta, nextTokenExpiry, saveSessionMeta, timeUntilExpiry, } from '../core/session.js';
8
9
  import { dateTime } from '../output/format.js';
9
- import { renderKeyValue } from '../output/table.js';
10
+ import { printTable, renderKeyValue } from '../output/table.js';
10
11
  export const authCommands = (program, run) => {
11
12
  program
12
13
  .command('login')
@@ -20,10 +21,15 @@ export const authCommands = (program, run) => {
20
21
  .description('Invalidate the session and remove the stored access token')
21
22
  .option('--all', 'Also remove the stored API secret')
22
23
  .action(run(logout));
23
- program.command('whoami').description('Show the current session and account details').action(run(whoami));
24
+ program
25
+ .command('whoami')
26
+ .description('Show the current session and account details')
27
+ .option('--all', 'List every configured profile and its session status')
28
+ .action(run(whoami));
24
29
  };
25
30
  async function login(ctx, opts) {
26
31
  const { io } = ctx;
32
+ const profileName = ctx.profile.name;
27
33
  if (!opts.force && ctx.client.hasSession() && ctx.session && !isExpired(ctx.session)) {
28
34
  io.success(`Already logged in as ${io.bold(ctx.session.userName ?? ctx.session.userId)} ` +
29
35
  `(expires in ${timeUntilExpiry(ctx.session)}).`);
@@ -31,8 +37,11 @@ async function login(ctx, opts) {
31
37
  return;
32
38
  }
33
39
  const isSandbox = ctx.env === 'sandbox';
40
+ if (profileName !== 'default' && !isSandbox) {
41
+ io.info(`Logging in to profile ${io.bold(profileName)}.`);
42
+ }
34
43
  // --- credentials -------------------------------------------------------
35
- let apiKey = opts.apiKey ?? (isSandbox ? SANDBOX_CREDENTIALS.apiKey : (ctx.config.apiKey ?? ''));
44
+ let apiKey = opts.apiKey ?? (isSandbox ? SANDBOX_CREDENTIALS.apiKey : ctx.profile.apiKey);
36
45
  let apiSecret;
37
46
  if (isSandbox) {
38
47
  apiKey = SANDBOX_CREDENTIALS.apiKey;
@@ -43,7 +52,7 @@ async function login(ctx, opts) {
43
52
  if (!apiKey) {
44
53
  apiKey = await promptText('Kite Connect API key', 'Create an app at https://developers.kite.trade to get one.');
45
54
  }
46
- const stored = await getSecret('api_secret', { env: ctx.env });
55
+ const stored = await getSecret('api_secret', { scope: ctx.credentialScope });
47
56
  if (stored && !opts.force) {
48
57
  apiSecret = stored.value;
49
58
  io.info(`Using stored API secret (${maskSecret(apiSecret)}) from the ${stored.backend}.`);
@@ -83,10 +92,10 @@ async function login(ctx, opts) {
83
92
  registerSecret(session.access_token);
84
93
  // --- persist ------------------------------------------------------------
85
94
  const backend = await setSecret('access_token', session.access_token, {
86
- env: ctx.env,
95
+ scope: ctx.credentialScope,
87
96
  });
88
97
  if (!isSandbox) {
89
- await setSecret('api_secret', apiSecret, { env: ctx.env });
98
+ await setSecret('api_secret', apiSecret, { scope: ctx.credentialScope });
90
99
  }
91
100
  const expiresAt = nextTokenExpiry();
92
101
  await saveSessionMeta({
@@ -95,25 +104,31 @@ async function login(ctx, opts) {
95
104
  broker: session.broker,
96
105
  env: ctx.env,
97
106
  apiKey,
107
+ profile: profileName,
98
108
  expiresAt: expiresAt.toISOString(),
99
109
  loginTime: session.login_time,
100
110
  exchanges: session.exchanges,
101
111
  products: session.products,
102
112
  });
103
- if (ctx.config.apiKey !== apiKey && !isSandbox) {
104
- await saveConfig({ ...ctx.config, apiKey });
113
+ // Register the profile's api key so subsequent runs find it, and so a new
114
+ // named profile becomes discoverable in `kite profiles`. The sandbox uses the
115
+ // public constant, so there is nothing worth persisting for it.
116
+ if (!isSandbox) {
117
+ await persistProfileApiKey(profileName, ctx.env, apiKey);
105
118
  }
106
119
  if (io.json) {
107
120
  io.writeJson({
108
121
  user_id: session.user_id,
109
122
  user_name: session.user_name,
110
123
  env: ctx.env,
124
+ profile: profileName,
111
125
  expires_at: expiresAt.toISOString(),
112
126
  storage: backend,
113
127
  });
114
128
  return;
115
129
  }
116
- io.success(`Logged in as ${io.bold(session.user_name ?? session.user_id)}.`);
130
+ const asProfile = profileName === 'default' ? '' : ` on profile ${io.bold(profileName)}`;
131
+ io.success(`Logged in as ${io.bold(session.user_name ?? session.user_id)}${asProfile}.`);
117
132
  io.info(`Session expires ${dateTime(expiresAt)} IST (Kite invalidates all tokens at 6 AM daily).`);
118
133
  io.info(backend === 'keyring'
119
134
  ? 'Credentials stored in your OS keyring.'
@@ -166,11 +181,12 @@ function extractState(loginUrl) {
166
181
  }
167
182
  async function logout(ctx, opts) {
168
183
  const { io } = ctx;
184
+ const profileName = ctx.profile.name;
169
185
  // Best-effort server-side invalidation; a failure here must not stop us
170
186
  // clearing local state, or the user is stuck with credentials they cannot
171
187
  // remove.
172
188
  if (ctx.client.hasSession()) {
173
- const stored = await getSecret('access_token', { env: ctx.env });
189
+ const stored = await getSecret('access_token', { scope: ctx.credentialScope });
174
190
  if (stored) {
175
191
  try {
176
192
  await ctx.api.invalidateSession(stored.value);
@@ -183,53 +199,63 @@ async function logout(ctx, opts) {
183
199
  }
184
200
  const { deleteSecret } = await import('../core/credentials.js');
185
201
  if (opts.all) {
186
- await deleteAllSecrets({ env: ctx.env });
202
+ await deleteAllSecrets({ scope: ctx.credentialScope });
187
203
  }
188
204
  else {
189
- await deleteSecret('access_token', { env: ctx.env });
205
+ await deleteSecret('access_token', { scope: ctx.credentialScope });
190
206
  }
191
- await clearSessionMeta();
207
+ await clearSessionMeta(profileName);
208
+ const onProfile = profileName === 'default' ? '' : ` (profile ${io.bold(profileName)})`;
192
209
  if (io.json) {
193
- io.writeJson({ logged_out: true, removed_api_secret: Boolean(opts.all) });
210
+ io.writeJson({ logged_out: true, profile: profileName, removed_api_secret: Boolean(opts.all) });
194
211
  return;
195
212
  }
196
- io.success(opts.all ? 'Logged out and removed the stored API secret.' : 'Logged out.');
213
+ io.success(opts.all ? `Logged out and removed the stored API secret${onProfile}.` : `Logged out${onProfile}.`);
197
214
  if (!opts.all)
198
215
  io.info('Your API secret is still stored. Use `kite logout --all` to remove it.');
199
216
  }
200
- async function whoami(ctx) {
217
+ async function whoami(ctx, opts) {
218
+ if (opts.all) {
219
+ await whoamiAll(ctx);
220
+ return;
221
+ }
201
222
  const { io } = ctx;
202
- const meta = await loadSessionMeta();
223
+ const profileName = ctx.profile.name;
224
+ const meta = await loadSessionMeta(profileName);
203
225
  if (!ctx.client.hasSession()) {
204
226
  // Exit non-zero in both modes: a script checking `kite whoami` must be able
205
227
  // to branch on the exit code, not parse stdout.
206
228
  process.exitCode = ExitCode.Auth;
207
229
  if (io.json) {
208
- io.writeJson({ logged_in: false });
230
+ io.writeJson({ logged_in: false, profile: profileName });
209
231
  return;
210
232
  }
211
- io.error('Not logged in.');
212
- io.info('Run `kite login` to start a session.');
233
+ io.error(profileName === 'default' ? 'Not logged in.' : `Not logged in to profile "${profileName}".`);
234
+ io.info(profileName === 'default'
235
+ ? 'Run `kite login` to start a session.'
236
+ : `Run \`kite --profile ${profileName} login\`.`);
213
237
  return;
214
238
  }
215
239
  // Hit the API so this reflects reality, not just what we cached.
216
- const profile = await ctx.api.getProfile(ctx.signal);
240
+ const account = await ctx.api.getProfile(ctx.signal);
217
241
  if (io.json) {
218
242
  io.writeJson({
219
243
  logged_in: true,
220
244
  env: ctx.env,
245
+ profile: profileName,
221
246
  expires_at: meta?.expiresAt,
222
- profile,
247
+ account,
223
248
  });
224
249
  return;
225
250
  }
226
251
  io.line(renderKeyValue(io, [
227
- ['User', `${profile.user_name ?? ''} (${profile.user_id})`],
228
- ['Email', profile.email ?? '—'],
229
- ['Broker', profile.broker ?? '—'],
252
+ ['Profile', profileName === 'default' ? profileName : io.bold(profileName)],
253
+ ['User', `${account.user_name ?? '—'} (${account.user_id})`],
254
+ ['Email', account.email ?? '—'],
255
+ ['Broker', account.broker ?? '—'],
230
256
  ['Environment', ctx.env === 'sandbox' ? io.cyan('sandbox') : 'production'],
231
- ['Exchanges', profile.exchanges.join(', ') || '—'],
232
- ['Products', profile.products.join(', ') || '—'],
257
+ ['Exchanges', account.exchanges.join(', ') || '—'],
258
+ ['Products', account.products.join(', ') || '—'],
233
259
  [
234
260
  'Session expires',
235
261
  meta ? `${dateTime(meta.expiresAt)} IST (${timeUntilExpiry(meta)} left)` : 'unknown (token from environment)',
@@ -237,6 +263,56 @@ async function whoami(ctx) {
237
263
  ['Keyring', (await keyringAvailable()) ? 'available' : 'unavailable (using encrypted file)'],
238
264
  ]));
239
265
  }
266
+ /** Enumerate every configured profile and its cached session, no network calls. */
267
+ async function whoamiAll(ctx) {
268
+ const { io } = ctx;
269
+ const config = await loadConfig();
270
+ const rows = await Promise.all(listProfileNames(config).map(async (name) => {
271
+ const meta = await loadSessionMeta(name);
272
+ const status = !meta
273
+ ? io.dim('no session')
274
+ : isExpired(meta)
275
+ ? io.yellow('expired')
276
+ : `expires in ${timeUntilExpiry(meta)}`;
277
+ return {
278
+ profile: name,
279
+ env: getProfile(config, name).env,
280
+ user: meta?.userName ?? meta?.userId ?? '—',
281
+ session: status,
282
+ current: name === ctx.profile.name,
283
+ };
284
+ }));
285
+ printTable(io, rows, [
286
+ { header: '', value: (r) => (r.current ? io.green('●') : ' ') },
287
+ { header: 'Profile', value: (r) => (r.current ? io.bold(r.profile) : r.profile) },
288
+ { header: 'Env', value: (r) => (r.env === 'sandbox' ? io.cyan(r.env) : r.env) },
289
+ { header: 'User', value: (r) => r.user },
290
+ { header: 'Session', value: (r) => r.session },
291
+ ], rows.map(({ current: _current, ...rest }) => rest));
292
+ }
293
+ /**
294
+ * Persist a profile's (semi-public) api key so later runs find it. The default
295
+ * profile keeps its key at the top level of the config for back-compat; a named
296
+ * profile is registered under `profiles`, which also makes it show up in
297
+ * `kite profiles`. Reads the raw config fresh so the in-memory effective
298
+ * trading overlay is never written back.
299
+ */
300
+ async function persistProfileApiKey(profileName, env, apiKey) {
301
+ const config = await loadConfig();
302
+ if (profileName === 'default') {
303
+ if (config.apiKey === apiKey)
304
+ return;
305
+ await saveConfig({ ...config, apiKey });
306
+ return;
307
+ }
308
+ const existing = config.profiles[profileName];
309
+ if (existing?.apiKey === apiKey && existing?.env === env)
310
+ return;
311
+ await saveConfig({
312
+ ...config,
313
+ profiles: { ...config.profiles, [profileName]: { ...existing, apiKey, env } },
314
+ });
315
+ }
240
316
  async function promptText(message, hint) {
241
317
  if (!process.stdin.isTTY) {
242
318
  throw new KiteCliError(`${message} is required but this is not an interactive terminal.`, ExitCode.ConfirmationRequired, 'Supply it via a flag or environment variable.');
@@ -29,12 +29,16 @@ async function showConfig(ctx) {
29
29
  io.writeJson(config);
30
30
  return;
31
31
  }
32
- const secret = await getSecret('api_secret', { env: ctx.env });
32
+ const secret = await getSecret('api_secret', { scope: ctx.credentialScope });
33
+ // Effective trading config for the active profile (global overlaid with the
34
+ // profile's overrides), so this view matches what a money-moving command sees.
35
+ const trading = ctx.config.trading;
33
36
  io.line(heading(io, 'Account'));
34
37
  io.line(renderKeyValue(io, [
35
- ['API key', config.apiKey ?? io.dim('not set')],
38
+ ['Profile', ctx.profile.name === 'default' ? ctx.profile.name : io.bold(ctx.profile.name)],
39
+ ['API key', ctx.profile.apiKey || io.dim('not set')],
36
40
  ['API secret', secret ? `${maskSecret(secret.value)} (${secret.backend})` : io.dim('not set')],
37
- ['Environment', config.env === 'sandbox' ? io.cyan('sandbox') : 'production'],
41
+ ['Environment', ctx.profile.env === 'sandbox' ? io.cyan('sandbox') : 'production'],
38
42
  [
39
43
  'Keyring',
40
44
  (await keyringAvailable()) ? io.green('available') : io.yellow('unavailable — using an encrypted file'),
@@ -42,11 +46,17 @@ async function showConfig(ctx) {
42
46
  ]));
43
47
  io.line(heading(io, 'Trading safety'));
44
48
  io.line(renderKeyValue(io, [
45
- ['Trading enabled', config.trading.enabled ? io.green('yes') : io.red('no — all order commands will refuse')],
46
- ['Confirm orders', config.trading.confirm ? 'yes' : io.yellow('no')],
47
- ['Max order value', config.trading.maxOrderValue ? rupees(config.trading.maxOrderValue) : io.dim('no cap')],
48
- ['Strict confirm above', rupees(config.trading.strictConfirmAbove)],
49
+ ['Trading enabled', trading.enabled ? io.green('yes') : io.red('no — all order commands will refuse')],
50
+ ['Confirm orders', trading.confirm ? 'yes' : io.yellow('no')],
51
+ ['Max order value', trading.maxOrderValue ? rupees(trading.maxOrderValue) : io.dim('no cap')],
52
+ ['Strict confirm above', rupees(trading.strictConfirmAbove)],
49
53
  ]));
54
+ const others = Object.keys(config.profiles);
55
+ if (others.length > 0) {
56
+ io.line(heading(io, 'Profiles'));
57
+ io.line(renderKeyValue(io, [['Configured', ['default', 'sandbox', ...others].join(', ')]]));
58
+ io.note(io.dim(' Manage them with `kite profiles`.'));
59
+ }
50
60
  io.line(heading(io, 'Login callback'));
51
61
  io.line(renderKeyValue(io, [
52
62
  ['Redirect URL', redirectUrlFor(config.redirectPort, config.redirectPath)],
@@ -78,7 +88,22 @@ async function setConfig(ctx, _opts, command) {
78
88
  const config = await loadConfig();
79
89
  const coerced = coerce(rawValue, SETTABLE_KEYS[key].type, key);
80
90
  const updated = structuredClone(config);
81
- writePath(updated, key, coerced);
91
+ const target = ctx.profile.name;
92
+ if (target === 'default') {
93
+ writePath(updated, key, coerced);
94
+ }
95
+ else {
96
+ // Only account- and trading-scoped keys make sense per profile; global
97
+ // presentation/callback settings would be silently ignored, so refuse them.
98
+ if (!isProfileScopedKey(key)) {
99
+ throw new UsageError(`"${key}" is a global setting.`, 'Drop --profile to set it globally.');
100
+ }
101
+ const profiles = (updated.profiles ?? {});
102
+ updated.profiles = profiles;
103
+ const entry = (profiles[target] ?? {});
104
+ profiles[target] = entry;
105
+ writePath(entry, key, coerced);
106
+ }
82
107
  const parsed = ConfigSchema.safeParse(updated);
83
108
  if (!parsed.success) {
84
109
  const { z } = await import('zod');
@@ -86,10 +111,11 @@ async function setConfig(ctx, _opts, command) {
86
111
  }
87
112
  await saveConfig(parsed.data);
88
113
  if (ctx.io.json) {
89
- ctx.io.writeJson({ key, value: coerced });
114
+ ctx.io.writeJson({ key, value: coerced, profile: target });
90
115
  return;
91
116
  }
92
- ctx.io.success(`Set ${ctx.io.bold(key)} to ${JSON.stringify(coerced)}.`);
117
+ const onProfile = target === 'default' ? '' : ` on profile ${ctx.io.bold(target)}`;
118
+ ctx.io.success(`Set ${ctx.io.bold(key)} to ${JSON.stringify(coerced)}${onProfile}.`);
93
119
  if (key === 'trading.enabled' && coerced === false) {
94
120
  ctx.io.info('The kill switch is on. All order commands will now refuse before contacting Kite.');
95
121
  }
@@ -107,7 +133,19 @@ async function unsetConfig(ctx, _opts, command) {
107
133
  }
108
134
  const config = await loadConfig();
109
135
  const updated = structuredClone(config);
110
- deletePath(updated, key);
136
+ const target = ctx.profile.name;
137
+ if (target === 'default') {
138
+ deletePath(updated, key);
139
+ }
140
+ else {
141
+ if (!isProfileScopedKey(key)) {
142
+ throw new UsageError(`"${key}" is a global setting.`, 'Drop --profile to unset it globally.');
143
+ }
144
+ const profiles = (updated.profiles ?? {});
145
+ const entry = profiles[target];
146
+ if (entry)
147
+ deletePath(entry, key);
148
+ }
111
149
  const parsed = ConfigSchema.safeParse(updated);
112
150
  if (!parsed.success) {
113
151
  const { z } = await import('zod');
@@ -115,10 +153,11 @@ async function unsetConfig(ctx, _opts, command) {
115
153
  }
116
154
  await saveConfig(parsed.data);
117
155
  if (ctx.io.json) {
118
- ctx.io.writeJson({ key, unset: true });
156
+ ctx.io.writeJson({ key, unset: true, profile: target });
119
157
  return;
120
158
  }
121
- ctx.io.success(`Unset ${ctx.io.bold(key)}.`);
159
+ const onProfile = target === 'default' ? '' : ` on profile ${ctx.io.bold(target)}`;
160
+ ctx.io.success(`Unset ${ctx.io.bold(key)}${onProfile}.`);
122
161
  }
123
162
  async function showPaths(ctx) {
124
163
  const { io } = ctx;
@@ -139,6 +178,10 @@ async function showPaths(ctx) {
139
178
  ]));
140
179
  }
141
180
  // ---------------------------------------------------------------------------
181
+ /** Keys that mean something per profile; the rest are global (output, callback). */
182
+ function isProfileScopedKey(key) {
183
+ return key === 'apiKey' || key === 'env' || key.startsWith('trading.');
184
+ }
142
185
  /** Coerce an argv string to the type the setting declares. */
143
186
  function coerce(raw, type, key) {
144
187
  if (type === 'boolean') {
@@ -0,0 +1,10 @@
1
+ import type { CommandFactory } from './types.js';
2
+ /**
3
+ * Account profile management.
4
+ *
5
+ * Selection itself is stateless (`--profile` / KITE_PROFILE, resolved every
6
+ * run); these commands only manage the persisted set of profiles and the
7
+ * rarely-changed default. Logging in is deliberately left to `kite login` so
8
+ * credential handling lives in exactly one place.
9
+ */
10
+ export declare const profileCommands: CommandFactory;
@@ -0,0 +1,213 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ import { EnvironmentSchema, loadConfig, saveConfig } from '../core/config.js';
3
+ import { deleteAllSecrets } from '../core/credentials.js';
4
+ import { AbortedError, ExitCode, KiteCliError, UsageError } from '../core/errors.js';
5
+ import { assertValidProfileName, DEFAULT_PROFILE, getProfile, isKnownProfile, listProfileNames, RESERVED_PROFILES, storagePrefixFor, } from '../core/profiles.js';
6
+ import { clearSessionMeta, isExpired, loadSessionMeta, timeUntilExpiry } from '../core/session.js';
7
+ import { printTable, renderKeyValue } from '../output/table.js';
8
+ /**
9
+ * Account profile management.
10
+ *
11
+ * Selection itself is stateless (`--profile` / KITE_PROFILE, resolved every
12
+ * run); these commands only manage the persisted set of profiles and the
13
+ * rarely-changed default. Logging in is deliberately left to `kite login` so
14
+ * credential handling lives in exactly one place.
15
+ */
16
+ export const profileCommands = (program, run) => {
17
+ const profiles = program.command('profiles').description('Manage account profiles for multiple Zerodha accounts');
18
+ profiles
19
+ .command('list', { isDefault: true })
20
+ .alias('ls')
21
+ .description('List configured profiles and their session status')
22
+ .action(run(listProfiles));
23
+ profiles
24
+ .command('add')
25
+ .description('Register a new account profile (does not log in)')
26
+ .argument('<name>', 'A short label, e.g. personal or huf')
27
+ .option('--api-key <key>', 'Kite Connect API key for this account')
28
+ // `--env` is a global option; it cannot be redeclared here without being
29
+ // shadowed by the root parser, so `addProfile` reads it from ctx.options.
30
+ .option('--max-order-value <rupees>', 'Per-profile cap on any single order')
31
+ .action(run(addProfile));
32
+ profiles
33
+ .command('remove')
34
+ .alias('rm')
35
+ .description('Delete a profile and its stored credentials')
36
+ .argument('<name>')
37
+ .action(run(removeProfile));
38
+ profiles
39
+ .command('use')
40
+ .description('Set the default profile for future commands')
41
+ .argument('<name>')
42
+ .action(run(useProfile));
43
+ profiles.command('current').description('Show the profile this invocation resolves to').action(run(currentProfile));
44
+ };
45
+ async function listProfiles(ctx) {
46
+ const { io } = ctx;
47
+ const config = await loadConfig();
48
+ const rows = await Promise.all(listProfileNames(config).map(async (name) => {
49
+ const profile = getProfile(config, name);
50
+ const meta = await loadSessionMeta(name);
51
+ return {
52
+ profile: name,
53
+ env: profile.env,
54
+ api_key: Boolean(profile.apiKey),
55
+ default: (config.defaultProfile ?? DEFAULT_PROFILE) === name,
56
+ current: name === ctx.profile.name,
57
+ session: !meta
58
+ ? io.dim('no session')
59
+ : isExpired(meta)
60
+ ? io.yellow('expired')
61
+ : `expires in ${timeUntilExpiry(meta)}`,
62
+ };
63
+ }));
64
+ printTable(io, rows, [
65
+ { header: '', value: (r) => (r.current ? io.green('●') : ' ') },
66
+ { header: 'Profile', value: (r) => (r.current ? io.bold(r.profile) : r.profile) },
67
+ { header: 'Env', value: (r) => (r.env === 'sandbox' ? io.cyan(r.env) : r.env) },
68
+ { header: 'API key', value: (r) => (r.api_key ? io.green('set') : io.dim('—')) },
69
+ { header: 'Default', value: (r) => (r.default ? '✓' : ' ') },
70
+ { header: 'Session', value: (r) => r.session },
71
+ ], rows.map(({ current: _current, ...rest }) => rest));
72
+ if (!io.json) {
73
+ io.note('');
74
+ io.info('Target a profile with `--profile <name>`, or set a default with `kite profiles use <name>`.');
75
+ }
76
+ }
77
+ async function addProfile(ctx, opts, command) {
78
+ const { io } = ctx;
79
+ const name = command.args[0];
80
+ if (!name)
81
+ throw new UsageError('A profile name is required, e.g. `kite profiles add personal`.');
82
+ assertValidProfileName(name);
83
+ if (RESERVED_PROFILES.includes(name)) {
84
+ throw new UsageError(`"${name}" is a reserved profile and always exists.`);
85
+ }
86
+ const config = await loadConfig();
87
+ if (isKnownProfile(config, name)) {
88
+ throw new UsageError(`Profile "${name}" already exists.`, 'Use `kite profiles remove` first, or pick another name.');
89
+ }
90
+ const entry = {};
91
+ if (opts.apiKey)
92
+ entry.apiKey = opts.apiKey;
93
+ // The global `--env` lands in ctx.options (see the command definition). It was
94
+ // already validated by profile resolution, so re-check defensively only.
95
+ if (ctx.options.env) {
96
+ const parsed = EnvironmentSchema.safeParse(ctx.options.env);
97
+ if (!parsed.success) {
98
+ throw new UsageError(`Unknown environment "${ctx.options.env}". Expected "production" or "sandbox".`);
99
+ }
100
+ entry.env = parsed.data;
101
+ }
102
+ if (opts.maxOrderValue !== undefined) {
103
+ const cap = Number(opts.maxOrderValue);
104
+ if (!Number.isFinite(cap) || cap <= 0)
105
+ throw new UsageError(`--max-order-value must be a positive number.`);
106
+ entry.trading = { maxOrderValue: cap };
107
+ }
108
+ await saveConfig({ ...config, profiles: { ...config.profiles, [name]: entry } });
109
+ if (io.json) {
110
+ io.writeJson({ added: name, ...entry });
111
+ return;
112
+ }
113
+ io.success(`Added profile ${io.bold(name)}.`);
114
+ io.info(`Log in with \`kite --profile ${name} login\`.`);
115
+ }
116
+ async function removeProfile(ctx, _opts, command) {
117
+ const { io } = ctx;
118
+ const name = command.args[0];
119
+ if (!name)
120
+ throw new UsageError('A profile name is required.');
121
+ if (RESERVED_PROFILES.includes(name)) {
122
+ throw new UsageError(`The reserved profile "${name}" cannot be removed.`);
123
+ }
124
+ const config = await loadConfig();
125
+ if (!isKnownProfile(config, name)) {
126
+ throw new UsageError(`No profile named "${name}".`, 'Run `kite profiles list` to see what exists.');
127
+ }
128
+ await confirmDestructive(ctx, `Remove profile "${name}" and its stored credentials?`);
129
+ // Drop the token and secret for this profile, then its session and config entry.
130
+ const scope = storagePrefixFor(getProfile(config, name));
131
+ await deleteAllSecrets({ scope });
132
+ await clearSessionMeta(name);
133
+ const profiles = { ...config.profiles };
134
+ delete profiles[name];
135
+ const next = { ...config, profiles };
136
+ // If this was the default, fall back to the reserved default rather than
137
+ // leaving a dangling pointer.
138
+ if (config.defaultProfile === name)
139
+ delete next.defaultProfile;
140
+ await saveConfig(next);
141
+ if (io.json) {
142
+ io.writeJson({ removed: name });
143
+ return;
144
+ }
145
+ io.success(`Removed profile ${io.bold(name)}.`);
146
+ }
147
+ async function useProfile(ctx, _opts, command) {
148
+ const { io } = ctx;
149
+ const name = command.args[0];
150
+ if (!name)
151
+ throw new UsageError('A profile name is required.');
152
+ assertValidProfileName(name);
153
+ const config = await loadConfig();
154
+ if (!isKnownProfile(config, name)) {
155
+ throw new UsageError(`No profile named "${name}".`, 'Create it first with `kite profiles add`.');
156
+ }
157
+ // Storing the reserved default as the pointer is just noise; clearing it says
158
+ // the same thing.
159
+ const next = { ...config };
160
+ if (name === DEFAULT_PROFILE)
161
+ delete next.defaultProfile;
162
+ else
163
+ next.defaultProfile = name;
164
+ await saveConfig(next);
165
+ const meta = await loadSessionMeta(name);
166
+ if (io.json) {
167
+ io.writeJson({ default_profile: name, user_id: meta?.userId ?? null });
168
+ return;
169
+ }
170
+ io.success(`Default profile is now ${io.bold(name)}.`);
171
+ // Loud, because a persisted default silently changes which account every
172
+ // later command targets — the exact thing a multi-account user must not lose
173
+ // track of. The identity is echoed so they can confirm it is who they meant.
174
+ const who = meta ? `${meta.userName ?? meta.userId} (${meta.userId})` : io.dim('not logged in yet');
175
+ io.warn(`Every command without --profile will now use ${io.bold(name)} — ${who}.`);
176
+ }
177
+ async function currentProfile(ctx) {
178
+ const { io, profile } = ctx;
179
+ const meta = await loadSessionMeta(profile.name);
180
+ if (io.json) {
181
+ io.writeJson({
182
+ profile: profile.name,
183
+ env: profile.env,
184
+ logged_in: ctx.client.hasSession(),
185
+ user_id: meta?.userId ?? null,
186
+ });
187
+ return;
188
+ }
189
+ io.line(renderKeyValue(io, [
190
+ ['Profile', profile.name === DEFAULT_PROFILE ? profile.name : io.bold(profile.name)],
191
+ ['Environment', profile.env === 'sandbox' ? io.cyan('sandbox') : 'production'],
192
+ ['API key', profile.apiKey ? io.green('set') : io.dim('not set')],
193
+ [
194
+ 'Session',
195
+ !ctx.client.hasSession()
196
+ ? io.dim('not logged in')
197
+ : meta
198
+ ? `${meta.userName ?? meta.userId} (${meta.userId}), expires in ${timeUntilExpiry(meta)}`
199
+ : 'token from environment',
200
+ ],
201
+ ]));
202
+ }
203
+ /** A yes/no gate that respects --yes and refuses (rather than proceeds) without a TTY. */
204
+ async function confirmDestructive(ctx, message) {
205
+ if (ctx.options.yes)
206
+ return;
207
+ if (!process.stdin.isTTY || !ctx.io.stderrIsTty) {
208
+ throw new KiteCliError(`${message} needs confirmation, but this is not an interactive terminal.`, ExitCode.ConfirmationRequired, 'Pass --yes to confirm non-interactively.');
209
+ }
210
+ const answer = await confirm({ message, initialValue: false });
211
+ if (isCancel(answer) || answer !== true)
212
+ throw new AbortedError();
213
+ }
@@ -72,7 +72,7 @@ async function watch(ctx, opts, command) {
72
72
  }
73
73
  const tokenToKey = new Map(watchlist.map((entry) => [entry.token, entry.key]));
74
74
  // --- credentials for the socket ----------------------------------------
75
- const stored = await getSecret('access_token', { env: ctx.env });
75
+ const stored = await getSecret('access_token', { scope: ctx.credentialScope });
76
76
  if (!stored) {
77
77
  throw new KiteCliError('No access token available for streaming.', ExitCode.Auth, 'Run `kite login`.');
78
78
  }
package/dist/context.d.ts CHANGED
@@ -2,6 +2,7 @@ import { KiteApi } from './core/api.js';
2
2
  import { KiteClient } from './core/client.js';
3
3
  import { type Config, type Endpoints, type Environment } from './core/config.js';
4
4
  import { InstrumentStore } from './core/instruments.js';
5
+ import { type ResolvedProfile } from './core/profiles.js';
5
6
  import { type SessionMeta } from './core/session.js';
6
7
  import { Io, type IoStreams } from './output/io.js';
7
8
  /**
@@ -18,6 +19,7 @@ export interface GlobalOptions {
18
19
  quiet?: boolean;
19
20
  debug?: boolean;
20
21
  env?: string;
22
+ profile?: string;
21
23
  yes?: boolean;
22
24
  dryRun?: boolean;
23
25
  }
@@ -25,6 +27,10 @@ export interface Context {
25
27
  io: Io;
26
28
  config: Config;
27
29
  env: Environment;
30
+ /** The account this invocation targets. */
31
+ profile: ResolvedProfile;
32
+ /** Keyring / file namespace prefix for this profile's secrets. */
33
+ credentialScope: string;
28
34
  endpoints: Endpoints;
29
35
  client: KiteClient;
30
36
  api: KiteApi;