@botdocs/cli 0.8.1 → 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 +66 -8
- package/dist/index.js +1 -0
- package/dist/lib/api.d.ts +9 -2
- package/dist/lib/api.js +16 -3
- 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;
|
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')) {
|
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",
|