@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.
@@ -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;
@@ -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. Returns
457
- * nothing on success. */
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
- const result = await apiFetch('/api/cli/ingest', {
460
- method: 'POST',
461
- auth: true,
462
- body: { skills, bundle: options.bundle ? { name: options.bundle } : undefined },
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
- const cf = file.canonicalFilename;
475
- // Nested ecosystems use /SKILL.md or /AGENT.md as the root marker.
476
- if (cf.endsWith('/SKILL.md') || cf.endsWith('/AGENT.md'))
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 — if its canonical
7
- * filename matches the ecosystem's root pattern. Root files are the only
8
- * ones surfaced in the TUI; adjacent files (scripts/, templates/) ride
9
- * along when their skill is toggled. */
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
- const cf = file.canonicalFilename;
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
- constructor(status: number, message: string);
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
- constructor(status, message) {
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.8.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",