@botdocs/cli 0.3.1 → 0.4.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 (90) hide show
  1. package/README.md +123 -37
  2. package/dist/commands/backups.d.ts +4 -0
  3. package/dist/commands/backups.js +291 -0
  4. package/dist/commands/edit.js +16 -8
  5. package/dist/commands/install.d.ts +4 -0
  6. package/dist/commands/install.js +21 -3
  7. package/dist/commands/login.d.ts +7 -0
  8. package/dist/commands/login.js +240 -75
  9. package/dist/commands/publish.js +53 -16
  10. package/dist/commands/sync.d.ts +16 -0
  11. package/dist/commands/sync.js +337 -25
  12. package/dist/commands/team.d.ts +2 -0
  13. package/dist/commands/team.js +251 -0
  14. package/dist/commands/undo.d.ts +19 -0
  15. package/dist/commands/undo.js +88 -0
  16. package/dist/commands/views/conflict-prompt.d.ts +24 -0
  17. package/dist/commands/views/conflict-prompt.js +19 -0
  18. package/dist/commands/views/login-app.d.ts +30 -0
  19. package/dist/commands/views/login-app.js +57 -0
  20. package/dist/commands/views/sync-app.d.ts +27 -0
  21. package/dist/commands/views/sync-app.js +147 -0
  22. package/dist/commands/views/sync-state.d.ts +84 -0
  23. package/dist/commands/views/sync-state.js +93 -0
  24. package/dist/commands/views/theme.d.ts +16 -0
  25. package/dist/commands/views/theme.js +16 -0
  26. package/dist/commands/whoami.js +13 -13
  27. package/dist/index.js +44 -38
  28. package/dist/lib/api.d.ts +2 -3
  29. package/dist/lib/api.js +14 -7
  30. package/dist/lib/auto-detect.js +46 -0
  31. package/dist/lib/backup.d.ts +121 -0
  32. package/dist/lib/backup.js +387 -0
  33. package/dist/lib/canonical.d.ts +1 -1
  34. package/dist/lib/canonical.js +43 -1
  35. package/dist/lib/config.d.ts +8 -1
  36. package/dist/lib/config.js +18 -9
  37. package/dist/lib/lockfile.d.ts +9 -0
  38. package/dist/lib/prompts.d.ts +10 -0
  39. package/dist/lib/prompts.js +36 -12
  40. package/package.json +27 -7
  41. package/templates/agents.md +60 -47
  42. package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
  43. package/templates/ecosystem-prompts/compile-copilot.md +14 -0
  44. package/templates/ecosystem-prompts/compile-gemini.md +14 -0
  45. package/templates/ecosystem-prompts/compile-opencode.md +13 -0
  46. package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
  47. package/dist/commands/check-updates.test.d.ts +0 -1
  48. package/dist/commands/check-updates.test.js +0 -128
  49. package/dist/commands/clone.d.ts +0 -3
  50. package/dist/commands/clone.js +0 -70
  51. package/dist/commands/compile.test.d.ts +0 -1
  52. package/dist/commands/compile.test.js +0 -110
  53. package/dist/commands/diff.d.ts +0 -3
  54. package/dist/commands/diff.js +0 -65
  55. package/dist/commands/edit.test.d.ts +0 -1
  56. package/dist/commands/edit.test.js +0 -102
  57. package/dist/commands/endorse.d.ts +0 -7
  58. package/dist/commands/endorse.js +0 -70
  59. package/dist/commands/ingest.test.d.ts +0 -1
  60. package/dist/commands/ingest.test.js +0 -109
  61. package/dist/commands/install.test.d.ts +0 -1
  62. package/dist/commands/install.test.js +0 -253
  63. package/dist/commands/list.test.d.ts +0 -1
  64. package/dist/commands/list.test.js +0 -51
  65. package/dist/commands/publish.test.d.ts +0 -1
  66. package/dist/commands/publish.test.js +0 -76
  67. package/dist/commands/pull.d.ts +0 -3
  68. package/dist/commands/pull.js +0 -78
  69. package/dist/commands/sync.test.d.ts +0 -1
  70. package/dist/commands/sync.test.js +0 -263
  71. package/dist/commands/uninstall.test.d.ts +0 -1
  72. package/dist/commands/uninstall.test.js +0 -67
  73. package/dist/lib/auto-detect.test.d.ts +0 -1
  74. package/dist/lib/auto-detect.test.js +0 -58
  75. package/dist/lib/canonical.test.d.ts +0 -1
  76. package/dist/lib/canonical.test.js +0 -48
  77. package/dist/lib/diff.test.d.ts +0 -1
  78. package/dist/lib/diff.test.js +0 -28
  79. package/dist/lib/library-sync.test.d.ts +0 -1
  80. package/dist/lib/library-sync.test.js +0 -63
  81. package/dist/lib/llm.test.d.ts +0 -1
  82. package/dist/lib/llm.test.js +0 -72
  83. package/dist/lib/lockfile.test.d.ts +0 -1
  84. package/dist/lib/lockfile.test.js +0 -99
  85. package/dist/lib/manifest.test.d.ts +0 -1
  86. package/dist/lib/manifest.test.js +0 -72
  87. package/dist/lib/shell-hook.test.d.ts +0 -1
  88. package/dist/lib/shell-hook.test.js +0 -68
  89. package/dist/test-utils.d.ts +0 -43
  90. package/dist/test-utils.js +0 -101
@@ -0,0 +1,251 @@
1
+ import { ApiError, apiFetch } from '../lib/api.js';
2
+ function reportApiError(err) {
3
+ if (err instanceof ApiError) {
4
+ if (err.status === 401) {
5
+ console.error('\n ✗ Not authenticated. Run `botdocs login` first.\n');
6
+ process.exit(1);
7
+ }
8
+ console.error(`\n ✗ ${err.message || `HTTP ${err.status}`}\n`);
9
+ process.exit(1);
10
+ }
11
+ throw err;
12
+ }
13
+ function parseSkillRef(raw) {
14
+ const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
15
+ const parts = cleaned.split('/');
16
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
17
+ throw new Error(`Invalid ref: ${raw} (expected @user/slug)`);
18
+ }
19
+ return { username: parts[0], slug: parts[1] };
20
+ }
21
+ async function teamList(options) {
22
+ let resp;
23
+ try {
24
+ resp = await apiFetch('/api/teams', { auth: true });
25
+ }
26
+ catch (err) {
27
+ reportApiError(err);
28
+ }
29
+ if (options.json) {
30
+ console.log(JSON.stringify(resp));
31
+ return;
32
+ }
33
+ if (resp.teams.length === 0) {
34
+ console.log('\n not a member of any teams yet — try `botdocs team create <slug> --name "..."`\n');
35
+ return;
36
+ }
37
+ console.log('');
38
+ for (const t of resp.teams) {
39
+ console.log(` ${t.slug} (${t.role}, ${t.memberCount} member${t.memberCount === 1 ? '' : 's'}, ${t.skillCount} skill${t.skillCount === 1 ? '' : 's'})`);
40
+ console.log(` ${t.name}`);
41
+ if (t.description)
42
+ console.log(` ${t.description}`);
43
+ }
44
+ console.log('');
45
+ }
46
+ async function teamCreate(slug, options) {
47
+ if (!options.name) {
48
+ console.error('\n ✗ --name is required\n');
49
+ process.exit(1);
50
+ }
51
+ let resp;
52
+ try {
53
+ resp = await apiFetch('/api/teams', {
54
+ method: 'POST',
55
+ auth: true,
56
+ body: { slug, name: options.name, description: options.description },
57
+ });
58
+ }
59
+ catch (err) {
60
+ reportApiError(err);
61
+ }
62
+ if (options.json) {
63
+ console.log(JSON.stringify(resp));
64
+ return;
65
+ }
66
+ console.log(`\n ✓ Created team ${resp.team.slug}`);
67
+ console.log(` https://botdocs.ai/teams/${resp.team.slug}\n`);
68
+ }
69
+ async function teamShow(slug, options) {
70
+ let team;
71
+ let members;
72
+ let skills;
73
+ try {
74
+ team = await apiFetch(`/api/teams/${slug}`, { auth: true });
75
+ members = await apiFetch(`/api/teams/${slug}/members`, { auth: true });
76
+ skills = await apiFetch(`/api/teams/${slug}/skills`, { auth: true });
77
+ }
78
+ catch (err) {
79
+ reportApiError(err);
80
+ }
81
+ if (options.json) {
82
+ console.log(JSON.stringify({ team: team.team, role: team.role, members: members.members, skills: skills.skills }));
83
+ return;
84
+ }
85
+ console.log(`\n ${team.team.slug} (you are ${team.role})`);
86
+ console.log(` ${team.team.name}`);
87
+ if (team.team.description)
88
+ console.log(` ${team.team.description}`);
89
+ console.log(`\n Members (${members.members.length}):`);
90
+ for (const m of members.members) {
91
+ console.log(` @${m.username} (${m.role}) — ${m.displayName}`);
92
+ }
93
+ console.log(`\n Pinned skills (${skills.skills.length}):`);
94
+ if (skills.skills.length === 0) {
95
+ console.log(' (none yet — run `botdocs team push <slug> @user/skill` to pin)');
96
+ }
97
+ else {
98
+ for (const s of skills.skills) {
99
+ const ver = s.versionPin ? `v${s.versionPin} (pinned)` : s.currentVersion ? `v${s.currentVersion}` : 'unpublished';
100
+ const title = s.title ?? '(unpublished)';
101
+ console.log(` @${s.username}/${s.slug} ${ver}`);
102
+ console.log(` ${title}`);
103
+ }
104
+ }
105
+ console.log('');
106
+ }
107
+ async function teamAdd(slug, username, options) {
108
+ const role = options.role ? options.role.toUpperCase() : 'WRITE';
109
+ if (!['ADMIN', 'WRITE', 'READ'].includes(role)) {
110
+ console.error('\n ✗ Role must be one of: admin, write, read\n');
111
+ process.exit(1);
112
+ }
113
+ let resp;
114
+ try {
115
+ resp = await apiFetch(`/api/teams/${slug}/members`, {
116
+ method: 'POST',
117
+ auth: true,
118
+ body: { username, role },
119
+ });
120
+ }
121
+ catch (err) {
122
+ reportApiError(err);
123
+ }
124
+ if (options.json) {
125
+ console.log(JSON.stringify(resp));
126
+ return;
127
+ }
128
+ console.log(`\n ✓ Added @${resp.member.username} to ${slug} as ${resp.member.role}\n`);
129
+ }
130
+ async function teamRemove(slug, username, options) {
131
+ // Resolve username → userId via members list (avoids needing a separate
132
+ // /api/users/[username] route).
133
+ let members;
134
+ try {
135
+ members = await apiFetch(`/api/teams/${slug}/members`, { auth: true });
136
+ }
137
+ catch (err) {
138
+ reportApiError(err);
139
+ }
140
+ const target = members.members.find((m) => m.username === username);
141
+ if (!target) {
142
+ console.error(`\n ✗ @${username} is not a member of ${slug}\n`);
143
+ process.exit(1);
144
+ }
145
+ try {
146
+ await apiFetch(`/api/teams/${slug}/members/${target.userId}`, {
147
+ method: 'DELETE',
148
+ auth: true,
149
+ });
150
+ }
151
+ catch (err) {
152
+ reportApiError(err);
153
+ }
154
+ if (options.json) {
155
+ console.log(JSON.stringify({ removed: { slug, username } }));
156
+ return;
157
+ }
158
+ console.log(`\n ✓ Removed @${username} from ${slug}\n`);
159
+ }
160
+ async function teamPush(slug, ref, options) {
161
+ const parsed = parseSkillRef(ref);
162
+ let resp;
163
+ try {
164
+ resp = await apiFetch(`/api/teams/${slug}/skills`, {
165
+ method: 'POST',
166
+ auth: true,
167
+ body: {
168
+ username: parsed.username,
169
+ slug: parsed.slug,
170
+ versionPin: options.version ?? null,
171
+ },
172
+ });
173
+ }
174
+ catch (err) {
175
+ reportApiError(err);
176
+ }
177
+ if (options.json) {
178
+ console.log(JSON.stringify(resp));
179
+ return;
180
+ }
181
+ const verNote = resp.skill.versionPin ? ` (pinned to v${resp.skill.versionPin})` : '';
182
+ console.log(`\n ✓ Pinned @${parsed.username}/${parsed.slug} to ${slug}${verNote}\n`);
183
+ }
184
+ async function teamUnpush(slug, ref, options) {
185
+ const parsed = parseSkillRef(ref);
186
+ try {
187
+ await apiFetch(`/api/teams/${slug}/skills/${parsed.username}/${parsed.slug}`, {
188
+ method: 'DELETE',
189
+ auth: true,
190
+ });
191
+ }
192
+ catch (err) {
193
+ reportApiError(err);
194
+ }
195
+ if (options.json) {
196
+ console.log(JSON.stringify({ unpinned: { slug, ref } }));
197
+ return;
198
+ }
199
+ console.log(`\n ✓ Unpinned @${parsed.username}/${parsed.slug} from ${slug}\n`);
200
+ }
201
+ export function registerTeamCommands(program) {
202
+ const team = program
203
+ .command('team')
204
+ .description('Manage teams: shared skill libraries for your org');
205
+ team
206
+ .command('list')
207
+ .description('List teams you are a member of')
208
+ .action(async () => {
209
+ await teamList({ json: program.opts().json });
210
+ });
211
+ team
212
+ .command('create <slug>')
213
+ .description('Create a new team (you become its first ADMIN)')
214
+ .requiredOption('--name <name>', 'Display name for the team')
215
+ .option('--description <description>', 'Optional description')
216
+ .action(async (slug, opts) => {
217
+ await teamCreate(slug, { ...opts, json: program.opts().json });
218
+ });
219
+ team
220
+ .command('show <slug>')
221
+ .description('Show a team — info, members, and pinned skills')
222
+ .action(async (slug) => {
223
+ await teamShow(slug, { json: program.opts().json });
224
+ });
225
+ team
226
+ .command('add <slug> <username>')
227
+ .description('Add a user to a team by username (ADMIN only)')
228
+ .option('--role <role>', 'Role: admin, write, or read', 'write')
229
+ .action(async (slug, username, opts) => {
230
+ await teamAdd(slug, username, { ...opts, json: program.opts().json });
231
+ });
232
+ team
233
+ .command('remove <slug> <username>')
234
+ .description('Remove a user from a team (ADMIN only; or self-leave)')
235
+ .action(async (slug, username) => {
236
+ await teamRemove(slug, username, { json: program.opts().json });
237
+ });
238
+ team
239
+ .command('push <slug> <ref>')
240
+ .description('Pin a published skill to a team (WRITE+ only)')
241
+ .option('--version <v>', 'Pin to a specific version (omit to float to latest)')
242
+ .action(async (slug, ref, opts) => {
243
+ await teamPush(slug, ref, { ...opts, json: program.opts().json });
244
+ });
245
+ team
246
+ .command('unpush <slug> <ref>')
247
+ .description('Unpin a skill from a team (WRITE+ only)')
248
+ .action(async (slug, ref) => {
249
+ await teamUnpush(slug, ref, { json: program.opts().json });
250
+ });
251
+ }
@@ -0,0 +1,19 @@
1
+ export interface UndoOptions {
2
+ /** Show the plan but don't write. */
3
+ dryRun?: boolean;
4
+ /** Skip the confirmation prompt. */
5
+ yes?: boolean;
6
+ /** Don't back up the current state before restoring (advanced). */
7
+ noBackup?: boolean;
8
+ /** Output JSON instead of human-readable text. */
9
+ json?: boolean;
10
+ }
11
+ /** Restore the most recent backup run.
12
+ *
13
+ * - Finds the newest run via `listBackupRuns`. If there are none, prints a
14
+ * friendly "nothing to undo" and exits.
15
+ * - Prints a summary of what will be restored, including the note that the
16
+ * current state will itself be backed up first (so undo is reversible).
17
+ * - Prompts y/N unless `--yes`. On cancel, exits gracefully.
18
+ * - On confirm, calls `restoreBackup` and prints the result. */
19
+ export declare function undo(options?: UndoOptions): Promise<void>;
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ import * as p from '@clack/prompts';
3
+ import { listBackupRuns, listBackupFiles, restoreBackup, } from '../lib/backup.js';
4
+ /** Restore the most recent backup run.
5
+ *
6
+ * - Finds the newest run via `listBackupRuns`. If there are none, prints a
7
+ * friendly "nothing to undo" and exits.
8
+ * - Prints a summary of what will be restored, including the note that the
9
+ * current state will itself be backed up first (so undo is reversible).
10
+ * - Prompts y/N unless `--yes`. On cancel, exits gracefully.
11
+ * - On confirm, calls `restoreBackup` and prints the result. */
12
+ export async function undo(options = {}) {
13
+ const projectRoot = process.cwd();
14
+ const runs = listBackupRuns(projectRoot);
15
+ if (runs.length === 0) {
16
+ if (options.json) {
17
+ console.log(JSON.stringify({ undone: null, message: 'No backups to undo.' }));
18
+ }
19
+ else {
20
+ console.log('\n No backups to undo.\n');
21
+ }
22
+ return;
23
+ }
24
+ const latest = runs[0];
25
+ const entries = listBackupFiles(latest.runTimestamp, projectRoot);
26
+ if (!options.json) {
27
+ const verb = options.dryRun ? 'Would restore' : 'Restore';
28
+ console.log(`\n ${verb} backup run: ${latest.runTimestamp}`);
29
+ console.log(` Files: ${entries.length}\n`);
30
+ for (const e of entries) {
31
+ const rel = path.relative(projectRoot, e.originalPath) || e.originalPath;
32
+ console.log(` ${rel}`);
33
+ }
34
+ if (!options.noBackup && !options.dryRun) {
35
+ console.log('\n The current files at these paths will themselves be backed up first');
36
+ console.log(' (you can `botdocs undo` again to swap back).');
37
+ }
38
+ console.log('');
39
+ }
40
+ if (!options.dryRun && !options.yes && !options.json) {
41
+ const confirmed = await p.confirm({
42
+ message: `Restore ${entries.length} file(s) from ${latest.runTimestamp}?`,
43
+ initialValue: false,
44
+ });
45
+ // Clack returns a cancel sentinel on Ctrl-C; treat that as "cancelled" too.
46
+ if (p.isCancel(confirmed) || !confirmed) {
47
+ console.log(' Cancelled.\n');
48
+ return;
49
+ }
50
+ }
51
+ const result = restoreBackup(latest.runTimestamp, projectRoot, {
52
+ dryRun: options.dryRun,
53
+ noBackup: options.noBackup,
54
+ });
55
+ if (options.json) {
56
+ console.log(JSON.stringify({
57
+ runTimestamp: latest.runTimestamp,
58
+ dryRun: options.dryRun ?? false,
59
+ restored: result.restored.map((e) => e.originalPath),
60
+ failed: result.failed.map((f) => ({
61
+ originalPath: f.entry.originalPath,
62
+ error: f.error,
63
+ })),
64
+ preBackedUp: result.preBackedUp,
65
+ }));
66
+ return;
67
+ }
68
+ reportRestoreResult(result, projectRoot, options.dryRun ?? false);
69
+ }
70
+ function reportRestoreResult(result, projectRoot, dryRun) {
71
+ const verb = dryRun ? 'Would restore' : 'Restored';
72
+ console.log(` ✓ ${verb} ${result.restored.length} file(s)`);
73
+ for (const e of result.restored) {
74
+ const rel = path.relative(projectRoot, e.originalPath) || e.originalPath;
75
+ console.log(` ${rel}`);
76
+ }
77
+ if (result.failed.length > 0) {
78
+ console.log(`\n ⚠ ${result.failed.length} file(s) failed:`);
79
+ for (const f of result.failed) {
80
+ const rel = path.relative(projectRoot, f.entry.originalPath) || f.entry.originalPath;
81
+ console.log(` ${rel} (${f.error})`);
82
+ }
83
+ }
84
+ if (!dryRun && result.preBackedUp.length > 0) {
85
+ console.log(`\n Pre-backed-up ${result.preBackedUp.length} file(s) — run \`botdocs undo\` again to swap back.`);
86
+ }
87
+ console.log('');
88
+ }
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import type { SyncFileRow } from './sync-state.js';
3
+ /** The three resolutions available on a conflict. Mirrors `ConflictChoice` in
4
+ * `commands/sync.ts` — kept in sync by hand because pulling that type would
5
+ * create a circular dep between the views directory and its parent. */
6
+ export type ConflictChoiceValue = 'keep' | 'overwrite' | 'skip';
7
+ export interface ConflictPromptProps {
8
+ /** The row whose conflict is being resolved. We render the ref + details
9
+ * line above the select so the user keeps context for what they're deciding. */
10
+ row: SyncFileRow;
11
+ /** Fired when the user picks one of the three options. The caller is
12
+ * responsible for closing the prompt (typically by dispatching the row to
13
+ * its post-choice state and clearing the conflict resolver). */
14
+ onChoice: (choice: ConflictChoiceValue) => void;
15
+ }
16
+ /** Inline conflict prompt rendered "under" a conflict row in the live sync
17
+ * view. Three options match the runner's `ConflictChoice` union — `keep` and
18
+ * `skip` are semantically identical today but kept distinct for forward
19
+ * compatibility (and so the UI can label them differently).
20
+ *
21
+ * The component is intentionally dumb: it owns no state, no I/O. The Ink view
22
+ * mounts it when a row enters `conflict` with a resolver attached, and the
23
+ * resolver fires when the user picks. */
24
+ export declare function ConflictPrompt({ row, onChoice }: ConflictPromptProps): React.ReactElement;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import SelectInput from 'ink-select-input';
4
+ import { theme } from './theme.js';
5
+ /** Inline conflict prompt rendered "under" a conflict row in the live sync
6
+ * view. Three options match the runner's `ConflictChoice` union — `keep` and
7
+ * `skip` are semantically identical today but kept distinct for forward
8
+ * compatibility (and so the UI can label them differently).
9
+ *
10
+ * The component is intentionally dumb: it owns no state, no I/O. The Ink view
11
+ * mounts it when a row enters `conflict` with a resolver attached, and the
12
+ * resolver fires when the user picks. */
13
+ export function ConflictPrompt({ row, onChoice }) {
14
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.amber, children: "\u26A0 " }), _jsx(Text, { children: row.ref }), row.details ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.zinc, children: " \u2014 " }), _jsx(Text, { children: row.details })] })) : null] }), _jsx(Text, { color: theme.zinc, children: " Local edits detected. What should we do?" }), _jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsx(SelectInput, { items: [
15
+ { key: 'k', label: 'Keep my local version', value: 'keep' },
16
+ { key: 'o', label: 'Overwrite with new version', value: 'overwrite' },
17
+ { key: 's', label: 'Skip and continue', value: 'skip' },
18
+ ], onSelect: (item) => onChoice(item.value) }) })] }));
19
+ }
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ /** All visible states the login flow can be in. The component is a thin
3
+ * renderer over this status — the actual work (init, polling, save) is driven
4
+ * by callbacks from the parent so the Ink view stays I/O-free for testing. */
5
+ export type LoginStatus = 'initializing' | 'browser-opening' | 'polling' | 'success' | 'expired' | 'error';
6
+ export interface LoginAppProps {
7
+ /** Current visible state. Parent updates this as the auth flow progresses. */
8
+ status: LoginStatus;
9
+ /** Full URL to display (only relevant during 'polling'). */
10
+ authUrl?: string;
11
+ /** Last 6 hex chars of the state — printed as a "Confirm the suffix matches
12
+ * …xxxxxx" hint so the user can verify the right page in their browser. */
13
+ stateTail?: string;
14
+ /** Resolved username after success. Used for the celebratory "Signed in as
15
+ * @username" line. */
16
+ username?: string;
17
+ /** Free-form error message for `status === 'error'`. */
18
+ errorMessage?: string;
19
+ /** When sync-library was requested, surface a follow-up hint after success. */
20
+ syncLibrary?: boolean;
21
+ /** Terminal width — used to decide whether the BigText wordmark fits. Inject
22
+ * here (instead of reading at module load) so tests can render at a fixed
23
+ * width. */
24
+ columns?: number;
25
+ }
26
+ /** Live Ink view for `botdocs login`. The component is a pure function of
27
+ * `status`; the parent owns the polling timer and dispatches status changes
28
+ * via props. On `success`, `expired`, or `error` the parent should call
29
+ * `unmount()` from `render(...)` shortly after the user has seen the result. */
30
+ export declare function LoginApp(props: LoginAppProps): React.ReactElement;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useApp } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import Gradient from 'ink-gradient';
6
+ import BigText from 'ink-big-text';
7
+ import { theme, BIG_TEXT_MIN_COLS } from './theme.js';
8
+ /** Brand wordmark. Falls back to a bold plain "botdocs" on narrow terminals
9
+ * where ASCII art would wrap. The `tiny` font keeps the cap height low so the
10
+ * screen stays scannable. */
11
+ function BrandMark({ columns }) {
12
+ if (columns < BIG_TEXT_MIN_COLS) {
13
+ return (_jsx(Text, { bold: true, color: theme.cyan, children: "botdocs" }));
14
+ }
15
+ return (_jsx(Gradient, { colors: [theme.cyan, theme.violet], children: _jsx(BigText, { text: "botdocs", font: "tiny" }) }));
16
+ }
17
+ /** Single source of truth for active-state lines. Returns a label + spinner
18
+ * pair the renderer can drop into the layout — keeps the state-string mapping
19
+ * in one place. */
20
+ function ActiveLine({ status }) {
21
+ switch (status) {
22
+ case 'initializing':
23
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.cyan, children: _jsx(Spinner, { type: "dots" }) }), ' ', _jsx(Text, { children: "Generating session..." })] }));
24
+ case 'browser-opening':
25
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.cyan, children: _jsx(Spinner, { type: "dots" }) }), ' ', _jsx(Text, { children: "Opening your browser..." })] }));
26
+ case 'polling':
27
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.cyan, children: _jsx(Spinner, { type: "dots" }) }), ' ', _jsx(Text, { children: "Waiting for authorization..." })] }));
28
+ default:
29
+ return null;
30
+ }
31
+ }
32
+ /** Live Ink view for `botdocs login`. The component is a pure function of
33
+ * `status`; the parent owns the polling timer and dispatches status changes
34
+ * via props. On `success`, `expired`, or `error` the parent should call
35
+ * `unmount()` from `render(...)` shortly after the user has seen the result. */
36
+ export function LoginApp(props) {
37
+ const { status, authUrl, stateTail, username, errorMessage, syncLibrary, columns } = props;
38
+ const { exit } = useApp();
39
+ // Terminal width — fall back to a sensible default for non-TTY paths or
40
+ // when stdout doesn't expose `columns` yet. The component only branches on
41
+ // this to choose between the BigText wordmark and the plain fallback.
42
+ const [cols] = useState(columns ?? process.stdout.columns ?? 80);
43
+ // Auto-exit after a terminal state. The small delay on `success` gives the
44
+ // user a moment to read the celebratory line before the screen unmounts.
45
+ useEffect(() => {
46
+ if (status === 'success') {
47
+ const timer = setTimeout(() => exit(), 800);
48
+ return () => clearTimeout(timer);
49
+ }
50
+ if (status === 'expired' || status === 'error') {
51
+ const timer = setTimeout(() => exit(), 50);
52
+ return () => clearTimeout(timer);
53
+ }
54
+ return undefined;
55
+ }, [status, exit]);
56
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(BrandMark, { columns: cols }), _jsx(Box, { marginTop: 1, children: _jsx(ActiveLine, { status: status }) }), status === 'polling' && authUrl ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.zinc, children: "Authorize this session at:" }), _jsx(Text, { children: authUrl }), stateTail ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.zinc, children: ["Confirm the suffix matches: \u2026", stateTail] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.zinc, children: "State expires in 10 minutes \u2014 press Ctrl-C to abort." }) })] })) : null, status === 'success' && username ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.green, children: ["\u2713 Signed in as @", username] }), syncLibrary ? (_jsx(Text, { color: theme.zinc, children: "Library sync enabled \u2014 your installed-refs will appear at https://botdocs.ai/library." })) : (_jsx(Text, { color: theme.zinc, children: "Library sync is OFF. Re-run with --sync-library to enable the personalized Library page." }))] })) : null, status === 'expired' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.amber, children: "Authorization expired \u2014 re-run `botdocs login`." }) })) : null, status === 'error' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.red, children: ["\u2717 ", errorMessage ?? 'Login failed.'] }) })) : null] }));
57
+ }
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import { type SyncAction } from './sync-state.js';
3
+ import { type ConflictChoiceValue } from './conflict-prompt.js';
4
+ /** The dependencies the runner receives from the Ink view. `dispatch` updates
5
+ * the reducer state; `awaitConflictChoice` returns a Promise that resolves
6
+ * when the inline prompt fires. */
7
+ export interface SyncDeps {
8
+ dispatch: (action: SyncAction) => void;
9
+ awaitConflictChoice: (ref: string, file: string) => Promise<ConflictChoiceValue>;
10
+ }
11
+ export interface SyncAppProps {
12
+ /** Pre-known rows to seed the queued state. Team rows are added later via
13
+ * `ADD_ROW` once `/api/cli/teams` returns — we don't have them at mount time. */
14
+ initialRows: Array<{
15
+ ref: string;
16
+ team?: string;
17
+ }>;
18
+ /** The async work to run on mount. Receives the dispatch/awaiter pair and
19
+ * is expected to dispatch FINISH when done. The view auto-unmounts ~600ms
20
+ * after FINISH so the user can read the summary. */
21
+ runSync: (deps: SyncDeps) => Promise<void>;
22
+ }
23
+ /** Live Ink view for `botdocs sync`. Owns the reducer state and the conflict
24
+ * resolver Map. On mount it kicks off `runSync` with a dispatch/awaiter pair —
25
+ * the awaiter parks a Promise resolver keyed by `ref|file` in a ref-held Map,
26
+ * and the inline `<ConflictPrompt />` resolves it when the user picks. */
27
+ export declare function SyncApp({ initialRows, runSync }: SyncAppProps): React.ReactElement;