Skip to content

Commit d1ef6b2

Browse files
Add direct user API key login to the CLI (#3132)
## Summary - Adds a new `composio login --user-api-key` flow for direct login without the browser/session-key path. - Supports selecting a default organization with `--org` and validates incompatible flag combinations. - Updates login help, CLI docs, and command output so the direct login path reports the selected org consistently. - Expands login tests to cover help text and direct API key login behavior. ## Testing - `pnpm test -- login.cmd.test.ts` or equivalent CLI test run covering the updated login command. - Verified help output includes `--user-api-key` and `--org` and no longer advertises legacy `--api-key`. - Verified direct login stores the chosen org and writes the expected user config values. - Not run: full repository test suite.
1 parent 39d39b1 commit d1ef6b2

File tree

5 files changed

+272
-73
lines changed

5 files changed

+272
-73
lines changed

ts/packages/cli/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Each command is declared with `@effect/cli`'s `Command.make()` pattern:
3131
| -------------------------------------------------------- | ------------------------------------------------------------------------------------- |
3232
| `composio version` | Display CLI version |
3333
| `composio whoami` | Show logged-in user's API key |
34-
| `composio login [--no-browser] [--no-wait] [--key text]` | Login with browser redirect |
34+
| `composio login [--no-browser] [--no-wait] [--key text] [--user-api-key text] [--org text]` | Login with browser redirect or direct user API key |
3535
| `composio logout` | Clear stored API key |
3636
| `composio upgrade` | Self-update binary from GitHub releases |
3737
| `composio generate` | Auto-detect project language, delegate to `ts` or `py` |

ts/packages/cli/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ composio [--log-level all|trace|debug|info|warning|error|fatal|none]
2727

2828
- `composio version`: Display the current CLI version.
2929
- `composio whoami`: Show the currently logged-in user/account.
30-
- `composio login [--no-browser] [--no-wait] [--key text] [-y, --yes] [--no-skill-install]`: Log in to the Composio CLI session.
30+
- `composio login [--no-browser] [--no-wait] [--key text] [--user-api-key text] [--org text] [-y, --yes] [--no-skill-install]`: Log in to the Composio CLI session.
3131
- `composio logout`: Log out from the Composio CLI session.
3232
- `composio orgs list|switch`: Inspect and switch your default organization context.
3333
- `composio search <query...> [--toolkits text] [--limit integer] [--human]`: Find tools by use case across toolkits/apps.

ts/packages/cli/src/commands/login.cmd.ts

Lines changed: 165 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ComposioSessionRepository,
66
getSessionInfo,
77
getSessionInfoByUserApiKey,
8+
listOrganizations,
89
type SessionInfoResponse,
910
} from 'src/services/composio-clients';
1011
import { ComposioUserContext } from 'src/services/user-context';
@@ -32,6 +33,16 @@ const keyOpt = Options.text('key').pipe(
3233
Options.optional
3334
);
3435

36+
const userApiKeyOpt = Options.text('user-api-key').pipe(
37+
Options.withDescription('Log in directly with a Composio user API key'),
38+
Options.optional
39+
);
40+
41+
const orgOpt = Options.text('org').pipe(
42+
Options.withDescription('Default organization ID or name to store for CLI commands'),
43+
Options.optional
44+
);
45+
3546
const yesOpt = Options.boolean('yes').pipe(
3647
Options.withAlias('y'),
3748
Options.withDefault(false),
@@ -57,10 +68,107 @@ const formatLoginSuccessMessage = (params: { email?: string; orgName?: string })
5768
return 'Logged in successfully';
5869
};
5970

71+
const emitLoginComplete = (params: {
72+
email?: string;
73+
orgId: string;
74+
orgName?: string;
75+
skipHints?: boolean;
76+
}) =>
77+
Effect.gen(function* () {
78+
const ui = yield* TerminalUI;
79+
const { email, orgId, orgName, skipHints = false } = params;
80+
81+
yield* ui.log.success(formatLoginSuccessMessage({ email, orgName }));
82+
if (!skipHints) {
83+
yield* ui.log.info(commandHintStep('Execute a tool directly', 'root.execute'));
84+
yield* ui.log.info(commandHintStep('Switch your default org', 'root.orgs.switch'));
85+
}
86+
87+
yield* ui.output(
88+
JSON.stringify({
89+
email,
90+
org_id: orgId,
91+
org_name: orgName ?? '',
92+
})
93+
);
94+
95+
if (!skipHints) {
96+
yield* ui.outro("You're all set!");
97+
}
98+
});
99+
100+
const resolveDirectLoginOrganization = (params: {
101+
apiKey: string;
102+
baseURL: string;
103+
requestedOrg?: string;
104+
fallbackOrgId: string;
105+
fallbackOrgName?: string;
106+
}) =>
107+
Effect.gen(function* () {
108+
const ui = yield* TerminalUI;
109+
const { apiKey, baseURL, requestedOrg, fallbackOrgId, fallbackOrgName } = params;
110+
111+
if (!requestedOrg) {
112+
return {
113+
id: fallbackOrgId,
114+
name: fallbackOrgName ?? fallbackOrgId,
115+
};
116+
}
117+
118+
const organizations = yield* listOrganizations({
119+
baseURL,
120+
apiKey,
121+
});
122+
const match = organizations.data.find(
123+
org => org.id === requestedOrg || org.name === requestedOrg
124+
);
125+
126+
if (!match) {
127+
yield* ui.log.error(`Organization "${requestedOrg}" was not found for this API key.`);
128+
return yield* Effect.fail(
129+
new Error('Invalid organization. Run `composio orgs list` to inspect available orgs.')
130+
);
131+
}
132+
133+
return match;
134+
});
135+
136+
const directLogin = (params: { userApiKey: string; org?: string }) =>
137+
Effect.gen(function* () {
138+
const ctx = yield* ComposioUserContext;
139+
const sessionInfo = yield* getSessionInfoByUserApiKey({
140+
baseURL: ctx.data.baseURL,
141+
userApiKey: params.userApiKey,
142+
});
143+
144+
const selectedOrg = yield* resolveDirectLoginOrganization({
145+
apiKey: params.userApiKey,
146+
baseURL: ctx.data.baseURL,
147+
requestedOrg: params.org,
148+
fallbackOrgId: sessionInfo.project.org.id,
149+
fallbackOrgName: sessionInfo.project.org.name,
150+
});
151+
152+
const sessionUserId = sessionInfo.org_member.user_id ?? sessionInfo.org_member.id;
153+
const testUserId = sessionUserId
154+
? `pg-test-${sessionUserId}`
155+
: Option.getOrUndefined(ctx.data.testUserId);
156+
157+
yield* ctx.login(params.userApiKey, selectedOrg.id, testUserId);
158+
yield* primeConsumerConnectedToolkitsCacheInBackground({
159+
orgId: selectedOrg.id,
160+
});
161+
yield* emitLoginComplete({
162+
email: sessionInfo.org_member.email || undefined,
163+
orgId: selectedOrg.id,
164+
orgName: selectedOrg.name,
165+
});
166+
});
167+
60168
/**
61169
* Verifies credentials via session/info and stores them.
62170
*
63-
* Resolves TerminalUI and ComposioUserContext from the Effect context rather
171+
* Resolves ComposioUserContext from the Effect context rather
64172
* than accepting them as parameters -- this keeps the signature focused on
65173
* data and avoids hand-rolled structural types.
66174
*/
@@ -76,7 +184,6 @@ const storeCredentials = (params: {
76184
skipOutput?: boolean;
77185
}) =>
78186
Effect.gen(function* () {
79-
const ui = yield* TerminalUI;
80187
const ctx = yield* ComposioUserContext;
81188

82189
const {
@@ -131,27 +238,13 @@ const storeCredentials = (params: {
131238
orgId,
132239
});
133240

134-
const email = sessionInfo?.org_member.email || fallbackEmail || undefined;
135-
const orgName = sessionInfo?.project.org.name || undefined;
136-
yield* ui.log.success(formatLoginSuccessMessage({ email, orgName }));
137-
if (!skipHints) {
138-
yield* ui.log.info(commandHintStep('Execute a tool directly', 'root.execute'));
139-
yield* ui.log.info(commandHintStep('Switch your default org', 'root.orgs.switch'));
140-
}
141-
142-
// Emit structured JSON for piped/scripted consumption (agent-native)
143241
if (!skipOutput) {
144-
yield* ui.output(
145-
JSON.stringify({
146-
email,
147-
org_id: orgId,
148-
org_name: sessionInfo?.project.org.name ?? '',
149-
})
150-
);
151-
}
152-
153-
if (!skipHints) {
154-
yield* ui.outro("You're all set!");
242+
yield* emitLoginComplete({
243+
email: sessionInfo?.org_member.email || fallbackEmail || undefined,
244+
orgId,
245+
orgName: sessionInfo?.project.org.name || undefined,
246+
skipHints,
247+
});
155248
}
156249
});
157250

@@ -256,25 +349,14 @@ const loginWithKey = (params: { key: string; noWait: boolean; skipOrgProjectPick
256349
yield* primeConsumerConnectedToolkitsCacheInBackground({
257350
orgId: result.id,
258351
});
259-
yield* ui.log.success(
260-
formatLoginSuccessMessage({
261-
email: uakSessionInfo.org_member.email || linkedSession.account.email || undefined,
262-
orgName: result.name,
263-
})
264-
);
265352
}
266353
const finalOrgId = result?.id ?? xOrgId;
267354
const finalOrgName = result?.name ?? uakSessionInfo.project.org.name ?? '';
268-
yield* ui.output(
269-
JSON.stringify({
270-
email: linkedSession.account.email ?? undefined,
271-
org_id: finalOrgId,
272-
org_name: finalOrgName,
273-
})
274-
);
275-
yield* ui.log.info(commandHintStep('Execute a tool directly', 'root.execute'));
276-
yield* ui.log.info(commandHintStep('Switch your default org', 'root.orgs.switch'));
277-
yield* ui.outro("You're all set!");
355+
yield* emitLoginComplete({
356+
email: linkedSession.account.email ?? undefined,
357+
orgId: finalOrgId,
358+
orgName: finalOrgName,
359+
});
278360
}
279361
});
280362

@@ -421,25 +503,14 @@ export const browserLogin = (params: {
421503
yield* primeConsumerConnectedToolkitsCacheInBackground({
422504
orgId: result.id,
423505
});
424-
yield* ui.log.success(
425-
formatLoginSuccessMessage({
426-
email: uakSessionInfo.org_member.email || linkedSession.account.email || undefined,
427-
orgName: result.name,
428-
})
429-
);
430506
}
431507
const finalOrgId = result?.id ?? xOrgId;
432508
const finalOrgName = result?.name ?? uakSessionInfo.project.org.name ?? '';
433-
yield* ui.output(
434-
JSON.stringify({
435-
email: linkedSession.account.email ?? undefined,
436-
org_id: finalOrgId,
437-
org_name: finalOrgName,
438-
})
439-
);
440-
yield* ui.log.info(commandHintStep('Execute a tool directly', 'root.execute'));
441-
yield* ui.log.info(commandHintStep('Switch your default org', 'root.orgs.switch'));
442-
yield* ui.outro("You're all set!");
509+
yield* emitLoginComplete({
510+
email: linkedSession.account.email ?? undefined,
511+
orgId: finalOrgId,
512+
orgName: finalOrgName,
513+
});
443514
}
444515
});
445516

@@ -451,6 +522,7 @@ export const browserLogin = (params: {
451522
* Use --no-wait to print login URL and session info (JSON) then exit without opening browser or waiting.
452523
* Use --key to complete login with a session key from --no-wait. Without --no-wait, polls until linked;
453524
* with --no-wait, checks once and fails if not linked.
525+
* Use --user-api-key to log in directly without a browser flow, and --org to override the default org.
454526
* Use -y to skip org picker and use session default org.
455527
*
456528
* @example
@@ -460,19 +532,45 @@ export const browserLogin = (params: {
460532
* composio login --no-wait
461533
* composio login --key <key>
462534
* composio login --key <key> --no-wait
535+
* composio login --user-api-key <uak>
536+
* composio login --user-api-key <uak> --org <org>
463537
* composio login -y
464538
* ```
465539
*/
466540
export const loginCmd = Command.make(
467541
'login',
468-
{ noBrowser, noWait, key: keyOpt, yes: yesOpt, noSkillInstall },
469-
({ noBrowser, noWait, key, yes, noSkillInstall }) =>
542+
{
543+
noBrowser,
544+
noWait,
545+
key: keyOpt,
546+
userApiKey: userApiKeyOpt,
547+
org: orgOpt,
548+
yes: yesOpt,
549+
noSkillInstall,
550+
},
551+
({ noBrowser, noWait, key, userApiKey, org, yes, noSkillInstall }) =>
470552
Effect.gen(function* () {
471553
const ui = yield* TerminalUI;
472554
const ctx = yield* ComposioUserContext;
473555

474556
yield* ui.intro('composio login');
475557

558+
if (Option.isSome(key) && Option.isSome(userApiKey)) {
559+
return yield* Effect.fail(new Error('Use either `--key` or `--user-api-key`, not both.'));
560+
}
561+
562+
if (Option.isSome(org) && Option.isNone(userApiKey)) {
563+
return yield* Effect.fail(new Error('`--org` requires `--user-api-key`.'));
564+
}
565+
566+
if (Option.isSome(userApiKey) && (noBrowser || noWait || Option.isSome(key))) {
567+
return yield* Effect.fail(
568+
new Error(
569+
'`--user-api-key` is a direct login path and cannot be combined with browser or session flags.'
570+
)
571+
);
572+
}
573+
476574
if (Option.isSome(key)) {
477575
yield* loginWithKey({
478576
key: key.value,
@@ -485,6 +583,17 @@ export const loginCmd = Command.make(
485583
return;
486584
}
487585

586+
if (Option.isSome(userApiKey)) {
587+
yield* directLogin({
588+
userApiKey: userApiKey.value,
589+
org: Option.getOrUndefined(org),
590+
});
591+
if (!noSkillInstall) {
592+
yield* installSkillSafe({ channel: inferSkillReleaseChannel(APP_VERSION) });
593+
}
594+
return;
595+
}
596+
488597
if (ctx.isLoggedIn()) {
489598
if (Option.isSome(ctx.data.orgId)) {
490599
yield* ui.log.warn(`You're already logged in!`);

ts/packages/cli/src/commands/root-help.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,14 +516,22 @@ const SUBCOMMAND_HELP: Record<string, SubcommandHelp | TaggedValue<SubcommandHel
516516

517517
login: {
518518
usage:
519-
'composio login [--no-browser] [--no-wait] [--key text] [-y, --yes] [--no-skill-install]',
519+
'composio login [--no-browser] [--no-wait] [--key text] [--user-api-key text] [--org text] [-y, --yes] [--no-skill-install]',
520520
description:
521521
'Log in to the Composio CLI session. By default, also installs the composio-cli skill for Claude Code.',
522522
options: [
523523
{
524524
name: '--key <text>',
525525
description: 'Complete login using session key from composio login --no-wait',
526526
},
527+
{
528+
name: '--user-api-key <text>',
529+
description: 'Log in directly with a Composio user API key',
530+
},
531+
{
532+
name: '--org <text>',
533+
description: 'Default organization ID or name to store for CLI commands',
534+
},
527535
],
528536
flags: [
529537
{ name: '--no-browser', description: 'Login without browser interaction' },

0 commit comments

Comments
 (0)