pilotswarm 0.0.1 → 0.4.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 (149) hide show
  1. package/README.md +37 -1
  2. package/mcp/README.md +484 -0
  3. package/mcp/dist/bin/pilotswarm-mcp.d.ts +3 -0
  4. package/mcp/dist/bin/pilotswarm-mcp.d.ts.map +1 -0
  5. package/mcp/dist/bin/pilotswarm-mcp.js +367 -0
  6. package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -0
  7. package/mcp/dist/src/auth.d.ts +7 -0
  8. package/mcp/dist/src/auth.d.ts.map +1 -0
  9. package/mcp/dist/src/auth.js +99 -0
  10. package/mcp/dist/src/auth.js.map +1 -0
  11. package/mcp/dist/src/context.d.ts +48 -0
  12. package/mcp/dist/src/context.d.ts.map +1 -0
  13. package/mcp/dist/src/context.js +83 -0
  14. package/mcp/dist/src/context.js.map +1 -0
  15. package/mcp/dist/src/index.d.ts +4 -0
  16. package/mcp/dist/src/index.d.ts.map +1 -0
  17. package/mcp/dist/src/index.js +3 -0
  18. package/mcp/dist/src/index.js.map +1 -0
  19. package/mcp/dist/src/prompts/skills.d.ts +4 -0
  20. package/mcp/dist/src/prompts/skills.d.ts.map +1 -0
  21. package/mcp/dist/src/prompts/skills.js +11 -0
  22. package/mcp/dist/src/prompts/skills.js.map +1 -0
  23. package/mcp/dist/src/resources/agents.d.ts +4 -0
  24. package/mcp/dist/src/resources/agents.d.ts.map +1 -0
  25. package/mcp/dist/src/resources/agents.js +64 -0
  26. package/mcp/dist/src/resources/agents.js.map +1 -0
  27. package/mcp/dist/src/resources/facts.d.ts +4 -0
  28. package/mcp/dist/src/resources/facts.d.ts.map +1 -0
  29. package/mcp/dist/src/resources/facts.js +125 -0
  30. package/mcp/dist/src/resources/facts.js.map +1 -0
  31. package/mcp/dist/src/resources/models.d.ts +4 -0
  32. package/mcp/dist/src/resources/models.d.ts.map +1 -0
  33. package/mcp/dist/src/resources/models.js +43 -0
  34. package/mcp/dist/src/resources/models.js.map +1 -0
  35. package/mcp/dist/src/resources/sessions.d.ts +4 -0
  36. package/mcp/dist/src/resources/sessions.d.ts.map +1 -0
  37. package/mcp/dist/src/resources/sessions.js +190 -0
  38. package/mcp/dist/src/resources/sessions.js.map +1 -0
  39. package/mcp/dist/src/resources/subscriptions.d.ts +9 -0
  40. package/mcp/dist/src/resources/subscriptions.d.ts.map +1 -0
  41. package/mcp/dist/src/resources/subscriptions.js +157 -0
  42. package/mcp/dist/src/resources/subscriptions.js.map +1 -0
  43. package/mcp/dist/src/server.d.ts +4 -0
  44. package/mcp/dist/src/server.d.ts.map +1 -0
  45. package/mcp/dist/src/server.js +59 -0
  46. package/mcp/dist/src/server.js.map +1 -0
  47. package/mcp/dist/src/tools/agents.d.ts +4 -0
  48. package/mcp/dist/src/tools/agents.d.ts.map +1 -0
  49. package/mcp/dist/src/tools/agents.js +317 -0
  50. package/mcp/dist/src/tools/agents.js.map +1 -0
  51. package/mcp/dist/src/tools/facts.d.ts +4 -0
  52. package/mcp/dist/src/tools/facts.d.ts.map +1 -0
  53. package/mcp/dist/src/tools/facts.js +151 -0
  54. package/mcp/dist/src/tools/facts.js.map +1 -0
  55. package/mcp/dist/src/tools/models.d.ts +4 -0
  56. package/mcp/dist/src/tools/models.d.ts.map +1 -0
  57. package/mcp/dist/src/tools/models.js +256 -0
  58. package/mcp/dist/src/tools/models.js.map +1 -0
  59. package/mcp/dist/src/tools/sessions.d.ts +4 -0
  60. package/mcp/dist/src/tools/sessions.d.ts.map +1 -0
  61. package/mcp/dist/src/tools/sessions.js +606 -0
  62. package/mcp/dist/src/tools/sessions.js.map +1 -0
  63. package/mcp/dist/src/util/command.d.ts +52 -0
  64. package/mcp/dist/src/util/command.d.ts.map +1 -0
  65. package/mcp/dist/src/util/command.js +78 -0
  66. package/mcp/dist/src/util/command.js.map +1 -0
  67. package/package.json +81 -6
  68. package/tui/README.md +35 -0
  69. package/tui/bin/tui.js +30 -0
  70. package/tui/plugins/.mcp.json +7 -0
  71. package/tui/plugins/plugin.json +13 -0
  72. package/tui/plugins/session-policy.json +8 -0
  73. package/tui/src/app.js +850 -0
  74. package/tui/src/auth/cli.js +111 -0
  75. package/tui/src/auth/entra-auth.js +218 -0
  76. package/tui/src/bootstrap-env.js +176 -0
  77. package/tui/src/embedded-workers.js +79 -0
  78. package/tui/src/http-transport-host.js +106 -0
  79. package/tui/src/index.js +340 -0
  80. package/tui/src/node-sdk-transport.js +1793 -0
  81. package/tui/src/platform.js +984 -0
  82. package/tui/src/plugin-config.js +239 -0
  83. package/tui/src/portal.js +7 -0
  84. package/tui/src/version.js +7 -0
  85. package/tui/tui-splash.txt +11 -0
  86. package/ui/core/README.md +6 -0
  87. package/ui/core/src/commands.js +93 -0
  88. package/ui/core/src/context-usage.js +212 -0
  89. package/ui/core/src/controller.js +6104 -0
  90. package/ui/core/src/formatting.js +1036 -0
  91. package/ui/core/src/history.js +932 -0
  92. package/ui/core/src/index.js +13 -0
  93. package/ui/core/src/layout.js +332 -0
  94. package/ui/core/src/reducer.js +1935 -0
  95. package/ui/core/src/selectors.js +5409 -0
  96. package/ui/core/src/session-errors.js +14 -0
  97. package/ui/core/src/session-tree.js +151 -0
  98. package/ui/core/src/state.js +248 -0
  99. package/ui/core/src/store.js +23 -0
  100. package/ui/core/src/system-titles.js +24 -0
  101. package/ui/core/src/themes/catppuccin-mocha.js +56 -0
  102. package/ui/core/src/themes/cobalt2.js +56 -0
  103. package/ui/core/src/themes/dark-high-contrast.js +56 -0
  104. package/ui/core/src/themes/daylight.js +62 -0
  105. package/ui/core/src/themes/dracula.js +56 -0
  106. package/ui/core/src/themes/github-dark.js +56 -0
  107. package/ui/core/src/themes/github-light.js +59 -0
  108. package/ui/core/src/themes/gruvbox-dark.js +56 -0
  109. package/ui/core/src/themes/hacker-x-matrix.js +56 -0
  110. package/ui/core/src/themes/hacker-x-orion-prime.js +56 -0
  111. package/ui/core/src/themes/helpers.js +79 -0
  112. package/ui/core/src/themes/high-contrast-mono.js +59 -0
  113. package/ui/core/src/themes/index.js +52 -0
  114. package/ui/core/src/themes/light-high-contrast.js +62 -0
  115. package/ui/core/src/themes/noctis-obscuro.js +56 -0
  116. package/ui/core/src/themes/noctis.js +56 -0
  117. package/ui/core/src/themes/paper-ink.js +62 -0
  118. package/ui/core/src/themes/solarized-ops.js +59 -0
  119. package/ui/core/src/themes/terminal-green.js +59 -0
  120. package/ui/core/src/themes/tokyo-night.js +56 -0
  121. package/ui/react/README.md +5 -0
  122. package/ui/react/src/chat-status.js +39 -0
  123. package/ui/react/src/components.js +1989 -0
  124. package/ui/react/src/index.js +4 -0
  125. package/ui/react/src/platform.js +15 -0
  126. package/ui/react/src/use-controller-state.js +38 -0
  127. package/ui/react/src/web-app.js +4390 -0
  128. package/web/README.md +198 -0
  129. package/web/api/router.js +196 -0
  130. package/web/api/ws.js +152 -0
  131. package/web/auth/authz/engine.js +204 -0
  132. package/web/auth/config.js +115 -0
  133. package/web/auth/index.js +175 -0
  134. package/web/auth/normalize/entra.js +22 -0
  135. package/web/auth/providers/entra.js +76 -0
  136. package/web/auth/providers/none.js +24 -0
  137. package/web/auth.js +10 -0
  138. package/web/bin/serve.js +53 -0
  139. package/web/config.js +20 -0
  140. package/web/dist/app.js +469 -0
  141. package/web/dist/assets/index-CBgQQk-j.css +1 -0
  142. package/web/dist/assets/index-DMefB7Wb.js +24 -0
  143. package/web/dist/assets/msal-CytV9RFv.js +7 -0
  144. package/web/dist/assets/pilotswarm-DyBlqVTY.js +87 -0
  145. package/web/dist/assets/react-BZwsW8pe.js +1 -0
  146. package/web/dist/index.html +16 -0
  147. package/web/runtime.js +454 -0
  148. package/web/server.js +276 -0
  149. package/index.js +0 -1
@@ -0,0 +1,111 @@
1
+ import { parseArgs } from "node:util";
2
+ import {
3
+ createTokenProvider,
4
+ fetchAuthConfig,
5
+ getSignedInAccount,
6
+ signIn,
7
+ signOut,
8
+ } from "./entra-auth.js";
9
+
10
+ /**
11
+ * `pilotswarm auth login|status|logout --api-url <url>`
12
+ *
13
+ * Pre-provision or inspect the per-origin token cache used by the TUI's
14
+ * API mode. The TUI triggers the same sign-in lazily on start, so
15
+ * `auth login` is optional.
16
+ */
17
+
18
+ function resolveApiUrl(flags) {
19
+ const apiUrl = String(flags["api-url"] || process.env.PILOTSWARM_API_URL || "").trim();
20
+ if (!apiUrl) {
21
+ throw new Error("An API URL is required: pass --api-url <url> or set PILOTSWARM_API_URL.");
22
+ }
23
+ return apiUrl.replace(/\/+$/, "");
24
+ }
25
+
26
+ /**
27
+ * Resolve the deployment's auth mode and, for Entra, run sign-in (silent
28
+ * first, then device code) and return a token supplier. Used by both the
29
+ * auth subcommands and TUI startup — runs before the Ink app renders.
30
+ */
31
+ export async function bootstrapApiAuth(apiUrl, { output = console.error, interactive = true, useDeviceCode = false } = {}) {
32
+ const authConfig = await fetchAuthConfig(apiUrl);
33
+ if (!authConfig?.enabled || authConfig?.provider === "none") {
34
+ return { authConfig, getAccessToken: async () => null };
35
+ }
36
+ if (authConfig.provider !== "entra") {
37
+ throw new Error(`Unsupported auth provider '${authConfig.provider}' reported by ${apiUrl}.`);
38
+ }
39
+ if (interactive) {
40
+ await signIn(apiUrl, authConfig, { output, useDeviceCode });
41
+ }
42
+ const getAccessToken = await createTokenProvider(apiUrl, authConfig);
43
+ return { authConfig, getAccessToken };
44
+ }
45
+
46
+ export async function runAuthCommand(argv) {
47
+ const { values: flags, positionals } = parseArgs({
48
+ options: {
49
+ "api-url": { type: "string" },
50
+ "device-code": { type: "boolean" },
51
+ env: { type: "string", short: "e" },
52
+ help: { type: "boolean", short: "h" },
53
+ },
54
+ allowPositionals: true,
55
+ strict: false,
56
+ args: argv,
57
+ });
58
+
59
+ const action = positionals[0];
60
+ if (flags.help || !["login", "status", "logout"].includes(action)) {
61
+ console.log(`
62
+ pilotswarm auth — manage Web API sign-in
63
+
64
+ USAGE
65
+ pilotswarm auth login --api-url <url> Sign in (opens your browser) and cache tokens
66
+ pilotswarm auth status --api-url <url> Show the signed-in account
67
+ pilotswarm auth logout --api-url <url> Drop cached tokens for that origin
68
+
69
+ FLAGS
70
+ --device-code Use the device-code flow instead of the browser
71
+ (headless hosts; only where the tenant permits it —
72
+ many corp tenants block device code via Conditional Access)
73
+
74
+ PILOTSWARM_API_URL can replace --api-url.
75
+ `.trim());
76
+ return flags.help ? 0 : 1;
77
+ }
78
+
79
+ const apiUrl = resolveApiUrl(flags);
80
+
81
+ if (action === "logout") {
82
+ const cacheFile = await signOut(apiUrl);
83
+ console.log(`Signed out of ${apiUrl} (removed ${cacheFile}).`);
84
+ return 0;
85
+ }
86
+
87
+ const authConfig = await fetchAuthConfig(apiUrl);
88
+ if (!authConfig?.enabled || authConfig?.provider === "none") {
89
+ console.log(`${apiUrl} does not require sign-in (auth provider: ${authConfig?.provider || "none"}).`);
90
+ return 0;
91
+ }
92
+
93
+ if (action === "login") {
94
+ const result = await signIn(apiUrl, authConfig, { output: console.log, useDeviceCode: Boolean(flags["device-code"]) });
95
+ const username = result?.account?.username || result?.account?.name || "signed in";
96
+ console.log(`Signed in to ${apiUrl} as ${username}.`);
97
+ return 0;
98
+ }
99
+
100
+ // status
101
+ const account = await getSignedInAccount(apiUrl, authConfig);
102
+ if (!account) {
103
+ console.log(`Not signed in to ${apiUrl}. Run: pilotswarm auth login --api-url ${apiUrl}`);
104
+ return 1;
105
+ }
106
+ console.log(`Signed in to ${apiUrl} as ${account.username || account.name}.`);
107
+ const getAccessToken = await createTokenProvider(apiUrl, authConfig);
108
+ const token = await getAccessToken();
109
+ console.log(token ? "Token cache is valid (silent acquisition succeeded)." : "Token cache is stale — run: pilotswarm auth login");
110
+ return token ? 0 : 1;
111
+ }
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { ApiClient } from "pilotswarm-sdk/api";
5
+ import { openUrlInDefaultBrowser } from "../http-transport-host.js";
6
+
7
+ /**
8
+ * Entra ID sign-in for the TUI's API mode.
9
+ *
10
+ * Default flow: OAuth 2.0 authorization code + PKCE via msal-node's
11
+ * `acquireTokenInteractive`. It opens the system browser to a normal Entra
12
+ * sign-in and captures the code on a loopback (`http://localhost:<port>`)
13
+ * redirect — the same interactive flow `az login`, the GitHub CLI, and the
14
+ * browser portal use. Crucially, it satisfies Conditional Access policies
15
+ * that block the device-code flow (common in hardened corporate tenants;
16
+ * Microsoft's own tenant returns AADSTS53003 for device code).
17
+ *
18
+ * Fallback flow: device code (`useDeviceCode`) for headless hosts with no
19
+ * browser — only usable where the tenant permits it.
20
+ *
21
+ * The TUI never carries tenant/client ids in local config — they are
22
+ * discovered from the deployment's public `GET /api/v1/auth/config`, so
23
+ * app-registration rotation never breaks clients. Tokens are cached per API
24
+ * origin at `~/.config/pilotswarm/auth/<origin>.json` (0600). MSAL owns
25
+ * refresh; `getAccessToken` runs acquireTokenSilent per call.
26
+ */
27
+
28
+ const AUTH_CACHE_DIR = path.join(
29
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
30
+ "pilotswarm",
31
+ "auth",
32
+ );
33
+
34
+ const BROWSER_RESULT_HTML = (heading, body) => `<!doctype html><html><head><meta charset="utf-8">`
35
+ + `<title>PilotSwarm</title><style>body{font-family:-apple-system,Segoe UI,sans-serif;`
36
+ + `background:#0d1117;color:#e5edf5;display:flex;align-items:center;justify-content:center;`
37
+ + `height:100vh;margin:0}.card{text-align:center;max-width:28rem;padding:2rem}`
38
+ + `h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#8b949e}</style></head>`
39
+ + `<body><div class="card"><h1>${heading}</h1><p>${body}</p></div></body></html>`;
40
+
41
+ const SUCCESS_TEMPLATE = BROWSER_RESULT_HTML("Signed in to PilotSwarm", "You can close this tab and return to the terminal.");
42
+ const ERROR_TEMPLATE = BROWSER_RESULT_HTML("Sign-in failed", "Return to the terminal for details.");
43
+
44
+ function cacheFileForOrigin(apiUrl) {
45
+ const origin = new URL(apiUrl).origin.replace(/[^\w.-]+/g, "_");
46
+ return path.join(AUTH_CACHE_DIR, `${origin}.json`);
47
+ }
48
+
49
+ function createCachePlugin(cacheFile) {
50
+ return {
51
+ beforeCacheAccess: async (cacheContext) => {
52
+ try {
53
+ cacheContext.tokenCache.deserialize(await fs.promises.readFile(cacheFile, "utf8"));
54
+ } catch {}
55
+ },
56
+ afterCacheAccess: async (cacheContext) => {
57
+ if (!cacheContext.cacheHasChanged) return;
58
+ await fs.promises.mkdir(AUTH_CACHE_DIR, { recursive: true, mode: 0o700 });
59
+ await fs.promises.writeFile(cacheFile, cacheContext.tokenCache.serialize(), { mode: 0o600 });
60
+ },
61
+ };
62
+ }
63
+
64
+ export async function fetchAuthConfig(apiUrl) {
65
+ const api = new ApiClient({ apiUrl });
66
+ return api.getAuthConfig();
67
+ }
68
+
69
+ function requireEntraClientConfig(authConfig, apiUrl) {
70
+ const client = authConfig?.client;
71
+ if (!client?.clientId || !client?.authority) {
72
+ throw new Error(
73
+ `The deployment at ${apiUrl} reports Entra auth but no public client configuration. `
74
+ + "Check PORTAL_AUTH_ENTRA_TENANT_ID / PORTAL_AUTH_ENTRA_CLIENT_ID on the server.",
75
+ );
76
+ }
77
+ return client;
78
+ }
79
+
80
+ async function createPublicClientApp(authConfig, apiUrl) {
81
+ const client = requireEntraClientConfig(authConfig, apiUrl);
82
+ const { PublicClientApplication } = await import("@azure/msal-node");
83
+ const app = new PublicClientApplication({
84
+ auth: {
85
+ clientId: client.clientId,
86
+ authority: client.authority,
87
+ },
88
+ cache: {
89
+ cachePlugin: createCachePlugin(cacheFileForOrigin(apiUrl)),
90
+ },
91
+ });
92
+ return { app, scopes: [`${client.clientId}/.default`] };
93
+ }
94
+
95
+ async function firstCachedAccount(app) {
96
+ const accounts = await app.getTokenCache().getAllAccounts();
97
+ return accounts[0] || null;
98
+ }
99
+
100
+ // MSAL's loopback listener never times out on its own; bound the wait so a
101
+ // browser that never opens (headless/SSH, or a failed launch) surfaces an
102
+ // actionable error instead of hanging TUI startup forever. Generous enough
103
+ // for interactive MFA.
104
+ const INTERACTIVE_TIMEOUT_MS = 5 * 60 * 1000;
105
+
106
+ async function acquireInteractive(app, scopes, apiUrl, output) {
107
+ output("");
108
+ output(` Opening your browser to sign in to ${apiUrl} …`);
109
+ output("");
110
+ const request = {
111
+ scopes,
112
+ successTemplate: SUCCESS_TEMPLATE,
113
+ errorTemplate: ERROR_TEMPLATE,
114
+ openBrowser: async (url) => {
115
+ // Print the URL too so the user can copy it if the browser does
116
+ // not open on its own (SSH, restricted shells).
117
+ output(` If your browser does not open, visit:\n ${url}`);
118
+ output("");
119
+ try {
120
+ await openUrlInDefaultBrowser(url);
121
+ } catch {
122
+ output(" (Could not launch a browser automatically — paste the URL above, or");
123
+ output(" press Ctrl-C and re-run with --device-code on a headless host.)");
124
+ output("");
125
+ }
126
+ },
127
+ };
128
+
129
+ let timer;
130
+ const timeout = new Promise((_, reject) => {
131
+ timer = setTimeout(() => {
132
+ reject(new Error(
133
+ "Browser sign-in timed out. If your browser did not open, paste the printed URL "
134
+ + "into a browser on this machine, or re-run with --device-code.",
135
+ ));
136
+ }, INTERACTIVE_TIMEOUT_MS);
137
+ timer.unref?.();
138
+ });
139
+
140
+ let result;
141
+ try {
142
+ result = await Promise.race([app.acquireTokenInteractive(request), timeout]);
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ if (!result?.accessToken) {
147
+ throw new Error("Entra sign-in did not produce an access token.");
148
+ }
149
+ return result;
150
+ }
151
+
152
+ async function acquireDeviceCode(app, scopes, apiUrl, output) {
153
+ const result = await app.acquireTokenByDeviceCode({
154
+ scopes,
155
+ deviceCodeCallback: (deviceCode) => {
156
+ output("");
157
+ output(` Sign in to ${apiUrl}`);
158
+ output(` Visit ${deviceCode.verificationUri} and enter code ${deviceCode.userCode}`);
159
+ output("");
160
+ openUrlInDefaultBrowser(deviceCode.verificationUri).catch(() => {});
161
+ },
162
+ });
163
+ if (!result?.accessToken) {
164
+ throw new Error("Entra sign-in did not produce an access token.");
165
+ }
166
+ return result;
167
+ }
168
+
169
+ /**
170
+ * Interactive sign-in: silent first, then the browser (auth-code+PKCE) flow —
171
+ * or the device-code flow when `useDeviceCode` is set.
172
+ */
173
+ export async function signIn(apiUrl, authConfig, { output = console.error, useDeviceCode = false } = {}) {
174
+ const { app, scopes } = await createPublicClientApp(authConfig, apiUrl);
175
+
176
+ const account = await firstCachedAccount(app);
177
+ if (account) {
178
+ try {
179
+ const silent = await app.acquireTokenSilent({ account, scopes });
180
+ if (silent?.accessToken) return silent;
181
+ } catch {}
182
+ }
183
+
184
+ return useDeviceCode
185
+ ? acquireDeviceCode(app, scopes, apiUrl, output)
186
+ : acquireInteractive(app, scopes, apiUrl, output);
187
+ }
188
+
189
+ /**
190
+ * Build the `getAccessToken` callback for an Entra deployment. Runs the
191
+ * silent flow per call (MSAL caches in memory); if silent acquisition fails
192
+ * the caller sees null and the server's 401 tells the user to run
193
+ * `pilotswarm auth login`.
194
+ */
195
+ export async function createTokenProvider(apiUrl, authConfig) {
196
+ const { app, scopes } = await createPublicClientApp(authConfig, apiUrl);
197
+ return async () => {
198
+ const account = await firstCachedAccount(app);
199
+ if (!account) return null;
200
+ try {
201
+ const result = await app.acquireTokenSilent({ account, scopes });
202
+ return result?.accessToken || null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ };
207
+ }
208
+
209
+ export async function getSignedInAccount(apiUrl, authConfig) {
210
+ const { app } = await createPublicClientApp(authConfig, apiUrl);
211
+ return firstCachedAccount(app);
212
+ }
213
+
214
+ export async function signOut(apiUrl) {
215
+ const cacheFile = cacheFileForOrigin(apiUrl);
216
+ await fs.promises.rm(cacheFile, { force: true });
217
+ return cacheFile;
218
+ }
@@ -0,0 +1,176 @@
1
+ import { parseArgs } from "node:util";
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { resolveTuiBranding } from "./plugin-config.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const pkgRoot = path.resolve(__dirname, "..");
10
+
11
+ function resolvePluginDir(flags) {
12
+ if (flags.plugin) return path.resolve(flags.plugin);
13
+ if (process.env.PLUGIN_DIRS) {
14
+ const dirs = process.env.PLUGIN_DIRS.split(",").map((value) => value.trim()).filter(Boolean);
15
+ return dirs[0] || null;
16
+ }
17
+ const cwdPlugin = path.resolve("plugins");
18
+ if (fs.existsSync(cwdPlugin)) return cwdPlugin;
19
+ const bundledPlugin = path.join(pkgRoot, "plugins");
20
+ if (fs.existsSync(bundledPlugin)) return bundledPlugin;
21
+ return null;
22
+ }
23
+
24
+ function resolveSystemMessage(flags) {
25
+ if (flags.system) {
26
+ if (fs.existsSync(flags.system)) {
27
+ return fs.readFileSync(flags.system, "utf-8").trim();
28
+ }
29
+ return flags.system;
30
+ }
31
+
32
+ const pluginDir = resolvePluginDir(flags);
33
+ if (pluginDir) {
34
+ const systemMd = path.join(pluginDir, "system.md");
35
+ if (fs.existsSync(systemMd)) {
36
+ return fs.readFileSync(systemMd, "utf-8").trim();
37
+ }
38
+ }
39
+
40
+ if (process.env.SYSTEM_MESSAGE) return process.env.SYSTEM_MESSAGE;
41
+ return undefined;
42
+ }
43
+
44
+ function loadEnvFile(envFile) {
45
+ if (!fs.existsSync(envFile)) return;
46
+ const envContent = fs.readFileSync(envFile, "utf-8");
47
+ for (const line of envContent.split("\n")) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || trimmed.startsWith("#")) continue;
50
+ const eqIdx = trimmed.indexOf("=");
51
+ if (eqIdx === -1) continue;
52
+ const key = trimmed.slice(0, eqIdx).trim();
53
+ const value = trimmed.slice(eqIdx + 1).trim();
54
+ if (!process.env[key]) {
55
+ process.env[key] = value;
56
+ }
57
+ }
58
+ }
59
+
60
+ function ensureGithubToken() {
61
+ if (process.env.GITHUB_TOKEN) return;
62
+ try {
63
+ const token = execFileSync("gh", ["auth", "token"], {
64
+ encoding: "utf-8",
65
+ stdio: ["ignore", "pipe", "ignore"],
66
+ }).trim();
67
+ if (token) {
68
+ process.env.GITHUB_TOKEN = token;
69
+ }
70
+ } catch {}
71
+ }
72
+
73
+ export function parseCliIntoEnv(argv) {
74
+ const { values: flags, positionals } = parseArgs({
75
+ options: {
76
+ store: { type: "string", short: "s" },
77
+ "api-url": { type: "string" },
78
+ "device-code": { type: "boolean" },
79
+ env: { type: "string", short: "e" },
80
+ plugin: { type: "string", short: "p" },
81
+ worker: { type: "string", short: "w" },
82
+ workers: { type: "string", short: "n" },
83
+ model: { type: "string", short: "m" },
84
+ system: { type: "string" },
85
+ context: { type: "string", short: "c" },
86
+ namespace: { type: "string" },
87
+ label: { type: "string" },
88
+ help: { type: "boolean", short: "h" },
89
+ },
90
+ allowPositionals: true,
91
+ strict: false,
92
+ args: argv,
93
+ });
94
+
95
+ if (flags.help) {
96
+ console.log(`
97
+ pilotswarm — PilotSwarm terminal UI
98
+
99
+ USAGE
100
+ npx pilotswarm [local|remote] [flags]
101
+ npx pilotswarm auth login|status|logout --api-url <url>
102
+
103
+ MODES
104
+ local Run everything locally (embedded workers)
105
+ remote Connect to a deployment's Web API (--api-url; the
106
+ supported remote mode) or directly to its database
107
+ (--store; internal/operator use)
108
+
109
+ FLAGS
110
+ --api-url <url> Web API base URL for remote mode (or PILOTSWARM_API_URL)
111
+ --device-code Entra sign-in via device code instead of the browser
112
+ (headless; only where the tenant allows it)
113
+ -e, --env <file> Env file
114
+ -p, --plugin <dir> Plugin directory
115
+ -w, --worker <module> Worker tools module (local mode)
116
+ -n, --workers <count> Embedded worker count (local mode)
117
+ -m, --model <name> Initial model
118
+ -c, --context <ctx> K8s context (direct remote mode only)
119
+ --namespace <ns> K8s namespace (direct remote mode only)
120
+ --label <selector> K8s pod label (direct remote mode only)
121
+ -h, --help Show help
122
+ `.trim());
123
+ process.exit(0);
124
+ }
125
+
126
+ const mode = positionals[0] === "remote" ? "remote" : "local";
127
+ const envFile = flags.env || (mode === "remote" ? ".env.remote" : ".env");
128
+ loadEnvFile(envFile);
129
+ ensureGithubToken();
130
+
131
+ const apiUrl = String(flags["api-url"] || process.env.PILOTSWARM_API_URL || "").trim().replace(/\/+$/, "");
132
+ if (apiUrl && mode !== "remote") {
133
+ throw new Error("--api-url is only valid in remote mode: npx pilotswarm remote --api-url <url>");
134
+ }
135
+ if (apiUrl && flags.store) {
136
+ throw new Error("Pass either --api-url (Web API remote mode) or --store (direct remote mode), not both.");
137
+ }
138
+ if (apiUrl && (flags.context || flags.namespace || flags.label)) {
139
+ console.warn("[pilotswarm] K8s log-tail flags are ignored in API mode — the server tails logs in-cluster.");
140
+ }
141
+
142
+ process.env.DATABASE_URL = flags.store || process.env.DATABASE_URL || "sqlite::memory:";
143
+ process.env.WORKERS = mode === "remote" ? "0" : (flags.workers ?? process.env.WORKERS ?? "4");
144
+ process.env.COPILOT_MODEL = flags.model || process.env.COPILOT_MODEL || "";
145
+ process.env.K8S_CONTEXT = flags.context || process.env.K8S_CONTEXT || "";
146
+ process.env.K8S_NAMESPACE = flags.namespace || process.env.K8S_NAMESPACE || "copilot-runtime";
147
+ process.env.K8S_POD_LABEL = flags.label || process.env.K8S_POD_LABEL || "app.kubernetes.io/component=worker";
148
+
149
+ const pluginDir = resolvePluginDir(flags);
150
+ if (pluginDir) {
151
+ process.env.PLUGIN_DIRS = pluginDir;
152
+ }
153
+
154
+ const branding = resolveTuiBranding(pluginDir);
155
+ process.env._TUI_TITLE = branding.title;
156
+ process.env._TUI_SPLASH = branding.splash;
157
+
158
+ const systemMessage = resolveSystemMessage(flags);
159
+ if (systemMessage) process.env._TUI_SYSTEM_MESSAGE = systemMessage;
160
+
161
+ if (mode === "local" && flags.worker) {
162
+ const resolvedWorker = path.resolve(flags.worker);
163
+ if (!fs.existsSync(resolvedWorker)) {
164
+ throw new Error(`Worker module not found: ${resolvedWorker}`);
165
+ }
166
+ process.env._TUI_WORKER_MODULE = resolvedWorker;
167
+ }
168
+
169
+ return {
170
+ mode,
171
+ store: process.env.DATABASE_URL,
172
+ apiUrl: apiUrl || null,
173
+ deviceCode: Boolean(flags["device-code"]),
174
+ branding,
175
+ };
176
+ }
@@ -0,0 +1,79 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import { createRequire } from "node:module";
4
+ import { PilotSwarmWorker, horizonConfigFromEnv } from "pilotswarm-sdk";
5
+ import { getPluginDirsFromEnv } from "./plugin-config.js";
6
+
7
+ export async function startEmbeddedWorkers({ count, store }) {
8
+ const workers = [];
9
+ if (!count || count <= 0) return workers;
10
+
11
+ const pluginDirs = getPluginDirsFromEnv();
12
+
13
+ let workerModuleConfig = {};
14
+ if (process.env._TUI_WORKER_MODULE) {
15
+ const imported = await import(process.env._TUI_WORKER_MODULE);
16
+ workerModuleConfig = imported.default || imported;
17
+ }
18
+
19
+ const sessionStateDir = process.env.SESSION_STATE_DIR || path.join(os.homedir(), ".copilot", "session-state");
20
+ const originalConsole = {
21
+ log: console.log,
22
+ warn: console.warn,
23
+ error: console.error,
24
+ };
25
+
26
+ for (let index = 0; index < count; index++) {
27
+ console.log = () => {};
28
+ console.warn = () => {};
29
+ console.error = () => {};
30
+ try {
31
+ const worker = new PilotSwarmWorker({
32
+ store,
33
+ githubToken: process.env.GITHUB_TOKEN,
34
+ logLevel: process.env.LOG_LEVEL || "error",
35
+ sessionStateDir,
36
+ workerNodeId: `local-${index}`,
37
+ systemMessage: workerModuleConfig.systemMessage || process.env._TUI_SYSTEM_MESSAGE || undefined,
38
+ pluginDirs,
39
+ // Bicep-deploy MI flow (set in worker-env ConfigMap by the
40
+ // overlay .env). Unset for legacy `scripts/deploy-aks.sh`,
41
+ // local Docker, and CI — those keep using the password
42
+ // URL via `store` (and AZURE_STORAGE_CONNECTION_STRING for
43
+ // blobs). See packages/sdk/src/pg-pool-factory.ts.
44
+ useManagedIdentity: ["1", "true", "yes", "on"].includes(
45
+ (process.env.PILOTSWARM_USE_MANAGED_IDENTITY || "").trim().toLowerCase(),
46
+ ),
47
+ cmsFactsDatabaseUrl: process.env.PILOTSWARM_CMS_FACTS_DATABASE_URL || undefined,
48
+ aadDbUser: process.env.PILOTSWARM_DB_AAD_USER || undefined,
49
+ blobAccountUrl: process.env.AZURE_STORAGE_ACCOUNT_URL || undefined,
50
+ blobConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING || undefined,
51
+ blobContainer: process.env.AZURE_STORAGE_CONTAINER || undefined,
52
+ // Optional EnhancedFactStore + knowledge graph (HorizonDB). Empty
53
+ // unless HORIZON_DATABASE_URL is set, so default deployments are
54
+ // unaffected (plain PgFactStore).
55
+ ...horizonConfigFromEnv(),
56
+ });
57
+
58
+ const workerTools = typeof workerModuleConfig.createTools === "function"
59
+ ? await workerModuleConfig.createTools({ workerNodeId: `local-${index}`, workerIndex: index })
60
+ : workerModuleConfig.tools;
61
+ if (workerTools?.length) {
62
+ worker.registerTools(workerTools);
63
+ }
64
+
65
+ await worker.start();
66
+ workers.push(worker);
67
+ } finally {
68
+ console.log = originalConsole.log;
69
+ console.warn = originalConsole.warn;
70
+ console.error = originalConsole.error;
71
+ }
72
+ }
73
+
74
+ return workers;
75
+ }
76
+
77
+ export async function stopEmbeddedWorkers(workers) {
78
+ await Promise.allSettled((workers || []).map((worker) => worker.stop()));
79
+ }
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { HttpApiTransport } from "pilotswarm-sdk/api";
6
+
7
+ /**
8
+ * TUI host for the Web API transport: HttpApiTransport plus the local-machine
9
+ * conveniences the shared UI expects from a Node environment (artifact export
10
+ * to disk, open-in-default-app/browser). All data flows through the API —
11
+ * this file only touches the local filesystem and OS openers.
12
+ */
13
+
14
+ function expandUserPath(filePath) {
15
+ const raw = String(filePath || "");
16
+ if (raw === "~") return os.homedir();
17
+ if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
18
+ return raw;
19
+ }
20
+
21
+ const EXPORTS_DIR = path.resolve(
22
+ expandUserPath(process.env.PILOTSWARM_EXPORT_DIR || path.join(os.homedir(), "pilotswarm-exports")),
23
+ );
24
+
25
+ function spawnDetached(command, args) {
26
+ return new Promise((resolve, reject) => {
27
+ try {
28
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
29
+ child.once("error", reject);
30
+ child.unref();
31
+ resolve();
32
+ } catch (error) {
33
+ reject(error);
34
+ }
35
+ });
36
+ }
37
+
38
+ async function openWithPlatformOpener(target) {
39
+ if (process.platform === "darwin") {
40
+ await spawnDetached("open", [target]);
41
+ } else if (process.platform === "win32") {
42
+ await spawnDetached("cmd", ["/c", "start", "", target]);
43
+ } else {
44
+ await spawnDetached("xdg-open", [target]);
45
+ }
46
+ }
47
+
48
+ async function saveArtifactDownload(transport, sessionId, filename) {
49
+ const response = await transport.api.downloadArtifactResponse(sessionId, filename);
50
+ const body = Buffer.from(await response.arrayBuffer());
51
+ const sessionDir = path.join(EXPORTS_DIR, String(sessionId || "").slice(0, 8));
52
+ await fs.promises.mkdir(sessionDir, { recursive: true });
53
+ const localPath = path.join(sessionDir, path.basename(filename));
54
+ await fs.promises.writeFile(localPath, body);
55
+ return { localPath, filename };
56
+ }
57
+
58
+ async function uploadArtifactFromPath(transport, sessionId, filePath) {
59
+ const resolvedPath = path.resolve(expandUserPath(filePath));
60
+ const content = await fs.promises.readFile(resolvedPath);
61
+ return transport.uploadArtifactContent(
62
+ sessionId,
63
+ path.basename(resolvedPath),
64
+ content.toString("base64"),
65
+ undefined,
66
+ "base64",
67
+ );
68
+ }
69
+
70
+ async function openPathInDefaultApp(targetPath) {
71
+ const resolvedPath = path.resolve(expandUserPath(targetPath));
72
+ const stat = await fs.promises.stat(resolvedPath).catch(() => null);
73
+ if (!stat || !stat.isFile()) {
74
+ throw new Error(`File not found: ${targetPath}`);
75
+ }
76
+ await openWithPlatformOpener(resolvedPath);
77
+ return { localPath: resolvedPath };
78
+ }
79
+
80
+ export async function openUrlInDefaultBrowser(targetUrl) {
81
+ const href = String(targetUrl || "").trim();
82
+ if (!href) throw new Error("URL cannot be empty.");
83
+ const parsedUrl = new URL(href);
84
+ if (!/^https?:$/i.test(parsedUrl.protocol)) {
85
+ throw new Error(`Unsupported URL protocol: ${parsedUrl.protocol}`);
86
+ }
87
+ await openWithPlatformOpener(parsedUrl.toString());
88
+ return { url: parsedUrl.toString() };
89
+ }
90
+
91
+ export function createHttpTransportHost({ apiUrl, getAccessToken, onUnauthorized, onForbidden }) {
92
+ fs.mkdirSync(EXPORTS_DIR, { recursive: true });
93
+ return new HttpApiTransport({
94
+ apiUrl,
95
+ getAccessToken,
96
+ onUnauthorized,
97
+ onForbidden,
98
+ host: {
99
+ saveArtifactDownload,
100
+ uploadArtifactFromPath,
101
+ openPathInDefaultApp,
102
+ openUrlInDefaultBrowser,
103
+ artifactExportDirectory: EXPORTS_DIR,
104
+ },
105
+ });
106
+ }