@kb-labs/shared 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.
Files changed (232) hide show
  1. package/.cursorrules +32 -0
  2. package/.github/workflows/ci.yml +13 -0
  3. package/.github/workflows/deploy.yml +28 -0
  4. package/.github/workflows/docker-build.yml +25 -0
  5. package/.github/workflows/drift-check.yml +10 -0
  6. package/.github/workflows/profiles-validate.yml +16 -0
  7. package/.github/workflows/release.yml +8 -0
  8. package/.kb/devkit/agents/devkit-maintainer/context.globs +15 -0
  9. package/.kb/devkit/agents/devkit-maintainer/permissions.yml +17 -0
  10. package/.kb/devkit/agents/devkit-maintainer/prompt.md +28 -0
  11. package/.kb/devkit/agents/devkit-maintainer/runbook.md +31 -0
  12. package/.kb/devkit/agents/docs-crafter/prompt.md +24 -0
  13. package/.kb/devkit/agents/docs-crafter/runbook.md +18 -0
  14. package/.kb/devkit/agents/release-manager/context.globs +7 -0
  15. package/.kb/devkit/agents/release-manager/prompt.md +27 -0
  16. package/.kb/devkit/agents/release-manager/runbook.md +17 -0
  17. package/.kb/devkit/agents/test-generator/context.globs +7 -0
  18. package/.kb/devkit/agents/test-generator/prompt.md +27 -0
  19. package/.kb/devkit/agents/test-generator/runbook.md +18 -0
  20. package/.vscode/settings.json +23 -0
  21. package/CHANGELOG.md +33 -0
  22. package/CONTRIBUTING.md +117 -0
  23. package/LICENSE +21 -0
  24. package/README.md +306 -0
  25. package/docs/DECLARATIVE-FLAGS-AND-ENV.md +622 -0
  26. package/docs/DOCUMENTATION.md +70 -0
  27. package/docs/adr/0000-template.md +52 -0
  28. package/docs/adr/0001-architecture-and-repository-layout.md +31 -0
  29. package/docs/adr/0002-plugins-and-extensibility.md +44 -0
  30. package/docs/adr/0003-package-and-module-boundaries.md +35 -0
  31. package/docs/adr/0004-versioning-and-release-policy.md +36 -0
  32. package/docs/adr/0005-reactive-loader-pattern.md +179 -0
  33. package/docs/adr/0006-declarative-flags-and-env-systems.md +376 -0
  34. package/eslint.config.js +27 -0
  35. package/kb-labs.config.json +5 -0
  36. package/package.json +88 -0
  37. package/package.json.bin +25 -0
  38. package/package.json.lib +30 -0
  39. package/packages/shared-cli-ui/CHANGELOG.md +20 -0
  40. package/packages/shared-cli-ui/README.md +342 -0
  41. package/packages/shared-cli-ui/docs/ARCHITECTURE.md +105 -0
  42. package/packages/shared-cli-ui/eslint.config.js +27 -0
  43. package/packages/shared-cli-ui/package.json +72 -0
  44. package/packages/shared-cli-ui/src/__tests__/artifacts-display.spec.ts +89 -0
  45. package/packages/shared-cli-ui/src/__tests__/format.spec.ts +44 -0
  46. package/packages/shared-cli-ui/src/__tests__/loader-json-mode.test.ts +119 -0
  47. package/packages/shared-cli-ui/src/artifacts-display.ts +266 -0
  48. package/packages/shared-cli-ui/src/cli-auto-discovery.ts +120 -0
  49. package/packages/shared-cli-ui/src/colors.ts +142 -0
  50. package/packages/shared-cli-ui/src/command-discovery.ts +72 -0
  51. package/packages/shared-cli-ui/src/command-output.ts +153 -0
  52. package/packages/shared-cli-ui/src/command-result.ts +267 -0
  53. package/packages/shared-cli-ui/src/command-runner.ts +310 -0
  54. package/packages/shared-cli-ui/src/command-suggestions.ts +204 -0
  55. package/packages/shared-cli-ui/src/debug/components/output.ts +141 -0
  56. package/packages/shared-cli-ui/src/debug/components/trace.ts +101 -0
  57. package/packages/shared-cli-ui/src/debug/components/tree.ts +88 -0
  58. package/packages/shared-cli-ui/src/debug/formatters/ai.ts +17 -0
  59. package/packages/shared-cli-ui/src/debug/formatters/human.ts +98 -0
  60. package/packages/shared-cli-ui/src/debug/formatters/timeline.ts +94 -0
  61. package/packages/shared-cli-ui/src/debug/index.ts +56 -0
  62. package/packages/shared-cli-ui/src/debug/types.ts +57 -0
  63. package/packages/shared-cli-ui/src/debug/utilities.ts +203 -0
  64. package/packages/shared-cli-ui/src/dynamic-command-discovery.ts +131 -0
  65. package/packages/shared-cli-ui/src/format.ts +412 -0
  66. package/packages/shared-cli-ui/src/index.ts +34 -0
  67. package/packages/shared-cli-ui/src/loader.ts +196 -0
  68. package/packages/shared-cli-ui/src/manifest-parser.ts +151 -0
  69. package/packages/shared-cli-ui/src/modern-format.ts +271 -0
  70. package/packages/shared-cli-ui/src/multi-cli-suggestions.ts +159 -0
  71. package/packages/shared-cli-ui/src/table.ts +134 -0
  72. package/packages/shared-cli-ui/src/timing-tracker.ts +68 -0
  73. package/packages/shared-cli-ui/src/utils/context.ts +12 -0
  74. package/packages/shared-cli-ui/src/utils/env.ts +164 -0
  75. package/packages/shared-cli-ui/src/utils/flags.ts +269 -0
  76. package/packages/shared-cli-ui/src/utils/path.ts +8 -0
  77. package/packages/shared-cli-ui/tsconfig.build.json +15 -0
  78. package/packages/shared-cli-ui/tsconfig.json +9 -0
  79. package/packages/shared-cli-ui/tsup.config.ts +11 -0
  80. package/packages/shared-cli-ui/vitest.config.ts +15 -0
  81. package/packages/shared-command-kit/CHANGELOG.md +20 -0
  82. package/packages/shared-command-kit/LICENSE +22 -0
  83. package/packages/shared-command-kit/README.md +1030 -0
  84. package/packages/shared-command-kit/docs/HIGH-LEVEL-API.md +89 -0
  85. package/packages/shared-command-kit/docs/LOW-LEVEL-API.md +105 -0
  86. package/packages/shared-command-kit/docs/MIGRATION-GUIDE.md +135 -0
  87. package/packages/shared-command-kit/eslint.config.js +27 -0
  88. package/packages/shared-command-kit/eslint.config.ts +14 -0
  89. package/packages/shared-command-kit/package.json +76 -0
  90. package/packages/shared-command-kit/prettierrc.json +5 -0
  91. package/packages/shared-command-kit/src/__tests__/define-command.spec.ts +294 -0
  92. package/packages/shared-command-kit/src/__tests__/define-route.test.ts +285 -0
  93. package/packages/shared-command-kit/src/__tests__/define-system-command.spec.ts +508 -0
  94. package/packages/shared-command-kit/src/__tests__/define-webhook.test.ts +156 -0
  95. package/packages/shared-command-kit/src/__tests__/define-websocket.test.ts +316 -0
  96. package/packages/shared-command-kit/src/__tests__/errors.spec.ts +45 -0
  97. package/packages/shared-command-kit/src/__tests__/flags.spec.ts +353 -0
  98. package/packages/shared-command-kit/src/__tests__/platform-api.test.ts +135 -0
  99. package/packages/shared-command-kit/src/__tests__/plugin-context-v3.snapshot.spec.ts +240 -0
  100. package/packages/shared-command-kit/src/__tests__/ws-types.test.ts +359 -0
  101. package/packages/shared-command-kit/src/analytics/index.ts +6 -0
  102. package/packages/shared-command-kit/src/analytics/with-analytics.ts +195 -0
  103. package/packages/shared-command-kit/src/define-action.ts +100 -0
  104. package/packages/shared-command-kit/src/define-command.ts +113 -0
  105. package/packages/shared-command-kit/src/define-route.ts +113 -0
  106. package/packages/shared-command-kit/src/define-system-command.ts +362 -0
  107. package/packages/shared-command-kit/src/define-webhook.ts +115 -0
  108. package/packages/shared-command-kit/src/define-websocket.ts +308 -0
  109. package/packages/shared-command-kit/src/errors/factory.ts +282 -0
  110. package/packages/shared-command-kit/src/errors/format-validation.ts +144 -0
  111. package/packages/shared-command-kit/src/errors/format.ts +92 -0
  112. package/packages/shared-command-kit/src/errors/index.ts +9 -0
  113. package/packages/shared-command-kit/src/errors/types.ts +32 -0
  114. package/packages/shared-command-kit/src/flags/define.ts +92 -0
  115. package/packages/shared-command-kit/src/flags/index.ts +9 -0
  116. package/packages/shared-command-kit/src/flags/types.ts +153 -0
  117. package/packages/shared-command-kit/src/flags/validate.ts +358 -0
  118. package/packages/shared-command-kit/src/helpers/context.ts +8 -0
  119. package/packages/shared-command-kit/src/helpers/flags.ts +84 -0
  120. package/packages/shared-command-kit/src/helpers/index.ts +42 -0
  121. package/packages/shared-command-kit/src/helpers/patterns.ts +464 -0
  122. package/packages/shared-command-kit/src/helpers/platform.ts +335 -0
  123. package/packages/shared-command-kit/src/helpers/use-analytics.ts +95 -0
  124. package/packages/shared-command-kit/src/helpers/use-cache.ts +97 -0
  125. package/packages/shared-command-kit/src/helpers/use-config.ts +99 -0
  126. package/packages/shared-command-kit/src/helpers/use-embeddings.ts +49 -0
  127. package/packages/shared-command-kit/src/helpers/use-llm.ts +316 -0
  128. package/packages/shared-command-kit/src/helpers/use-logger.ts +77 -0
  129. package/packages/shared-command-kit/src/helpers/use-platform.ts +111 -0
  130. package/packages/shared-command-kit/src/helpers/use-resource-broker.ts +106 -0
  131. package/packages/shared-command-kit/src/helpers/use-storage.ts +71 -0
  132. package/packages/shared-command-kit/src/helpers/use-vector-store.ts +49 -0
  133. package/packages/shared-command-kit/src/helpers/validation.ts +398 -0
  134. package/packages/shared-command-kit/src/index.ts +410 -0
  135. package/packages/shared-command-kit/src/jobs.ts +132 -0
  136. package/packages/shared-command-kit/src/lifecycle/define-handlers.ts +366 -0
  137. package/packages/shared-command-kit/src/lifecycle/index.ts +6 -0
  138. package/packages/shared-command-kit/src/manifest.ts +127 -0
  139. package/packages/shared-command-kit/src/rest/define-handler.ts +187 -0
  140. package/packages/shared-command-kit/src/rest/index.ts +11 -0
  141. package/packages/shared-command-kit/src/studio/index.ts +12 -0
  142. package/packages/shared-command-kit/src/validation/index.ts +6 -0
  143. package/packages/shared-command-kit/src/validation/schema-builders.ts +409 -0
  144. package/packages/shared-command-kit/src/ws-types.ts +106 -0
  145. package/packages/shared-command-kit/tsconfig.build.json +15 -0
  146. package/packages/shared-command-kit/tsconfig.json +9 -0
  147. package/packages/shared-command-kit/tsup.config.ts +30 -0
  148. package/packages/shared-command-kit/vitest.config.ts +4 -0
  149. package/packages/shared-http/package.json +67 -0
  150. package/packages/shared-http/src/__tests__/log-correlation.test.ts +81 -0
  151. package/packages/shared-http/src/__tests__/operation-metrics-tracker.test.ts +55 -0
  152. package/packages/shared-http/src/http-observability-collector.ts +363 -0
  153. package/packages/shared-http/src/index.ts +36 -0
  154. package/packages/shared-http/src/log-correlation.ts +89 -0
  155. package/packages/shared-http/src/operation-metrics-tracker.ts +107 -0
  156. package/packages/shared-http/src/register-openapi.ts +108 -0
  157. package/packages/shared-http/src/resolve-schema-ref.ts +75 -0
  158. package/packages/shared-http/src/schemas.ts +29 -0
  159. package/packages/shared-http/src/service-observability.ts +63 -0
  160. package/packages/shared-http/tsconfig.build.json +15 -0
  161. package/packages/shared-http/tsconfig.json +9 -0
  162. package/packages/shared-http/tsup.config.ts +23 -0
  163. package/packages/shared-http/vitest.config.ts +13 -0
  164. package/packages/shared-perm-presets/CHANGELOG.md +20 -0
  165. package/packages/shared-perm-presets/README.md +78 -0
  166. package/packages/shared-perm-presets/eslint.config.js +27 -0
  167. package/packages/shared-perm-presets/package.json +45 -0
  168. package/packages/shared-perm-presets/src/__tests__/combine.test.ts +403 -0
  169. package/packages/shared-perm-presets/src/__tests__/presets.test.ts +205 -0
  170. package/packages/shared-perm-presets/src/combine.ts +278 -0
  171. package/packages/shared-perm-presets/src/index.ts +18 -0
  172. package/packages/shared-perm-presets/src/presets/ci-environment.ts +34 -0
  173. package/packages/shared-perm-presets/src/presets/full-env.ts +16 -0
  174. package/packages/shared-perm-presets/src/presets/git-workflow.ts +40 -0
  175. package/packages/shared-perm-presets/src/presets/index.ts +8 -0
  176. package/packages/shared-perm-presets/src/presets/kb-platform.ts +30 -0
  177. package/packages/shared-perm-presets/src/presets/llm-access.ts +29 -0
  178. package/packages/shared-perm-presets/src/presets/minimal.ts +21 -0
  179. package/packages/shared-perm-presets/src/presets/npm-publish.ts +48 -0
  180. package/packages/shared-perm-presets/src/presets/vector-store.ts +40 -0
  181. package/packages/shared-perm-presets/src/types.ts +192 -0
  182. package/packages/shared-perm-presets/tsconfig.build.json +15 -0
  183. package/packages/shared-perm-presets/tsconfig.json +9 -0
  184. package/packages/shared-perm-presets/tsup.config.ts +8 -0
  185. package/packages/shared-perm-presets/vitest.config.ts +9 -0
  186. package/packages/shared-testing/CHANGELOG.md +20 -0
  187. package/packages/shared-testing/README.md +430 -0
  188. package/packages/shared-testing/package.json +51 -0
  189. package/packages/shared-testing/src/__tests__/create-test-context.test.ts +199 -0
  190. package/packages/shared-testing/src/__tests__/mock-cache.test.ts +174 -0
  191. package/packages/shared-testing/src/__tests__/mock-llm.test.ts +212 -0
  192. package/packages/shared-testing/src/__tests__/setup-platform.test.ts +90 -0
  193. package/packages/shared-testing/src/__tests__/test-command.test.ts +557 -0
  194. package/packages/shared-testing/src/create-test-context.ts +550 -0
  195. package/packages/shared-testing/src/index.ts +77 -0
  196. package/packages/shared-testing/src/mock-cache.ts +179 -0
  197. package/packages/shared-testing/src/mock-llm.ts +319 -0
  198. package/packages/shared-testing/src/mock-logger.ts +97 -0
  199. package/packages/shared-testing/src/mock-storage.ts +108 -0
  200. package/packages/shared-testing/src/setup-platform.ts +101 -0
  201. package/packages/shared-testing/src/test-command.ts +288 -0
  202. package/packages/shared-testing/tsconfig.build.json +15 -0
  203. package/packages/shared-testing/tsconfig.json +9 -0
  204. package/packages/shared-testing/tsup.config.ts +20 -0
  205. package/packages/shared-testing/vitest.config.ts +3 -0
  206. package/packages/shared-tool-kit/CHANGELOG.md +20 -0
  207. package/packages/shared-tool-kit/package.json +47 -0
  208. package/packages/shared-tool-kit/src/__tests__/factory.test.ts +103 -0
  209. package/packages/shared-tool-kit/src/__tests__/mock-tool.test.ts +95 -0
  210. package/packages/shared-tool-kit/src/factory.ts +126 -0
  211. package/packages/shared-tool-kit/src/index.ts +32 -0
  212. package/packages/shared-tool-kit/src/testing/index.ts +84 -0
  213. package/packages/shared-tool-kit/tsconfig.build.json +15 -0
  214. package/packages/shared-tool-kit/tsconfig.json +9 -0
  215. package/packages/shared-tool-kit/tsup.config.ts +21 -0
  216. package/pnpm-workspace.yaml +11070 -0
  217. package/prettierrc.json +1 -0
  218. package/scripts/devkit-sync.mjs +37 -0
  219. package/scripts/hooks/post-push +9 -0
  220. package/scripts/hooks/pre-commit +9 -0
  221. package/scripts/hooks/pre-push +9 -0
  222. package/tsconfig.base.json +9 -0
  223. package/tsconfig.build.json +15 -0
  224. package/tsconfig.json +9 -0
  225. package/tsconfig.paths.json +50 -0
  226. package/tsconfig.tools.json +18 -0
  227. package/tsup.config.bin.ts +34 -0
  228. package/tsup.config.cli.ts +41 -0
  229. package/tsup.config.dual.ts +46 -0
  230. package/tsup.config.ts +36 -0
  231. package/tsup.external.json +104 -0
  232. package/vitest.config.ts +48 -0
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { platform } from '@kb-labs/core-runtime';
3
+ import { setupTestPlatform } from '../setup-platform.js';
4
+ import { mockLLM } from '../mock-llm.js';
5
+ import { mockCache } from '../mock-cache.js';
6
+ import { mockLogger } from '../mock-logger.js';
7
+
8
+ describe('setupTestPlatform', () => {
9
+ let cleanup: (() => void) | undefined;
10
+
11
+ afterEach(() => {
12
+ cleanup?.();
13
+ cleanup = undefined;
14
+ });
15
+
16
+ it('should set LLM adapter in the global singleton', () => {
17
+ const llm = mockLLM();
18
+ const result = setupTestPlatform({ llm });
19
+ cleanup = result.cleanup;
20
+
21
+ expect(result.platform).toBe(platform);
22
+ expect(platform.llm).toBe(llm);
23
+ });
24
+
25
+ it('should set cache adapter in the global singleton', () => {
26
+ const cache = mockCache();
27
+ const result = setupTestPlatform({ cache });
28
+ cleanup = result.cleanup;
29
+
30
+ expect(platform.cache).toBe(cache);
31
+ });
32
+
33
+ it('should set logger adapter in the global singleton', () => {
34
+ const logger = mockLogger();
35
+ const result = setupTestPlatform({ logger });
36
+ cleanup = result.cleanup;
37
+
38
+ expect(platform.logger).toBe(logger);
39
+ });
40
+
41
+ it('should set multiple adapters at once', () => {
42
+ const llm = mockLLM();
43
+ const cache = mockCache();
44
+ const logger = mockLogger();
45
+
46
+ const result = setupTestPlatform({ llm, cache, logger });
47
+ cleanup = result.cleanup;
48
+
49
+ expect(platform.llm).toBe(llm);
50
+ expect(platform.cache).toBe(cache);
51
+ expect(platform.logger).toBe(logger);
52
+ });
53
+
54
+ it('should not set adapters that are not provided', () => {
55
+ const result = setupTestPlatform({ llm: mockLLM() });
56
+ cleanup = result.cleanup;
57
+
58
+ // cache should be the fallback from PlatformContainer, not the mock
59
+ expect(platform.hasAdapter('cache')).toBe(false);
60
+ });
61
+
62
+ it('cleanup should reset the platform', () => {
63
+ const llm = mockLLM();
64
+ const result = setupTestPlatform({ llm });
65
+
66
+ expect(platform.hasAdapter('llm')).toBe(true);
67
+
68
+ result.cleanup();
69
+ cleanup = undefined;
70
+
71
+ expect(platform.hasAdapter('llm')).toBe(false);
72
+ });
73
+
74
+ it('should reset previous state before setting new adapters', () => {
75
+ // First setup — sets llm
76
+ const llm1 = mockLLM();
77
+ setupTestPlatform({ llm: llm1 });
78
+ expect(platform.hasAdapter('llm')).toBe(true);
79
+
80
+ // Second setup — should clear llm1, only set cache
81
+ const cache = mockCache();
82
+ const r2 = setupTestPlatform({ cache });
83
+ cleanup = r2.cleanup;
84
+
85
+ // llm was cleared by the second setupTestPlatform() reset
86
+ expect(platform.hasAdapter('llm')).toBe(false);
87
+ // cache was set
88
+ expect(platform.cache).toBe(cache);
89
+ });
90
+ });
@@ -0,0 +1,557 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { resetPlatform } from '@kb-labs/core-runtime';
3
+ import { testCommand } from '../test-command.js';
4
+ import { mockLLM } from '../mock-llm.js';
5
+ import { mockCache } from '../mock-cache.js';
6
+ import type { PluginContextV3, CommandResult } from '@kb-labs/plugin-contracts';
7
+
8
+ // ────────────────────────────────────────────────────────────────────
9
+ // Test handlers (simulating what plugin authors write)
10
+ // ────────────────────────────────────────────────────────────────────
11
+
12
+ /** Simple CLI command handler (like defineCommand output) */
13
+ const greetHandler = {
14
+ async execute(
15
+ ctx: PluginContextV3,
16
+ input: { flags: { name?: string }; argv: string[] }
17
+ ): Promise<CommandResult<{ message: string }>> {
18
+ const name = input.flags.name || 'World';
19
+ ctx.ui.success(`Hello, ${name}!`);
20
+ return { exitCode: 0, result: { message: `Hello, ${name}!` } };
21
+ },
22
+ };
23
+
24
+ /** Handler that returns non-zero exit code */
25
+ const failingHandler = {
26
+ async execute(
27
+ ctx: PluginContextV3,
28
+ _input: { flags: Record<string, unknown>; argv: string[] }
29
+ ): Promise<CommandResult> {
30
+ ctx.ui.error('Something went wrong');
31
+ return { exitCode: 1, meta: { reason: 'test-failure' } };
32
+ },
33
+ };
34
+
35
+ /** Handler that uses LLM */
36
+ const analyzeHandler = {
37
+ async execute(
38
+ ctx: PluginContextV3,
39
+ input: { flags: { file?: string }; argv: string[] }
40
+ ): Promise<CommandResult<{ analysis: string }>> {
41
+ const file = input.flags.file || 'unknown';
42
+ const response = await ctx.platform.llm.complete(`Analyze ${file}`);
43
+ const analysis = response.content;
44
+ ctx.ui.info(`Analysis for ${file}: ${analysis}`);
45
+ return { exitCode: 0, result: { analysis } };
46
+ },
47
+ };
48
+
49
+ /** Handler that uses cache */
50
+ const cachedHandler = {
51
+ async execute(
52
+ ctx: PluginContextV3,
53
+ input: { flags: { key?: string }; argv: string[] }
54
+ ): Promise<CommandResult<{ cached: boolean; value: unknown }>> {
55
+ const key = input.flags.key || 'default';
56
+ const cached = await ctx.platform.cache.get(key);
57
+ if (cached) {
58
+ return { exitCode: 0, result: { cached: true, value: cached } };
59
+ }
60
+ await ctx.platform.cache.set(key, 'computed-value', 60000);
61
+ return { exitCode: 0, result: { cached: false, value: 'computed-value' } };
62
+ },
63
+ };
64
+
65
+ /** REST handler (like defineHandler output) — returns raw data */
66
+ const restHandler = {
67
+ async execute(
68
+ ctx: PluginContextV3,
69
+ input: { query?: { workspace?: string }; body?: { name: string } }
70
+ ): Promise<{ workspace: string; name: string }> {
71
+ const workspace = input.query?.workspace || 'root';
72
+ const name = input.body?.name || 'unnamed';
73
+ ctx.ui.info(`Creating in ${workspace}: ${name}`);
74
+ return { workspace, name };
75
+ },
76
+ };
77
+
78
+ /** REST route handler (like defineRoute output) — returns CommandResult */
79
+ const routeHandler = {
80
+ async execute(
81
+ ctx: PluginContextV3,
82
+ input: { query?: { id?: string } }
83
+ ): Promise<CommandResult<{ found: boolean }>> {
84
+ const id = input.query?.id;
85
+ if (!id) {
86
+ return { exitCode: 1, result: { found: false } };
87
+ }
88
+ return { exitCode: 0, result: { found: true } };
89
+ },
90
+ };
91
+
92
+ /** Handler that returns void */
93
+ const voidHandler = {
94
+ async execute(
95
+ ctx: PluginContextV3,
96
+ _input: { flags: Record<string, unknown>; argv: string[] }
97
+ ): Promise<void> {
98
+ ctx.ui.success('Done!');
99
+ },
100
+ };
101
+
102
+ /** Handler with cleanup */
103
+ let cleanupCalled = false;
104
+ const handlerWithCleanup = {
105
+ async execute(
106
+ _ctx: PluginContextV3,
107
+ _input: { flags: Record<string, unknown>; argv: string[] }
108
+ ): Promise<CommandResult> {
109
+ return { exitCode: 0 };
110
+ },
111
+ async cleanup() {
112
+ cleanupCalled = true;
113
+ },
114
+ };
115
+
116
+ /** Handler that reads config */
117
+ const configHandler = {
118
+ async execute(
119
+ ctx: PluginContextV3<{ apiKey: string; verbose: boolean }>,
120
+ _input: { flags: Record<string, unknown>; argv: string[] }
121
+ ): Promise<CommandResult<{ key: string }>> {
122
+ return { exitCode: 0, result: { key: ctx.config!.apiKey } };
123
+ },
124
+ };
125
+
126
+ /** Handler that throws */
127
+ const throwingHandler = {
128
+ async execute(): Promise<CommandResult> {
129
+ throw new Error('Handler crashed');
130
+ },
131
+ };
132
+
133
+ /** Handler that uses meta */
134
+ const metaHandler = {
135
+ async execute(
136
+ _ctx: PluginContextV3,
137
+ _input: { flags: Record<string, unknown>; argv: string[] }
138
+ ): Promise<CommandResult<string>> {
139
+ return {
140
+ exitCode: 0,
141
+ result: 'ok',
142
+ meta: { tokens: 150, model: 'gpt-4' },
143
+ };
144
+ },
145
+ };
146
+
147
+ // ────────────────────────────────────────────────────────────────────
148
+ // Tests
149
+ // ────────────────────────────────────────────────────────────────────
150
+
151
+ describe('testCommand', () => {
152
+ afterEach(() => {
153
+ resetPlatform();
154
+ cleanupCalled = false;
155
+ });
156
+
157
+ // ── Basic CLI ────────────────────────────────────────────────────
158
+
159
+ describe('CLI commands', () => {
160
+ it('should execute a simple handler with flags', async () => {
161
+ const result = await testCommand(greetHandler, {
162
+ flags: { name: 'Alice' },
163
+ });
164
+ result.cleanup();
165
+
166
+ expect(result.exitCode).toBe(0);
167
+ expect(result.result).toEqual({ message: 'Hello, Alice!' });
168
+ expect(result.ui.success).toHaveBeenCalledWith('Hello, Alice!');
169
+ });
170
+
171
+ it('should use default flags when none provided', async () => {
172
+ const result = await testCommand(greetHandler);
173
+ result.cleanup();
174
+
175
+ expect(result.exitCode).toBe(0);
176
+ expect(result.result).toEqual({ message: 'Hello, World!' });
177
+ });
178
+
179
+ it('should pass argv to handler', async () => {
180
+ const argvCapture: string[] = [];
181
+ const handler = {
182
+ async execute(
183
+ _ctx: PluginContextV3,
184
+ input: { flags: Record<string, unknown>; argv: string[] }
185
+ ): Promise<CommandResult<string[]>> {
186
+ argvCapture.push(...input.argv);
187
+ return { exitCode: 0, result: input.argv };
188
+ },
189
+ };
190
+
191
+ const result = await testCommand(handler, {
192
+ argv: ['file1.ts', 'file2.ts'],
193
+ });
194
+ result.cleanup();
195
+
196
+ expect(result.result).toEqual(['file1.ts', 'file2.ts']);
197
+ });
198
+
199
+ it('should handle non-zero exit code', async () => {
200
+ const result = await testCommand(failingHandler);
201
+ result.cleanup();
202
+
203
+ expect(result.exitCode).toBe(1);
204
+ expect(result.ui.error).toHaveBeenCalledWith('Something went wrong');
205
+ expect(result.meta).toEqual({ reason: 'test-failure' });
206
+ });
207
+
208
+ it('should handle void return', async () => {
209
+ const result = await testCommand(voidHandler);
210
+ result.cleanup();
211
+
212
+ expect(result.exitCode).toBe(0);
213
+ expect(result.result).toBeUndefined();
214
+ expect(result.ui.success).toHaveBeenCalledWith('Done!');
215
+ });
216
+ });
217
+
218
+ // ── Platform mocks ──────────────────────────────────────────────
219
+
220
+ describe('platform mocks', () => {
221
+ it('should inject LLM mock into context', async () => {
222
+ const llm = mockLLM().onAnyComplete().respondWith('looks great');
223
+
224
+ const result = await testCommand(analyzeHandler, {
225
+ flags: { file: 'app.ts' },
226
+ platform: { llm },
227
+ });
228
+ result.cleanup();
229
+
230
+ expect(result.exitCode).toBe(0);
231
+ expect(result.result).toEqual({ analysis: 'looks great' });
232
+ expect(llm.complete).toHaveBeenCalled();
233
+ expect(result.ui.info).toHaveBeenCalledWith('Analysis for app.ts: looks great');
234
+ });
235
+
236
+ it('should inject cache mock into context', async () => {
237
+ const cache = mockCache();
238
+ await cache.set('my-key', 'pre-cached', 60000);
239
+
240
+ const result = await testCommand(cachedHandler, {
241
+ flags: { key: 'my-key' },
242
+ platform: { cache },
243
+ });
244
+ result.cleanup();
245
+
246
+ expect(result.exitCode).toBe(0);
247
+ expect(result.result).toEqual({ cached: true, value: 'pre-cached' });
248
+ });
249
+
250
+ it('should use default mocks when no platform provided', async () => {
251
+ // analyzeHandler uses llm.complete — default mock returns 'mock response'
252
+ const result = await testCommand(analyzeHandler, {
253
+ flags: { file: 'test.ts' },
254
+ });
255
+ result.cleanup();
256
+
257
+ expect(result.exitCode).toBe(0);
258
+ // Default mockLLM returns 'mock response' for complete
259
+ expect(result.result).toEqual({ analysis: 'mock response' });
260
+ });
261
+ });
262
+
263
+ // ── REST handlers ──────────────────────────────────────────────
264
+
265
+ describe('REST handlers', () => {
266
+ it('should pass query and body for REST host', async () => {
267
+ const result = await testCommand(restHandler, {
268
+ host: 'rest',
269
+ query: { workspace: 'my-project' },
270
+ body: { name: 'release-v2' },
271
+ });
272
+ result.cleanup();
273
+
274
+ expect(result.exitCode).toBe(0);
275
+ // restHandler returns raw data (not CommandResult), so it's treated as result
276
+ expect(result.result).toEqual({ workspace: 'my-project', name: 'release-v2' });
277
+ expect(result.ui.info).toHaveBeenCalledWith('Creating in my-project: release-v2');
278
+ });
279
+
280
+ it('should handle REST route handler with CommandResult', async () => {
281
+ const result = await testCommand(routeHandler, {
282
+ host: 'rest',
283
+ query: { id: '123' },
284
+ });
285
+ result.cleanup();
286
+
287
+ expect(result.exitCode).toBe(0);
288
+ expect(result.result).toEqual({ found: true });
289
+ });
290
+
291
+ it('should handle REST route handler failure', async () => {
292
+ const result = await testCommand(routeHandler, {
293
+ host: 'rest',
294
+ // No id — should fail
295
+ });
296
+ result.cleanup();
297
+
298
+ expect(result.exitCode).toBe(1);
299
+ expect(result.result).toEqual({ found: false });
300
+ });
301
+
302
+ it('should pass params for REST', async () => {
303
+ const paramHandler = {
304
+ async execute(
305
+ _ctx: PluginContextV3,
306
+ input: { params?: { id?: string } }
307
+ ): Promise<CommandResult<string>> {
308
+ return { exitCode: 0, result: input.params?.id || 'none' };
309
+ },
310
+ };
311
+
312
+ const result = await testCommand(paramHandler, {
313
+ host: 'rest',
314
+ params: { id: 'abc-123' },
315
+ });
316
+ result.cleanup();
317
+
318
+ expect(result.result).toBe('abc-123');
319
+ });
320
+ });
321
+
322
+ // ── Raw input ──────────────────────────────────────────────────
323
+
324
+ describe('raw input', () => {
325
+ it('should pass raw input directly when provided', async () => {
326
+ const customHandler = {
327
+ async execute(
328
+ _ctx: PluginContextV3,
329
+ input: { custom: string; data: number[] }
330
+ ): Promise<CommandResult<string>> {
331
+ return { exitCode: 0, result: `${input.custom}:${input.data.length}` };
332
+ },
333
+ };
334
+
335
+ const result = await testCommand(customHandler, {
336
+ input: { custom: 'hello', data: [1, 2, 3] },
337
+ });
338
+ result.cleanup();
339
+
340
+ expect(result.result).toBe('hello:3');
341
+ });
342
+
343
+ it('raw input should override flags/argv', async () => {
344
+ const handler = {
345
+ async execute(
346
+ _ctx: PluginContextV3,
347
+ input: unknown
348
+ ): Promise<CommandResult<unknown>> {
349
+ return { exitCode: 0, result: input };
350
+ },
351
+ };
352
+
353
+ const result = await testCommand(handler, {
354
+ flags: { shouldBeIgnored: true },
355
+ argv: ['ignored'],
356
+ input: { override: true },
357
+ });
358
+ result.cleanup();
359
+
360
+ expect(result.result).toEqual({ override: true });
361
+ });
362
+ });
363
+
364
+ // ── Config ─────────────────────────────────────────────────────
365
+
366
+ describe('config', () => {
367
+ it('should pass config to context', async () => {
368
+ const result = await testCommand(configHandler, {
369
+ config: { apiKey: 'sk-test-123', verbose: true },
370
+ });
371
+ result.cleanup();
372
+
373
+ expect(result.exitCode).toBe(0);
374
+ expect(result.result).toEqual({ key: 'sk-test-123' });
375
+ });
376
+ });
377
+
378
+ // ── Metadata ───────────────────────────────────────────────────
379
+
380
+ describe('meta', () => {
381
+ it('should return meta from CommandResult', async () => {
382
+ const result = await testCommand(metaHandler);
383
+ result.cleanup();
384
+
385
+ expect(result.meta).toEqual({ tokens: 150, model: 'gpt-4' });
386
+ });
387
+
388
+ it('should return undefined meta for raw-data handlers', async () => {
389
+ const result = await testCommand(restHandler, {
390
+ host: 'rest',
391
+ body: { name: 'test' },
392
+ });
393
+ result.cleanup();
394
+
395
+ expect(result.meta).toBeUndefined();
396
+ });
397
+ });
398
+
399
+ // ── Cleanup & lifecycle ────────────────────────────────────────
400
+
401
+ describe('cleanup & lifecycle', () => {
402
+ it('should call handler.cleanup() after execute', async () => {
403
+ expect(cleanupCalled).toBe(false);
404
+
405
+ const result = await testCommand(handlerWithCleanup);
406
+ result.cleanup();
407
+
408
+ expect(cleanupCalled).toBe(true);
409
+ });
410
+
411
+ it('should call handler.cleanup() even if execute throws', async () => {
412
+ let cleaned = false;
413
+ const handler = {
414
+ async execute(): Promise<CommandResult> {
415
+ throw new Error('boom');
416
+ },
417
+ async cleanup() {
418
+ cleaned = true;
419
+ },
420
+ };
421
+
422
+ await expect(testCommand(handler)).rejects.toThrow('boom');
423
+ expect(cleaned).toBe(true);
424
+ });
425
+
426
+ it('should propagate handler errors', async () => {
427
+ await expect(testCommand(throwingHandler)).rejects.toThrow('Handler crashed');
428
+ });
429
+ });
430
+
431
+ // ── Context properties ─────────────────────────────────────────
432
+
433
+ describe('context properties', () => {
434
+ it('should set host on context', async () => {
435
+ const capturedHost: string[] = [];
436
+ const handler = {
437
+ async execute(ctx: PluginContextV3): Promise<CommandResult> {
438
+ capturedHost.push(ctx.host);
439
+ return { exitCode: 0 };
440
+ },
441
+ };
442
+
443
+ const r1 = await testCommand(handler, { host: 'cli' });
444
+ const r2 = await testCommand(handler, { host: 'rest' });
445
+ const r3 = await testCommand(handler, { host: 'workflow' });
446
+ r1.cleanup();
447
+ r2.cleanup();
448
+ r3.cleanup();
449
+
450
+ expect(capturedHost).toEqual(['cli', 'rest', 'workflow']);
451
+ });
452
+
453
+ it('should expose ctx in result for advanced assertions', async () => {
454
+ const result = await testCommand(greetHandler, {
455
+ flags: { name: 'Bob' },
456
+ });
457
+ result.cleanup();
458
+
459
+ expect(result.ctx.host).toBe('cli');
460
+ expect(result.ctx.pluginId).toBe('test-plugin');
461
+ expect(result.ctx.ui).toBe(result.ui);
462
+ });
463
+
464
+ it('should pass tenantId to context', async () => {
465
+ const handler = {
466
+ async execute(ctx: PluginContextV3): Promise<CommandResult<string | undefined>> {
467
+ return { exitCode: 0, result: ctx.tenantId };
468
+ },
469
+ };
470
+
471
+ const result = await testCommand(handler, { tenantId: 'acme-corp' });
472
+ result.cleanup();
473
+
474
+ expect(result.result).toBe('acme-corp');
475
+ });
476
+
477
+ it('should pass cwd to context', async () => {
478
+ const handler = {
479
+ async execute(ctx: PluginContextV3): Promise<CommandResult<string>> {
480
+ return { exitCode: 0, result: ctx.cwd };
481
+ },
482
+ };
483
+
484
+ const result = await testCommand(handler, { cwd: '/tmp/test-project' });
485
+ result.cleanup();
486
+
487
+ expect(result.result).toBe('/tmp/test-project');
488
+ });
489
+ });
490
+
491
+ // ── UI spies ───────────────────────────────────────────────────
492
+
493
+ describe('UI spies', () => {
494
+ it('should track all UI method calls', async () => {
495
+ const handler = {
496
+ async execute(ctx: PluginContextV3): Promise<CommandResult> {
497
+ ctx.ui.info('step 1');
498
+ ctx.ui.warn('caution');
499
+ ctx.ui.success('done');
500
+ ctx.ui.debug('internal');
501
+ return { exitCode: 0 };
502
+ },
503
+ };
504
+
505
+ const result = await testCommand(handler);
506
+ result.cleanup();
507
+
508
+ expect(result.ui.info).toHaveBeenCalledWith('step 1');
509
+ expect(result.ui.warn).toHaveBeenCalledWith('caution');
510
+ expect(result.ui.success).toHaveBeenCalledWith('done');
511
+ expect(result.ui.debug).toHaveBeenCalledWith('internal');
512
+ });
513
+
514
+ it('should support UI override', async () => {
515
+ const customError = vi.fn();
516
+
517
+ const result = await testCommand(failingHandler, {
518
+ ui: { error: customError },
519
+ });
520
+ result.cleanup();
521
+
522
+ expect(customError).toHaveBeenCalledWith('Something went wrong');
523
+ });
524
+ });
525
+
526
+ // ── raw field ──────────────────────────────────────────────────
527
+
528
+ describe('raw return value', () => {
529
+ it('should expose raw return for CommandResult handlers', async () => {
530
+ const result = await testCommand(metaHandler);
531
+ result.cleanup();
532
+
533
+ expect(result.raw).toEqual({
534
+ exitCode: 0,
535
+ result: 'ok',
536
+ meta: { tokens: 150, model: 'gpt-4' },
537
+ });
538
+ });
539
+
540
+ it('should expose raw return for data-returning handlers', async () => {
541
+ const result = await testCommand(restHandler, {
542
+ host: 'rest',
543
+ body: { name: 'test' },
544
+ });
545
+ result.cleanup();
546
+
547
+ expect(result.raw).toEqual({ workspace: 'root', name: 'test' });
548
+ });
549
+
550
+ it('should expose null raw for void handlers', async () => {
551
+ const result = await testCommand(voidHandler);
552
+ result.cleanup();
553
+
554
+ expect(result.raw).toBeUndefined();
555
+ });
556
+ });
557
+ });