rakomi 0.3.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,7 +25,8 @@ 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 tenants memberships` lists every tenant
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
19
30
  your account is a verified member of, with your role in each. `rakomi use <slug>` looks one up from
20
31
  that list and remembers it — a slug you don't belong to fails with a plain "not found" rather than
21
32
  confirming the tenant exists; `rakomi use <tenant-id>` (a full id) remembers it directly without a
@@ -27,10 +38,12 @@ lookup, and `--tenant <tenant-id>` overrides it for one command.
27
38
  npx rakomi connect
28
39
  ```
29
40
 
30
- Detects Claude Code and writes the `.mcp.json` entry for you, or prints Claude Desktop's
31
- Custom-Connectors instructions when run with `--client claude-desktop`. Read access works
32
- immediately once the client finishes its own sign-in; write access is a separate step your
33
- 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).
34
47
 
35
48
  Pass `--cimd-url <url>` (find it in your MCP client's own connection diagnostics) and `rakomi`
36
49
  confirms the connection for you instead of leaving you to check the dashboard; add `--write` to
@@ -57,7 +70,7 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
57
70
  | `logout` | clear the local session |
58
71
  | `whoami` | show the signed-in account, home tenant, and active tenant |
59
72
  | `use <tenant-id-or-slug>` | remember a tenant locally for `whoami`/future commands; a slug is server-verified |
60
- | `connect` | connect Claude Code / Claude Desktop to your tenant (read access) |
73
+ | `connect` | connect an MCP client (Claude Code, Claude Desktop, and others — see `--help`) to your tenant (read access) |
61
74
  | `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
62
75
  | `tenants list` | list tenants you provisioned |
63
76
  | `tenants memberships` | list tenants you're a verified member of |
@@ -73,10 +86,11 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
73
86
  | `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
74
87
  | `--tenant-id <uuid>` | (`login`) the tenant to sign in against, if `RAKOMI_PLATFORM_TENANT_ID` isn't set for you |
75
88
  | `--tenant <tenant-id>` | override the active tenant for a single command (`whoami` today) |
76
- | `--client <name>` | `claude-code` or `claude-desktop` — required when more than one applies |
77
- | `--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`) |
78
92
  | `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
79
- | `--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 |
80
94
  | `-h`, `--help` | show help |
81
95
  | `-V`, `--version` | print the version |
82
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);
@@ -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
+ }
@@ -21,6 +21,6 @@ export async function runUse(deps, args) {
21
21
  throw new UsageError(`No tenant membership found for "${args.tenantId}" — check the spelling, or ask a tenant ` +
22
22
  'admin to invite you. (Run `rakomi tenants memberships` to see the tenants you already belong to.)');
23
23
  }
24
- deps.tenantConfig.write(membership.id);
24
+ deps.tenantConfig.write(membership.id, membership.slug);
25
25
  deps.stdout.write(`Active tenant set to ${membership.slug} (${membership.id}) — role: ${membership.role}.\n`);
26
26
  }
@@ -1,12 +1,29 @@
1
1
  import { NotLoggedInError } from '../errors.js';
2
2
  import { resolveDpopKey } from '../install-key.js';
3
3
  import { fetchUserInfo } from '../userinfo-client.js';
4
+ function describeTokenStatus(expiresAt, now) {
5
+ return now >= expiresAt ? 'expired (will auto-refresh on next use)' : 'valid';
6
+ }
7
+ function describeActiveTenant(deps, activeTenantId) {
8
+ if (!activeTenantId)
9
+ return 'none set (run `rakomi use <tenant-slug>`)';
10
+ if (deps.explicitTenant)
11
+ return `${activeTenantId} (--tenant override for this command only — not verified)`;
12
+ const active = deps.tenantConfig.readActive?.() ?? null;
13
+ if (active && active.tenantId === activeTenantId && active.verifiedViaSlug) {
14
+ return `${activeTenantId} (verified member of "${active.verifiedViaSlug}")`;
15
+ }
16
+ return `${activeTenantId} (remembered locally — membership not verified; re-run \`rakomi use <tenant-slug>\` to verify)`;
17
+ }
4
18
  export async function runWhoami(deps) {
5
- const session = deps.session.read();
6
- if (!session)
19
+ const initialSession = deps.session.read();
20
+ if (!initialSession)
7
21
  throw new NotLoggedInError();
8
- const dpop = resolveDpopKey(deps.keys, session);
9
- const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, dpop });
22
+ const dpop = resolveDpopKey(deps.keys, initialSession);
23
+ const info = await fetchUserInfo(deps, { apiBaseUrl: initialSession.api_base_url, accessToken: initialSession.access_token, dpop });
24
+ const session = deps.session.read() ?? initialSession;
25
+ const now = (deps.now ?? Date.now)();
26
+ const tokenStatus = describeTokenStatus(session.expires_at, now);
10
27
  const activeTenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
11
28
  if (deps.json) {
12
29
  deps.stdout.write(JSON.stringify({
@@ -16,6 +33,8 @@ export async function runWhoami(deps) {
16
33
  org_role: info.org_role,
17
34
  session_store: deps.session.describePath(),
18
35
  token_type: session.token_type,
36
+ expires_at: new Date(session.expires_at).toISOString(),
37
+ token_status: tokenStatus,
19
38
  home_tenant_id: session.home_tenant_id,
20
39
  active_tenant_id: activeTenantId,
21
40
  }) + '\n');
@@ -26,8 +45,9 @@ export async function runWhoami(deps) {
26
45
  lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
27
46
  lines.push(`API: ${session.api_base_url}`);
28
47
  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>`)'}`);
48
+ lines.push(`Active tenant: ${describeActiveTenant(deps, activeTenantId)}`);
30
49
  lines.push(`Session stored in: ${deps.session.describePath()}`);
31
50
  lines.push(`Token type: ${session.token_type}`);
51
+ lines.push(`Token expires: ${new Date(session.expires_at).toISOString()} (${tokenStatus})`);
32
52
  deps.stdout.write(lines.join('\n') + '\n');
33
53
  }
package/dist/http.js CHANGED
@@ -7,7 +7,40 @@ function isErrorEnvelope(v) {
7
7
  function isProblemDetails(v) {
8
8
  return typeof v === 'object' && v !== null && !('error' in v) && ('code' in v || 'detail' in v || 'title' in v);
9
9
  }
10
+ function errorDetails(body) {
11
+ if (isErrorEnvelope(body) && typeof body.error === 'object') {
12
+ const d = body.error.details;
13
+ return d && typeof d === 'object' ? d : undefined;
14
+ }
15
+ if (isProblemDetails(body)) {
16
+ return body.details && typeof body.details === 'object' ? body.details : undefined;
17
+ }
18
+ return undefined;
19
+ }
20
+ export function upsellFromProblem(body, status) {
21
+ if (status !== 403)
22
+ return undefined;
23
+ const url = errorDetails(body)?.upgrade_url;
24
+ return typeof url === 'string' && url.length > 0 ? { upgradeUrl: url } : undefined;
25
+ }
26
+ export function requestIdFromError(body) {
27
+ if (isErrorEnvelope(body) && typeof body.error === 'object') {
28
+ const id = body.error.request_id;
29
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
30
+ }
31
+ if (isProblemDetails(body)) {
32
+ return typeof body.request_id === 'string' && body.request_id.length > 0 ? body.request_id : undefined;
33
+ }
34
+ return undefined;
35
+ }
10
36
  export function describeError(body, status) {
37
+ const base = describeErrorBase(body, status);
38
+ const upsell = upsellFromProblem(body, status);
39
+ const withUpsell = upsell ? `${base}\nUpgrade: ${upsell.upgradeUrl}\nRun \`rakomi upgrade\` to open this in your browser.` : base;
40
+ const id = requestIdFromError(body);
41
+ return id ? `${withUpsell}\nRequest ID: ${id}` : withUpsell;
42
+ }
43
+ function describeErrorBase(body, status) {
11
44
  if (isErrorEnvelope(body)) {
12
45
  if (typeof body.error === 'string')
13
46
  return body.error_description || body.error;
@@ -72,6 +105,8 @@ async function performOnce(deps, req, dpopNonce) {
72
105
  parsed = JSON.parse(text);
73
106
  }
74
107
  catch {
108
+ if (res.status < 200 || res.status >= 300)
109
+ return { status: res.status, body: {}, headers: res.headers };
75
110
  throw new CliError('The Rakomi API returned a response the CLI could not understand.', EXIT.FAIL);
76
111
  }
77
112
  }
@@ -89,7 +124,7 @@ function isDpopNonceChallenge(result) {
89
124
  const nonce = result.headers.get('dpop-nonce');
90
125
  return nonce && nonce.length > 0 ? nonce : undefined;
91
126
  }
92
- export async function request(deps, req) {
127
+ async function performWithNonceRetry(deps, req) {
93
128
  const first = await performOnce(deps, req);
94
129
  if (!req.dpop)
95
130
  return first;
@@ -102,3 +137,25 @@ export async function request(deps, req) {
102
137
  }
103
138
  return retry;
104
139
  }
140
+ function isAuthenticatedRequest(req) {
141
+ if (req.dpop?.accessToken !== undefined)
142
+ return true;
143
+ return typeof req.headers?.authorization === 'string' && req.headers.authorization.length > 0;
144
+ }
145
+ function withRefreshedCredentials(req, credentials) {
146
+ if (req.dpop?.accessToken !== undefined) {
147
+ return { ...req, dpop: { ...req.dpop, accessToken: credentials.accessToken } };
148
+ }
149
+ return { ...req, headers: { ...req.headers, authorization: `Bearer ${credentials.accessToken}` } };
150
+ }
151
+ export async function request(deps, req) {
152
+ const first = await performWithNonceRetry(deps, req);
153
+ if (first.status !== 401)
154
+ return first;
155
+ if (!deps.onUnauthorized || !isAuthenticatedRequest(req))
156
+ return first;
157
+ const refreshed = await deps.onUnauthorized();
158
+ if (!refreshed)
159
+ return first;
160
+ return performWithNonceRetry(deps, withRefreshedCredentials(req, refreshed));
161
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
3
4
  import { delimiter, join } from 'node:path';
4
5
  import process from 'node:process';
5
6
  import { fileURLToPath } from 'node:url';
@@ -10,6 +11,7 @@ import { runLogin, runLoginCi } from './commands/login.js';
10
11
  import { runLogout } from './commands/logout.js';
11
12
  import { runMcpToolsCi } from './commands/mcp.js';
12
13
  import { runTenantsClaim, runTenantsCreate, runTenantsList, runTenantsMemberships, runTenantsRelease } from './commands/tenants.js';
14
+ import { runUpgrade } from './commands/upgrade.js';
13
15
  import { runUse } from './commands/use.js';
14
16
  import { runWhoami } from './commands/whoami.js';
15
17
  import { apiBaseUrl, DEFAULT_MCP_URL } from './env.js';
@@ -17,6 +19,7 @@ import { CliError, EXIT, UsageError } from './errors.js';
17
19
  import { startLoopbackListener } from './loopback-server.js';
18
20
  import { resolveStores } from './session.js';
19
21
  import { FileTenantConfigStore } from './tenant-config.js';
22
+ import { refreshSession } from './token-refresh.js';
20
23
  import { helpText, usageLine } from './usage.js';
21
24
  const GLOBAL_OPTIONS = {
22
25
  json: { type: 'boolean' },
@@ -37,6 +40,7 @@ const GLOBAL_OPTIONS = {
37
40
  label: { type: 'string' },
38
41
  'cimd-url': { type: 'string' },
39
42
  status: { type: 'boolean' },
43
+ name: { type: 'string' },
40
44
  help: { type: 'boolean', short: 'h' },
41
45
  version: { type: 'boolean', short: 'V' },
42
46
  };
@@ -89,6 +93,13 @@ async function dispatch(args, deps) {
89
93
  const dryRun = values['dry-run'] === true;
90
94
  const json = values.json === true;
91
95
  const httpDeps = { fetchImpl: deps.fetchImpl };
96
+ const onUnauthorized = async () => {
97
+ const current = deps.session.read();
98
+ if (!current)
99
+ return undefined;
100
+ return refreshSession({ fetchImpl: deps.fetchImpl }, current, deps.keys, deps.session, () => Date.now());
101
+ };
102
+ const authHttpDeps = { ...httpDeps, onUnauthorized };
92
103
  const [command, ...rest] = positionals;
93
104
  switch (command) {
94
105
  case 'login': {
@@ -126,7 +137,7 @@ async function dispatch(args, deps) {
126
137
  return EXIT.OK;
127
138
  case 'whoami':
128
139
  await runWhoami({
129
- ...httpDeps,
140
+ ...authHttpDeps,
130
141
  session: deps.session,
131
142
  keys: deps.keys,
132
143
  json,
@@ -139,15 +150,27 @@ async function dispatch(args, deps) {
139
150
  const tenantId = rest[0];
140
151
  if (!tenantId)
141
152
  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 });
153
+ await runUse({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
143
154
  return EXIT.OK;
144
155
  }
156
+ case 'upgrade':
157
+ await runUpgrade({
158
+ ...authHttpDeps,
159
+ session: deps.session,
160
+ keys: deps.keys,
161
+ tenantConfig: deps.tenantConfig,
162
+ explicitTenant: typeof values.tenant === 'string' ? values.tenant : undefined,
163
+ openBrowser: systemBrowserOpener(),
164
+ stdout: deps.stdout,
165
+ });
166
+ return EXIT.OK;
145
167
  case 'connect':
146
168
  await runConnect({
147
- ...httpDeps,
169
+ ...authHttpDeps,
148
170
  session: deps.session,
149
171
  keys: deps.keys,
150
172
  cwd: deps.cwd,
173
+ homeDir: homedir(),
151
174
  apiBaseUrl: apiBaseUrl(deps.env),
152
175
  mcpUrl: deps.env.RAKOMI_API_URL ? `${apiBaseUrl(deps.env)}/mcp` : DEFAULT_MCP_URL,
153
176
  detectClaudeCode: deps.detectClaudeCode,
@@ -159,6 +182,7 @@ async function dispatch(args, deps) {
159
182
  explicitClient: typeof values.client === 'string' ? values.client : undefined,
160
183
  cimdUrl: typeof values['cimd-url'] === 'string' ? values['cimd-url'] : undefined,
161
184
  status: values.status === true,
185
+ serverName: typeof values.name === 'string' ? values.name : undefined,
162
186
  });
163
187
  return EXIT.OK;
164
188
  case 'mcp': {
@@ -187,22 +211,22 @@ async function dispatch(args, deps) {
187
211
  const name = tenantArgs[0];
188
212
  if (!name)
189
213
  throw new UsageError('Usage: rakomi tenants create <name> [--owner me|<email>] [--slug <slug>]');
190
- await runTenantsCreate({ ...httpDeps, session: deps.session, keys: deps.keys, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
214
+ await runTenantsCreate({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
191
215
  return EXIT.OK;
192
216
  }
193
217
  if (sub === 'list') {
194
- await runTenantsList({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
218
+ await runTenantsList({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
195
219
  return EXIT.OK;
196
220
  }
197
221
  if (sub === 'memberships') {
198
- await runTenantsMemberships({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
222
+ await runTenantsMemberships({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
199
223
  return EXIT.OK;
200
224
  }
201
225
  if (sub === 'claim') {
202
226
  if (values.ci !== true) {
203
227
  throw new UsageError('rakomi tenants claim requires --ci (no interactive form exists yet).');
204
228
  }
205
- await runTenantsClaim({ ...httpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, {
229
+ await runTenantsClaim({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, {
206
230
  parentTenantId: typeof values.tenant === 'string' ? values.tenant : undefined,
207
231
  ttlSeconds: parseTtlSeconds(values['ttl-seconds']),
208
232
  label: typeof values.label === 'string' ? values.label : undefined,
@@ -216,7 +240,7 @@ async function dispatch(args, deps) {
216
240
  const tenantId = tenantArgs[0];
217
241
  if (!tenantId)
218
242
  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 });
243
+ await runTenantsRelease({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, { tenantId, parentTenantId: typeof values.tenant === 'string' ? values.tenant : undefined });
220
244
  return EXIT.OK;
221
245
  }
222
246
  throw new UsageError('Usage: rakomi tenants <create|list|claim|release> ...');
@@ -1,11 +1,90 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { CliError, EXIT } from './errors.js';
3
- const SUCCESS_PAGE = `<!doctype html><html><head><title>rakomi login</title></head>
4
- <body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;text-align:center">
5
- <h1>Signed in</h1><p>You can close this tab and return to your terminal.</p></body></html>`;
6
- const ERROR_PAGE = (message) => `<!doctype html><html><head><title>rakomi login</title></head>
7
- <body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;text-align:center">
8
- <h1>Sign-in failed</h1><p>${escapeHtml(message)}</p><p>Return to your terminal and try again.</p></body></html>`;
3
+ const PAGE_STYLE = `
4
+ :root { color-scheme: light dark; }
5
+ * { box-sizing: border-box; }
6
+ body {
7
+ margin: 0;
8
+ min-height: 100vh;
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ font-family: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
13
+ background: linear-gradient(to bottom, #F3F4F7, #ECEEF2, #E4E6EB);
14
+ color: #2A2A2A;
15
+ }
16
+ .card {
17
+ max-width: 26rem;
18
+ width: calc(100% - 3rem);
19
+ margin: 1.5rem;
20
+ padding: 2.5rem 2rem;
21
+ border-radius: 1rem;
22
+ text-align: center;
23
+ background: rgba(255, 255, 255, 0.75);
24
+ border: 1px solid rgba(29, 85, 102, 0.14);
25
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(29, 85, 102, 0.08);
26
+ }
27
+ .wordmark {
28
+ font-size: 0.8125rem;
29
+ font-weight: 600;
30
+ letter-spacing: 0.08em;
31
+ text-transform: uppercase;
32
+ color: #1d5566;
33
+ margin: 0 0 1.5rem;
34
+ }
35
+ .icon {
36
+ width: 3rem;
37
+ height: 3rem;
38
+ margin: 0 auto 1.25rem;
39
+ border-radius: 999px;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ font-size: 1.5rem;
44
+ line-height: 1;
45
+ }
46
+ .icon.success { background: rgba(40, 114, 77, 0.14); color: #28724D; }
47
+ .icon.error { background: rgba(189, 46, 46, 0.14); color: #BD2E2E; }
48
+ h1 {
49
+ font-size: 1.25rem;
50
+ font-weight: 700;
51
+ line-height: 1.25;
52
+ margin: 0 0 0.5rem;
53
+ color: #2A2A2A;
54
+ }
55
+ p {
56
+ font-size: 0.9375rem;
57
+ line-height: 1.5;
58
+ color: #4A4A4A;
59
+ margin: 0 0 0.375rem;
60
+ }
61
+ p.hint { color: #616161; font-size: 0.875rem; }
62
+ @media (prefers-color-scheme: dark) {
63
+ body { background: linear-gradient(to bottom right, #23242c, #1e1f26, #191a20); color: #e6e7ec; }
64
+ .card { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.10); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); }
65
+ .wordmark { color: #51b0cd; }
66
+ .icon.success { background: rgba(98, 200, 146, 0.16); color: #62C892; }
67
+ .icon.error { background: rgba(242, 159, 159, 0.16); color: #F29F9F; }
68
+ h1 { color: #e6e7ec; }
69
+ p { color: #a1a3ad; }
70
+ p.hint { color: #a1a3ad; }
71
+ }
72
+ `;
73
+ const SUCCESS_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>rakomi login</title><style>${PAGE_STYLE}</style></head>
74
+ <body><main class="card">
75
+ <p class="wordmark">rakomi</p>
76
+ <div class="icon success" aria-hidden="true">&#10003;</div>
77
+ <h1>Signed in</h1>
78
+ <p>You can close this tab and return to your terminal.</p>
79
+ </main></body></html>`;
80
+ const ERROR_PAGE = (message) => `<!doctype html><html><head><meta charset="utf-8"><title>rakomi login</title><style>${PAGE_STYLE}</style></head>
81
+ <body><main class="card">
82
+ <p class="wordmark">rakomi</p>
83
+ <div class="icon error" aria-hidden="true">&#10005;</div>
84
+ <h1>Sign-in failed</h1>
85
+ <p>${escapeHtml(message)}</p>
86
+ <p class="hint">Return to your terminal and try again.</p>
87
+ </main></body></html>`;
9
88
  function escapeHtml(s) {
10
89
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
90
  }
@@ -1,7 +1,12 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- export const RAKOMI_SERVER_NAME = 'rakomi';
4
- const BACKUP_SUFFIX = '.rakomi-backup';
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { RAKOMI_SERVER_NAME } from './client-registry.generated.js';
4
+ import { CliError, EXIT } from './errors.js';
5
+ export { RAKOMI_SERVER_NAME };
6
+ export function backupSuffixFor(serverName) {
7
+ return `.${serverName}-backup`;
8
+ }
9
+ export const BACKUP_SUFFIX = backupSuffixFor(RAKOMI_SERVER_NAME);
5
10
  function readIfPresent(file) {
6
11
  try {
7
12
  return readFileSync(file, 'utf8');
@@ -12,13 +17,17 @@ function readIfPresent(file) {
12
17
  throw err;
13
18
  }
14
19
  }
15
- export function mcpConfigPath(projectDir) {
16
- return join(projectDir, '.mcp.json');
20
+ export function clientConfigPath(client, cwd, homeDir) {
21
+ if (!client.config) {
22
+ throw new CliError(`clientConfigPath: '${client.id}' is instructed-tier — it has no config file to write`, EXIT.FAIL);
23
+ }
24
+ const base = client.config.scope === 'user' ? homeDir : cwd;
25
+ return join(base, client.config.pathTemplate);
17
26
  }
18
- function backupPath(configPath) {
19
- return `${configPath}${BACKUP_SUFFIX}`;
27
+ function backupPath(configPath, serverName) {
28
+ return `${configPath}${backupSuffixFor(serverName)}`;
20
29
  }
21
- function readConfig(path) {
30
+ function readJsonConfig(path) {
22
31
  const text = readIfPresent(path);
23
32
  if (text === null)
24
33
  return {};
@@ -32,43 +41,57 @@ function readConfig(path) {
32
41
  return {};
33
42
  }
34
43
  }
35
- export function mergeRakomiServerEntry(config, mcpUrl) {
36
- const previousEntry = config.mcpServers?.[RAKOMI_SERVER_NAME];
37
- const nextEntry = { type: 'http', url: mcpUrl };
38
- const alreadyIdentical = previousEntry !== undefined &&
39
- typeof previousEntry === 'object' &&
40
- previousEntry.type === 'http' &&
41
- previousEntry.url === mcpUrl;
44
+ function shallowEqualStringRecord(a, b) {
45
+ const aKeys = Object.keys(a);
46
+ const bKeys = Object.keys(b);
47
+ if (aKeys.length !== bKeys.length)
48
+ return false;
49
+ return aKeys.every((k) => a[k] === b[k]);
50
+ }
51
+ export function mergeClientServerEntry(config, client, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
52
+ if (!client.config || client.config.serialization !== 'json') {
53
+ throw new CliError(`mergeClientServerEntry: '${client.id}' is not a JSON-shaped written client`, EXIT.FAIL);
54
+ }
55
+ const { topLevelKey, remoteUrlField, extraFields } = client.config;
56
+ const bucket = config[topLevelKey] ?? {};
57
+ const previousEntry = bucket[serverName];
58
+ const nextEntry = { ...extraFields, [remoteUrlField]: mcpUrl };
59
+ const alreadyIdentical = previousEntry !== undefined && shallowEqualStringRecord(previousEntry, nextEntry);
42
60
  if (alreadyIdentical)
43
61
  return { config, changed: false, previousEntry };
44
62
  const merged = {
45
63
  ...config,
46
- mcpServers: { ...(config.mcpServers ?? {}), [RAKOMI_SERVER_NAME]: nextEntry },
64
+ [topLevelKey]: { ...bucket, [serverName]: nextEntry },
47
65
  };
48
66
  return { config: merged, changed: true, previousEntry };
49
67
  }
50
- export function writeRakomiMcpServerEntry(projectDir, mcpUrl) {
51
- const path = mcpConfigPath(projectDir);
52
- const before = readConfig(path);
53
- const { config: after, changed, previousEntry } = mergeRakomiServerEntry(before, mcpUrl);
68
+ export function writeClientMcpConfigEntry(client, cwd, homeDir, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
69
+ const path = clientConfigPath(client, cwd, homeDir);
70
+ const before = readJsonConfig(path);
71
+ const { config: after, changed, previousEntry } = mergeClientServerEntry(before, client, mcpUrl, serverName);
54
72
  if (!changed) {
55
- return { wrote: false, backedUp: false, diff: `${RAKOMI_SERVER_NAME} already configured with ${mcpUrl} — no change.` };
73
+ return { wrote: false, backedUp: false, diff: `${serverName} already configured with ${mcpUrl} — no change.`, path };
56
74
  }
57
75
  let backedUp = false;
58
76
  const existing = readIfPresent(path);
59
77
  if (existing !== null) {
60
- writeFileSync(backupPath(path), existing);
78
+ writeFileSync(backupPath(path, serverName), existing);
61
79
  backedUp = true;
62
80
  }
81
+ else {
82
+ mkdirSync(dirname(path), { recursive: true });
83
+ }
63
84
  writeFileSync(path, JSON.stringify(after, null, 2) + '\n');
85
+ const topLevelKey = client.config.topLevelKey;
86
+ const nowEntry = after[topLevelKey][serverName];
64
87
  const diff = previousEntry
65
- ? `mcpServers.${RAKOMI_SERVER_NAME}: ${JSON.stringify(previousEntry)} -> ${JSON.stringify(after.mcpServers?.[RAKOMI_SERVER_NAME])}`
66
- : `mcpServers.${RAKOMI_SERVER_NAME}: (added) ${JSON.stringify(after.mcpServers?.[RAKOMI_SERVER_NAME])}`;
67
- return { wrote: true, backedUp, diff };
88
+ ? `${topLevelKey}.${serverName}: ${JSON.stringify(previousEntry)} -> ${JSON.stringify(nowEntry)}`
89
+ : `${topLevelKey}.${serverName}: (added) ${JSON.stringify(nowEntry)}`;
90
+ return { wrote: true, backedUp, diff, path };
68
91
  }
69
- export function undoMcpConfigWrite(projectDir) {
70
- const path = mcpConfigPath(projectDir);
71
- const backup = readIfPresent(backupPath(path));
92
+ export function undoClientConfigWrite(client, cwd, homeDir, serverName = RAKOMI_SERVER_NAME) {
93
+ const path = clientConfigPath(client, cwd, homeDir);
94
+ const backup = readIfPresent(backupPath(path, serverName));
72
95
  if (backup === null)
73
96
  return false;
74
97
  writeFileSync(path, backup);
@@ -0,0 +1,15 @@
1
+ import { RAKOMI_SERVER_NAME } from './client-registry.generated.js';
2
+ import { UsageError } from './errors.js';
3
+ export const SERVER_NAME_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
4
+ export const SERVER_NAME_MAX_LENGTH = 40;
5
+ export function isValidServerName(value) {
6
+ return SERVER_NAME_PATTERN.test(value);
7
+ }
8
+ export function resolveServerName(raw) {
9
+ if (raw === undefined)
10
+ return RAKOMI_SERVER_NAME;
11
+ if (isValidServerName(raw))
12
+ return raw;
13
+ throw new UsageError(`Invalid --name "${raw}": use lowercase letters, digits and hyphens only, starting with a letter, ` +
14
+ `at most ${SERVER_NAME_MAX_LENGTH} characters (for example \`${RAKOMI_SERVER_NAME}-acme\`).`);
15
+ }
@@ -5,7 +5,9 @@ function isStoredTenantConfig(value) {
5
5
  if (typeof value !== 'object' || value === null)
6
6
  return false;
7
7
  const v = value;
8
- return typeof v.active_tenant_id === 'string' && v.active_tenant_id.length > 0;
8
+ if (typeof v.active_tenant_id !== 'string' || v.active_tenant_id.length === 0)
9
+ return false;
10
+ return v.verified_via_slug === undefined || typeof v.verified_via_slug === 'string';
9
11
  }
10
12
  function tenantConfigFilePath(env) {
11
13
  return join(configDir(env), 'tenant.json');
@@ -18,18 +20,27 @@ export class FileTenantConfigStore {
18
20
  this.path = tenantConfigFilePath(env);
19
21
  }
20
22
  read() {
23
+ return this.readActive()?.tenantId ?? null;
24
+ }
25
+ readActive() {
21
26
  try {
22
27
  const raw = readFileSync(this.path, 'utf8');
23
28
  const parsed = JSON.parse(raw);
24
- return isStoredTenantConfig(parsed) ? parsed.active_tenant_id : null;
29
+ if (!isStoredTenantConfig(parsed))
30
+ return null;
31
+ return parsed.verified_via_slug !== undefined && parsed.verified_via_slug.length > 0
32
+ ? { tenantId: parsed.active_tenant_id, verifiedViaSlug: parsed.verified_via_slug }
33
+ : { tenantId: parsed.active_tenant_id };
25
34
  }
26
35
  catch {
27
36
  return null;
28
37
  }
29
38
  }
30
- write(tenantId) {
39
+ write(tenantId, verifiedViaSlug) {
31
40
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
32
- const config = { active_tenant_id: tenantId };
41
+ const config = verifiedViaSlug !== undefined && verifiedViaSlug.length > 0
42
+ ? { active_tenant_id: tenantId, verified_via_slug: verifiedViaSlug }
43
+ : { active_tenant_id: tenantId };
33
44
  writeFileSync(this.path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
34
45
  try {
35
46
  chmodSync(this.path, 0o600);
@@ -0,0 +1,56 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { request, requestIdFromError } from './http.js';
3
+ import { resolveDpopKey } from './install-key.js';
4
+ export async function refreshSession(deps, session, keys, sessionStore, now) {
5
+ if (!session.refresh_token)
6
+ return undefined;
7
+ const dpopKey = resolveDpopKey(keys, session);
8
+ let result;
9
+ try {
10
+ result = await request({ fetchImpl: deps.fetchImpl, timeoutMs: deps.timeoutMs }, {
11
+ method: 'POST',
12
+ url: `${session.api_base_url}/oauth/token`,
13
+ form: {
14
+ grant_type: 'refresh_token',
15
+ refresh_token: session.refresh_token,
16
+ client_id: session.client_id,
17
+ },
18
+ dpop: dpopKey ? { key: dpopKey } : undefined,
19
+ });
20
+ }
21
+ catch (err) {
22
+ const cause = err instanceof CliError ? err.message : 'The Rakomi API could not be reached.';
23
+ throw new CliError(`Could not refresh your session: ${cause} You are still logged in — try again in a moment.`, EXIT.FAIL);
24
+ }
25
+ if (isTransientStatus(result.status)) {
26
+ const retryAfter = retryAfterSeconds(result.headers.get('retry-after'));
27
+ const wait = retryAfter !== undefined ? `in ${retryAfter}s` : 'in a moment';
28
+ const id = requestIdFromError(result.body);
29
+ throw new CliError(`Could not refresh your session: the Rakomi API is temporarily unavailable (HTTP ${result.status}). ` +
30
+ `You are still logged in — try again ${wait}.${id ? `\nRequest ID: ${id}` : ''}`, EXIT.FAIL);
31
+ }
32
+ if (result.status !== 200)
33
+ return undefined;
34
+ const token = result.body;
35
+ if (typeof token.access_token !== 'string' || token.access_token.length === 0)
36
+ return undefined;
37
+ if (typeof token.expires_in !== 'number' || !Number.isFinite(token.expires_in))
38
+ return undefined;
39
+ const updated = {
40
+ ...session,
41
+ access_token: token.access_token,
42
+ refresh_token: token.refresh_token ?? session.refresh_token,
43
+ token_type: token.token_type === 'DPoP' ? 'DPoP' : 'Bearer',
44
+ expires_at: now() + token.expires_in * 1000,
45
+ };
46
+ sessionStore.write(updated);
47
+ return { accessToken: updated.access_token };
48
+ }
49
+ function isTransientStatus(status) {
50
+ return status >= 500 || status === 429 || status === 408;
51
+ }
52
+ function retryAfterSeconds(header) {
53
+ if (header === null || !/^\d+$/.test(header.trim()))
54
+ return undefined;
55
+ return Number(header.trim());
56
+ }
@@ -0,0 +1,96 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { CliError, EXIT } from './errors.js';
4
+ import { backupSuffixFor, clientConfigPath, RAKOMI_SERVER_NAME } from './mcp-config.js';
5
+ function readIfPresent(file) {
6
+ try {
7
+ return readFileSync(file, 'utf8');
8
+ }
9
+ catch (err) {
10
+ if (err.code === 'ENOENT')
11
+ return null;
12
+ throw err;
13
+ }
14
+ }
15
+ function backupPath(configPath, serverName) {
16
+ return `${configPath}${backupSuffixFor(serverName)}`;
17
+ }
18
+ function tomlBasicString(value) {
19
+ return JSON.stringify(value);
20
+ }
21
+ function renderTomlBlockLines(header, fields) {
22
+ const lines = [`[${header}]`];
23
+ for (const [key, value] of Object.entries(fields)) {
24
+ lines.push(`${key} = ${tomlBasicString(value)}`);
25
+ }
26
+ return lines;
27
+ }
28
+ export function mergeTomlServerTable(text, client, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
29
+ if (!client.config || client.config.serialization !== 'toml') {
30
+ throw new CliError(`mergeTomlServerTable: '${client.id}' is not a TOML-shaped written client`, EXIT.FAIL);
31
+ }
32
+ const header = `${client.config.topLevelKey}.${serverName}`;
33
+ const fields = { ...client.config.extraFields, [client.config.remoteUrlField]: mcpUrl };
34
+ const newBlockLines = renderTomlBlockLines(header, fields);
35
+ const newBlockText = newBlockLines.join('\n');
36
+ const lines = text.length > 0 ? text.split('\n') : [];
37
+ const headerLine = `[${header}]`;
38
+ let start = -1;
39
+ for (let i = 0; i < lines.length; i++) {
40
+ if (lines[i]?.trim() === headerLine) {
41
+ start = i;
42
+ break;
43
+ }
44
+ }
45
+ if (start === -1) {
46
+ const needsSeparator = lines.length > 0 && lines[lines.length - 1] !== '';
47
+ const prefix = needsSeparator ? [...lines, ''] : lines;
48
+ const nextLines = [...prefix, ...newBlockLines, ''];
49
+ return { text: nextLines.join('\n'), changed: true, previousBlock: undefined };
50
+ }
51
+ let end = lines.length;
52
+ for (let i = start + 1; i < lines.length; i++) {
53
+ if (/^\[/.test(lines[i] ?? '')) {
54
+ end = i;
55
+ break;
56
+ }
57
+ }
58
+ let blockEnd = end;
59
+ while (blockEnd > start + 1 && lines[blockEnd - 1] === '')
60
+ blockEnd--;
61
+ const previousBlock = lines.slice(start, blockEnd).join('\n');
62
+ if (previousBlock === newBlockText) {
63
+ return { text, changed: false, previousBlock };
64
+ }
65
+ const nextLines = [...lines.slice(0, start), ...newBlockLines, ...lines.slice(end)];
66
+ return { text: nextLines.join('\n'), changed: true, previousBlock };
67
+ }
68
+ export function writeClientTomlConfigEntry(client, cwd, homeDir, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
69
+ const path = clientConfigPath(client, cwd, homeDir);
70
+ const existing = readIfPresent(path);
71
+ const before = existing ?? '';
72
+ const { text: after, changed, previousBlock } = mergeTomlServerTable(before, client, mcpUrl, serverName);
73
+ if (!changed) {
74
+ return { wrote: false, backedUp: false, diff: `${serverName} already configured with ${mcpUrl} — no change.`, path };
75
+ }
76
+ let backedUp = false;
77
+ if (existing !== null) {
78
+ writeFileSync(backupPath(path, serverName), existing);
79
+ backedUp = true;
80
+ }
81
+ else {
82
+ mkdirSync(dirname(path), { recursive: true });
83
+ }
84
+ writeFileSync(path, after.endsWith('\n') ? after : `${after}\n`);
85
+ const header = `${client.config.topLevelKey}.${serverName}`;
86
+ const diff = previousBlock !== undefined ? `[${header}]: updated` : `[${header}]: (added)`;
87
+ return { wrote: true, backedUp, diff, path };
88
+ }
89
+ export function undoClientTomlConfigWrite(client, cwd, homeDir, serverName = RAKOMI_SERVER_NAME) {
90
+ const path = clientConfigPath(client, cwd, homeDir);
91
+ const backup = readIfPresent(backupPath(path, serverName));
92
+ if (backup === null)
93
+ return false;
94
+ writeFileSync(path, backup);
95
+ return true;
96
+ }
package/dist/usage.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { KNOWN_CLIENTS } from './clients.js';
1
2
  export function helpText() {
3
+ const clientList = KNOWN_CLIENTS.join(' | ');
2
4
  return [
3
5
  'rakomi — the Rakomi CLI: sign in, connect an AI agent, and provision tenants',
4
6
  '',
@@ -13,12 +15,14 @@ export function helpText() {
13
15
  ' logout clear the local session',
14
16
  ' whoami show the signed-in account, home tenant, and active tenant',
15
17
  ' use <tenant-id-or-slug> remember a tenant locally (whoami/future commands); a UUID is stored as-is, a slug is server-verified',
16
- ' connect connect Claude Code / Claude Desktop to your tenant (read access)',
18
+ ' upgrade open your current tenant\'s upgrade page in the browser',
19
+ ' connect connect an MCP client to your tenant (read access)',
17
20
  ' --write also request write access for the connected client',
18
- ' --client <name> claude-code | claude-desktop (required if more than one applies)',
19
- ' --undo restore the .mcp.json this command last backed up',
21
+ ` --client <name> ${clientList} (required if more than one applies)`,
22
+ ' --name <server-name> the config key to write the entry under (default: rakomi) — one name per workspace on a machine',
23
+ ' --undo restore the config file this command last backed up (for the same --name)',
20
24
  ' --cimd-url <url> the connecting client\'s own CIMD document URL — confirms the connection',
21
- ' --status re-check status only (needs --cimd-url; never rewrites .mcp.json)',
25
+ ' --status re-check status only (needs --cimd-url; never rewrites the config file)',
22
26
  ' tenants create <name> create a tenant (parent-tenant only; depth-1 enforced server-side)',
23
27
  ' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
24
28
  ' --slug <slug> optional; auto-derived + suffixed if omitted',
@@ -38,7 +42,7 @@ export function helpText() {
38
42
  ' --dry-run print what would happen, make no writes or mutating calls',
39
43
  ' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
40
44
  ' --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)',
45
+ ' --tenant <id> the tenant for this invocation (whoami, upgrade, tenants claim/release)',
42
46
  ' -h, --help show this help and exit',
43
47
  ' -V, --version print the version and exit',
44
48
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rakomi",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -42,6 +42,6 @@
42
42
  "build": "tsc",
43
43
  "typecheck": "tsc --noEmit",
44
44
  "lint": "eslint src/ test/ --max-warnings=0",
45
- "test": "vitest run"
45
+ "test": "node ../../scripts/assert-not-story-agent-worktree.mjs test && node ../../scripts/ci/vitest-fork-crash-retry.mjs --results ./test-results.json -- vitest run"
46
46
  }
47
47
  }
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.3.0",
29
+ "bom-ref": "pkg:npm/rakomi@0.3.1",
30
30
  "name": "rakomi",
31
- "version": "0.3.0",
32
- "purl": "pkg:npm/rakomi@0.3.0",
31
+ "version": "0.3.1",
32
+ "purl": "pkg:npm/rakomi@0.3.1",
33
33
  "licenses": [
34
34
  {
35
35
  "license": {