hanka-cli 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.
- package/README.md +75 -0
- package/dist/index.js +208 -0
- package/package.json +24 -0
- package/src/index.ts +244 -0
- package/tsconfig.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Hanka CLI
|
|
2
|
+
|
|
3
|
+
CLI tool for fetching and managing AI agent skills from Hanka instances.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx hanka --help
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
### `hanka login`
|
|
14
|
+
|
|
15
|
+
Authenticate with GitHub using Device Flow. Required for accessing private repos.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx hanka login
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### `hanka logout`
|
|
22
|
+
|
|
23
|
+
Remove stored GitHub credentials.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx hanka logout
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### `hanka whoami`
|
|
30
|
+
|
|
31
|
+
Show currently authenticated user.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx hanka whoami
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### `hanka add <instance> <username/slug>`
|
|
38
|
+
|
|
39
|
+
Fetch a public skill from a Hanka instance.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx hanka add https://hanka.example.com johndoe/docx-writer
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
- `-p, --path <dir>` - Output directory (default: current directory)
|
|
47
|
+
- `--stdout` - Print to stdout instead of writing a file
|
|
48
|
+
- `--repo <repo>` - GitHub repo name (default: hanka-skills)
|
|
49
|
+
|
|
50
|
+
### `hanka get <username/repo:slug>`
|
|
51
|
+
|
|
52
|
+
Fetch a skill directly from GitHub (supports private repos, requires login).
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx hanka get johndoe/hanka-skills:docx-writer
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Options:
|
|
59
|
+
- `-p, --path <dir>` - Output directory (default: current directory)
|
|
60
|
+
- `--stdout` - Print to stdout instead of writing a file
|
|
61
|
+
|
|
62
|
+
### `hanka list <instance> <username>`
|
|
63
|
+
|
|
64
|
+
List public skills from a Hanka instance.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx hanka list https://hanka.example.com johndoe
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Options:
|
|
71
|
+
- `--repo <repo>` - GitHub repo name (default: hanka-skills)
|
|
72
|
+
|
|
73
|
+
## Environment Variables
|
|
74
|
+
|
|
75
|
+
- `HANKA_CLIENT_ID` - GitHub OAuth App Client ID (optional, uses embedded default)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import Conf from 'conf';
|
|
6
|
+
import { Octokit } from '@octokit/rest';
|
|
7
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
const GITHUB_CLIENT_ID = process.env.HANKA_CLIENT_ID ?? 'YOUR_GITHUB_OAUTH_APP_CLIENT_ID';
|
|
10
|
+
const config = new Conf({ projectName: 'hanka' });
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name('hanka')
|
|
14
|
+
.version('0.1.0')
|
|
15
|
+
.description('Hanka CLI — manage and fetch AI agent skills');
|
|
16
|
+
program
|
|
17
|
+
.command('login')
|
|
18
|
+
.description('Authenticate with GitHub (enables private repo access)')
|
|
19
|
+
.action(async () => {
|
|
20
|
+
const spinner = ora('Requesting device code...').start();
|
|
21
|
+
const deviceRes = await fetch('https://github.com/login/device/code', {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
24
|
+
body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: 'repo' }),
|
|
25
|
+
});
|
|
26
|
+
const deviceData = await deviceRes.json();
|
|
27
|
+
spinner.stop();
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(chalk.bold('Open this URL in your browser:'));
|
|
30
|
+
console.log(chalk.cyan(' ' + deviceData.verification_uri));
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(chalk.bold('Enter this code when prompted:'));
|
|
33
|
+
console.log(chalk.yellow(' ' + deviceData.user_code));
|
|
34
|
+
console.log();
|
|
35
|
+
const pollSpinner = ora('Waiting for authorization...').start();
|
|
36
|
+
const deadline = Date.now() + deviceData.expires_in * 1000;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
await new Promise(r => setTimeout(r, (deviceData.interval + 1) * 1000));
|
|
39
|
+
const pollRes = await fetch('https://github.com/login/oauth/access_token', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
client_id: GITHUB_CLIENT_ID,
|
|
44
|
+
device_code: deviceData.device_code,
|
|
45
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
const pollData = await pollRes.json();
|
|
49
|
+
if (pollData.access_token) {
|
|
50
|
+
const userRes = await fetch('https://api.github.com/user', {
|
|
51
|
+
headers: { Authorization: `Bearer ${pollData.access_token}` },
|
|
52
|
+
});
|
|
53
|
+
const { login } = await userRes.json();
|
|
54
|
+
config.set('token', pollData.access_token);
|
|
55
|
+
config.set('username', login);
|
|
56
|
+
pollSpinner.succeed(chalk.green(`Logged in as @${login}`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (pollData.error && pollData.error !== 'authorization_pending' && pollData.error !== 'slow_down') {
|
|
60
|
+
pollSpinner.fail(`Authentication failed: ${pollData.error}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
pollSpinner.fail('Timed out waiting for authorization');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
program
|
|
68
|
+
.command('logout')
|
|
69
|
+
.description('Remove stored GitHub credentials')
|
|
70
|
+
.action(() => {
|
|
71
|
+
config.delete('token');
|
|
72
|
+
config.delete('username');
|
|
73
|
+
console.log(chalk.green('Logged out successfully'));
|
|
74
|
+
});
|
|
75
|
+
program
|
|
76
|
+
.command('whoami')
|
|
77
|
+
.description('Show currently authenticated GitHub user')
|
|
78
|
+
.action(() => {
|
|
79
|
+
const username = config.get('username');
|
|
80
|
+
if (username) {
|
|
81
|
+
console.log(chalk.green(`Logged in as @${username}`));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
console.log(chalk.yellow('Not logged in. Run: npx hanka login'));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
program
|
|
88
|
+
.command('add <instance> <target>')
|
|
89
|
+
.description('Fetch a public skill from a Hanka instance')
|
|
90
|
+
.option('-p, --path <dir>', 'Output directory (default: current directory)')
|
|
91
|
+
.option('--stdout', 'Print to stdout instead of writing a file')
|
|
92
|
+
.option('--repo <repo>', 'GitHub repo name (default: hanka-skills)', 'hanka-skills')
|
|
93
|
+
.action(async (instance, target, opts) => {
|
|
94
|
+
const parts = target.split('/');
|
|
95
|
+
if (parts.length !== 2) {
|
|
96
|
+
console.error(chalk.red('Format: hanka add <instance-url> <username/slug>'));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const [username, slug] = parts;
|
|
100
|
+
const cleanInstance = instance.replace(/\/$/, '');
|
|
101
|
+
const spinner = ora(`Fetching ${chalk.cyan(slug)} from ${cleanInstance}...`).start();
|
|
102
|
+
const res = await fetch(`${cleanInstance}/api/public/${username}/${slug}?repo=${opts.repo}`);
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
spinner.fail(`Skill not found or not public (${res.status})`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
const data = await res.json();
|
|
108
|
+
spinner.succeed(`Fetched: ${chalk.bold(data.name)}`);
|
|
109
|
+
if (opts.stdout) {
|
|
110
|
+
process.stdout.write(data.rawMarkdown);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const outDir = opts.path ?? '.';
|
|
114
|
+
mkdirSync(outDir, { recursive: true });
|
|
115
|
+
const outPath = join(outDir, `${slug}.md`);
|
|
116
|
+
writeFileSync(outPath, data.rawMarkdown, 'utf-8');
|
|
117
|
+
console.log(chalk.green(`✓ Saved to ${outPath}`));
|
|
118
|
+
});
|
|
119
|
+
program
|
|
120
|
+
.command('get <target>')
|
|
121
|
+
.description('Fetch a skill directly from GitHub (supports private repos, requires login)')
|
|
122
|
+
.option('-p, --path <dir>', 'Output directory (default: current directory)')
|
|
123
|
+
.option('--stdout', 'Print to stdout instead of writing a file')
|
|
124
|
+
.action(async (target, opts) => {
|
|
125
|
+
const token = config.get('token');
|
|
126
|
+
if (!token) {
|
|
127
|
+
console.error(chalk.red('Not logged in. Run: npx hanka login'));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
const match = target.match(/^([^/]+)\/([^:]+):(.+)$/);
|
|
131
|
+
if (!match) {
|
|
132
|
+
console.error(chalk.red('Format: hanka get username/repo:slug'));
|
|
133
|
+
console.error(chalk.gray('Example: hanka get johndoe/hanka-skills:docx-writer'));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
const [, owner, repo, slug] = match;
|
|
137
|
+
const spinner = ora(`Fetching ${chalk.cyan(slug)} from ${owner}/${repo}...`).start();
|
|
138
|
+
const octokit = new Octokit({ auth: token });
|
|
139
|
+
let filePath;
|
|
140
|
+
try {
|
|
141
|
+
const { data } = await octokit.rest.repos.getContent({ owner, repo, path: 'index.json' });
|
|
142
|
+
if (!('content' in data))
|
|
143
|
+
throw new Error('index.json is not a file');
|
|
144
|
+
const index = JSON.parse(Buffer.from(data.content, 'base64').toString('utf-8'));
|
|
145
|
+
const entry = index.find(s => s.slug === slug);
|
|
146
|
+
if (!entry) {
|
|
147
|
+
spinner.fail(`Skill "${slug}" not found in index.json`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
filePath = entry.filePath;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err.status === 404) {
|
|
154
|
+
spinner.fail(`Repo ${owner}/${repo} not found or index.json missing`);
|
|
155
|
+
}
|
|
156
|
+
else if (err.status === 401) {
|
|
157
|
+
spinner.fail('Token expired or invalid. Run: npx hanka login');
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
spinner.fail(`Error: ${err.message}`);
|
|
161
|
+
}
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath });
|
|
165
|
+
if (!('content' in data)) {
|
|
166
|
+
spinner.fail('Could not read skill file');
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
const rawMarkdown = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
170
|
+
spinner.succeed(`Fetched: ${chalk.bold(slug)}`);
|
|
171
|
+
if (opts.stdout) {
|
|
172
|
+
process.stdout.write(rawMarkdown);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const outDir = opts.path ?? '.';
|
|
176
|
+
mkdirSync(outDir, { recursive: true });
|
|
177
|
+
const outPath = join(outDir, `${slug}.md`);
|
|
178
|
+
writeFileSync(outPath, rawMarkdown, 'utf-8');
|
|
179
|
+
console.log(chalk.green(`✓ Saved to ${outPath}`));
|
|
180
|
+
});
|
|
181
|
+
program
|
|
182
|
+
.command('list <instance> <username>')
|
|
183
|
+
.description('List public skills from a Hanka instance')
|
|
184
|
+
.option('--repo <repo>', 'GitHub repo name', 'hanka-skills')
|
|
185
|
+
.action(async (instance, username, opts) => {
|
|
186
|
+
const cleanInstance = instance.replace(/\/$/, '');
|
|
187
|
+
const spinner = ora('Fetching skill list...').start();
|
|
188
|
+
const res = await fetch(`${cleanInstance}/api/public/${username}?repo=${opts.repo}`);
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
spinner.fail(`Could not fetch skills for @${username}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const skills = await res.json();
|
|
194
|
+
spinner.stop();
|
|
195
|
+
if (skills.length === 0) {
|
|
196
|
+
console.log(chalk.yellow(`No public skills found for @${username}`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
console.log(chalk.bold(`\n${username}'s public skills (${skills.length}):\n`));
|
|
200
|
+
skills.forEach(s => {
|
|
201
|
+
const tags = s.tags?.map(t => chalk.gray(`#${t}`)).join(' ') ?? '';
|
|
202
|
+
console.log(` ${chalk.cyan(s.slug.padEnd(28))} ${s.name.padEnd(32)} ${tags}`);
|
|
203
|
+
});
|
|
204
|
+
console.log();
|
|
205
|
+
console.log(chalk.gray(`To fetch a skill: npx hanka add ${cleanInstance} ${username}/<slug>`));
|
|
206
|
+
console.log();
|
|
207
|
+
});
|
|
208
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hanka-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Hanka — fetch and manage agent skills",
|
|
5
|
+
"bin": { "hanka": "./dist/index.js" },
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"dev": "tsx src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"commander": "^12.0.0",
|
|
12
|
+
"chalk": "^5.3.0",
|
|
13
|
+
"ora": "^8.0.0",
|
|
14
|
+
"conf": "^13.0.0",
|
|
15
|
+
"@octokit/rest": "^21.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.0.0",
|
|
19
|
+
"tsx": "^4.0.0",
|
|
20
|
+
"@types/node": "^20.0.0"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": { "node": ">=18" }
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import ora from 'ora'
|
|
5
|
+
import Conf from 'conf'
|
|
6
|
+
import { Octokit } from '@octokit/rest'
|
|
7
|
+
import { writeFileSync, mkdirSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
|
|
10
|
+
const GITHUB_CLIENT_ID = process.env.HANKA_CLIENT_ID ?? 'YOUR_GITHUB_OAUTH_APP_CLIENT_ID'
|
|
11
|
+
|
|
12
|
+
const config = new Conf<{ token?: string; username?: string }>({ projectName: 'hanka' })
|
|
13
|
+
|
|
14
|
+
const program = new Command()
|
|
15
|
+
program
|
|
16
|
+
.name('hanka')
|
|
17
|
+
.version('0.1.0')
|
|
18
|
+
.description('Hanka CLI — manage and fetch AI agent skills')
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.command('login')
|
|
22
|
+
.description('Authenticate with GitHub (enables private repo access)')
|
|
23
|
+
.action(async () => {
|
|
24
|
+
const spinner = ora('Requesting device code...').start()
|
|
25
|
+
|
|
26
|
+
const deviceRes = await fetch('https://github.com/login/device/code', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
29
|
+
body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: 'repo' }),
|
|
30
|
+
})
|
|
31
|
+
const deviceData = await deviceRes.json() as {
|
|
32
|
+
device_code: string
|
|
33
|
+
user_code: string
|
|
34
|
+
verification_uri: string
|
|
35
|
+
interval: number
|
|
36
|
+
expires_in: number
|
|
37
|
+
}
|
|
38
|
+
spinner.stop()
|
|
39
|
+
|
|
40
|
+
console.log()
|
|
41
|
+
console.log(chalk.bold('Open this URL in your browser:'))
|
|
42
|
+
console.log(chalk.cyan(' ' + deviceData.verification_uri))
|
|
43
|
+
console.log()
|
|
44
|
+
console.log(chalk.bold('Enter this code when prompted:'))
|
|
45
|
+
console.log(chalk.yellow(' ' + deviceData.user_code))
|
|
46
|
+
console.log()
|
|
47
|
+
|
|
48
|
+
const pollSpinner = ora('Waiting for authorization...').start()
|
|
49
|
+
const deadline = Date.now() + deviceData.expires_in * 1000
|
|
50
|
+
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
await new Promise(r => setTimeout(r, (deviceData.interval + 1) * 1000))
|
|
53
|
+
|
|
54
|
+
const pollRes = await fetch('https://github.com/login/oauth/access_token', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
client_id: GITHUB_CLIENT_ID,
|
|
59
|
+
device_code: deviceData.device_code,
|
|
60
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
61
|
+
}),
|
|
62
|
+
})
|
|
63
|
+
const pollData = await pollRes.json() as { access_token?: string; error?: string }
|
|
64
|
+
|
|
65
|
+
if (pollData.access_token) {
|
|
66
|
+
const userRes = await fetch('https://api.github.com/user', {
|
|
67
|
+
headers: { Authorization: `Bearer ${pollData.access_token}` },
|
|
68
|
+
})
|
|
69
|
+
const { login } = await userRes.json() as { login: string }
|
|
70
|
+
|
|
71
|
+
config.set('token', pollData.access_token)
|
|
72
|
+
config.set('username', login)
|
|
73
|
+
pollSpinner.succeed(chalk.green(`Logged in as @${login}`))
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (pollData.error && pollData.error !== 'authorization_pending' && pollData.error !== 'slow_down') {
|
|
78
|
+
pollSpinner.fail(`Authentication failed: ${pollData.error}`)
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pollSpinner.fail('Timed out waiting for authorization')
|
|
84
|
+
process.exit(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
program
|
|
88
|
+
.command('logout')
|
|
89
|
+
.description('Remove stored GitHub credentials')
|
|
90
|
+
.action(() => {
|
|
91
|
+
config.delete('token')
|
|
92
|
+
config.delete('username')
|
|
93
|
+
console.log(chalk.green('Logged out successfully'))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
program
|
|
97
|
+
.command('whoami')
|
|
98
|
+
.description('Show currently authenticated GitHub user')
|
|
99
|
+
.action(() => {
|
|
100
|
+
const username = config.get('username')
|
|
101
|
+
if (username) {
|
|
102
|
+
console.log(chalk.green(`Logged in as @${username}`))
|
|
103
|
+
} else {
|
|
104
|
+
console.log(chalk.yellow('Not logged in. Run: npx hanka login'))
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
program
|
|
109
|
+
.command('add <instance> <target>')
|
|
110
|
+
.description('Fetch a public skill from a Hanka instance')
|
|
111
|
+
.option('-p, --path <dir>', 'Output directory (default: current directory)')
|
|
112
|
+
.option('--stdout', 'Print to stdout instead of writing a file')
|
|
113
|
+
.option('--repo <repo>', 'GitHub repo name (default: hanka-skills)', 'hanka-skills')
|
|
114
|
+
.action(async (instance: string, target: string, opts: { path?: string; stdout?: boolean; repo: string }) => {
|
|
115
|
+
const parts = target.split('/')
|
|
116
|
+
if (parts.length !== 2) {
|
|
117
|
+
console.error(chalk.red('Format: hanka add <instance-url> <username/slug>'))
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
const [username, slug] = parts
|
|
121
|
+
const cleanInstance = instance.replace(/\/$/, '')
|
|
122
|
+
|
|
123
|
+
const spinner = ora(`Fetching ${chalk.cyan(slug)} from ${cleanInstance}...`).start()
|
|
124
|
+
|
|
125
|
+
const res = await fetch(`${cleanInstance}/api/public/${username}/${slug}?repo=${opts.repo}`)
|
|
126
|
+
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
spinner.fail(`Skill not found or not public (${res.status})`)
|
|
129
|
+
process.exit(1)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const data = await res.json() as { rawMarkdown: string; name: string }
|
|
133
|
+
spinner.succeed(`Fetched: ${chalk.bold(data.name)}`)
|
|
134
|
+
|
|
135
|
+
if (opts.stdout) {
|
|
136
|
+
process.stdout.write(data.rawMarkdown)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const outDir = opts.path ?? '.'
|
|
141
|
+
mkdirSync(outDir, { recursive: true })
|
|
142
|
+
const outPath = join(outDir, `${slug}.md`)
|
|
143
|
+
writeFileSync(outPath, data.rawMarkdown, 'utf-8')
|
|
144
|
+
console.log(chalk.green(`✓ Saved to ${outPath}`))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
program
|
|
148
|
+
.command('get <target>')
|
|
149
|
+
.description('Fetch a skill directly from GitHub (supports private repos, requires login)')
|
|
150
|
+
.option('-p, --path <dir>', 'Output directory (default: current directory)')
|
|
151
|
+
.option('--stdout', 'Print to stdout instead of writing a file')
|
|
152
|
+
.action(async (target: string, opts: { path?: string; stdout?: boolean }) => {
|
|
153
|
+
const token = config.get('token')
|
|
154
|
+
if (!token) {
|
|
155
|
+
console.error(chalk.red('Not logged in. Run: npx hanka login'))
|
|
156
|
+
process.exit(1)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const match = target.match(/^([^/]+)\/([^:]+):(.+)$/)
|
|
160
|
+
if (!match) {
|
|
161
|
+
console.error(chalk.red('Format: hanka get username/repo:slug'))
|
|
162
|
+
console.error(chalk.gray('Example: hanka get johndoe/hanka-skills:docx-writer'))
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
const [, owner, repo, slug] = match
|
|
166
|
+
|
|
167
|
+
const spinner = ora(`Fetching ${chalk.cyan(slug)} from ${owner}/${repo}...`).start()
|
|
168
|
+
const octokit = new Octokit({ auth: token })
|
|
169
|
+
|
|
170
|
+
let filePath: string
|
|
171
|
+
try {
|
|
172
|
+
const { data } = await octokit.rest.repos.getContent({ owner, repo, path: 'index.json' })
|
|
173
|
+
if (!('content' in data)) throw new Error('index.json is not a file')
|
|
174
|
+
const index = JSON.parse(Buffer.from(data.content, 'base64').toString('utf-8')) as Array<{ slug: string; filePath: string }>
|
|
175
|
+
const entry = index.find(s => s.slug === slug)
|
|
176
|
+
if (!entry) {
|
|
177
|
+
spinner.fail(`Skill "${slug}" not found in index.json`)
|
|
178
|
+
process.exit(1)
|
|
179
|
+
}
|
|
180
|
+
filePath = entry.filePath
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
if (err.status === 404) {
|
|
183
|
+
spinner.fail(`Repo ${owner}/${repo} not found or index.json missing`)
|
|
184
|
+
} else if (err.status === 401) {
|
|
185
|
+
spinner.fail('Token expired or invalid. Run: npx hanka login')
|
|
186
|
+
} else {
|
|
187
|
+
spinner.fail(`Error: ${err.message}`)
|
|
188
|
+
}
|
|
189
|
+
process.exit(1)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { data } = await octokit.rest.repos.getContent({ owner, repo, path: filePath })
|
|
193
|
+
if (!('content' in data)) {
|
|
194
|
+
spinner.fail('Could not read skill file')
|
|
195
|
+
process.exit(1)
|
|
196
|
+
}
|
|
197
|
+
const rawMarkdown = Buffer.from(data.content, 'base64').toString('utf-8')
|
|
198
|
+
spinner.succeed(`Fetched: ${chalk.bold(slug)}`)
|
|
199
|
+
|
|
200
|
+
if (opts.stdout) {
|
|
201
|
+
process.stdout.write(rawMarkdown)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const outDir = opts.path ?? '.'
|
|
206
|
+
mkdirSync(outDir, { recursive: true })
|
|
207
|
+
const outPath = join(outDir, `${slug}.md`)
|
|
208
|
+
writeFileSync(outPath, rawMarkdown, 'utf-8')
|
|
209
|
+
console.log(chalk.green(`✓ Saved to ${outPath}`))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
program
|
|
213
|
+
.command('list <instance> <username>')
|
|
214
|
+
.description('List public skills from a Hanka instance')
|
|
215
|
+
.option('--repo <repo>', 'GitHub repo name', 'hanka-skills')
|
|
216
|
+
.action(async (instance: string, username: string, opts: { repo: string }) => {
|
|
217
|
+
const cleanInstance = instance.replace(/\/$/, '')
|
|
218
|
+
const spinner = ora('Fetching skill list...').start()
|
|
219
|
+
|
|
220
|
+
const res = await fetch(`${cleanInstance}/api/public/${username}?repo=${opts.repo}`)
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
spinner.fail(`Could not fetch skills for @${username}`)
|
|
223
|
+
process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const skills = await res.json() as Array<{ slug: string; name: string; description?: string; tags?: string[] }>
|
|
227
|
+
spinner.stop()
|
|
228
|
+
|
|
229
|
+
if (skills.length === 0) {
|
|
230
|
+
console.log(chalk.yellow(`No public skills found for @${username}`))
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log(chalk.bold(`\n${username}'s public skills (${skills.length}):\n`))
|
|
235
|
+
skills.forEach(s => {
|
|
236
|
+
const tags = s.tags?.map(t => chalk.gray(`#${t}`)).join(' ') ?? ''
|
|
237
|
+
console.log(` ${chalk.cyan(s.slug.padEnd(28))} ${s.name.padEnd(32)} ${tags}`)
|
|
238
|
+
})
|
|
239
|
+
console.log()
|
|
240
|
+
console.log(chalk.gray(`To fetch a skill: npx hanka add ${cleanInstance} ${username}/<slug>`))
|
|
241
|
+
console.log()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
program.parse()
|
package/tsconfig.json
ADDED