rakomi 0.2.0 → 0.3.1

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
@@ -2,6 +2,16 @@
2
2
 
3
3
  The [Rakomi](https://rakomi.com) CLI. EU-native authentication as a service.
4
4
 
5
+ ## Ask your agent
6
+
7
+ Add the Rakomi MCP server to your coding agent, then ask it directly:
8
+
9
+ ```sh
10
+ claude mcp add --transport http rakomi https://mcp.rakomi.com/mcp
11
+ ```
12
+
13
+ "List the AI agents connected to my Rakomi tenant."
14
+
5
15
  ## Getting started
6
16
 
7
17
  ```sh
@@ -15,9 +25,12 @@ session to get a short code to enter on another device instead. If you don't alr
15
25
  Rakomi administrator for the right value.
16
26
 
17
27
  `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 use <tenant-id>` remembers a tenant id
19
- locally for that; `--tenant <tenant-id>` overrides it for one command. Neither is a verified
20
- grant — the tenant `whoami` shows as your Home tenant is the only one the server has confirmed.
28
+ commands should treat as active ("Active tenant") — including whether that active selection was
29
+ confirmed by the server or only remembered locally. `rakomi tenants memberships` lists every tenant
30
+ your account is a verified member of, with your role in each. `rakomi use <slug>` looks one up from
31
+ that list and remembers it — a slug you don't belong to fails with a plain "not found" rather than
32
+ confirming the tenant exists; `rakomi use <tenant-id>` (a full id) remembers it directly without a
33
+ lookup, and `--tenant <tenant-id>` overrides it for one command.
21
34
 
22
35
  ## Connect an AI agent
23
36
 
@@ -25,10 +38,12 @@ grant — the tenant `whoami` shows as your Home tenant is the only one the serv
25
38
  npx rakomi connect
26
39
  ```
27
40
 
28
- Detects Claude Code and writes the `.mcp.json` entry for you, or prints Claude Desktop's
29
- Custom-Connectors instructions when run with `--client claude-desktop`. Read access works
30
- immediately once the client finishes its own sign-in; write access is a separate step your
31
- tenant owner approves — see the [Connect an AI Agent guide](https://docs.rakomi.dev/guides/connect-an-ai-agent).
41
+ Detects Claude Code and writes its config entry for you. Pass `--client <name>` for any other known
42
+ client — a config file is written in that client's own shape for one that supports it, or its own
43
+ connection steps are printed for a UI-only client (Claude Desktop, ChatGPT, JetBrains). Run
44
+ `rakomi --help` for the current client list. Read access works immediately once the client finishes
45
+ its own sign-in; write access is a separate step your tenant owner approves — see the
46
+ [Connect an AI Agent guide](https://docs.rakomi.dev/guides/connect-an-ai-agent).
32
47
 
33
48
  Pass `--cimd-url <url>` (find it in your MCP client's own connection diagnostics) and `rakomi`
34
49
  confirms the connection for you instead of leaving you to check the dashboard; add `--write` to
@@ -54,10 +69,11 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
54
69
  | `login` | sign in (browser by default; `--no-browser` for a device code) |
55
70
  | `logout` | clear the local session |
56
71
  | `whoami` | show the signed-in account, home tenant, and active tenant |
57
- | `use <tenant-id>` | remember a tenant id locally for `whoami`/future commands |
58
- | `connect` | connect Claude Code / Claude Desktop to your tenant (read access) |
72
+ | `use <tenant-id-or-slug>` | remember a tenant locally for `whoami`/future commands; a slug is server-verified |
73
+ | `connect` | connect an MCP client (Claude Code, Claude Desktop, and others — see `--help`) to your tenant (read access) |
59
74
  | `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
60
75
  | `tenants list` | list tenants you provisioned |
76
+ | `tenants memberships` | list tenants you're a verified member of |
61
77
 
62
78
  ## Global options
63
79
 
@@ -70,10 +86,11 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
70
86
  | `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
71
87
  | `--tenant-id <uuid>` | (`login`) the tenant to sign in against, if `RAKOMI_PLATFORM_TENANT_ID` isn't set for you |
72
88
  | `--tenant <tenant-id>` | override the active tenant for a single command (`whoami` today) |
73
- | `--client <name>` | `claude-code` or `claude-desktop` — required when more than one applies |
74
- | `--undo` | restore the `.mcp.json` `connect` last backed up |
89
+ | `--client <name>` | one of the known MCP clients (`connect --help` prints the current list) — required when more than one applies |
90
+ | `--name <server-name>` | (`connect`) the config key to write the entry under (default `rakomi`) — one name per workspace on a machine; a client keeps one login per server name |
91
+ | `--undo` | restore the config file `connect` last backed up (for the same `--name`) |
75
92
  | `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
76
- | `--status` | (`connect`) re-check status only — needs `--cimd-url`, never rewrites `.mcp.json` |
93
+ | `--status` | (`connect`) re-check status only — needs `--cimd-url`, never rewrites the config file |
77
94
  | `-h`, `--help` | show help |
78
95
  | `-V`, `--version` | print the version |
79
96
 
package/SECURITY.md CHANGED
@@ -9,8 +9,8 @@ While these packages remain pre-1.0 (`0.x`), they carry **no stability or suppor
9
9
  (SemVer 2.0.0 §4); the latest `0.x` line receives security updates on a best-effort basis.
10
10
 
11
11
  From version **1.0** onward, Rakomi maintains the current (N) and previous (N-1) MAJOR in parallel, with N-1 receiving
12
- security-only fixes. The CRA support period for each MAJOR is determined in accordance with
13
- **CRA Art. 13(8)** — at least five years, or the product's expected use time where shorter. The authoritative, machine-readable support windows are published at
12
+ security-only fixes. For each MAJOR version of the Rakomi SDKs, CRE8EVE commits to a CRA support period of
13
+ **at least five years (60 months)**, meeting the support-period requirement of **CRA Art. 13(8)**. The authoritative, machine-readable support windows are published at
14
14
  [`https://api.rakomi.com/.well-known/sdk-support.json`](https://api.rakomi.com/.well-known/sdk-support.json)
15
15
  and rendered for humans on the [SDK Support & Lifecycle page](https://rakomi.com/sdk-support). This
16
16
  document points at that single source rather than re-typing dated rows.
@@ -0,0 +1,19 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { describeError, request } from './http.js';
3
+ export async function getBillingPlan(deps, opts) {
4
+ const url = new URL('/v1/billing/plan', opts.apiBaseUrl);
5
+ url.searchParams.set('tenant_id', opts.tenantId);
6
+ const result = await request(deps, {
7
+ method: 'GET',
8
+ url: url.toString(),
9
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
10
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
11
+ });
12
+ if (result.status === 401) {
13
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
14
+ }
15
+ if (result.status !== 200) {
16
+ throw new CliError(`Could not fetch your billing plan: ${describeError(result.body, result.status)}`, EXIT.FAIL);
17
+ }
18
+ return result.body;
19
+ }
@@ -0,0 +1,156 @@
1
+ export const RAKOMI_SERVER_NAME = "rakomi";
2
+ export const CLIENT_REGISTRY = [
3
+ {
4
+ id: "claude-code",
5
+ displayName: "Claude Code",
6
+ tier: "written",
7
+ status: "verified",
8
+ finishInstruction: "1. Run `claude` in this directory and approve the \"{{SERVER_NAME}}\" project server when prompted.\n2. Then run `claude mcp login {{SERVER_NAME}}` to finish sign-in in your browser (or use `/mcp` inside that session).",
9
+ config: {
10
+ scope: "project",
11
+ pathTemplate: ".mcp.json",
12
+ serialization: "json",
13
+ topLevelKey: "mcpServers",
14
+ remoteUrlField: "url",
15
+ extraFields: { "type": "http" },
16
+ },
17
+ evidenceUrl: "https://code.claude.com/docs/en/mcp",
18
+ },
19
+ {
20
+ id: "vscode",
21
+ displayName: "VS Code",
22
+ tier: "written",
23
+ status: "documented-shape-only",
24
+ finishInstruction: "Open the Command Palette and run \"MCP: List Servers\", or the Extensions view — VS Code starts the OAuth flow automatically the first time it connects.",
25
+ config: {
26
+ scope: "project",
27
+ pathTemplate: ".vscode/mcp.json",
28
+ serialization: "json",
29
+ topLevelKey: "servers",
30
+ remoteUrlField: "url",
31
+ extraFields: { "type": "http" },
32
+ },
33
+ evidenceUrl: "https://code.visualstudio.com/docs/agents/reference/mcp-configuration",
34
+ },
35
+ {
36
+ id: "cursor",
37
+ displayName: "Cursor",
38
+ tier: "written",
39
+ status: "documented-shape-only",
40
+ finishInstruction: "Open Cursor's MCP settings and connect — for a server that supports OAuth, Cursor completes the flow for you (or accepts static client credentials in mcp.json instead of dynamic client registration).",
41
+ config: {
42
+ scope: "project",
43
+ pathTemplate: ".cursor/mcp.json",
44
+ serialization: "json",
45
+ topLevelKey: "mcpServers",
46
+ remoteUrlField: "url",
47
+ extraFields: {},
48
+ },
49
+ evidenceUrl: "https://cursor.com/docs/mcp",
50
+ },
51
+ {
52
+ id: "antigravity",
53
+ displayName: "Google Antigravity",
54
+ tier: "written",
55
+ status: "documented-shape-only",
56
+ finishInstruction: "Open the MCP Store or your MCP settings in Antigravity — it handles OAuth automatically for servers that support dynamic client registration (DCR).",
57
+ config: {
58
+ scope: "project",
59
+ pathTemplate: ".agents/mcp_config.json",
60
+ serialization: "json",
61
+ topLevelKey: "mcpServers",
62
+ remoteUrlField: "serverUrl",
63
+ extraFields: {},
64
+ },
65
+ evidenceUrl: "https://antigravity.google/docs/ide/mcp/",
66
+ },
67
+ {
68
+ id: "gemini-cli",
69
+ displayName: "Gemini CLI",
70
+ tier: "written",
71
+ status: "documented-shape-only",
72
+ finishInstruction: "Run `gemini mcp list`, or just start a session — the Gemini CLI supports OAuth 2.0 for remote MCP servers and negotiates it automatically.",
73
+ config: {
74
+ scope: "project",
75
+ pathTemplate: ".gemini/settings.json",
76
+ serialization: "json",
77
+ topLevelKey: "mcpServers",
78
+ remoteUrlField: "httpUrl",
79
+ extraFields: {},
80
+ },
81
+ evidenceUrl: "https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html",
82
+ },
83
+ {
84
+ id: "zed",
85
+ displayName: "Zed",
86
+ tier: "written",
87
+ status: "documented-shape-only",
88
+ finishInstruction: "Open Settings -> AI -> MCP Servers in Zed — when a remote server has no Authorization header configured, Zed prompts you to authenticate with the standard MCP OAuth flow.",
89
+ config: {
90
+ scope: "project",
91
+ pathTemplate: ".zed/settings.json",
92
+ serialization: "json",
93
+ topLevelKey: "context_servers",
94
+ remoteUrlField: "url",
95
+ extraFields: {},
96
+ },
97
+ evidenceUrl: "https://zed.dev/docs/ai/mcp",
98
+ },
99
+ {
100
+ id: "codex-cli",
101
+ displayName: "OpenAI Codex CLI",
102
+ tier: "written",
103
+ status: "documented-shape-only",
104
+ finishInstruction: "Run `codex mcp login {{SERVER_NAME}}` to finish sign-in in your browser.",
105
+ config: {
106
+ scope: "user",
107
+ pathTemplate: ".codex/config.toml",
108
+ serialization: "toml",
109
+ topLevelKey: "mcp_servers",
110
+ remoteUrlField: "url",
111
+ extraFields: {},
112
+ },
113
+ evidenceUrl: "https://learn.chatgpt.com/docs/extend/mcp?surface=cli",
114
+ },
115
+ {
116
+ id: "devin-desktop",
117
+ displayName: "Devin Desktop",
118
+ tier: "written",
119
+ status: "documented-shape-only",
120
+ finishInstruction: "Open Devin Desktop — it supports OAuth for each transport type and prompts you to connect the first time.",
121
+ config: {
122
+ scope: "user",
123
+ pathTemplate: ".codeium/windsurf/mcp_config.json",
124
+ serialization: "json",
125
+ topLevelKey: "mcpServers",
126
+ remoteUrlField: "serverUrl",
127
+ extraFields: {},
128
+ },
129
+ evidenceUrl: "https://docs.devin.ai/desktop/cascade/mcp",
130
+ },
131
+ {
132
+ id: "claude-desktop",
133
+ displayName: "Claude Desktop",
134
+ tier: "instructed",
135
+ status: "verified",
136
+ finishInstruction: "Claude Desktop connects to remote MCP servers through Connectors, configured from your Claude account rather than a local file — there is nothing for `rakomi connect` to write.\n\n1. Open Settings -> Connectors in Claude Desktop and add a custom connector.\n2. Enter Rakomi's MCP server URL: {{MCP_URL}}\n3. Click Connect. Claude Desktop opens your browser at accounts.rakomi.com — sign in and\n approve the read-only consent screen.",
137
+ evidenceUrl: "https://support.claude.com/en/articles/11175166-about-custom-connectors-remote-mcp",
138
+ },
139
+ {
140
+ id: "chatgpt",
141
+ displayName: "ChatGPT",
142
+ tier: "instructed",
143
+ status: "documented-shape-only",
144
+ finishInstruction: "In ChatGPT, go to Workspace Settings -> Permissions & Roles -> Developer mode, then add {{MCP_URL}} as a remote server. ChatGPT starts an OAuth flow to your workspace's identity provider once it's added.",
145
+ evidenceUrl: "https://developers.openai.com/api/docs/mcp",
146
+ },
147
+ {
148
+ id: "jetbrains",
149
+ displayName: "JetBrains AI Assistant",
150
+ tier: "instructed",
151
+ status: "documented-shape-only",
152
+ finishInstruction: "In your JetBrains IDE, go to Settings -> Tools -> AI Assistant -> Model Context Protocol (MCP), click Add, and paste: {\"mcpServers\":{\"{{SERVER_NAME}}\":{\"url\":\"{{MCP_URL}}\"}}}\nJetBrains' own documentation does not state whether it drives OAuth for you — be ready to complete sign-in in whatever browser tab it opens.",
153
+ evidenceUrl: "https://www.jetbrains.com/help/ai-assistant/mcp.html",
154
+ },
155
+ ];
156
+ export const KNOWN_CLIENTS = CLIENT_REGISTRY.map((c) => c.id);
package/dist/clients.js CHANGED
@@ -1,9 +1,17 @@
1
- export const KNOWN_CLIENTS = ['claude-code', 'claude-desktop'];
1
+ import { CLIENT_REGISTRY, KNOWN_CLIENTS } from './client-registry.generated.js';
2
+ import { CliError, EXIT } from './errors.js';
3
+ export { CLIENT_REGISTRY, KNOWN_CLIENTS };
2
4
  export function isKnownClient(value) {
3
5
  return KNOWN_CLIENTS.includes(value);
4
6
  }
7
+ export function clientRegistryEntry(id) {
8
+ const entry = CLIENT_REGISTRY.find((c) => c.id === id);
9
+ if (!entry)
10
+ throw new CliError(`clientRegistryEntry: unknown client '${id}' — this should be unreachable, isKnownClient() gates every caller`, EXIT.FAIL);
11
+ return entry;
12
+ }
5
13
  export function clientDisplayName(client) {
6
- return client === 'claude-code' ? 'Claude Code' : 'Claude Desktop';
14
+ return clientRegistryEntry(client).displayName;
7
15
  }
8
16
  export function resolveTargetClients(opts) {
9
17
  if (opts.explicitClient) {
@@ -1,24 +1,29 @@
1
- import { AmbiguousClientError, clientDisplayName, resolveTargetClients } from '../clients.js';
1
+ import { AmbiguousClientError, clientRegistryEntry, resolveTargetClients } from '../clients.js';
2
2
  import { pollCimdMaterialization, requestWriteElevation } from '../connect-client.js';
3
- import { DEFAULT_MCP_URL } from '../env.js';
4
3
  import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
5
4
  import { resolveDpopKey } from '../install-key.js';
6
- import { mcpConfigPath, undoMcpConfigWrite, writeRakomiMcpServerEntry } from '../mcp-config.js';
7
- const CLAUDE_DESKTOP_INSTRUCTIONS = [
8
- 'Claude Desktop connects to remote MCP servers through Connectors, configured from your Claude',
9
- 'account rather than a local file — there is nothing for `rakomi connect` to write.',
10
- '',
11
- '1. Open Settings -> Connectors in Claude Desktop and add a custom connector.',
12
- `2. Enter Rakomi's MCP server URL: ${DEFAULT_MCP_URL}`,
13
- '3. Click Connect. Claude Desktop opens your browser at accounts.rakomi.com — sign in and',
14
- ' approve the read-only consent screen.',
15
- ].join('\n');
5
+ import { backupSuffixFor, clientConfigPath, RAKOMI_SERVER_NAME, undoClientConfigWrite, writeClientMcpConfigEntry, } from '../mcp-config.js';
6
+ import { resolveServerName } from '../server-name.js';
7
+ import { undoClientTomlConfigWrite, writeClientTomlConfigEntry } from '../toml-config.js';
8
+ function renderInstruction(instruction, mcpUrl, serverName) {
9
+ return instruction.replaceAll('{{MCP_URL}}', mcpUrl).replaceAll('{{SERVER_NAME}}', serverName);
10
+ }
11
+ function resolveSingleTargetClient(deps) {
12
+ let targets;
13
+ try {
14
+ targets = resolveTargetClients({ explicitClient: deps.explicitClient, claudeCodeDetected: deps.detectClaudeCode() });
15
+ }
16
+ catch (e) {
17
+ if (e instanceof AmbiguousClientError)
18
+ throw new UsageError(e.message);
19
+ throw e;
20
+ }
21
+ return clientRegistryEntry(targets[0]);
22
+ }
16
23
  export async function runConnect(deps) {
24
+ const serverName = resolveServerName(deps.serverName);
17
25
  if (deps.undo) {
18
- const restored = undoMcpConfigWrite(deps.cwd);
19
- deps.stdout.write(restored
20
- ? `Restored ${mcpConfigPath(deps.cwd)} from the backup \`rakomi connect\` made.\n`
21
- : `No \`rakomi connect\` backup found at ${mcpConfigPath(deps.cwd)}.rakomi-backup — nothing to undo.\n`);
26
+ runUndo(deps, serverName);
22
27
  return;
23
28
  }
24
29
  const session = deps.session.read();
@@ -37,13 +42,33 @@ export async function runConnect(deps) {
37
42
  throw new UsageError(e.message);
38
43
  throw e;
39
44
  }
40
- for (const client of targets) {
41
- if (client === 'claude-desktop') {
42
- deps.stdout.write(`${clientDisplayName(client)}:\n${CLAUDE_DESKTOP_INSTRUCTIONS}\n`);
45
+ for (const clientId of targets) {
46
+ const client = clientRegistryEntry(clientId);
47
+ if (client.tier === 'instructed') {
48
+ deps.stdout.write(`${client.displayName}:\n${renderInstruction(client.finishInstruction, deps.mcpUrl, serverName)}\n`);
43
49
  continue;
44
50
  }
45
- await connectClaudeCode(deps, session);
51
+ if (clientId === 'claude-code') {
52
+ await connectClaudeCode(deps, session, client, serverName);
53
+ }
54
+ else {
55
+ connectGenericWrittenClient(deps, client, serverName);
56
+ }
57
+ }
58
+ }
59
+ function runUndo(deps, serverName) {
60
+ const client = resolveSingleTargetClient(deps);
61
+ if (!client.config) {
62
+ deps.stdout.write(`${client.displayName} writes no config file — nothing to undo.\n`);
63
+ return;
46
64
  }
65
+ const path = clientConfigPath(client, deps.cwd, deps.homeDir);
66
+ const restored = client.config.serialization === 'toml'
67
+ ? undoClientTomlConfigWrite(client, deps.cwd, deps.homeDir, serverName)
68
+ : undoClientConfigWrite(client, deps.cwd, deps.homeDir, serverName);
69
+ deps.stdout.write(restored
70
+ ? `Restored ${path} from the backup \`rakomi connect\` made.\n`
71
+ : `No \`rakomi connect\` backup found at ${path}${backupSuffixFor(serverName)} — nothing to undo.\n`);
47
72
  }
48
73
  async function reportStatus(deps, session) {
49
74
  if (!deps.cimdUrl) {
@@ -65,20 +90,35 @@ async function reportStatus(deps, session) {
65
90
  await requestWrite(deps, session, row.id);
66
91
  }
67
92
  }
68
- async function connectClaudeCode(deps, session) {
69
- if (deps.dryRun) {
70
- deps.stdout.write(`Would write ${mcpConfigPath(deps.cwd)} with mcpServers.rakomi -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
71
- return;
72
- }
73
- const result = writeRakomiMcpServerEntry(deps.cwd, deps.mcpUrl);
93
+ function writeSummary(deps, result) {
74
94
  if (result.wrote) {
75
- deps.stdout.write(`Wrote ${mcpConfigPath(deps.cwd)}${result.backedUp ? ' (previous file backed up)' : ''}.\n${result.diff}\n`);
95
+ deps.stdout.write(`Wrote ${result.path}${result.backedUp ? ' (previous file backed up)' : ''}.\n${result.diff}\n`);
76
96
  }
77
97
  else {
78
98
  deps.stdout.write(`${result.diff}\n`);
79
99
  }
80
- deps.stdout.write('Now open Claude Code and run `claude mcp login rakomi` to finish sign-in in your browser.\n' +
100
+ }
101
+ function secondWorkspaceHint(client, explicitClient) {
102
+ const example = `${RAKOMI_SERVER_NAME}-<workspace>`;
103
+ const clientFlag = explicitClient ? ` --client ${explicitClient}` : '';
104
+ const where = client.config?.scope === 'user' ? '' : ' in that project';
105
+ const login = client.id === 'claude-code' ? `, then \`claude mcp login ${example}\`` : ', then sign in under that name';
106
+ return (`\nTip: another project on this machine bound to a different workspace? Sign-ins are kept per server ` +
107
+ `name, so give it its own: run \`rakomi connect${clientFlag} --name ${example}\`${where}${login}.\n`);
108
+ }
109
+ async function connectClaudeCode(deps, session, client, serverName) {
110
+ const path = clientConfigPath(client, deps.cwd, deps.homeDir);
111
+ if (deps.dryRun) {
112
+ deps.stdout.write(`Would write ${path} with ${client.config.topLevelKey}.${serverName} -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
113
+ return;
114
+ }
115
+ const result = writeClientMcpConfigEntry(client, deps.cwd, deps.homeDir, deps.mcpUrl, serverName);
116
+ writeSummary(deps, result);
117
+ deps.stdout.write(`${renderInstruction(client.finishInstruction, deps.mcpUrl, serverName)}\n` +
81
118
  'Once approved, Claude Code has READ access to your tenant immediately.\n');
119
+ if (result.wrote && serverName === RAKOMI_SERVER_NAME) {
120
+ deps.stdout.write(secondWorkspaceHint(client, deps.explicitClient));
121
+ }
82
122
  if (!deps.cimdUrl) {
83
123
  deps.stdout.write('\nOnce connected, Rakomi can confirm it and (with --write) request write access for you — pass ' +
84
124
  '`--cimd-url <url>` (find it in your MCP client\'s own connection diagnostics) to use it, or ' +
@@ -93,8 +133,9 @@ async function connectClaudeCode(deps, session) {
93
133
  dpop,
94
134
  });
95
135
  if (!row) {
136
+ const nameFlag = serverName === RAKOMI_SERVER_NAME ? '' : ` --name ${serverName}`;
96
137
  deps.stdout.write(`\nNot connected yet. Ctrl+C is safe — resume any time with:\n` +
97
- ` rakomi connect --status --cimd-url ${deps.cimdUrl}\n`);
138
+ ` rakomi connect --status${nameFlag} --cimd-url ${deps.cimdUrl}\n`);
98
139
  return;
99
140
  }
100
141
  deps.stdout.write(`\nConnected — access: ${row.agent_access.level}.\n`);
@@ -105,6 +146,21 @@ async function connectClaudeCode(deps, session) {
105
146
  await requestWrite(deps, session, row.id);
106
147
  }
107
148
  }
149
+ function connectGenericWrittenClient(deps, client, serverName) {
150
+ const path = clientConfigPath(client, deps.cwd, deps.homeDir);
151
+ if (deps.dryRun) {
152
+ deps.stdout.write(`Would write ${path} with ${client.config.topLevelKey}.${serverName} -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
153
+ return;
154
+ }
155
+ const result = client.config.serialization === 'toml'
156
+ ? writeClientTomlConfigEntry(client, deps.cwd, deps.homeDir, deps.mcpUrl, serverName)
157
+ : writeClientMcpConfigEntry(client, deps.cwd, deps.homeDir, deps.mcpUrl, serverName);
158
+ writeSummary(deps, result);
159
+ deps.stdout.write(`${renderInstruction(client.finishInstruction, deps.mcpUrl, serverName)}\n`);
160
+ if (result.wrote && serverName === RAKOMI_SERVER_NAME) {
161
+ deps.stdout.write(secondWorkspaceHint(client, deps.explicitClient));
162
+ }
163
+ }
108
164
  async function requestWrite(deps, session, oauthClientId) {
109
165
  const dpop = resolveDpopKey(deps.keys, session);
110
166
  await requestWriteElevation(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken: session.access_token, oauthClientId, dpop });
@@ -89,6 +89,7 @@ async function runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId) {
89
89
  scope: LOGIN_SCOPE,
90
90
  tenantId,
91
91
  });
92
+ deps.stdout.write('Signing you in to your Rakomi developer account (separate from your tenants\' end-user accounts)…\n');
92
93
  deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
93
94
  await deps.openBrowser(authorizeUrl);
94
95
  const callback = await listener.waitForCallback(LOOPBACK_TIMEOUT_MS);
@@ -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,21 @@
1
+ import { getBillingPlan } from '../billing-client.js';
2
+ import { NotLoggedInError, UsageError } from '../errors.js';
3
+ import { resolveDpopKey } from '../install-key.js';
4
+ export async function runUpgrade(deps) {
5
+ const session = deps.session.read();
6
+ if (!session)
7
+ throw new NotLoggedInError();
8
+ const tenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
9
+ if (!tenantId) {
10
+ throw new UsageError('No active tenant set. Run `rakomi use <tenant-slug>` first, or pass --tenant <id>.');
11
+ }
12
+ const dpop = resolveDpopKey(deps.keys, session);
13
+ const plan = await getBillingPlan(deps, {
14
+ apiBaseUrl: session.api_base_url,
15
+ accessToken: session.access_token,
16
+ tenantId,
17
+ dpop,
18
+ });
19
+ deps.stdout.write(`Current plan: ${plan.effective_plan}\nOpening ${plan.upgrade_url} in your browser...\n`);
20
+ await deps.openBrowser(plan.upgrade_url);
21
+ }
@@ -1,10 +1,26 @@
1
1
  import { isValidTenantId } from '../env.js';
2
- import { UsageError } from '../errors.js';
3
- export function runUse(deps, args) {
4
- if (!isValidTenantId(args.tenantId)) {
5
- throw new UsageError(`"${args.tenantId}" is not a valid tenant id — expected a UUID (e.g. the value \`rakomi login\` ` +
6
- 'printed as "Home tenant", or the id shown in the dashboard).');
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;
7
10
  }
8
- deps.tenantConfig.write(args.tenantId);
9
- deps.stdout.write(`Active tenant set to ${args.tenantId}.\n`);
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, membership.slug);
25
+ deps.stdout.write(`Active tenant set to ${membership.slug} (${membership.id}) — role: ${membership.role}.\n`);
10
26
  }