@botdocs/cli 0.3.1 → 0.3.2

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.
@@ -65,6 +65,11 @@ export async function publish(source, options) {
65
65
  console.error('Description is required. Use --description "..."');
66
66
  process.exit(1);
67
67
  }
68
+ // Pull type + sourceEcosystem from botdocs.json so the server stores
69
+ // the row as a SKILL/BUNDLE (instead of defaulting to SPEC).
70
+ const manifest = stat.isDirectory() ? readManifest(resolved) : null;
71
+ const botdocType = manifest?.type ?? 'SPEC';
72
+ const sourceEcosystem = manifest?.sourceEcosystem ?? null;
68
73
  console.log(`Publishing "${title}" (${files.length} file(s))...`);
69
74
  const result = await apiFetch('/api/botdocs', {
70
75
  method: 'POST',
@@ -76,6 +81,8 @@ export async function publish(source, options) {
76
81
  tags,
77
82
  license,
78
83
  files,
84
+ botdocType,
85
+ sourceEcosystem,
79
86
  },
80
87
  });
81
88
  if (options.json) {
@@ -85,6 +92,21 @@ export async function publish(source, options) {
85
92
  console.log(`\nPublished: ${result.url}`);
86
93
  }
87
94
  }
95
+ function readManifest(source) {
96
+ const manifestPath = path.join(source, 'botdocs.json');
97
+ if (!fs.existsSync(manifestPath))
98
+ return null;
99
+ try {
100
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
101
+ return parseManifest(raw);
102
+ }
103
+ catch {
104
+ // Validate is the canonical place to surface manifest errors; here we
105
+ // silently fall back so a malformed file doesn't break a publish that
106
+ // was already going to default to SPEC anyway.
107
+ return null;
108
+ }
109
+ }
88
110
  async function maybeAutoCompile(source, options) {
89
111
  if (options.noCompile)
90
112
  return;
@@ -124,25 +146,38 @@ function collectFromFile(filePath) {
124
146
  return [{ filename, content, sortOrder: 0 }];
125
147
  }
126
148
  function collectFromDirectory(dirPath) {
127
- const entries = fs.readdirSync(dirPath);
128
149
  const files = [];
129
- for (let i = 0; i < entries.length; i++) {
130
- const entry = entries[i];
150
+ walkDirectory(dirPath, dirPath, files);
151
+ files.sort((a, b) => a.sortOrder - b.sortOrder);
152
+ return files;
153
+ }
154
+ function walkDirectory(rootDir, currentDir, out) {
155
+ const entries = fs.readdirSync(currentDir);
156
+ for (const entry of entries) {
131
157
  if (entry.startsWith('.'))
132
158
  continue;
133
- const fullPath = path.join(dirPath, entry);
159
+ const fullPath = path.join(currentDir, entry);
134
160
  const stat = fs.statSync(fullPath);
135
- if (stat.isFile() && (entry.endsWith('.md') || entry.endsWith('.markdown'))) {
136
- files.push({
137
- filename: entry,
138
- content: fs.readFileSync(fullPath, 'utf-8'),
139
- sortOrder: entry === 'index.md' ? 0 : i + 1,
140
- });
161
+ if (stat.isDirectory()) {
162
+ walkDirectory(rootDir, fullPath, out);
163
+ continue;
141
164
  }
165
+ if (!stat.isFile())
166
+ continue;
167
+ if (!entry.endsWith('.md') && !entry.endsWith('.markdown'))
168
+ continue;
169
+ // POSIX-style relative path so the install-time prefix check
170
+ // (e.g. `claude/SKILL.md`) works on every platform.
171
+ const relative = path
172
+ .relative(rootDir, fullPath)
173
+ .split(path.sep)
174
+ .join('/');
175
+ out.push({
176
+ filename: relative,
177
+ content: fs.readFileSync(fullPath, 'utf-8'),
178
+ sortOrder: relative === 'index.md' ? 0 : out.length + 1,
179
+ });
142
180
  }
143
- // Ensure index.md is first
144
- files.sort((a, b) => a.sortOrder - b.sortOrder);
145
- return files;
146
181
  }
147
182
  function collectFromZip(zipPath) {
148
183
  const zip = new AdmZip(zipPath);
@@ -152,11 +187,13 @@ function collectFromZip(zipPath) {
152
187
  const entry = entries[i];
153
188
  if (entry.isDirectory)
154
189
  continue;
155
- // Get the filename (strip any directory prefix)
156
- const filename = path.basename(entry.entryName);
190
+ // POSIX-style path inside the archive preserves directory prefixes
191
+ // (e.g. `claude/SKILL.md`) so install can route the file correctly.
192
+ const filename = entry.entryName.replace(/\\/g, '/');
193
+ const basename = path.basename(filename);
157
194
  if (!filename.endsWith('.md') && !filename.endsWith('.markdown'))
158
195
  continue;
159
- if (filename.startsWith('.'))
196
+ if (basename.startsWith('.'))
160
197
  continue;
161
198
  files.push({
162
199
  filename,
@@ -73,4 +73,66 @@ describe('publish auto-compile gate', () => {
73
73
  await publish(root, { description: 'desc' });
74
74
  expect(compileMod.compile).toHaveBeenCalledTimes(1);
75
75
  });
76
+ it('uploads nested files with directory-prefixed filenames', async () => {
77
+ // Build a tree with files in subdirectories — we need to know what
78
+ // ends up in the POST body so install can route by the `claude/` /
79
+ // `claude-code/` prefix.
80
+ const root = path.join(tmp.dir, 'nested-skill');
81
+ fs.mkdirSync(path.join(root, 'claude'), { recursive: true });
82
+ fs.mkdirSync(path.join(root, 'claude-code', 'commands'), { recursive: true });
83
+ fs.writeFileSync(path.join(root, 'index.md'), '# Top\n\n' + 'a'.repeat(150));
84
+ fs.writeFileSync(path.join(root, 'claude', 'SKILL.md'), '# Claude content');
85
+ fs.writeFileSync(path.join(root, 'claude-code', 'commands', 'cli.md'), '# CC content');
86
+ const fm = mockFetch([
87
+ {
88
+ method: 'POST',
89
+ url: '/api/botdocs',
90
+ response: { body: { id: 'b1', slug: 'nested-skill', url: 'http://x/y' } },
91
+ },
92
+ ]);
93
+ restoreFetch = fm.restore;
94
+ await publish(root, { description: 'desc', noCompile: true });
95
+ const post = fm.calls.find((c) => c.url.includes('/api/botdocs') && c.method === 'POST');
96
+ const body = post?.body;
97
+ const filenames = (body?.files ?? []).map((f) => f.filename).sort();
98
+ expect(filenames).toEqual([
99
+ 'claude-code/commands/cli.md',
100
+ 'claude/SKILL.md',
101
+ 'index.md',
102
+ ]);
103
+ });
104
+ it('forwards botdocType + sourceEcosystem from botdocs.json', async () => {
105
+ const root = setupCanonicalSkill();
106
+ const fm = mockFetch([
107
+ {
108
+ method: 'POST',
109
+ url: '/api/botdocs',
110
+ response: { body: { id: 'b1', slug: 'my-skill', url: 'http://x/y' } },
111
+ },
112
+ ]);
113
+ restoreFetch = fm.restore;
114
+ await publish(root, { description: 'desc', noCompile: true });
115
+ const post = fm.calls.find((c) => c.url.includes('/api/botdocs') && c.method === 'POST');
116
+ const body = post?.body;
117
+ expect(body.botdocType).toBe('SKILL');
118
+ expect(body.sourceEcosystem).toBe('claude-code');
119
+ });
120
+ it('defaults to SPEC when no botdocs.json is present', async () => {
121
+ const root = path.join(tmp.dir, 'plain-spec');
122
+ fs.mkdirSync(root, { recursive: true });
123
+ fs.writeFileSync(path.join(root, 'index.md'), '# Spec\n\n' + 'a'.repeat(150));
124
+ const fm = mockFetch([
125
+ {
126
+ method: 'POST',
127
+ url: '/api/botdocs',
128
+ response: { body: { id: 'b1', slug: 'plain-spec', url: 'http://x/y' } },
129
+ },
130
+ ]);
131
+ restoreFetch = fm.restore;
132
+ await publish(root, { description: 'desc' });
133
+ const post = fm.calls.find((c) => c.url.includes('/api/botdocs') && c.method === 'POST');
134
+ const body = post?.body;
135
+ expect(body.botdocType).toBe('SPEC');
136
+ expect(body.sourceEcosystem).toBeNull();
137
+ });
76
138
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botdocs/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "CLI for BotDocs — clone, search, publish, and endorse concept specifications.",
5
5
  "keywords": [
6
6
  "botdocs",