@botdocs/cli 0.8.0 → 0.9.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.
- package/dist/commands/ingest.d.ts +4 -0
- package/dist/commands/ingest.js +69 -19
- package/dist/commands/views/ingest-discover-app.js +6 -20
- package/dist/index.js +1 -0
- package/dist/lib/api.d.ts +9 -2
- package/dist/lib/api.js +16 -3
- package/dist/lib/ingest-discover.d.ts +7 -0
- package/dist/lib/ingest-discover.js +2 -0
- package/package.json +1 -1
|
@@ -11,6 +11,10 @@ interface IngestOptions {
|
|
|
11
11
|
/** Force the plain-text rendering path — disables the Ink TUI. Mirrors the
|
|
12
12
|
* `--no-ink` flag on `login` and `sync`. */
|
|
13
13
|
noInk?: boolean;
|
|
14
|
+
/** Replace existing *draft* skills that collide on slug. Published skills
|
|
15
|
+
* are never replaced — they surface as a blocking 409 either way. Set via
|
|
16
|
+
* `--force`. */
|
|
17
|
+
force?: boolean;
|
|
14
18
|
}
|
|
15
19
|
/** Per-file size cap. Any file larger than this is skipped with a warning. */
|
|
16
20
|
export declare const PER_FILE_BYTE_CAP: number;
|
package/dist/commands/ingest.js
CHANGED
|
@@ -2,7 +2,8 @@ import React from 'react';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { render } from 'ink';
|
|
5
|
-
import { apiFetch } from '../lib/api.js';
|
|
5
|
+
import { ApiError, apiFetch } from '../lib/api.js';
|
|
6
|
+
import { loadAuth } from '../lib/config.js';
|
|
6
7
|
import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
|
|
7
8
|
import { IngestDiscoverApp } from './views/ingest-discover-app.js';
|
|
8
9
|
// ---------- Cap + skip rules (shared with discovery) ----------
|
|
@@ -452,15 +453,72 @@ function groupDiscoveredIntoSkills(discovered) {
|
|
|
452
453
|
}
|
|
453
454
|
return [...grouped.values()];
|
|
454
455
|
}
|
|
456
|
+
/** Narrow an unknown ApiError body to the ingest 409 `{ conflicts }` shape. */
|
|
457
|
+
function parseConflicts(body) {
|
|
458
|
+
if (typeof body !== 'object' || body === null)
|
|
459
|
+
return null;
|
|
460
|
+
const conflicts = body.conflicts;
|
|
461
|
+
if (typeof conflicts !== 'object' || conflicts === null)
|
|
462
|
+
return null;
|
|
463
|
+
const { replaceable, blocking } = conflicts;
|
|
464
|
+
if (!Array.isArray(replaceable) || !Array.isArray(blocking))
|
|
465
|
+
return null;
|
|
466
|
+
return {
|
|
467
|
+
replaceable: replaceable.filter((s) => typeof s === 'string'),
|
|
468
|
+
blocking: blocking.filter((s) => typeof s === 'string'),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** Render the delete-hint ref for a slug. Uses the logged-in username when
|
|
472
|
+
* it's readily available (so the user can copy-paste `botdocs delete
|
|
473
|
+
* @me/slug`); falls back to the bare slug otherwise. */
|
|
474
|
+
function deleteRef(slug) {
|
|
475
|
+
const username = loadAuth()?.username;
|
|
476
|
+
return username ? `@${username}/${slug}` : slug;
|
|
477
|
+
}
|
|
478
|
+
/** Print an actionable 409 message and exit non-zero. Honors `--json` by
|
|
479
|
+
* emitting the structured `{ ok: false, conflicts }` shape instead of prose. */
|
|
480
|
+
function reportConflicts(conflicts, options) {
|
|
481
|
+
if (options.json) {
|
|
482
|
+
console.log(JSON.stringify({ ok: false, conflicts }));
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
if (conflicts.blocking.length > 0) {
|
|
486
|
+
const refs = conflicts.blocking.map((s) => `botdocs delete ${deleteRef(s)}`).join(', ');
|
|
487
|
+
console.error(`\n ✗ These skills are already published and won't be replaced: ${conflicts.blocking.join(', ')}. ` +
|
|
488
|
+
`Unpublish or delete them first (${refs}), then re-ingest.`);
|
|
489
|
+
}
|
|
490
|
+
if (conflicts.replaceable.length > 0) {
|
|
491
|
+
console.error(`\n ✗ These skills already exist as drafts: ${conflicts.replaceable.join(', ')}. ` +
|
|
492
|
+
`Re-run with --force to replace them, or delete them first.`);
|
|
493
|
+
}
|
|
494
|
+
console.error('');
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
455
497
|
/** POST the grouped skills to the ingest endpoint. Honors `--json` (emit raw
|
|
456
|
-
* response) and the default human-friendly "drafts created" line.
|
|
457
|
-
*
|
|
498
|
+
* response) and the default human-friendly "drafts created" line. On a 409
|
|
499
|
+
* slug-conflict, prints an actionable message and exits non-zero rather than
|
|
500
|
+
* letting the ApiError bubble up as a stack trace. Returns nothing on success. */
|
|
458
501
|
async function uploadSkills(skills, options) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
502
|
+
let result;
|
|
503
|
+
try {
|
|
504
|
+
result = await apiFetch('/api/cli/ingest', {
|
|
505
|
+
method: 'POST',
|
|
506
|
+
auth: true,
|
|
507
|
+
body: {
|
|
508
|
+
skills,
|
|
509
|
+
bundle: options.bundle ? { name: options.bundle } : undefined,
|
|
510
|
+
force: options.force,
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
if (err instanceof ApiError && err.status === 409) {
|
|
516
|
+
const conflicts = parseConflicts(err.body);
|
|
517
|
+
if (conflicts)
|
|
518
|
+
reportConflicts(conflicts, options);
|
|
519
|
+
}
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
464
522
|
if (options.json) {
|
|
465
523
|
console.log(JSON.stringify(result));
|
|
466
524
|
return;
|
|
@@ -471,17 +529,9 @@ async function uploadSkills(skills, options) {
|
|
|
471
529
|
* sees as a checkbox row in the TUI / a bullet in plain-text output. Adjacent
|
|
472
530
|
* files (scripts/, templates/, etc.) ride along with the root's selection. */
|
|
473
531
|
function isRoot(file) {
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
return true;
|
|
478
|
-
// Flat ecosystems have no adjacent sweep today, so every file IS a root.
|
|
479
|
-
// Detect by checking that the canonical filename matches the simple
|
|
480
|
-
// `<prefix>/<slug>.<ext>` shape with no extra path segment.
|
|
481
|
-
// (A claude-code-agents single-file would also match here — but we only
|
|
482
|
-
// produce AGENT.md as the root for nested layouts, so flat .md under
|
|
483
|
-
// claude-code/agents/ is treated as a root.)
|
|
484
|
-
return !cf.includes('/scripts/') && !cf.includes('/templates/') && !cf.includes('/reference/');
|
|
532
|
+
// `isRoot` is set authoritatively at discovery time (rootFile = true,
|
|
533
|
+
// swept adjacent files = false), so there's no filename guessing here.
|
|
534
|
+
return file.isRoot;
|
|
485
535
|
}
|
|
486
536
|
/** Filter a discovery file list down to the set the `--auto` / stub filter
|
|
487
537
|
* would actually upload: only ROOT files smaller than the stub threshold are
|
|
@@ -3,27 +3,13 @@ import { useMemo, useState } from 'react';
|
|
|
3
3
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
4
|
import { theme } from './theme.js';
|
|
5
5
|
import { ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, } from '../../lib/ingest-discover.js';
|
|
6
|
-
/** A file is a "root" — selectable at the skill level —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
/** A file is a "root" — selectable at the skill level — when discovery marked
|
|
7
|
+
* it as the skill's primary file. Root files are the only ones surfaced in the
|
|
8
|
+
* TUI; adjacent files (scripts/, templates/) ride along when their skill is
|
|
9
|
+
* toggled. The `isRoot` flag is set authoritatively in `discoverSkills`, so
|
|
10
|
+
* the renderer never has to guess from the filename. */
|
|
10
11
|
function isRootFile(file) {
|
|
11
|
-
|
|
12
|
-
// claude/<slug>/SKILL.md or claude-code/agents/<slug>/AGENT.md are nested
|
|
13
|
-
// root files; everything else is flat (filename matches canonical exactly
|
|
14
|
-
// and ends with the ecosystem's primary extension).
|
|
15
|
-
if (cf.endsWith('/SKILL.md'))
|
|
16
|
-
return true;
|
|
17
|
-
if (cf.endsWith('/AGENT.md'))
|
|
18
|
-
return true;
|
|
19
|
-
// For flat ecosystems (claude-code commands, cursor rules, etc.) there's
|
|
20
|
-
// no adjacent sweep today, so every discovered file IS a root.
|
|
21
|
-
if (!cf.endsWith('/SKILL.md') && !cf.endsWith('/AGENT.md')) {
|
|
22
|
-
// Heuristic: if this file's slug + scope appears exactly once in the
|
|
23
|
-
// discovery, treat it as the root.
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
return false;
|
|
12
|
+
return file.isRoot;
|
|
27
13
|
}
|
|
28
14
|
/** Group files by ecosystem (preserving order) and emit a flat row list with
|
|
29
15
|
* one section header per ecosystem followed by its skill rows. Empty
|
package/dist/index.js
CHANGED
|
@@ -160,6 +160,7 @@ program
|
|
|
160
160
|
.option('--dry-run', 'Show what would be ingested without uploading')
|
|
161
161
|
.option('--from-tool <ecosystem>', `Treat every file in the path as belonging to a single ecosystem (one of: ${INGEST_SUPPORTED_TOOLS.join(', ')}). Useful when ingesting directly from ~/.claude/commands/, .cursor/rules/, etc.`)
|
|
162
162
|
.option('--auto', 'Skip the interactive selection and ingest everything discovery finds (zero-arg mode only)')
|
|
163
|
+
.option('--force', 'Replace existing draft skills with the same name (won\'t touch published skills)')
|
|
163
164
|
.option('--no-ink', 'Disable the interactive TUI; use plain output (zero-arg mode only)')
|
|
164
165
|
.action(async (sourcePath, opts) => {
|
|
165
166
|
// Commander's --no-ink convention sets opts.ink = false; flip to noInk
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
export declare function getApiUrl(): string;
|
|
2
2
|
/** Thrown by apiFetch when the server returns a non-2xx response. Carries the
|
|
3
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.
|
|
4
|
+
* different HTTP error codes (404 vs 403 vs 5xx) with friendly hints.
|
|
5
|
+
*
|
|
6
|
+
* `body` holds the parsed JSON error payload when the server returned one
|
|
7
|
+
* (e.g. ingest's `{ error, conflicts }`). It's `undefined` when the response
|
|
8
|
+
* body wasn't JSON. Callers that need structured fields (not just the
|
|
9
|
+
* `message` string) read it — most callers ignore it and the existing
|
|
10
|
+
* `status` + `message` branching keeps working unchanged. */
|
|
5
11
|
export declare class ApiError extends Error {
|
|
6
12
|
readonly status: number;
|
|
7
|
-
|
|
13
|
+
readonly body?: unknown;
|
|
14
|
+
constructor(status: number, message: string, body?: unknown);
|
|
8
15
|
}
|
|
9
16
|
interface FetchOptions {
|
|
10
17
|
method?: string;
|
package/dist/lib/api.js
CHANGED
|
@@ -5,13 +5,21 @@ export function getApiUrl() {
|
|
|
5
5
|
}
|
|
6
6
|
/** Thrown by apiFetch when the server returns a non-2xx response. Carries the
|
|
7
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.
|
|
8
|
+
* different HTTP error codes (404 vs 403 vs 5xx) with friendly hints.
|
|
9
|
+
*
|
|
10
|
+
* `body` holds the parsed JSON error payload when the server returned one
|
|
11
|
+
* (e.g. ingest's `{ error, conflicts }`). It's `undefined` when the response
|
|
12
|
+
* body wasn't JSON. Callers that need structured fields (not just the
|
|
13
|
+
* `message` string) read it — most callers ignore it and the existing
|
|
14
|
+
* `status` + `message` branching keeps working unchanged. */
|
|
9
15
|
export class ApiError extends Error {
|
|
10
16
|
status;
|
|
11
|
-
|
|
17
|
+
body;
|
|
18
|
+
constructor(status, message, body) {
|
|
12
19
|
super(message);
|
|
13
20
|
this.name = 'ApiError';
|
|
14
21
|
this.status = status;
|
|
22
|
+
this.body = body;
|
|
15
23
|
}
|
|
16
24
|
}
|
|
17
25
|
export async function apiFetch(path, options = {}) {
|
|
@@ -42,8 +50,13 @@ export async function apiFetch(path, options = {}) {
|
|
|
42
50
|
if (!response.ok) {
|
|
43
51
|
const text = await response.text();
|
|
44
52
|
let message;
|
|
53
|
+
// `parsedBody` is the structured error payload when the server sent JSON
|
|
54
|
+
// (e.g. ingest's `{ error, conflicts }`). Left undefined for non-JSON
|
|
55
|
+
// bodies so callers can distinguish "no structured data" from "{}".
|
|
56
|
+
let parsedBody;
|
|
45
57
|
try {
|
|
46
58
|
const json = JSON.parse(text);
|
|
59
|
+
parsedBody = json;
|
|
47
60
|
message = json.error || text;
|
|
48
61
|
}
|
|
49
62
|
catch {
|
|
@@ -54,7 +67,7 @@ export async function apiFetch(path, options = {}) {
|
|
|
54
67
|
if (response.status === 401 && auth) {
|
|
55
68
|
message = 'Authentication failed. Run `botdocs login` to sign in again.';
|
|
56
69
|
}
|
|
57
|
-
throw new ApiError(response.status, message);
|
|
70
|
+
throw new ApiError(response.status, message, parsedBody);
|
|
58
71
|
}
|
|
59
72
|
const contentType = response.headers.get('content-type') || '';
|
|
60
73
|
if (contentType.includes('application/json')) {
|
|
@@ -36,6 +36,13 @@ export interface DiscoveredSkillFile {
|
|
|
36
36
|
/** Canonical filename for the upload payload — e.g.
|
|
37
37
|
* `claude-code/commands/foo.md`, `claude/<slug>/SKILL.md`, etc. */
|
|
38
38
|
canonicalFilename: string;
|
|
39
|
+
/** True for the skill's primary file (SKILL.md / AGENT.md / a flat .md),
|
|
40
|
+
* false for adjacent files swept in from the skill directory
|
|
41
|
+
* (scripts/, templates/, etc.). Set authoritatively at discovery time —
|
|
42
|
+
* the TUI and plain-text renderers list ONE row per skill by filtering to
|
|
43
|
+
* `isRoot`, and adjacent files ride along when their root is selected.
|
|
44
|
+
* Marking it here avoids fragile filename-suffix guessing downstream. */
|
|
45
|
+
isRoot: boolean;
|
|
39
46
|
}
|
|
40
47
|
/** Per-skill aggregate stats produced by discovery — surfaced in the TUI and
|
|
41
48
|
* the plain-text fallback so the user can see how big a skill is before
|
|
@@ -171,6 +171,7 @@ export function discoverSkills(options = {}) {
|
|
|
171
171
|
content,
|
|
172
172
|
mode: safeMode(abs),
|
|
173
173
|
canonicalFilename: detector.canonicalFilename(slug),
|
|
174
|
+
isRoot: true,
|
|
174
175
|
};
|
|
175
176
|
files.push(rootFile);
|
|
176
177
|
// Per-skill summary aggregates the root file + any adjacent sweep
|
|
@@ -205,6 +206,7 @@ export function discoverSkills(options = {}) {
|
|
|
205
206
|
content: adj.content,
|
|
206
207
|
mode: adj.mode,
|
|
207
208
|
canonicalFilename: detector.canonicalAdjacentFilename(slug, adj.relPath),
|
|
209
|
+
isRoot: false,
|
|
208
210
|
});
|
|
209
211
|
summary.totalFiles += 1;
|
|
210
212
|
summary.totalBytes += adj.sizeBytes;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botdocs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|