rakomi 0.1.2 → 0.3.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
@@ -9,7 +9,17 @@ npx rakomi login
9
9
  ```
10
10
 
11
11
  Signs you in with your browser (loopback OAuth + PKCE). Add `--no-browser` on a headless/SSH
12
- session to get a short code to enter on another device instead.
12
+ session to get a short code to enter on another device instead. If you don't already have a
13
+ `accounts.rakomi.com` session or an invitation link, pass `--tenant-id <uuid>` (or set
14
+ `RAKOMI_PLATFORM_TENANT_ID`) so sign-in knows which tenant to authenticate against — ask your
15
+ Rakomi administrator for the right value.
16
+
17
+ `rakomi whoami` shows the tenant you signed in against ("Home tenant") and the tenant your other
18
+ commands should treat as active ("Active tenant"). `rakomi tenants memberships` lists every tenant
19
+ your account is a verified member of, with your role in each. `rakomi use <slug>` looks one up from
20
+ that list and remembers it — a slug you don't belong to fails with a plain "not found" rather than
21
+ confirming the tenant exists; `rakomi use <tenant-id>` (a full id) remembers it directly without a
22
+ lookup, and `--tenant <tenant-id>` overrides it for one command.
13
23
 
14
24
  ## Connect an AI agent
15
25
 
@@ -45,10 +55,12 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
45
55
  | --- | --- |
46
56
  | `login` | sign in (browser by default; `--no-browser` for a device code) |
47
57
  | `logout` | clear the local session |
48
- | `whoami` | show the signed-in account |
58
+ | `whoami` | show the signed-in account, home tenant, and active tenant |
59
+ | `use <tenant-id-or-slug>` | remember a tenant locally for `whoami`/future commands; a slug is server-verified |
49
60
  | `connect` | connect Claude Code / Claude Desktop to your tenant (read access) |
50
61
  | `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
51
62
  | `tenants list` | list tenants you provisioned |
63
+ | `tenants memberships` | list tenants you're a verified member of |
52
64
 
53
65
  ## Global options
54
66
 
@@ -59,6 +71,8 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
59
71
  | `--dry-run` | print what would happen, make no writes or mutating calls |
60
72
  | `--no-browser` | use the device-code flow instead of opening a browser |
61
73
  | `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
74
+ | `--tenant-id <uuid>` | (`login`) the tenant to sign in against, if `RAKOMI_PLATFORM_TENANT_ID` isn't set for you |
75
+ | `--tenant <tenant-id>` | override the active tenant for a single command (`whoami` today) |
62
76
  | `--client <name>` | `claude-code` or `claude-desktop` — required when more than one applies |
63
77
  | `--undo` | restore the `.mcp.json` `connect` last backed up |
64
78
  | `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
@@ -1,7 +1,8 @@
1
1
  import { resolveCiOidcToken } from '../ci-oidc-token.js';
2
- import { accountsBaseUrl, apiBaseUrl, clientId as resolveClientId, isCimdClientId, isCimdPlatformSupported, } from '../env.js';
2
+ import { generateEphemeralDpopKey } from '../dpop.js';
3
+ import { accountsBaseUrl, apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, clientId as resolveClientId, isCimdClientId, isCimdPlatformSupported, isValidTenantId, platformTenantId, } from '../env.js';
3
4
  import { CliError, EXIT } from '../errors.js';
4
- import { bindInstallKey, getOrCreateInstallKey, signClientAssertion } from '../install-key.js';
5
+ import { bindInstallKey, getOrCreateInstallKey, peekInstallKey, persistInstallKey, signClientAssertion, } from '../install-key.js';
5
6
  import { buildAuthorizeUrl, exchangeAuthorizationCode } from '../oauth-authcode.js';
6
7
  import { loginWithDeviceGrant } from '../oauth-device.js';
7
8
  import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
@@ -14,6 +15,25 @@ function cimdUnsupportedMessage(cid) {
14
15
  'the dashboard, then run `rakomi login --client <your-client-id>` (or set RAKOMI_CLIENT_ID) ' +
15
16
  'instead.');
16
17
  }
18
+ function platformTenantMissingMessage() {
19
+ return ('rakomi login needs to know which tenant to authenticate your account against. Set ' +
20
+ 'RAKOMI_PLATFORM_TENANT_ID to a real tenant UUID, or pass --tenant-id <uuid>. If neither is ' +
21
+ 'set, this Rakomi environment likely does not have a platform tenant provisioned yet — ask ' +
22
+ 'your operator.');
23
+ }
24
+ function explicitTenantIdMalformedMessage(value) {
25
+ return (`--tenant-id "${value}" is not a valid tenant id — expected a well-formed UUID (not the nil ` +
26
+ 'or max UUID). Pass the real tenant id, or omit --tenant-id to fall back to RAKOMI_PLATFORM_TENANT_ID.');
27
+ }
28
+ function resolvePlatformTenantId(env, explicit) {
29
+ if (explicit !== undefined) {
30
+ if (!isValidTenantId(explicit)) {
31
+ throw new CliError(explicitTenantIdMalformedMessage(explicit), EXIT.FAIL);
32
+ }
33
+ return explicit;
34
+ }
35
+ return platformTenantId(env);
36
+ }
17
37
  export async function runLogin(deps) {
18
38
  const apiUrl = apiBaseUrl(deps.env);
19
39
  const cid = resolveClientId(deps.env);
@@ -29,9 +49,16 @@ export async function runLogin(deps) {
29
49
  }
30
50
  }
31
51
  const installKey = isCimdClientId(cid) ? getOrCreateInstallKey(deps.keys, cid) : undefined;
52
+ let tenantId;
53
+ if (!deps.noBrowser && isCimdClientId(cid)) {
54
+ tenantId = resolvePlatformTenantId(deps.env, deps.explicitTenantId);
55
+ if (!tenantId) {
56
+ throw new CliError(platformTenantMissingMessage(), EXIT.FAIL);
57
+ }
58
+ }
32
59
  const token = deps.noBrowser
33
60
  ? await runDeviceGrantLogin(deps, apiUrl, cid)
34
- : await runLoopbackLogin(deps, apiUrl, cid, installKey);
61
+ : await runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId);
35
62
  const session = {
36
63
  access_token: token.access_token,
37
64
  refresh_token: token.refresh_token,
@@ -39,6 +66,7 @@ export async function runLogin(deps) {
39
66
  expires_at: deps.now() + token.expires_in * 1000,
40
67
  api_base_url: apiUrl,
41
68
  client_id: cid,
69
+ ...(tenantId ? { home_tenant_id: tenantId } : {}),
42
70
  };
43
71
  if (token.token_type !== 'Bearer' && token.token_type !== 'DPoP') {
44
72
  deps.stdout.write(`(debug) unexpected token_type "${token.token_type}" from the server — treating this session as Bearer.\n`);
@@ -49,7 +77,7 @@ export async function runLogin(deps) {
49
77
  await bindInstallKey(deps, deps.keys, { apiBaseUrl: apiUrl, clientId: cid, accessToken: token.access_token, dpopBound: session.token_type === 'DPoP' }, installKey);
50
78
  }
51
79
  }
52
- async function runLoopbackLogin(deps, apiUrl, cid, installKey) {
80
+ async function runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId) {
53
81
  const listener = await deps.startLoopback();
54
82
  try {
55
83
  const pkce = generatePkcePair();
@@ -59,6 +87,7 @@ async function runLoopbackLogin(deps, apiUrl, cid, installKey) {
59
87
  redirectUri: listener.redirectUri,
60
88
  pkce,
61
89
  scope: LOGIN_SCOPE,
90
+ tenantId,
62
91
  });
63
92
  deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
64
93
  await deps.openBrowser(authorizeUrl);
@@ -94,7 +123,6 @@ async function runDeviceGrantLogin(deps, apiUrl, cid) {
94
123
  },
95
124
  }, { apiBaseUrl: apiUrl, clientId: cid, scope: LOGIN_SCOPE });
96
125
  }
97
- const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
98
126
  export async function runLoginCi(deps) {
99
127
  const apiUrl = apiBaseUrl(deps.env);
100
128
  const subjectToken = await resolveCiOidcToken(deps, {
@@ -103,7 +131,11 @@ export async function runLoginCi(deps) {
103
131
  readTextFile: deps.readTextFile,
104
132
  audience: apiUrl,
105
133
  });
106
- const token = await exchangeOidcSubjectToken(deps, { apiBaseUrl: apiUrl, subjectToken, resource: apiUrl });
134
+ const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
135
+ const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
136
+ const token = await exchangeOidcSubjectToken(deps, { apiBaseUrl: apiUrl, subjectToken, resource: apiUrl, dpopKey });
137
+ if (!existingKey)
138
+ persistInstallKey(deps.keys, dpopKey);
107
139
  const session = {
108
140
  access_token: token.access_token,
109
141
  token_type: token.token_type,
@@ -0,0 +1,45 @@
1
+ import { resolveCiOidcToken } from '../ci-oidc-token.js';
2
+ import { generateEphemeralDpopKey } from '../dpop.js';
3
+ import { apiBaseUrl, CI_FEDERATION_SESSION_CLIENT_ID, DEFAULT_MCP_URL } from '../env.js';
4
+ import { getOrCreateInstallKey, peekInstallKey, persistInstallKey } from '../install-key.js';
5
+ import { listMcpToolsCi, MCP_RESOURCE } from '../mcp-tools-ci.js';
6
+ import { exchangeOidcSubjectToken } from '../oidc-federation-login.js';
7
+ function hintLine(t) {
8
+ const hints = [];
9
+ if (t.annotations?.readOnlyHint)
10
+ hints.push('read-only');
11
+ if (t.annotations?.idempotentHint)
12
+ hints.push('idempotent');
13
+ return hints.length > 0 ? ` (${hints.join(', ')})` : '';
14
+ }
15
+ export async function runMcpToolsCi(deps) {
16
+ const apiUrl = apiBaseUrl(deps.env);
17
+ const mcpUrl = deps.env.RAKOMI_API_URL ? `${apiUrl}/mcp` : DEFAULT_MCP_URL;
18
+ const subjectToken = await resolveCiOidcToken(deps, {
19
+ env: deps.ciEnv,
20
+ oidcTokenFile: deps.oidcTokenFile,
21
+ readTextFile: deps.readTextFile,
22
+ audience: MCP_RESOURCE,
23
+ });
24
+ const existingKey = peekInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
25
+ const dpopKey = existingKey ?? { clientId: CI_FEDERATION_SESSION_CLIENT_ID, ...generateEphemeralDpopKey(), confirmedBound: false };
26
+ const token = await exchangeOidcSubjectToken(deps, {
27
+ apiBaseUrl: apiUrl,
28
+ subjectToken,
29
+ resource: MCP_RESOURCE,
30
+ dpopKey,
31
+ });
32
+ if (!existingKey)
33
+ persistInstallKey(deps.keys, dpopKey);
34
+ const resolvedKey = getOrCreateInstallKey(deps.keys, CI_FEDERATION_SESSION_CLIENT_ID);
35
+ const tools = await listMcpToolsCi(deps, { mcpUrl, accessToken: token.access_token, dpopKey: resolvedKey });
36
+ if (deps.json) {
37
+ deps.stdout.write(JSON.stringify({ tools }) + '\n');
38
+ return;
39
+ }
40
+ if (tools.length === 0) {
41
+ deps.stdout.write('No MCP tools visible to this CI identity.\n');
42
+ return;
43
+ }
44
+ deps.stdout.write(tools.map((t) => `${t.name}${hintLine(t)} — ${t.description}`).join('\n') + '\n');
45
+ }
@@ -1,6 +1,7 @@
1
1
  import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
2
2
  import { resolveDpopKey } from '../install-key.js';
3
- import { createTenant, listTenants } from '../tenants-client.js';
3
+ import { claimEphemeralTenant, EPHEMERAL_LEASE_LABEL_MAX_LENGTH, EPHEMERAL_TENANT_TTL_SECONDS_MAX, EPHEMERAL_TENANT_TTL_SECONDS_MIN, releaseEphemeralTenant, } from '../lease-client.js';
4
+ import { createTenant, listTenantMemberships, listTenants } from '../tenants-client.js';
4
5
  export async function runTenantsCreate(deps, args) {
5
6
  const session = deps.session.read();
6
7
  if (!session)
@@ -59,3 +60,76 @@ export async function runTenantsList(deps) {
59
60
  }
60
61
  deps.stdout.write(result.data.map((t) => `${t.slug}\t${t.id}\t${t.status}`).join('\n') + '\n');
61
62
  }
63
+ export async function runTenantsMemberships(deps) {
64
+ const session = deps.session.read();
65
+ if (!session)
66
+ throw new NotLoggedInError();
67
+ const result = await listTenantMemberships(deps, {
68
+ apiBaseUrl: session.api_base_url,
69
+ accessToken: session.access_token,
70
+ dpop: resolveDpopKey(deps.keys, session),
71
+ });
72
+ if (deps.json) {
73
+ deps.stdout.write(JSON.stringify(result) + '\n');
74
+ return;
75
+ }
76
+ if (result.data.length === 0) {
77
+ deps.stdout.write('No tenant memberships found for your account.\n');
78
+ return;
79
+ }
80
+ deps.stdout.write(result.data.map((m) => `${m.slug}\t${m.id}\t${m.role}`).join('\n') + '\n');
81
+ }
82
+ function resolveParentTenantId(explicit, tenantConfig) {
83
+ return explicit ?? tenantConfig.read() ?? undefined;
84
+ }
85
+ const NO_PARENT_TENANT_MESSAGE = 'No tenant to operate on — pass --tenant <tenant-id>, or run `rakomi use <tenant-id>` first.';
86
+ export async function runTenantsClaim(deps, args) {
87
+ const session = deps.session.read();
88
+ if (!session)
89
+ throw new NotLoggedInError('Not logged in. Run `rakomi login --ci` first.');
90
+ const parentTenantId = resolveParentTenantId(args.parentTenantId, deps.tenantConfig);
91
+ if (!parentTenantId)
92
+ throw new UsageError(NO_PARENT_TENANT_MESSAGE);
93
+ if (args.ttlSeconds !== undefined &&
94
+ (args.ttlSeconds < EPHEMERAL_TENANT_TTL_SECONDS_MIN || args.ttlSeconds > EPHEMERAL_TENANT_TTL_SECONDS_MAX)) {
95
+ throw new UsageError(`--ttl-seconds must be between ${EPHEMERAL_TENANT_TTL_SECONDS_MIN} and ${EPHEMERAL_TENANT_TTL_SECONDS_MAX}, got ${args.ttlSeconds}`);
96
+ }
97
+ const label = args.label?.trim();
98
+ if (args.label !== undefined && (label === '' || (label?.length ?? 0) > EPHEMERAL_LEASE_LABEL_MAX_LENGTH)) {
99
+ throw new UsageError(`--label must be 1-${EPHEMERAL_LEASE_LABEL_MAX_LENGTH} characters after trimming, got "${args.label}"`);
100
+ }
101
+ const result = await claimEphemeralTenant(deps, {
102
+ apiBaseUrl: session.api_base_url,
103
+ accessToken: session.access_token,
104
+ parentTenantId,
105
+ ttlSeconds: args.ttlSeconds,
106
+ label,
107
+ dpop: resolveDpopKey(deps.keys, session),
108
+ });
109
+ if (deps.json) {
110
+ deps.stdout.write(JSON.stringify(result) + '\n');
111
+ return;
112
+ }
113
+ deps.stdout.write(`Claimed ephemeral tenant "${result.slug}" (${result.tenant_id}) — expires ${result.expires_at} (source: ${result.source})\n` +
114
+ `Lease id: ${result.lease_id} — pass this to \`rakomi tenants release --ci\` when you're done with it.\n`);
115
+ }
116
+ export async function runTenantsRelease(deps, args) {
117
+ const session = deps.session.read();
118
+ if (!session)
119
+ throw new NotLoggedInError('Not logged in. Run `rakomi login --ci` first.');
120
+ const parentTenantId = resolveParentTenantId(args.parentTenantId, deps.tenantConfig);
121
+ if (!parentTenantId)
122
+ throw new UsageError(NO_PARENT_TENANT_MESSAGE);
123
+ await releaseEphemeralTenant(deps, {
124
+ apiBaseUrl: session.api_base_url,
125
+ accessToken: session.access_token,
126
+ parentTenantId,
127
+ tenantId: args.tenantId,
128
+ dpop: resolveDpopKey(deps.keys, session),
129
+ });
130
+ if (deps.json) {
131
+ deps.stdout.write(JSON.stringify({ released: true, tenant_id: args.tenantId }) + '\n');
132
+ return;
133
+ }
134
+ deps.stdout.write(`Released ephemeral tenant ${args.tenantId} — deletion scheduled.\n`);
135
+ }
@@ -0,0 +1,26 @@
1
+ import { isValidTenantId } from '../env.js';
2
+ import { NotLoggedInError, UsageError } from '../errors.js';
3
+ import { resolveDpopKey } from '../install-key.js';
4
+ import { getTenantMembershipBySlug } from '../tenants-client.js';
5
+ export async function runUse(deps, args) {
6
+ if (isValidTenantId(args.tenantId)) {
7
+ deps.tenantConfig.write(args.tenantId);
8
+ deps.stdout.write(`Active tenant set to ${args.tenantId}.\n`);
9
+ return;
10
+ }
11
+ const session = deps.session.read();
12
+ if (!session)
13
+ throw new NotLoggedInError();
14
+ const membership = await getTenantMembershipBySlug(deps, {
15
+ apiBaseUrl: session.api_base_url,
16
+ accessToken: session.access_token,
17
+ slug: args.tenantId,
18
+ dpop: resolveDpopKey(deps.keys, session),
19
+ });
20
+ if (!membership) {
21
+ throw new UsageError(`No tenant membership found for "${args.tenantId}" — check the spelling, or ask a tenant ` +
22
+ 'admin to invite you. (Run `rakomi tenants memberships` to see the tenants you already belong to.)');
23
+ }
24
+ deps.tenantConfig.write(membership.id);
25
+ deps.stdout.write(`Active tenant set to ${membership.slug} (${membership.id}) — role: ${membership.role}.\n`);
26
+ }
@@ -7,6 +7,7 @@ export async function runWhoami(deps) {
7
7
  throw new NotLoggedInError();
8
8
  const dpop = resolveDpopKey(deps.keys, session);
9
9
  const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, dpop });
10
+ const activeTenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
10
11
  if (deps.json) {
11
12
  deps.stdout.write(JSON.stringify({
12
13
  sub: info.sub,
@@ -15,6 +16,8 @@ export async function runWhoami(deps) {
15
16
  org_role: info.org_role,
16
17
  session_store: deps.session.describePath(),
17
18
  token_type: session.token_type,
19
+ home_tenant_id: session.home_tenant_id,
20
+ active_tenant_id: activeTenantId,
18
21
  }) + '\n');
19
22
  return;
20
23
  }
@@ -22,6 +25,8 @@ export async function runWhoami(deps) {
22
25
  if (info.org_id)
23
26
  lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
24
27
  lines.push(`API: ${session.api_base_url}`);
28
+ lines.push(`Home tenant: ${session.home_tenant_id ?? 'unknown (this session did not authenticate via the CIMD-default flow)'}`);
29
+ lines.push(`Active tenant: ${activeTenantId ?? 'none set (run `rakomi use <tenant-id>`)'}`);
25
30
  lines.push(`Session stored in: ${deps.session.describePath()}`);
26
31
  lines.push(`Token type: ${session.token_type}`);
27
32
  deps.stdout.write(lines.join('\n') + '\n');
package/dist/dpop.js CHANGED
@@ -6,7 +6,7 @@ export function generateEphemeralDpopKey() {
6
6
  return { privateKeyPem, publicJwk };
7
7
  }
8
8
  function base64url(buf) {
9
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
9
+ return buf.toString('base64url');
10
10
  }
11
11
  export function canonicalizeHtu(input) {
12
12
  const url = new URL(input);
package/dist/env.js CHANGED
@@ -3,6 +3,8 @@ export const DEFAULT_API_BASE_URL = 'https://api.rakomi.com';
3
3
  export const DEFAULT_ACCOUNTS_BASE_URL = 'https://accounts.rakomi.com';
4
4
  export const DEFAULT_MCP_URL = 'https://mcp.rakomi.com/mcp';
5
5
  export const DEFAULT_CIMD_CLIENT_ID_URL = 'https://api.rakomi.com/.well-known/rakomi-cli/client-metadata.json';
6
+ export const DEFAULT_PLATFORM_TENANT_ID = '01ef847e-4c40-4960-af05-11112c4764b2';
7
+ export const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
6
8
  export function isCimdClientId(id) {
7
9
  let url;
8
10
  try {
@@ -22,6 +24,24 @@ export function accountsBaseUrl(env) {
22
24
  export function clientId(env) {
23
25
  return env.RAKOMI_CLIENT_ID || DEFAULT_CIMD_CLIENT_ID_URL;
24
26
  }
27
+ const TENANT_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+ const NIL_TENANT_ID = '00000000-0000-0000-0000-000000000000';
29
+ const MAX_TENANT_ID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
30
+ export function isValidTenantId(id) {
31
+ if (id.length !== 36)
32
+ return false;
33
+ if (!TENANT_ID_UUID_PATTERN.test(id))
34
+ return false;
35
+ const lower = id.toLowerCase();
36
+ return lower !== NIL_TENANT_ID && lower !== MAX_TENANT_ID;
37
+ }
38
+ export function platformTenantId(env) {
39
+ const raw = env.RAKOMI_PLATFORM_TENANT_ID;
40
+ if (raw)
41
+ return isValidTenantId(raw) ? raw : undefined;
42
+ const isUnoverriddenProdHost = apiBaseUrl(env) === DEFAULT_API_BASE_URL && accountsBaseUrl(env) === DEFAULT_ACCOUNTS_BASE_URL;
43
+ return isUnoverriddenProdHost ? DEFAULT_PLATFORM_TENANT_ID : undefined;
44
+ }
25
45
  const CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD = 'client_id_metadata_document_supported';
26
46
  export async function isCimdPlatformSupported(deps, apiUrl) {
27
47
  try {
package/dist/index.js CHANGED
@@ -8,12 +8,15 @@ import { systemBrowserOpener } from './browser.js';
8
8
  import { runConnect } from './commands/connect.js';
9
9
  import { runLogin, runLoginCi } from './commands/login.js';
10
10
  import { runLogout } from './commands/logout.js';
11
- import { runTenantsCreate, runTenantsList } from './commands/tenants.js';
11
+ import { runMcpToolsCi } from './commands/mcp.js';
12
+ import { runTenantsClaim, runTenantsCreate, runTenantsList, runTenantsMemberships, runTenantsRelease } from './commands/tenants.js';
13
+ import { runUse } from './commands/use.js';
12
14
  import { runWhoami } from './commands/whoami.js';
13
15
  import { apiBaseUrl, DEFAULT_MCP_URL } from './env.js';
14
16
  import { CliError, EXIT, UsageError } from './errors.js';
15
17
  import { startLoopbackListener } from './loopback-server.js';
16
18
  import { resolveStores } from './session.js';
19
+ import { FileTenantConfigStore } from './tenant-config.js';
17
20
  import { helpText, usageLine } from './usage.js';
18
21
  const GLOBAL_OPTIONS = {
19
22
  json: { type: 'boolean' },
@@ -28,6 +31,10 @@ const GLOBAL_OPTIONS = {
28
31
  write: { type: 'boolean' },
29
32
  owner: { type: 'string' },
30
33
  slug: { type: 'string' },
34
+ 'tenant-id': { type: 'string' },
35
+ tenant: { type: 'string' },
36
+ 'ttl-seconds': { type: 'string' },
37
+ label: { type: 'string' },
31
38
  'cimd-url': { type: 'string' },
32
39
  status: { type: 'boolean' },
33
40
  help: { type: 'boolean', short: 'h' },
@@ -50,6 +57,15 @@ export async function run(args, deps) {
50
57
  return EXIT.FAIL;
51
58
  }
52
59
  }
60
+ function parseTtlSeconds(raw) {
61
+ if (typeof raw !== 'string')
62
+ return undefined;
63
+ const n = Number(raw);
64
+ if (!Number.isInteger(n) || n <= 0) {
65
+ throw new UsageError(`--ttl-seconds must be a positive integer, got "${raw}"`);
66
+ }
67
+ return n;
68
+ }
53
69
  async function dispatch(args, deps) {
54
70
  let values;
55
71
  let positionals;
@@ -82,6 +98,7 @@ async function dispatch(args, deps) {
82
98
  env: deps.env,
83
99
  ciEnv: deps.ciEnv ?? {},
84
100
  session: deps.session,
101
+ keys: deps.keys,
85
102
  oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
86
103
  readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
87
104
  stdout: deps.stdout,
@@ -100,6 +117,7 @@ async function dispatch(args, deps) {
100
117
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
101
118
  stdout: deps.stdout,
102
119
  now: () => Date.now(),
120
+ explicitTenantId: typeof values['tenant-id'] === 'string' ? values['tenant-id'] : undefined,
103
121
  });
104
122
  return EXIT.OK;
105
123
  }
@@ -107,8 +125,23 @@ async function dispatch(args, deps) {
107
125
  runLogout({ session: deps.session, stdout: deps.stdout });
108
126
  return EXIT.OK;
109
127
  case 'whoami':
110
- await runWhoami({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
128
+ await runWhoami({
129
+ ...httpDeps,
130
+ session: deps.session,
131
+ keys: deps.keys,
132
+ json,
133
+ stdout: deps.stdout,
134
+ tenantConfig: deps.tenantConfig,
135
+ explicitTenant: typeof values.tenant === 'string' ? values.tenant : undefined,
136
+ });
111
137
  return EXIT.OK;
138
+ case 'use': {
139
+ const tenantId = rest[0];
140
+ if (!tenantId)
141
+ throw new UsageError('Usage: rakomi use <tenant-id-or-slug>');
142
+ await runUse({ ...httpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
143
+ return EXIT.OK;
144
+ }
112
145
  case 'connect':
113
146
  await runConnect({
114
147
  ...httpDeps,
@@ -128,6 +161,26 @@ async function dispatch(args, deps) {
128
161
  status: values.status === true,
129
162
  });
130
163
  return EXIT.OK;
164
+ case 'mcp': {
165
+ const [sub] = rest;
166
+ if (sub !== 'tools') {
167
+ throw new UsageError('Usage: rakomi mcp tools --ci');
168
+ }
169
+ if (values.ci !== true) {
170
+ throw new UsageError('rakomi mcp tools requires --ci (workload-identity federation; no interactive form exists yet).');
171
+ }
172
+ await runMcpToolsCi({
173
+ ...httpDeps,
174
+ env: deps.env,
175
+ ciEnv: deps.ciEnv ?? {},
176
+ keys: deps.keys,
177
+ oidcTokenFile: typeof values['oidc-token-file'] === 'string' ? values['oidc-token-file'] : undefined,
178
+ readTextFile: deps.readTextFile ?? ((p) => readFileSync(p, 'utf8')),
179
+ json,
180
+ stdout: deps.stdout,
181
+ });
182
+ return EXIT.OK;
183
+ }
131
184
  case 'tenants': {
132
185
  const [sub, ...tenantArgs] = rest;
133
186
  if (sub === 'create') {
@@ -141,7 +194,32 @@ async function dispatch(args, deps) {
141
194
  await runTenantsList({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
142
195
  return EXIT.OK;
143
196
  }
144
- throw new UsageError('Usage: rakomi tenants <create|list> ...');
197
+ if (sub === 'memberships') {
198
+ await runTenantsMemberships({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
199
+ return EXIT.OK;
200
+ }
201
+ if (sub === 'claim') {
202
+ if (values.ci !== true) {
203
+ throw new UsageError('rakomi tenants claim requires --ci (no interactive form exists yet).');
204
+ }
205
+ await runTenantsClaim({ ...httpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, {
206
+ parentTenantId: typeof values.tenant === 'string' ? values.tenant : undefined,
207
+ ttlSeconds: parseTtlSeconds(values['ttl-seconds']),
208
+ label: typeof values.label === 'string' ? values.label : undefined,
209
+ });
210
+ return EXIT.OK;
211
+ }
212
+ if (sub === 'release') {
213
+ if (values.ci !== true) {
214
+ throw new UsageError('rakomi tenants release requires --ci (no interactive form exists yet).');
215
+ }
216
+ const tenantId = tenantArgs[0];
217
+ if (!tenantId)
218
+ throw new UsageError('Usage: rakomi tenants release --ci <tenant-id> [--tenant <parent-tenant-id>]');
219
+ await runTenantsRelease({ ...httpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, { tenantId, parentTenantId: typeof values.tenant === 'string' ? values.tenant : undefined });
220
+ return EXIT.OK;
221
+ }
222
+ throw new UsageError('Usage: rakomi tenants <create|list|claim|release> ...');
145
223
  }
146
224
  default:
147
225
  throw new UsageError(`Unknown command "${command ?? ''}".`);
@@ -170,6 +248,7 @@ async function main() {
170
248
  RAKOMI_ACCOUNTS_URL: process.env.RAKOMI_ACCOUNTS_URL,
171
249
  RAKOMI_CLIENT_ID: process.env.RAKOMI_CLIENT_ID,
172
250
  RAKOMI_CONFIG_DIR: process.env.RAKOMI_CONFIG_DIR,
251
+ RAKOMI_PLATFORM_TENANT_ID: process.env.RAKOMI_PLATFORM_TENANT_ID,
173
252
  CI: process.env.CI,
174
253
  NO_COLOR: process.env.NO_COLOR,
175
254
  };
@@ -194,6 +273,7 @@ async function main() {
194
273
  fetchImpl: fetch,
195
274
  session: resolved.session,
196
275
  keys: resolved.keys,
276
+ tenantConfig: new FileTenantConfigStore(env),
197
277
  detectClaudeCode: () => detectClaudeCodeCli(),
198
278
  ciEnv,
199
279
  });
@@ -1,5 +1,5 @@
1
1
  import { createPrivateKey, generateKeyPairSync, randomUUID, sign as cryptoSign } from 'node:crypto';
2
- import { isCimdClientId } from './env.js';
2
+ import { CI_FEDERATION_SESSION_CLIENT_ID, isCimdClientId } from './env.js';
3
3
  import { request } from './http.js';
4
4
  const INSTALL_KEY_NAME = 'install-key';
5
5
  const ASSERTION_LIFETIME_SEC = 60;
@@ -19,23 +19,32 @@ function generateKeyPair(clientId) {
19
19
  const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
20
20
  return { clientId, privateKeyPem, publicJwk, confirmedBound: false };
21
21
  }
22
- export function getOrCreateInstallKey(keys, clientId) {
22
+ export function peekInstallKey(keys, clientId) {
23
23
  const raw = keys.get(INSTALL_KEY_NAME);
24
- if (raw !== null) {
25
- try {
26
- const parsed = JSON.parse(raw);
27
- if (isStoredInstallKey(parsed) && parsed.clientId === clientId)
28
- return parsed;
29
- }
30
- catch {
31
- }
24
+ if (raw === null)
25
+ return undefined;
26
+ try {
27
+ const parsed = JSON.parse(raw);
28
+ if (isStoredInstallKey(parsed) && parsed.clientId === clientId)
29
+ return parsed;
32
30
  }
31
+ catch {
32
+ }
33
+ return undefined;
34
+ }
35
+ export function persistInstallKey(keys, key) {
36
+ keys.set(INSTALL_KEY_NAME, JSON.stringify(key));
37
+ }
38
+ export function getOrCreateInstallKey(keys, clientId) {
39
+ const existing = peekInstallKey(keys, clientId);
40
+ if (existing)
41
+ return existing;
33
42
  const fresh = generateKeyPair(clientId);
34
- keys.set(INSTALL_KEY_NAME, JSON.stringify(fresh));
43
+ persistInstallKey(keys, fresh);
35
44
  return fresh;
36
45
  }
37
46
  function base64url(buf) {
38
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
47
+ return buf.toString('base64url');
39
48
  }
40
49
  export function signClientAssertion(key, opts) {
41
50
  const privateKey = createPrivateKey(key.privateKeyPem);
@@ -79,7 +88,7 @@ export async function bindInstallKey(deps, keys, opts, key) {
79
88
  export function resolveDpopKey(keys, session) {
80
89
  if (session.token_type !== 'DPoP')
81
90
  return undefined;
82
- if (!isCimdClientId(session.client_id))
91
+ if (!isCimdClientId(session.client_id) && session.client_id !== CI_FEDERATION_SESSION_CLIENT_ID)
83
92
  return undefined;
84
93
  return getOrCreateInstallKey(keys, session.client_id);
85
94
  }
@@ -0,0 +1,64 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { CliError, EXIT } from './errors.js';
3
+ import { describeError, errorCode, request } from './http.js';
4
+ export const EPHEMERAL_TENANT_TTL_SECONDS_MIN = 600;
5
+ export const EPHEMERAL_TENANT_TTL_SECONDS_MAX = 1_209_600;
6
+ export const EPHEMERAL_LEASE_LABEL_MAX_LENGTH = 200;
7
+ function generateIdempotencyKey() {
8
+ return randomBytes(16).toString('hex');
9
+ }
10
+ const CHILD_TENANT_LIMIT_REACHED_CODE = 'tenant/child_tenant_limit_reached';
11
+ export async function claimEphemeralTenant(deps, opts) {
12
+ const result = await request(deps, {
13
+ method: 'POST',
14
+ url: `${opts.apiBaseUrl}/v1/tenants/${encodeURIComponent(opts.parentTenantId)}/ephemeral/claims`,
15
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
16
+ body: {
17
+ ...(opts.ttlSeconds !== undefined ? { ttl_seconds: opts.ttlSeconds } : {}),
18
+ ...(opts.label ? { label: opts.label } : {}),
19
+ },
20
+ idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey(),
21
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
22
+ });
23
+ if (result.status === 401) {
24
+ throw new CliError('Your session has expired. Run `rakomi login --ci` again.', EXIT.NOT_LOGGED_IN);
25
+ }
26
+ if (result.status === 403) {
27
+ throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)} ` +
28
+ "(missing the `tenants:lease` scope — check this CI identity's trust policy).", EXIT.FAIL);
29
+ }
30
+ if (result.status === 404) {
31
+ throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)} ` +
32
+ "(the given tenant is not the caller's own tenant, or the caller's own tenant is itself " +
33
+ 'ephemeral — ephemeral tenants cannot nest).', EXIT.FAIL);
34
+ }
35
+ if (result.status === 409 && errorCode(result.body) === CHILD_TENANT_LIMIT_REACHED_CODE) {
36
+ throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)}.`, EXIT.FAIL);
37
+ }
38
+ if (result.status !== 201) {
39
+ throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)}`, EXIT.FAIL);
40
+ }
41
+ return result.body;
42
+ }
43
+ export async function releaseEphemeralTenant(deps, opts) {
44
+ const result = await request(deps, {
45
+ method: 'DELETE',
46
+ url: `${opts.apiBaseUrl}/v1/tenants/${encodeURIComponent(opts.parentTenantId)}/ephemeral/claims/${encodeURIComponent(opts.tenantId)}`,
47
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
48
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
49
+ });
50
+ if (result.status === 401) {
51
+ throw new CliError('Your session has expired. Run `rakomi login --ci` again.', EXIT.NOT_LOGGED_IN);
52
+ }
53
+ if (result.status === 403) {
54
+ throw new CliError(`Could not release the ephemeral tenant: ${describeError(result.body, result.status)} ` +
55
+ "(missing the `tenants:lease` scope — check this CI identity's trust policy).", EXIT.FAIL);
56
+ }
57
+ if (result.status === 404) {
58
+ throw new CliError(`Could not release the ephemeral tenant: ${describeError(result.body, result.status)} ` +
59
+ '(the given tenant id does not exist under the given parent, or is not an ephemeral tenant).', EXIT.FAIL);
60
+ }
61
+ if (result.status !== 204) {
62
+ throw new CliError(`Could not release the ephemeral tenant: ${describeError(result.body, result.status)}`, EXIT.FAIL);
63
+ }
64
+ }
@@ -0,0 +1,44 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { describeError, request } from './http.js';
3
+ export const MCP_RESOURCE = 'https://mcp.rakomi.com';
4
+ const MCP_PROTOCOL_VERSION = '2026-07-28';
5
+ const MCP_PROTOCOL_VERSION_HEADER = 'MCP-Protocol-Version';
6
+ const MCP_METHOD_HEADER = 'Mcp-Method';
7
+ const MCP_META_PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion';
8
+ const MCP_META_CLIENT_CAPABILITIES_KEY = 'io.modelcontextprotocol/clientCapabilities';
9
+ function isJsonRpcError(body) {
10
+ return typeof body === 'object' && body !== null && 'error' in body;
11
+ }
12
+ export async function listMcpToolsCi(deps, opts) {
13
+ const message = {
14
+ jsonrpc: '2.0',
15
+ id: 1,
16
+ method: 'tools/list',
17
+ params: {
18
+ _meta: {
19
+ [MCP_META_PROTOCOL_VERSION_KEY]: MCP_PROTOCOL_VERSION,
20
+ [MCP_META_CLIENT_CAPABILITIES_KEY]: {},
21
+ },
22
+ },
23
+ };
24
+ const result = await request(deps, {
25
+ method: 'POST',
26
+ url: opts.mcpUrl,
27
+ headers: {
28
+ [MCP_PROTOCOL_VERSION_HEADER]: MCP_PROTOCOL_VERSION,
29
+ [MCP_METHOD_HEADER]: 'tools/list',
30
+ },
31
+ body: message,
32
+ dpop: { key: opts.dpopKey, accessToken: opts.accessToken },
33
+ });
34
+ if (result.status === 401) {
35
+ throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
36
+ }
37
+ if (isJsonRpcError(result.body)) {
38
+ throw new CliError(`Could not list MCP tools: ${result.body.error.message}`, EXIT.FAIL);
39
+ }
40
+ if (result.status !== 200 || typeof result.body !== 'object' || result.body === null || !('result' in result.body)) {
41
+ throw new CliError(`Could not list MCP tools: ${describeError(result.body, result.status)}`, EXIT.FAIL);
42
+ }
43
+ return result.body.result.tools;
44
+ }
@@ -9,6 +9,8 @@ export function buildAuthorizeUrl(opts) {
9
9
  url.searchParams.set('state', opts.pkce.state);
10
10
  url.searchParams.set('code_challenge', opts.pkce.codeChallenge);
11
11
  url.searchParams.set('code_challenge_method', opts.pkce.codeChallengeMethod);
12
+ if (opts.tenantId)
13
+ url.searchParams.set('tenant_id', opts.tenantId);
12
14
  return url.toString();
13
15
  }
14
16
  export async function exchangeAuthorizationCode(deps, opts) {
@@ -1,11 +1,10 @@
1
- import { buildDpopProof, canonicalizeHtu, generateEphemeralDpopKey } from './dpop.js';
1
+ import { buildDpopProof, canonicalizeHtu } from './dpop.js';
2
2
  import { CliError, EXIT } from './errors.js';
3
3
  import { describeError, request } from './http.js';
4
4
  export const OIDC_FEDERATION_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
5
5
  export async function exchangeOidcSubjectToken(deps, opts) {
6
6
  const tokenUrl = `${opts.apiBaseUrl}/oauth/token`;
7
- const dpopKey = generateEphemeralDpopKey();
8
- const proof = buildDpopProof(dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
7
+ const proof = buildDpopProof(opts.dpopKey, { htm: 'POST', htu: canonicalizeHtu(tokenUrl) });
9
8
  const result = await request(deps, {
10
9
  method: 'POST',
11
10
  url: tokenUrl,
package/dist/pkce.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  function base64url(buf) {
3
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
3
+ return buf.toString('base64url');
4
4
  }
5
5
  export function generateCodeVerifier() {
6
6
  return base64url(randomBytes(32));
package/dist/session.js CHANGED
@@ -98,7 +98,8 @@ function isStoredSession(value) {
98
98
  (v.token_type === 'Bearer' || v.token_type === 'DPoP') &&
99
99
  typeof v.expires_at === 'number' &&
100
100
  typeof v.api_base_url === 'string' &&
101
- typeof v.client_id === 'string');
101
+ typeof v.client_id === 'string' &&
102
+ (v.home_tenant_id === undefined || typeof v.home_tenant_id === 'string'));
102
103
  }
103
104
  export function isExpired(session, now) {
104
105
  return now >= session.expires_at;
@@ -0,0 +1,50 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configDir } from './session.js';
4
+ function isStoredTenantConfig(value) {
5
+ if (typeof value !== 'object' || value === null)
6
+ return false;
7
+ const v = value;
8
+ return typeof v.active_tenant_id === 'string' && v.active_tenant_id.length > 0;
9
+ }
10
+ function tenantConfigFilePath(env) {
11
+ return join(configDir(env), 'tenant.json');
12
+ }
13
+ export class FileTenantConfigStore {
14
+ path;
15
+ dir;
16
+ constructor(env) {
17
+ this.dir = configDir(env);
18
+ this.path = tenantConfigFilePath(env);
19
+ }
20
+ read() {
21
+ try {
22
+ const raw = readFileSync(this.path, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ return isStoredTenantConfig(parsed) ? parsed.active_tenant_id : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ write(tenantId) {
31
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
32
+ const config = { active_tenant_id: tenantId };
33
+ writeFileSync(this.path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
34
+ try {
35
+ chmodSync(this.path, 0o600);
36
+ }
37
+ catch {
38
+ }
39
+ }
40
+ clear() {
41
+ try {
42
+ rmSync(this.path, { force: true });
43
+ }
44
+ catch {
45
+ }
46
+ }
47
+ describePath() {
48
+ return this.path;
49
+ }
50
+ }
@@ -64,3 +64,46 @@ export async function listTenants(deps, opts) {
64
64
  }
65
65
  return result.body;
66
66
  }
67
+ function describeCallerUnverified(action) {
68
+ return (`Could not ${action}: your account e-mail is not verified (or is disabled). Verify your e-mail ` +
69
+ 'and sign in again.');
70
+ }
71
+ export async function listTenantMemberships(deps, opts) {
72
+ const result = await request(deps, {
73
+ method: 'GET',
74
+ url: `${opts.apiBaseUrl}/v1/tenants/memberships`,
75
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
76
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
77
+ });
78
+ if (result.status === 401) {
79
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
80
+ }
81
+ if (result.status === 403) {
82
+ throw new CliError(describeCallerUnverified('list your tenant memberships'), EXIT.FAIL);
83
+ }
84
+ if (result.status !== 200) {
85
+ throw new CliError(`Could not list your tenant memberships: ${describeError(result.body, result.status)}`, EXIT.FAIL);
86
+ }
87
+ return result.body;
88
+ }
89
+ export async function getTenantMembershipBySlug(deps, opts) {
90
+ const result = await request(deps, {
91
+ method: 'GET',
92
+ url: `${opts.apiBaseUrl}/v1/tenants/memberships/${encodeURIComponent(opts.slug)}`,
93
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
94
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
95
+ });
96
+ if (result.status === 401) {
97
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
98
+ }
99
+ if (result.status === 404) {
100
+ return null;
101
+ }
102
+ if (result.status === 403) {
103
+ throw new CliError(describeCallerUnverified('look up this tenant membership'), EXIT.FAIL);
104
+ }
105
+ if (result.status !== 200) {
106
+ throw new CliError(`Could not look up tenant "${opts.slug}": ${describeError(result.body, result.status)}`, EXIT.FAIL);
107
+ }
108
+ return result.body;
109
+ }
package/dist/usage.js CHANGED
@@ -9,8 +9,10 @@ export function helpText() {
9
9
  ' login sign in (browser by default; --no-browser for a device code)',
10
10
  ' --ci sign in from CI via the runner\'s own OIDC identity (no browser/device code)',
11
11
  ' --oidc-token-file <p> read the OIDC token from a file instead of the CI platform\'s own env',
12
+ ' --tenant-id <uuid> the tenant to authenticate against (or set RAKOMI_PLATFORM_TENANT_ID)',
12
13
  ' logout clear the local session',
13
- ' whoami show the signed-in account',
14
+ ' whoami show the signed-in account, home tenant, and active tenant',
15
+ ' use <tenant-id-or-slug> remember a tenant locally (whoami/future commands); a UUID is stored as-is, a slug is server-verified',
14
16
  ' connect connect Claude Code / Claude Desktop to your tenant (read access)',
15
17
  ' --write also request write access for the connected client',
16
18
  ' --client <name> claude-code | claude-desktop (required if more than one applies)',
@@ -21,6 +23,14 @@ export function helpText() {
21
23
  ' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
22
24
  ' --slug <slug> optional; auto-derived + suffixed if omitted',
23
25
  ' tenants list list tenants you provisioned',
26
+ ' tenants memberships list tenants your account is a verified member of',
27
+ ' tenants claim --ci claim a temporary, self-expiring tenant under your own tenant',
28
+ ' --tenant <id> your own tenant to claim under (or run `rakomi use <tenant-id>` first)',
29
+ ' --ttl-seconds <n> how long the claim should live, in seconds (only for a freshly-created tenant)',
30
+ ' --label <text> optional label, stored and echoed back, never interpreted',
31
+ ' tenants release --ci <tenant-id> release a tenant claimed by `tenants claim`',
32
+ ' --tenant <id> the tenant it was claimed under (or run `rakomi use <tenant-id>` first)',
33
+ ' mcp tools --ci list MCP read tools your CI identity can invoke (no login needed first)',
24
34
  '',
25
35
  'Global options:',
26
36
  ' --json machine-readable output',
@@ -28,6 +38,7 @@ export function helpText() {
28
38
  ' --dry-run print what would happen, make no writes or mutating calls',
29
39
  ' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
30
40
  ' --no-keychain store the session in a 0600 file instead of the OS keychain',
41
+ ' --tenant <id> the tenant for this invocation (whoami, tenants claim/release)',
31
42
  ' -h, --help show this help and exit',
32
43
  ' -V, --version print the version and exit',
33
44
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rakomi",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "The Rakomi CLI — sign in, connect an AI agent (Claude Code, Claude Desktop) to your tenant, and provision tenants. EU-native auth-as-a-service.",
5
5
  "keywords": [
6
6
  "rakomi",
package/sbom.cdx.json CHANGED
@@ -26,10 +26,10 @@
26
26
  },
27
27
  "component": {
28
28
  "type": "library",
29
- "bom-ref": "pkg:npm/rakomi@0.1.2",
29
+ "bom-ref": "pkg:npm/rakomi@0.3.0",
30
30
  "name": "rakomi",
31
- "version": "0.1.2",
32
- "purl": "pkg:npm/rakomi@0.1.2",
31
+ "version": "0.3.0",
32
+ "purl": "pkg:npm/rakomi@0.3.0",
33
33
  "licenses": [
34
34
  {
35
35
  "license": {