@agent-nexus/csreg 0.1.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.
@@ -0,0 +1,276 @@
1
+ import { Command } from 'commander';
2
+ import { resolve, join, dirname } from 'node:path';
3
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, renameSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { confirm } from '@inquirer/prompts';
6
+ import { ApiClient } from '../api-client.js';
7
+ import { extract } from '../lib/archive.js';
8
+ import { findClaudeSkillsDir } from '../lib/discovery.js';
9
+ import { spinner, success, info, warn } from '../lib/output.js';
10
+ import { handleError, CliError } from '../lib/errors.js';
11
+
12
+ interface DownloadResponse {
13
+ downloadUrl: string;
14
+ archiveSha256: string;
15
+ archiveSizeBytes: number;
16
+ version: string;
17
+ }
18
+
19
+ interface SkillLockEntry {
20
+ ref: string;
21
+ version?: string;
22
+ }
23
+
24
+ interface SkillsConfig {
25
+ skills: SkillLockEntry[];
26
+ }
27
+
28
+ function parseSkillRef(ref: string): { scope: string; name: string; version?: string } {
29
+ // Strip leading @ (e.g., @nexus/newsletter → nexus/newsletter)
30
+ const cleaned = ref.startsWith('@') ? ref.slice(1) : ref;
31
+
32
+ // Check for version suffix: nexus/newsletter@1.0.0
33
+ const atIndex = cleaned.lastIndexOf('@');
34
+ let scopeAndName: string;
35
+ let version: string | undefined;
36
+
37
+ if (atIndex > 0) {
38
+ scopeAndName = cleaned.slice(0, atIndex);
39
+ version = cleaned.slice(atIndex + 1);
40
+ } else {
41
+ scopeAndName = cleaned;
42
+ }
43
+
44
+ const slashIndex = scopeAndName.indexOf('/');
45
+ if (slashIndex < 0) {
46
+ throw new CliError(`Invalid skill reference: ${ref}`, [
47
+ 'Use the format: @scope/name or @scope/name@version',
48
+ ]);
49
+ }
50
+
51
+ return {
52
+ scope: scopeAndName.slice(0, slashIndex),
53
+ name: scopeAndName.slice(slashIndex + 1),
54
+ version,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Read the skills config file (.claude/skills.json).
60
+ */
61
+ function readSkillsConfig(): SkillsConfig | null {
62
+ // Look for .claude/skills.json by walking up
63
+ let dir = resolve('.');
64
+ while (dir !== dirname(dir)) {
65
+ const configPath = join(dir, '.claude', 'skills.json');
66
+ if (existsSync(configPath)) {
67
+ try {
68
+ const raw = readFileSync(configPath, 'utf-8');
69
+ return JSON.parse(raw) as SkillsConfig;
70
+ } catch {
71
+ throw new CliError('Failed to parse .claude/skills.json', [
72
+ 'Check that the file is valid JSON.',
73
+ ]);
74
+ }
75
+ }
76
+ dir = dirname(dir);
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Pull a single skill to a target directory.
83
+ */
84
+ async function pullSkill(
85
+ scope: string,
86
+ name: string,
87
+ version: string | undefined,
88
+ targetDir: string,
89
+ ): Promise<{ version: string }> {
90
+ const spin = spinner(`Fetching ${scope}/${name}${version ? `@${version}` : ''}...`);
91
+
92
+ const client = new ApiClient();
93
+ const query: Record<string, string> = {};
94
+ if (version) {
95
+ query.version = version;
96
+ }
97
+
98
+ const downloadInfo = await client.get<DownloadResponse>(
99
+ `/api/v1/skills/${scope}/${name}/download`,
100
+ { query },
101
+ );
102
+ spin.succeed(`Found ${scope}/${name}@${downloadInfo.version}`);
103
+
104
+ // Download the archive
105
+ spin.start('Downloading archive...');
106
+ const response = await fetch(downloadInfo.downloadUrl);
107
+ if (!response.ok) {
108
+ spin.fail('Download failed.');
109
+ throw new CliError(`Download failed with status ${response.status}.`);
110
+ }
111
+
112
+ const arrayBuffer = await response.arrayBuffer();
113
+ const archiveData = Buffer.from(arrayBuffer);
114
+
115
+ // Verify SHA-256
116
+ const actualSha = createHash('sha256').update(archiveData).digest('hex');
117
+ if (actualSha !== downloadInfo.archiveSha256) {
118
+ spin.fail('Integrity check failed.');
119
+ throw new CliError(
120
+ `SHA-256 mismatch: expected ${downloadInfo.archiveSha256}, got ${actualSha}`,
121
+ ['The archive may be corrupted. Try again.'],
122
+ );
123
+ }
124
+ spin.succeed('Downloaded and verified.');
125
+
126
+ // Ensure target directory exists
127
+ mkdirSync(targetDir, { recursive: true });
128
+
129
+ // Write temp file and extract to a temp location, then move
130
+ spin.start('Installing...');
131
+ const tempDir = join(dirname(targetDir), `.csreg-tmp-${Date.now()}`);
132
+ mkdirSync(tempDir, { recursive: true });
133
+ const tempArchive = join(tempDir, `${name}.tar.gz`);
134
+ writeFileSync(tempArchive, archiveData);
135
+
136
+ try {
137
+ await extract(tempArchive, tempDir, downloadInfo.archiveSha256);
138
+
139
+ // The archive extracts to tempDir/<name>/ — move contents to targetDir
140
+ const extractedDir = join(tempDir, name);
141
+ const finalDir = join(targetDir, name);
142
+
143
+ // Remove existing skill directory if it exists (update)
144
+ if (existsSync(finalDir)) {
145
+ // Remove recursively
146
+ const { rmSync } = await import('node:fs');
147
+ rmSync(finalDir, { recursive: true });
148
+ }
149
+
150
+ renameSync(extractedDir, finalDir);
151
+ spin.succeed(`Installed to ${finalDir}`);
152
+
153
+ return { version: downloadInfo.version };
154
+ } finally {
155
+ // Clean up temp dir
156
+ try {
157
+ const { rmSync } = await import('node:fs');
158
+ rmSync(tempDir, { recursive: true, force: true });
159
+ } catch {
160
+ // ignore cleanup errors
161
+ }
162
+ }
163
+ }
164
+
165
+ export const pullCommand = new Command('pull')
166
+ .description('Download and install a skill')
167
+ .argument('[ref]', 'Skill reference (@scope/name[@version])')
168
+ .option('--all', 'Pull all skills listed in .claude/skills.json')
169
+ .option('--path <dir>', 'Custom install directory (overrides auto-detection)')
170
+ .action(async (ref: string | undefined, opts: { all?: boolean; path?: string }) => {
171
+ try {
172
+ // --- Mode: pull --all from skills.json ---
173
+ if (opts.all) {
174
+ const config = readSkillsConfig();
175
+ if (!config) {
176
+ throw new CliError('No .claude/skills.json found.', [
177
+ 'Create .claude/skills.json with a "skills" array.',
178
+ 'Example:\n {\n "skills": [\n { "ref": "@nexus/newsletter" },\n { "ref": "@nexus/code-review", "version": "1.2.0" }\n ]\n }',
179
+ ]);
180
+ }
181
+
182
+ if (!config.skills || config.skills.length === 0) {
183
+ info('No skills listed in .claude/skills.json. Nothing to pull.');
184
+ return;
185
+ }
186
+
187
+ // Determine target
188
+ const skillsDir = opts.path ? resolve(opts.path) : findClaudeSkillsDir();
189
+ if (!skillsDir) {
190
+ throw new CliError('Cannot find .claude/ directory.', [
191
+ 'Run this from a project with a .claude/ directory, or use --path.',
192
+ ]);
193
+ }
194
+
195
+ console.log(`Installing ${config.skills.length} skill(s) to ${skillsDir}/\n`);
196
+
197
+ let installed = 0;
198
+ for (const entry of config.skills) {
199
+ try {
200
+ const { scope, name, version } = parseSkillRef(entry.ref);
201
+ const ver = entry.version || version;
202
+ await pullSkill(scope, name, ver, skillsDir);
203
+ installed++;
204
+ console.log('');
205
+ } catch (err) {
206
+ warn(`Failed to pull ${entry.ref}: ${err instanceof Error ? err.message : String(err)}`);
207
+ console.log('');
208
+ }
209
+ }
210
+
211
+ console.log('');
212
+ success(`Installed ${installed}/${config.skills.length} skill(s).`);
213
+ return;
214
+ }
215
+
216
+ // --- Mode: pull single skill ---
217
+ if (!ref) {
218
+ throw new CliError('Missing skill reference.', [
219
+ 'Usage: csreg pull @scope/name[@version]',
220
+ 'Or use: csreg pull --all (to pull from .claude/skills.json)',
221
+ ]);
222
+ }
223
+
224
+ const { scope, name, version } = parseSkillRef(ref);
225
+
226
+ // Determine install target
227
+ let targetDir: string;
228
+
229
+ if (opts.path) {
230
+ targetDir = resolve(opts.path);
231
+ } else {
232
+ const detected = findClaudeSkillsDir();
233
+
234
+ if (detected) {
235
+ targetDir = detected;
236
+ const finalPath = join(detected, name);
237
+ const ok = await confirm({
238
+ message: `Install to ${finalPath}?`,
239
+ default: true,
240
+ });
241
+ if (!ok) {
242
+ const { input } = await import('@inquirer/prompts');
243
+ const custom = await input({
244
+ message: 'Custom install path:',
245
+ default: join('.', '.claude', 'skills'),
246
+ });
247
+ targetDir = resolve(custom);
248
+ }
249
+ } else {
250
+ // No .claude/ found — default to .claude/skills/ in cwd
251
+ targetDir = join(resolve('.'), '.claude', 'skills');
252
+ info(`No .claude/ directory found. Will create ${targetDir}/`);
253
+ const ok = await confirm({
254
+ message: `Install to ${join(targetDir, name)}?`,
255
+ default: true,
256
+ });
257
+ if (!ok) {
258
+ const { input } = await import('@inquirer/prompts');
259
+ const custom = await input({
260
+ message: 'Custom install path:',
261
+ default: targetDir,
262
+ });
263
+ targetDir = resolve(custom);
264
+ }
265
+ }
266
+ }
267
+
268
+ const result = await pullSkill(scope, name, version, targetDir);
269
+
270
+ console.log('');
271
+ success(`Pulled ${scope}/${name}@${result.version}`);
272
+ info(`Skill is ready at ${join(targetDir, name)}/SKILL.md`);
273
+ } catch (err) {
274
+ handleError(err);
275
+ }
276
+ });
@@ -0,0 +1,228 @@
1
+ import { Command } from 'commander';
2
+ import { resolve, join, basename } from 'node:path';
3
+ import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { runValidation } from './validate.js';
6
+ import { pack as archivePack } from '../lib/archive.js';
7
+ import { parseManifest } from '../lib/manifest.js';
8
+ import { findClaudeSkillsDir, discoverSkillDirs } from '../lib/discovery.js';
9
+ import { ApiClient } from '../api-client.js';
10
+ import { spinner, success, warn } from '../lib/output.js';
11
+ import { handleError, CliError } from '../lib/errors.js';
12
+ import { getAuthToken } from '../config.js';
13
+
14
+ interface PrepareResponse {
15
+ versionId: string;
16
+ uploadUrl: string;
17
+ }
18
+
19
+ function collectFileTree(dir: string, prefix = ''): Array<{ path: string; size: number; sha256: string }> {
20
+ const files: Array<{ path: string; size: number; sha256: string }> = [];
21
+ const entries = readdirSync(dir, { withFileTypes: true });
22
+
23
+ for (const entry of entries) {
24
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
25
+ const fullPath = join(dir, entry.name);
26
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
27
+
28
+ if (entry.isDirectory()) {
29
+ files.push(...collectFileTree(fullPath, relativePath));
30
+ } else {
31
+ const stat = statSync(fullPath);
32
+ const content = readFileSync(fullPath);
33
+ const sha256 = createHash('sha256').update(content).digest('hex');
34
+ files.push({ path: relativePath, size: stat.size, sha256 });
35
+ }
36
+ }
37
+ return files;
38
+ }
39
+
40
+ /**
41
+ * Push a single skill directory to the registry.
42
+ * Returns the published version string, or throws on failure.
43
+ */
44
+ async function pushSingle(resolved: string): Promise<string> {
45
+ // Step 1: Validate
46
+ const spin = spinner('Validating skill...');
47
+ const validation = runValidation(resolved);
48
+ if (!validation.valid) {
49
+ spin.fail('Validation failed.');
50
+ for (const e of validation.errors) {
51
+ console.error(' ' + e);
52
+ }
53
+ throw new CliError('Fix validation errors before pushing.');
54
+ }
55
+ spin.succeed('Validation passed.');
56
+
57
+ // Step 2: Parse manifest from SKILL.md frontmatter
58
+ const manifest = parseManifest(resolved);
59
+
60
+ if (!manifest.version) {
61
+ throw new CliError('Missing "version" in SKILL.md frontmatter.', [
62
+ 'Add a version field: version: "1.0.0"',
63
+ ]);
64
+ }
65
+ if (!manifest.scope) {
66
+ throw new CliError('Missing "scope" in SKILL.md frontmatter.', [
67
+ 'Add a scope field: scope: my-team',
68
+ ]);
69
+ }
70
+
71
+ // Step 3: Pack
72
+ spin.start('Packing skill...');
73
+ const archive = await archivePack(resolved);
74
+ spin.succeed(`Packed (${(archive.size / 1024).toFixed(1)}KB).`);
75
+
76
+ // Step 4: Ensure skill exists in registry (auto-create on first push)
77
+ const client = new ApiClient();
78
+ spin.start('Checking registry...');
79
+ try {
80
+ await client.get(`/api/v1/skills/${manifest.scope}/${manifest.name}`);
81
+ spin.succeed('Skill found in registry.');
82
+ } catch {
83
+ spin.start(`Creating skill ${manifest.scope}/${manifest.name}...`);
84
+ await client.post('/api/v1/skills', {
85
+ body: {
86
+ scope: manifest.scope,
87
+ slug: manifest.name,
88
+ displayName: manifest.name,
89
+ description: manifest.description || '',
90
+ tags: manifest.tags || [],
91
+ },
92
+ });
93
+ spin.succeed(`Created skill ${manifest.scope}/${manifest.name}.`);
94
+ }
95
+
96
+ // Step 5: Prepare version
97
+ spin.start('Preparing version...');
98
+ const fileTree = collectFileTree(resolved);
99
+
100
+ const prepared = await client.post<PrepareResponse>(
101
+ `/api/v1/skills/${manifest.scope}/${manifest.name}/versions`,
102
+ {
103
+ body: {
104
+ version: manifest.version,
105
+ fileTree,
106
+ archiveSha256: archive.sha256,
107
+ archiveSize: archive.size,
108
+ entryPoint: 'SKILL.md',
109
+ manifestJson: {
110
+ name: manifest.name,
111
+ description: manifest.description,
112
+ version: manifest.version,
113
+ scope: manifest.scope,
114
+ 'allowed-tools': manifest['allowed-tools'],
115
+ 'argument-hint': manifest['argument-hint'],
116
+ 'disable-model-invocation': manifest['disable-model-invocation'],
117
+ 'user-invocable': manifest['user-invocable'],
118
+ tags: manifest.tags,
119
+ },
120
+ },
121
+ },
122
+ );
123
+ spin.succeed('Version prepared.');
124
+
125
+ // Step 6: Upload to presigned URL
126
+ spin.start('Uploading archive...');
127
+ const archiveData = readFileSync(archive.path);
128
+ const uploadResponse = await fetch(prepared.uploadUrl, {
129
+ method: 'PUT',
130
+ headers: {
131
+ 'Content-Type': 'application/gzip',
132
+ 'Content-Length': String(archive.size),
133
+ },
134
+ body: archiveData,
135
+ });
136
+
137
+ if (!uploadResponse.ok) {
138
+ spin.fail('Upload failed.');
139
+ throw new CliError(`Upload failed with status ${uploadResponse.status}.`, [
140
+ 'Try again. If the problem persists, contact support.',
141
+ ]);
142
+ }
143
+ spin.succeed('Archive uploaded.');
144
+
145
+ // Step 7: Finalize
146
+ spin.start('Finalizing version...');
147
+ await client.post(
148
+ `/api/v1/skills/${manifest.scope}/${manifest.name}/versions/${manifest.version}/finalize`,
149
+ {
150
+ body: { status: 'published' },
151
+ },
152
+ );
153
+ spin.succeed('Version finalized.');
154
+
155
+ return `${manifest.scope}/${manifest.name}@${manifest.version}`;
156
+ }
157
+
158
+ export const pushCommand = new Command('push')
159
+ .description('Publish a skill to the registry')
160
+ .argument('[dir]', 'Skill directory', '.')
161
+ .option('--all', 'Push all skills in .claude/skills/')
162
+ .action(async (dir: string, opts: { all?: boolean }) => {
163
+ try {
164
+ if (!getAuthToken()) {
165
+ throw new CliError('Not logged in.', ['Run `csreg login` to authenticate.']);
166
+ }
167
+
168
+ // --- Mode: push --all ---
169
+ if (opts.all) {
170
+ const skillsDir = findClaudeSkillsDir();
171
+ if (!skillsDir) {
172
+ throw new CliError('Cannot find .claude/skills/ directory.', [
173
+ 'Run this from a project with a .claude/ directory.',
174
+ ]);
175
+ }
176
+
177
+ const skillDirs = discoverSkillDirs(skillsDir);
178
+ if (skillDirs.length === 0) {
179
+ throw new CliError(`No skills found in ${skillsDir}/`, [
180
+ 'Skills must be directories containing a SKILL.md file.',
181
+ ]);
182
+ }
183
+
184
+ console.log(`Pushing ${skillDirs.length} skill(s) from ${skillsDir}/\n`);
185
+
186
+ let published = 0;
187
+ let failed = 0;
188
+
189
+ for (const skillDir of skillDirs) {
190
+ const name = basename(skillDir);
191
+ console.log(`\n--- ${name} ---\n`);
192
+
193
+ try {
194
+ const ref = await pushSingle(skillDir);
195
+ success(`Published ${ref}`);
196
+ published++;
197
+ } catch (err) {
198
+ const msg = err instanceof Error ? err.message : String(err);
199
+ warn(`Failed to push ${name}: ${msg}`);
200
+ failed++;
201
+ }
202
+ }
203
+
204
+ console.log(`\n${'─'.repeat(40)}\n`);
205
+ if (failed === 0) {
206
+ success(`All ${published} skill(s) published.`);
207
+ } else {
208
+ success(`Published ${published}/${published + failed} skill(s).`);
209
+ if (failed > 0) {
210
+ warn(`${failed} skill(s) failed. Check the errors above.`);
211
+ }
212
+ }
213
+ return;
214
+ }
215
+
216
+ // --- Mode: push single ---
217
+ const resolved = resolve(dir);
218
+ if (!existsSync(resolved)) {
219
+ throw new CliError(`Directory not found: ${resolved}`);
220
+ }
221
+
222
+ const ref = await pushSingle(resolved);
223
+ console.log('');
224
+ success(`Published ${ref}`);
225
+ } catch (err) {
226
+ handleError(err);
227
+ }
228
+ });
@@ -0,0 +1,58 @@
1
+ import { Command } from 'commander';
2
+ import { ApiClient } from '../api-client.js';
3
+ import { formatTable, info } from '../lib/output.js';
4
+ import { handleError } from '../lib/errors.js';
5
+
6
+ interface SearchResult {
7
+ scope: string;
8
+ name: string;
9
+ description?: string;
10
+ totalDownloads: number;
11
+ latestVersion: string;
12
+ }
13
+
14
+ interface SearchResponse {
15
+ skills: SearchResult[];
16
+ total: number;
17
+ }
18
+
19
+ export const searchCommand = new Command('search')
20
+ .description('Search for skills in the registry')
21
+ .argument('<query>', 'Search query')
22
+ .option('-t, --type <type>', 'Filter by skill type')
23
+ .option('-l, --limit <limit>', 'Max results', '20')
24
+ .action(async (query: string, opts: { type?: string; limit: string }) => {
25
+ try {
26
+ const client = new ApiClient();
27
+ const queryParams: Record<string, string> = {
28
+ q: query,
29
+ limit: opts.limit,
30
+ };
31
+ if (opts.type) {
32
+ queryParams.type = opts.type;
33
+ }
34
+
35
+ const data = await client.get<SearchResponse>('/api/v1/skills', {
36
+ query: queryParams,
37
+ });
38
+
39
+ if (data.skills.length === 0) {
40
+ info(`No skills found for "${query}".`);
41
+ return;
42
+ }
43
+
44
+ const rows = data.skills.map(s => [
45
+ `${s.scope}/${s.name}`,
46
+ s.description?.slice(0, 50) ?? '',
47
+ String(s.totalDownloads),
48
+ s.latestVersion,
49
+ ]);
50
+
51
+ console.log('');
52
+ console.log(formatTable(['Skill', 'Description', 'Downloads', 'Version'], rows));
53
+ console.log('');
54
+ info(`Showing ${data.skills.length} of ${data.total} results.`);
55
+ } catch (err) {
56
+ handleError(err);
57
+ }
58
+ });