@rootly/wizard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/assets/rootly-logo-glyph-purple.png +0 -0
  4. package/assets/rootly-logo-glyph.png +0 -0
  5. package/assets/welcome-screen.png +0 -0
  6. package/package.json +47 -0
  7. package/src/actions/guided.js +87 -0
  8. package/src/actions/inspect.js +341 -0
  9. package/src/actions/integrations.js +75 -0
  10. package/src/actions/mcp.js +52 -0
  11. package/src/actions/oneshot.js +243 -0
  12. package/src/actions/phone.js +122 -0
  13. package/src/actions/registry.js +373 -0
  14. package/src/actions/setup.js +574 -0
  15. package/src/actions/testing.js +141 -0
  16. package/src/actions/workflow.js +47 -0
  17. package/src/auth.js +553 -0
  18. package/src/cli.js +199 -0
  19. package/src/detect-state.js +232 -0
  20. package/src/format.js +43 -0
  21. package/src/mcp.js +254 -0
  22. package/src/rootly-api.js +325 -0
  23. package/src/runtime.js +55 -0
  24. package/src/tui/components/AppShell.js +68 -0
  25. package/src/tui/components/Banner.js +145 -0
  26. package/src/tui/components/BigText.js +46 -0
  27. package/src/tui/components/Celebration.js +47 -0
  28. package/src/tui/components/KeyValueList.js +22 -0
  29. package/src/tui/components/MenuList.js +170 -0
  30. package/src/tui/components/MultiSelectList.js +181 -0
  31. package/src/tui/components/NoticeBox.js +56 -0
  32. package/src/tui/components/SlideReveal.js +52 -0
  33. package/src/tui/index.js +2078 -0
  34. package/src/tui/screens/ListScreen.js +29 -0
  35. package/src/tui/screens/LoadFailedScreen.js +30 -0
  36. package/src/tui/screens/LoadingScreen.js +32 -0
  37. package/src/tui/screens/MainMenuScreen.js +81 -0
  38. package/src/tui/screens/MultiSelectScreen.js +17 -0
  39. package/src/tui/screens/OneShotRunnerScreen.js +186 -0
  40. package/src/tui/screens/OptionScreen.js +24 -0
  41. package/src/tui/screens/ResultScreen.js +35 -0
  42. package/src/tui/screens/StatusScreen.js +70 -0
  43. package/src/tui/screens/TextEntryScreen.js +115 -0
  44. package/src/tui/screens/WelcomeScreen.js +66 -0
  45. package/src/tui/theme.js +69 -0
  46. package/src/tui-legacy-bridge.js +371 -0
package/src/mcp.js ADDED
@@ -0,0 +1,254 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const HOSTED_MCP_URL = 'https://mcp.rootly.com/mcp';
7
+
8
+ function stringifyConfig(config) {
9
+ return `${JSON.stringify(config, null, 2)}\n`;
10
+ }
11
+
12
+ function mergeMcpServers(existing, incoming) {
13
+ return {
14
+ ...existing,
15
+ ...incoming,
16
+ mcpServers: {
17
+ ...(existing?.mcpServers || {}),
18
+ ...(incoming?.mcpServers || {})
19
+ }
20
+ };
21
+ }
22
+
23
+ function hostedJsonConfig(token) {
24
+ return {
25
+ mcpServers: {
26
+ rootly: {
27
+ url: HOSTED_MCP_URL,
28
+ headers: {
29
+ Authorization: `Bearer ${token}`
30
+ }
31
+ }
32
+ }
33
+ };
34
+ }
35
+
36
+ function claudeDesktopConfig(token) {
37
+ return {
38
+ mcpServers: {
39
+ rootly: {
40
+ command: 'npx',
41
+ args: ['-y', 'mcp-remote', 'https://mcp.rootly.com/sse', '--header', `Authorization: Bearer ${token}`]
42
+ }
43
+ }
44
+ };
45
+ }
46
+
47
+ function claudeCodeConfig(token) {
48
+ return {
49
+ mcpServers: {
50
+ rootly: {
51
+ type: 'http',
52
+ url: HOSTED_MCP_URL,
53
+ headers: {
54
+ Authorization: `Bearer ${token}`
55
+ }
56
+ }
57
+ }
58
+ };
59
+ }
60
+
61
+ function windsurfConfig(token) {
62
+ return {
63
+ mcpServers: {
64
+ rootly: {
65
+ serverUrl: HOSTED_MCP_URL,
66
+ headers: {
67
+ Authorization: `Bearer ${token}`
68
+ }
69
+ }
70
+ }
71
+ };
72
+ }
73
+
74
+ function codexConfig() {
75
+ return [
76
+ '[mcp_servers.rootly]',
77
+ `url = "${HOSTED_MCP_URL}"`,
78
+ 'bearer_token_env_var = "ROOTLY_API_TOKEN"'
79
+ ].join('\n');
80
+ }
81
+
82
+ function configForClient(client, token) {
83
+ switch (client) {
84
+ case 'Cursor':
85
+ return stringifyConfig(hostedJsonConfig(token));
86
+ case 'Claude Desktop':
87
+ return stringifyConfig(claudeDesktopConfig(token));
88
+ case 'Claude Code':
89
+ return stringifyConfig(claudeCodeConfig(token));
90
+ case 'Windsurf':
91
+ return stringifyConfig(windsurfConfig(token));
92
+ case 'Codex':
93
+ return `${codexConfig()}\n`;
94
+ default:
95
+ throw new Error(`Unsupported MCP client: ${client}`);
96
+ }
97
+ }
98
+
99
+ function configFileForClient(client) {
100
+ const home = os.homedir();
101
+ const appData = process.env.APPDATA;
102
+ const platform = process.platform;
103
+
104
+ switch (client) {
105
+ case 'Cursor':
106
+ return path.join(home, '.cursor', 'mcp.json');
107
+ case 'Claude Desktop':
108
+ if (platform === 'win32') {
109
+ return path.join(appData || home, 'Claude', 'claude_desktop_config.json');
110
+ }
111
+ if (platform === 'darwin') {
112
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
113
+ }
114
+ return path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
115
+ case 'Windsurf':
116
+ if (platform === 'win32') {
117
+ return path.join(appData || home, 'Codeium', 'Windsurf', 'mcp_config.json');
118
+ }
119
+ return path.join(home, '.codeium', 'windsurf', 'mcp_config.json');
120
+ case 'Claude Code':
121
+ return path.join(process.cwd(), '.mcp.json');
122
+ case 'Codex':
123
+ return path.join(home, '.codex', 'config.toml');
124
+ default:
125
+ throw new Error(`Unsupported MCP client: ${client}`);
126
+ }
127
+ }
128
+
129
+ async function ensureParentDir(filePath) {
130
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
131
+ }
132
+
133
+ async function maybeBackupFile(filePath) {
134
+ try {
135
+ await fs.access(filePath);
136
+ } catch {
137
+ return null;
138
+ }
139
+
140
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
141
+ const backupPath = `${filePath}.bak.${stamp}`;
142
+ await fs.copyFile(filePath, backupPath);
143
+ return backupPath;
144
+ }
145
+
146
+ async function readExistingJson(filePath) {
147
+ try {
148
+ const raw = await fs.readFile(filePath, 'utf8');
149
+ return JSON.parse(raw);
150
+ } catch {
151
+ return {};
152
+ }
153
+ }
154
+
155
+ export function buildHostedMcpPreview(client, tokenType) {
156
+ return [
157
+ `Client: ${client}`,
158
+ `Auth: ${tokenType}`,
159
+ 'Connection: hosted Rootly MCP server',
160
+ '',
161
+ configForClient(client, '<YOUR_ROOTLY_API_TOKEN>')
162
+ ].join('\n');
163
+ }
164
+
165
+ export async function writeHostedMcpConfig(client, token) {
166
+ const targetPath = configFileForClient(client);
167
+ await ensureParentDir(targetPath);
168
+ const backupPath = await maybeBackupFile(targetPath);
169
+
170
+ if (client === 'Codex') {
171
+ // Codex config references the token via env var, so it holds no secret —
172
+ // but write it owner-only for consistency with the token-bearing configs.
173
+ await fs.writeFile(targetPath, codexConfig(), { encoding: 'utf8', mode: 0o600 });
174
+ return { targetPath, backupPath };
175
+ }
176
+
177
+ const existing = await readExistingJson(targetPath);
178
+ const merged = mergeMcpServers(existing, JSON.parse(configForClient(client, token)));
179
+ // These configs embed the bearer token inline, so restrict to owner read/write
180
+ // (0600) rather than leaving the default world-readable perms. writeFile's mode
181
+ // only applies on create, so chmod afterward to also tighten a pre-existing file.
182
+ await fs.writeFile(targetPath, stringifyConfig(merged), { encoding: 'utf8', mode: 0o600 });
183
+ await fs.chmod(targetPath, 0o600).catch(() => {});
184
+ return { targetPath, backupPath };
185
+ }
186
+
187
+ export function getMcpConfigPath(client) {
188
+ return configFileForClient(client);
189
+ }
190
+
191
+ // Claude Code stores user-scoped (global) MCP servers in ~/.claude.json. The
192
+ // supported way to register one is the `claude` CLI, which manages that file
193
+ // correctly, rather than hand-editing it.
194
+ export function buildClaudeCodeUserCommandArgs(token) {
195
+ return ['mcp', 'add', '--scope', 'user', '--transport', 'http', 'rootly', HOSTED_MCP_URL, '--header', `Authorization: Bearer ${token}`];
196
+ }
197
+
198
+ export function claudeCodeUserCommandDisplay() {
199
+ return `claude mcp add --scope user --transport http rootly ${HOSTED_MCP_URL} --header "Authorization: Bearer <YOUR_ROOTLY_API_TOKEN>"`;
200
+ }
201
+
202
+ function claudeCliAvailable() {
203
+ try {
204
+ const result = spawnSync('claude', ['--version'], { stdio: 'ignore' });
205
+ return !result.error && result.status === 0;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ export function addClaudeCodeUserScope(token) {
212
+ const display = claudeCodeUserCommandDisplay();
213
+
214
+ if (!claudeCliAvailable()) {
215
+ return { ran: false, target: 'user scope (~/.claude.json)', command: display };
216
+ }
217
+
218
+ const result = spawnSync('claude', buildClaudeCodeUserCommandArgs(token), { stdio: 'ignore' });
219
+ return { ran: !result.error && result.status === 0, target: 'user scope (~/.claude.json)', command: display };
220
+ }
221
+
222
+ export async function verifyHostedMcpConfig(client) {
223
+ const targetPath = configFileForClient(client);
224
+
225
+ if (client === 'Codex') {
226
+ const raw = await fs.readFile(targetPath, 'utf8');
227
+ if (!raw.includes('[mcp_servers.rootly]')) {
228
+ throw new Error(`Rootly MCP config not found in ${targetPath}`);
229
+ }
230
+ if (!raw.includes(HOSTED_MCP_URL)) {
231
+ throw new Error(`Rootly MCP config in ${targetPath} is missing the hosted Rootly endpoint`);
232
+ }
233
+ if (!raw.includes('bearer_token_env_var')) {
234
+ throw new Error(`Rootly MCP config in ${targetPath} is missing bearer_token_env_var`);
235
+ }
236
+ return targetPath;
237
+ }
238
+
239
+ const config = await readExistingJson(targetPath);
240
+ const rootly = config?.mcpServers?.rootly || config?.rootly;
241
+
242
+ if (!rootly) {
243
+ throw new Error(`Rootly MCP config not found in ${targetPath}`);
244
+ }
245
+
246
+ const isHostedHttp = rootly.url === HOSTED_MCP_URL || rootly.serverUrl === HOSTED_MCP_URL;
247
+ const isClaudeDesktop = rootly.command === 'npx' && Array.isArray(rootly.args) && rootly.args.includes('mcp-remote');
248
+
249
+ if (!isHostedHttp && !isClaudeDesktop) {
250
+ throw new Error(`Rootly MCP config in ${targetPath} does not point at the hosted Rootly server`);
251
+ }
252
+
253
+ return targetPath;
254
+ }
@@ -0,0 +1,325 @@
1
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
2
+
3
+ export class RootlyApiClient {
4
+ constructor(token, baseUrl = 'https://api.rootly.com') {
5
+ this.token = token;
6
+ this.baseUrl = baseUrl;
7
+ }
8
+
9
+ async request(path, options = {}) {
10
+ const response = await fetch(new URL(path, this.baseUrl), {
11
+ method: options.method || 'GET',
12
+ headers: {
13
+ Authorization: `Bearer ${this.token}`,
14
+ Accept: 'application/vnd.api+json',
15
+ ...(options.body ? { 'Content-Type': 'application/vnd.api+json' } : {}),
16
+ ...(options.headers || {})
17
+ },
18
+ signal: options.signal || AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS),
19
+ ...(options.body ? { body: JSON.stringify(options.body) } : {})
20
+ });
21
+
22
+ if (!response.ok) {
23
+ const text = await response.text().catch(() => '');
24
+ throw new Error(`Rootly API request failed for ${path}: ${response.status}${text ? ` - ${text}` : ''}`);
25
+ }
26
+
27
+ return response.json();
28
+ }
29
+
30
+ async getCurrentUser() {
31
+ return this.request('/v1/users/me');
32
+ }
33
+
34
+ async listTeams() {
35
+ return this.request('/v1/teams?include=users,schedules,escalation_policies');
36
+ }
37
+
38
+ async getTeam(id) {
39
+ return this.request(`/v1/teams/${id}?include=users`);
40
+ }
41
+
42
+ async listUsers() {
43
+ return this.request('/v1/users');
44
+ }
45
+
46
+ async listAllUsers() {
47
+ const users = [];
48
+ let next = '/v1/users';
49
+ let pagesScanned = 0;
50
+
51
+ while (next && pagesScanned < 100) {
52
+ pagesScanned += 1;
53
+ const payload = await this.request(next);
54
+ if (Array.isArray(payload?.data)) {
55
+ users.push(...payload.data);
56
+ }
57
+ next = payload?.links?.next || null;
58
+ }
59
+
60
+ return users;
61
+ }
62
+
63
+ async listSchedules() {
64
+ return this.request('/v1/schedules');
65
+ }
66
+
67
+ async listEscalationPolicies() {
68
+ return this.request('/v1/escalation_policies');
69
+ }
70
+
71
+ async listAlertSources() {
72
+ return this.request('/v1/alert_sources');
73
+ }
74
+
75
+ async listSeverities() {
76
+ return this.request('/v1/severities');
77
+ }
78
+
79
+ async listServices() {
80
+ return this.request('/v1/services');
81
+ }
82
+
83
+ async listFunctionalities() {
84
+ return this.request('/v1/functionalities');
85
+ }
86
+
87
+ async createFunctionality(attributes) {
88
+ return this.request('/v1/functionalities', {
89
+ method: 'POST',
90
+ body: {
91
+ data: {
92
+ type: 'functionalities',
93
+ attributes
94
+ }
95
+ }
96
+ });
97
+ }
98
+
99
+ async createService(attributes) {
100
+ return this.request('/v1/services', {
101
+ method: 'POST',
102
+ body: {
103
+ data: {
104
+ type: 'services',
105
+ attributes
106
+ }
107
+ }
108
+ });
109
+ }
110
+
111
+ async listEnvironments() {
112
+ return this.request('/v1/environments');
113
+ }
114
+
115
+ async listIncidentTypes() {
116
+ return this.request('/v1/incident_types');
117
+ }
118
+
119
+ async createTeam(attributes) {
120
+ return this.request('/v1/teams', {
121
+ method: 'POST',
122
+ body: {
123
+ data: {
124
+ type: 'groups',
125
+ attributes
126
+ }
127
+ }
128
+ });
129
+ }
130
+
131
+ async updateTeam(id, attributes) {
132
+ return this.request(`/v1/teams/${id}`, {
133
+ method: 'PUT',
134
+ body: {
135
+ data: {
136
+ type: 'groups',
137
+ attributes
138
+ }
139
+ }
140
+ });
141
+ }
142
+
143
+ async createSchedule(attributes) {
144
+ return this.request('/v1/schedules', {
145
+ method: 'POST',
146
+ body: {
147
+ data: {
148
+ type: 'schedules',
149
+ attributes
150
+ }
151
+ }
152
+ });
153
+ }
154
+
155
+ async createScheduleRotation(scheduleId, attributes) {
156
+ return this.request(`/v1/schedules/${scheduleId}/schedule_rotations`, {
157
+ method: 'POST',
158
+ body: {
159
+ data: {
160
+ type: 'schedule_rotations',
161
+ attributes
162
+ }
163
+ }
164
+ });
165
+ }
166
+
167
+ async createEscalationPolicy(attributes) {
168
+ return this.request('/v1/escalation_policies', {
169
+ method: 'POST',
170
+ body: {
171
+ data: {
172
+ type: 'escalation_policies',
173
+ attributes
174
+ }
175
+ }
176
+ });
177
+ }
178
+
179
+ async createEscalationPath(escalationPolicyId, attributes) {
180
+ return this.request(`/v1/escalation_policies/${escalationPolicyId}/escalation_paths`, {
181
+ method: 'POST',
182
+ body: {
183
+ data: {
184
+ type: 'escalation_paths',
185
+ attributes
186
+ }
187
+ }
188
+ });
189
+ }
190
+
191
+ async createEscalationLevel(escalationPolicyId, attributes) {
192
+ return this.request(`/v1/escalation_policies/${escalationPolicyId}/escalation_levels`, {
193
+ method: 'POST',
194
+ body: {
195
+ data: {
196
+ type: 'escalation_levels',
197
+ attributes
198
+ }
199
+ }
200
+ });
201
+ }
202
+
203
+ async listAlertUrgencies() {
204
+ return this.request('/v1/alert_urgencies');
205
+ }
206
+
207
+ async listStatusPages() {
208
+ return this.request('/v1/status-pages');
209
+ }
210
+
211
+ async createStatusPage(attributes) {
212
+ return this.request('/v1/status-pages', {
213
+ method: 'POST',
214
+ body: {
215
+ data: {
216
+ type: 'status_pages',
217
+ attributes
218
+ }
219
+ }
220
+ });
221
+ }
222
+
223
+ async updateStatusPage(id, attributes) {
224
+ return this.request(`/v1/status-pages/${id}`, {
225
+ method: 'PATCH',
226
+ body: {
227
+ data: {
228
+ type: 'status_pages',
229
+ attributes
230
+ }
231
+ }
232
+ });
233
+ }
234
+
235
+ async createAlertSource(attributes) {
236
+ return this.request('/v1/alert_sources', {
237
+ method: 'POST',
238
+ body: {
239
+ data: {
240
+ type: 'alert_sources',
241
+ attributes
242
+ }
243
+ }
244
+ });
245
+ }
246
+
247
+ async createAlert(attributes) {
248
+ return this.request('/v1/alerts', {
249
+ method: 'POST',
250
+ body: {
251
+ data: {
252
+ type: 'alerts',
253
+ attributes
254
+ }
255
+ }
256
+ });
257
+ }
258
+
259
+ async createIncident(attributes) {
260
+ return this.request('/v1/incidents', {
261
+ method: 'POST',
262
+ body: {
263
+ data: {
264
+ type: 'incidents',
265
+ attributes
266
+ }
267
+ }
268
+ });
269
+ }
270
+
271
+ async getUserPhoneNumbers(userId) {
272
+ return this.request(`/v1/users/${userId}/phone_numbers`);
273
+ }
274
+
275
+ async createUserPhoneNumber(userId, phone) {
276
+ return this.request(`/v1/users/${userId}/phone_numbers`, {
277
+ method: 'POST',
278
+ body: {
279
+ data: {
280
+ type: 'user_phone_numbers',
281
+ attributes: { phone }
282
+ }
283
+ }
284
+ });
285
+ }
286
+
287
+ // Triggers a Twilio SMS with a verification code (member route is shallow).
288
+ async sendPhoneVerification(phoneNumberId) {
289
+ return this.request(`/v1/phone_numbers/${phoneNumberId}/verify`, { method: 'POST' });
290
+ }
291
+
292
+ async submitPhoneVerificationCode(phoneNumberId, code) {
293
+ return this.request(`/v1/phone_numbers/${phoneNumberId}/verify_code`, {
294
+ method: 'PATCH',
295
+ body: { code }
296
+ });
297
+ }
298
+
299
+ async resendPhoneVerification(phoneNumberId) {
300
+ return this.request(`/v1/phone_numbers/${phoneNumberId}/resend_verification`, { method: 'POST' });
301
+ }
302
+
303
+ async deleteUserPhoneNumber(phoneNumberId) {
304
+ return this.request(`/v1/phone_numbers/${phoneNumberId}`, { method: 'DELETE' });
305
+ }
306
+
307
+ async findUserByEmail(email) {
308
+ const target = String(email).toLowerCase();
309
+ let next = '/v1/users';
310
+ let pagesScanned = 0;
311
+
312
+ while (next && pagesScanned < 100) {
313
+ pagesScanned += 1;
314
+ const payload = await this.request(next);
315
+ const match = payload?.data?.find((user) => user?.attributes?.email?.toLowerCase() === target);
316
+ if (match) {
317
+ return match;
318
+ }
319
+
320
+ next = payload?.links?.next || null;
321
+ }
322
+
323
+ return null;
324
+ }
325
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,55 @@
1
+ import { getStoredToken } from './auth.js';
2
+ import { RootlyApiClient } from './rootly-api.js';
3
+ import { detectOnboardingState } from './detect-state.js';
4
+
5
+ export async function getActiveToken() {
6
+ return process.env.ROOTLY_TOKEN?.trim() || (await getStoredToken()) || null;
7
+ }
8
+
9
+ export async function loadApiClient() {
10
+ const token = await getActiveToken();
11
+ if (!token) {
12
+ throw new Error('Rootly auth is required first.');
13
+ }
14
+
15
+ return new RootlyApiClient(token);
16
+ }
17
+
18
+ export async function loadOnboardingState() {
19
+ const token = await getActiveToken();
20
+ if (!token) {
21
+ return null;
22
+ }
23
+
24
+ const api = new RootlyApiClient(token);
25
+ const [userPayload, teamsPayload, schedulesPayload, escalationPoliciesPayload] = await Promise.all([
26
+ api.getCurrentUser(),
27
+ api.listTeams(),
28
+ api.listSchedules(),
29
+ api.listEscalationPolicies()
30
+ ]);
31
+
32
+ return detectOnboardingState({
33
+ userPayload,
34
+ teamsPayload,
35
+ schedulesPayload,
36
+ escalationPoliciesPayload,
37
+ authMode: process.env.ROOTLY_TOKEN?.trim() ? 'env-token' : 'stored-token'
38
+ });
39
+ }
40
+
41
+ export function extractTeamId(group) {
42
+ return group?.data?.id || group?.id || null;
43
+ }
44
+
45
+ export function extractUserId(user) {
46
+ return user?.id || user?.data?.id || null;
47
+ }
48
+
49
+ // API keys authenticate as a synthetic service user whose email looks like
50
+ // `bot+apikey-<uuid>@rootly.com`. Such an identity should not be seeded as a
51
+ // team member or on-call participant.
52
+ export function isServiceAccount(user) {
53
+ const email = user?.data?.attributes?.email || user?.attributes?.email || '';
54
+ return /^bot\+apikey-/i.test(email);
55
+ }
@@ -0,0 +1,68 @@
1
+ import { createElement as h } from 'react';
2
+ import { Box, Text, useWindowSize } from 'ink';
3
+ import { palette, glyphs, HINTS } from '../theme.js';
4
+
5
+ function KeyHints({ items, keyColor = palette.brand }) {
6
+ if (!items?.length) {
7
+ return h(Text, { color: palette.border }, ' ');
8
+ }
9
+ const parts = [];
10
+ items.forEach((item, index) => {
11
+ if (index > 0) {
12
+ parts.push(h(Text, { key: `sep-${index}`, color: palette.border }, ' '));
13
+ }
14
+ parts.push(h(Text, { key: `key-${index}`, color: keyColor }, item.key));
15
+ parts.push(h(Text, { key: `lbl-${index}`, color: palette.muted }, ` ${item.label}`));
16
+ });
17
+ return h(Box, null, ...parts);
18
+ }
19
+
20
+ export function AppShell({ title, context = 'rootly.com', hints = HINTS.nav, keyColor = palette.brand, children }) {
21
+ const { columns } = useWindowSize();
22
+ const cols = columns || 80;
23
+ // Fit the terminal: shrink on narrow screens, cap on wide ones.
24
+ const width = Math.min(82, Math.max(24, cols - 4));
25
+ const ruleWidth = Math.max(4, Math.min(title ? title.length : 0, width - 8));
26
+
27
+ return h(
28
+ Box,
29
+ // Full-width column, centered — so on a wide/enlarged terminal everything
30
+ // stays centered rather than pinned to the left.
31
+ { flexDirection: 'column', alignItems: 'center', width: cols, paddingTop: 1 },
32
+ // Brand header
33
+ h(
34
+ Box,
35
+ { width, justifyContent: 'space-between', marginBottom: 1 },
36
+ h(
37
+ Box,
38
+ null,
39
+ h(Text, { color: keyColor, bold: true }, `${glyphs.logo} `),
40
+ h(Text, { bold: true, color: palette.text }, 'Rootly Wizard')
41
+ ),
42
+ h(Text, { color: palette.accent, bold: true }, context)
43
+ ),
44
+ // Content card
45
+ h(
46
+ Box,
47
+ {
48
+ width,
49
+ flexDirection: 'column',
50
+ borderStyle: 'round',
51
+ borderColor: palette.border,
52
+ paddingX: 2,
53
+ paddingY: 1
54
+ },
55
+ title
56
+ ? h(
57
+ Box,
58
+ { flexDirection: 'column', marginBottom: 1 },
59
+ h(Text, { bold: true, color: palette.text }, title),
60
+ h(Text, { color: palette.brand }, '─'.repeat(ruleWidth))
61
+ )
62
+ : null,
63
+ ...(Array.isArray(children) ? children : [children]).filter(Boolean)
64
+ ),
65
+ // Footer hints
66
+ h(Box, { width, marginTop: 1, justifyContent: 'center' }, h(KeyHints, { items: hints, keyColor }))
67
+ );
68
+ }