@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,147 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef } from 'react';
3
+ import { Box, Text, useApp } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { theme } from './theme.js';
6
+ import { initialState, syncReducer, } from './sync-state.js';
7
+ import { ConflictPrompt } from './conflict-prompt.js';
8
+ /** Map a row state to a one-character status icon. Active states ('checking',
9
+ * 'updating') render a spinner instead and pass `null` through here. */
10
+ function statusIcon(state) {
11
+ switch (state) {
12
+ case 'queued':
13
+ return { glyph: '◌', color: theme.zinc };
14
+ case 'up-to-date':
15
+ case 'updated':
16
+ return { glyph: '✓', color: theme.green };
17
+ case 'skipped':
18
+ return { glyph: '⌀', color: theme.zinc };
19
+ case 'conflict':
20
+ return { glyph: '⚠', color: theme.amber };
21
+ case 'error':
22
+ return { glyph: '✗', color: theme.red };
23
+ // checking/updating handled by the spinner branch in the renderer.
24
+ default:
25
+ return { glyph: ' ', color: theme.zinc };
26
+ }
27
+ }
28
+ /** Right-hand details text for a row. Keeps the rendering consistent across
29
+ * the per-section loops below. */
30
+ function detailsText(row) {
31
+ if (row.details)
32
+ return row.details;
33
+ switch (row.state) {
34
+ case 'queued':
35
+ return 'queued';
36
+ case 'checking':
37
+ return 'checking...';
38
+ case 'up-to-date':
39
+ return 'up to date';
40
+ case 'updating':
41
+ return 'installing...';
42
+ case 'updated':
43
+ return 'updated';
44
+ case 'skipped':
45
+ return 'skipped';
46
+ case 'conflict':
47
+ return 'local edits detected';
48
+ case 'error':
49
+ return 'error';
50
+ default:
51
+ return '';
52
+ }
53
+ }
54
+ /** Render one row. Conflict rows still render through this — the inline
55
+ * `<ConflictPrompt />` is rendered separately below the section so the user
56
+ * sees the row + the choice list together. */
57
+ function Row({ row }) {
58
+ const isActive = row.state === 'checking' || row.state === 'updating';
59
+ const { glyph, color } = statusIcon(row.state);
60
+ return (_jsxs(Box, { marginLeft: 2, children: [_jsx(Box, { width: 2, children: isActive ? (_jsx(Text, { color: theme.cyan, children: _jsx(Spinner, { type: "dots" }) })) : (_jsx(Text, { color: color, children: glyph })) }), _jsx(Text, { children: row.ref }), _jsxs(Text, { color: theme.zinc, children: [" \u2014 ", detailsText(row)] })] }));
61
+ }
62
+ /** Group rows by their `team` field. Personal rows (no team) come first;
63
+ * teams are sorted alphabetically by slug to keep the layout stable across
64
+ * runs even when the server returns teams in a different order. */
65
+ function groupByTeam(rows) {
66
+ const personal = [];
67
+ const byTeam = new Map();
68
+ for (const r of rows) {
69
+ if (!r.team) {
70
+ personal.push(r);
71
+ continue;
72
+ }
73
+ const list = byTeam.get(r.team) ?? [];
74
+ list.push(r);
75
+ byTeam.set(r.team, list);
76
+ }
77
+ const sections = [];
78
+ if (personal.length > 0)
79
+ sections.push({ team: null, rows: personal });
80
+ const teamSlugs = Array.from(byTeam.keys()).sort();
81
+ for (const slug of teamSlugs) {
82
+ sections.push({ team: slug, rows: byTeam.get(slug) });
83
+ }
84
+ return sections;
85
+ }
86
+ /** Live Ink view for `botdocs sync`. Owns the reducer state and the conflict
87
+ * resolver Map. On mount it kicks off `runSync` with a dispatch/awaiter pair —
88
+ * the awaiter parks a Promise resolver keyed by `ref|file` in a ref-held Map,
89
+ * and the inline `<ConflictPrompt />` resolves it when the user picks. */
90
+ export function SyncApp({ initialRows, runSync }) {
91
+ const [state, dispatch] = useReducer(syncReducer, initialRows, (rows) => syncReducer(initialState(), { type: 'INIT', rows }));
92
+ const conflictResolvers = useRef(new Map());
93
+ const { exit } = useApp();
94
+ const didRun = useRef(false);
95
+ // Kick off the sync once on mount. Strict-mode-style double-invocation would
96
+ // re-run; we guard with a ref because the work has side effects.
97
+ useEffect(() => {
98
+ if (didRun.current)
99
+ return;
100
+ didRun.current = true;
101
+ const awaitConflictChoice = (ref, file) => new Promise((resolve) => {
102
+ const key = `${ref}|${file}`;
103
+ conflictResolvers.current.set(key, resolve);
104
+ // Wire up the row-level resolver so the inline <ConflictPrompt /> can
105
+ // call it directly without needing to know which file is active.
106
+ dispatch({
107
+ type: 'CONFLICT',
108
+ ref,
109
+ details: file,
110
+ resolveConflict: (choice) => {
111
+ const r = conflictResolvers.current.get(key);
112
+ if (r) {
113
+ conflictResolvers.current.delete(key);
114
+ r(choice);
115
+ }
116
+ },
117
+ });
118
+ });
119
+ runSync({ dispatch, awaitConflictChoice }).catch((err) => {
120
+ const message = err instanceof Error ? err.message : String(err);
121
+ // Surface fatal errors as a synthetic row + finish so the user still
122
+ // sees something rather than an empty frame.
123
+ dispatch({ type: 'FINISH', elapsedMs: 0 });
124
+ // Re-throw via process.nextTick so the unmount completes first.
125
+ process.nextTick(() => {
126
+ throw err instanceof Error ? err : new Error(message);
127
+ });
128
+ });
129
+ // Intentionally only depend on first mount; the runner is consumed once.
130
+ }, []);
131
+ // Hold the final summary briefly before unmounting so the user can read it.
132
+ useEffect(() => {
133
+ if (state.finished) {
134
+ const timer = setTimeout(() => exit(), 600);
135
+ return () => clearTimeout(timer);
136
+ }
137
+ return undefined;
138
+ }, [state.finished, exit]);
139
+ const { rows, summary, finished, elapsedMs } = state;
140
+ const sections = groupByTeam(rows);
141
+ const totals = `${rows.length} file${rows.length === 1 ? '' : 's'}`;
142
+ // Find the first unresolved conflict row to render the inline prompt under.
143
+ // Only one conflict prompt is active at a time because the runner awaits
144
+ // each one before moving on.
145
+ const activeConflict = rows.find((r) => r.state === 'conflict' && Boolean(r.resolveConflict));
146
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { bold: true, color: theme.cyan, children: "BotDocs sync" }), _jsx(Text, { color: theme.zinc, children: totals }), sections.map((section) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.violet, children: section.team ? `TEAM ${section.team}` : 'PERSONAL' }), section.rows.map((r) => (_jsx(Row, { row: r }, r.ref)))] }, section.team ?? '__personal'))), activeConflict ? (_jsx(ConflictPrompt, { row: activeConflict, onChoice: (choice) => activeConflict.resolveConflict?.(choice) })) : null, finished ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.zinc, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { children: [_jsxs(Text, { color: theme.green, children: [summary.updated, " updated"] }), _jsx(Text, { color: theme.zinc, children: " \u00B7 " }), _jsxs(Text, { color: theme.green, children: [summary.upToDate, " up to date"] }), summary.skipped > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.zinc, children: " \u00B7 " }), _jsxs(Text, { color: theme.zinc, children: [summary.skipped, " skipped"] })] })) : null, summary.conflict > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.zinc, children: " \u00B7 " }), _jsxs(Text, { color: theme.amber, children: [summary.conflict, " conflict"] })] })) : null, summary.error > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.zinc, children: " \u00B7 " }), _jsxs(Text, { color: theme.red, children: [summary.error, " error"] })] })) : null, typeof elapsedMs === 'number' ? (_jsxs(Text, { color: theme.zinc, children: [' · ', "finished in ", (elapsedMs / 1000).toFixed(1), "s"] })) : null] })] })) : null] }));
147
+ }
@@ -0,0 +1,84 @@
1
+ /** Pure reducer + types for the Ink sync view. Kept in a separate file from
2
+ * the React component so we can drive it through unit tests without rendering. */
3
+ /** Per-row state machine. The order roughly tracks the path a row takes during
4
+ * a sync: queued → checking → (up-to-date | updating → updated | conflict →
5
+ * skipped|updated | error). 'skipped' is reachable both from clean-update
6
+ * "skip" and from conflict "skip"; both render identically. */
7
+ export type SyncFileState = 'queued' | 'checking' | 'up-to-date' | 'updating' | 'updated' | 'conflict' | 'skipped' | 'error';
8
+ export interface SyncFileRow {
9
+ /** Display label, typically `@user/slug` or `@user/slug:filename`. */
10
+ ref: string;
11
+ /** Owning section. `undefined` is treated as the personal section. */
12
+ team?: string;
13
+ /** Optional secondary detail (e.g. version delta or error message). */
14
+ details?: string;
15
+ state: SyncFileState;
16
+ /** When `state === 'conflict'`, optional handler the view layer can call
17
+ * with the user's selection. Kept off the wire format and out of summaries.
18
+ *
19
+ * The live Ink view sets this via `CONFLICT` so the inline `<SelectInput />`
20
+ * can resolve the promise the runner is awaiting. When undefined, the row
21
+ * is in a non-interactive "conflict" presentation (e.g. test fixtures). */
22
+ resolveConflict?: (choice: 'overwrite' | 'skip' | 'keep') => void;
23
+ }
24
+ export interface SyncSummary {
25
+ updated: number;
26
+ upToDate: number;
27
+ conflict: number;
28
+ error: number;
29
+ skipped: number;
30
+ }
31
+ export interface SyncState {
32
+ rows: SyncFileRow[];
33
+ summary: SyncSummary;
34
+ /** Wall-clock ms since the sync started. Set on FINISH so the final summary
35
+ * can render "finished in X.Ys" without the view layer needing its own timer. */
36
+ elapsedMs?: number;
37
+ /** True once the parent driver has dispatched FINISH. View uses this to
38
+ * render the divider + summary footer and stop showing active spinners. */
39
+ finished: boolean;
40
+ }
41
+ export type SyncAction = {
42
+ type: 'INIT';
43
+ rows: Array<{
44
+ ref: string;
45
+ team?: string;
46
+ }>;
47
+ } | {
48
+ type: 'ADD_ROW';
49
+ ref: string;
50
+ team?: string;
51
+ } | {
52
+ type: 'CHECKING';
53
+ ref: string;
54
+ } | {
55
+ type: 'UP_TO_DATE';
56
+ ref: string;
57
+ details?: string;
58
+ } | {
59
+ type: 'UPDATING';
60
+ ref: string;
61
+ details?: string;
62
+ } | {
63
+ type: 'UPDATED';
64
+ ref: string;
65
+ details?: string;
66
+ } | {
67
+ type: 'CONFLICT';
68
+ ref: string;
69
+ details?: string;
70
+ resolveConflict?: (choice: 'overwrite' | 'skip' | 'keep') => void;
71
+ } | {
72
+ type: 'SKIPPED';
73
+ ref: string;
74
+ details?: string;
75
+ } | {
76
+ type: 'ERROR';
77
+ ref: string;
78
+ details?: string;
79
+ } | {
80
+ type: 'FINISH';
81
+ elapsedMs: number;
82
+ };
83
+ export declare function initialState(): SyncState;
84
+ export declare function syncReducer(state: SyncState, action: SyncAction): SyncState;
@@ -0,0 +1,93 @@
1
+ /** Pure reducer + types for the Ink sync view. Kept in a separate file from
2
+ * the React component so we can drive it through unit tests without rendering. */
3
+ export function initialState() {
4
+ return {
5
+ rows: [],
6
+ summary: { updated: 0, upToDate: 0, conflict: 0, error: 0, skipped: 0 },
7
+ finished: false,
8
+ };
9
+ }
10
+ /** Recompute the summary counts from rows in their current state. We keep
11
+ * `summary` in sync this way (rather than incrementing on each transition) so
12
+ * a row moving from CONFLICT → UPDATED doesn't double-count. */
13
+ function recomputeSummary(rows) {
14
+ const s = { updated: 0, upToDate: 0, conflict: 0, error: 0, skipped: 0 };
15
+ for (const r of rows) {
16
+ if (r.state === 'updated')
17
+ s.updated++;
18
+ else if (r.state === 'up-to-date')
19
+ s.upToDate++;
20
+ else if (r.state === 'conflict')
21
+ s.conflict++;
22
+ else if (r.state === 'error')
23
+ s.error++;
24
+ else if (r.state === 'skipped')
25
+ s.skipped++;
26
+ }
27
+ return s;
28
+ }
29
+ function updateRow(state, ref, patch) {
30
+ const rows = state.rows.map((r) => (r.ref === ref ? { ...r, ...patch } : r));
31
+ return { ...state, rows, summary: recomputeSummary(rows) };
32
+ }
33
+ export function syncReducer(state, action) {
34
+ switch (action.type) {
35
+ case 'INIT': {
36
+ const rows = action.rows.map((r) => ({
37
+ ref: r.ref,
38
+ team: r.team,
39
+ state: 'queued',
40
+ }));
41
+ return { rows, summary: recomputeSummary(rows), finished: false };
42
+ }
43
+ case 'ADD_ROW': {
44
+ // Late-added row (e.g. team-pinned skills surfaced after personal sync).
45
+ // No-op if a row with this ref already exists — keep the existing state.
46
+ if (state.rows.some((r) => r.ref === action.ref))
47
+ return state;
48
+ const rows = [
49
+ ...state.rows,
50
+ { ref: action.ref, team: action.team, state: 'queued' },
51
+ ];
52
+ return { ...state, rows, summary: recomputeSummary(rows) };
53
+ }
54
+ case 'CHECKING':
55
+ return updateRow(state, action.ref, { state: 'checking' });
56
+ case 'UP_TO_DATE':
57
+ return updateRow(state, action.ref, {
58
+ state: 'up-to-date',
59
+ details: action.details,
60
+ resolveConflict: undefined,
61
+ });
62
+ case 'UPDATING':
63
+ return updateRow(state, action.ref, { state: 'updating', details: action.details });
64
+ case 'UPDATED':
65
+ return updateRow(state, action.ref, {
66
+ state: 'updated',
67
+ details: action.details,
68
+ resolveConflict: undefined,
69
+ });
70
+ case 'CONFLICT':
71
+ return updateRow(state, action.ref, {
72
+ state: 'conflict',
73
+ details: action.details,
74
+ resolveConflict: action.resolveConflict,
75
+ });
76
+ case 'SKIPPED':
77
+ return updateRow(state, action.ref, {
78
+ state: 'skipped',
79
+ details: action.details,
80
+ resolveConflict: undefined,
81
+ });
82
+ case 'ERROR':
83
+ return updateRow(state, action.ref, {
84
+ state: 'error',
85
+ details: action.details,
86
+ resolveConflict: undefined,
87
+ });
88
+ case 'FINISH':
89
+ return { ...state, elapsedMs: action.elapsedMs, finished: true };
90
+ default:
91
+ return state;
92
+ }
93
+ }
@@ -0,0 +1,16 @@
1
+ /** Shared BotDocs CLI theme tokens. Mirrors the web app's Tailwind palette so
2
+ * the brand stays consistent across surfaces. Values are hex strings; Ink
3
+ * components accept hex directly for `color` and `backgroundColor` props. */
4
+ export declare const theme: {
5
+ readonly cyan: "#22d3ee";
6
+ readonly violet: "#a78bfa";
7
+ readonly green: "#34d399";
8
+ readonly amber: "#fbbf24";
9
+ readonly red: "#f87171";
10
+ readonly zinc: "#71717a";
11
+ };
12
+ /** Width below which we skip the `ink-big-text` ASCII wordmark and fall back
13
+ * to a plain bold "botdocs" line — narrower terminals render the big-text
14
+ * with awkward wrapping. 60 was chosen empirically: a 50-column wordmark plus
15
+ * margins fits comfortably above that. */
16
+ export declare const BIG_TEXT_MIN_COLS = 60;
@@ -0,0 +1,16 @@
1
+ /** Shared BotDocs CLI theme tokens. Mirrors the web app's Tailwind palette so
2
+ * the brand stays consistent across surfaces. Values are hex strings; Ink
3
+ * components accept hex directly for `color` and `backgroundColor` props. */
4
+ export const theme = {
5
+ cyan: '#22d3ee', // Tailwind cyan-400 — primary brand
6
+ violet: '#a78bfa', // Tailwind violet-400 — secondary brand
7
+ green: '#34d399', // Tailwind emerald-400 — success
8
+ amber: '#fbbf24', // Tailwind amber-400 — warning
9
+ red: '#f87171', // Tailwind red-400 — error
10
+ zinc: '#71717a', // Tailwind zinc-500 — subtle / hint
11
+ };
12
+ /** Width below which we skip the `ink-big-text` ASCII wordmark and fall back
13
+ * to a plain bold "botdocs" line — narrower terminals render the big-text
14
+ * with awkward wrapping. 60 was chosen empirically: a 50-column wordmark plus
15
+ * margins fits comfortably above that. */
16
+ export const BIG_TEXT_MIN_COLS = 60;
@@ -1,26 +1,26 @@
1
1
  import { loadAuth } from '../lib/config.js';
2
+ import { apiFetch, ApiError } from '../lib/api.js';
2
3
  export async function whoami(options = {}) {
3
4
  const auth = loadAuth();
4
5
  if (!auth) {
5
6
  console.log('Not logged in. Run `botdocs login` to authenticate.');
6
7
  process.exit(1);
7
8
  }
8
- // Verify token is still valid
9
- const response = await fetch('https://api.github.com/user', {
10
- headers: {
11
- Authorization: `Bearer ${auth.githubToken}`,
12
- Accept: 'application/vnd.github+json',
13
- },
14
- });
15
- if (!response.ok) {
16
- console.log('Session expired. Run `botdocs login` to re-authenticate.');
17
- process.exit(1);
9
+ let user;
10
+ try {
11
+ user = await apiFetch('/api/cli/whoami', { auth: true });
12
+ }
13
+ catch (err) {
14
+ if (err instanceof ApiError && err.status === 401) {
15
+ console.log('Authentication failed. Run `botdocs login` to sign in again.');
16
+ process.exit(1);
17
+ }
18
+ throw err;
18
19
  }
19
- const user = (await response.json());
20
20
  if (options.json) {
21
- console.log(JSON.stringify({ login: user.login, name: user.name }));
21
+ console.log(JSON.stringify({ login: user.username, name: user.displayName }));
22
22
  }
23
23
  else {
24
- console.log(`Logged in as ${user.login}${user.name ? ` (${user.name})` : ''}`);
24
+ console.log(`Logged in as ${user.username}${user.displayName && user.displayName !== user.username ? ` (${user.displayName})` : ''}`);
25
25
  }
26
26
  }
package/dist/index.js CHANGED
@@ -1,15 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { clone } from './commands/clone.js';
4
3
  import { search } from './commands/search.js';
5
4
  import { publish } from './commands/publish.js';
6
- import { pull } from './commands/pull.js';
7
5
  import { login } from './commands/login.js';
8
6
  import { whoami } from './commands/whoami.js';
9
7
  import { init } from './commands/init.js';
10
8
  import { validate } from './commands/validate.js';
11
- import { diff } from './commands/diff.js';
12
- import { endorse } from './commands/endorse.js';
13
9
  import { installInstructions } from './commands/install-instructions.js';
14
10
  import { install } from './commands/install.js';
15
11
  import { list } from './commands/list.js';
@@ -19,11 +15,17 @@ import { ingest } from './commands/ingest.js';
19
15
  import { checkUpdates } from './commands/check-updates.js';
20
16
  import { compile } from './commands/compile.js';
21
17
  import { edit } from './commands/edit.js';
18
+ import { registerTeamCommands } from './commands/team.js';
19
+ import { registerBackupCommands } from './commands/backups.js';
20
+ import { undo } from './commands/undo.js';
21
+ import { createRequire } from 'node:module';
22
+ const require = createRequire(import.meta.url);
23
+ const pkg = require('../package.json');
22
24
  const program = new Command();
23
25
  program
24
26
  .name('botdocs')
25
- .description('CLI for BotDocs — clone, search, and publish concept specifications')
26
- .version('0.1.0')
27
+ .description('CLI for BotDocs — author, publish, install, and sync agent skills')
28
+ .version(pkg.version)
27
29
  .option('--json', 'Output results as JSON');
28
30
  program
29
31
  .command('init [name]')
@@ -40,12 +42,6 @@ program
40
42
  .action(async (source) => {
41
43
  await validate(source, { json: program.opts().json });
42
44
  });
43
- program
44
- .command('clone <username/slug>')
45
- .description('Download all BotDoc files to a local directory')
46
- .action(async (ref) => {
47
- await clone(ref, { json: program.opts().json });
48
- });
49
45
  program
50
46
  .command('search <query>')
51
47
  .description('Search for BotDocs')
@@ -64,32 +60,17 @@ program
64
60
  .action(async (source, options) => {
65
61
  await publish(source, { ...options, json: program.opts().json });
66
62
  });
67
- program
68
- .command('diff <username/slug>')
69
- .description('Preview remote changes before pulling')
70
- .action(async (ref) => {
71
- await diff(ref, { json: program.opts().json });
72
- });
73
- program
74
- .command('pull <username/slug>')
75
- .description('Update previously cloned BotDoc files with the latest version')
76
- .action(async (ref) => {
77
- await pull(ref, { json: program.opts().json });
78
- });
79
- program
80
- .command('endorse <username/slug>')
81
- .description('Endorse a BotDoc after building from it')
82
- .requiredOption('--rating <rating>', 'Rating: positive, neutral, or negative')
83
- .option('--comment <comment>', 'Optional feedback about the build experience')
84
- .action(async (ref, opts) => {
85
- await endorse(ref, { ...opts, json: program.opts().json });
86
- });
87
63
  program
88
64
  .command('login')
89
- .description('Authenticate via GitHub device code flow')
65
+ .description('Authenticate by opening your browser; or pass --token for non-interactive use')
90
66
  .option('--sync-library', 'Enable personalized Library page (uploads sanitized lockfile after install/sync/uninstall)')
67
+ .option('--token <token>', 'Authenticate non-interactively with a bd_<...> token (mint one at /settings/tokens)')
68
+ .option('--no-ink', 'Disable the live Ink UI and use plain text output (for screen readers or simple terminals)')
91
69
  .action(async (opts) => {
92
- await login(opts);
70
+ // Commander's --no-ink convention sets opts.ink = false; flip to noInk
71
+ // so downstream consumers don't have to handle the inverted boolean.
72
+ const { ink, ...rest } = opts;
73
+ await login({ ...rest, noInk: ink === false });
93
74
  });
94
75
  program
95
76
  .command('whoami')
@@ -108,12 +89,16 @@ program
108
89
  });
109
90
  program
110
91
  .command('install <ref>')
111
- .description('Install a skill or bundle locally (skills go to ~/.claude/skills/, project files to .cursor/rules/, etc.)')
92
+ .description('Install a skill or bundle locally (skills go to ~/.claude/skills/, project files to .cursor/rules/, .codex/skills/, .github/instructions/, etc.)')
112
93
  .option('--project <dir>', 'Override the project root used for project-local files')
113
94
  .option('--flat', 'Skip the {scope} subdirectory in install paths (collision-prone, not recommended)')
114
95
  .option('--clean', 'Wipe-and-reinstall instead of additive')
96
+ .option('--no-backup', 'Do not back up files that would be overwritten (use in CI where backups are noise)')
115
97
  .action(async (ref, opts) => {
116
- await install(ref, { ...opts, json: program.opts().json });
98
+ // Commander's --no-backup convention sets opts.backup = false.
99
+ // Translate to options.noBackup for downstream consumers.
100
+ const { backup, ...rest } = opts;
101
+ await install(ref, { ...rest, noBackup: backup === false, json: program.opts().json });
117
102
  });
118
103
  program
119
104
  .command('list')
@@ -133,8 +118,16 @@ program
133
118
  .description('Check installed skills/bundles for updates and apply')
134
119
  .option('--yes', 'Auto-accept clean updates; skip files with local edits')
135
120
  .option('--dry-run', 'Show what would change without applying')
121
+ .option('--no-backup', 'Do not back up files that would be overwritten (use in CI where backups are noise)')
122
+ .option('--no-ink', 'Disable the live Ink summary view and use plain text output')
136
123
  .action(async (ref, opts) => {
137
- await sync(ref, { ...opts, json: program.opts().json });
124
+ const { backup, ink, ...rest } = opts;
125
+ await sync(ref, {
126
+ ...rest,
127
+ noBackup: backup === false,
128
+ noInk: ink === false,
129
+ json: program.opts().json,
130
+ });
138
131
  });
139
132
  program
140
133
  .command('check-updates')
@@ -164,9 +157,22 @@ program
164
157
  program
165
158
  .command('edit <ref>')
166
159
  .description('LLM-assisted revision of a published skill ecosystem file (BYOK; pushes a draft)')
167
- .requiredOption('--ecosystem <name>', 'Which ecosystem file to edit (claude, claude-code, cursor, chatgpt, codex)')
160
+ .requiredOption('--ecosystem <name>', 'Which ecosystem file to edit (claude, claude-code, cursor, chatgpt, codex, copilot, antigravity, gemini, opencode, windsurf)')
168
161
  .option('--key-env <name>', 'Override the env var used for the API key')
169
162
  .action(async (ref, opts) => {
170
163
  await edit(ref, { ...opts, json: program.opts().json });
171
164
  });
165
+ registerTeamCommands(program);
166
+ registerBackupCommands(program);
167
+ program
168
+ .command('undo')
169
+ .description('Restore files from the most recent backup run (reversible)')
170
+ .option('--dry-run', 'Show what would be restored without writing')
171
+ .option('--yes', 'Skip the confirmation prompt')
172
+ .option('--no-backup', 'Skip backing up the current state before restoring (advanced)')
173
+ .action(async (opts) => {
174
+ // Commander's --no-backup convention sets opts.backup = false.
175
+ const { backup, ...rest } = opts;
176
+ await undo({ ...rest, noBackup: backup === false, json: program.opts().json });
177
+ });
172
178
  program.parse();
package/dist/lib/api.d.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  export declare function getApiUrl(): string;
2
2
  /** Thrown by apiFetch when the server returns a non-2xx response. Carries the
3
- * status code and the server-provided message so callers can branch on it
4
- * (e.g. the `endorse` command surfaces a friendly hint on the 403 returned by
5
- * the clone-required guard). */
3
+ * status code and the server-provided message so callers can branch on
4
+ * different HTTP error codes (404 vs 403 vs 5xx) with friendly hints. */
6
5
  export declare class ApiError extends Error {
7
6
  readonly status: number;
8
7
  constructor(status: number, message: string);
package/dist/lib/api.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { loadAuth } from './config.js';
2
- const DEFAULT_API_URL = 'https://botdocs.ai';
2
+ const DEFAULT_API_URL = 'https://www.botdocs.ai';
3
3
  export function getApiUrl() {
4
4
  return process.env.BOTDOCS_API_URL || DEFAULT_API_URL;
5
5
  }
6
6
  /** Thrown by apiFetch when the server returns a non-2xx response. Carries the
7
- * status code and the server-provided message so callers can branch on it
8
- * (e.g. the `endorse` command surfaces a friendly hint on the 403 returned by
9
- * the clone-required guard). */
7
+ * status code and the server-provided message so callers can branch on
8
+ * different HTTP error codes (404 vs 403 vs 5xx) with friendly hints. */
10
9
  export class ApiError extends Error {
11
10
  status;
12
11
  constructor(status, message) {
@@ -27,10 +26,13 @@ export async function apiFetch(path, options = {}) {
27
26
  }
28
27
  if (auth) {
29
28
  const config = loadAuth();
30
- if (!config?.githubToken) {
31
- throw new Error('Not authenticated. Run `botdocs login` first.');
29
+ if (!config?.token) {
30
+ // Shape this as an ApiError(401) so callers that already branch on auth
31
+ // failures (e.g. sync's optional team-skill path) handle it uniformly
32
+ // without a separate "no token saved" code path.
33
+ throw new ApiError(401, 'Not authenticated. Run `botdocs login` first.');
32
34
  }
33
- headers['Authorization'] = `Bearer ${config.githubToken}`;
35
+ headers['Authorization'] = `Bearer ${config.token}`;
34
36
  }
35
37
  const response = await fetch(url, {
36
38
  method,
@@ -47,6 +49,11 @@ export async function apiFetch(path, options = {}) {
47
49
  catch {
48
50
  message = text;
49
51
  }
52
+ // Friendlier 401 hint when the saved token is stale (e.g. left over from a
53
+ // previous CLI version that stored a GitHub access token).
54
+ if (response.status === 401 && auth) {
55
+ message = 'Authentication failed. Run `botdocs login` to sign in again.';
56
+ }
50
57
  throw new ApiError(response.status, message);
51
58
  }
52
59
  const contentType = response.headers.get('content-type') || '';
@@ -27,6 +27,52 @@ export function detectDestination(srcRelative, ctx) {
27
27
  dest: path.join(ctx.projectDir, '.cursor', 'rules', path.basename(src)),
28
28
  };
29
29
  }
30
+ if (src.startsWith('codex/')) {
31
+ return {
32
+ kind: 'project',
33
+ dest: path.join(ctx.projectDir, '.codex', 'skills', path.basename(src)),
34
+ };
35
+ }
36
+ if (src.startsWith('copilot/instructions/')) {
37
+ // GitHub Copilot custom instructions live in .github/instructions/
38
+ // (https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot).
39
+ return {
40
+ kind: 'project',
41
+ dest: path.join(ctx.projectDir, '.github', 'instructions', path.basename(src)),
42
+ };
43
+ }
44
+ if (src.startsWith('windsurf/rules/')) {
45
+ // Windsurf (Codeium) reads project rules from .codeium/windsurf-rules/
46
+ // (https://docs.codeium.com/windsurf/cascade#windsurfrules).
47
+ return {
48
+ kind: 'project',
49
+ dest: path.join(ctx.projectDir, '.codeium', 'windsurf-rules', path.basename(src)),
50
+ };
51
+ }
52
+ if (src.startsWith('gemini/instructions/')) {
53
+ // Gemini CLI reads global instructions from ~/.gemini/instructions/
54
+ // (https://github.com/google-gemini/gemini-cli).
55
+ return {
56
+ kind: 'global',
57
+ dest: path.join(ctx.homeDir, '.gemini', 'instructions', path.basename(src)),
58
+ };
59
+ }
60
+ if (src.startsWith('antigravity/skills/')) {
61
+ // Google Antigravity reads skills from ~/.gemini/antigravity/skills/
62
+ // (shares the gemini config tree).
63
+ return {
64
+ kind: 'global',
65
+ dest: path.join(ctx.homeDir, '.gemini', 'antigravity', 'skills', path.basename(src)),
66
+ };
67
+ }
68
+ if (src.startsWith('opencode/instructions/')) {
69
+ // OpenCode (SST) reads instructions from ~/.config/opencode/instructions/
70
+ // (https://github.com/sst/opencode).
71
+ return {
72
+ kind: 'global',
73
+ dest: path.join(ctx.homeDir, '.config', 'opencode', 'instructions', path.basename(src)),
74
+ };
75
+ }
30
76
  if (src.startsWith('chatgpt/')) {
31
77
  return { kind: 'manual', dest: src };
32
78
  }