kirohub 1.0.0 → 1.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [1.1.0] - 2026-02-23
4
+
5
+ ### Added
6
+ - Interactive CLI mode
7
+ - Search by resource type (`--type`)
8
+ - Search by category (`--category`)
9
+
10
+ ## [1.0.1] - Initial release
package/README.md CHANGED
@@ -2,35 +2,78 @@
2
2
 
3
3
  Install and discover Kiro resources from the command line.
4
4
 
5
- ## Usage
5
+ ## Installation
6
+
7
+ ```bash
8
+ npx kirohub
9
+ ```
10
+
11
+ ## Interactive Mode
12
+
13
+ Run without arguments to launch the interactive menu:
14
+
15
+ ```bash
16
+ npx kirohub
17
+ ```
18
+
19
+ This provides a guided experience to search, install, list, and remove resources with type and category filters.
20
+
21
+ ## Commands
22
+
23
+ ### Search
6
24
 
7
25
  ```bash
8
26
  npx kirohub search <query>
27
+ ```
28
+
29
+ Options:
30
+ - `-t, --type <type>` — Filter by type: `Steering`, `Hook`, `Power`, `Prompt`, `Agent`, `Skill`
31
+ - `-c, --category <cat>` — Filter by category: `aws`, `frontend`, `backend`, `api-design`, `documentation`, `devops`, `security`, `testing`, `database`, `ai-ml`, `architecture`, `developer-tools`, `typescript`, `python`, `java`, `rust`, `golang`, `csharp`
32
+ - `-l, --limit <n>` — Limit results (default: 10)
33
+ - `--json` — Output as JSON for scripting
34
+
35
+ Examples:
36
+
37
+ ```bash
38
+ npx kirohub search terraform
39
+ npx kirohub search aws --type Steering
40
+ npx kirohub search react --category frontend --limit 20
41
+ npx kirohub search api --type Hook --category backend --json
42
+ ```
43
+
44
+ ### Add
45
+
46
+ ```bash
9
47
  npx kirohub add <owner/resource>
48
+ ```
49
+
50
+ Options:
51
+ - `-y, --yes` — Skip confirmation prompt
52
+
53
+ ### List
54
+
55
+ ```bash
10
56
  npx kirohub list
11
57
  ```
12
58
 
13
- ## Examples
59
+ Shows installed resources in your `.kiro/` directory.
60
+
61
+ ### Remove
14
62
 
15
63
  ```bash
16
- npx kirohub search steering
17
- npx kirohub add owner/my-steering
18
- npx kirohub add kirodotdev/power-builder
64
+ npx kirohub remove [resource]
19
65
  ```
20
66
 
67
+ Options:
68
+ - `-y, --yes` — Skip confirmation prompt
69
+
70
+ If no resource is specified, shows an interactive picker.
71
+
21
72
  ## Publish (Maintainers)
22
73
 
23
74
  ```bash
24
75
  cd packages/cli
25
76
  npm install
26
77
  npm run build
27
- node dist/cli.js --help
28
- npm login
29
78
  npm publish --access public
30
79
  ```
31
-
32
- Test the published CLI:
33
-
34
- ```bash
35
- npx kirohub --help
36
- ```
package/dist/cli.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import * as p from '@clack/prompts';
3
4
  import { addResource } from './commands/add.js';
4
5
  import { listResources } from './commands/list.js';
5
- import { searchResources } from './commands/search.js';
6
+ import { searchResources, fetchResources } from './commands/search.js';
7
+ import { removeResource } from './commands/remove.js';
8
+ import { showBanner, showLogo } from './utils/banner.js';
6
9
  const program = new Command();
7
10
  program
8
11
  .name('kirohub')
@@ -11,8 +14,12 @@ program
11
14
  program
12
15
  .command('add <resource>')
13
16
  .description('Install a Kiro resource (e.g., owner/resource-name)')
17
+ .option('-y, --yes', 'Skip confirmation prompt')
14
18
  .option('--no-telemetry', 'Disable telemetry')
15
- .action(addResource);
19
+ .action((resource, options) => {
20
+ showLogo();
21
+ addResource(resource, options);
22
+ });
16
23
  program
17
24
  .command('list')
18
25
  .description('List installed resources')
@@ -20,6 +27,140 @@ program
20
27
  program
21
28
  .command('search <query>')
22
29
  .description('Search available resources')
23
- .option('-t, --type <type>', 'Filter by type')
30
+ .option('-t, --type <type>', 'Filter by type (Steering, Hook, Power, Prompt, Agent, Skill)')
31
+ .option('-c, --category <category>', 'Filter by category (aws, frontend, backend, etc.)')
32
+ .option('-l, --limit <n>', 'Limit results (default: 10)')
33
+ .option('--json', 'Output as JSON (for scripting)')
24
34
  .action(searchResources);
25
- program.parse();
35
+ program
36
+ .command('remove [resource]')
37
+ .description('Remove an installed resource')
38
+ .option('-y, --yes', 'Skip confirmation prompt')
39
+ .action((resource, options) => {
40
+ showLogo();
41
+ removeResource(resource, options);
42
+ });
43
+ async function interactiveMenu() {
44
+ showBanner();
45
+ while (true) {
46
+ const action = await p.select({
47
+ message: 'What would you like to do?',
48
+ options: [
49
+ { value: 'search', label: 'Search resources', hint: 'find and install from hub' },
50
+ { value: 'add', label: 'Add a resource', hint: 'install by slug' },
51
+ { value: 'list', label: 'List installed', hint: 'show local resources' },
52
+ { value: 'remove', label: 'Remove a resource', hint: 'uninstall' },
53
+ { value: 'exit', label: 'Exit' },
54
+ ],
55
+ });
56
+ if (p.isCancel(action) || action === 'exit') {
57
+ p.outro('Goodbye!');
58
+ break;
59
+ }
60
+ console.log();
61
+ if (action === 'search') {
62
+ const query = await p.text({
63
+ message: 'Search query:',
64
+ placeholder: 'e.g., aws, terraform, react',
65
+ });
66
+ if (p.isCancel(query))
67
+ continue;
68
+ const filterType = await p.select({
69
+ message: 'Filter by type?',
70
+ options: [
71
+ { value: '', label: 'All types' },
72
+ { value: 'Steering', label: 'Steering', hint: 'context files' },
73
+ { value: 'Hook', label: 'Hook', hint: 'event triggers' },
74
+ { value: 'Power', label: 'Power', hint: 'bundled packages' },
75
+ { value: 'Prompt', label: 'Prompt', hint: 'CLI prompts' },
76
+ { value: 'Agent', label: 'Agent', hint: 'agent configs' },
77
+ { value: 'Skill', label: 'Skill', hint: 'agent skills' },
78
+ ],
79
+ });
80
+ if (p.isCancel(filterType))
81
+ continue;
82
+ const filterCategory = await p.select({
83
+ message: 'Filter by category?',
84
+ options: [
85
+ { value: '', label: 'All categories' },
86
+ { value: 'aws', label: 'AWS' },
87
+ { value: 'frontend', label: 'Frontend' },
88
+ { value: 'backend', label: 'Backend' },
89
+ { value: 'api-design', label: 'API Design' },
90
+ { value: 'documentation', label: 'Documentation' },
91
+ { value: 'devops', label: 'DevOps' },
92
+ { value: 'security', label: 'Security' },
93
+ { value: 'testing', label: 'Testing' },
94
+ { value: 'database', label: 'Database' },
95
+ { value: 'ai-ml', label: 'AI/ML' },
96
+ { value: 'architecture', label: 'Architecture' },
97
+ { value: 'developer-tools', label: 'Developer Tools' },
98
+ { value: 'typescript', label: 'TypeScript' },
99
+ { value: 'python', label: 'Python' },
100
+ { value: 'java', label: 'Java' },
101
+ { value: 'rust', label: 'Rust' },
102
+ { value: 'golang', label: 'Go' },
103
+ { value: 'csharp', label: 'C#' },
104
+ ],
105
+ });
106
+ if (p.isCancel(filterCategory))
107
+ continue;
108
+ const s = p.spinner();
109
+ s.start('Searching...');
110
+ try {
111
+ const results = await fetchResources(query, {
112
+ type: filterType || undefined,
113
+ category: filterCategory || undefined,
114
+ limit: 20,
115
+ });
116
+ s.stop(`Found ${results.length} resources`);
117
+ if (results.length === 0) {
118
+ p.log.warn('No resources found.');
119
+ continue;
120
+ }
121
+ const selected = await p.select({
122
+ message: 'Select to install (or esc to cancel)',
123
+ options: results.map(r => ({
124
+ value: r.slug,
125
+ label: `${r.slug}${r.overallScore ? ` [${r.overallScore}]` : ''}`,
126
+ hint: r.type,
127
+ })),
128
+ });
129
+ if (p.isCancel(selected))
130
+ continue;
131
+ await addResource(selected, { yes: true });
132
+ }
133
+ catch (err) {
134
+ s.stop(`Search failed: ${err.message}`);
135
+ }
136
+ }
137
+ else if (action === 'add') {
138
+ const slug = await p.text({
139
+ message: 'Resource slug:',
140
+ placeholder: 'e.g., owner/resource-name',
141
+ });
142
+ if (p.isCancel(slug))
143
+ continue;
144
+ await addResource(slug, {});
145
+ }
146
+ else if (action === 'list') {
147
+ await listResources();
148
+ }
149
+ else if (action === 'remove') {
150
+ await removeResource(undefined, {});
151
+ }
152
+ console.log();
153
+ }
154
+ }
155
+ // Show interactive menu when no command provided
156
+ if (process.argv.length <= 2) {
157
+ if (process.stdout.isTTY) {
158
+ interactiveMenu();
159
+ }
160
+ else {
161
+ showBanner();
162
+ }
163
+ }
164
+ else {
165
+ program.parse();
166
+ }
@@ -1,11 +1,16 @@
1
- import { writeFileSync, mkdirSync } from 'fs';
1
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { join, dirname, basename } from 'path';
3
3
  import chalk from 'chalk';
4
- import ora from 'ora';
4
+ import * as p from '@clack/prompts';
5
5
  import { trackResourceInstall, trackCommand } from '../utils/telemetry.js';
6
6
  const KIROHUB_API = 'https://kirohub.dev/resource-map.json';
7
+ function isInteractive() {
8
+ return process.stdout.isTTY === true;
9
+ }
7
10
  export async function addResource(resourceSlug, options) {
8
- const spinner = ora('Fetching resource...').start();
11
+ const skipPrompts = options.yes || !isInteractive();
12
+ const s = p.spinner();
13
+ s.start('Fetching resource...');
9
14
  try {
10
15
  // Fetch resource map
11
16
  const response = await fetch(KIROHUB_API);
@@ -16,22 +21,46 @@ export async function addResource(resourceSlug, options) {
16
21
  // Find resource by slug
17
22
  const resource = resources.find(r => r.slug === resourceSlug);
18
23
  if (!resource) {
19
- spinner.fail(`Resource "${resourceSlug}" not found`);
20
- console.log(chalk.gray('Available resources:'));
21
- resources.slice(0, 5).forEach(r => {
22
- console.log(chalk.gray(` ${r.slug} (${r.type})`));
23
- });
24
+ s.stop(`Resource "${resourceSlug}" not found`);
25
+ if (isInteractive()) {
26
+ console.log(chalk.gray('Available resources:'));
27
+ resources.slice(0, 5).forEach(r => {
28
+ console.log(chalk.gray(` ${r.slug} (${r.type})`));
29
+ });
30
+ }
24
31
  return;
25
32
  }
26
- spinner.text = `Installing ${resource.name}...`;
33
+ // Determine installation path
34
+ const installPath = getInstallPath(resource);
35
+ s.stop('Found resource');
36
+ // Check if file exists and prompt for confirmation
37
+ if (existsSync(installPath) && !skipPrompts) {
38
+ const overwrite = await p.confirm({
39
+ message: `File already exists at ${installPath}. Overwrite?`,
40
+ });
41
+ if (p.isCancel(overwrite) || !overwrite) {
42
+ p.cancel('Cancelled.');
43
+ return;
44
+ }
45
+ }
46
+ // Prompt for confirmation if not using -y flag
47
+ if (!skipPrompts && !existsSync(installPath)) {
48
+ p.note(`${resource.name} (${resource.type})\n→ ${installPath}`, 'Resource');
49
+ const proceed = await p.confirm({
50
+ message: 'Install this resource?',
51
+ });
52
+ if (p.isCancel(proceed) || !proceed) {
53
+ p.cancel('Cancelled.');
54
+ return;
55
+ }
56
+ }
57
+ s.start(`Installing ${resource.name}...`);
27
58
  // Fetch file content
28
59
  const fileResponse = await fetch(resource.githubRawUrl);
29
60
  if (!fileResponse.ok) {
30
61
  throw new Error(`Failed to fetch resource file: ${fileResponse.status} ${fileResponse.statusText}`);
31
62
  }
32
63
  const content = await fileResponse.text();
33
- // Determine installation path
34
- const installPath = getInstallPath(resource);
35
64
  // Create directory if needed
36
65
  mkdirSync(dirname(installPath), { recursive: true });
37
66
  // Write file
@@ -41,13 +70,13 @@ export async function addResource(resourceSlug, options) {
41
70
  trackResourceInstall(resource.slug, resource.type);
42
71
  trackCommand('add');
43
72
  }
44
- spinner.succeed(`Installed ${chalk.green(resource.name)} to ${chalk.gray(installPath)}`);
73
+ s.stop(`Installed ${resource.name} to ${installPath}`);
45
74
  }
46
75
  catch (error) {
47
- spinner.fail(`Failed to install resource: ${error.message}`);
76
+ s.stop(`Failed: ${error.message}`);
48
77
  }
49
78
  }
50
- function getInstallPath(resource) {
79
+ export function getInstallPath(resource) {
51
80
  const cwd = process.cwd();
52
81
  const safeName = sanitizeName(resource.name || resource.slug);
53
82
  const safeSlug = sanitizeName(resource.slug);
@@ -69,7 +98,7 @@ function getInstallPath(resource) {
69
98
  return join(cwd, '.kiro', 'resources', safeName);
70
99
  }
71
100
  }
72
- function sanitizeName(value) {
101
+ export function sanitizeName(value) {
73
102
  return value
74
103
  .toLowerCase()
75
104
  .trim()
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { join } from 'path';
3
+ import { getInstallPath, sanitizeName } from './add.js';
4
+ describe('sanitizeName', () => {
5
+ it('lowercases and trims', () => {
6
+ expect(sanitizeName(' Hello World ')).toBe('hello-world');
7
+ });
8
+ it('replaces special chars with hyphens', () => {
9
+ expect(sanitizeName('my@resource!name')).toBe('my-resource-name');
10
+ });
11
+ it('collapses multiple hyphens', () => {
12
+ expect(sanitizeName('a---b')).toBe('a-b');
13
+ });
14
+ it('strips leading/trailing slashes', () => {
15
+ expect(sanitizeName('/owner/repo/')).toBe('owner/repo');
16
+ });
17
+ it('preserves dots, underscores, slashes', () => {
18
+ expect(sanitizeName('my_file.md')).toBe('my_file.md');
19
+ expect(sanitizeName('owner/repo')).toBe('owner/repo');
20
+ });
21
+ });
22
+ describe('getInstallPath', () => {
23
+ const cwd = process.cwd();
24
+ const makeResource = (overrides) => ({
25
+ id: '1',
26
+ name: 'Test Resource',
27
+ slug: 'owner/test-resource',
28
+ type: 'Steering',
29
+ url: '',
30
+ githubRepo: 'owner/repo',
31
+ githubFile: 'architecture.md',
32
+ githubRawUrl: '',
33
+ ...overrides,
34
+ });
35
+ it('puts steering files in .kiro/steering/', () => {
36
+ const result = getInstallPath(makeResource({ type: 'Steering', githubFile: 'architecture.md' }));
37
+ expect(result).toBe(join(cwd, '.kiro', 'steering', 'architecture.md'));
38
+ });
39
+ it('puts hooks in .kiro/hooks/', () => {
40
+ const result = getInstallPath(makeResource({ type: 'Hook', githubFile: 'on-save.kiro.hook' }));
41
+ expect(result).toBe(join(cwd, '.kiro', 'hooks', 'on-save.kiro.hook'));
42
+ });
43
+ it('puts powers in .kiro/powers/<slug>/', () => {
44
+ const result = getInstallPath(makeResource({ type: 'Power', githubFile: 'power.json', slug: 'owner/my-power' }));
45
+ expect(result).toBe(join(cwd, '.kiro', 'powers', 'owner/my-power', 'power.json'));
46
+ });
47
+ it('puts prompts in .kiro/prompts/', () => {
48
+ const result = getInstallPath(makeResource({ type: 'Prompt', githubFile: 'deploy.md' }));
49
+ expect(result).toBe(join(cwd, '.kiro', 'prompts', 'deploy.md'));
50
+ });
51
+ it('puts agents in .kiro/agents/', () => {
52
+ const result = getInstallPath(makeResource({ type: 'Agent', githubFile: 'reviewer.json' }));
53
+ expect(result).toBe(join(cwd, '.kiro', 'agents', 'reviewer.json'));
54
+ });
55
+ it('puts skills in .kiro/skills/<slug>/', () => {
56
+ const result = getInstallPath(makeResource({ type: 'Skill', githubFile: 'SKILL.md', slug: 'owner/my-skill' }));
57
+ expect(result).toBe(join(cwd, '.kiro', 'skills', 'owner/my-skill', 'SKILL.md'));
58
+ });
59
+ it('falls back to sanitized name when no githubFile', () => {
60
+ const result = getInstallPath(makeResource({ type: 'Steering', githubFile: '', name: 'My Resource' }));
61
+ expect(result).toBe(join(cwd, '.kiro', 'steering', 'my-resource.md'));
62
+ });
63
+ it('uses default path for unknown type', () => {
64
+ const result = getInstallPath(makeResource({ type: 'Unknown', githubFile: '' }));
65
+ expect(result).toContain(join('.kiro', 'resources'));
66
+ });
67
+ });
@@ -0,0 +1,101 @@
1
+ import { readdirSync, rmSync, statSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import chalk from 'chalk';
4
+ import * as p from '@clack/prompts';
5
+ import { trackCommand } from '../utils/telemetry.js';
6
+ const KIRO_DIRS = [
7
+ { path: '.kiro/steering', type: 'Steering' },
8
+ { path: '.kiro/hooks', type: 'Hook' },
9
+ { path: '.kiro/powers', type: 'Power' },
10
+ { path: '.kiro/prompts', type: 'Prompt' },
11
+ { path: '.kiro/agents', type: 'Agent' },
12
+ { path: '.kiro/skills', type: 'Skill' },
13
+ ];
14
+ function isInteractive() {
15
+ return process.stdout.isTTY === true;
16
+ }
17
+ function getInstalledResources() {
18
+ const cwd = process.cwd();
19
+ const resources = [];
20
+ for (const dir of KIRO_DIRS) {
21
+ const fullPath = join(cwd, dir.path);
22
+ if (!existsSync(fullPath))
23
+ continue;
24
+ try {
25
+ const entries = readdirSync(fullPath);
26
+ for (const entry of entries) {
27
+ if (entry.startsWith('.'))
28
+ continue;
29
+ const entryPath = join(fullPath, entry);
30
+ const stat = statSync(entryPath);
31
+ resources.push({
32
+ name: entry.replace(/\.(md|kiro\.hook|json)$/, ''),
33
+ path: entryPath,
34
+ type: dir.type,
35
+ isDir: stat.isDirectory(),
36
+ });
37
+ }
38
+ }
39
+ catch {
40
+ // Directory doesn't exist or can't be read
41
+ }
42
+ }
43
+ return resources;
44
+ }
45
+ export async function removeResource(resourceName, options) {
46
+ const skipPrompts = options.yes || !isInteractive();
47
+ const resources = getInstalledResources();
48
+ if (resources.length === 0) {
49
+ p.log.warn('No installed resources found.');
50
+ return;
51
+ }
52
+ // If no resource specified and interactive, show selection
53
+ if (!resourceName) {
54
+ if (!isInteractive()) {
55
+ p.log.error('Resource name required in non-interactive mode.');
56
+ console.log(chalk.gray('Usage: kirohub remove <resource-name> -y'));
57
+ return;
58
+ }
59
+ const selected = await p.select({
60
+ message: 'Select resource to remove',
61
+ options: resources.map(r => ({
62
+ value: r.name,
63
+ label: r.name,
64
+ hint: r.type,
65
+ })),
66
+ });
67
+ if (p.isCancel(selected)) {
68
+ p.cancel('Cancelled.');
69
+ return;
70
+ }
71
+ resourceName = selected;
72
+ }
73
+ // Find the resource
74
+ const resource = resources.find(r => r.name.toLowerCase() === resourceName.toLowerCase());
75
+ if (!resource) {
76
+ p.log.error(`Resource "${resourceName}" not found.`);
77
+ console.log(chalk.gray('Use `kirohub list` to see installed resources.'));
78
+ return;
79
+ }
80
+ // Confirm removal
81
+ if (!skipPrompts) {
82
+ p.note(`${resource.name} (${resource.type})\n${resource.path}`, 'Resource');
83
+ const proceed = await p.confirm({
84
+ message: 'Remove this resource?',
85
+ });
86
+ if (p.isCancel(proceed) || !proceed) {
87
+ p.cancel('Cancelled.');
88
+ return;
89
+ }
90
+ }
91
+ const s = p.spinner();
92
+ s.start(`Removing ${resource.name}...`);
93
+ try {
94
+ rmSync(resource.path, { recursive: true });
95
+ trackCommand('remove');
96
+ s.stop(`Removed ${resource.name}`);
97
+ }
98
+ catch (error) {
99
+ s.stop(`Failed: ${error.message}`);
100
+ }
101
+ }
@@ -1,39 +1,138 @@
1
1
  import chalk from 'chalk';
2
2
  import { trackCommand } from '../utils/telemetry.js';
3
- const KIROHUB_API = 'https://kirohub.dev/resource-map.json';
3
+ const KIROHUB_BASE = 'https://kirohub.dev';
4
+ export const TYPES = ['Steering', 'Hook', 'Power', 'Prompt', 'Agent', 'Skill'];
5
+ export const CATEGORIES = [
6
+ 'aws', 'frontend', 'backend', 'api-design', 'documentation', 'devops',
7
+ 'security', 'testing', 'database', 'ai-ml', 'architecture',
8
+ 'developer-tools', 'typescript', 'python', 'java', 'rust', 'golang', 'csharp',
9
+ ];
10
+ export function getApiUrl(options) {
11
+ // Use specific map if filtering by type or category
12
+ if (options.type) {
13
+ const typeMatch = TYPES.find(t => t.toLowerCase() === options.type.toLowerCase());
14
+ if (typeMatch)
15
+ return `${KIROHUB_BASE}/resource-map-${typeMatch.toLowerCase()}.json`;
16
+ }
17
+ if (options.category) {
18
+ const catMatch = CATEGORIES.find(c => c === options.category.toLowerCase());
19
+ if (catMatch)
20
+ return `${KIROHUB_BASE}/resource-map-category-${catMatch}.json`;
21
+ }
22
+ return `${KIROHUB_BASE}/resource-map.json`;
23
+ }
24
+ export async function fetchResources(query, options) {
25
+ const apiUrl = getApiUrl(options);
26
+ const response = await fetch(apiUrl);
27
+ if (!response.ok) {
28
+ throw new Error(`Failed to fetch resource map: ${response.status} ${response.statusText}`);
29
+ }
30
+ let resources = await response.json();
31
+ if (options.type && options.category) {
32
+ const cat = options.category.toLowerCase();
33
+ resources = resources.filter(r => r.categories?.includes(cat));
34
+ }
35
+ const results = resources.filter(r => r.name.toLowerCase().includes(query.toLowerCase()) ||
36
+ r.slug.toLowerCase().includes(query.toLowerCase()));
37
+ results.sort((a, b) => (b.overallScore ?? 0) - (a.overallScore ?? 0));
38
+ const limit = options.limit ? Math.max(1, parseInt(options.limit, 10) || 10) : 10;
39
+ return results.slice(0, limit);
40
+ }
4
41
  export async function searchResources(query, options) {
5
42
  try {
6
43
  trackCommand('search');
7
- const response = await fetch(KIROHUB_API);
44
+ // Validate type filter
45
+ if (options.type) {
46
+ const typeMatch = TYPES.find(t => t.toLowerCase() === options.type.toLowerCase());
47
+ if (!typeMatch) {
48
+ if (options.json) {
49
+ console.log(JSON.stringify({ error: `Unknown type: ${options.type}`, availableTypes: TYPES }));
50
+ }
51
+ else {
52
+ console.log(chalk.yellow(`Unknown type "${options.type}". Available: ${TYPES.join(', ')}`));
53
+ }
54
+ return;
55
+ }
56
+ }
57
+ // Validate category filter
58
+ if (options.category) {
59
+ const catMatch = CATEGORIES.find(c => c === options.category.toLowerCase());
60
+ if (!catMatch) {
61
+ if (options.json) {
62
+ console.log(JSON.stringify({ error: `Unknown category: ${options.category}`, availableCategories: CATEGORIES }));
63
+ }
64
+ else {
65
+ console.log(chalk.yellow(`Unknown category "${options.category}".`));
66
+ console.log(chalk.gray(`Available: ${CATEGORIES.join(', ')}`));
67
+ }
68
+ return;
69
+ }
70
+ }
71
+ const apiUrl = getApiUrl(options);
72
+ const response = await fetch(apiUrl);
8
73
  if (!response.ok) {
9
74
  throw new Error(`Failed to fetch resource map: ${response.status} ${response.statusText}`);
10
75
  }
11
- const resources = await response.json();
12
- // Filter by type if specified
13
- let filtered = resources;
14
- if (options.type) {
15
- filtered = resources.filter(r => r.type.toLowerCase() === options.type.toLowerCase());
76
+ let resources = await response.json();
77
+ // Client-side category filter when type map was fetched (type takes URL priority)
78
+ if (options.type && options.category) {
79
+ const cat = options.category.toLowerCase();
80
+ resources = resources.filter(r => r.categories?.includes(cat));
16
81
  }
17
82
  // Search by name/slug
18
- const results = filtered.filter(r => r.name.toLowerCase().includes(query.toLowerCase()) ||
83
+ const results = resources.filter(r => r.name.toLowerCase().includes(query.toLowerCase()) ||
19
84
  r.slug.toLowerCase().includes(query.toLowerCase()));
85
+ // Sort by score if available
86
+ results.sort((a, b) => (b.overallScore ?? 0) - (a.overallScore ?? 0));
87
+ // Apply limit
88
+ const limit = Math.max(1, parseInt(options.limit, 10) || 10);
89
+ const limited = results.slice(0, limit);
90
+ // JSON output mode
91
+ if (options.json) {
92
+ console.log(JSON.stringify({
93
+ query,
94
+ total: results.length,
95
+ results: limited.map(r => ({
96
+ slug: r.slug,
97
+ name: r.name,
98
+ type: r.type,
99
+ categories: r.categories,
100
+ score: r.overallScore,
101
+ install: `npx kirohub add ${r.slug}`,
102
+ })),
103
+ }));
104
+ return;
105
+ }
20
106
  if (results.length === 0) {
21
107
  console.log(chalk.yellow(`No resources found for "${query}"`));
108
+ if (options.type)
109
+ console.log(chalk.gray(` type: ${options.type}`));
110
+ if (options.category)
111
+ console.log(chalk.gray(` category: ${options.category}`));
22
112
  return;
23
113
  }
24
114
  console.log(chalk.bold(`Found ${results.length} resources:`));
25
115
  console.log();
26
- results.slice(0, 10).forEach(resource => {
27
- console.log(chalk.green(`${resource.slug}`));
116
+ limited.forEach(resource => {
117
+ const score = resource.overallScore ? chalk.gray(` [${resource.overallScore}]`) : '';
118
+ console.log(chalk.green(`${resource.slug}`) + score);
28
119
  console.log(chalk.gray(` Type: ${resource.type}`));
120
+ if (resource.categories?.length) {
121
+ console.log(chalk.gray(` Categories: ${resource.categories.join(', ')}`));
122
+ }
29
123
  console.log(chalk.gray(` Install: npx kirohub add ${resource.slug}`));
30
124
  console.log();
31
125
  });
32
- if (results.length > 10) {
33
- console.log(chalk.gray(`... and ${results.length - 10} more`));
126
+ if (results.length > limit) {
127
+ console.log(chalk.gray(`... and ${results.length - limit} more (use --limit to show more)`));
34
128
  }
35
129
  }
36
130
  catch (error) {
37
- console.error(chalk.red(`Search failed: ${error.message}`));
131
+ if (options.json) {
132
+ console.log(JSON.stringify({ error: error.message }));
133
+ }
134
+ else {
135
+ console.error(chalk.red(`Search failed: ${error.message}`));
136
+ }
38
137
  }
39
138
  }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { getApiUrl, TYPES, CATEGORIES } from './search.js';
3
+ describe('getApiUrl', () => {
4
+ const base = 'https://kirohub.dev';
5
+ it('returns resource-map.json with no filters', () => {
6
+ expect(getApiUrl({})).toBe(`${base}/resource-map.json`);
7
+ });
8
+ it('returns type-specific map for type filter', () => {
9
+ expect(getApiUrl({ type: 'Steering' })).toBe(`${base}/steering.json`);
10
+ expect(getApiUrl({ type: 'Hook' })).toBe(`${base}/hook.json`);
11
+ expect(getApiUrl({ type: 'Power' })).toBe(`${base}/power.json`);
12
+ });
13
+ it('is case-insensitive for type', () => {
14
+ expect(getApiUrl({ type: 'steering' })).toBe(`${base}/steering.json`);
15
+ expect(getApiUrl({ type: 'HOOK' })).toBe(`${base}/hook.json`);
16
+ });
17
+ it('returns category-specific map for category filter', () => {
18
+ expect(getApiUrl({ category: 'aws' })).toBe(`${base}/aws.json`);
19
+ expect(getApiUrl({ category: 'frontend' })).toBe(`${base}/frontend.json`);
20
+ });
21
+ it('type takes priority over category for URL', () => {
22
+ expect(getApiUrl({ type: 'Steering', category: 'aws' })).toBe(`${base}/steering.json`);
23
+ });
24
+ it('falls back to resource-map.json for unknown type', () => {
25
+ expect(getApiUrl({ type: 'Unknown' })).toBe(`${base}/resource-map.json`);
26
+ });
27
+ it('falls back to resource-map.json for unknown category', () => {
28
+ expect(getApiUrl({ category: 'nonexistent' })).toBe(`${base}/resource-map.json`);
29
+ });
30
+ });
31
+ describe('searchResources JSON output', () => {
32
+ const mockResources = [
33
+ { id: '1', name: 'AWS Lambda Steering', slug: 'user/aws-lambda', type: 'Steering', url: '', categories: ['aws', 'backend'], overallScore: 90 },
34
+ { id: '2', name: 'React Hooks Guide', slug: 'user/react-hooks', type: 'Steering', url: '', categories: ['frontend'], overallScore: 85 },
35
+ { id: '3', name: 'AWS DynamoDB Hook', slug: 'user/aws-dynamo', type: 'Hook', url: '', categories: ['aws', 'database'], overallScore: 80 },
36
+ ];
37
+ beforeEach(() => {
38
+ vi.restoreAllMocks();
39
+ vi.resetModules();
40
+ });
41
+ it('filters by query and outputs JSON', async () => {
42
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
43
+ ok: true,
44
+ json: () => Promise.resolve(mockResources),
45
+ }));
46
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => { });
47
+ const { searchResources } = await import('./search.js');
48
+ await searchResources('aws', { json: true });
49
+ const output = JSON.parse(spy.mock.calls[0][0]);
50
+ expect(output.total).toBe(2);
51
+ expect(output.results[0].slug).toBe('user/aws-lambda');
52
+ expect(output.results[1].slug).toBe('user/aws-dynamo');
53
+ });
54
+ it('applies limit', async () => {
55
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
56
+ ok: true,
57
+ json: () => Promise.resolve(mockResources),
58
+ }));
59
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => { });
60
+ const { searchResources } = await import('./search.js');
61
+ await searchResources('', { json: true, limit: '1' });
62
+ const output = JSON.parse(spy.mock.calls[0][0]);
63
+ expect(output.total).toBe(3);
64
+ expect(output.results).toHaveLength(1);
65
+ });
66
+ it('defaults limit to 10 for invalid value', async () => {
67
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
68
+ ok: true,
69
+ json: () => Promise.resolve(mockResources),
70
+ }));
71
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => { });
72
+ const { searchResources } = await import('./search.js');
73
+ await searchResources('', { json: true, limit: 'foo' });
74
+ const output = JSON.parse(spy.mock.calls[0][0]);
75
+ expect(output.results).toHaveLength(3); // all 3, since default limit 10 > 3
76
+ });
77
+ it('sorts by score descending', async () => {
78
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
79
+ ok: true,
80
+ json: () => Promise.resolve(mockResources),
81
+ }));
82
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => { });
83
+ const { searchResources } = await import('./search.js');
84
+ await searchResources('', { json: true });
85
+ const output = JSON.parse(spy.mock.calls[0][0]);
86
+ const scores = output.results.map((r) => r.score);
87
+ expect(scores).toEqual([90, 85, 80]);
88
+ });
89
+ it('applies client-side category filter when both type and category set', async () => {
90
+ // When fetching steering.json, only Steering resources come back
91
+ const steeringOnly = mockResources.filter(r => r.type === 'Steering');
92
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
93
+ ok: true,
94
+ json: () => Promise.resolve(steeringOnly),
95
+ }));
96
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => { });
97
+ const { searchResources } = await import('./search.js');
98
+ await searchResources('', { json: true, type: 'Steering', category: 'aws' });
99
+ const output = JSON.parse(spy.mock.calls[0][0]);
100
+ expect(output.total).toBe(1);
101
+ expect(output.results[0].slug).toBe('user/aws-lambda');
102
+ });
103
+ });
104
+ describe('constants', () => {
105
+ it('has 6 types', () => {
106
+ expect(TYPES).toHaveLength(6);
107
+ expect(TYPES).toContain('Steering');
108
+ expect(TYPES).toContain('Skill');
109
+ });
110
+ it('has 18 categories', () => {
111
+ expect(CATEGORIES).toHaveLength(18);
112
+ expect(CATEGORIES).toContain('aws');
113
+ expect(CATEGORIES).toContain('csharp');
114
+ });
115
+ });
@@ -0,0 +1,37 @@
1
+ import * as p from '@clack/prompts';
2
+ const RESET = '\x1b[0m';
3
+ const DIM = '\x1b[38;5;102m';
4
+ const TEXT = '\x1b[38;5;145m';
5
+ const LOGO_LINES = [
6
+ '██╗ ██╗██╗██████╗ ██████╗ ██╗ ██╗██╗ ██╗██████╗ ',
7
+ '██║ ██╔╝██║██╔══██╗██╔═══██╗ ██║ ██║██║ ██║██╔══██╗',
8
+ '█████╔╝ ██║██████╔╝██║ ██║ ███████║██║ ██║██████╔╝',
9
+ '██╔═██╗ ██║██╔══██╗██║ ██║ ██╔══██║██║ ██║██╔══██╗',
10
+ '██║ ██╗██║██║ ██║╚██████╔╝ ██║ ██║╚██████╔╝██████╔╝',
11
+ '╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ',
12
+ ];
13
+ const GRAYS = [
14
+ '\x1b[38;5;250m',
15
+ '\x1b[38;5;248m',
16
+ '\x1b[38;5;245m',
17
+ '\x1b[38;5;243m',
18
+ '\x1b[38;5;240m',
19
+ '\x1b[38;5;238m',
20
+ ];
21
+ export function showLogo() {
22
+ console.log();
23
+ LOGO_LINES.forEach((line, i) => {
24
+ console.log(`${GRAYS[i]}${line}${RESET}`);
25
+ });
26
+ console.log();
27
+ }
28
+ export function showBanner() {
29
+ showLogo();
30
+ p.intro('The Kiro resource hub');
31
+ console.log(` ${DIM}$${RESET} ${TEXT}npx kirohub add ${DIM}<slug>${RESET} ${DIM}Install a resource${RESET}`);
32
+ console.log(` ${DIM}$${RESET} ${TEXT}npx kirohub list${RESET} ${DIM}List installed resources${RESET}`);
33
+ console.log(` ${DIM}$${RESET} ${TEXT}npx kirohub search ${DIM}<query>${RESET} ${DIM}Search resources${RESET}`);
34
+ console.log(` ${DIM}$${RESET} ${TEXT}npx kirohub remove${RESET} ${DIM}Remove resources${RESET}`);
35
+ console.log();
36
+ p.outro(`Discover more at ${TEXT}https://kirohub.dev/${RESET}`);
37
+ }
@@ -1,6 +1,6 @@
1
1
  // Telemetry endpoint — update this after deploying the Lambda Function URL
2
2
  // IMPORTANT: Replace this URL with the actual Lambda Function URL before publishing to npm
3
- const TELEMETRY_ENDPOINT = 'https://kirohub.dev/api/telemetry';
3
+ const TELEMETRY_ENDPOINT = 'https://2ht62y6ttd7fwjoavzef7xtoge0mlzob.lambda-url.eu-west-1.on.aws/';
4
4
  const TELEMETRY_TIMEOUT = 1500;
5
5
  import { createRequire } from 'module';
6
6
  const require = createRequire(import.meta.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kirohub",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI for installing Kiro resources from KiroHub",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -10,27 +10,39 @@
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.json",
12
12
  "dev": "tsx src/cli.ts",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
13
15
  "smoke": "node dist/cli.js --help",
14
16
  "postinstall": "node postinstall.cjs",
15
17
  "prepublishOnly": "npm run build"
16
18
  },
17
- "keywords": ["kiro", "ai", "cli", "resources", "steering", "hooks", "powers"],
19
+ "keywords": [
20
+ "kiro",
21
+ "ai",
22
+ "cli",
23
+ "resources",
24
+ "steering",
25
+ "hooks",
26
+ "powers"
27
+ ],
18
28
  "author": "Kiro Community",
19
29
  "license": "MIT",
20
30
  "dependencies": {
21
- "commander": "^12.0.0",
31
+ "@clack/prompts": "^1.0.1",
22
32
  "chalk": "^5.3.0",
23
- "ora": "^8.0.1"
33
+ "commander": "^12.0.0"
24
34
  },
25
35
  "devDependencies": {
26
36
  "@types/node": "^22.0.0",
27
37
  "tsx": "^4.0.0",
28
- "typescript": "^5.8.2"
38
+ "typescript": "^5.8.2",
39
+ "vitest": "^4.0.18"
29
40
  },
30
41
  "files": [
31
42
  "dist/**/*",
32
43
  "postinstall.cjs",
33
- "README.md"
44
+ "README.md",
45
+ "CHANGELOG.md"
34
46
  ],
35
47
  "engines": {
36
48
  "node": ">=18"
package/postinstall.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // TODO: Update with actual Lambda Function URL after deployment
2
2
  // IMPORTANT: Replace this URL with the actual Lambda Function URL before publishing to npm
3
- const TELEMETRY_ENDPOINT = "https://kirohub.dev/api/telemetry";
3
+ const TELEMETRY_ENDPOINT = "https://2ht62y6ttd7fwjoavzef7xtoge0mlzob.lambda-url.eu-west-1.on.aws/";
4
4
 
5
5
  function isTelemetryDisabled() {
6
6
  return (