@botdocs/cli 0.6.0 → 0.8.1
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/README.md +80 -8
- package/dist/commands/delete.d.ts +23 -0
- package/dist/commands/delete.js +106 -0
- package/dist/commands/ingest.d.ts +58 -0
- package/dist/commands/ingest.js +344 -27
- package/dist/commands/install.js +11 -0
- package/dist/commands/publish.d.ts +29 -1
- package/dist/commands/publish.js +85 -1
- package/dist/commands/unpublish.d.ts +16 -0
- package/dist/commands/unpublish.js +53 -0
- package/dist/commands/views/ingest-discover-app.d.ts +6 -1
- package/dist/commands/views/ingest-discover-app.js +56 -14
- package/dist/index.js +18 -1
- package/dist/lib/auto-detect.js +19 -0
- package/dist/lib/ingest-discover.d.ts +42 -2
- package/dist/lib/ingest-discover.js +72 -6
- package/dist/lib/ref.d.ts +42 -0
- package/dist/lib/ref.js +60 -0
- package/package.json +1 -1
- package/templates/agents.md +2 -1
|
@@ -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,46 @@ 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 — 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. */
|
|
11
|
+
function isRootFile(file) {
|
|
12
|
+
return file.isRoot;
|
|
13
|
+
}
|
|
6
14
|
/** Group files by ecosystem (preserving order) and emit a flat row list with
|
|
7
|
-
* one section header per ecosystem followed by its
|
|
8
|
-
* are surfaced separately as a trailing line, not in the row list.
|
|
9
|
-
|
|
15
|
+
* one section header per ecosystem followed by its skill rows. Empty
|
|
16
|
+
* ecosystems are surfaced separately as a trailing line, not in the row list.
|
|
17
|
+
*
|
|
18
|
+
* Only root files become rows. Adjacent files are tracked via the summary
|
|
19
|
+
* map so the row can show aggregate counts/sizes. */
|
|
20
|
+
function buildRows(files, skillSummaries) {
|
|
10
21
|
const rows = [];
|
|
11
22
|
let lastEcosystem = null;
|
|
12
23
|
files.forEach((file, index) => {
|
|
24
|
+
if (!isRootFile(file))
|
|
25
|
+
return;
|
|
13
26
|
if (file.ecosystem !== lastEcosystem) {
|
|
14
27
|
rows.push({ kind: 'header', ecosystem: file.ecosystem });
|
|
15
28
|
lastEcosystem = file.ecosystem;
|
|
16
29
|
}
|
|
17
|
-
|
|
30
|
+
const summary = skillSummaries?.get(summaryKey(file));
|
|
31
|
+
rows.push({ kind: 'file', index, file, summary });
|
|
18
32
|
});
|
|
19
33
|
return rows;
|
|
20
34
|
}
|
|
21
|
-
/** Default-checked rule: a file is checked iff
|
|
22
|
-
*
|
|
35
|
+
/** Default-checked rule: a file is checked iff its OWN size is at or above
|
|
36
|
+
* STUB_BYTE_THRESHOLD. The stub filter looks at the root file's size only
|
|
37
|
+
* — a SKILL.md stub + heavy scripts/ should NOT pass (the user hasn't
|
|
38
|
+
* filled in the spec), but a SKILL.md with real content + small scripts
|
|
39
|
+
* should. Returned as a Set of indices for O(1) toggle. */
|
|
23
40
|
function defaultChecked(files) {
|
|
24
41
|
const out = new Set();
|
|
25
42
|
files.forEach((f, i) => {
|
|
43
|
+
if (!isRootFile(f))
|
|
44
|
+
return;
|
|
26
45
|
if (f.sizeBytes >= STUB_BYTE_THRESHOLD)
|
|
27
46
|
out.add(i);
|
|
28
47
|
});
|
|
@@ -46,17 +65,27 @@ function moveCursor(rows, current, delta) {
|
|
|
46
65
|
// Off the end — stay where we were.
|
|
47
66
|
return current;
|
|
48
67
|
}
|
|
68
|
+
/** Build the "details" suffix shown to the right of a row label. For a
|
|
69
|
+
* multi-file skill we report aggregate count + size (`12.4 KB · 4 files`);
|
|
70
|
+
* for a single-file skill we keep the legacy `<size> · <lines> lines` so
|
|
71
|
+
* existing plain-text snapshots still parse. */
|
|
72
|
+
function detailsFor(file, summary) {
|
|
73
|
+
if (summary && summary.totalFiles > 1) {
|
|
74
|
+
return `${formatBytes(summary.totalBytes)} · ${summary.totalFiles} files`;
|
|
75
|
+
}
|
|
76
|
+
return `${formatBytes(file.sizeBytes)} · ${file.lineCount} lines`;
|
|
77
|
+
}
|
|
49
78
|
/** Render a single file row. The checkbox uses `[x]`/`[ ]` so the output is
|
|
50
79
|
* legible in plain-text snapshots; the active row gets a leading `▶`. */
|
|
51
|
-
function FileRow({ file, checked, active, }) {
|
|
80
|
+
function FileRow({ file, summary, checked, active, }) {
|
|
52
81
|
const label = file.scope ? `${file.scope}/${file.slug}` : file.slug;
|
|
53
|
-
const details =
|
|
82
|
+
const details = detailsFor(file, summary);
|
|
54
83
|
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
84
|
}
|
|
56
85
|
export function IngestDiscoverApp(props) {
|
|
57
|
-
const { files, emptyEcosystems, onDone } = props;
|
|
86
|
+
const { files, emptyEcosystems, onDone, skillSummaries } = props;
|
|
58
87
|
const { exit } = useApp();
|
|
59
|
-
const rows = useMemo(() => buildRows(files), [files]);
|
|
88
|
+
const rows = useMemo(() => buildRows(files, skillSummaries), [files, skillSummaries]);
|
|
60
89
|
const [cursor, setCursor] = useState(() => firstSelectableIndex(rows));
|
|
61
90
|
const [checked, setChecked] = useState(() => defaultChecked(files));
|
|
62
91
|
// When the user hits enter with zero selections we don't unmount — we just
|
|
@@ -86,7 +115,11 @@ export function IngestDiscoverApp(props) {
|
|
|
86
115
|
}
|
|
87
116
|
if (input === 'a' || input === 'A') {
|
|
88
117
|
const next = new Set();
|
|
89
|
-
|
|
118
|
+
// Only root rows are selectable — match the same gate buildRows uses.
|
|
119
|
+
files.forEach((f, i) => {
|
|
120
|
+
if (isRootFile(f))
|
|
121
|
+
next.add(i);
|
|
122
|
+
});
|
|
90
123
|
setChecked(next);
|
|
91
124
|
setEmptyConfirm(false);
|
|
92
125
|
return;
|
|
@@ -100,7 +133,16 @@ export function IngestDiscoverApp(props) {
|
|
|
100
133
|
setEmptyConfirm(true);
|
|
101
134
|
return;
|
|
102
135
|
}
|
|
103
|
-
|
|
136
|
+
// Each checked index points at a ROOT row. Expand to the full skill —
|
|
137
|
+
// every file with the same ecosystem + scope + slug rides along.
|
|
138
|
+
const selectedKeys = new Set();
|
|
139
|
+
for (const i of checked) {
|
|
140
|
+
const f = files[i];
|
|
141
|
+
if (!f)
|
|
142
|
+
continue;
|
|
143
|
+
selectedKeys.add(summaryKey(f));
|
|
144
|
+
}
|
|
145
|
+
const selected = files.filter((f) => selectedKeys.has(summaryKey(f)));
|
|
104
146
|
onDone({ kind: 'confirmed', selected });
|
|
105
147
|
exit();
|
|
106
148
|
return;
|
|
@@ -121,7 +163,7 @@ export function IngestDiscoverApp(props) {
|
|
|
121
163
|
if (row.kind === 'header') {
|
|
122
164
|
return (_jsx(Box, { marginTop: i === 0 ? 0 : 1, children: _jsxs(Text, { bold: true, color: theme.violet, children: [ecosystemLabel(row.ecosystem), ":"] }) }, `h-${row.ecosystem}`));
|
|
123
165
|
}
|
|
124
|
-
return (_jsx(FileRow, { file: row.file, checked: checked.has(row.index), active: i === cursor }, `f-${row.index}`));
|
|
166
|
+
return (_jsx(FileRow, { file: row.file, summary: row.summary, checked: checked.has(row.index), active: i === cursor }, `f-${row.index}`));
|
|
125
167
|
}) }), 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
168
|
}
|
|
127
169
|
/** 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
|
|
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')
|
package/dist/lib/auto-detect.js
CHANGED
|
@@ -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,15 +19,43 @@ 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;
|
|
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;
|
|
46
|
+
}
|
|
47
|
+
/** Per-skill aggregate stats produced by discovery — surfaced in the TUI and
|
|
48
|
+
* the plain-text fallback so the user can see how big a skill is before
|
|
49
|
+
* confirming the upload. */
|
|
50
|
+
export interface DiscoveredSkillSummary {
|
|
51
|
+
ecosystem: DiscoveryEcosystem;
|
|
52
|
+
scope?: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
totalFiles: number;
|
|
55
|
+
/** Sum of `sizeBytes` across every file in the skill. */
|
|
56
|
+
totalBytes: number;
|
|
57
|
+
/** Sweep warnings (binary skip, oversize, cap hit). Empty when none. */
|
|
58
|
+
warnings: AdjacentSweepWarning[];
|
|
30
59
|
}
|
|
31
60
|
/** One row in the per-ecosystem scan table. Each row describes a single
|
|
32
61
|
* absolute directory to scan + how to convert each discovered file into a
|
|
@@ -63,6 +92,11 @@ export declare function buildDetectors(homeDir: string, cwd: string): DetectorRo
|
|
|
63
92
|
export interface DiscoveryResult {
|
|
64
93
|
/** All discovered files, flattened across ecosystems. */
|
|
65
94
|
files: DiscoveredSkillFile[];
|
|
95
|
+
/** Per-skill aggregate summary (count + size + warnings). Keyed by
|
|
96
|
+
* `<ecosystem>::<scope?>/<slug>` so multi-scope claude skills don't
|
|
97
|
+
* collide. The TUI uses this to render per-skill totals without
|
|
98
|
+
* re-summing the files array. */
|
|
99
|
+
skillSummaries: Map<string, DiscoveredSkillSummary>;
|
|
66
100
|
/** Ecosystems we attempted to scan but found nothing for. Used by the TUI
|
|
67
101
|
* to render a "Other tools: 0 skills found (...)" trailing line. */
|
|
68
102
|
emptyEcosystems: DiscoveryEcosystem[];
|
|
@@ -70,6 +104,9 @@ export interface DiscoveryResult {
|
|
|
70
104
|
* — the gate itself is applied inside `buildDetectors`. */
|
|
71
105
|
inGitRepo: boolean;
|
|
72
106
|
}
|
|
107
|
+
/** Key for the per-skill summary map. Stays stable across discoveries so the
|
|
108
|
+
* TUI can look up a summary by row. */
|
|
109
|
+
export declare function summaryKey(file: Pick<DiscoveredSkillFile, 'ecosystem' | 'scope' | 'slug'>): string;
|
|
73
110
|
export interface DiscoveryOptions {
|
|
74
111
|
/** Override $HOME. Defaults to `os.homedir()`. */
|
|
75
112
|
homeDir?: string;
|
|
@@ -85,6 +122,9 @@ export interface DiscoveryOptions {
|
|
|
85
122
|
* decides where to look on disk, `slugFor` extracts the slug, and
|
|
86
123
|
* `canonicalFilename` produces the upload-shaped filename. */
|
|
87
124
|
export declare function discoverSkills(options?: DiscoveryOptions): DiscoveryResult;
|
|
125
|
+
/** Re-export the warning formatter so callers (TUI, plain-text) can share
|
|
126
|
+
* one rendering. */
|
|
127
|
+
export { formatSweepWarnings };
|
|
88
128
|
/** Human-readable label per ecosystem for the TUI section header. */
|
|
89
129
|
export declare function ecosystemLabel(ecosystem: DiscoveryEcosystem): string;
|
|
90
130
|
/** 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
|
-
|
|
163
|
+
const rootFile = {
|
|
148
164
|
ecosystem: row.ecosystem,
|
|
149
165
|
sourcePath: abs,
|
|
150
166
|
slug,
|
|
@@ -153,32 +169,82 @@ 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
|
+
isRoot: true,
|
|
175
|
+
};
|
|
176
|
+
files.push(rootFile);
|
|
177
|
+
// Per-skill summary aggregates the root file + any adjacent sweep
|
|
178
|
+
// outputs below. We seed it now and append adjacent files inline.
|
|
179
|
+
const sumKey = summaryKey(rootFile);
|
|
180
|
+
const summary = {
|
|
181
|
+
ecosystem: row.ecosystem,
|
|
182
|
+
scope,
|
|
183
|
+
slug,
|
|
184
|
+
totalFiles: 1,
|
|
185
|
+
totalBytes: sizeBytes,
|
|
186
|
+
warnings: [],
|
|
187
|
+
};
|
|
188
|
+
skillSummaries.set(sumKey, summary);
|
|
189
|
+
// Adjacent-file sweep for detectors that opt in (claude, claude-code-agents).
|
|
190
|
+
if (detector.includeAdjacent && detector.skillRoot && detector.canonicalAdjacentFilename) {
|
|
191
|
+
const skillRoot = detector.skillRoot(abs);
|
|
192
|
+
const sweep = sweepSkillRoot(skillRoot, abs);
|
|
193
|
+
for (const adj of sweep.files) {
|
|
194
|
+
files.push({
|
|
195
|
+
ecosystem: row.ecosystem,
|
|
196
|
+
sourcePath: adj.absPath,
|
|
197
|
+
slug,
|
|
198
|
+
scope,
|
|
199
|
+
title: rootFile.title,
|
|
200
|
+
// sizeBytes / lineCount on adjacent rows describe the adjacent
|
|
201
|
+
// file itself — not the skill total. The TUI uses sizeBytes on
|
|
202
|
+
// the ROOT row only for the stub filter; per-skill totals come
|
|
203
|
+
// from the summary map.
|
|
204
|
+
sizeBytes: adj.sizeBytes,
|
|
205
|
+
lineCount: adj.content.length === 0 ? 0 : adj.content.split(/\r?\n/).length,
|
|
206
|
+
content: adj.content,
|
|
207
|
+
mode: adj.mode,
|
|
208
|
+
canonicalFilename: detector.canonicalAdjacentFilename(slug, adj.relPath),
|
|
209
|
+
isRoot: false,
|
|
210
|
+
});
|
|
211
|
+
summary.totalFiles += 1;
|
|
212
|
+
summary.totalBytes += adj.sizeBytes;
|
|
213
|
+
}
|
|
214
|
+
summary.warnings = sweep.warnings;
|
|
215
|
+
}
|
|
158
216
|
seen.add(row.ecosystem);
|
|
159
217
|
empty.delete(row.ecosystem);
|
|
160
218
|
}
|
|
161
219
|
}
|
|
162
|
-
// Sort stable: by ecosystem, then by display label (scope/slug)
|
|
163
|
-
//
|
|
220
|
+
// Sort stable: by ecosystem, then by display label (scope/slug), then by
|
|
221
|
+
// canonical filename so adjacent files cluster behind their root file.
|
|
164
222
|
files.sort((a, b) => {
|
|
165
223
|
if (a.ecosystem !== b.ecosystem)
|
|
166
224
|
return a.ecosystem.localeCompare(b.ecosystem);
|
|
167
225
|
const aLabel = a.scope ? `${a.scope}/${a.slug}` : a.slug;
|
|
168
226
|
const bLabel = b.scope ? `${b.scope}/${b.slug}` : b.slug;
|
|
169
|
-
|
|
227
|
+
if (aLabel !== bLabel)
|
|
228
|
+
return aLabel.localeCompare(bLabel);
|
|
229
|
+
return a.canonicalFilename.localeCompare(b.canonicalFilename);
|
|
170
230
|
});
|
|
171
231
|
return {
|
|
172
232
|
files,
|
|
233
|
+
skillSummaries,
|
|
173
234
|
emptyEcosystems: [...empty].sort(),
|
|
174
235
|
inGitRepo: isGitRepo(cwd),
|
|
175
236
|
};
|
|
176
237
|
}
|
|
238
|
+
/** Re-export the warning formatter so callers (TUI, plain-text) can share
|
|
239
|
+
* one rendering. */
|
|
240
|
+
export { formatSweepWarnings };
|
|
177
241
|
/** Human-readable label per ecosystem for the TUI section header. */
|
|
178
242
|
export function ecosystemLabel(ecosystem) {
|
|
179
243
|
switch (ecosystem) {
|
|
180
244
|
case 'claude-code':
|
|
181
245
|
return 'Claude Code';
|
|
246
|
+
case 'claude-code-agents':
|
|
247
|
+
return 'Claude Code agents';
|
|
182
248
|
case 'claude':
|
|
183
249
|
return 'Claude skills';
|
|
184
250
|
case 'cursor':
|
|
@@ -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;
|
package/dist/lib/ref.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|
package/templates/agents.md
CHANGED
|
@@ -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. |
|