@botdocs/cli 0.5.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.
- package/README.md +134 -6
- package/dist/commands/delete.d.ts +23 -0
- package/dist/commands/delete.js +106 -0
- package/dist/commands/ingest.d.ts +97 -1
- package/dist/commands/ingest.js +512 -19
- 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 +26 -0
- package/dist/commands/views/ingest-discover-app.js +186 -0
- package/dist/index.js +27 -5
- package/dist/lib/auto-detect.js +19 -0
- package/dist/lib/ingest-discover.d.ts +128 -0
- package/dist/lib/ingest-discover.js +271 -0
- package/dist/lib/ref.d.ts +42 -0
- package/dist/lib/ref.js +60 -0
- package/package.json +1 -1
- package/templates/agents.md +3 -2
|
@@ -6,6 +6,34 @@ interface PublishOptions {
|
|
|
6
6
|
license?: string;
|
|
7
7
|
json?: boolean;
|
|
8
8
|
noCompile?: boolean;
|
|
9
|
+
/** Skip confirmation prompts. Reserved for future use — publish on a
|
|
10
|
+
* ref currently doesn't prompt, but the flag is accepted so users
|
|
11
|
+
* developing scripts get a consistent surface across publish/unpublish. */
|
|
12
|
+
yes?: boolean;
|
|
9
13
|
}
|
|
10
14
|
export declare function publish(source: string, options: PublishOptions): Promise<void>;
|
|
11
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Toggle an existing BotDoc from draft → published via the API.
|
|
17
|
+
*
|
|
18
|
+
* Called from `publish()` when the source argument looks like a ref
|
|
19
|
+
* (`@user/slug` or `user/slug`) instead of a local path. Strips the
|
|
20
|
+
* `draft` and `ingest:<id>` tags server-side. No prompt — moving from
|
|
21
|
+
* draft to published is the natural progression, and unpublishing a
|
|
22
|
+
* mistake is one CLI call away.
|
|
23
|
+
*/
|
|
24
|
+
declare function publishRef(rawRef: string, options: PublishOptions): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Map an `ApiError` from a publish/unpublish call to a friendly,
|
|
27
|
+
* actionable message. Shared by both verbs so the wording stays in sync.
|
|
28
|
+
*
|
|
29
|
+
* 401: most likely the user isn't logged in (or their saved token is
|
|
30
|
+
* stale). The api lib already produces a helpful "run `botdocs login`"
|
|
31
|
+
* hint — pass it through.
|
|
32
|
+
*
|
|
33
|
+
* 403: authenticated as someone who doesn't own this BotDoc.
|
|
34
|
+
*
|
|
35
|
+
* 404: BotDoc doesn't exist (or is owned by a different user — the API
|
|
36
|
+
* returns 404 rather than 403 to avoid leaking existence to non-authors).
|
|
37
|
+
*/
|
|
38
|
+
declare function handlePublishToggleError(err: unknown, refLabel: string, options: PublishOptions): void;
|
|
39
|
+
export { publishRef, handlePublishToggleError };
|
package/dist/commands/publish.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type DiscoveredSkillFile, type DiscoveredSkillSummary, type DiscoveryEcosystem } from '../../lib/ingest-discover.js';
|
|
3
|
+
/** The outcome of one TUI session. `null` selections means the user cancelled
|
|
4
|
+
* via `q`/Esc; an empty array means they hit enter with everything unchecked
|
|
5
|
+
* (the runner stays on screen — the parent never receives an empty array). */
|
|
6
|
+
export type IngestDiscoverResult = {
|
|
7
|
+
kind: 'confirmed';
|
|
8
|
+
selected: DiscoveredSkillFile[];
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'cancelled';
|
|
11
|
+
};
|
|
12
|
+
export interface IngestDiscoverAppProps {
|
|
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>;
|
|
19
|
+
/** Ecosystems we scanned but found nothing for. Rendered as a single
|
|
20
|
+
* trailing "Other tools: ..." line so the user can see we looked. */
|
|
21
|
+
emptyEcosystems: DiscoveryEcosystem[];
|
|
22
|
+
/** Called with the user's choice when they confirm or cancel. The parent
|
|
23
|
+
* is responsible for unmounting on receipt. */
|
|
24
|
+
onDone: (result: IngestDiscoverResult) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare function IngestDiscoverApp(props: IngestDiscoverAppProps): React.ReactElement;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
|
+
import { theme } from './theme.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
|
+
}
|
|
28
|
+
/** Group files by ecosystem (preserving order) and emit a flat row list with
|
|
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) {
|
|
35
|
+
const rows = [];
|
|
36
|
+
let lastEcosystem = null;
|
|
37
|
+
files.forEach((file, index) => {
|
|
38
|
+
if (!isRootFile(file))
|
|
39
|
+
return;
|
|
40
|
+
if (file.ecosystem !== lastEcosystem) {
|
|
41
|
+
rows.push({ kind: 'header', ecosystem: file.ecosystem });
|
|
42
|
+
lastEcosystem = file.ecosystem;
|
|
43
|
+
}
|
|
44
|
+
const summary = skillSummaries?.get(summaryKey(file));
|
|
45
|
+
rows.push({ kind: 'file', index, file, summary });
|
|
46
|
+
});
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
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. */
|
|
54
|
+
function defaultChecked(files) {
|
|
55
|
+
const out = new Set();
|
|
56
|
+
files.forEach((f, i) => {
|
|
57
|
+
if (!isRootFile(f))
|
|
58
|
+
return;
|
|
59
|
+
if (f.sizeBytes >= STUB_BYTE_THRESHOLD)
|
|
60
|
+
out.add(i);
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Find the first selectable (file) row in the flattened list. Used to seed
|
|
65
|
+
* the cursor — we never park it on a header. */
|
|
66
|
+
function firstSelectableIndex(rows) {
|
|
67
|
+
return rows.findIndex((r) => r.kind === 'file');
|
|
68
|
+
}
|
|
69
|
+
/** Move the cursor by `delta` (±1) and skip past header rows. Caller guarantees
|
|
70
|
+
* `rows` contains at least one file row. */
|
|
71
|
+
function moveCursor(rows, current, delta) {
|
|
72
|
+
const max = rows.length - 1;
|
|
73
|
+
let next = current + delta;
|
|
74
|
+
while (next >= 0 && next <= max) {
|
|
75
|
+
if (rows[next]?.kind === 'file')
|
|
76
|
+
return next;
|
|
77
|
+
next += delta;
|
|
78
|
+
}
|
|
79
|
+
// Off the end — stay where we were.
|
|
80
|
+
return current;
|
|
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
|
+
}
|
|
92
|
+
/** Render a single file row. The checkbox uses `[x]`/`[ ]` so the output is
|
|
93
|
+
* legible in plain-text snapshots; the active row gets a leading `▶`. */
|
|
94
|
+
function FileRow({ file, summary, checked, active, }) {
|
|
95
|
+
const label = file.scope ? `${file.scope}/${file.slug}` : file.slug;
|
|
96
|
+
const details = detailsFor(file, summary);
|
|
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}` })] }));
|
|
98
|
+
}
|
|
99
|
+
export function IngestDiscoverApp(props) {
|
|
100
|
+
const { files, emptyEcosystems, onDone, skillSummaries } = props;
|
|
101
|
+
const { exit } = useApp();
|
|
102
|
+
const rows = useMemo(() => buildRows(files, skillSummaries), [files, skillSummaries]);
|
|
103
|
+
const [cursor, setCursor] = useState(() => firstSelectableIndex(rows));
|
|
104
|
+
const [checked, setChecked] = useState(() => defaultChecked(files));
|
|
105
|
+
// When the user hits enter with zero selections we don't unmount — we just
|
|
106
|
+
// surface a hint and let them keep toggling. This flag drives that hint.
|
|
107
|
+
const [emptyConfirm, setEmptyConfirm] = useState(false);
|
|
108
|
+
useInput((input, key) => {
|
|
109
|
+
if (key.upArrow) {
|
|
110
|
+
setCursor((c) => moveCursor(rows, c, -1));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.downArrow) {
|
|
114
|
+
setCursor((c) => moveCursor(rows, c, 1));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (input === ' ') {
|
|
118
|
+
const row = rows[cursor];
|
|
119
|
+
if (row?.kind !== 'file')
|
|
120
|
+
return;
|
|
121
|
+
const next = new Set(checked);
|
|
122
|
+
if (next.has(row.index))
|
|
123
|
+
next.delete(row.index);
|
|
124
|
+
else
|
|
125
|
+
next.add(row.index);
|
|
126
|
+
setChecked(next);
|
|
127
|
+
setEmptyConfirm(false);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (input === 'a' || input === 'A') {
|
|
131
|
+
const next = new Set();
|
|
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
|
+
});
|
|
137
|
+
setChecked(next);
|
|
138
|
+
setEmptyConfirm(false);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (input === 'n' || input === 'N') {
|
|
142
|
+
setChecked(new Set());
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (key.return) {
|
|
146
|
+
if (checked.size === 0) {
|
|
147
|
+
setEmptyConfirm(true);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
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)));
|
|
160
|
+
onDone({ kind: 'confirmed', selected });
|
|
161
|
+
exit();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (input === 'q' || input === 'Q' || key.escape) {
|
|
165
|
+
onDone({ kind: 'cancelled' });
|
|
166
|
+
exit();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Empty discovery — render a helpful empty state and exit on any key. This
|
|
171
|
+
// is reachable when discovery returned files but they were all filtered out
|
|
172
|
+
// somehow (defensive — the caller currently bails before mounting us).
|
|
173
|
+
if (files.length === 0) {
|
|
174
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: _jsx(Text, { children: "No skills found in any known location." }) }));
|
|
175
|
+
}
|
|
176
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { bold: true, color: theme.cyan, children: "BotDocs ingest" }), _jsxs(Text, { color: theme.zinc, children: ["Found ", files.length, " skill", files.length === 1 ? '' : 's', " across", ' ', countEcosystems(files), " tool", countEcosystems(files) === 1 ? '' : 's', ":"] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: rows.map((row, i) => {
|
|
177
|
+
if (row.kind === 'header') {
|
|
178
|
+
return (_jsx(Box, { marginTop: i === 0 ? 0 : 1, children: _jsxs(Text, { bold: true, color: theme.violet, children: [ecosystemLabel(row.ecosystem), ":"] }) }, `h-${row.ecosystem}`));
|
|
179
|
+
}
|
|
180
|
+
return (_jsx(FileRow, { file: row.file, summary: row.summary, checked: checked.has(row.index), active: i === cursor }, `f-${row.index}`));
|
|
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" }) })] }));
|
|
182
|
+
}
|
|
183
|
+
/** Count distinct ecosystems in the discovery — used in the header line. */
|
|
184
|
+
function countEcosystems(files) {
|
|
185
|
+
return new Set(files.map((f) => f.ecosystem)).size;
|
|
186
|
+
}
|
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')
|
|
@@ -137,13 +154,18 @@ program
|
|
|
137
154
|
await checkUpdates({ ...opts, json: program.opts().json });
|
|
138
155
|
});
|
|
139
156
|
program
|
|
140
|
-
.command('ingest
|
|
141
|
-
.description('
|
|
157
|
+
.command('ingest [path]')
|
|
158
|
+
.description('Scan your system for skills and ingest them as drafts (or ingest a specific directory)')
|
|
142
159
|
.option('--bundle <name>', 'Group all detected skills into a single bundle draft')
|
|
143
|
-
.option('--dry-run', 'Show what would be
|
|
160
|
+
.option('--dry-run', 'Show what would be ingested without uploading')
|
|
144
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
|
+
.option('--auto', 'Skip the interactive selection and ingest everything discovery finds (zero-arg mode only)')
|
|
163
|
+
.option('--no-ink', 'Disable the interactive TUI; use plain output (zero-arg mode only)')
|
|
145
164
|
.action(async (sourcePath, opts) => {
|
|
146
|
-
|
|
165
|
+
// Commander's --no-ink convention sets opts.ink = false; flip to noInk
|
|
166
|
+
// so downstream consumers don't have to handle the inverted boolean.
|
|
167
|
+
const { ink, ...rest } = opts;
|
|
168
|
+
await ingest(sourcePath, { ...rest, noInk: ink === false, json: program.opts().json });
|
|
147
169
|
});
|
|
148
170
|
program
|
|
149
171
|
.command('compile <path>')
|
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',
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { formatSweepWarnings, type AdjacentSweepWarning } from '../commands/ingest.js';
|
|
2
|
+
/** The set of ecosystems discovery can find on-disk. ChatGPT is intentionally
|
|
3
|
+
* omitted — it has no canonical install location (manual-paste in the install
|
|
4
|
+
* flow). The string identifiers mirror the canonical-path prefixes used by
|
|
5
|
+
* the publish/install machinery so converting back is a string operation. */
|
|
6
|
+
export type DiscoveryEcosystem = 'claude-code' | 'claude-code-agents' | 'claude' | 'cursor' | 'codex' | 'copilot' | 'windsurf' | 'gemini' | 'antigravity' | 'opencode';
|
|
7
|
+
export interface DiscoveredSkillFile {
|
|
8
|
+
/** The ecosystem this file was discovered under. Drives canonical-filename
|
|
9
|
+
* mapping when we POST to /api/cli/ingest. */
|
|
10
|
+
ecosystem: DiscoveryEcosystem;
|
|
11
|
+
/** Absolute path on disk. */
|
|
12
|
+
sourcePath: string;
|
|
13
|
+
/** Slug derived from the filename (ext stripped, dir-extracted for nested
|
|
14
|
+
* SKILL.md). For `~/.claude/skills/<scope>/<slug>/SKILL.md`, the slug is
|
|
15
|
+
* `<slug>` (the leaf directory). */
|
|
16
|
+
slug: string;
|
|
17
|
+
/** Optional scope segment for nested ecosystems (currently only
|
|
18
|
+
* `claude` SKILL.md trees — `~/.claude/skills/<scope>/<slug>/SKILL.md`). */
|
|
19
|
+
scope?: string;
|
|
20
|
+
/** First H1 from the file contents, or a Title-Cased slug fallback. */
|
|
21
|
+
title: string;
|
|
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". */
|
|
27
|
+
sizeBytes: number;
|
|
28
|
+
/** Line count of the file contents. Surfaced in the TUI alongside size. */
|
|
29
|
+
lineCount: number;
|
|
30
|
+
/** Raw file contents, kept in memory through the upload step. */
|
|
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;
|
|
36
|
+
/** Canonical filename for the upload payload — e.g.
|
|
37
|
+
* `claude-code/commands/foo.md`, `claude/<slug>/SKILL.md`, etc. */
|
|
38
|
+
canonicalFilename: string;
|
|
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
|
+
}
|
|
53
|
+
/** One row in the per-ecosystem scan table. Each row describes a single
|
|
54
|
+
* absolute directory to scan + how to convert each discovered file into a
|
|
55
|
+
* `DiscoveredSkillFile`. Derived from the shared `DETECTORS` table — see
|
|
56
|
+
* `buildDetectors` below for the fan-out logic. */
|
|
57
|
+
export interface DetectorRow {
|
|
58
|
+
ecosystem: DiscoveryEcosystem;
|
|
59
|
+
/** `'global'` paths live under HOME and always scan regardless of repo
|
|
60
|
+
* state; `'project'` paths live under CWD and only appear when CWD is a git
|
|
61
|
+
* repo (since they're per-repo settings). */
|
|
62
|
+
kind: 'global' | 'project';
|
|
63
|
+
/** Absolute directory to scan. */
|
|
64
|
+
dir: string;
|
|
65
|
+
/** When true, walk the directory tree recursively (used for claude skills
|
|
66
|
+
* which live one or two scope-segments deep). When false, only direct
|
|
67
|
+
* children are considered. Mirrors `DETECTORS.<ecosystem>.nested`. */
|
|
68
|
+
recursive: boolean;
|
|
69
|
+
}
|
|
70
|
+
/** Tiny H1 extractor — the same regex `ingest.ts` uses today. Falls back to a
|
|
71
|
+
* Title-Cased slug when no H1 is present. */
|
|
72
|
+
export declare function titleFromContent(content: string, slug: string): string;
|
|
73
|
+
/** Walk up from `start` looking for a `.git` directory. Returns true if any
|
|
74
|
+
* ancestor is a git repo. Used to gate project-scoped scans (cursor, codex,
|
|
75
|
+
* copilot, windsurf) which only make sense for repos. */
|
|
76
|
+
export declare function isGitRepo(start: string): boolean;
|
|
77
|
+
/** Build the per-row scan table for a given home + cwd by fanning out the
|
|
78
|
+
* shared `DETECTORS.<ecosystem>.scanPaths()` results. Project rows are
|
|
79
|
+
* naturally absent when `cwd` is outside a git repo because each project
|
|
80
|
+
* detector returns `[]` for that case.
|
|
81
|
+
*
|
|
82
|
+
* Split out as its own exported function so tests can assert the row shape
|
|
83
|
+
* without spinning up a fixture tree on disk. */
|
|
84
|
+
export declare function buildDetectors(homeDir: string, cwd: string): DetectorRow[];
|
|
85
|
+
export interface DiscoveryResult {
|
|
86
|
+
/** All discovered files, flattened across ecosystems. */
|
|
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>;
|
|
93
|
+
/** Ecosystems we attempted to scan but found nothing for. Used by the TUI
|
|
94
|
+
* to render a "Other tools: 0 skills found (...)" trailing line. */
|
|
95
|
+
emptyEcosystems: DiscoveryEcosystem[];
|
|
96
|
+
/** True when CWD is inside a git repo. The TUI uses this only for messaging
|
|
97
|
+
* — the gate itself is applied inside `buildDetectors`. */
|
|
98
|
+
inGitRepo: boolean;
|
|
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;
|
|
103
|
+
export interface DiscoveryOptions {
|
|
104
|
+
/** Override $HOME. Defaults to `os.homedir()`. */
|
|
105
|
+
homeDir?: string;
|
|
106
|
+
/** Override cwd. Defaults to `process.cwd()`. */
|
|
107
|
+
cwd?: string;
|
|
108
|
+
}
|
|
109
|
+
/** Scan all known on-disk locations and return a flat list of discoveries.
|
|
110
|
+
* Reads file contents synchronously — discovery is one-shot and typically
|
|
111
|
+
* touches a handful of small files, so the simplicity wins over async sprawl.
|
|
112
|
+
*
|
|
113
|
+
* Single source of truth for which ecosystems we know about is the shared
|
|
114
|
+
* `DETECTORS` table in `commands/ingest.ts`. Each detector's `scanPaths`
|
|
115
|
+
* decides where to look on disk, `slugFor` extracts the slug, and
|
|
116
|
+
* `canonicalFilename` produces the upload-shaped filename. */
|
|
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 };
|
|
121
|
+
/** Human-readable label per ecosystem for the TUI section header. */
|
|
122
|
+
export declare function ecosystemLabel(ecosystem: DiscoveryEcosystem): string;
|
|
123
|
+
/** Format a byte count as "0.1 KB" / "2.4 KB" etc. — matches the spec sample.
|
|
124
|
+
* Single decimal place keeps narrow rows aligned in the TUI. */
|
|
125
|
+
export declare function formatBytes(n: number): string;
|
|
126
|
+
/** Files smaller than this are unchecked by default (likely stub files the
|
|
127
|
+
* user hasn't filled in yet). 100 bytes ≈ a one-line H1 plus a blank line. */
|
|
128
|
+
export declare const STUB_BYTE_THRESHOLD = 100;
|