@tyvm/knowhow 0.0.108 → 0.0.109-dev.e88af1e

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 (214) hide show
  1. package/README.md +45 -0
  2. package/package.json +9 -4
  3. package/scripts/build-for-node.sh +10 -24
  4. package/scripts/publish.sh +86 -0
  5. package/src/agents/base/base.ts +10 -0
  6. package/src/agents/tools/execCommand.ts +49 -6
  7. package/src/agents/tools/index.ts +0 -1
  8. package/src/agents/tools/list.ts +0 -2
  9. package/src/chat/CliChatService.ts +10 -1
  10. package/src/chat/modules/AgentModule.ts +61 -31
  11. package/src/chat/modules/SessionsModule.ts +47 -3
  12. package/src/chat/renderer/CompactRenderer.ts +20 -0
  13. package/src/chat/renderer/ConsoleRenderer.ts +19 -0
  14. package/src/chat/renderer/FancyRenderer.ts +19 -0
  15. package/src/chat/renderer/types.ts +11 -0
  16. package/src/cli.ts +91 -659
  17. package/src/clients/anthropic.ts +17 -16
  18. package/src/clients/index.ts +6 -5
  19. package/src/clients/types.ts +19 -4
  20. package/src/cloudWorker.ts +175 -113
  21. package/src/commands/agent.ts +246 -0
  22. package/src/commands/misc.ts +174 -0
  23. package/src/commands/modules.ts +217 -0
  24. package/src/commands/services.ts +77 -0
  25. package/src/commands/workers.ts +168 -0
  26. package/src/config.ts +37 -0
  27. package/src/fileSync.ts +70 -29
  28. package/src/hashes.ts +35 -13
  29. package/src/index.ts +18 -0
  30. package/src/logger.ts +197 -0
  31. package/src/plugins/embedding.ts +11 -6
  32. package/src/plugins/plugins.ts +0 -21
  33. package/src/plugins/vim.ts +5 -16
  34. package/src/processors/JsonCompressor.ts +6 -6
  35. package/src/services/EventService.ts +61 -1
  36. package/src/services/KnowhowClient.ts +34 -4
  37. package/src/services/modules/index.ts +95 -51
  38. package/src/services/modules/types.ts +6 -0
  39. package/src/tunnel.ts +216 -0
  40. package/src/types.ts +0 -1
  41. package/src/worker.ts +105 -312
  42. package/src/workers/auth/WsMiddleware.ts +99 -0
  43. package/src/workers/auth/authMiddleware.ts +104 -0
  44. package/src/workers/auth/types.ts +14 -2
  45. package/src/workers/tools/index.ts +2 -0
  46. package/src/workers/tools/reloadConfig.ts +84 -0
  47. package/tests/services/WorkerReloadConfig.test.ts +141 -0
  48. package/tests/unit/commands/github-credentials.test.ts +211 -0
  49. package/tests/unit/modules/moduleLoading.test.ts +39 -37
  50. package/tests/unit/plugins/pluginLoading.test.ts +0 -85
  51. package/ts_build/package.json +9 -4
  52. package/ts_build/src/agents/base/base.js +11 -0
  53. package/ts_build/src/agents/base/base.js.map +1 -1
  54. package/ts_build/src/agents/tools/execCommand.d.ts +1 -1
  55. package/ts_build/src/agents/tools/execCommand.js +39 -5
  56. package/ts_build/src/agents/tools/execCommand.js.map +1 -1
  57. package/ts_build/src/agents/tools/index.d.ts +0 -1
  58. package/ts_build/src/agents/tools/index.js +0 -1
  59. package/ts_build/src/agents/tools/index.js.map +1 -1
  60. package/ts_build/src/agents/tools/list.js +0 -2
  61. package/ts_build/src/agents/tools/list.js.map +1 -1
  62. package/ts_build/src/chat/CliChatService.js +13 -1
  63. package/ts_build/src/chat/CliChatService.js.map +1 -1
  64. package/ts_build/src/chat/modules/AgentModule.d.ts +1 -1
  65. package/ts_build/src/chat/modules/AgentModule.js +43 -20
  66. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  67. package/ts_build/src/chat/modules/SessionsModule.js +37 -3
  68. package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
  69. package/ts_build/src/chat/renderer/CompactRenderer.d.ts +4 -0
  70. package/ts_build/src/chat/renderer/CompactRenderer.js +16 -0
  71. package/ts_build/src/chat/renderer/CompactRenderer.js.map +1 -1
  72. package/ts_build/src/chat/renderer/ConsoleRenderer.d.ts +4 -0
  73. package/ts_build/src/chat/renderer/ConsoleRenderer.js +16 -0
  74. package/ts_build/src/chat/renderer/ConsoleRenderer.js.map +1 -1
  75. package/ts_build/src/chat/renderer/FancyRenderer.d.ts +4 -0
  76. package/ts_build/src/chat/renderer/FancyRenderer.js +16 -0
  77. package/ts_build/src/chat/renderer/FancyRenderer.js.map +1 -1
  78. package/ts_build/src/chat/renderer/types.d.ts +2 -0
  79. package/ts_build/src/cli.js +47 -519
  80. package/ts_build/src/cli.js.map +1 -1
  81. package/ts_build/src/clients/anthropic.d.ts +5 -5
  82. package/ts_build/src/clients/anthropic.js +17 -16
  83. package/ts_build/src/clients/anthropic.js.map +1 -1
  84. package/ts_build/src/clients/index.js +2 -4
  85. package/ts_build/src/clients/index.js.map +1 -1
  86. package/ts_build/src/clients/types.d.ts +3 -2
  87. package/ts_build/src/cloudWorker.d.ts +14 -0
  88. package/ts_build/src/cloudWorker.js +105 -66
  89. package/ts_build/src/cloudWorker.js.map +1 -1
  90. package/ts_build/src/commands/agent.d.ts +6 -0
  91. package/ts_build/src/commands/agent.js +229 -0
  92. package/ts_build/src/commands/agent.js.map +1 -0
  93. package/ts_build/src/commands/misc.d.ts +10 -0
  94. package/ts_build/src/commands/misc.js +197 -0
  95. package/ts_build/src/commands/misc.js.map +1 -0
  96. package/ts_build/src/commands/modules.d.ts +3 -0
  97. package/ts_build/src/commands/modules.js +207 -0
  98. package/ts_build/src/commands/modules.js.map +1 -0
  99. package/ts_build/src/commands/services.d.ts +5 -0
  100. package/ts_build/src/commands/services.js +87 -0
  101. package/ts_build/src/commands/services.js.map +1 -0
  102. package/ts_build/src/commands/workers.d.ts +6 -0
  103. package/ts_build/src/commands/workers.js +168 -0
  104. package/ts_build/src/commands/workers.js.map +1 -0
  105. package/ts_build/src/config.d.ts +1 -0
  106. package/ts_build/src/config.js +32 -0
  107. package/ts_build/src/config.js.map +1 -1
  108. package/ts_build/src/fileSync.d.ts +6 -0
  109. package/ts_build/src/fileSync.js +50 -23
  110. package/ts_build/src/fileSync.js.map +1 -1
  111. package/ts_build/src/hashes.d.ts +2 -2
  112. package/ts_build/src/hashes.js +35 -9
  113. package/ts_build/src/hashes.js.map +1 -1
  114. package/ts_build/src/index.d.ts +1 -0
  115. package/ts_build/src/index.js +17 -1
  116. package/ts_build/src/index.js.map +1 -1
  117. package/ts_build/src/logger.d.ts +21 -0
  118. package/ts_build/src/logger.js +106 -0
  119. package/ts_build/src/logger.js.map +1 -0
  120. package/ts_build/src/plugins/embedding.js +4 -3
  121. package/ts_build/src/plugins/embedding.js.map +1 -1
  122. package/ts_build/src/plugins/plugins.d.ts +0 -2
  123. package/ts_build/src/plugins/plugins.js +0 -11
  124. package/ts_build/src/plugins/plugins.js.map +1 -1
  125. package/ts_build/src/plugins/vim.js +3 -9
  126. package/ts_build/src/plugins/vim.js.map +1 -1
  127. package/ts_build/src/processors/JsonCompressor.js +4 -4
  128. package/ts_build/src/processors/JsonCompressor.js.map +1 -1
  129. package/ts_build/src/services/EventService.d.ts +6 -1
  130. package/ts_build/src/services/EventService.js +29 -0
  131. package/ts_build/src/services/EventService.js.map +1 -1
  132. package/ts_build/src/services/KnowhowClient.d.ts +13 -1
  133. package/ts_build/src/services/KnowhowClient.js +19 -2
  134. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  135. package/ts_build/src/services/modules/index.d.ts +33 -0
  136. package/ts_build/src/services/modules/index.js +67 -47
  137. package/ts_build/src/services/modules/index.js.map +1 -1
  138. package/ts_build/src/services/modules/types.d.ts +6 -0
  139. package/ts_build/src/tunnel.d.ts +27 -0
  140. package/ts_build/src/tunnel.js +112 -0
  141. package/ts_build/src/tunnel.js.map +1 -0
  142. package/ts_build/src/types.d.ts +0 -1
  143. package/ts_build/src/types.js.map +1 -1
  144. package/ts_build/src/worker.d.ts +1 -4
  145. package/ts_build/src/worker.js +59 -227
  146. package/ts_build/src/worker.js.map +1 -1
  147. package/ts_build/src/workers/auth/WsMiddleware.d.ts +8 -0
  148. package/ts_build/src/workers/auth/WsMiddleware.js +65 -0
  149. package/ts_build/src/workers/auth/WsMiddleware.js.map +1 -0
  150. package/ts_build/src/workers/auth/authMiddleware.d.ts +3 -0
  151. package/ts_build/src/workers/auth/authMiddleware.js +60 -0
  152. package/ts_build/src/workers/auth/authMiddleware.js.map +1 -0
  153. package/ts_build/src/workers/auth/types.d.ts +8 -1
  154. package/ts_build/src/workers/tools/index.d.ts +2 -0
  155. package/ts_build/src/workers/tools/index.js +4 -1
  156. package/ts_build/src/workers/tools/index.js.map +1 -1
  157. package/ts_build/src/workers/tools/reloadConfig.d.ts +14 -0
  158. package/ts_build/src/workers/tools/reloadConfig.js +48 -0
  159. package/ts_build/src/workers/tools/reloadConfig.js.map +1 -0
  160. package/ts_build/tests/services/WorkerReloadConfig.test.d.ts +1 -0
  161. package/ts_build/tests/services/WorkerReloadConfig.test.js +86 -0
  162. package/ts_build/tests/services/WorkerReloadConfig.test.js.map +1 -0
  163. package/ts_build/tests/unit/commands/github-credentials.test.d.ts +1 -0
  164. package/ts_build/tests/unit/commands/github-credentials.test.js +146 -0
  165. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -0
  166. package/ts_build/tests/unit/modules/moduleLoading.test.js +20 -26
  167. package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
  168. package/ts_build/tests/unit/plugins/pluginLoading.test.js +0 -65
  169. package/ts_build/tests/unit/plugins/pluginLoading.test.js.map +1 -1
  170. package/src/agents/tools/executeScript/README.md +0 -94
  171. package/src/agents/tools/executeScript/definition.ts +0 -79
  172. package/src/agents/tools/executeScript/examples/dependency-injection-validation.ts +0 -272
  173. package/src/agents/tools/executeScript/examples/quick-test.ts +0 -74
  174. package/src/agents/tools/executeScript/examples/serialization-test.ts +0 -321
  175. package/src/agents/tools/executeScript/examples/test-runner.ts +0 -197
  176. package/src/agents/tools/executeScript/index.ts +0 -98
  177. package/src/services/script-execution/SandboxContext.ts +0 -282
  178. package/src/services/script-execution/ScriptExecutor.ts +0 -441
  179. package/src/services/script-execution/ScriptPolicy.ts +0 -194
  180. package/src/services/script-execution/ScriptTracer.ts +0 -249
  181. package/src/services/script-execution/types.ts +0 -134
  182. package/ts_build/src/agents/tools/executeScript/definition.d.ts +0 -2
  183. package/ts_build/src/agents/tools/executeScript/definition.js +0 -76
  184. package/ts_build/src/agents/tools/executeScript/definition.js.map +0 -1
  185. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.d.ts +0 -18
  186. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.js +0 -192
  187. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.js.map +0 -1
  188. package/ts_build/src/agents/tools/executeScript/examples/quick-test.d.ts +0 -3
  189. package/ts_build/src/agents/tools/executeScript/examples/quick-test.js +0 -64
  190. package/ts_build/src/agents/tools/executeScript/examples/quick-test.js.map +0 -1
  191. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.d.ts +0 -15
  192. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.js +0 -266
  193. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.js.map +0 -1
  194. package/ts_build/src/agents/tools/executeScript/examples/test-runner.d.ts +0 -4
  195. package/ts_build/src/agents/tools/executeScript/examples/test-runner.js +0 -208
  196. package/ts_build/src/agents/tools/executeScript/examples/test-runner.js.map +0 -1
  197. package/ts_build/src/agents/tools/executeScript/index.d.ts +0 -28
  198. package/ts_build/src/agents/tools/executeScript/index.js +0 -72
  199. package/ts_build/src/agents/tools/executeScript/index.js.map +0 -1
  200. package/ts_build/src/services/script-execution/SandboxContext.d.ts +0 -34
  201. package/ts_build/src/services/script-execution/SandboxContext.js +0 -189
  202. package/ts_build/src/services/script-execution/SandboxContext.js.map +0 -1
  203. package/ts_build/src/services/script-execution/ScriptExecutor.d.ts +0 -19
  204. package/ts_build/src/services/script-execution/ScriptExecutor.js +0 -269
  205. package/ts_build/src/services/script-execution/ScriptExecutor.js.map +0 -1
  206. package/ts_build/src/services/script-execution/ScriptPolicy.d.ts +0 -28
  207. package/ts_build/src/services/script-execution/ScriptPolicy.js +0 -115
  208. package/ts_build/src/services/script-execution/ScriptPolicy.js.map +0 -1
  209. package/ts_build/src/services/script-execution/ScriptTracer.d.ts +0 -19
  210. package/ts_build/src/services/script-execution/ScriptTracer.js +0 -186
  211. package/ts_build/src/services/script-execution/ScriptTracer.js.map +0 -1
  212. package/ts_build/src/services/script-execution/types.d.ts +0 -108
  213. package/ts_build/src/services/script-execution/types.js +0 -3
  214. package/ts_build/src/services/script-execution/types.js.map +0 -1
@@ -2,6 +2,17 @@
2
2
  * Auth message types for the worker WebSocket authentication protocol.
3
3
  */
4
4
 
5
+ // Client → Worker: request a challenge
6
+ export interface AuthGetChallengeMessage {
7
+ type: "auth:getChallenge";
8
+ }
9
+
10
+ // Worker → Client: sent when a non-auth message is received while locked
11
+ export interface AuthLockedMessage {
12
+ type: "auth:locked";
13
+ message: string;
14
+ }
15
+
5
16
  // Worker → Client: challenge
6
17
  export interface AuthChallengeMessage {
7
18
  type: "auth:challenge";
@@ -31,12 +42,13 @@ export interface AuthFailureMessage {
31
42
  type: "auth:failure";
32
43
  reason: "invalid_signature" | "expired" | "unknown_credential";
33
44
  }
34
-
35
45
  export type AuthMessage =
46
+ | AuthGetChallengeMessage
36
47
  | AuthChallengeMessage
37
48
  | AuthResponseMessage
38
49
  | AuthSuccessMessage
39
- | AuthFailureMessage;
50
+ | AuthFailureMessage
51
+ | AuthLockedMessage;
40
52
 
41
53
  // Passkey credential stored in config
42
54
  export interface PasskeyCredential {
@@ -2,6 +2,7 @@ export * from "./listAllowedPorts";
2
2
  export * from "./getChallenge";
3
3
  export * from "./unlock";
4
4
  export * from "./lock";
5
+ export * from "./reloadConfig";
5
6
 
6
7
  import {
7
8
  listAllowedPorts,
@@ -11,6 +12,7 @@ import {
11
12
  export { makeGetChallengeTool } from "./getChallenge";
12
13
  export { makeUnlockTool } from "./unlock";
13
14
  export { makeLockTool } from "./lock";
15
+ export { makeReloadConfigTool } from "./reloadConfig";
14
16
 
15
17
  export default {
16
18
  tools: { listAllowedPorts },
@@ -0,0 +1,84 @@
1
+ import { Tool } from "../../clients/types";
2
+ import { getConfig } from "../../config";
3
+ import { McpServerService } from "../../services/McpServer";
4
+ import { McpService } from "../../services/Mcp";
5
+ import { ToolsService } from "../../services/Tools";
6
+
7
+ export interface ReloadConfigResult {
8
+ success: boolean;
9
+ toolCount: number;
10
+ mcpCount: number;
11
+ message: string;
12
+ }
13
+
14
+ /**
15
+ * Factory that creates the reloadConfig tool with access to runtime services.
16
+ * The tool re-reads the config from disk, reconnects all MCPs, and rebuilds
17
+ * the active tool list — the same logic as the WebSocket reloadConfig handler.
18
+ *
19
+ * Typical usage after pulling updated config from the cloud worker API:
20
+ * 1. execCommand("knowhow cloudworker --pull <cloudWorkerId>")
21
+ * 2. reloadConfig()
22
+ */
23
+ export function makeReloadConfigTool(
24
+ Mcp: McpService,
25
+ Tools: ToolsService,
26
+ mcpServer: McpServerService,
27
+ setToolsToUse: (tools: ReturnType<typeof Tools.getToolsByNames>) => void
28
+ ) {
29
+ const reloadConfig = async (): Promise<ReloadConfigResult> => {
30
+ try {
31
+ // Re-read fresh config from disk
32
+ const freshConfig = await getConfig();
33
+
34
+ // Close all existing MCP connections
35
+ await Mcp.closeAll();
36
+
37
+ // Reconnect from fresh config and re-register tools
38
+ await Mcp.connectToConfigured(Tools);
39
+
40
+ // Rebuild the allowed tools list from fresh config
41
+ const allowedToolNames =
42
+ freshConfig.worker?.allowedTools ?? Tools.getToolNames();
43
+ const newToolsToUse = Tools.getToolsByNames(allowedToolNames);
44
+ setToolsToUse(newToolsToUse);
45
+
46
+ // Update the MCP server with the new tool list
47
+ mcpServer.withTools(newToolsToUse);
48
+
49
+ const mcpCount = freshConfig.mcps?.length ?? 0;
50
+
51
+ return {
52
+ success: true,
53
+ toolCount: newToolsToUse.length,
54
+ mcpCount,
55
+ message: `Config reloaded: ${newToolsToUse.length} tools active, ${mcpCount} MCP(s) configured`,
56
+ };
57
+ } catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ return {
60
+ success: false,
61
+ toolCount: 0,
62
+ mcpCount: 0,
63
+ message: `Failed to reload config: ${message}`,
64
+ };
65
+ }
66
+ };
67
+
68
+ const reloadConfigDefinition: Tool = {
69
+ type: "function" as const,
70
+ function: {
71
+ name: "reloadConfig",
72
+ description:
73
+ "Reload the worker config from disk, reconnect all MCPs, and rebuild the active tool list. " +
74
+ "Call this after running `knowhow cloudworker --pull <id>` to apply updated MCPs without restarting the worker.",
75
+ parameters: {
76
+ type: "object",
77
+ properties: {},
78
+ required: [],
79
+ },
80
+ },
81
+ };
82
+
83
+ return { reloadConfig, reloadConfigDefinition };
84
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for the worker reloadConfig message handler.
3
+ *
4
+ * We test the core logic in isolation — simulating the "reloadConfig" message
5
+ * arriving on the WebSocket and verifying that MCPs are torn down and
6
+ * re-connected, and that the tool list is rebuilt from fresh config.
7
+ */
8
+
9
+ import { McpService } from "../../src/services/Mcp";
10
+ import { ToolsService } from "../../src/services/Tools";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers: build the same reload logic that lives in worker.ts so we can
14
+ // test it in isolation without spinning up a real WebSocket server.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ async function simulateReloadConfig(
18
+ Mcp: McpService,
19
+ Tools: ToolsService,
20
+ mcpServer: { withTools: (tools: unknown[]) => void },
21
+ getConfig: () => Promise<{ worker?: { allowedTools?: string[] } }>,
22
+ toolsToUseRef: { value: unknown[] }
23
+ ) {
24
+ // This mirrors the handler in worker.ts
25
+ const freshConfig = await getConfig();
26
+ await Mcp.closeAll();
27
+ await Mcp.connectToConfigured(Tools);
28
+ const allowedToolNames =
29
+ freshConfig.worker?.allowedTools ?? Tools.getToolNames();
30
+ toolsToUseRef.value = Tools.getToolsByNames(allowedToolNames);
31
+ mcpServer.withTools(toolsToUseRef.value);
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tests
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("Worker reloadConfig handler", () => {
39
+ let Mcp: McpService;
40
+ let Tools: ToolsService;
41
+ let mcpServer: { withTools: jest.Mock };
42
+ let toolsToUseRef: { value: unknown[] };
43
+
44
+ beforeEach(() => {
45
+ Mcp = new McpService();
46
+ Tools = new ToolsService();
47
+ mcpServer = { withTools: jest.fn() };
48
+ toolsToUseRef = { value: [] };
49
+
50
+ // Spy on MCP methods
51
+ jest.spyOn(Mcp, "closeAll").mockResolvedValue(undefined);
52
+ jest.spyOn(Mcp, "connectToConfigured").mockResolvedValue(undefined);
53
+ });
54
+
55
+ afterEach(() => {
56
+ jest.restoreAllMocks();
57
+ });
58
+
59
+ it("should call closeAll() to tear down existing MCP connections", async () => {
60
+ const getConfig = jest
61
+ .fn()
62
+ .mockResolvedValue({ worker: { allowedTools: [] } });
63
+
64
+ await simulateReloadConfig(Mcp, Tools, mcpServer, getConfig, toolsToUseRef);
65
+
66
+ expect(Mcp.closeAll).toHaveBeenCalledTimes(1);
67
+ });
68
+
69
+ it("should call connectToConfigured() to reconnect MCPs from fresh config", async () => {
70
+ const getConfig = jest
71
+ .fn()
72
+ .mockResolvedValue({ worker: { allowedTools: [] } });
73
+
74
+ await simulateReloadConfig(Mcp, Tools, mcpServer, getConfig, toolsToUseRef);
75
+
76
+ expect(Mcp.connectToConfigured).toHaveBeenCalledWith(Tools);
77
+ });
78
+
79
+ it("should rebuild the tool list from allowedTools in fresh config", async () => {
80
+ // Spy on getToolsByNames so we can track what names were requested
81
+ const toolsByNamesSpy = jest
82
+ .spyOn(Tools, "getToolsByNames")
83
+ .mockReturnValue([{ function: { name: "execCommand" } }] as ReturnType<ToolsService["getToolsByNames"]>);
84
+
85
+ const getConfig = jest
86
+ .fn()
87
+ .mockResolvedValue({ worker: { allowedTools: ["execCommand"] } });
88
+
89
+ await simulateReloadConfig(Mcp, Tools, mcpServer, getConfig, toolsToUseRef);
90
+
91
+ expect(toolsByNamesSpy).toHaveBeenCalledWith(["execCommand"]);
92
+ expect(toolsToUseRef.value).toHaveLength(1);
93
+ });
94
+
95
+ it("should fall back to all tool names when allowedTools is not set", async () => {
96
+ const allNames = ["execCommand", "readFile", "writeFileChunk"];
97
+ jest.spyOn(Tools, "getToolNames").mockReturnValue(allNames);
98
+ const toolsByNamesSpy = jest
99
+ .spyOn(Tools, "getToolsByNames")
100
+ .mockReturnValue([] as ReturnType<ToolsService["getToolsByNames"]>);
101
+
102
+ // Config has no worker.allowedTools
103
+ const getConfig = jest.fn().mockResolvedValue({});
104
+
105
+ await simulateReloadConfig(Mcp, Tools, mcpServer, getConfig, toolsToUseRef);
106
+
107
+ expect(toolsByNamesSpy).toHaveBeenCalledWith(allNames);
108
+ });
109
+
110
+ it("should call mcpServer.withTools() with the rebuilt tool list", async () => {
111
+ const fakeTools = [{ function: { name: "readFile" } }] as ReturnType<ToolsService["getToolsByNames"]>;
112
+ jest.spyOn(Tools, "getToolsByNames").mockReturnValue(fakeTools);
113
+
114
+ const getConfig = jest
115
+ .fn()
116
+ .mockResolvedValue({ worker: { allowedTools: ["readFile"] } });
117
+
118
+ await simulateReloadConfig(Mcp, Tools, mcpServer, getConfig, toolsToUseRef);
119
+
120
+ expect(mcpServer.withTools).toHaveBeenCalledWith(fakeTools);
121
+ });
122
+
123
+ it("should re-read the config on every reload (not use stale config)", async () => {
124
+ const getConfig = jest
125
+ .fn()
126
+ .mockResolvedValueOnce({ worker: { allowedTools: ["execCommand"] } })
127
+ .mockResolvedValueOnce({ worker: { allowedTools: ["readFile", "writeFileChunk"] } });
128
+
129
+ jest.spyOn(Tools, "getToolsByNames").mockReturnValue([]);
130
+
131
+ // First reload
132
+ await simulateReloadConfig(Mcp, Tools, mcpServer, getConfig, toolsToUseRef);
133
+ // Second reload
134
+ await simulateReloadConfig(Mcp, Tools, mcpServer, getConfig, toolsToUseRef);
135
+
136
+ expect(getConfig).toHaveBeenCalledTimes(2);
137
+ // Each reload should tear down and reconnect
138
+ expect(Mcp.closeAll).toHaveBeenCalledTimes(2);
139
+ expect(Mcp.connectToConfigured).toHaveBeenCalledTimes(2);
140
+ });
141
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Unit tests for the github-credentials command.
3
+ *
4
+ * Key invariant: running `github-credentials` must NEVER write anything other than
5
+ * the credential lines to stdout. Module loading logs, warnings, etc. must be
6
+ * silenced so the git credential helper protocol is not corrupted.
7
+ */
8
+
9
+ // Mock config before any imports that depend on it
10
+ jest.mock("../../../src/config", () => ({
11
+ getConfig: jest.fn().mockResolvedValue({ modules: [] }),
12
+ getGlobalConfig: jest.fn().mockResolvedValue({ modules: [] }),
13
+ getConfigSync: jest.fn().mockReturnValue({}),
14
+ migrateConfig: jest.fn().mockResolvedValue(undefined),
15
+ }));
16
+
17
+ // Mock clients to avoid openai.ts side-effects
18
+ jest.mock("../../../src/clients", () => ({
19
+ AIClient: jest.fn(),
20
+ Clients: { registerClient: jest.fn(), registerModels: jest.fn() },
21
+ }));
22
+
23
+ // Mock KnowhowSimpleClient so we control what getGitCredential returns
24
+ // without needing a real JWT or network connection
25
+ jest.mock("../../../src/services/KnowhowClient", () => ({
26
+ KnowhowSimpleClient: jest.fn().mockImplementation(() => ({
27
+ getGitCredential: jest.fn().mockResolvedValue({
28
+ protocol: "https",
29
+ host: "github.com",
30
+ username: "x-access-token",
31
+ password: "ghu_TESTTOKEN123",
32
+ }),
33
+ })),
34
+ }));
35
+
36
+ // Mock readline so the 'get' action doesn't hang waiting for stdin
37
+ jest.mock("readline", () => ({
38
+ createInterface: jest.fn().mockReturnValue({
39
+ on: jest.fn().mockImplementation(function (event: string, cb: Function) {
40
+ // Immediately fire 'close' so the readline promise resolves
41
+ if (event === "close") {
42
+ setImmediate(() => cb());
43
+ }
44
+ return this;
45
+ }),
46
+ }),
47
+ }));
48
+
49
+ import { Command } from "commander";
50
+ import { addGithubCredentialsCommand } from "../../../src/commands/misc";
51
+ import { logger } from "../../../src/logger";
52
+
53
+ describe("github-credentials command", () => {
54
+ /**
55
+ * This test verifies the EARLY silencing logic in cli.ts main().
56
+ * The problem: modules load BEFORE parseAsync, so any module that emits
57
+ * warnings (e.g. Terminal module: no TunnelHandler) does so before the
58
+ * action's logger.silence() call can stop it.
59
+ *
60
+ * The fix: cli.ts checks process.argv before module loading and silences early.
61
+ * This test simulates that logic directly.
62
+ */
63
+ describe("early silencing (pre-module-load)", () => {
64
+ beforeEach(() => {
65
+ logger.unsilence();
66
+ logger.installConsoleOverload();
67
+ });
68
+
69
+ afterEach(() => {
70
+ logger.unsilence();
71
+ logger.uninstallConsoleOverload();
72
+ });
73
+
74
+ it("silences before module loading when github-credentials is in argv", () => {
75
+ const originalArgv = process.argv;
76
+ process.argv = ["node", "knowhow", "github-credentials", "get"];
77
+
78
+ // Simulate the exact early-detection logic from cli.ts main()
79
+ const rawArgs = process.argv.slice(2);
80
+ const SILENT_COMMANDS = ["github-credentials"];
81
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
82
+ logger.silence();
83
+ }
84
+
85
+ // Now any module-load-time console.log/warn should be suppressed
86
+ const consoleSpy = jest.spyOn(process.stdout, "write");
87
+ console.warn("⚠️ Terminal module: no TunnelHandler in context — terminal addon not registered");
88
+ console.log("some other module loading noise");
89
+
90
+ expect(consoleSpy).not.toHaveBeenCalled();
91
+ consoleSpy.mockRestore();
92
+ process.argv = originalArgv;
93
+ });
94
+
95
+ it("does NOT silence for other commands", () => {
96
+ const originalArgv = process.argv;
97
+ process.argv = ["node", "knowhow", "chat"];
98
+
99
+ const rawArgs = process.argv.slice(2);
100
+ const SILENT_COMMANDS = ["github-credentials"];
101
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
102
+ logger.silence();
103
+ }
104
+
105
+ expect(logger.isSilenced()).toBe(false);
106
+ process.argv = originalArgv;
107
+ });
108
+ });
109
+
110
+ let program: Command;
111
+ let stdoutSpy: jest.SpyInstance;
112
+ let writtenToStdout: string[];
113
+
114
+ beforeEach(() => {
115
+ jest.clearAllMocks();
116
+
117
+ // Reset logger silence state between tests
118
+ logger.unsilence();
119
+
120
+ // Capture process.stdout.write — this is what the credential helper uses
121
+ writtenToStdout = [];
122
+ stdoutSpy = jest
123
+ .spyOn(process.stdout, "write")
124
+ .mockImplementation((chunk: any) => {
125
+ writtenToStdout.push(typeof chunk === "string" ? chunk : chunk.toString());
126
+ return true;
127
+ });
128
+
129
+ program = new Command();
130
+ program.exitOverride(); // prevent process.exit during tests
131
+ addGithubCredentialsCommand(program);
132
+ });
133
+
134
+ afterEach(() => {
135
+ stdoutSpy.mockRestore();
136
+ logger.unsilence();
137
+ });
138
+
139
+ it("outputs only credential lines to stdout for 'get' action", async () => {
140
+ await program.parseAsync([
141
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
142
+ ]);
143
+
144
+ expect(writtenToStdout).toHaveLength(1);
145
+ expect(writtenToStdout[0]).toBe(
146
+ "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghu_TESTTOKEN123\n"
147
+ );
148
+ });
149
+
150
+ it("silences the logger immediately so module logs don't pollute stdout", async () => {
151
+ await program.parseAsync([
152
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
153
+ ]);
154
+
155
+ // The action must have called logger.silence() — state persists after action
156
+ expect(logger.isSilenced()).toBe(true);
157
+ });
158
+
159
+ it("produces exactly 4 credential field lines and nothing else", async () => {
160
+ await program.parseAsync([
161
+ "node", "knowhow", "github-credentials", "get", "--repo", "myorg/myrepo",
162
+ ]);
163
+
164
+ const allOutput = writtenToStdout.join("");
165
+ const lines = allOutput.trim().split("\n");
166
+
167
+ expect(lines).toHaveLength(4);
168
+ expect(lines[0]).toMatch(/^protocol=/);
169
+ expect(lines[1]).toMatch(/^host=/);
170
+ expect(lines[2]).toMatch(/^username=/);
171
+ expect(lines[3]).toMatch(/^password=/);
172
+ });
173
+
174
+ it("exits cleanly for 'store' action without writing credentials", async () => {
175
+ let exitCode: number | undefined;
176
+ // Throw to stop execution after exit() is called — otherwise the mock
177
+ // just sets a flag and the action continues to fetch credentials.
178
+ const exitSpy = jest
179
+ .spyOn(process, "exit")
180
+ .mockImplementation(((code?: number) => {
181
+ exitCode = code ?? 0;
182
+ throw new Error(`process.exit(${exitCode})`);
183
+ }) as any);
184
+
185
+ await expect(
186
+ program.parseAsync(["node", "knowhow", "github-credentials", "store"])
187
+ ).rejects.toThrow("process.exit(0)");
188
+
189
+ expect(exitCode).toBe(0);
190
+ expect(writtenToStdout).toHaveLength(0);
191
+ exitSpy.mockRestore();
192
+ });
193
+
194
+ it("exits cleanly for 'erase' action without writing credentials", async () => {
195
+ let exitCode: number | undefined;
196
+ const exitSpy = jest
197
+ .spyOn(process, "exit")
198
+ .mockImplementation(((code?: number) => {
199
+ exitCode = code ?? 0;
200
+ throw new Error(`process.exit(${exitCode})`);
201
+ }) as any);
202
+
203
+ await expect(
204
+ program.parseAsync(["node", "knowhow", "github-credentials", "erase"])
205
+ ).rejects.toThrow("process.exit(0)");
206
+
207
+ expect(exitCode).toBe(0);
208
+ expect(writtenToStdout).toHaveLength(0);
209
+ exitSpy.mockRestore();
210
+ });
211
+ });
@@ -24,20 +24,27 @@ jest.mock("../../../src/services", () => ({
24
24
  }));
25
25
 
26
26
  import { ModulesService } from "../../../src/services/modules";
27
- import { ModuleContext, KnowhowModule } from "../../../src/services/modules/types";
27
+ import {
28
+ ModuleContext,
29
+ KnowhowModule,
30
+ } from "../../../src/services/modules/types";
28
31
  import { getConfig, getGlobalConfig } from "../../../src/config";
29
32
 
30
33
  const mockGetConfig = getConfig as jest.MockedFunction<typeof getConfig>;
31
- const mockGetGlobalConfig = getGlobalConfig as jest.MockedFunction<typeof getGlobalConfig>;
34
+ const mockGetGlobalConfig = getGlobalConfig as jest.MockedFunction<
35
+ typeof getGlobalConfig
36
+ >;
32
37
 
33
38
  function makeContext(overrides?: Partial<ModuleContext>): ModuleContext {
34
39
  return {
40
+ Events: {
41
+ log: jest.fn(),
42
+ } as any,
35
43
  Agents: {
36
44
  registerAgent: jest.fn(),
37
45
  } as any,
38
46
  Plugins: {
39
47
  registerPlugin: jest.fn(),
40
- loadPluginsFromConfig: jest.fn().mockResolvedValue(undefined),
41
48
  } as any,
42
49
  Clients: {
43
50
  registerClient: jest.fn(),
@@ -87,7 +94,8 @@ describe("ModulesService.loadModulesFromConfig", () => {
87
94
  const context = makeContext();
88
95
 
89
96
  // Mock require used inside loadModulesFromConfig
90
- const requireSpy = jest.spyOn(service as any, "loadModulesFromConfig")
97
+ const requireSpy = jest
98
+ .spyOn(service as any, "loadModulesFromConfig")
91
99
  .mockImplementation(async (ctx?: ModuleContext) => {
92
100
  const resolvedCtx = ctx || context;
93
101
  await mockModule.init({ config: {} as Config, cwd: process.cwd() });
@@ -113,26 +121,35 @@ describe("ModulesService.loadModulesFromConfig", () => {
113
121
  };
114
122
  const mockToolHandler = jest.fn();
115
123
  const mockModule = makeModule({
116
- tools: [{ name: "myTool", handler: mockToolHandler, definition: mockToolDef }],
124
+ tools: [
125
+ { name: "myTool", handler: mockToolHandler, definition: mockToolDef },
126
+ ],
117
127
  });
118
128
 
119
129
  const service = new ModulesService();
120
130
  const context = makeContext();
121
131
 
122
- const spy = jest.spyOn(service as any, "loadModulesFromConfig")
132
+ const spy = jest
133
+ .spyOn(service as any, "loadModulesFromConfig")
123
134
  .mockImplementation(async (ctx?: ModuleContext) => {
124
135
  const resolvedCtx = ctx || context;
125
136
  await mockModule.init({ config: {} as Config, cwd: process.cwd() });
126
137
  for (const tool of mockModule.tools) {
127
138
  resolvedCtx.Tools.addTool(tool.definition);
128
- resolvedCtx.Tools.setFunction(tool.definition.function.name, tool.handler);
139
+ resolvedCtx.Tools.setFunction(
140
+ tool.definition.function.name,
141
+ tool.handler
142
+ );
129
143
  }
130
144
  });
131
145
 
132
146
  await service.loadModulesFromConfig(context);
133
147
 
134
148
  expect(context.Tools.addTool).toHaveBeenCalledWith(mockToolDef);
135
- expect(context.Tools.setFunction).toHaveBeenCalledWith("myTool", mockToolHandler);
149
+ expect(context.Tools.setFunction).toHaveBeenCalledWith(
150
+ "myTool",
151
+ mockToolHandler
152
+ );
136
153
  spy.mockRestore();
137
154
  });
138
155
 
@@ -147,7 +164,9 @@ describe("ModulesService.loadModulesFromConfig", () => {
147
164
  embed: () => Promise.resolve([]),
148
165
  };
149
166
  // ModulePlugin expects a constructor (class), not an instance
150
- const MockPluginClass = jest.fn().mockImplementation(() => mockPluginInstance);
167
+ const MockPluginClass = jest
168
+ .fn()
169
+ .mockImplementation(() => mockPluginInstance);
151
170
  const mockModule = makeModule({
152
171
  plugins: [{ name: "test-plugin", plugin: MockPluginClass as any }],
153
172
  });
@@ -155,7 +174,8 @@ describe("ModulesService.loadModulesFromConfig", () => {
155
174
  const service = new ModulesService();
156
175
  const context = makeContext();
157
176
 
158
- const spy = jest.spyOn(service as any, "loadModulesFromConfig")
177
+ const spy = jest
178
+ .spyOn(service as any, "loadModulesFromConfig")
159
179
  .mockImplementation(async (ctx?: ModuleContext) => {
160
180
  const resolvedCtx = ctx || context;
161
181
  await mockModule.init({ config: {} as Config, cwd: process.cwd() });
@@ -167,36 +187,17 @@ describe("ModulesService.loadModulesFromConfig", () => {
167
187
 
168
188
  await service.loadModulesFromConfig(context);
169
189
 
170
- expect(context.Plugins.registerPlugin).toHaveBeenCalledWith("test-plugin", mockPluginInstance);
190
+ expect(context.Plugins.registerPlugin).toHaveBeenCalledWith(
191
+ "test-plugin",
192
+ mockPluginInstance
193
+ );
171
194
  spy.mockRestore();
172
195
  });
173
196
 
174
- it("should call loadPluginsFromConfig with both global and local configs", async () => {
175
- const localConfig = {
176
- modules: [],
177
- pluginPackages: { asana: "@knowhow/plugin-asana" },
178
- } as unknown as Config;
179
- const globalConfig = {
180
- modules: [],
181
- pluginPackages: { linear: "@knowhow/plugin-linear" },
182
- } as unknown as Config;
183
-
184
- mockGetConfig.mockResolvedValue(localConfig);
185
- mockGetGlobalConfig.mockResolvedValue(globalConfig);
186
-
187
- const service = new ModulesService();
188
- const context = makeContext();
189
-
190
- await service.loadModulesFromConfig(context);
191
-
192
- // pluginService.loadPluginsFromConfig should be called twice: once for local, once for global
193
- expect(context.Plugins.loadPluginsFromConfig).toHaveBeenCalledTimes(2);
194
- expect(context.Plugins.loadPluginsFromConfig).toHaveBeenCalledWith(localConfig);
195
- expect(context.Plugins.loadPluginsFromConfig).toHaveBeenCalledWith(globalConfig);
196
- });
197
-
198
197
  it("should load modules from both global and local config paths", async () => {
199
- const globalModule = makeModule({ agents: [{ name: "GlobalAgent" } as any] });
198
+ const globalModule = makeModule({
199
+ agents: [{ name: "GlobalAgent" } as any],
200
+ });
200
201
  const localModule = makeModule({ agents: [{ name: "LocalAgent" } as any] });
201
202
 
202
203
  mockGetConfig.mockResolvedValue({
@@ -210,7 +211,8 @@ describe("ModulesService.loadModulesFromConfig", () => {
210
211
  const context = makeContext();
211
212
 
212
213
  const loadedPaths: string[] = [];
213
- const spy = jest.spyOn(service as any, "loadModulesFromConfig")
214
+ const spy = jest
215
+ .spyOn(service as any, "loadModulesFromConfig")
214
216
  .mockImplementation(async (ctx?: ModuleContext) => {
215
217
  const resolvedCtx = ctx || context;
216
218
  for (const [path, mod] of [