@botdocs/cli 0.5.0 → 0.8.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.
@@ -0,0 +1,271 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { DETECTORS, formatSweepWarnings, sweepSkillRoot, } from '../commands/ingest.js';
5
+ /** Tiny H1 extractor — the same regex `ingest.ts` uses today. Falls back to a
6
+ * Title-Cased slug when no H1 is present. */
7
+ export function titleFromContent(content, slug) {
8
+ const m = content.match(/^#\s+(.+)$/m);
9
+ return m?.[1]?.trim() ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
10
+ }
11
+ /** Walk up from `start` looking for a `.git` directory. Returns true if any
12
+ * ancestor is a git repo. Used to gate project-scoped scans (cursor, codex,
13
+ * copilot, windsurf) which only make sense for repos. */
14
+ export function isGitRepo(start) {
15
+ let dir = start;
16
+ // Guard against an infinite loop on platforms where dirname(/) === /
17
+ for (let i = 0; i < 64; i++) {
18
+ const candidate = path.join(dir, '.git');
19
+ try {
20
+ if (fs.existsSync(candidate))
21
+ return true;
22
+ }
23
+ catch {
24
+ // Permission denied on some ancestor — treat as not-a-repo and keep walking
25
+ }
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir)
28
+ return false;
29
+ dir = parent;
30
+ }
31
+ return false;
32
+ }
33
+ /** Build the per-row scan table for a given home + cwd by fanning out the
34
+ * shared `DETECTORS.<ecosystem>.scanPaths()` results. Project rows are
35
+ * naturally absent when `cwd` is outside a git repo because each project
36
+ * detector returns `[]` for that case.
37
+ *
38
+ * Split out as its own exported function so tests can assert the row shape
39
+ * without spinning up a fixture tree on disk. */
40
+ export function buildDetectors(homeDir, cwd) {
41
+ const inRepo = isGitRepo(cwd);
42
+ const rows = [];
43
+ for (const [ecosystem, detector] of Object.entries(DETECTORS)) {
44
+ const paths = detector.scanPaths(homeDir, cwd, inRepo);
45
+ if (paths.length === 0)
46
+ continue;
47
+ // Anything under homeDir is "global"; anything else is "project". This
48
+ // mirrors how each detector's scanPaths was authored — global paths use
49
+ // homeDir, project paths use projectRoot.
50
+ for (const dir of paths) {
51
+ const kind = dir.startsWith(homeDir) ? 'global' : 'project';
52
+ rows.push({
53
+ ecosystem: ecosystem,
54
+ kind,
55
+ dir,
56
+ recursive: detector.nested,
57
+ });
58
+ }
59
+ }
60
+ return rows;
61
+ }
62
+ /** Recursively walk a directory collecting absolute paths. Returns an empty
63
+ * list if the directory doesn't exist. Caller decides how to filter results. */
64
+ function walkAll(dir, recursive) {
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(dir, { withFileTypes: true });
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ const out = [];
73
+ for (const entry of entries) {
74
+ const abs = path.join(dir, entry.name);
75
+ if (entry.isDirectory()) {
76
+ if (!recursive)
77
+ continue;
78
+ // Recurse but skip noisy dirs (node_modules etc. are unlikely under tool
79
+ // config trees but cheap to defend against).
80
+ if (entry.name === 'node_modules' || entry.name === '.git')
81
+ continue;
82
+ out.push(...walkAll(abs, true));
83
+ }
84
+ else if (entry.isFile()) {
85
+ out.push(abs);
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+ /** For nested ecosystems, the scope is the directory segment immediately
91
+ * under the scan directory that contains the slug directory. For unscoped
92
+ * layouts (`<scanDir>/<slug>/SKILL.md`), the parent of the slug dir IS the
93
+ * scan dir and there's no scope. Returns undefined when no scope applies. */
94
+ function scopeForNested(absPath, scanDir) {
95
+ // absPath looks like `<scanDir>/.../<scope?>/<slug>/SKILL.md`. The slug dir
96
+ // is the parent; the scope dir (if any) is the grandparent.
97
+ const slugDir = path.dirname(absPath);
98
+ const candidateScopeDir = path.dirname(slugDir);
99
+ if (candidateScopeDir === scanDir)
100
+ return undefined;
101
+ return path.basename(candidateScopeDir);
102
+ }
103
+ /** Key for the per-skill summary map. Stays stable across discoveries so the
104
+ * TUI can look up a summary by row. */
105
+ export function summaryKey(file) {
106
+ return `${file.ecosystem}::${file.scope ?? ''}/${file.slug}`;
107
+ }
108
+ /** Stat helper: return st_mode masked to the low 9 perm bits, or 0o644 on
109
+ * failure. Mirrors the same helper in commands/ingest.ts. */
110
+ function safeMode(absPath) {
111
+ try {
112
+ return fs.statSync(absPath).mode & 0o777;
113
+ }
114
+ catch {
115
+ return 0o644;
116
+ }
117
+ }
118
+ /** Scan all known on-disk locations and return a flat list of discoveries.
119
+ * Reads file contents synchronously — discovery is one-shot and typically
120
+ * touches a handful of small files, so the simplicity wins over async sprawl.
121
+ *
122
+ * Single source of truth for which ecosystems we know about is the shared
123
+ * `DETECTORS` table in `commands/ingest.ts`. Each detector's `scanPaths`
124
+ * decides where to look on disk, `slugFor` extracts the slug, and
125
+ * `canonicalFilename` produces the upload-shaped filename. */
126
+ export function discoverSkills(options = {}) {
127
+ const homeDir = options.homeDir ?? os.homedir();
128
+ const cwd = options.cwd ?? process.cwd();
129
+ const rows = buildDetectors(homeDir, cwd);
130
+ const files = [];
131
+ const skillSummaries = new Map();
132
+ const seen = new Set();
133
+ const empty = new Set();
134
+ for (const row of rows) {
135
+ const detector = DETECTORS[row.ecosystem];
136
+ const candidates = walkAll(row.dir, row.recursive);
137
+ // Filter to files this detector would accept. We pass the scan dir as the
138
+ // "root" so `slugFor` can do path-relative work — same convention the
139
+ // path-based ingest path uses, with `scanDir` standing in for the source
140
+ // tree root.
141
+ const matched = candidates.filter((abs) => detector.extensions.some((ext) => abs.endsWith(ext)));
142
+ if (matched.length === 0) {
143
+ if (!seen.has(row.ecosystem))
144
+ empty.add(row.ecosystem);
145
+ continue;
146
+ }
147
+ for (const abs of matched) {
148
+ const slug = detector.slugFor(abs, row.dir);
149
+ if (!slug)
150
+ continue;
151
+ let content;
152
+ try {
153
+ content = fs.readFileSync(abs, 'utf-8');
154
+ }
155
+ catch {
156
+ // Unreadable file — skip silently. Discovery should never throw on
157
+ // permission errors; users with unusual setups still get the rest.
158
+ continue;
159
+ }
160
+ const scope = detector.nested ? scopeForNested(abs, row.dir) : undefined;
161
+ const sizeBytes = Buffer.byteLength(content, 'utf-8');
162
+ const lineCount = content.length === 0 ? 0 : content.split(/\r?\n/).length;
163
+ const rootFile = {
164
+ ecosystem: row.ecosystem,
165
+ sourcePath: abs,
166
+ slug,
167
+ scope,
168
+ title: titleFromContent(content, slug),
169
+ sizeBytes,
170
+ lineCount,
171
+ content,
172
+ mode: safeMode(abs),
173
+ canonicalFilename: detector.canonicalFilename(slug),
174
+ };
175
+ files.push(rootFile);
176
+ // Per-skill summary aggregates the root file + any adjacent sweep
177
+ // outputs below. We seed it now and append adjacent files inline.
178
+ const sumKey = summaryKey(rootFile);
179
+ const summary = {
180
+ ecosystem: row.ecosystem,
181
+ scope,
182
+ slug,
183
+ totalFiles: 1,
184
+ totalBytes: sizeBytes,
185
+ warnings: [],
186
+ };
187
+ skillSummaries.set(sumKey, summary);
188
+ // Adjacent-file sweep for detectors that opt in (claude, claude-code-agents).
189
+ if (detector.includeAdjacent && detector.skillRoot && detector.canonicalAdjacentFilename) {
190
+ const skillRoot = detector.skillRoot(abs);
191
+ const sweep = sweepSkillRoot(skillRoot, abs);
192
+ for (const adj of sweep.files) {
193
+ files.push({
194
+ ecosystem: row.ecosystem,
195
+ sourcePath: adj.absPath,
196
+ slug,
197
+ scope,
198
+ title: rootFile.title,
199
+ // sizeBytes / lineCount on adjacent rows describe the adjacent
200
+ // file itself — not the skill total. The TUI uses sizeBytes on
201
+ // the ROOT row only for the stub filter; per-skill totals come
202
+ // from the summary map.
203
+ sizeBytes: adj.sizeBytes,
204
+ lineCount: adj.content.length === 0 ? 0 : adj.content.split(/\r?\n/).length,
205
+ content: adj.content,
206
+ mode: adj.mode,
207
+ canonicalFilename: detector.canonicalAdjacentFilename(slug, adj.relPath),
208
+ });
209
+ summary.totalFiles += 1;
210
+ summary.totalBytes += adj.sizeBytes;
211
+ }
212
+ summary.warnings = sweep.warnings;
213
+ }
214
+ seen.add(row.ecosystem);
215
+ empty.delete(row.ecosystem);
216
+ }
217
+ }
218
+ // Sort stable: by ecosystem, then by display label (scope/slug), then by
219
+ // canonical filename so adjacent files cluster behind their root file.
220
+ files.sort((a, b) => {
221
+ if (a.ecosystem !== b.ecosystem)
222
+ return a.ecosystem.localeCompare(b.ecosystem);
223
+ const aLabel = a.scope ? `${a.scope}/${a.slug}` : a.slug;
224
+ const bLabel = b.scope ? `${b.scope}/${b.slug}` : b.slug;
225
+ if (aLabel !== bLabel)
226
+ return aLabel.localeCompare(bLabel);
227
+ return a.canonicalFilename.localeCompare(b.canonicalFilename);
228
+ });
229
+ return {
230
+ files,
231
+ skillSummaries,
232
+ emptyEcosystems: [...empty].sort(),
233
+ inGitRepo: isGitRepo(cwd),
234
+ };
235
+ }
236
+ /** Re-export the warning formatter so callers (TUI, plain-text) can share
237
+ * one rendering. */
238
+ export { formatSweepWarnings };
239
+ /** Human-readable label per ecosystem for the TUI section header. */
240
+ export function ecosystemLabel(ecosystem) {
241
+ switch (ecosystem) {
242
+ case 'claude-code':
243
+ return 'Claude Code';
244
+ case 'claude-code-agents':
245
+ return 'Claude Code agents';
246
+ case 'claude':
247
+ return 'Claude skills';
248
+ case 'cursor':
249
+ return 'Cursor rules';
250
+ case 'codex':
251
+ return 'Codex';
252
+ case 'copilot':
253
+ return 'GitHub Copilot';
254
+ case 'windsurf':
255
+ return 'Windsurf';
256
+ case 'gemini':
257
+ return 'Gemini CLI';
258
+ case 'antigravity':
259
+ return 'Antigravity';
260
+ case 'opencode':
261
+ return 'OpenCode';
262
+ }
263
+ }
264
+ /** Format a byte count as "0.1 KB" / "2.4 KB" etc. — matches the spec sample.
265
+ * Single decimal place keeps narrow rows aligned in the TUI. */
266
+ export function formatBytes(n) {
267
+ return `${(n / 1024).toFixed(1)} KB`;
268
+ }
269
+ /** Files smaller than this are unchecked by default (likely stub files the
270
+ * user hasn't filled in yet). 100 bytes ≈ a one-line H1 plus a blank line. */
271
+ export const STUB_BYTE_THRESHOLD = 100;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Parse a BotDoc ref like `@user/slug` or `user/slug` into its parts.
3
+ *
4
+ * Mirrors the inline `parseRef` already in `commands/install.ts` and
5
+ * `commands/edit.ts` — kept here for reuse by `publish`/`unpublish`.
6
+ * The existing duplicates can be folded into this helper in a future
7
+ * refactor without behavior change.
8
+ *
9
+ * Throws on a malformed input so callers can surface a clear message
10
+ * instead of silently dispatching a malformed API request.
11
+ */
12
+ export declare function parseRef(raw: string): {
13
+ username: string;
14
+ slug: string;
15
+ };
16
+ /**
17
+ * True when `source` looks like a BotDoc ref (e.g. `@me/my-skill` or
18
+ * `me/my-skill`), false when it looks like a local filesystem path.
19
+ *
20
+ * Used by `botdocs publish <source>` to overload one verb by argument
21
+ * shape — ref-form dispatches to the publish-toggle API, anything else
22
+ * goes through the existing file/dir/zip upload flow.
23
+ *
24
+ * Heuristic (lean toward "is a ref" only when unambiguous):
25
+ *
26
+ * - `@…` → ref. The leading `@` is unambiguous; no filesystem path
27
+ * starts with `@`.
28
+ * - Starts with `./`, `..`, or `/` → path. Absolute and explicit
29
+ * relative paths are never refs.
30
+ * - Contains a `.` (e.g. `foo.md`, `foo.zip`, `dir/file.md`) → path.
31
+ * File extensions are the strongest signal we're looking at a file.
32
+ * - Contains exactly one `/` with non-empty parts on either side → ref.
33
+ * `user/slug` and `org/repo`-style.
34
+ * - Otherwise → path. Bare names like `my-skill` resolve as directory
35
+ * paths under the existing upload flow.
36
+ *
37
+ * False positives (a real directory named `user/slug` with no `.`) will
38
+ * surface as a clean "BotDoc not found" 404 from the API — not silent
39
+ * data loss, just a clearer error than letting the upload flow choke
40
+ * on a non-existent path.
41
+ */
42
+ export declare function isRefForm(source: string): boolean;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Parse a BotDoc ref like `@user/slug` or `user/slug` into its parts.
3
+ *
4
+ * Mirrors the inline `parseRef` already in `commands/install.ts` and
5
+ * `commands/edit.ts` — kept here for reuse by `publish`/`unpublish`.
6
+ * The existing duplicates can be folded into this helper in a future
7
+ * refactor without behavior change.
8
+ *
9
+ * Throws on a malformed input so callers can surface a clear message
10
+ * instead of silently dispatching a malformed API request.
11
+ */
12
+ export function parseRef(raw) {
13
+ const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
14
+ const parts = cleaned.split('/');
15
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
16
+ throw new Error(`Invalid ref: ${raw} (expected @user/slug)`);
17
+ }
18
+ return { username: parts[0], slug: parts[1] };
19
+ }
20
+ /**
21
+ * True when `source` looks like a BotDoc ref (e.g. `@me/my-skill` or
22
+ * `me/my-skill`), false when it looks like a local filesystem path.
23
+ *
24
+ * Used by `botdocs publish <source>` to overload one verb by argument
25
+ * shape — ref-form dispatches to the publish-toggle API, anything else
26
+ * goes through the existing file/dir/zip upload flow.
27
+ *
28
+ * Heuristic (lean toward "is a ref" only when unambiguous):
29
+ *
30
+ * - `@…` → ref. The leading `@` is unambiguous; no filesystem path
31
+ * starts with `@`.
32
+ * - Starts with `./`, `..`, or `/` → path. Absolute and explicit
33
+ * relative paths are never refs.
34
+ * - Contains a `.` (e.g. `foo.md`, `foo.zip`, `dir/file.md`) → path.
35
+ * File extensions are the strongest signal we're looking at a file.
36
+ * - Contains exactly one `/` with non-empty parts on either side → ref.
37
+ * `user/slug` and `org/repo`-style.
38
+ * - Otherwise → path. Bare names like `my-skill` resolve as directory
39
+ * paths under the existing upload flow.
40
+ *
41
+ * False positives (a real directory named `user/slug` with no `.`) will
42
+ * surface as a clean "BotDoc not found" 404 from the API — not silent
43
+ * data loss, just a clearer error than letting the upload flow choke
44
+ * on a non-existent path.
45
+ */
46
+ export function isRefForm(source) {
47
+ if (!source)
48
+ return false;
49
+ if (source.startsWith('@'))
50
+ return true;
51
+ if (source.startsWith('./') || source.startsWith('../') || source.startsWith('/')) {
52
+ return false;
53
+ }
54
+ if (source.includes('.'))
55
+ return false;
56
+ const parts = source.split('/');
57
+ if (parts.length !== 2)
58
+ return false;
59
+ return Boolean(parts[0] && parts[1]);
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botdocs/cli",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
5
5
  "keywords": [
6
6
  "botdocs",
@@ -119,7 +119,8 @@ the user and recommend they set one.
119
119
  | `init [name]` | Scaffold a new skill directory. |
120
120
  | `validate <source>` | Pre-publish structural check. |
121
121
  | `search <query>` | Search the registry. |
122
- | `publish <source>` | Publish from a file, directory, or zip. |
122
+ | `publish <source>` | Publish from a file, directory, or zip — or pass `@user/slug` to mark an existing draft live. |
123
+ | `unpublish <ref>` | Hide a published BotDoc from `/explore` (sets the `draft` flag back; prompts unless `--yes`). |
123
124
  | `install <ref>` | Install a skill or bundle (auto-detects destinations). |
124
125
  | `sync [ref]` | Apply available updates to installed skills. |
125
126
  | `uninstall <ref>` | Remove an installed skill or bundle. |
@@ -129,7 +130,7 @@ the user and recommend they set one.
129
130
  | `backups list / restore / diff / clear` | Browse and manage backup runs. |
130
131
  | `compile <path>` | Generate per-ecosystem drafts from a canonical source (BYOK). |
131
132
  | `edit <ref>` | LLM-assisted revision of a published skill ecosystem file (BYOK). |
132
- | `ingest <path>` | Walk a directory, detect existing skills across all 10 ecosystems, upload as drafts. Use `--from-tool=<ecosystem>` to ingest from real on-disk locations like `~/.claude/commands/` or `.cursor/rules/`. |
133
+ | `ingest [path]` | Scan your system (zero-arg) or a directory, detect existing skills across all 10 ecosystems, upload as drafts. Use `--from-tool=<ecosystem>` for real on-disk locations like `~/.claude/commands/`; pass `--auto` to skip the interactive selection. |
133
134
  | `team list` | List teams you belong to. |
134
135
  | `team show <slug>` | Members + pinned skills for a team. |
135
136
  | `team push <slug> <ref>` | Pin a skill to a team (WRITE+). |