@fargowealth/fargo-ai-cli 0.2.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.
- package/CHANGELOG.md +10 -0
- package/README.md +22 -9
- package/package.json +3 -2
- package/src/auth.mjs +154 -9
- package/src/cli.mjs +29 -4
- package/src/constants.mjs +2 -2
- package/src/credential-store.mjs +104 -27
- package/src/loopback-receiver.mjs +107 -0
- package/src/mcp-proxy.mjs +3 -2
- package/src/workbuddy-install.mjs +55 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- **Behavior change:** Fargo CLI is explicitly WorkBuddy-only. On macOS, credentials use the sandbox-compatible private file at `~/.fargo/credentials.json`; the unsupported Keychain mode is rejected. Windows remains protected by DPAPI.
|
|
6
|
+
- `workbuddy install` writes the macOS credential mode and absolute credential/refresh-lock paths into WorkBuddy's MCP configuration.
|
|
7
|
+
- On macOS, `workbuddy install` resolves an absolute `npx` executable and supplies a stable GUI `PATH`.
|
|
8
|
+
- Windows opens the complete OAuth authorization URL directly through Explorer instead of `cmd.exe start`, preventing `&` query separators from truncating `redirect_uri`.
|
|
9
|
+
- Private-file writes are atomic and `fsync`-backed; directory/file modes are repaired to `0700`/`0600`, and symbolic-link credential reads are rejected.
|
|
10
|
+
- Existing macOS 0.3.x Keychain credentials are not copied into the file store. Users must reconnect once; the previous Keychain item should be removed separately if it exists.
|
package/README.md
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
# Fargo CLI
|
|
1
|
+
# Fargo CLI for WorkBuddy
|
|
2
2
|
|
|
3
|
-
`fargo-cli`
|
|
3
|
+
`fargo-cli` is the compatibility bridge that connects WorkBuddy to the hosted Fargo AI MCP server. It uses OAuth Authorization Code + PKCE over an ephemeral loopback callback and exposes the remote MCP over local stdio.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
7
|
-
fargo
|
|
8
|
-
fargo auth
|
|
9
|
-
fargo auth status --output=json
|
|
10
|
-
fargo mcp serve
|
|
11
|
-
fargo auth logout
|
|
6
|
+
npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always --output=jsonl
|
|
7
|
+
npx -y @fargowealth/fargo-ai-cli@latest auth status --output=json
|
|
8
|
+
npx -y @fargowealth/fargo-ai-cli@latest auth logout --output=json
|
|
12
9
|
```
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
The login opens the shared Fargo AI consent page and returns to `http://127.0.0.1:<random-port>/oauth/callback/...`. The CLI binds only to IPv4 loopback, validates OAuth `state`, uses PKCE S256, closes the listener after the callback, and never prints private OAuth credentials.
|
|
12
|
+
|
|
13
|
+
## Credential storage
|
|
14
|
+
|
|
15
|
+
- **WorkBuddy on macOS:** `~/.fargo/credentials.json`. WorkBuddy's sandbox blocks both the `security` CLI and native Keychain Services. The CLI therefore uses a private file by default and never asks for Full Disk Access.
|
|
16
|
+
- **WorkBuddy on Windows:** DPAPI `CurrentUser`, stored as encrypted bytes under `%APPDATA%\Fargo AI\credentials.bin`.
|
|
17
|
+
- **WorkBuddy on Linux:** `~/.fargo/credentials.json`.
|
|
18
|
+
|
|
19
|
+
On macOS and Linux, the credential directory is forced to `0700` and the credential file to `0600`. Writes use an `fsync`-backed temporary file followed by atomic replacement, symbolic-link reads are rejected, and refresh-token rotation is serialized across local CLI processes.
|
|
20
|
+
|
|
21
|
+
The local file contains an OAuth refresh credential. Do not copy, upload, commit, print, or include it in logs. Use `npx -y @fargowealth/fargo-ai-cli@latest auth logout` to remove it when a device is no longer trusted, and revoke the connection from Fargo AI.
|
|
22
|
+
|
|
23
|
+
Device Authorization Grant remains available only as an explicit fallback:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx -y @fargowealth/fargo-ai-cli@latest auth device --open-browser=always --output=jsonl
|
|
27
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fargowealth/fargo-ai-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Fargo AI
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Fargo AI OAuth and stdio MCP bridge for WorkBuddy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"fargo": "bin/fargo.mjs",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"src",
|
|
13
|
+
"CHANGELOG.md",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
package/src/auth.mjs
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL, DEVICE_CODE_GRANT_TYPE, OAUTH_CLIENT_ID, OAUTH_SCOPE } from "./constants.mjs";
|
|
4
|
+
import { createLoopbackReceiver } from "./loopback-receiver.mjs";
|
|
5
|
+
|
|
6
|
+
export async function authorizePkce(store, options = {}) {
|
|
7
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
8
|
+
const authUrl = options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL;
|
|
9
|
+
const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
|
|
10
|
+
const metadata = await discoverOAuthMetadata(authUrl, fetchImpl);
|
|
11
|
+
const random = options.randomBytes ?? randomBytes;
|
|
12
|
+
const verifier = random(32).toString("base64url");
|
|
13
|
+
const state = random(32).toString("base64url");
|
|
14
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
15
|
+
const receiver = await (options.loopbackReceiverFactory ?? createLoopbackReceiver)({
|
|
16
|
+
expectedState: state,
|
|
17
|
+
randomBytes: random,
|
|
18
|
+
timeoutMs: options.authorizationTimeoutMs
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const registrationResponse = await fetchImpl(metadata.registration_endpoint, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
agent_type: "workbuddy",
|
|
27
|
+
client_name: "Fargo CLI for WorkBuddy",
|
|
28
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
29
|
+
redirect_uris: [receiver.redirectUri],
|
|
30
|
+
response_types: ["code"],
|
|
31
|
+
software_id: "@fargowealth/fargo-ai-cli",
|
|
32
|
+
software_version: CLI_VERSION,
|
|
33
|
+
token_endpoint_auth_method: "none"
|
|
34
|
+
})
|
|
35
|
+
});
|
|
36
|
+
const registration = await readOAuthResponse(registrationResponse);
|
|
37
|
+
if (typeof registration.client_id !== "string" || !registration.client_id) {
|
|
38
|
+
throw new Error("OAuth client registration did not return a client_id");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const authorizationUrl = new URL(metadata.authorization_endpoint);
|
|
42
|
+
authorizationUrl.search = new URLSearchParams({
|
|
43
|
+
client_id: registration.client_id,
|
|
44
|
+
code_challenge: challenge,
|
|
45
|
+
code_challenge_method: "S256",
|
|
46
|
+
redirect_uri: receiver.redirectUri,
|
|
47
|
+
resource: mcpUrl,
|
|
48
|
+
response_type: "code",
|
|
49
|
+
scope: OAUTH_SCOPE,
|
|
50
|
+
state
|
|
51
|
+
}).toString();
|
|
52
|
+
await options.onAuthorization?.({ authorizationUrl: authorizationUrl.toString(), redirectUri: receiver.redirectUri });
|
|
53
|
+
if (options.openBrowser !== false) (options.browserOpener ?? openBrowser)(authorizationUrl.toString());
|
|
54
|
+
|
|
55
|
+
const callback = await receiver.waitForCallback();
|
|
56
|
+
if (callback.searchParams.get("state") !== state) {
|
|
57
|
+
throw new Error("OAuth callback state did not match the pending Fargo authorization");
|
|
58
|
+
}
|
|
59
|
+
const oauthError = callback.searchParams.get("error");
|
|
60
|
+
if (oauthError) throw new Error(`OAuth authorization was not completed (${oauthError})`);
|
|
61
|
+
const code = callback.searchParams.get("code");
|
|
62
|
+
if (!code) throw new Error("OAuth callback did not contain an authorization code");
|
|
63
|
+
|
|
64
|
+
const tokenResponse = await fetchImpl(metadata.token_endpoint, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
|
|
67
|
+
body: new URLSearchParams({
|
|
68
|
+
client_id: registration.client_id,
|
|
69
|
+
code,
|
|
70
|
+
code_verifier: verifier,
|
|
71
|
+
grant_type: "authorization_code",
|
|
72
|
+
redirect_uri: receiver.redirectUri
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
const payload = await readOAuthResponse(tokenResponse);
|
|
76
|
+
const credential = credentialFromToken(payload, authUrl, {
|
|
77
|
+
authorization_method: "authorization_code_pkce",
|
|
78
|
+
client_id: registration.client_id,
|
|
79
|
+
resource_url: mcpUrl,
|
|
80
|
+
token_endpoint: metadata.token_endpoint
|
|
81
|
+
});
|
|
82
|
+
await store.save(credential);
|
|
83
|
+
return credential;
|
|
84
|
+
} finally {
|
|
85
|
+
await receiver.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
3
88
|
|
|
4
89
|
export async function startDeviceAuthorization(options = {}) {
|
|
5
90
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
@@ -28,7 +113,12 @@ export async function pollDeviceAuthorization(device, store, options = {}) {
|
|
|
28
113
|
});
|
|
29
114
|
const payload = await response.json().catch(() => ({}));
|
|
30
115
|
if (response.ok) {
|
|
31
|
-
const credential = credentialFromToken(payload, authUrl
|
|
116
|
+
const credential = credentialFromToken(payload, authUrl, {
|
|
117
|
+
authorization_method: "device_flow",
|
|
118
|
+
client_id: OAUTH_CLIENT_ID,
|
|
119
|
+
resource_url: options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL,
|
|
120
|
+
token_endpoint: `${authUrl}/v1/mcp/oauth/token`
|
|
121
|
+
});
|
|
32
122
|
await store.save(credential);
|
|
33
123
|
return credential;
|
|
34
124
|
}
|
|
@@ -42,13 +132,20 @@ export async function pollDeviceAuthorization(device, store, options = {}) {
|
|
|
42
132
|
export async function refreshCredential(store, credential, options = {}) {
|
|
43
133
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
44
134
|
const authUrl = credential.auth_url ?? options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL;
|
|
45
|
-
const
|
|
135
|
+
const clientId = credential.client_id ?? OAUTH_CLIENT_ID;
|
|
136
|
+
const tokenEndpoint = credential.token_endpoint ?? `${authUrl}/v1/mcp/oauth/token`;
|
|
137
|
+
const response = await fetchImpl(tokenEndpoint, {
|
|
46
138
|
method: "POST",
|
|
47
139
|
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
|
|
48
|
-
body: new URLSearchParams({ grant_type: "refresh_token", client_id:
|
|
140
|
+
body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: credential.refresh_token })
|
|
49
141
|
});
|
|
50
142
|
const payload = await readOAuthResponse(response);
|
|
51
|
-
const refreshed = credentialFromToken(payload, authUrl
|
|
143
|
+
const refreshed = credentialFromToken(payload, authUrl, {
|
|
144
|
+
authorization_method: credential.authorization_method,
|
|
145
|
+
client_id: clientId,
|
|
146
|
+
resource_url: credential.resource_url,
|
|
147
|
+
token_endpoint: tokenEndpoint
|
|
148
|
+
});
|
|
52
149
|
await store.save(refreshed);
|
|
53
150
|
return refreshed;
|
|
54
151
|
}
|
|
@@ -57,23 +154,51 @@ export async function currentCredential(store, options = {}) {
|
|
|
57
154
|
let credential = await store.load();
|
|
58
155
|
if (!credential) return undefined;
|
|
59
156
|
if (!credential.access_token || Number(credential.expires_at) <= Date.now() + 60_000) {
|
|
60
|
-
credential = await
|
|
157
|
+
credential = await refreshCurrentCredential(store, credential, options);
|
|
61
158
|
}
|
|
62
159
|
return credential;
|
|
63
160
|
}
|
|
64
161
|
|
|
162
|
+
export async function refreshCurrentCredential(store, observedCredential, options = {}) {
|
|
163
|
+
const refresh = async () => {
|
|
164
|
+
const latest = await store.load();
|
|
165
|
+
if (!latest) return undefined;
|
|
166
|
+
if (latest.refresh_token !== observedCredential.refresh_token) return latest;
|
|
167
|
+
try {
|
|
168
|
+
return await refreshCredential(store, latest, options);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const recovered = await store.load();
|
|
171
|
+
if (recovered?.refresh_token && recovered.refresh_token !== latest.refresh_token) return recovered;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
return typeof store.withRefreshLock === "function" ? store.withRefreshLock(refresh) : refresh();
|
|
176
|
+
}
|
|
177
|
+
|
|
65
178
|
export function openBrowser(url, platform = process.platform) {
|
|
66
|
-
const
|
|
67
|
-
const args = platform === "win32" ? ["/d", "/s", "/c", "start", "", url] : [url];
|
|
179
|
+
const { args, command } = browserLaunchCommand(url, platform);
|
|
68
180
|
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
181
|
+
child.on("error", () => {
|
|
182
|
+
// The authorization URL is emitted before this best-effort launch, so the
|
|
183
|
+
// host can still open it when no platform browser launcher is available.
|
|
184
|
+
});
|
|
69
185
|
child.unref();
|
|
70
186
|
}
|
|
71
187
|
|
|
72
|
-
function
|
|
188
|
+
export function browserLaunchCommand(url, platform = process.platform) {
|
|
189
|
+
if (platform === "win32") {
|
|
190
|
+
// Do not route OAuth URLs through `cmd.exe start`: unescaped `&` query
|
|
191
|
+
// separators are parsed by CMD and truncate the URL after client_id.
|
|
192
|
+
return { command: "explorer.exe", args: [url] };
|
|
193
|
+
}
|
|
194
|
+
return { command: platform === "darwin" ? "open" : "xdg-open", args: [url] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function credentialFromToken(payload, authUrl, metadata = {}) {
|
|
73
198
|
if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
|
|
74
199
|
throw new Error("OAuth token response did not contain rotating credentials");
|
|
75
200
|
}
|
|
76
|
-
return { ...payload, auth_url: authUrl, obtained_at: Date.now(), expires_at: Date.now() + Math.max(1, Number(payload.expires_in) || 3600) * 1000 };
|
|
201
|
+
return { ...payload, ...metadata, auth_url: authUrl, obtained_at: Date.now(), expires_at: Date.now() + Math.max(1, Number(payload.expires_in) || 3600) * 1000 };
|
|
77
202
|
}
|
|
78
203
|
|
|
79
204
|
async function readOAuthResponse(response) {
|
|
@@ -81,3 +206,23 @@ async function readOAuthResponse(response) {
|
|
|
81
206
|
if (!response.ok) throw new Error(payload.error_description || payload.message || payload.error || `OAuth request failed (${response.status})`);
|
|
82
207
|
return payload;
|
|
83
208
|
}
|
|
209
|
+
|
|
210
|
+
async function discoverOAuthMetadata(authUrl, fetchImpl) {
|
|
211
|
+
const response = await fetchImpl(`${authUrl.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, {
|
|
212
|
+
headers: { "Accept": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` }
|
|
213
|
+
});
|
|
214
|
+
const metadata = await readOAuthResponse(response);
|
|
215
|
+
for (const field of ["authorization_endpoint", "registration_endpoint", "token_endpoint"]) {
|
|
216
|
+
if (typeof metadata[field] !== "string" || !metadata[field]) {
|
|
217
|
+
throw new Error(`OAuth metadata did not contain ${field}`);
|
|
218
|
+
}
|
|
219
|
+
const endpoint = new URL(metadata[field]);
|
|
220
|
+
if (endpoint.protocol !== "https:" && endpoint.hostname !== "127.0.0.1" && endpoint.hostname !== "localhost") {
|
|
221
|
+
throw new Error(`OAuth metadata returned an unsafe ${field}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!Array.isArray(metadata.code_challenge_methods_supported) || !metadata.code_challenge_methods_supported.includes("S256")) {
|
|
225
|
+
throw new Error("OAuth server does not advertise PKCE S256 support");
|
|
226
|
+
}
|
|
227
|
+
return metadata;
|
|
228
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCredentialStore } from "./credential-store.mjs";
|
|
2
|
-
import { currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
|
|
2
|
+
import { authorizePkce, currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
|
|
3
3
|
import { CLI_VERSION } from "./constants.mjs";
|
|
4
4
|
import { runMcpProxy } from "./mcp-proxy.mjs";
|
|
5
5
|
import { installWorkBuddyMcp } from "./workbuddy-install.mjs";
|
|
@@ -17,9 +17,13 @@ export async function runCli(args, options = {}) {
|
|
|
17
17
|
await authorizeDevice(args, stdout, store, options);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
+
if (args[0] === "auth" && (args[1] === "login" || args[1] === "pkce")) {
|
|
21
|
+
await authorizeWithPkce(args, stdout, store, options);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
20
24
|
if (args[0] === "auth" && args[1] === "status") {
|
|
21
25
|
const credential = await store.load();
|
|
22
|
-
stdout.write(`${JSON.stringify({ logged_in: Boolean(credential?.refresh_token), method: credential?.refresh_token ? "oauth" : null, credential_store: store.kind, expires_at: credential?.expires_at ?? null })}\n`);
|
|
26
|
+
stdout.write(`${JSON.stringify({ logged_in: Boolean(credential?.refresh_token), method: credential?.authorization_method ?? (credential?.refresh_token ? "oauth" : null), credential_store: store.kind, expires_at: credential?.expires_at ?? null })}\n`);
|
|
23
27
|
return;
|
|
24
28
|
}
|
|
25
29
|
if (args[0] === "auth" && (args[1] === "logout" || args[1] === "revoke")) {
|
|
@@ -42,11 +46,14 @@ export async function runCli(args, options = {}) {
|
|
|
42
46
|
credential = undefined;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
|
-
if (
|
|
49
|
+
if (credential?.authorization_method !== "authorization_code_pkce") {
|
|
50
|
+
credential = undefined;
|
|
51
|
+
}
|
|
52
|
+
if (!credential?.refresh_token) await authorizeWithPkce(args, stdout, store, options);
|
|
46
53
|
stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
|
|
47
54
|
return;
|
|
48
55
|
}
|
|
49
|
-
throw new Error("Usage: fargo auth device|status|logout | fargo workbuddy install | fargo mcp serve | fargo version");
|
|
56
|
+
throw new Error("Usage: fargo auth login|device|status|logout | fargo workbuddy install | fargo mcp serve | fargo version");
|
|
50
57
|
} catch (error) {
|
|
51
58
|
stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
52
59
|
if (options.throwOnError) throw error;
|
|
@@ -54,6 +61,24 @@ export async function runCli(args, options = {}) {
|
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
async function authorizeWithPkce(args, stdout, store, options) {
|
|
65
|
+
const browserMode = optionValue(args, "--open-browser") ?? "auto";
|
|
66
|
+
await authorizePkce(store, {
|
|
67
|
+
...options,
|
|
68
|
+
openBrowser: browserMode !== "never" && options.openBrowser !== false,
|
|
69
|
+
async onAuthorization(details) {
|
|
70
|
+
stdout.write(`${JSON.stringify({
|
|
71
|
+
type: "authorization_required",
|
|
72
|
+
method: "authorization_code_pkce",
|
|
73
|
+
authorization_url: details.authorizationUrl,
|
|
74
|
+
callback: "loopback"
|
|
75
|
+
})}\n`);
|
|
76
|
+
await options.onAuthorization?.(details);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
stdout.write(`${JSON.stringify({ type: "authorization_complete", logged_in: true, method: "authorization_code_pkce" })}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
async function authorizeDevice(args, stdout, store, options) {
|
|
58
83
|
const device = await startDeviceAuthorization(options);
|
|
59
84
|
stdout.write(`${JSON.stringify({
|
package/src/constants.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.4.0";
|
|
2
2
|
export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
3
3
|
export const DEFAULT_AUTH_URL = "https://auth.fargowealth.app";
|
|
4
4
|
export const DEFAULT_MCP_URL = "https://mcp.fargowealth.app/mcp";
|
|
5
5
|
export const OAUTH_CLIENT_ID = "fargo-cli-workbuddy";
|
|
6
|
-
export const OAUTH_SCOPE = "fargo:forum:read fargo:hub:read fargo:lego:read fargo:knowledge:read";
|
|
6
|
+
export const OAUTH_SCOPE = "fargo:forum:read fargo:forum:write fargo:hub:read fargo:lego:read fargo:lego:write fargo:knowledge:read fargo:knowledge:write fargo:knowledge:share";
|
package/src/credential-store.mjs
CHANGED
|
@@ -1,42 +1,36 @@
|
|
|
1
|
-
import { mkdir, readFile, rm,
|
|
1
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
|
|
8
8
|
export function createCredentialStore(options = {}) {
|
|
9
9
|
const platform = options.platform ?? process.platform;
|
|
10
10
|
const env = options.env ?? process.env;
|
|
11
11
|
const home = options.home ?? homedir();
|
|
12
12
|
const run = options.run ?? runCommand;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
kind: "macos-keychain",
|
|
20
|
-
async load() {
|
|
21
|
-
const result = await run("security", ["find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"], { allowFailure: true });
|
|
22
|
-
if (result.code !== 0 || !result.stdout.trim()) return undefined;
|
|
23
|
-
return parseCredential(result.stdout.trim());
|
|
24
|
-
},
|
|
25
|
-
async save(value) {
|
|
26
|
-
const serialized = JSON.stringify(value);
|
|
27
|
-
const result = await run("security", ["add-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w", serialized, "-U"], { allowFailure: true });
|
|
28
|
-
if (result.code !== 0) throw new Error("Unable to save Fargo credentials in macOS Keychain");
|
|
29
|
-
},
|
|
30
|
-
async clear() {
|
|
31
|
-
await run("security", ["delete-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE], { allowFailure: true });
|
|
32
|
-
}
|
|
33
|
-
};
|
|
13
|
+
const storeKind = env.FARGO_CREDENTIAL_STORE;
|
|
14
|
+
if (storeKind === "keychain") {
|
|
15
|
+
throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by the WorkBuddy CLI; use file on macOS");
|
|
16
|
+
}
|
|
17
|
+
if (storeKind && !["dpapi", "file"].includes(storeKind)) {
|
|
18
|
+
throw new Error(`Unsupported FARGO_CREDENTIAL_STORE: ${storeKind}`);
|
|
34
19
|
}
|
|
20
|
+
if (storeKind === "dpapi" && platform !== "win32") {
|
|
21
|
+
throw new Error("FARGO_CREDENTIAL_STORE=dpapi is only supported on Windows");
|
|
22
|
+
}
|
|
23
|
+
const filePath = env.FARGO_CREDENTIAL_PATH ?? join(home, ".fargo", "credentials.json");
|
|
24
|
+
const refreshLockPath = env.FARGO_REFRESH_LOCK_PATH ?? (platform === "win32"
|
|
25
|
+
? join(env.APPDATA || home, "Fargo AI", "oauth-refresh.lock")
|
|
26
|
+
: join(home, ".fargo", "oauth-refresh.lock"));
|
|
27
|
+
const withRefreshLock = (callback) => withFileLock(refreshLockPath, callback);
|
|
35
28
|
|
|
36
|
-
if (
|
|
29
|
+
if (platform === "win32" && storeKind !== "file") {
|
|
37
30
|
const protectedPath = env.FARGO_CREDENTIAL_PATH ?? join(env.APPDATA || home, "Fargo AI", "credentials.bin");
|
|
38
31
|
return {
|
|
39
32
|
kind: "windows-dpapi",
|
|
33
|
+
withRefreshLock,
|
|
40
34
|
async load() {
|
|
41
35
|
const script = "$p=$env:FARGO_CREDENTIAL_PATH;if(!(Test-Path -LiteralPath $p)){exit 3};$e=[IO.File]::ReadAllBytes($p);$b=[Security.Cryptography.ProtectedData]::Unprotect($e,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Console]::Out.Write([Text.Encoding]::UTF8.GetString($b))";
|
|
42
36
|
const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath } });
|
|
@@ -56,8 +50,10 @@ export function createCredentialStore(options = {}) {
|
|
|
56
50
|
|
|
57
51
|
return {
|
|
58
52
|
kind: "private-file",
|
|
53
|
+
withRefreshLock,
|
|
59
54
|
async load() {
|
|
60
55
|
try {
|
|
56
|
+
await secureExistingFile(filePath);
|
|
61
57
|
return parseCredential(await readFile(filePath, "utf8"));
|
|
62
58
|
} catch (error) {
|
|
63
59
|
if (error?.code === "ENOENT") return undefined;
|
|
@@ -65,15 +61,96 @@ export function createCredentialStore(options = {}) {
|
|
|
65
61
|
}
|
|
66
62
|
},
|
|
67
63
|
async save(value) {
|
|
68
|
-
|
|
69
|
-
await
|
|
64
|
+
const directory = dirname(filePath);
|
|
65
|
+
await ensurePrivateDirectory(directory);
|
|
66
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
67
|
+
let handle;
|
|
68
|
+
try {
|
|
69
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
70
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
|
|
71
|
+
await handle.sync();
|
|
72
|
+
await handle.close();
|
|
73
|
+
handle = undefined;
|
|
74
|
+
await chmod(temporaryPath, 0o600);
|
|
75
|
+
await rename(temporaryPath, filePath);
|
|
76
|
+
await secureExistingFile(filePath);
|
|
77
|
+
} finally {
|
|
78
|
+
await handle?.close().catch(() => {});
|
|
79
|
+
await rm(temporaryPath, { force: true });
|
|
80
|
+
}
|
|
70
81
|
},
|
|
71
82
|
async clear() {
|
|
83
|
+
await secureExistingFile(filePath).catch((error) => {
|
|
84
|
+
if (error?.code !== "ENOENT") throw error;
|
|
85
|
+
});
|
|
72
86
|
await rm(filePath, { force: true });
|
|
73
87
|
}
|
|
74
88
|
};
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
async function withFileLock(lockPath, callback) {
|
|
92
|
+
await ensurePrivateDirectory(dirname(lockPath));
|
|
93
|
+
const deadline = Date.now() + 15_000;
|
|
94
|
+
const owner = `${process.pid}:${randomUUID()}`;
|
|
95
|
+
let handle;
|
|
96
|
+
while (!handle) {
|
|
97
|
+
try {
|
|
98
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code !== "EEXIST") throw error;
|
|
101
|
+
try {
|
|
102
|
+
const lockStat = await stat(lockPath);
|
|
103
|
+
if (Date.now() - lockStat.mtimeMs > 120_000) {
|
|
104
|
+
await rm(lockPath, { force: true });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
} catch (statError) {
|
|
108
|
+
if (statError?.code !== "ENOENT") throw statError;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for another Fargo session to refresh OAuth");
|
|
112
|
+
await delay(75);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await handle.writeFile(owner, "utf8");
|
|
116
|
+
try {
|
|
117
|
+
return await callback();
|
|
118
|
+
} finally {
|
|
119
|
+
await handle.close();
|
|
120
|
+
const currentOwner = await readFile(lockPath, "utf8").catch((error) => {
|
|
121
|
+
if (error?.code === "ENOENT") return undefined;
|
|
122
|
+
throw error;
|
|
123
|
+
});
|
|
124
|
+
if (currentOwner === owner) await rm(lockPath, { force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function ensurePrivateDirectory(directoryPath) {
|
|
129
|
+
await mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
130
|
+
const details = await lstat(directoryPath);
|
|
131
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
132
|
+
throw new Error(`Fargo credential directory is not a private directory: ${directoryPath}`);
|
|
133
|
+
}
|
|
134
|
+
assertCurrentUserOwns(details, "credential directory");
|
|
135
|
+
await chmod(directoryPath, 0o700);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function secureExistingFile(filePath) {
|
|
139
|
+
const details = await lstat(filePath);
|
|
140
|
+
if (!details.isFile() || details.isSymbolicLink()) {
|
|
141
|
+
throw new Error(`Fargo credential path is not a regular file: ${filePath}`);
|
|
142
|
+
}
|
|
143
|
+
assertCurrentUserOwns(details, "credential file");
|
|
144
|
+
await chmod(filePath, 0o600);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertCurrentUserOwns(details, label) {
|
|
148
|
+
if (typeof process.getuid !== "function") return;
|
|
149
|
+
if (details.uid !== process.getuid()) {
|
|
150
|
+
throw new Error(`Fargo ${label} is not owned by the current user`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
77
154
|
function parseCredential(value) {
|
|
78
155
|
const parsed = JSON.parse(value);
|
|
79
156
|
if (!parsed || typeof parsed !== "object" || typeof parsed.refresh_token !== "string") {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
5
|
+
|
|
6
|
+
export async function createLoopbackReceiver(options = {}) {
|
|
7
|
+
const callbackPath = `/oauth/callback/${(options.randomBytes ?? randomBytes)(18).toString("base64url")}`;
|
|
8
|
+
const timeoutMs = Math.max(1_000, Number(options.timeoutMs) || 10 * 60 * 1_000);
|
|
9
|
+
let address;
|
|
10
|
+
let settled = false;
|
|
11
|
+
let timer;
|
|
12
|
+
let resolveCallback;
|
|
13
|
+
let rejectCallback;
|
|
14
|
+
const callback = new Promise((resolve, reject) => {
|
|
15
|
+
resolveCallback = resolve;
|
|
16
|
+
rejectCallback = reject;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const server = createServer((request, response) => {
|
|
20
|
+
const requestUrl = new URL(request.url || "/", `http://${LOOPBACK_HOST}:${address?.port || 0}`);
|
|
21
|
+
if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
|
|
22
|
+
response.writeHead(404, secureHtmlHeaders());
|
|
23
|
+
response.end(completionPage(false, "这个本地回调地址无效,请返回 Fargo AI 重新授权。"));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (settled) {
|
|
27
|
+
response.writeHead(409, secureHtmlHeaders());
|
|
28
|
+
response.end(completionPage(false, "这次授权回调已经处理,请返回 WorkBuddy。"));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
settled = true;
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
if (options.expectedState && requestUrl.searchParams.get("state") !== options.expectedState) {
|
|
34
|
+
response.writeHead(400, secureHtmlHeaders());
|
|
35
|
+
response.end(completionPage(false, "这次授权回调无法验证,请返回 Fargo AI 重新授权。"));
|
|
36
|
+
rejectCallback(new Error("OAuth callback state did not match the pending Fargo authorization"));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const failed = Boolean(requestUrl.searchParams.get("error"));
|
|
40
|
+
response.writeHead(failed ? 400 : 200, secureHtmlHeaders());
|
|
41
|
+
response.end(completionPage(!failed, failed
|
|
42
|
+
? "没有签发任何凭据。你可以关闭此页面后重新尝试。"
|
|
43
|
+
: "Fargo CLI 已收到授权结果,正在安全完成连接。现在可以关闭此页面。"));
|
|
44
|
+
resolveCallback(requestUrl);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await new Promise((resolve, reject) => {
|
|
48
|
+
const onError = (error) => {
|
|
49
|
+
server.off("listening", onListening);
|
|
50
|
+
reject(error);
|
|
51
|
+
};
|
|
52
|
+
const onListening = () => {
|
|
53
|
+
server.off("error", onError);
|
|
54
|
+
resolve();
|
|
55
|
+
};
|
|
56
|
+
server.once("error", onError);
|
|
57
|
+
server.once("listening", onListening);
|
|
58
|
+
server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
address = server.address();
|
|
62
|
+
if (!address || typeof address === "string") {
|
|
63
|
+
await closeServer(server);
|
|
64
|
+
throw new Error("Unable to allocate a Fargo OAuth loopback port");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
rejectCallback(new Error("Fargo OAuth authorization timed out"));
|
|
71
|
+
void closeServer(server);
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
redirectUri: `http://${LOOPBACK_HOST}:${address.port}${callbackPath}`,
|
|
76
|
+
async waitForCallback() {
|
|
77
|
+
return callback;
|
|
78
|
+
},
|
|
79
|
+
async close() {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
await closeServer(server);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function secureHtmlHeaders() {
|
|
87
|
+
return {
|
|
88
|
+
"Cache-Control": "no-store",
|
|
89
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
90
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
91
|
+
"Referrer-Policy": "no-referrer",
|
|
92
|
+
"X-Content-Type-Options": "nosniff"
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function completionPage(success, message) {
|
|
97
|
+
const title = success ? "授权成功" : "授权未完成";
|
|
98
|
+
const marker = success ? "✓ 安全连接" : "连接未完成";
|
|
99
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>
|
|
100
|
+
:root{color-scheme:light;--ink:#11110f;--muted:#68635b;--line:#aaa49a;--green:#27704e}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;background:#f3f2ee;color:var(--ink);font-family:Inter,"Noto Sans SC",system-ui,sans-serif;padding:24px}main{width:min(100%,620px);border:1px solid var(--line);border-top:4px solid var(--ink);background:#fff;padding:40px;box-shadow:0 8px 24px rgba(0,0,0,.05)}.marker{color:${success ? "var(--green)" : "var(--muted)"};font-weight:850}h1{font-size:36px;margin:12px 0}p{color:var(--muted);line-height:1.65}
|
|
101
|
+
</style></head><body><main><span class="marker">${marker}</span><h1>${title}</h1><p>${message}</p></main></body></html>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function closeServer(server) {
|
|
105
|
+
if (!server.listening) return Promise.resolve();
|
|
106
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
107
|
+
}
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { CLI_VERSION, DEFAULT_MCP_URL } from "./constants.mjs";
|
|
3
|
-
import { currentCredential,
|
|
3
|
+
import { currentCredential, refreshCurrentCredential } from "./auth.mjs";
|
|
4
4
|
|
|
5
5
|
export async function runMcpProxy(store, options = {}) {
|
|
6
6
|
const input = options.input ?? process.stdin;
|
|
@@ -39,7 +39,8 @@ export async function runMcpProxy(store, options = {}) {
|
|
|
39
39
|
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
40
40
|
const response = await fetchImpl(mcpUrl, { method: "POST", headers, body: JSON.stringify(message) });
|
|
41
41
|
if (response.status === 401 && !didRefresh) {
|
|
42
|
-
credential = await
|
|
42
|
+
credential = await refreshCurrentCredential(store, credential, { fetchImpl });
|
|
43
|
+
if (!credential) throw new Error("Fargo CLI is not authenticated");
|
|
43
44
|
return forward(message, true);
|
|
44
45
|
}
|
|
45
46
|
if (!response.ok && response.status !== 202) throw new Error(`Fargo MCP returned HTTP ${response.status}`);
|
|
@@ -1,22 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
5
|
import { CLI_VERSION } from "./constants.mjs";
|
|
5
6
|
|
|
6
7
|
const PACKAGE_NAME = "@fargowealth/fargo-ai-cli";
|
|
7
8
|
|
|
8
9
|
export async function installWorkBuddyMcp(options = {}) {
|
|
10
|
+
const platform = options.platform ?? process.platform;
|
|
11
|
+
const env = options.env ?? process.env;
|
|
12
|
+
const homeDirectory = options.home ?? homedir();
|
|
9
13
|
const configPath = options.configPath
|
|
10
|
-
??
|
|
11
|
-
?? defaultWorkBuddyConfigPath();
|
|
14
|
+
?? env.FARGO_WORKBUDDY_MCP_CONFIG
|
|
15
|
+
?? defaultWorkBuddyConfigPath(homeDirectory);
|
|
12
16
|
const packageSpec = `${PACKAGE_NAME}@${options.packageVersion ?? CLI_VERSION}`;
|
|
13
17
|
const config = await readConfig(configPath);
|
|
18
|
+
const npxCommand = options.npxCommand ?? (platform === "darwin"
|
|
19
|
+
? await resolveNpxCommand({ env, execPath: options.execPath, platform })
|
|
20
|
+
: "npx");
|
|
14
21
|
const nextServer = {
|
|
15
22
|
type: "stdio",
|
|
16
|
-
command:
|
|
23
|
+
command: npxCommand,
|
|
17
24
|
args: ["-y", packageSpec, "mcp", "serve"],
|
|
18
25
|
timeout: 60000
|
|
19
26
|
};
|
|
27
|
+
if (platform === "darwin") {
|
|
28
|
+
const credentialDirectory = join(homeDirectory, ".fargo");
|
|
29
|
+
nextServer.env = {
|
|
30
|
+
FARGO_CREDENTIAL_PATH: join(credentialDirectory, "credentials.json"),
|
|
31
|
+
FARGO_CREDENTIAL_STORE: "file",
|
|
32
|
+
FARGO_REFRESH_LOCK_PATH: join(credentialDirectory, "oauth-refresh.lock"),
|
|
33
|
+
PATH: buildMacPath(npxCommand)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
20
36
|
const previousServer = config.mcpServers?.["fargo-ai"];
|
|
21
37
|
const changed = JSON.stringify(previousServer) !== JSON.stringify(nextServer);
|
|
22
38
|
|
|
@@ -37,6 +53,40 @@ export async function installWorkBuddyMcp(options = {}) {
|
|
|
37
53
|
};
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
export async function resolveNpxCommand(options = {}) {
|
|
57
|
+
const platform = options.platform ?? process.platform;
|
|
58
|
+
const env = options.env ?? process.env;
|
|
59
|
+
const execPath = options.execPath ?? process.execPath;
|
|
60
|
+
const executableName = platform === "win32" ? "npx.cmd" : "npx";
|
|
61
|
+
const pathDelimiter = platform === "win32" ? ";" : ":";
|
|
62
|
+
const candidates = [
|
|
63
|
+
join(dirname(execPath), executableName),
|
|
64
|
+
...String(env.PATH ?? "").split(pathDelimiter).filter(Boolean).map((entry) => join(entry, executableName))
|
|
65
|
+
];
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const absoluteCandidate = isAbsolute(candidate) ? candidate : resolve(candidate);
|
|
68
|
+
try {
|
|
69
|
+
await access(absoluteCandidate, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
|
70
|
+
return absoluteCandidate;
|
|
71
|
+
} catch {
|
|
72
|
+
// Keep searching for an executable that will also be visible to the GUI host.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new Error("Unable to locate an absolute npx executable for WorkBuddy");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildMacPath(npxCommand) {
|
|
79
|
+
return [...new Set([
|
|
80
|
+
dirname(npxCommand),
|
|
81
|
+
"/opt/homebrew/bin",
|
|
82
|
+
"/usr/local/bin",
|
|
83
|
+
"/usr/bin",
|
|
84
|
+
"/bin",
|
|
85
|
+
"/usr/sbin",
|
|
86
|
+
"/sbin"
|
|
87
|
+
])].join(":");
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
export function defaultWorkBuddyConfigPath(homeDirectory = homedir()) {
|
|
41
91
|
return join(homeDirectory, ".workbuddy-ai", "mcp.json");
|
|
42
92
|
}
|