@botdocs/cli 0.6.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.
@@ -1,10 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import AdmZip from 'adm-zip';
4
- import { apiFetch } from '../lib/api.js';
4
+ import { ApiError, apiFetch } from '../lib/api.js';
5
5
  import { parseManifest } from '../lib/manifest.js';
6
6
  import { compile } from './compile.js';
7
7
  import { ecosystemDestination } from '../lib/canonical.js';
8
+ import { isRefForm, parseRef } from '../lib/ref.js';
8
9
  const VALID_CATEGORIES = [
9
10
  'KNOWLEDGE_MANAGEMENT',
10
11
  'DEV_WORKFLOW',
@@ -15,6 +16,16 @@ const VALID_CATEGORIES = [
15
16
  ];
16
17
  const VALID_LICENSES = ['MIT', 'CC_BY_4_0', 'CC_BY_SA_4_0', 'CC0', 'ALL_RIGHTS_RESERVED'];
17
18
  export async function publish(source, options) {
19
+ // Ref-form (e.g. "@user/slug" or "user/slug") → toggle the published
20
+ // flag via the API. Path-form continues to the existing upload flow
21
+ // below. Overloading by argument shape (rather than introducing
22
+ // `botdocs publish-ref` or a `--ref` flag) keeps the verb intuitive:
23
+ // a user typing `botdocs publish @me/foo` doesn't have to know it's a
24
+ // different code path under the hood.
25
+ if (isRefForm(source)) {
26
+ await publishRef(source, options);
27
+ return;
28
+ }
18
29
  const resolved = path.resolve(source);
19
30
  if (!fs.existsSync(resolved)) {
20
31
  console.error(`Source not found: ${source}`);
@@ -92,6 +103,79 @@ export async function publish(source, options) {
92
103
  console.log(`\nPublished: ${result.url}`);
93
104
  }
94
105
  }
106
+ /**
107
+ * Toggle an existing BotDoc from draft → published via the API.
108
+ *
109
+ * Called from `publish()` when the source argument looks like a ref
110
+ * (`@user/slug` or `user/slug`) instead of a local path. Strips the
111
+ * `draft` and `ingest:<id>` tags server-side. No prompt — moving from
112
+ * draft to published is the natural progression, and unpublishing a
113
+ * mistake is one CLI call away.
114
+ */
115
+ async function publishRef(rawRef, options) {
116
+ let parsed;
117
+ try {
118
+ parsed = parseRef(rawRef);
119
+ }
120
+ catch (err) {
121
+ console.error(err instanceof Error ? err.message : String(err));
122
+ process.exit(1);
123
+ }
124
+ const { username, slug } = parsed;
125
+ const refLabel = `@${username}/${slug}`;
126
+ try {
127
+ await apiFetch(`/api/botdocs/${username}/${slug}/publish`, {
128
+ method: 'POST',
129
+ auth: true,
130
+ });
131
+ }
132
+ catch (err) {
133
+ handlePublishToggleError(err, refLabel, options);
134
+ return;
135
+ }
136
+ if (options.json) {
137
+ console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'published' }));
138
+ }
139
+ else {
140
+ console.log(`✓ Published ${refLabel} — visible at https://botdocs.ai/${refLabel}`);
141
+ }
142
+ }
143
+ /**
144
+ * Map an `ApiError` from a publish/unpublish call to a friendly,
145
+ * actionable message. Shared by both verbs so the wording stays in sync.
146
+ *
147
+ * 401: most likely the user isn't logged in (or their saved token is
148
+ * stale). The api lib already produces a helpful "run `botdocs login`"
149
+ * hint — pass it through.
150
+ *
151
+ * 403: authenticated as someone who doesn't own this BotDoc.
152
+ *
153
+ * 404: BotDoc doesn't exist (or is owned by a different user — the API
154
+ * returns 404 rather than 403 to avoid leaking existence to non-authors).
155
+ */
156
+ function handlePublishToggleError(err, refLabel, options) {
157
+ if (!(err instanceof ApiError)) {
158
+ throw err;
159
+ }
160
+ if (options.json) {
161
+ console.log(JSON.stringify({ ok: false, ref: refLabel, status: err.status, error: err.message }));
162
+ process.exit(1);
163
+ }
164
+ if (err.status === 401) {
165
+ console.error(`\n ✗ ${err.message}\n`);
166
+ }
167
+ else if (err.status === 403) {
168
+ console.error(`\n ✗ You don't own ${refLabel}.\n`);
169
+ }
170
+ else if (err.status === 404) {
171
+ console.error(`\n ✗ BotDoc not found: ${refLabel}\n`);
172
+ }
173
+ else {
174
+ console.error(`\n ✗ ${err.message}\n`);
175
+ }
176
+ process.exit(1);
177
+ }
178
+ export { publishRef, handlePublishToggleError };
95
179
  function readManifest(source) {
96
180
  const manifestPath = path.join(source, 'botdocs.json');
97
181
  if (!fs.existsSync(manifestPath))
@@ -0,0 +1,16 @@
1
+ interface UnpublishOptions {
2
+ yes?: boolean;
3
+ json?: boolean;
4
+ }
5
+ /**
6
+ * Set the `draft` flag back on a published BotDoc, hiding it from
7
+ * `/explore` and 404ing the public URL for everyone except the author.
8
+ *
9
+ * Idempotent server-side — calling unpublish on something already a
10
+ * draft is a no-op. Prompts for confirmation by default because the
11
+ * effect is visible to anyone who had the URL bookmarked; skip with
12
+ * `--yes` for scripting/CI. `--json` implies `--yes` so machine-driven
13
+ * callers don't deadlock on the prompt.
14
+ */
15
+ export declare function unpublish(rawRef: string, options: UnpublishOptions): Promise<void>;
16
+ export {};
@@ -0,0 +1,53 @@
1
+ import * as p from '@clack/prompts';
2
+ import { apiFetch } from '../lib/api.js';
3
+ import { parseRef } from '../lib/ref.js';
4
+ import { handlePublishToggleError } from './publish.js';
5
+ /**
6
+ * Set the `draft` flag back on a published BotDoc, hiding it from
7
+ * `/explore` and 404ing the public URL for everyone except the author.
8
+ *
9
+ * Idempotent server-side — calling unpublish on something already a
10
+ * draft is a no-op. Prompts for confirmation by default because the
11
+ * effect is visible to anyone who had the URL bookmarked; skip with
12
+ * `--yes` for scripting/CI. `--json` implies `--yes` so machine-driven
13
+ * callers don't deadlock on the prompt.
14
+ */
15
+ export async function unpublish(rawRef, options) {
16
+ let parsed;
17
+ try {
18
+ parsed = parseRef(rawRef);
19
+ }
20
+ catch (err) {
21
+ console.error(err instanceof Error ? err.message : String(err));
22
+ process.exit(1);
23
+ }
24
+ const { username, slug } = parsed;
25
+ const refLabel = `@${username}/${slug}`;
26
+ if (!options.yes && !options.json) {
27
+ const confirmed = await p.confirm({
28
+ message: `Unpublish ${refLabel}? It will be hidden from /explore and the public URL ` +
29
+ `will 404 for everyone except you.`,
30
+ initialValue: false,
31
+ });
32
+ if (p.isCancel(confirmed) || !confirmed) {
33
+ console.log(' Cancelled.\n');
34
+ return;
35
+ }
36
+ }
37
+ try {
38
+ await apiFetch(`/api/botdocs/${username}/${slug}/unpublish`, {
39
+ method: 'POST',
40
+ auth: true,
41
+ });
42
+ }
43
+ catch (err) {
44
+ handlePublishToggleError(err, refLabel, options);
45
+ return;
46
+ }
47
+ if (options.json) {
48
+ console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'draft' }));
49
+ }
50
+ else {
51
+ console.log(`✓ Unpublished ${refLabel} — now hidden from /explore.`);
52
+ }
53
+ }
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { type DiscoveredSkillFile, type DiscoveryEcosystem } from '../../lib/ingest-discover.js';
2
+ import { type DiscoveredSkillFile, type DiscoveredSkillSummary, type DiscoveryEcosystem } from '../../lib/ingest-discover.js';
3
3
  /** The outcome of one TUI session. `null` selections means the user cancelled
4
4
  * via `q`/Esc; an empty array means they hit enter with everything unchecked
5
5
  * (the runner stays on screen — the parent never receives an empty array). */
@@ -11,6 +11,11 @@ export type IngestDiscoverResult = {
11
11
  };
12
12
  export interface IngestDiscoverAppProps {
13
13
  files: DiscoveredSkillFile[];
14
+ /** Per-skill aggregate stats from discovery — count, total size, sweep
15
+ * warnings. Keyed by `summaryKey(file)` so the row renderer can look up
16
+ * a skill's aggregate from the root row alone. Optional for backwards
17
+ * compatibility with tests that don't compute summaries. */
18
+ skillSummaries?: Map<string, DiscoveredSkillSummary>;
14
19
  /** Ecosystems we scanned but found nothing for. Rendered as a single
15
20
  * trailing "Other tools: ..." line so the user can see we looked. */
16
21
  emptyEcosystems: DiscoveryEcosystem[];
@@ -2,27 +2,60 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useState } from 'react';
3
3
  import { Box, Text, useApp, useInput } from 'ink';
4
4
  import { theme } from './theme.js';
5
- import { ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, } from '../../lib/ingest-discover.js';
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. */
10
+ 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;
27
+ }
6
28
  /** Group files by ecosystem (preserving order) and emit a flat row list with
7
- * one section header per ecosystem followed by its files. Empty ecosystems
8
- * are surfaced separately as a trailing line, not in the row list. */
9
- function buildRows(files) {
29
+ * one section header per ecosystem followed by its skill rows. Empty
30
+ * ecosystems are surfaced separately as a trailing line, not in the row list.
31
+ *
32
+ * Only root files become rows. Adjacent files are tracked via the summary
33
+ * map so the row can show aggregate counts/sizes. */
34
+ function buildRows(files, skillSummaries) {
10
35
  const rows = [];
11
36
  let lastEcosystem = null;
12
37
  files.forEach((file, index) => {
38
+ if (!isRootFile(file))
39
+ return;
13
40
  if (file.ecosystem !== lastEcosystem) {
14
41
  rows.push({ kind: 'header', ecosystem: file.ecosystem });
15
42
  lastEcosystem = file.ecosystem;
16
43
  }
17
- rows.push({ kind: 'file', index, file });
44
+ const summary = skillSummaries?.get(summaryKey(file));
45
+ rows.push({ kind: 'file', index, file, summary });
18
46
  });
19
47
  return rows;
20
48
  }
21
- /** Default-checked rule: a file is checked iff it's >= STUB_BYTE_THRESHOLD.
22
- * Returned as a Set of indices for O(1) toggle. */
49
+ /** Default-checked rule: a file is checked iff its OWN size is at or above
50
+ * STUB_BYTE_THRESHOLD. The stub filter looks at the root file's size only
51
+ * — a SKILL.md stub + heavy scripts/ should NOT pass (the user hasn't
52
+ * filled in the spec), but a SKILL.md with real content + small scripts
53
+ * should. Returned as a Set of indices for O(1) toggle. */
23
54
  function defaultChecked(files) {
24
55
  const out = new Set();
25
56
  files.forEach((f, i) => {
57
+ if (!isRootFile(f))
58
+ return;
26
59
  if (f.sizeBytes >= STUB_BYTE_THRESHOLD)
27
60
  out.add(i);
28
61
  });
@@ -46,17 +79,27 @@ function moveCursor(rows, current, delta) {
46
79
  // Off the end — stay where we were.
47
80
  return current;
48
81
  }
82
+ /** Build the "details" suffix shown to the right of a row label. For a
83
+ * multi-file skill we report aggregate count + size (`12.4 KB · 4 files`);
84
+ * for a single-file skill we keep the legacy `<size> · <lines> lines` so
85
+ * existing plain-text snapshots still parse. */
86
+ function detailsFor(file, summary) {
87
+ if (summary && summary.totalFiles > 1) {
88
+ return `${formatBytes(summary.totalBytes)} · ${summary.totalFiles} files`;
89
+ }
90
+ return `${formatBytes(file.sizeBytes)} · ${file.lineCount} lines`;
91
+ }
49
92
  /** Render a single file row. The checkbox uses `[x]`/`[ ]` so the output is
50
93
  * legible in plain-text snapshots; the active row gets a leading `▶`. */
51
- function FileRow({ file, checked, active, }) {
94
+ function FileRow({ file, summary, checked, active, }) {
52
95
  const label = file.scope ? `${file.scope}/${file.slug}` : file.slug;
53
- const details = `${formatBytes(file.sizeBytes)} · ${file.lineCount} lines`;
96
+ const details = detailsFor(file, summary);
54
97
  return (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: active ? theme.cyan : undefined, children: active ? '▶' : ' ' }) }), _jsxs(Text, { color: active ? theme.cyan : undefined, children: [checked ? '[x] ' : '[ ] ', label] }), _jsx(Text, { color: theme.zinc, children: ` ${details}` })] }));
55
98
  }
56
99
  export function IngestDiscoverApp(props) {
57
- const { files, emptyEcosystems, onDone } = props;
100
+ const { files, emptyEcosystems, onDone, skillSummaries } = props;
58
101
  const { exit } = useApp();
59
- const rows = useMemo(() => buildRows(files), [files]);
102
+ const rows = useMemo(() => buildRows(files, skillSummaries), [files, skillSummaries]);
60
103
  const [cursor, setCursor] = useState(() => firstSelectableIndex(rows));
61
104
  const [checked, setChecked] = useState(() => defaultChecked(files));
62
105
  // When the user hits enter with zero selections we don't unmount — we just
@@ -86,7 +129,11 @@ export function IngestDiscoverApp(props) {
86
129
  }
87
130
  if (input === 'a' || input === 'A') {
88
131
  const next = new Set();
89
- files.forEach((_, i) => next.add(i));
132
+ // Only root rows are selectable — match the same gate buildRows uses.
133
+ files.forEach((f, i) => {
134
+ if (isRootFile(f))
135
+ next.add(i);
136
+ });
90
137
  setChecked(next);
91
138
  setEmptyConfirm(false);
92
139
  return;
@@ -100,7 +147,16 @@ export function IngestDiscoverApp(props) {
100
147
  setEmptyConfirm(true);
101
148
  return;
102
149
  }
103
- const selected = files.filter((_, i) => checked.has(i));
150
+ // Each checked index points at a ROOT row. Expand to the full skill —
151
+ // every file with the same ecosystem + scope + slug rides along.
152
+ const selectedKeys = new Set();
153
+ for (const i of checked) {
154
+ const f = files[i];
155
+ if (!f)
156
+ continue;
157
+ selectedKeys.add(summaryKey(f));
158
+ }
159
+ const selected = files.filter((f) => selectedKeys.has(summaryKey(f)));
104
160
  onDone({ kind: 'confirmed', selected });
105
161
  exit();
106
162
  return;
@@ -121,7 +177,7 @@ export function IngestDiscoverApp(props) {
121
177
  if (row.kind === 'header') {
122
178
  return (_jsx(Box, { marginTop: i === 0 ? 0 : 1, children: _jsxs(Text, { bold: true, color: theme.violet, children: [ecosystemLabel(row.ecosystem), ":"] }) }, `h-${row.ecosystem}`));
123
179
  }
124
- return (_jsx(FileRow, { file: row.file, checked: checked.has(row.index), active: i === cursor }, `f-${row.index}`));
180
+ return (_jsx(FileRow, { file: row.file, summary: row.summary, checked: checked.has(row.index), active: i === cursor }, `f-${row.index}`));
125
181
  }) }), emptyEcosystems.length > 0 ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.zinc, children: ["Other tools: 0 skills found (", emptyEcosystems.map(ecosystemLabel).join(', '), ")"] }) })) : null, emptyConfirm ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.amber, children: "Nothing selected. Use space to toggle, then enter." }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.zinc, children: "\u2191/\u2193 navigate \u00B7 space toggle \u00B7 a select all \u00B7 n select none \u00B7 enter confirm \u00B7 q cancel" }) })] }));
126
182
  }
127
183
  /** Count distinct ecosystems in the discovery — used in the header line. */
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { Command } from 'commander';
3
3
  import { search } from './commands/search.js';
4
4
  import { publish } from './commands/publish.js';
5
+ import { unpublish } from './commands/unpublish.js';
6
+ import { delete_ as deleteCmd } from './commands/delete.js';
5
7
  import { login } from './commands/login.js';
6
8
  import { whoami } from './commands/whoami.js';
7
9
  import { init } from './commands/init.js';
@@ -50,16 +52,31 @@ program
50
52
  });
51
53
  program
52
54
  .command('publish <source>')
53
- .description('Publish a BotDoc from a file, directory, or zip archive')
55
+ .description('Publish a BotDoc pass a local path to upload, or @user/slug to mark an existing draft live')
54
56
  .option('--title <title>', 'BotDoc title')
55
57
  .option('--description <description>', 'BotDoc description')
56
58
  .option('--category <category>', 'Category (knowledge_management, dev_workflow, automation, agent_config, project_scaffold, other)')
57
59
  .option('--tags <tags>', 'Comma-separated tags')
58
60
  .option('--license <license>', 'License (MIT, CC_BY_4_0, CC_BY_SA_4_0, CC0, ALL_RIGHTS_RESERVED)')
59
61
  .option('--no-compile', 'Skip auto-compile (publish whatever files are on disk as-is)')
62
+ .option('--yes', 'Skip any confirmation prompt (reserved for future use)')
60
63
  .action(async (source, options) => {
61
64
  await publish(source, { ...options, json: program.opts().json });
62
65
  });
66
+ program
67
+ .command('unpublish <ref>')
68
+ .description('Hide a published BotDoc from /explore (sets the draft flag back)')
69
+ .option('--yes', 'Skip the confirmation prompt')
70
+ .action(async (ref, options) => {
71
+ await unpublish(ref, { ...options, json: program.opts().json });
72
+ });
73
+ program
74
+ .command('delete <ref>')
75
+ .description('Delete a BotDoc — drafts are hard-deleted with cascade; published BotDocs are soft-deleted (hidden, version history preserved)')
76
+ .option('--yes', 'Skip the confirmation prompt')
77
+ .action(async (ref, options) => {
78
+ await deleteCmd(ref, { ...options, json: program.opts().json });
79
+ });
63
80
  program
64
81
  .command('login')
65
82
  .description('Authenticate by opening your browser; or pass --token for non-interactive use')
@@ -6,6 +6,8 @@ export function detectDestination(srcRelative, ctx) {
6
6
  const remainder = src.slice('claude/'.length);
7
7
  // claude/SKILL.md → keep the leaf filename
8
8
  // claude/<inner>/SKILL.md → strip the inner-name segment, keep the leaf
9
+ // claude/<inner>/scripts/helper.sh → strip the inner-name, keep the
10
+ // relpath (`scripts/helper.sh`) so adjacent files land in subdirs.
9
11
  const finalName = remainder.includes('/')
10
12
  ? remainder.replace(/^[^/]+\//, '')
11
13
  : remainder;
@@ -15,6 +17,23 @@ export function detectDestination(srcRelative, ctx) {
15
17
  dest: path.join(ctx.homeDir, '.claude', 'skills', skillPath, finalName),
16
18
  };
17
19
  }
20
+ if (src.startsWith('claude-code/agents/')) {
21
+ // Layout mirrors claude skills:
22
+ // claude-code/agents/<slug>/AGENT.md → .claude/agents/<slug>/AGENT.md
23
+ // claude-code/agents/<slug>/scripts/helper.sh → .claude/agents/<slug>/scripts/helper.sh
24
+ // Strip the `claude-code/agents/<slug>/` prefix to get the relpath, then
25
+ // join under the project's `.claude/agents/<slug>/` dir. Single-file
26
+ // agents (no nested layout) still work via the same code path because
27
+ // the strip below leaves them with the bare filename.
28
+ const remainder = src.slice('claude-code/agents/'.length);
29
+ const finalName = remainder.includes('/')
30
+ ? remainder.replace(/^[^/]+\//, '')
31
+ : remainder;
32
+ return {
33
+ kind: 'project',
34
+ dest: path.join(ctx.projectDir, '.claude', 'agents', ctx.slug, finalName),
35
+ };
36
+ }
18
37
  if (src.startsWith('claude-code/commands/')) {
19
38
  return {
20
39
  kind: 'project',
@@ -1,8 +1,9 @@
1
+ import { formatSweepWarnings, type AdjacentSweepWarning } from '../commands/ingest.js';
1
2
  /** The set of ecosystems discovery can find on-disk. ChatGPT is intentionally
2
3
  * omitted — it has no canonical install location (manual-paste in the install
3
4
  * flow). The string identifiers mirror the canonical-path prefixes used by
4
5
  * the publish/install machinery so converting back is a string operation. */
5
- export type DiscoveryEcosystem = 'claude-code' | 'claude' | 'cursor' | 'codex' | 'copilot' | 'windsurf' | 'gemini' | 'antigravity' | 'opencode';
6
+ export type DiscoveryEcosystem = 'claude-code' | 'claude-code-agents' | 'claude' | 'cursor' | 'codex' | 'copilot' | 'windsurf' | 'gemini' | 'antigravity' | 'opencode';
6
7
  export interface DiscoveredSkillFile {
7
8
  /** The ecosystem this file was discovered under. Drives canonical-filename
8
9
  * mapping when we POST to /api/cli/ingest. */
@@ -18,16 +19,37 @@ export interface DiscoveredSkillFile {
18
19
  scope?: string;
19
20
  /** First H1 from the file contents, or a Title-Cased slug fallback. */
20
21
  title: string;
21
- /** File size in bytes. Used to default-uncheck tiny stub files. */
22
+ /** File size in bytes. Used to default-uncheck tiny stub files. For the
23
+ * root file (SKILL.md / AGENT.md / a flat .md) this is the file's own size
24
+ * — NOT a sum across the skill. The stub filter must look at the root
25
+ * size, not adjacent files, so a SKILL.md stub + heavy scripts doesn't
26
+ * sneak past as "real content". */
22
27
  sizeBytes: number;
23
28
  /** Line count of the file contents. Surfaced in the TUI alongside size. */
24
29
  lineCount: number;
25
30
  /** Raw file contents, kept in memory through the upload step. */
26
31
  content: string;
32
+ /** POSIX permission triple captured from `fs.statSync`. Defaults to 0o644
33
+ * when stat fails. Restored on `botdocs install` so executable bits on
34
+ * adjacent files (scripts/*.sh, etc.) survive the round-trip. */
35
+ mode: number;
27
36
  /** Canonical filename for the upload payload — e.g.
28
37
  * `claude-code/commands/foo.md`, `claude/<slug>/SKILL.md`, etc. */
29
38
  canonicalFilename: string;
30
39
  }
40
+ /** Per-skill aggregate stats produced by discovery — surfaced in the TUI and
41
+ * the plain-text fallback so the user can see how big a skill is before
42
+ * confirming the upload. */
43
+ export interface DiscoveredSkillSummary {
44
+ ecosystem: DiscoveryEcosystem;
45
+ scope?: string;
46
+ slug: string;
47
+ totalFiles: number;
48
+ /** Sum of `sizeBytes` across every file in the skill. */
49
+ totalBytes: number;
50
+ /** Sweep warnings (binary skip, oversize, cap hit). Empty when none. */
51
+ warnings: AdjacentSweepWarning[];
52
+ }
31
53
  /** One row in the per-ecosystem scan table. Each row describes a single
32
54
  * absolute directory to scan + how to convert each discovered file into a
33
55
  * `DiscoveredSkillFile`. Derived from the shared `DETECTORS` table — see
@@ -63,6 +85,11 @@ export declare function buildDetectors(homeDir: string, cwd: string): DetectorRo
63
85
  export interface DiscoveryResult {
64
86
  /** All discovered files, flattened across ecosystems. */
65
87
  files: DiscoveredSkillFile[];
88
+ /** Per-skill aggregate summary (count + size + warnings). Keyed by
89
+ * `<ecosystem>::<scope?>/<slug>` so multi-scope claude skills don't
90
+ * collide. The TUI uses this to render per-skill totals without
91
+ * re-summing the files array. */
92
+ skillSummaries: Map<string, DiscoveredSkillSummary>;
66
93
  /** Ecosystems we attempted to scan but found nothing for. Used by the TUI
67
94
  * to render a "Other tools: 0 skills found (...)" trailing line. */
68
95
  emptyEcosystems: DiscoveryEcosystem[];
@@ -70,6 +97,9 @@ export interface DiscoveryResult {
70
97
  * — the gate itself is applied inside `buildDetectors`. */
71
98
  inGitRepo: boolean;
72
99
  }
100
+ /** Key for the per-skill summary map. Stays stable across discoveries so the
101
+ * TUI can look up a summary by row. */
102
+ export declare function summaryKey(file: Pick<DiscoveredSkillFile, 'ecosystem' | 'scope' | 'slug'>): string;
73
103
  export interface DiscoveryOptions {
74
104
  /** Override $HOME. Defaults to `os.homedir()`. */
75
105
  homeDir?: string;
@@ -85,6 +115,9 @@ export interface DiscoveryOptions {
85
115
  * decides where to look on disk, `slugFor` extracts the slug, and
86
116
  * `canonicalFilename` produces the upload-shaped filename. */
87
117
  export declare function discoverSkills(options?: DiscoveryOptions): DiscoveryResult;
118
+ /** Re-export the warning formatter so callers (TUI, plain-text) can share
119
+ * one rendering. */
120
+ export { formatSweepWarnings };
88
121
  /** Human-readable label per ecosystem for the TUI section header. */
89
122
  export declare function ecosystemLabel(ecosystem: DiscoveryEcosystem): string;
90
123
  /** Format a byte count as "0.1 KB" / "2.4 KB" etc. — matches the spec sample.
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { DETECTORS } from '../commands/ingest.js';
4
+ import { DETECTORS, formatSweepWarnings, sweepSkillRoot, } from '../commands/ingest.js';
5
5
  /** Tiny H1 extractor — the same regex `ingest.ts` uses today. Falls back to a
6
6
  * Title-Cased slug when no H1 is present. */
7
7
  export function titleFromContent(content, slug) {
@@ -100,6 +100,21 @@ function scopeForNested(absPath, scanDir) {
100
100
  return undefined;
101
101
  return path.basename(candidateScopeDir);
102
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
+ }
103
118
  /** Scan all known on-disk locations and return a flat list of discoveries.
104
119
  * Reads file contents synchronously — discovery is one-shot and typically
105
120
  * touches a handful of small files, so the simplicity wins over async sprawl.
@@ -113,6 +128,7 @@ export function discoverSkills(options = {}) {
113
128
  const cwd = options.cwd ?? process.cwd();
114
129
  const rows = buildDetectors(homeDir, cwd);
115
130
  const files = [];
131
+ const skillSummaries = new Map();
116
132
  const seen = new Set();
117
133
  const empty = new Set();
118
134
  for (const row of rows) {
@@ -144,7 +160,7 @@ export function discoverSkills(options = {}) {
144
160
  const scope = detector.nested ? scopeForNested(abs, row.dir) : undefined;
145
161
  const sizeBytes = Buffer.byteLength(content, 'utf-8');
146
162
  const lineCount = content.length === 0 ? 0 : content.split(/\r?\n/).length;
147
- files.push({
163
+ const rootFile = {
148
164
  ecosystem: row.ecosystem,
149
165
  sourcePath: abs,
150
166
  slug,
@@ -153,32 +169,80 @@ export function discoverSkills(options = {}) {
153
169
  sizeBytes,
154
170
  lineCount,
155
171
  content,
172
+ mode: safeMode(abs),
156
173
  canonicalFilename: detector.canonicalFilename(slug),
157
- });
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
+ }
158
214
  seen.add(row.ecosystem);
159
215
  empty.delete(row.ecosystem);
160
216
  }
161
217
  }
162
- // Sort stable: by ecosystem, then by display label (scope/slug) so the TUI
163
- // and dry-run output are deterministic across runs.
218
+ // Sort stable: by ecosystem, then by display label (scope/slug), then by
219
+ // canonical filename so adjacent files cluster behind their root file.
164
220
  files.sort((a, b) => {
165
221
  if (a.ecosystem !== b.ecosystem)
166
222
  return a.ecosystem.localeCompare(b.ecosystem);
167
223
  const aLabel = a.scope ? `${a.scope}/${a.slug}` : a.slug;
168
224
  const bLabel = b.scope ? `${b.scope}/${b.slug}` : b.slug;
169
- return aLabel.localeCompare(bLabel);
225
+ if (aLabel !== bLabel)
226
+ return aLabel.localeCompare(bLabel);
227
+ return a.canonicalFilename.localeCompare(b.canonicalFilename);
170
228
  });
171
229
  return {
172
230
  files,
231
+ skillSummaries,
173
232
  emptyEcosystems: [...empty].sort(),
174
233
  inGitRepo: isGitRepo(cwd),
175
234
  };
176
235
  }
236
+ /** Re-export the warning formatter so callers (TUI, plain-text) can share
237
+ * one rendering. */
238
+ export { formatSweepWarnings };
177
239
  /** Human-readable label per ecosystem for the TUI section header. */
178
240
  export function ecosystemLabel(ecosystem) {
179
241
  switch (ecosystem) {
180
242
  case 'claude-code':
181
243
  return 'Claude Code';
244
+ case 'claude-code-agents':
245
+ return 'Claude Code agents';
182
246
  case 'claude':
183
247
  return 'Claude skills';
184
248
  case 'cursor':