kadai 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +710 -123
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  # kadai
4
4
 
5
- A terminal UI for discovering and running project scripts. Drop scripts into `.kadai/actions/`, and kadai gives you a fuzzy-searchable menu to run them.
5
+ 1. Drop scripts into `.kadai/actions/`.
6
+ 2. Run with `bunx kadai`.
7
+ 3. Share them with your team in the repo.
8
+ 4. Automatically make them discoverable by AI.
6
9
 
7
10
  ## Getting Started
8
11
 
package/dist/cli.js CHANGED
@@ -45,7 +45,8 @@ async function loadConfig(kadaiDir) {
45
45
  const userConfig = mod.default ?? mod;
46
46
  return {
47
47
  actionsDir: userConfig.actionsDir ?? DEFAULT_CONFIG.actionsDir,
48
- env: userConfig.env ?? DEFAULT_CONFIG.env
48
+ env: userConfig.env ?? DEFAULT_CONFIG.env,
49
+ plugins: userConfig.plugins
49
50
  };
50
51
  }
51
52
  var DEFAULT_CONFIG;
@@ -198,14 +199,14 @@ async function getGitAddedDates(dir) {
198
199
  } catch {}
199
200
  return dates;
200
201
  }
201
- async function loadActions(actionsDir) {
202
+ async function loadActions(actionsDir, origin = { type: "local" }) {
202
203
  const actions = [];
203
204
  const gitDates = await getGitAddedDates(actionsDir);
204
- await scanDirectory(actionsDir, actionsDir, [], actions, 0, gitDates);
205
+ await scanDirectory(actionsDir, actionsDir, [], actions, 0, gitDates, origin);
205
206
  actions.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
206
207
  return actions;
207
208
  }
208
- async function scanDirectory(baseDir, currentDir, category, actions, depth, gitDates) {
209
+ async function scanDirectory(baseDir, currentDir, category, actions, depth, gitDates, origin = { type: "local" }) {
209
210
  if (depth > 3)
210
211
  return;
211
212
  let entries;
@@ -219,7 +220,7 @@ async function scanDirectory(baseDir, currentDir, category, actions, depth, gitD
219
220
  continue;
220
221
  const fullPath = join2(currentDir, entry.name);
221
222
  if (entry.isDirectory()) {
222
- await scanDirectory(baseDir, fullPath, [...category, entry.name], actions, depth + 1, gitDates);
223
+ await scanDirectory(baseDir, fullPath, [...category, entry.name], actions, depth + 1, gitDates, origin);
223
224
  } else if (entry.isFile()) {
224
225
  const ext = `.${entry.name.split(".").pop()}`;
225
226
  if (!SUPPORTED_EXTENSIONS.has(ext))
@@ -237,6 +238,7 @@ async function scanDirectory(baseDir, currentDir, category, actions, depth, gitD
237
238
  category,
238
239
  runtime: runtimeFromExtension(ext),
239
240
  addedAt,
241
+ origin,
240
242
  ...shebang ? { shebang } : {}
241
243
  });
242
244
  }
@@ -274,12 +276,174 @@ var init_loader = __esm(() => {
274
276
  ]);
275
277
  });
276
278
 
277
- // src/core/runner.ts
278
- var exports_runner = {};
279
- __export(exports_runner, {
280
- resolveCommand: () => resolveCommand,
281
- parseShebangCommand: () => parseShebangCommand
282
- });
279
+ // src/core/fetchers/github.ts
280
+ import { mkdir, rm } from "fs/promises";
281
+ import { join as join3 } from "path";
282
+ async function fetchGithubPlugin(source, destDir) {
283
+ const ref = source.ref ?? "main";
284
+ const repoUrl = `https://github.com/${source.github}.git`;
285
+ await mkdir(destDir, { recursive: true });
286
+ const proc = Bun.spawn(["git", "clone", "--depth", "1", "--branch", ref, repoUrl, destDir], { stdout: "pipe", stderr: "pipe" });
287
+ const exitCode = await proc.exited;
288
+ if (exitCode !== 0) {
289
+ const stderr = await new Response(proc.stderr).text();
290
+ throw new Error(`Failed to clone "${source.github}" (ref: ${ref}): ${stderr.trim()}`);
291
+ }
292
+ const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
293
+ cwd: destDir,
294
+ stdout: "pipe",
295
+ stderr: "pipe"
296
+ });
297
+ const sha = (await new Response(shaProc.stdout).text()).trim();
298
+ await shaProc.exited;
299
+ await rm(join3(destDir, ".git"), { recursive: true, force: true });
300
+ return { resolvedVersion: sha };
301
+ }
302
+ async function checkGithubUpdate(source, currentSha) {
303
+ try {
304
+ const ref = source.ref ?? "main";
305
+ const repoUrl = `https://github.com/${source.github}.git`;
306
+ const proc = Bun.spawn(["git", "ls-remote", repoUrl, ref], {
307
+ stdout: "pipe",
308
+ stderr: "pipe"
309
+ });
310
+ const output = (await new Response(proc.stdout).text()).trim();
311
+ const exitCode = await proc.exited;
312
+ if (exitCode !== 0)
313
+ return false;
314
+ const remoteSha = output.split("\t")[0] ?? "";
315
+ if (!remoteSha)
316
+ return false;
317
+ return remoteSha !== currentSha;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+ var init_github = () => {};
323
+
324
+ // src/core/fetchers/npm.ts
325
+ import { mkdir as mkdir2, unlink } from "fs/promises";
326
+ import { join as join4 } from "path";
327
+ function parseSemver(v) {
328
+ const withoutPrerelease = v.replace(/^v/, "").split("-")[0] ?? "";
329
+ const clean = withoutPrerelease.split("+")[0] ?? "";
330
+ const parts = clean.split(".");
331
+ if (parts.length !== 3)
332
+ return null;
333
+ const nums = parts.map(Number);
334
+ if (nums.some((n) => Number.isNaN(n)))
335
+ return null;
336
+ return nums;
337
+ }
338
+ function compareSemver(a, b) {
339
+ for (let i = 0;i < 3; i++) {
340
+ const av = a[i];
341
+ const bv = b[i];
342
+ if (av !== bv)
343
+ return av - bv;
344
+ }
345
+ return 0;
346
+ }
347
+ function satisfies(version, range) {
348
+ if (range === "*" || range === "x")
349
+ return true;
350
+ const parsed = parseSemver(version);
351
+ if (!parsed)
352
+ return false;
353
+ if (range.startsWith("^")) {
354
+ const min = parseSemver(range.slice(1));
355
+ if (!min)
356
+ return false;
357
+ if (compareSemver(parsed, min) < 0)
358
+ return false;
359
+ if (min[0] === 0) {
360
+ return parsed[0] === 0 && parsed[1] === min[1];
361
+ }
362
+ return parsed[0] === min[0];
363
+ }
364
+ if (range.startsWith("~")) {
365
+ const min = parseSemver(range.slice(1));
366
+ if (!min)
367
+ return false;
368
+ if (compareSemver(parsed, min) < 0)
369
+ return false;
370
+ return parsed[0] === min[0] && parsed[1] === min[1];
371
+ }
372
+ const exact = parseSemver(range);
373
+ if (!exact)
374
+ return false;
375
+ return compareSemver(parsed, exact) === 0;
376
+ }
377
+ async function resolveVersion(source) {
378
+ const version = source.version ?? "latest";
379
+ const res = await fetch(`${REGISTRY}/${encodeURIComponent(source.npm)}`);
380
+ if (!res.ok) {
381
+ throw new Error(`Failed to fetch npm package "${source.npm}": ${res.status} ${res.statusText}`);
382
+ }
383
+ const meta = await res.json();
384
+ const tagVersion = meta["dist-tags"][version];
385
+ if (tagVersion) {
386
+ const versionData = meta.versions[tagVersion];
387
+ if (!versionData) {
388
+ throw new Error(`npm package "${source.npm}": version ${tagVersion} not found in registry`);
389
+ }
390
+ return { version: tagVersion, tarballUrl: versionData.dist.tarball };
391
+ }
392
+ const exactData = meta.versions[version];
393
+ if (exactData) {
394
+ return { version, tarballUrl: exactData.dist.tarball };
395
+ }
396
+ const allVersions = Object.keys(meta.versions);
397
+ const matching = allVersions.filter((v) => satisfies(v, version)).map((v) => ({ version: v, parsed: parseSemver(v) })).filter((v) => v.parsed !== null).sort((a, b) => compareSemver(b.parsed, a.parsed));
398
+ if (matching.length === 0) {
399
+ throw new Error(`npm package "${source.npm}": no version matching "${version}" found`);
400
+ }
401
+ const bestMatch = matching[0];
402
+ if (!bestMatch) {
403
+ throw new Error(`npm package "${source.npm}": no version matching "${version}" found`);
404
+ }
405
+ const best = bestMatch.version;
406
+ const bestData = meta.versions[best];
407
+ if (!bestData) {
408
+ throw new Error(`npm package "${source.npm}": version data for ${best} missing`);
409
+ }
410
+ return { version: best, tarballUrl: bestData.dist.tarball };
411
+ }
412
+ async function fetchNpmPlugin(source, destDir) {
413
+ const { version, tarballUrl } = await resolveVersion(source);
414
+ const tarballRes = await fetch(tarballUrl);
415
+ if (!tarballRes.ok || !tarballRes.body) {
416
+ throw new Error(`Failed to download tarball for "${source.npm}@${version}": ${tarballRes.status}`);
417
+ }
418
+ await mkdir2(destDir, { recursive: true });
419
+ const tarball = await tarballRes.arrayBuffer();
420
+ const tarballPath = join4(destDir, ".plugin.tgz");
421
+ await Bun.write(tarballPath, tarball);
422
+ const proc = Bun.spawn(["tar", "xzf", tarballPath, "--strip-components=1"], {
423
+ cwd: destDir,
424
+ stdout: "pipe",
425
+ stderr: "pipe"
426
+ });
427
+ const exitCode = await proc.exited;
428
+ if (exitCode !== 0) {
429
+ const stderr = await new Response(proc.stderr).text();
430
+ throw new Error(`Failed to extract tarball: ${stderr}`);
431
+ }
432
+ await unlink(tarballPath);
433
+ return { resolvedVersion: version };
434
+ }
435
+ async function checkNpmUpdate(source, currentVersion) {
436
+ try {
437
+ const { version } = await resolveVersion(source);
438
+ return version !== currentVersion;
439
+ } catch {
440
+ return false;
441
+ }
442
+ }
443
+ var REGISTRY = "https://registry.npmjs.org";
444
+ var init_npm = () => {};
445
+
446
+ // src/core/which.ts
283
447
  function cachedWhich(bin) {
284
448
  if (whichCache.has(bin))
285
449
  return whichCache.get(bin) ?? null;
@@ -287,6 +451,219 @@ function cachedWhich(bin) {
287
451
  whichCache.set(bin, result ?? null);
288
452
  return result ?? null;
289
453
  }
454
+ var whichCache;
455
+ var init_which = __esm(() => {
456
+ whichCache = new Map;
457
+ });
458
+
459
+ // src/core/pm.ts
460
+ import { join as join5 } from "path";
461
+ async function resolvePM(dir) {
462
+ const pkgJsonPath = join5(dir, "package.json");
463
+ try {
464
+ const file = Bun.file(pkgJsonPath);
465
+ if (await file.exists()) {
466
+ const pkg = await file.json();
467
+ if (typeof pkg.packageManager === "string") {
468
+ const bin = pkg.packageManager.split("@")[0];
469
+ if (bin && cachedWhich(bin)) {
470
+ return { bin, install: [bin, "install"] };
471
+ }
472
+ }
473
+ }
474
+ } catch {}
475
+ for (const candidate of PM_CHAIN) {
476
+ if (cachedWhich(candidate.bin)) {
477
+ return candidate;
478
+ }
479
+ }
480
+ throw new Error(`No package manager found. Install bun or npm to use plugin dependencies.`);
481
+ }
482
+ var PM_CHAIN;
483
+ var init_pm = __esm(() => {
484
+ init_which();
485
+ PM_CHAIN = [
486
+ { bin: "bun", install: ["bun", "install"] },
487
+ { bin: "npm", install: ["npm", "install"] }
488
+ ];
489
+ });
490
+
491
+ // src/core/plugins.ts
492
+ import { existsSync } from "fs";
493
+ import { mkdir as mkdir3, rm as rm2 } from "fs/promises";
494
+ import { isAbsolute, join as join6, resolve } from "path";
495
+ async function ensurePluginCacheDir(kadaiDir) {
496
+ const cacheDir = join6(kadaiDir, ".cache", "plugins");
497
+ await mkdir3(cacheDir, { recursive: true });
498
+ const gitignorePath = join6(kadaiDir, ".cache", ".gitignore");
499
+ const gitignoreFile = Bun.file(gitignorePath);
500
+ if (!await gitignoreFile.exists()) {
501
+ await Bun.write(gitignorePath, `*
502
+ `);
503
+ }
504
+ return cacheDir;
505
+ }
506
+ function cacheKeyFor(source) {
507
+ if ("npm" in source) {
508
+ const name2 = source.npm.replace("/", "--");
509
+ const version = source.version ?? "latest";
510
+ return `npm/${name2}@${version}`;
511
+ }
512
+ const name = source.github.replace("/", "--");
513
+ const ref = source.ref ?? "main";
514
+ return `github/${name}@${ref}`;
515
+ }
516
+ function pluginDisplayName(source) {
517
+ if ("npm" in source)
518
+ return source.npm;
519
+ if ("github" in source)
520
+ return source.github;
521
+ return source.path;
522
+ }
523
+ async function readPluginMeta(cacheDir) {
524
+ try {
525
+ const file = Bun.file(join6(cacheDir, ".plugin-meta.json"));
526
+ if (!await file.exists())
527
+ return null;
528
+ return await file.json();
529
+ } catch {
530
+ return null;
531
+ }
532
+ }
533
+ async function writePluginMeta(cacheDir, meta) {
534
+ await Bun.write(join6(cacheDir, ".plugin-meta.json"), JSON.stringify(meta, null, 2));
535
+ }
536
+ async function loadCachedPlugins(kadaiDir, plugins) {
537
+ const allActions = [];
538
+ const cacheBase = join6(kadaiDir, ".cache", "plugins");
539
+ for (const source of plugins) {
540
+ if ("path" in source)
541
+ continue;
542
+ const key = cacheKeyFor(source);
543
+ const pluginCacheDir = join6(cacheBase, key);
544
+ const actionsDir = join6(pluginCacheDir, "actions");
545
+ if (!existsSync(actionsDir))
546
+ continue;
547
+ const name = pluginDisplayName(source);
548
+ const origin = { type: "plugin", pluginName: name };
549
+ const actions = await loadActions(actionsDir, origin);
550
+ for (const action of actions) {
551
+ action.category = [name, ...action.category];
552
+ action.id = `${name}/${action.id}`;
553
+ }
554
+ allActions.push(...actions);
555
+ }
556
+ return allActions;
557
+ }
558
+ async function installPluginDeps(pluginDir) {
559
+ const pkgJsonPath = join6(pluginDir, "package.json");
560
+ if (!existsSync(pkgJsonPath))
561
+ return;
562
+ const pm = await resolvePM(pluginDir);
563
+ const proc = Bun.spawn(pm.install, {
564
+ cwd: pluginDir,
565
+ stdout: "pipe",
566
+ stderr: "pipe"
567
+ });
568
+ const exitCode = await proc.exited;
569
+ if (exitCode !== 0) {
570
+ const stderr = await new Response(proc.stderr).text();
571
+ throw new Error(`Failed to install plugin dependencies in ${pluginDir}: ${stderr.trim()}`);
572
+ }
573
+ }
574
+ async function syncPlugin(kadaiDir, source) {
575
+ const cacheBase = await ensurePluginCacheDir(kadaiDir);
576
+ const key = cacheKeyFor(source);
577
+ const pluginCacheDir = join6(cacheBase, key);
578
+ const meta = await readPluginMeta(pluginCacheDir);
579
+ if (meta) {
580
+ let needsUpdate = false;
581
+ if ("npm" in source) {
582
+ needsUpdate = await checkNpmUpdate(source, meta.resolvedVersion);
583
+ } else {
584
+ needsUpdate = await checkGithubUpdate(source, meta.resolvedVersion);
585
+ }
586
+ if (!needsUpdate)
587
+ return;
588
+ }
589
+ await rm2(pluginCacheDir, { recursive: true, force: true });
590
+ await mkdir3(pluginCacheDir, { recursive: true });
591
+ let resolvedVersion;
592
+ if ("npm" in source) {
593
+ const result = await fetchNpmPlugin(source, pluginCacheDir);
594
+ resolvedVersion = result.resolvedVersion;
595
+ } else {
596
+ const result = await fetchGithubPlugin(source, pluginCacheDir);
597
+ resolvedVersion = result.resolvedVersion;
598
+ }
599
+ await installPluginDeps(pluginCacheDir);
600
+ await writePluginMeta(pluginCacheDir, {
601
+ fetchedAt: new Date().toISOString(),
602
+ source,
603
+ resolvedVersion
604
+ });
605
+ }
606
+ async function syncPlugins(kadaiDir, plugins, callbacks) {
607
+ const syncable = plugins.filter((p) => !("path" in p));
608
+ const SYNC_TIMEOUT_MS = 60000;
609
+ await Promise.allSettled(syncable.map(async (source) => {
610
+ const name = pluginDisplayName(source);
611
+ callbacks.onPluginStatus(name, "syncing");
612
+ try {
613
+ await Promise.race([
614
+ syncPlugin(kadaiDir, source),
615
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout for ${name}`)), SYNC_TIMEOUT_MS))
616
+ ]);
617
+ callbacks.onPluginStatus(name, "done");
618
+ } catch {
619
+ callbacks.onPluginStatus(name, "error");
620
+ }
621
+ }));
622
+ const allActions = await loadCachedPlugins(kadaiDir, plugins);
623
+ callbacks.onUpdate(allActions);
624
+ }
625
+ async function loadPathPlugin(kadaiDir, source) {
626
+ const pluginRoot = isAbsolute(source.path) ? source.path : resolve(kadaiDir, source.path);
627
+ const actionsDir = join6(pluginRoot, "actions");
628
+ if (!existsSync(actionsDir))
629
+ return [];
630
+ const name = source.path;
631
+ const origin = { type: "plugin", pluginName: name };
632
+ const actions = await loadActions(actionsDir, origin);
633
+ for (const action of actions) {
634
+ action.category = [name, ...action.category];
635
+ action.id = `${name}/${action.id}`;
636
+ }
637
+ return actions;
638
+ }
639
+ async function loadUserGlobalActions() {
640
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
641
+ if (!homeDir)
642
+ return [];
643
+ const actionsDir = join6(homeDir, ".kadai", "actions");
644
+ if (!existsSync(actionsDir))
645
+ return [];
646
+ const origin = { type: "plugin", pluginName: "~" };
647
+ const actions = await loadActions(actionsDir, origin);
648
+ for (const action of actions) {
649
+ action.category = ["~", ...action.category];
650
+ action.id = `~/${action.id}`;
651
+ }
652
+ return actions;
653
+ }
654
+ var init_plugins = __esm(() => {
655
+ init_github();
656
+ init_npm();
657
+ init_loader();
658
+ init_pm();
659
+ });
660
+
661
+ // src/core/runner.ts
662
+ var exports_runner = {};
663
+ __export(exports_runner, {
664
+ resolveCommand: () => resolveCommand,
665
+ parseShebangCommand: () => parseShebangCommand
666
+ });
290
667
  function parseShebangCommand(shebang, filePath) {
291
668
  if (!shebang || !shebang.startsWith("#!"))
292
669
  return null;
@@ -326,9 +703,9 @@ function resolveCommand(action) {
326
703
  return chainCmd;
327
704
  return FALLBACK_COMMANDS[action.runtime](action.filePath);
328
705
  }
329
- var whichCache, RUNTIME_CHAINS, FALLBACK_COMMANDS;
706
+ var RUNTIME_CHAINS, FALLBACK_COMMANDS;
330
707
  var init_runner = __esm(() => {
331
- whichCache = new Map;
708
+ init_which();
332
709
  RUNTIME_CHAINS = {
333
710
  python: [["uv", "run"], ["python3"], ["python"]],
334
711
  bash: [["bash"]],
@@ -347,6 +724,198 @@ var init_runner = __esm(() => {
347
724
  };
348
725
  });
349
726
 
727
+ // src/core/commands.ts
728
+ var exports_commands = {};
729
+ __export(exports_commands, {
730
+ handleSync: () => handleSync,
731
+ handleRun: () => handleRun,
732
+ handleList: () => handleList
733
+ });
734
+ import { join as join7 } from "path";
735
+ async function handleList(options) {
736
+ const { kadaiDir, all } = options;
737
+ const config = await loadConfig(kadaiDir);
738
+ const actionsDir = join7(kadaiDir, config.actionsDir ?? "actions");
739
+ let actions = await loadActions(actionsDir);
740
+ const globalActions = await loadUserGlobalActions();
741
+ actions = [...actions, ...globalActions];
742
+ if (config.plugins) {
743
+ for (const source of config.plugins) {
744
+ if ("path" in source) {
745
+ const pathActions = await loadPathPlugin(kadaiDir, source);
746
+ actions = [...actions, ...pathActions];
747
+ }
748
+ }
749
+ const cachedActions = await loadCachedPlugins(kadaiDir, config.plugins);
750
+ actions = [...actions, ...cachedActions];
751
+ }
752
+ const filtered = all ? actions : actions.filter((a) => !a.meta.hidden);
753
+ const output = filtered.map((a) => ({
754
+ id: a.id,
755
+ name: a.meta.name,
756
+ emoji: a.meta.emoji,
757
+ description: a.meta.description,
758
+ category: a.category,
759
+ runtime: a.runtime,
760
+ confirm: a.meta.confirm ?? false,
761
+ fullscreen: a.meta.fullscreen ?? false,
762
+ origin: a.origin
763
+ }));
764
+ process.stdout.write(`${JSON.stringify(output, null, 2)}
765
+ `);
766
+ process.exit(0);
767
+ }
768
+ async function handleRun(options) {
769
+ const { kadaiDir, actionId, cwd } = options;
770
+ const config = await loadConfig(kadaiDir);
771
+ const actionsDir = join7(kadaiDir, config.actionsDir ?? "actions");
772
+ let actions = await loadActions(actionsDir);
773
+ const globalActions = await loadUserGlobalActions();
774
+ actions = [...actions, ...globalActions];
775
+ if (config.plugins) {
776
+ for (const source of config.plugins) {
777
+ if ("path" in source) {
778
+ const pathActions = await loadPathPlugin(kadaiDir, source);
779
+ actions = [...actions, ...pathActions];
780
+ }
781
+ }
782
+ const cachedActions = await loadCachedPlugins(kadaiDir, config.plugins);
783
+ actions = [...actions, ...cachedActions];
784
+ }
785
+ const action = actions.find((a) => a.id === actionId);
786
+ if (!action) {
787
+ process.stderr.write(`Error: action "${actionId}" not found
788
+ `);
789
+ process.exit(1);
790
+ }
791
+ if (action.runtime === "ink") {
792
+ const mod = await import(action.filePath);
793
+ if (typeof mod.default !== "function") {
794
+ process.stderr.write(`Error: "${action.filePath}" does not export a default function component
795
+ `);
796
+ process.exit(1);
797
+ }
798
+ const cleanupFullscreen = action.meta.fullscreen ? enterFullscreen() : undefined;
799
+ const React = await import("react");
800
+ const { render } = await import("ink");
801
+ const instance = render(React.createElement(mod.default, {
802
+ cwd,
803
+ env: config.env ?? {},
804
+ args: [],
805
+ onExit: () => instance.unmount()
806
+ }));
807
+ await instance.waitUntilExit();
808
+ cleanupFullscreen?.();
809
+ process.exit(0);
810
+ }
811
+ const cmd = resolveCommand(action);
812
+ const env = {
813
+ ...process.env,
814
+ ...config.env ?? {}
815
+ };
816
+ process.stdin.removeAllListeners();
817
+ if (process.stdin.isTTY) {
818
+ process.stdin.setRawMode(false);
819
+ }
820
+ process.stdin.pause();
821
+ process.stdin.unref();
822
+ const proc = Bun.spawn(cmd, {
823
+ cwd,
824
+ stdout: "inherit",
825
+ stderr: "inherit",
826
+ stdin: "inherit",
827
+ env
828
+ });
829
+ const exitCode = await proc.exited;
830
+ process.exit(exitCode);
831
+ }
832
+ async function handleSync(options) {
833
+ const { kadaiDir } = options;
834
+ const config = await loadConfig(kadaiDir);
835
+ if (!config.plugins || config.plugins.length === 0) {
836
+ process.stdout.write(`No plugins configured.
837
+ `);
838
+ process.exit(0);
839
+ }
840
+ process.stdout.write(`Syncing plugins...
841
+ `);
842
+ const results = [];
843
+ await syncPlugins(kadaiDir, config.plugins, {
844
+ onPluginStatus: (name, status) => {
845
+ if (status === "syncing") {
846
+ process.stdout.write(` \u27F3 ${name}
847
+ `);
848
+ } else if (status === "done") {
849
+ results.push({ name, status: "done" });
850
+ } else if (status === "error") {
851
+ results.push({ name, status: "error" });
852
+ }
853
+ },
854
+ onUpdate: () => {}
855
+ });
856
+ process.stdout.write(`
857
+ `);
858
+ for (const r of results) {
859
+ const icon = r.status === "done" ? "\u2713" : "\u2717";
860
+ process.stdout.write(` ${icon} ${r.name}
861
+ `);
862
+ }
863
+ const failed = results.filter((r) => r.status === "error").length;
864
+ if (failed > 0) {
865
+ process.stdout.write(`
866
+ ${failed} plugin(s) failed to sync.
867
+ `);
868
+ process.exit(1);
869
+ }
870
+ process.stdout.write(`
871
+ All plugins synced.
872
+ `);
873
+ process.exit(0);
874
+ }
875
+ var init_commands = __esm(() => {
876
+ init_config();
877
+ init_loader();
878
+ init_plugins();
879
+ init_runner();
880
+ });
881
+
882
+ // package.json
883
+ var require_package = __commonJS((exports, module) => {
884
+ module.exports = {
885
+ name: "kadai",
886
+ version: "0.5.0",
887
+ type: "module",
888
+ bin: {
889
+ kadai: "./dist/cli.js"
890
+ },
891
+ scripts: {
892
+ build: "bun build.ts",
893
+ check: "tsc --noEmit && biome check ./src ./test",
894
+ lint: "biome check ./src ./test",
895
+ "lint:fix": "biome check --write ./src ./test"
896
+ },
897
+ files: [
898
+ "dist/"
899
+ ],
900
+ devDependencies: {
901
+ "@biomejs/biome": "^2.3.14",
902
+ "@types/bun": "latest",
903
+ "ink-testing-library": "^4.0.0"
904
+ },
905
+ peerDependencies: {
906
+ typescript: "^5"
907
+ },
908
+ dependencies: {
909
+ "@inkjs/ui": "^2.0.0",
910
+ "@modelcontextprotocol/sdk": "^1.12.1",
911
+ "@types/react": "^19.2.14",
912
+ fuzzysort: "^3.1.0",
913
+ ink: "^6.7.0",
914
+ react: "^19.2.4"
915
+ }
916
+ };
917
+ });
918
+
350
919
  // src/core/mcp.ts
351
920
  var exports_mcp = {};
352
921
  __export(exports_mcp, {
@@ -355,13 +924,13 @@ __export(exports_mcp, {
355
924
  ensureMcpConfig: () => ensureMcpConfig,
356
925
  actionIdToToolName: () => actionIdToToolName
357
926
  });
358
- import { join as join4, resolve } from "path";
927
+ import { join as join8, resolve as resolve2 } from "path";
359
928
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
360
929
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
361
930
  function resolveInvocationCommand() {
362
931
  const script = process.argv[1] ?? "";
363
932
  if (script.endsWith(".ts") || script.endsWith(".tsx")) {
364
- return { command: "bun", args: [resolve(script), "mcp"] };
933
+ return { command: "bun", args: [resolve2(script), "mcp"] };
365
934
  }
366
935
  return { command: "bunx", args: ["kadai", "mcp"] };
367
936
  }
@@ -382,7 +951,7 @@ function buildToolDescription(action) {
382
951
  return parts.join(" ");
383
952
  }
384
953
  async function ensureMcpConfig(projectRoot) {
385
- const mcpJsonPath = join4(projectRoot, ".mcp.json");
954
+ const mcpJsonPath = join8(projectRoot, ".mcp.json");
386
955
  const mcpFile = Bun.file(mcpJsonPath);
387
956
  const kadaiEntry = resolveInvocationCommand();
388
957
  if (await mcpFile.exists()) {
@@ -415,8 +984,21 @@ async function startMcpServer(kadaiDir, cwd) {
415
984
  let config = {};
416
985
  if (kadaiDir) {
417
986
  config = await loadConfig(kadaiDir);
418
- const actionsDir = join4(kadaiDir, config.actionsDir ?? "actions");
419
- visibleActions = (await loadActions(actionsDir)).filter((a) => !a.meta.hidden);
987
+ const actionsDir = join8(kadaiDir, config.actionsDir ?? "actions");
988
+ let allActions = await loadActions(actionsDir);
989
+ const globalActions = await loadUserGlobalActions();
990
+ allActions = [...allActions, ...globalActions];
991
+ if (config.plugins) {
992
+ for (const source of config.plugins) {
993
+ if ("path" in source) {
994
+ const pathActions = await loadPathPlugin(kadaiDir, source);
995
+ allActions = [...allActions, ...pathActions];
996
+ }
997
+ }
998
+ const cachedActions = await loadCachedPlugins(kadaiDir, config.plugins);
999
+ allActions = [...allActions, ...cachedActions];
1000
+ }
1001
+ visibleActions = allActions.filter((a) => !a.meta.hidden);
420
1002
  }
421
1003
  const server = new McpServer({ name: "kadai", version: "0.3.0" });
422
1004
  const env = {
@@ -465,6 +1047,7 @@ ${stderr}`);
465
1047
  var init_mcp = __esm(() => {
466
1048
  init_config();
467
1049
  init_loader();
1050
+ init_plugins();
468
1051
  init_runner();
469
1052
  });
470
1053
 
@@ -629,29 +1212,70 @@ function StatusBar() {
629
1212
  var init_StatusBar = () => {};
630
1213
 
631
1214
  // src/hooks/useActions.ts
632
- import { join as join5 } from "path";
1215
+ import { join as join9 } from "path";
633
1216
  import { useEffect as useEffect2, useRef, useState as useState2 } from "react";
634
1217
  function useActions({ kadaiDir }) {
635
1218
  const [actions, setActions] = useState2([]);
636
1219
  const [config, setConfig] = useState2({});
637
1220
  const [loading, setLoading] = useState2(true);
1221
+ const [pluginSyncStatuses, setPluginSyncStatuses] = useState2(new Map);
638
1222
  const actionsRef = useRef(actions);
639
1223
  actionsRef.current = actions;
640
1224
  useEffect2(() => {
641
1225
  (async () => {
642
1226
  const cfg = await loadConfig(kadaiDir);
643
1227
  setConfig(cfg);
644
- const actionsDir = join5(kadaiDir, cfg.actionsDir ?? "actions");
1228
+ const actionsDir = join9(kadaiDir, cfg.actionsDir ?? "actions");
645
1229
  const localActions = await loadActions(actionsDir);
646
- setActions(localActions);
1230
+ let allActions = [...localActions];
1231
+ const globalActions = await loadUserGlobalActions();
1232
+ allActions = [...allActions, ...globalActions];
1233
+ if (cfg.plugins) {
1234
+ for (const source of cfg.plugins) {
1235
+ if ("path" in source) {
1236
+ const pathActions = await loadPathPlugin(kadaiDir, source);
1237
+ allActions = [...allActions, ...pathActions];
1238
+ }
1239
+ }
1240
+ }
1241
+ if (cfg.plugins) {
1242
+ const cachedActions = await loadCachedPlugins(kadaiDir, cfg.plugins);
1243
+ allActions = [...allActions, ...cachedActions];
1244
+ }
1245
+ setActions(allActions);
647
1246
  setLoading(false);
1247
+ if (cfg.plugins && cfg.plugins.length > 0) {
1248
+ const initialStatuses = new Map;
1249
+ for (const source of cfg.plugins) {
1250
+ if (!("path" in source)) {
1251
+ initialStatuses.set(pluginDisplayName(source), "syncing");
1252
+ }
1253
+ }
1254
+ setPluginSyncStatuses(initialStatuses);
1255
+ syncPlugins(kadaiDir, cfg.plugins, {
1256
+ onPluginStatus: (name, status) => {
1257
+ setPluginSyncStatuses((prev) => {
1258
+ const next = new Map(prev);
1259
+ next.set(name, status);
1260
+ return next;
1261
+ });
1262
+ },
1263
+ onUpdate: (freshPluginActions) => {
1264
+ setActions((prev) => {
1265
+ const nonCached = prev.filter((a) => a.origin.type === "local" || a.origin.type === "plugin" && (a.origin.pluginName === "~" || cfg.plugins?.some((p) => ("path" in p) && p.path === a.origin.pluginName)));
1266
+ return [...nonCached, ...freshPluginActions];
1267
+ });
1268
+ }
1269
+ });
1270
+ }
648
1271
  })();
649
1272
  }, [kadaiDir]);
650
- return { actions, actionsRef, config, loading };
1273
+ return { actions, actionsRef, config, loading, pluginSyncStatuses };
651
1274
  }
652
1275
  var init_useActions = __esm(() => {
653
1276
  init_config();
654
1277
  init_loader();
1278
+ init_plugins();
655
1279
  });
656
1280
 
657
1281
  // src/hooks/useKeyboard.ts
@@ -1625,7 +2249,8 @@ import { Box as Box4, Text as Text4, useApp } from "ink";
1625
2249
  import { jsxDEV as jsxDEV5, Fragment as Fragment2 } from "react/jsx-dev-runtime";
1626
2250
  function MenuList({
1627
2251
  items,
1628
- selectedIndex
2252
+ selectedIndex,
2253
+ pluginSyncStatuses
1629
2254
  }) {
1630
2255
  const hasAnyNew = items.some((item) => item.isNew);
1631
2256
  return /* @__PURE__ */ jsxDEV5(Fragment2, {
@@ -1653,12 +2278,16 @@ function MenuList({
1653
2278
  /* @__PURE__ */ jsxDEV5(Text4, {
1654
2279
  color: selected ? "cyan" : undefined,
1655
2280
  children: [
1656
- item.type === "category" ? "\uD83D\uDCC1 " : "",
2281
+ item.type === "category" ? item.isPlugin ? "\uD83D\uDCE6 " : "\uD83D\uDCC1 " : "",
1657
2282
  item.type === "action" && item.emoji ? `${item.emoji} ` : "",
1658
2283
  item.label,
1659
2284
  item.type === "category" ? " \u25B8" : ""
1660
2285
  ]
1661
2286
  }, undefined, true, undefined, this),
2287
+ item.type === "category" && item.isPlugin && pluginSyncStatuses?.get(item.value) === "syncing" && /* @__PURE__ */ jsxDEV5(Text4, {
2288
+ dimColor: true,
2289
+ children: " \u27F3"
2290
+ }, undefined, false, undefined, this),
1662
2291
  item.description && /* @__PURE__ */ jsxDEV5(Text4, {
1663
2292
  dimColor: true,
1664
2293
  children: [
@@ -1680,7 +2309,7 @@ function App({ kadaiDir, onRunAction }) {
1680
2309
  };
1681
2310
  const search = useSearch();
1682
2311
  const nav = useNavigation({ onExit: exit, onNavigate: search.resetSearch });
1683
- const { actions, actionsRef, config, loading } = useActions({
2312
+ const { actions, actionsRef, config, loading, pluginSyncStatuses } = useActions({
1684
2313
  kadaiDir
1685
2314
  });
1686
2315
  useKeyboard({
@@ -1744,7 +2373,8 @@ function App({ kadaiDir, onRunAction }) {
1744
2373
  children: "No matching items"
1745
2374
  }, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV5(MenuList, {
1746
2375
  items: filteredItems,
1747
- selectedIndex: search.selectedIndex
2376
+ selectedIndex: search.selectedIndex,
2377
+ pluginSyncStatuses
1748
2378
  }, undefined, false, undefined, this),
1749
2379
  /* @__PURE__ */ jsxDEV5(StatusBar, {}, undefined, false, undefined, this)
1750
2380
  ]
@@ -1824,6 +2454,12 @@ function buildMenuItems(actions, path) {
1824
2454
  const categories = new Set;
1825
2455
  const items = [];
1826
2456
  const newActionIds = new Set;
2457
+ const pluginCategories = new Set;
2458
+ for (const action of actions) {
2459
+ if (action.origin.type === "plugin" && action.category.length > 0) {
2460
+ pluginCategories.add(action.category[0]);
2461
+ }
2462
+ }
1827
2463
  for (const action of actions) {
1828
2464
  if (isRecentlyAdded(action)) {
1829
2465
  newActionIds.add(action.id);
@@ -1838,7 +2474,8 @@ function buildMenuItems(actions, path) {
1838
2474
  items.push({
1839
2475
  type: "category",
1840
2476
  label: topCategory,
1841
- value: topCategory
2477
+ value: topCategory,
2478
+ isPlugin: pluginCategories.has(topCategory)
1842
2479
  });
1843
2480
  }
1844
2481
  } else {
@@ -1882,6 +2519,18 @@ function buildMenuItems(actions, path) {
1882
2519
  items.sort((a, b) => {
1883
2520
  if (a.type !== b.type)
1884
2521
  return a.type === "category" ? -1 : 1;
2522
+ if (a.type === "category" && b.type === "category") {
2523
+ const aPlugin = a.isPlugin ?? false;
2524
+ const bPlugin = b.isPlugin ?? false;
2525
+ if (aPlugin !== bPlugin)
2526
+ return aPlugin ? -1 : 1;
2527
+ if (aPlugin && bPlugin) {
2528
+ if (a.value === "~")
2529
+ return -1;
2530
+ if (b.value === "~")
2531
+ return 1;
2532
+ }
2533
+ }
1885
2534
  return a.label.localeCompare(b.label);
1886
2535
  });
1887
2536
  return items;
@@ -1905,8 +2554,8 @@ __export(exports_init_wizard, {
1905
2554
  writeInitFiles: () => writeInitFiles,
1906
2555
  generateConfigFile: () => generateConfigFile
1907
2556
  });
1908
- import { existsSync, mkdirSync } from "fs";
1909
- import { join as join6 } from "path";
2557
+ import { existsSync as existsSync2, mkdirSync } from "fs";
2558
+ import { join as join10 } from "path";
1910
2559
  function generateConfigFile() {
1911
2560
  const lines = [' // actionsDir: "actions",', " // env: {},"];
1912
2561
  return `export default {
@@ -1916,10 +2565,10 @@ ${lines.join(`
1916
2565
  `;
1917
2566
  }
1918
2567
  async function writeInitFiles(cwd) {
1919
- const kadaiDir = join6(cwd, ".kadai");
1920
- const actionsDir = join6(kadaiDir, "actions");
2568
+ const kadaiDir = join10(cwd, ".kadai");
2569
+ const actionsDir = join10(kadaiDir, "actions");
1921
2570
  mkdirSync(actionsDir, { recursive: true });
1922
- const sampleAction = join6(actionsDir, "hello.sh");
2571
+ const sampleAction = join10(actionsDir, "hello.sh");
1923
2572
  const sampleFile = Bun.file(sampleAction);
1924
2573
  let sampleCreated = false;
1925
2574
  if (!await sampleFile.exists()) {
@@ -1934,14 +2583,14 @@ echo "Add your own scripts to .kadai/actions/ to get started."
1934
2583
  sampleCreated = true;
1935
2584
  }
1936
2585
  const configContent = generateConfigFile();
1937
- const configPath = join6(kadaiDir, "config.ts");
2586
+ const configPath = join10(kadaiDir, "config.ts");
1938
2587
  await Bun.write(configPath, configContent);
1939
2588
  let skillCreated = false;
1940
- const hasClaudeDir = existsSync(join6(cwd, ".claude"));
1941
- const hasClaudeMd = existsSync(join6(cwd, "CLAUDE.md"));
2589
+ const hasClaudeDir = existsSync2(join10(cwd, ".claude"));
2590
+ const hasClaudeMd = existsSync2(join10(cwd, "CLAUDE.md"));
1942
2591
  if (hasClaudeDir || hasClaudeMd) {
1943
- const skillDir = join6(cwd, ".claude", "skills", "kadai");
1944
- const skillPath = join6(skillDir, "SKILL.md");
2592
+ const skillDir = join10(cwd, ".claude", "skills", "kadai");
2593
+ const skillPath = join10(skillDir, "SKILL.md");
1945
2594
  if (!await Bun.file(skillPath).exists()) {
1946
2595
  mkdirSync(skillDir, { recursive: true });
1947
2596
  await Bun.write(skillPath, generateSkillFile());
@@ -2053,6 +2702,9 @@ function parseArgs(argv) {
2053
2702
  return { type: "interactive" };
2054
2703
  }
2055
2704
  const command = argv[0];
2705
+ if (command === "--version" || command === "-v") {
2706
+ return { type: "version" };
2707
+ }
2056
2708
  switch (command) {
2057
2709
  case "list": {
2058
2710
  if (!argv.includes("--json")) {
@@ -2073,87 +2725,18 @@ function parseArgs(argv) {
2073
2725
  }
2074
2726
  case "mcp":
2075
2727
  return { type: "mcp" };
2728
+ case "sync":
2729
+ return { type: "sync" };
2076
2730
  default:
2077
2731
  return {
2078
2732
  type: "error",
2079
- message: `Unknown command: ${command}. Available commands: list, run, mcp`
2733
+ message: `Unknown command: ${command}. Available commands: list, run, sync, mcp, --version`
2080
2734
  };
2081
2735
  }
2082
2736
  }
2083
2737
 
2084
- // src/core/commands.ts
2085
- init_config();
2086
- init_loader();
2087
- init_runner();
2088
- import { join as join3 } from "path";
2089
- async function handleList(options) {
2090
- const { kadaiDir, all } = options;
2091
- const config = await loadConfig(kadaiDir);
2092
- const actionsDir = join3(kadaiDir, config.actionsDir ?? "actions");
2093
- const actions = await loadActions(actionsDir);
2094
- const filtered = all ? actions : actions.filter((a) => !a.meta.hidden);
2095
- const output = filtered.map((a) => ({
2096
- id: a.id,
2097
- name: a.meta.name,
2098
- emoji: a.meta.emoji,
2099
- description: a.meta.description,
2100
- category: a.category,
2101
- runtime: a.runtime,
2102
- confirm: a.meta.confirm ?? false,
2103
- fullscreen: a.meta.fullscreen ?? false
2104
- }));
2105
- process.stdout.write(`${JSON.stringify(output, null, 2)}
2106
- `);
2107
- process.exit(0);
2108
- }
2109
- async function handleRun(options) {
2110
- const { kadaiDir, actionId, cwd } = options;
2111
- const config = await loadConfig(kadaiDir);
2112
- const actionsDir = join3(kadaiDir, config.actionsDir ?? "actions");
2113
- const actions = await loadActions(actionsDir);
2114
- const action = actions.find((a) => a.id === actionId);
2115
- if (!action) {
2116
- process.stderr.write(`Error: action "${actionId}" not found
2117
- `);
2118
- process.exit(1);
2119
- }
2120
- if (action.runtime === "ink") {
2121
- const mod = await import(action.filePath);
2122
- if (typeof mod.default !== "function") {
2123
- process.stderr.write(`Error: "${action.filePath}" does not export a default function component
2124
- `);
2125
- process.exit(1);
2126
- }
2127
- const cleanupFullscreen = action.meta.fullscreen ? enterFullscreen() : undefined;
2128
- const React = await import("react");
2129
- const { render } = await import("ink");
2130
- const instance = render(React.createElement(mod.default, {
2131
- cwd,
2132
- env: config.env ?? {},
2133
- args: [],
2134
- onExit: () => instance.unmount()
2135
- }));
2136
- await instance.waitUntilExit();
2137
- cleanupFullscreen?.();
2138
- process.exit(0);
2139
- }
2140
- const cmd = resolveCommand(action);
2141
- const env = {
2142
- ...process.env,
2143
- ...config.env ?? {}
2144
- };
2145
- const proc = Bun.spawn(cmd, {
2146
- cwd,
2147
- stdout: "inherit",
2148
- stderr: "inherit",
2149
- stdin: "inherit",
2150
- env
2151
- });
2152
- const exitCode = await proc.exited;
2153
- process.exit(exitCode);
2154
- }
2155
-
2156
2738
  // src/cli.tsx
2739
+ init_commands();
2157
2740
  init_loader();
2158
2741
  var cwd = process.cwd();
2159
2742
  var parsed = parseArgs(process.argv.slice(2));
@@ -2162,6 +2745,11 @@ if (parsed.type === "error") {
2162
2745
  `);
2163
2746
  process.exit(1);
2164
2747
  }
2748
+ if (parsed.type === "version") {
2749
+ const { version } = await Promise.resolve().then(() => __toESM(require_package(), 1));
2750
+ console.log(version);
2751
+ process.exit(0);
2752
+ }
2165
2753
  if (parsed.type === "mcp") {
2166
2754
  const { ensureMcpConfig: ensureMcpConfig2, startMcpServer: startMcpServer2 } = await Promise.resolve().then(() => (init_mcp(), exports_mcp));
2167
2755
  const kadaiDir = findZcliDir(cwd);
@@ -2170,7 +2758,7 @@ if (parsed.type === "mcp") {
2170
2758
  await startMcpServer2(kadaiDir, cwd);
2171
2759
  await new Promise(() => {});
2172
2760
  }
2173
- if (parsed.type === "list" || parsed.type === "run") {
2761
+ if (parsed.type === "list" || parsed.type === "run" || parsed.type === "sync") {
2174
2762
  const kadaiDir = findZcliDir(cwd);
2175
2763
  if (!kadaiDir) {
2176
2764
  process.stderr.write(`Error: No .kadai directory found. Run kadai to initialize.
@@ -2179,8 +2767,11 @@ if (parsed.type === "list" || parsed.type === "run") {
2179
2767
  }
2180
2768
  if (parsed.type === "list") {
2181
2769
  await handleList({ kadaiDir, all: parsed.all });
2182
- } else {
2770
+ } else if (parsed.type === "run") {
2183
2771
  await handleRun({ kadaiDir, actionId: parsed.actionId, cwd });
2772
+ } else {
2773
+ const { handleSync: handleSync2 } = await Promise.resolve().then(() => (init_commands(), exports_commands));
2774
+ await handleSync2({ kadaiDir });
2184
2775
  }
2185
2776
  }
2186
2777
  var { Readable } = await import("stream");
@@ -2299,22 +2890,18 @@ var env = {
2299
2890
  console.log(`${action.meta.emoji ? `${action.meta.emoji} ` : ""}${action.meta.name}
2300
2891
  `);
2301
2892
  process.stdin.removeAllListeners("data");
2893
+ process.stdin.removeAllListeners("end");
2894
+ if (process.stdin.isTTY) {
2895
+ process.stdin.setRawMode(false);
2896
+ }
2897
+ process.stdin.pause();
2898
+ process.stdin.unref();
2302
2899
  var proc = Bun.spawn(cmd, {
2303
2900
  cwd,
2304
2901
  stdout: "inherit",
2305
2902
  stderr: "inherit",
2306
- stdin: "pipe",
2903
+ stdin: "inherit",
2307
2904
  env
2308
2905
  });
2309
- var forwardStdin = (data) => {
2310
- try {
2311
- const converted = Buffer.from(data.toString().replace(/\r/g, `
2312
- `));
2313
- proc.stdin.write(converted);
2314
- proc.stdin.flush();
2315
- } catch {}
2316
- };
2317
- process.stdin.on("data", forwardStdin);
2318
- process.stdin.resume();
2319
2906
  var exitCode = await proc.exited;
2320
2907
  process.exit(exitCode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kadai",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "kadai": "./dist/cli.js"