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 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
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "./dist",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["src/**/*"]
12
+ }