@tyvm/knowhow 0.0.156 → 0.0.158

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 (155) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/agents/base/base.ts +176 -39
  4. package/src/auth/browserLogin.ts +2 -13
  5. package/src/auth/environmentAuth.ts +51 -0
  6. package/src/auth/jwtStore.ts +70 -0
  7. package/src/auth/keyAuth.ts +4 -15
  8. package/src/auth/registerIdentity.ts +8 -8
  9. package/src/chat/modules/AgentModule.ts +3 -3
  10. package/src/chat/modules/RemoteSyncModule.ts +45 -17
  11. package/src/cli.ts +2 -0
  12. package/src/clients/anthropic.ts +156 -79
  13. package/src/clients/attribution.ts +53 -0
  14. package/src/clients/http.ts +211 -84
  15. package/src/clients/index.ts +45 -13
  16. package/src/clients/openai.ts +441 -220
  17. package/src/clients/openrouter.ts +10 -3
  18. package/src/clients/pricing/fireworks.ts +32 -2
  19. package/src/clients/pricing/openai.ts +15 -1
  20. package/src/clients/pricing/types.ts +15 -0
  21. package/src/clients/responseMetadata.ts +221 -0
  22. package/src/clients/types.ts +49 -4
  23. package/src/commands/mcp.ts +1 -0
  24. package/src/commands/misc.ts +1 -1
  25. package/src/commands/remote.ts +77 -0
  26. package/src/config.ts +9 -1
  27. package/src/login.ts +54 -42
  28. package/src/remotes.ts +110 -0
  29. package/src/services/KnowhowClient.ts +12 -16
  30. package/src/services/Mcp.ts +94 -7
  31. package/src/services/McpWebsocketTransport.ts +14 -11
  32. package/src/types.ts +12 -0
  33. package/tests/unit/McpWebsocketTransport.test.ts +56 -0
  34. package/tests/unit/auth/jwtStore.test.ts +55 -0
  35. package/tests/unit/auth/registerIdentity.test.ts +8 -2
  36. package/tests/unit/clients/anthropic.responseMetadata.test.ts +81 -0
  37. package/tests/unit/clients/attribution.test.ts +185 -0
  38. package/tests/unit/clients/http.responseMetadata.test.ts +86 -0
  39. package/tests/unit/clients/openai.responseMetadata.test.ts +90 -0
  40. package/tests/unit/clients/responseMetadata.test.ts +123 -0
  41. package/tests/unit/commands/remote.test.ts +79 -0
  42. package/tests/unit/login.test.ts +64 -3
  43. package/tests/unit/services/KnowhowClient.test.ts +2 -2
  44. package/tests/unit/services/Mcp.test.ts +2 -2
  45. package/ts_build/package.json +1 -1
  46. package/ts_build/src/agents/base/base.d.ts +8 -3
  47. package/ts_build/src/agents/base/base.js +112 -26
  48. package/ts_build/src/agents/base/base.js.map +1 -1
  49. package/ts_build/src/auth/browserLogin.js +2 -7
  50. package/ts_build/src/auth/browserLogin.js.map +1 -1
  51. package/ts_build/src/auth/environmentAuth.d.ts +5 -0
  52. package/ts_build/src/auth/environmentAuth.js +48 -0
  53. package/ts_build/src/auth/environmentAuth.js.map +1 -0
  54. package/ts_build/src/auth/jwtStore.d.ts +4 -0
  55. package/ts_build/src/auth/jwtStore.js +65 -0
  56. package/ts_build/src/auth/jwtStore.js.map +1 -0
  57. package/ts_build/src/auth/keyAuth.d.ts +1 -1
  58. package/ts_build/src/auth/keyAuth.js +4 -16
  59. package/ts_build/src/auth/keyAuth.js.map +1 -1
  60. package/ts_build/src/auth/registerIdentity.js +6 -7
  61. package/ts_build/src/auth/registerIdentity.js.map +1 -1
  62. package/ts_build/src/chat/modules/AgentModule.js +3 -3
  63. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  64. package/ts_build/src/chat/modules/RemoteSyncModule.d.ts +1 -0
  65. package/ts_build/src/chat/modules/RemoteSyncModule.js +19 -7
  66. package/ts_build/src/chat/modules/RemoteSyncModule.js.map +1 -1
  67. package/ts_build/src/cli.js +2 -0
  68. package/ts_build/src/cli.js.map +1 -1
  69. package/ts_build/src/clients/anthropic.d.ts +1 -0
  70. package/ts_build/src/clients/anthropic.js +83 -38
  71. package/ts_build/src/clients/anthropic.js.map +1 -1
  72. package/ts_build/src/clients/attribution.d.ts +2 -0
  73. package/ts_build/src/clients/attribution.js +32 -0
  74. package/ts_build/src/clients/attribution.js.map +1 -0
  75. package/ts_build/src/clients/http.js +100 -20
  76. package/ts_build/src/clients/http.js.map +1 -1
  77. package/ts_build/src/clients/index.d.ts +2 -1
  78. package/ts_build/src/clients/index.js +24 -6
  79. package/ts_build/src/clients/index.js.map +1 -1
  80. package/ts_build/src/clients/openai.d.ts +2 -1
  81. package/ts_build/src/clients/openai.js +225 -99
  82. package/ts_build/src/clients/openai.js.map +1 -1
  83. package/ts_build/src/clients/openrouter.d.ts +2 -2
  84. package/ts_build/src/clients/openrouter.js +6 -2
  85. package/ts_build/src/clients/openrouter.js.map +1 -1
  86. package/ts_build/src/clients/pricing/fireworks.d.ts +4 -0
  87. package/ts_build/src/clients/pricing/fireworks.js +14 -2
  88. package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
  89. package/ts_build/src/clients/pricing/openai.js +15 -1
  90. package/ts_build/src/clients/pricing/openai.js.map +1 -1
  91. package/ts_build/src/clients/pricing/types.d.ts +6 -0
  92. package/ts_build/src/clients/pricing/types.js.map +1 -1
  93. package/ts_build/src/clients/responseMetadata.d.ts +15 -0
  94. package/ts_build/src/clients/responseMetadata.js +141 -0
  95. package/ts_build/src/clients/responseMetadata.js.map +1 -0
  96. package/ts_build/src/clients/types.d.ts +18 -1
  97. package/ts_build/src/clients/types.js +2 -1
  98. package/ts_build/src/clients/types.js.map +1 -1
  99. package/ts_build/src/commands/mcp.js.map +1 -1
  100. package/ts_build/src/commands/misc.js +1 -1
  101. package/ts_build/src/commands/misc.js.map +1 -1
  102. package/ts_build/src/commands/remote.d.ts +2 -0
  103. package/ts_build/src/commands/remote.js +70 -0
  104. package/ts_build/src/commands/remote.js.map +1 -0
  105. package/ts_build/src/config.js +8 -0
  106. package/ts_build/src/config.js.map +1 -1
  107. package/ts_build/src/login.d.ts +4 -3
  108. package/ts_build/src/login.js +34 -35
  109. package/ts_build/src/login.js.map +1 -1
  110. package/ts_build/src/remotes.d.ts +14 -0
  111. package/ts_build/src/remotes.js +117 -0
  112. package/ts_build/src/remotes.js.map +1 -0
  113. package/ts_build/src/services/KnowhowClient.d.ts +1 -1
  114. package/ts_build/src/services/KnowhowClient.js +12 -14
  115. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  116. package/ts_build/src/services/Mcp.d.ts +1 -1
  117. package/ts_build/src/services/Mcp.js +68 -7
  118. package/ts_build/src/services/Mcp.js.map +1 -1
  119. package/ts_build/src/services/McpWebsocketTransport.d.ts +1 -0
  120. package/ts_build/src/services/McpWebsocketTransport.js +11 -6
  121. package/ts_build/src/services/McpWebsocketTransport.js.map +1 -1
  122. package/ts_build/src/types.d.ts +13 -0
  123. package/ts_build/src/types.js.map +1 -1
  124. package/ts_build/tests/unit/McpWebsocketTransport.test.d.ts +1 -0
  125. package/ts_build/tests/unit/McpWebsocketTransport.test.js +47 -0
  126. package/ts_build/tests/unit/McpWebsocketTransport.test.js.map +1 -0
  127. package/ts_build/tests/unit/auth/jwtStore.test.d.ts +1 -0
  128. package/ts_build/tests/unit/auth/jwtStore.test.js +46 -0
  129. package/ts_build/tests/unit/auth/jwtStore.test.js.map +1 -0
  130. package/ts_build/tests/unit/auth/registerIdentity.test.js +8 -2
  131. package/ts_build/tests/unit/auth/registerIdentity.test.js.map +1 -1
  132. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.d.ts +1 -0
  133. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js +77 -0
  134. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js.map +1 -0
  135. package/ts_build/tests/unit/clients/attribution.test.d.ts +1 -0
  136. package/ts_build/tests/unit/clients/attribution.test.js +153 -0
  137. package/ts_build/tests/unit/clients/attribution.test.js.map +1 -0
  138. package/ts_build/tests/unit/clients/http.responseMetadata.test.d.ts +1 -0
  139. package/ts_build/tests/unit/clients/http.responseMetadata.test.js +77 -0
  140. package/ts_build/tests/unit/clients/http.responseMetadata.test.js.map +1 -0
  141. package/ts_build/tests/unit/clients/openai.responseMetadata.test.d.ts +1 -0
  142. package/ts_build/tests/unit/clients/openai.responseMetadata.test.js +86 -0
  143. package/ts_build/tests/unit/clients/openai.responseMetadata.test.js.map +1 -0
  144. package/ts_build/tests/unit/clients/responseMetadata.test.d.ts +1 -0
  145. package/ts_build/tests/unit/clients/responseMetadata.test.js +96 -0
  146. package/ts_build/tests/unit/clients/responseMetadata.test.js.map +1 -0
  147. package/ts_build/tests/unit/commands/remote.test.d.ts +1 -0
  148. package/ts_build/tests/unit/commands/remote.test.js +70 -0
  149. package/ts_build/tests/unit/commands/remote.test.js.map +1 -0
  150. package/ts_build/tests/unit/login.test.js +44 -3
  151. package/ts_build/tests/unit/login.test.js.map +1 -1
  152. package/ts_build/tests/unit/services/KnowhowClient.test.js +2 -2
  153. package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -1
  154. package/ts_build/tests/unit/services/Mcp.test.js +2 -2
  155. package/ts_build/tests/unit/services/Mcp.test.js.map +1 -1
package/src/login.ts CHANGED
@@ -8,6 +8,16 @@ import { KNOWHOW_API_URL } from "./services/KnowhowClient";
8
8
  import { BrowserLoginService } from "./auth/browserLogin";
9
9
  import { authenticateWithKey, hasKeyPair, registerPublicKey } from "./auth/keyAuth";
10
10
  import { getDefaultPrivateKeyPath, getOrCreatePublicKey } from "./auth/keyManager";
11
+ import { storeJwtForApi } from "./auth/jwtStore";
12
+ import { getOrgIdForApi, setOrgIdForApi } from "./auth/environmentAuth";
13
+ import {
14
+ createRemote,
15
+ findRemoteForApi,
16
+ inferRemoteName,
17
+ registerRemote,
18
+ resolveJwtPath,
19
+ } from "./remotes";
20
+ import { KnowhowRemote } from "./types";
11
21
 
12
22
  /**
13
23
  * Log in to Knowhow.
@@ -23,37 +33,31 @@ import { getDefaultPrivateKeyPath, getOrCreatePublicKey } from "./auth/keyManage
23
33
  * @param jwtFlag True when --jwt flag is passed.
24
34
  * @param identityPath Optional path to a specific private key file (--identity flag).
25
35
  */
26
- export async function login(jwtFlag?: boolean, identityPath?: string): Promise<void> {
27
- if (!KNOWHOW_API_URL) {
28
- throw new Error("Error: KNOWHOW_API_URL environment variable not set.");
29
- }
36
+ export async function login(
37
+ jwtFlag?: boolean,
38
+ identityPath?: string,
39
+ apiUrl: string = KNOWHOW_API_URL,
40
+ remoteName?: string
41
+ ): Promise<void> {
42
+ const config = await getConfig();
43
+ const remote = findRemoteForApi(config, apiUrl) ??
44
+ createRemote(remoteName ?? inferRemoteName(apiUrl), apiUrl);
30
45
 
31
46
  if (jwtFlag) {
32
47
  const jwt = await ask("Enter your JWT: ");
33
-
34
- const configDir = path.join(process.cwd(), ".knowhow");
35
- const jwtFile = path.join(configDir, ".jwt");
36
-
37
- if (!fs.existsSync(configDir)) {
38
- fs.mkdirSync(configDir, { recursive: true });
39
- }
40
- fs.writeFileSync(jwtFile, jwt);
41
- fs.chmodSync(jwtFile, 0o600);
42
- console.log("JWT updated successfully.");
48
+ const jwtFile = storeJwtForApi(jwt, remote.apiUrl, remote.jwtPath);
49
+ console.log(`JWT updated successfully in ${path.relative(process.cwd(), jwtFile)}.`);
43
50
  } else {
44
51
  const selectedIdentityPath = identityPath ?? getDefaultPrivateKeyPath();
45
- const config = await getConfig();
46
- const orgId = config.orgId;
52
+ const orgId = getOrgIdForApi(config, remote.apiUrl);
47
53
 
48
- // A local key is not proof of registration. Let the selected environment
49
- // check it, but only when an organization is available to bind the attempt.
50
54
  if (orgId && hasKeyPair(selectedIdentityPath)) {
51
55
  console.log("Found CLI identity — authenticating with public key...");
52
56
  try {
53
- const success = await authenticateWithKey(orgId, KNOWHOW_API_URL, selectedIdentityPath);
57
+ const success = await authenticateWithKey(orgId, remote.apiUrl, selectedIdentityPath);
54
58
  if (success) {
55
59
  console.log("✅ Successfully authenticated via public key!");
56
- return await postLoginConfigUpdate(selectedIdentityPath);
60
+ return await postLoginConfigUpdate(remote, selectedIdentityPath);
57
61
  }
58
62
  console.warn("Key authentication returned no JWT, falling back to browser login...");
59
63
  } catch (error: unknown) {
@@ -62,30 +66,31 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
62
66
  }
63
67
  }
64
68
 
65
- // Fall back when no org/key is configured or when key auth is unavailable.
66
- await doBrowserLogin(selectedIdentityPath);
69
+ await doBrowserLogin(remote, selectedIdentityPath, orgId);
67
70
  identityPath = selectedIdentityPath;
68
71
  }
69
72
 
70
- await postLoginConfigUpdate(identityPath);
73
+ await postLoginConfigUpdate(remote, identityPath);
71
74
  }
72
75
 
73
76
  /** Complete browser PKCE, then best-effort bootstrap the selected identity. */
74
- async function doBrowserLogin(identityPath?: string): Promise<void> {
77
+ async function doBrowserLogin(
78
+ remote: KnowhowRemote,
79
+ identityPath?: string,
80
+ orgId?: string
81
+ ): Promise<void> {
75
82
  console.log("Starting browser-based authentication...");
76
83
  try {
77
- const existingConfig = await getConfig();
78
- const existingOrgId = existingConfig?.orgId;
79
- const browserLoginService = new BrowserLoginService(undefined, existingOrgId);
84
+ const browserLoginService = new BrowserLoginService(remote.apiUrl, orgId);
80
85
  await browserLoginService.login();
81
86
  console.log("✅ Successfully authenticated via browser!");
82
87
 
83
88
  try {
84
89
  const keyPair = getOrCreatePublicKey(identityPath);
85
90
  await registerPublicKey(
86
- await loadJwt(),
91
+ await loadJwt(remote),
87
92
  keyPair.publicKeyBase64,
88
- KNOWHOW_API_URL
93
+ remote.apiUrl
89
94
  );
90
95
  console.log(`Registered CLI identity ${keyPair.fingerprint}`);
91
96
  } catch (registrationError: unknown) {
@@ -114,10 +119,13 @@ function isTemporaryIdentityPath(identityPath: string): boolean {
114
119
  }
115
120
 
116
121
  /** After any successful login, update the local config with the current user/org. */
117
- async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
122
+ async function postLoginConfigUpdate(
123
+ remote: KnowhowRemote,
124
+ identityPath?: string
125
+ ): Promise<void> {
118
126
  try {
119
- const storedJwt = await loadJwt();
120
- const { user, currentOrg } = await checkJwt(storedJwt);
127
+ const storedJwt = await loadJwt(remote);
128
+ const { user, currentOrg } = await checkJwt(storedJwt, remote.apiUrl);
121
129
  const orgId = currentOrg?.organizationId;
122
130
 
123
131
  console.log(
@@ -137,8 +145,10 @@ async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
137
145
  config.modelProviders.push({ provider: "knowhow" });
138
146
  }
139
147
 
148
+ registerRemote(config, orgId ? { ...remote, orgId } : remote);
149
+ config.activeRemote = remote.name;
140
150
  if (orgId) {
141
- config.orgId = orgId;
151
+ setOrgIdForApi(config, remote.apiUrl, orgId);
142
152
  }
143
153
 
144
154
  if (identityPath && !isTemporaryIdentityPath(identityPath)) {
@@ -164,23 +174,25 @@ async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
164
174
  }
165
175
  }
166
176
 
167
- export async function loadJwt(): Promise<string> {
168
- const jwtFile = path.join(process.cwd(), ".knowhow", ".jwt");
169
- if (!fs.existsSync(jwtFile)) {
170
- throw new Error("Error: JWT file not found.");
171
- }
172
-
173
- const jwt = fs.readFileSync(jwtFile, "utf-8").trim();
177
+ export async function loadJwt(remote?: KnowhowRemote): Promise<string> {
178
+ const selectedRemote = remote ??
179
+ findRemoteForApi(await getConfig(), KNOWHOW_API_URL) ??
180
+ createRemote(inferRemoteName(KNOWHOW_API_URL), KNOWHOW_API_URL);
181
+ const jwtFile = resolveJwtPath(selectedRemote.jwtPath);
182
+ const jwt = fs.existsSync(jwtFile) ? fs.readFileSync(jwtFile, "utf8").trim() : "";
174
183
 
175
184
  if (!jwt) {
185
+ if (!fs.existsSync(jwtFile)) {
186
+ throw new Error(`Error: JWT file not found: ${jwtFile}`);
187
+ }
176
188
  throw new Error("Error: JWT is empty. Re-login with knowhow login --jwt.");
177
189
  }
178
190
 
179
191
  return jwt;
180
192
  }
181
193
 
182
- export async function checkJwt(storedJwt: string) {
183
- const response = await http.get(`${KNOWHOW_API_URL}/api/users/me`, {
194
+ export async function checkJwt(storedJwt: string, apiUrl: string = KNOWHOW_API_URL) {
195
+ const response = await http.get(`${apiUrl}/api/users/me`, {
184
196
  headers: {
185
197
  Authorization: `Bearer ${storedJwt}`,
186
198
  },
package/src/remotes.ts ADDED
@@ -0,0 +1,110 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Config, KnowhowRemote } from "./types";
4
+
5
+ export const DEFAULT_REMOTE_NAME = "origin";
6
+ export const DEFAULT_API_URL = "https://api.knowhow.tyvm.ai";
7
+
8
+ export function normalizeApiUrl(apiUrl: string): string {
9
+ const trimmed = apiUrl.trim().replace(/\/+$/, "");
10
+ const url = new URL(trimmed);
11
+ if (!/^https?:$/.test(url.protocol)) {
12
+ throw new Error(`Remote URL must use http or https: ${apiUrl}`);
13
+ }
14
+ return url.toString().replace(/\/$/, "");
15
+ }
16
+
17
+ export function defaultJwtPath(apiUrl: string): string {
18
+ const normalized = normalizeApiUrl(apiUrl);
19
+ if (normalized === DEFAULT_API_URL) return ".knowhow/.jwt";
20
+ const { hostname, port } = new URL(normalized);
21
+ let suffix: string;
22
+ if (hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.")) {
23
+ suffix = "local";
24
+ } else if (hostname === "api.dev.knowhow.tyvm.ai" || hostname.includes(".dev.")) {
25
+ suffix = "dev";
26
+ } else {
27
+ suffix = `${hostname}${port ? `-${port}` : ""}`
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9.-]+/g, "-")
30
+ .replace(/^-+|-+$/g, "");
31
+ }
32
+ return `.knowhow/.jwt.${suffix}`;
33
+ }
34
+
35
+ export function createRemote(name: string, apiUrl: string): KnowhowRemote {
36
+ if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
37
+ throw new Error("Remote names may only contain letters, numbers, '.', '_' and '-'.");
38
+ }
39
+ const normalizedUrl = normalizeApiUrl(apiUrl);
40
+ return { name, apiUrl: normalizedUrl, jwtPath: defaultJwtPath(normalizedUrl) };
41
+ }
42
+
43
+ export function getConfiguredRemotes(config: Config): Record<string, KnowhowRemote> {
44
+ return config.remotes ?? {};
45
+ }
46
+
47
+ export function findRemoteForApi(config: Config, apiUrl: string): KnowhowRemote | undefined {
48
+ const normalizedUrl = normalizeApiUrl(apiUrl);
49
+ return Object.values(getConfiguredRemotes(config)).find(
50
+ (remote) => normalizeApiUrl(remote.apiUrl) === normalizedUrl
51
+ );
52
+ }
53
+
54
+ export function findRemoteForApiSync(apiUrl: string): KnowhowRemote | undefined {
55
+ try {
56
+ const config = JSON.parse(
57
+ fs.readFileSync(path.join(process.cwd(), ".knowhow", "knowhow.json"), "utf8")
58
+ ) as Config;
59
+ return findRemoteForApi(config, apiUrl);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ export function getActiveRemote(config: Config): KnowhowRemote {
66
+ const envUrl = process.env.KNOWHOW_API_URL;
67
+ if (envUrl) {
68
+ return findRemoteForApi(config, envUrl) ?? createRemote(inferRemoteName(envUrl), envUrl);
69
+ }
70
+ const name = config.activeRemote ?? DEFAULT_REMOTE_NAME;
71
+ return config.remotes?.[name] ?? createRemote(DEFAULT_REMOTE_NAME, DEFAULT_API_URL);
72
+ }
73
+
74
+ export function getActiveRemoteSync(): KnowhowRemote {
75
+ try {
76
+ const config = JSON.parse(
77
+ fs.readFileSync(path.join(process.cwd(), ".knowhow", "knowhow.json"), "utf8")
78
+ ) as Config;
79
+ return getActiveRemote(config);
80
+ } catch {
81
+ const apiUrl = process.env.KNOWHOW_API_URL ?? DEFAULT_API_URL;
82
+ return createRemote(inferRemoteName(apiUrl), apiUrl);
83
+ }
84
+ }
85
+
86
+ export function registerRemote(config: Config, remote: KnowhowRemote): void {
87
+ const migratedRemotes: Record<string, KnowhowRemote> = {};
88
+ for (const [apiUrl, orgId] of Object.entries(config.orgIds ?? {})) {
89
+ const name = inferRemoteName(apiUrl);
90
+ migratedRemotes[name] = {
91
+ ...createRemote(name, apiUrl),
92
+ orgId,
93
+ };
94
+ }
95
+ config.remotes = { ...migratedRemotes, ...(config.remotes ?? {}), [remote.name]: remote };
96
+ delete config.orgIds;
97
+ }
98
+
99
+ export function inferRemoteName(apiUrl: string): string {
100
+ const normalized = normalizeApiUrl(apiUrl);
101
+ if (normalized === DEFAULT_API_URL) return DEFAULT_REMOTE_NAME;
102
+ const { hostname } = new URL(normalized);
103
+ if (hostname === "localhost" || hostname === "::1" || hostname.startsWith("127.")) return "local";
104
+ if (hostname === "api.dev.knowhow.tyvm.ai" || hostname.includes(".dev.")) return "dev";
105
+ return hostname.toLowerCase().replace(/[^a-z0-9.-]+/g, "-");
106
+ }
107
+
108
+ export function resolveJwtPath(jwtPath: string): string {
109
+ return path.resolve(process.cwd(), jwtPath);
110
+ }
@@ -5,8 +5,10 @@ import fs from "fs";
5
5
  import { getConfigSync } from "../config";
6
6
  import { exchangeAvailablePublicKeyJwt, storeJwt } from "../auth/keyAuth";
7
7
  import { keyPairExists } from "../auth/keyManager";
8
+ import { getOrgIdForApi } from "../auth/environmentAuth";
9
+ import { loadJwtFromDisk } from "../auth/jwtStore";
10
+ import { getActiveRemoteSync } from "../remotes";
8
11
  import { Message } from "../clients/types";
9
- import path from "path";
10
12
  import {
11
13
  CompletionOptions,
12
14
  CompletionResponse,
@@ -125,7 +127,7 @@ export interface GitCredentialResponse {
125
127
  expiresAt: string | null;
126
128
  }
127
129
 
128
- export function loadKnowhowJwt(): string {
130
+ export function loadKnowhowJwt(apiUrl: string = KNOWHOW_API_URL): string {
129
131
  // Check env var first — this is how cloud workers/CI runners get auth
130
132
  // (injected via KNOWHOW_JWT env var in the runner's .env file by GithubRunnerService).
131
133
  // This avoids depending on a potentially stale .knowhow/.jwt file from the snapshot.
@@ -133,17 +135,10 @@ export function loadKnowhowJwt(): string {
133
135
  return process.env.KNOWHOW_JWT;
134
136
  }
135
137
 
136
- const jwtFile = path.join(process.cwd(), ".knowhow", ".jwt");
137
- if (!fs.existsSync(jwtFile)) {
138
- return "";
139
- }
140
- const jwt = fs.readFileSync(jwtFile, "utf-8").trim();
141
-
142
- return jwt;
138
+ return loadJwtFromDisk(apiUrl);
143
139
  }
144
140
 
145
- export const KNOWHOW_API_URL =
146
- process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai";
141
+ export const KNOWHOW_API_URL = getActiveRemoteSync().apiUrl;
147
142
 
148
143
  @TraceAll()
149
144
  export class KnowhowSimpleClient {
@@ -159,7 +154,7 @@ export class KnowhowSimpleClient {
159
154
  // Explicit tokens (including KNOWHOW_JWT worker credentials) have no
160
155
  // corresponding local identity and must never trigger interactive renewal.
161
156
  this.canRefreshJwt = jwt === undefined && !process.env.KNOWHOW_JWT;
162
- this.jwt = jwt ?? loadKnowhowJwt();
157
+ this.jwt = jwt ?? loadKnowhowJwt(this.baseUrl);
163
158
  this.setJwt(this.jwt);
164
159
  if (this.canRefreshJwt) {
165
160
  Object.defineProperty(this.headers, HTTP_UNAUTHORIZED_HANDLER, {
@@ -175,7 +170,7 @@ export class KnowhowSimpleClient {
175
170
  * Reload the JWT from disk (useful after login refreshes the token).
176
171
  */
177
172
  refreshJwt() {
178
- const freshJwt = loadKnowhowJwt();
173
+ const freshJwt = loadKnowhowJwt(this.baseUrl);
179
174
  if (freshJwt) {
180
175
  this.setJwt(freshJwt);
181
176
  this.jwtValidated = false;
@@ -206,12 +201,13 @@ export class KnowhowSimpleClient {
206
201
  this.refreshPromise = (async () => {
207
202
  const config = getConfigSync();
208
203
  const identityPath = config.cliIdentityPath;
209
- if (!config.orgId || (!keyPairExists(identityPath) && !keyPairExists())) {
204
+ const orgId = getOrgIdForApi(config, this.baseUrl);
205
+ if (!orgId || (!keyPairExists(identityPath) && !keyPairExists())) {
210
206
  throw new Error("JWT expired and the CLI identity is unavailable. Please run `knowhow login` again.");
211
207
  }
212
208
  try {
213
- const freshJwt = await exchangeAvailablePublicKeyJwt(config.orgId, this.baseUrl, identityPath);
214
- storeJwt(freshJwt);
209
+ const freshJwt = await exchangeAvailablePublicKeyJwt(orgId, this.baseUrl, identityPath);
210
+ storeJwt(freshJwt, this.baseUrl);
215
211
  this.setJwt(freshJwt);
216
212
  this.jwtValidated = true;
217
213
  } catch (error) {
@@ -1,11 +1,14 @@
1
1
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import Anthropic from "@anthropic-ai/sdk";
4
+ import { spawn } from "child_process";
4
5
 
5
6
  import fs from "fs";
7
+ import path from "path";
6
8
  import { McpConfig } from "../types";
7
9
  import { Tool } from "../clients";
8
10
  import { getConfig } from "../config";
11
+ import { loadJwtFromDisk } from "../auth/jwtStore";
9
12
  import { TraceAll } from "../util/Trace";
10
13
  import { ToolsService } from "./Tools";
11
14
  import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -30,6 +33,84 @@ export const knowhowConfig = {
30
33
  export * from "./McpServer";
31
34
  export * from "./McpWebsocketTransport";
32
35
 
36
+ /*
37
+ * Returns true if the given URL string points to localhost / 127.x / ::1.
38
+ */
39
+ function isLocalhostUrl(url: string): boolean {
40
+ try {
41
+ const parsed = new URL(url);
42
+ const host = parsed.hostname;
43
+ return (
44
+ host === "localhost" ||
45
+ host === "127.0.0.1" ||
46
+ host === "::1" ||
47
+ /^127\./.test(host)
48
+ );
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Check if a URL is reachable by making a simple HTTP GET/HEAD request.
56
+ * Resolves true if we get any HTTP response (even 4xx), false on network error.
57
+ */
58
+ async function isUrlReachable(url: string, timeoutMs = 2000): Promise<boolean> {
59
+ return new Promise((resolve) => {
60
+ const parsed = new URL(url);
61
+ const lib = parsed.protocol === "https:" ? require("https") : require("http");
62
+ const req = lib.request(
63
+ { hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: parsed.pathname || "/", method: "GET", timeout: timeoutMs },
64
+ () => { resolve(true); req.destroy(); }
65
+ );
66
+ req.on("error", () => resolve(false));
67
+ req.on("timeout", () => { req.destroy(); resolve(false); });
68
+ req.end();
69
+ });
70
+ }
71
+
72
+ /**
73
+ * If an MCP config has both a localhost `url` and a `command`, this function
74
+ * ensures the server is running: checks reachability, and if not up, spawns
75
+ * the command and waits until the URL is reachable (or timeout expires).
76
+ */
77
+ async function ensureLocalServer(mcp: McpConfig): Promise<void> {
78
+ if (!mcp.url || !mcp.command || !isLocalhostUrl(mcp.url)) return;
79
+
80
+ const alreadyUp = await isUrlReachable(mcp.url);
81
+ if (alreadyUp) {
82
+ console.log(`MCP '${mcp.name}': server already running at ${mcp.url}`);
83
+ return;
84
+ }
85
+
86
+ console.log(`MCP '${mcp.name}': starting server with command: ${mcp.command} ${(mcp.args || []).join(" ")}`);
87
+
88
+ const child = spawn(mcp.command, mcp.args || [], {
89
+ detached: true,
90
+ stdio: "ignore",
91
+ env: mcp.env ? { ...process.env, ...mcp.env } : process.env,
92
+ });
93
+ child.unref(); // don't keep the knowhow process alive just for this child
94
+
95
+ // Wait up to 30s for the server to become reachable
96
+ const maxWait = 30_000;
97
+ const interval = 500;
98
+ const started = Date.now();
99
+
100
+ while (Date.now() - started < maxWait) {
101
+ await new Promise((r) => setTimeout(r, interval));
102
+ const up = await isUrlReachable(mcp.url!);
103
+ if (up) {
104
+ console.log(`MCP '${mcp.name}': server is up at ${mcp.url}`);
105
+ return;
106
+ }
107
+ }
108
+
109
+ throw new Error(
110
+ `MCP '${mcp.name}': timed out waiting for server to start at ${mcp.url} (command: ${mcp.command} ${(mcp.args || []).join(" ")})`
111
+ );
112
+ }
113
+
33
114
  /*
34
115
  *
35
116
  * McpService is a service that manages connections to multiple MCP servers.
@@ -53,7 +134,7 @@ export class McpService {
53
134
  }
54
135
 
55
136
  this.config = mcpServers;
56
- this.transports = mcpServers.map((mcp) => this.createTransport(mcp));
137
+ this.transports = await Promise.all(mcpServers.map((mcp) => this.createTransport(mcp)));
57
138
  this.clients = this.transports.map(() => new Client(knowhowMcpClient, knowhowConfig));
58
139
 
59
140
  return this.clients;
@@ -64,8 +145,9 @@ export class McpService {
64
145
  * Re-reads authorization_token_file every time so that token refreshes
65
146
  * are picked up without restarting the agent process.
66
147
  */
67
- createTransport(mcp: McpConfig): Transport {
68
- if (mcp.command) {
148
+ async createTransport(mcp: McpConfig): Promise<Transport> {
149
+ await ensureLocalServer(mcp);
150
+ if (mcp.command && !mcp.url) {
69
151
  const stdioParams: StdioServerParameters = {
70
152
  command: mcp.command,
71
153
  args: mcp.args,
@@ -79,8 +161,13 @@ export class McpService {
79
161
  if (mcp.url) {
80
162
  // Always re-read the token file so refreshed tokens are picked up
81
163
  if (mcp.authorization_token_file) {
82
- const token = fs.readFileSync(mcp.authorization_token_file, "utf-8");
83
- mcp.authorization_token = token.trim();
164
+ if (path.basename(mcp.authorization_token_file) === ".jwt") {
165
+ const apiUrl = process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai";
166
+ mcp.authorization_token = loadJwtFromDisk(apiUrl, mcp.authorization_token_file);
167
+ } else {
168
+ const token = fs.readFileSync(mcp.authorization_token_file, "utf-8");
169
+ mcp.authorization_token = token.trim();
170
+ }
84
171
  }
85
172
  const scheme = mcp.authorization_scheme === "basic" ? "Basic" : "Bearer";
86
173
  const transport = new StreamableHTTPClientTransport(new URL(mcp.url), {
@@ -216,7 +303,7 @@ export class McpService {
216
303
  // Always create a fresh transport + client before connecting.
217
304
  // This handles cases where a previous connection attempt failed and left
218
305
  // the transport in a started-but-not-connected state.
219
- this.transports[index] = this.createTransport(this.config[index]);
306
+ this.transports[index] = await this.createTransport(this.config[index]);
220
307
  this.clients[index] = new Client(knowhowMcpClient, knowhowConfig);
221
308
 
222
309
  const client = this.clients[index];
@@ -315,7 +402,7 @@ export class McpService {
315
402
  // Replace with a fresh transport + client so connectSingle() can reconnect
316
403
  // without hitting "StreamableHTTPClientTransport already started".
317
404
  // createTransport() re-reads authorization_token_file, picking up refreshed tokens.
318
- this.transports[index] = this.createTransport(this.config[index]);
405
+ this.transports[index] = await this.createTransport(this.config[index]);
319
406
  this.clients[index] = new Client(knowhowMcpClient, knowhowConfig);
320
407
 
321
408
  // Remove tools from cache
@@ -1,4 +1,3 @@
1
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
1
  import {
3
2
  JSONRPCMessage,
4
3
  JSONRPCMessageSchema,
@@ -9,6 +8,7 @@ import { WebSocket } from "ws";
9
8
  export class MCPWebSocketTransport implements Transport {
10
9
  protected _socket: WebSocket;
11
10
  onmessage?: (message: JSONRPCMessage) => void;
11
+ onrawmessage?: (message: unknown) => boolean | Promise<boolean>;
12
12
  onerror?: (error: Error) => void;
13
13
  onclose?: () => void;
14
14
 
@@ -27,15 +27,19 @@ export class MCPWebSocketTransport implements Transport {
27
27
  } else {
28
28
  parsed = JSON.parse(data.toString());
29
29
  }
30
- console.log("MCPW Message received", JSON.stringify(parsed));
31
- const message = JSONRPCMessageSchema.parse(parsed);
32
- // Process message asynchronously to avoid blocking the WebSocket
33
- // event loop while a long-running tool call is in progress.
34
- // Without this, all subsequent messages are queued until the
35
- // current tool call completes.
36
- setImmediate(() => {
37
- this.onmessage?.(message);
38
- });
30
+ Promise.resolve(this.onrawmessage?.(parsed) ?? false)
31
+ .then((consumed) => {
32
+ if (consumed) return;
33
+ const message = JSONRPCMessageSchema.parse(parsed);
34
+ // Process message asynchronously to avoid blocking the WebSocket
35
+ // event loop while a long-running tool call is in progress.
36
+ // Without this, all subsequent messages are queued until the
37
+ // current tool call completes.
38
+ setImmediate(() => {
39
+ this.onmessage?.(message);
40
+ });
41
+ })
42
+ .catch((error) => this.onerror?.(error as Error));
39
43
  } catch (error) {
40
44
  this.onerror?.(error as Error);
41
45
  }
@@ -53,7 +57,6 @@ export class MCPWebSocketTransport implements Transport {
53
57
  async send(message: JSONRPCMessage): Promise<void> {
54
58
  return new Promise((resolve, reject) => {
55
59
  const json = JSON.stringify(message);
56
- console.log("MCPWs sending", json);
57
60
 
58
61
  if (this._socket.readyState !== WebSocket.OPEN) {
59
62
  return reject(
package/src/types.ts CHANGED
@@ -70,11 +70,23 @@ export type EmbedSource = {
70
70
  minLength?: number;
71
71
  };
72
72
 
73
+ export type KnowhowRemote = {
74
+ name: string;
75
+ apiUrl: string;
76
+ jwtPath: string;
77
+ orgId?: string;
78
+ };
79
+
73
80
  export type Config = {
74
81
  openaiBaseUrl?: string;
75
82
  promptsDir: string;
76
83
  lintCommands?: { [fileExtension: string]: string };
84
+ /** Legacy organization field; migrated into the selected remote on login. */
77
85
  orgId?: string;
86
+ /** Legacy environment map retained for reading pre-remote configurations. */
87
+ orgIds?: Record<string, string>;
88
+ remotes?: Record<string, KnowhowRemote>;
89
+ activeRemote?: string;
78
90
  /** Private Ed25519 identity selected with `knowhow login --identity`. */
79
91
  cliIdentityPath?: string;
80
92
  syncRemote?: boolean;
@@ -0,0 +1,56 @@
1
+ import { EventEmitter } from "events";
2
+ import { WebSocket } from "ws";
3
+ import { MCPWebSocketTransport } from "../../src/services/McpWebsocketTransport";
4
+
5
+ class TestSocket extends EventEmitter {
6
+ readyState = WebSocket.OPEN;
7
+ // Simulate send() calling the callback immediately (required by MCPWebSocketTransport.send())
8
+ send = jest.fn((data: string, cb?: (err?: Error) => void) => { cb?.(); });
9
+ close = jest.fn();
10
+ }
11
+
12
+ const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
13
+
14
+ describe("MCPWebSocketTransport", () => {
15
+ it("delivers valid JSON-RPC frames to onmessage", async () => {
16
+ const socket = new TestSocket();
17
+ const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
18
+ const response = { jsonrpc: "2.0", id: 1, result: {} };
19
+ transport.onmessage = jest.fn();
20
+ transport.onerror = jest.fn();
21
+
22
+ await transport.start();
23
+ socket.emit("message", JSON.stringify(response));
24
+ await flush();
25
+ await flush();
26
+
27
+ expect(transport.onmessage).toHaveBeenCalledWith(response);
28
+ expect(transport.onerror).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("calls onerror for invalid JSON-RPC frames", async () => {
32
+ const socket = new TestSocket();
33
+ const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
34
+ const invalid = { notJsonRpc: true };
35
+ transport.onmessage = jest.fn();
36
+ transport.onerror = jest.fn();
37
+
38
+ await transport.start();
39
+ socket.emit("message", JSON.stringify(invalid));
40
+ await flush();
41
+
42
+ expect(transport.onmessage).not.toHaveBeenCalled();
43
+ expect(transport.onerror).toHaveBeenCalled();
44
+ });
45
+
46
+ it("sends serialized JSON over the socket", async () => {
47
+ const socket = new TestSocket();
48
+ const transport = new MCPWebSocketTransport(socket as unknown as WebSocket);
49
+ await transport.start();
50
+
51
+ const msg = { jsonrpc: "2.0" as const, method: "ping", id: 42 };
52
+ await transport.send(msg);
53
+
54
+ expect(socket.send).toHaveBeenCalledWith(JSON.stringify(msg), expect.any(Function));
55
+ });
56
+ });