@fargowealth/fargo-ai-cli 0.4.2 → 0.5.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Fargo CLI is now Agent-neutral; PKCE login records Codex, Claude Code,
6
+ Cursor, or WorkBuddy through `--agent`.
7
+ - Added one-shot `fargo mcp tools` and `fargo mcp call` commands for any
8
+ shell-capable Agent.
9
+ - Added deterministic LEGO source snapshot upload, list, and download commands
10
+ with `.legoignore`, secure defaults, SHA-256 manifests, and a 50 MB ZIP limit.
11
+ - WorkBuddy installation remains supported as an adapter over the common CLI.
12
+
3
13
  ## 0.4.2
4
14
 
5
15
  - Windows no longer calls `chmod` while reading or writing credentials; `%APPDATA%` continues to provide the inherited NTFS user ACL without a sandbox-blocked metadata write.
package/README.md CHANGED
@@ -1,27 +1,100 @@
1
- # Fargo CLI for WorkBuddy
1
+ # Fargo AI CLI
2
2
 
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.
3
+ `fargo` is the Agent-neutral command-line client for Fargo AI. Codex, Claude
4
+ Code, Cursor, WorkBuddy, and any Agent that can execute shell commands share
5
+ the same Fargo identity, hosted MCP contracts, and LEGO project operations.
6
+
7
+ Requires Node.js 20.19 or newer.
8
+
9
+ ## Install or upgrade
4
10
 
5
11
  ```sh
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
+ npm install -g @fargowealth/fargo-ai-cli@0.5.0
13
+ fargo version
9
14
  ```
10
15
 
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.
16
+ Use the native remote Fargo MCP connection as the default online tool surface.
17
+ Authenticate the CLI separately only when an Agent needs to package or upload
18
+ files from its local workspace:
12
19
 
13
- ## Credential storage
20
+ ```sh
21
+ fargo auth login --agent codex
22
+ fargo auth login --agent claude-code
23
+ fargo auth login --agent cursor
24
+ fargo auth status
25
+ ```
26
+
27
+ Aliases `cc` and `claude` resolve to `claude-code`. Login uses OAuth
28
+ Authorization Code + PKCE over an ephemeral IPv4 loopback callback. The CLI
29
+ validates OAuth state, uses PKCE S256, closes the listener after callback, and
30
+ never prints access or refresh credentials.
14
31
 
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:** `%APPDATA%\Fargo AI\credentials.json`. WorkBuddy may also restrict DPAPI access, so the CLI uses a user-profile credential file and does not depend on DPAPI.
17
- - **WorkBuddy on Linux:** `~/.fargo/credentials.json`.
32
+ ## MCP boundary
18
33
 
19
- On macOS and Linux, the credential directory is forced to `0700` and the credential file to `0600`. Windows inherits the current user's NTFS ACL from `%APPDATA%`. 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.
34
+ - MCP is the default for online Fargo resources, status, deployment,
35
+ publication, rollback, and other business operations.
36
+ - CLI is used when the operation needs the Agent's local filesystem or binary
37
+ transfer: source walking, exclusions, ZIP creation, hashing, upload, and
38
+ download.
39
+ - A future `fargo lego deploy --source .` command may compose the two, but it
40
+ will remain a client-side workflow over the same server deployment contract.
20
41
 
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.
42
+ Agents without native MCP support can use the CLI fallback:
22
43
 
23
- Device Authorization Grant remains available only as an explicit fallback:
44
+ ```sh
45
+ fargo mcp tools
46
+ fargo mcp call lego_get_app --args '{"appId":"app_xxx"}'
47
+ fargo mcp call lego_get_app --args-file ./arguments.json
48
+ ```
49
+
50
+ `fargo mcp serve` remains the local stdio bridge for hosts such as WorkBuddy
51
+ that require one. Codex, Claude Code, and Cursor should prefer the remote MCP
52
+ URL and its native OAuth support.
53
+
54
+ ## LEGO source snapshots
24
55
 
25
56
  ```sh
26
- npx -y @fargowealth/fargo-ai-cli@latest auth device --open-browser=always --output=jsonl
57
+ fargo lego snapshot upload --app app_xxx --source . --dry-run
58
+ fargo lego snapshot upload --app app_xxx --source . --version v1
59
+ fargo lego snapshot list --app app_xxx
60
+ fargo lego snapshot download --app app_xxx --snapshot snap_xxx
27
61
  ```
62
+
63
+ The CLI applies secure defaults plus `.legoignore`, skips symbolic links,
64
+ creates a deterministic ZIP, calculates SHA-256 digests, and uploads the bytes
65
+ over an authenticated HTTP data path. Archive bytes are not embedded in MCP
66
+ JSON-RPC. The default archive limit is 50 MB.
67
+
68
+ The ZIP includes `.fargo-snapshot/manifest.json`. Dependencies, VCS data,
69
+ generated output, caches, secrets, previous archives, large video/audio, and
70
+ design-source files are excluded by default. Add project-specific gitignore
71
+ syntax to `.legoignore`.
72
+
73
+ ## WorkBuddy adapter
74
+
75
+ ```sh
76
+ npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always --output=jsonl
77
+ ```
78
+
79
+ The installer preserves existing MCP servers and adds the local Fargo stdio
80
+ bridge. Device Authorization Grant remains available only as an explicit
81
+ WorkBuddy fallback:
82
+
83
+ ```sh
84
+ fargo auth device --open-browser=always --output=jsonl
85
+ ```
86
+
87
+ ## Credential storage
88
+
89
+ - macOS and Linux: `~/.fargo/credentials.json`
90
+ - Windows: `%APPDATA%\Fargo AI\credentials.json`
91
+
92
+ On macOS and Linux, the directory is forced to `0700` and the credential file
93
+ to `0600`. Windows inherits the current user's NTFS ACL from `%APPDATA%`.
94
+ Writes use an `fsync`-backed temporary file followed by atomic replacement,
95
+ symbolic-link reads are rejected, and refresh-token rotation is serialized
96
+ across local CLI processes.
97
+
98
+ The file contains an OAuth refresh credential. Never copy, upload, commit, or
99
+ print it. Use `fargo auth logout` to remove it and revoke the Fargo connection
100
+ when a device is no longer trusted.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fargowealth/fargo-ai-cli",
3
- "version": "0.4.2",
4
- "description": "Fargo AI OAuth and stdio MCP bridge for WorkBuddy",
3
+ "version": "0.5.0",
4
+ "description": "Agent-neutral Fargo AI CLI, MCP client, and LEGO project tooling",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "fargo": "bin/fargo.mjs",
@@ -16,6 +16,10 @@
16
16
  "engines": {
17
17
  "node": ">=20.19.0"
18
18
  },
19
+ "dependencies": {
20
+ "fflate": "^0.8.2",
21
+ "ignore": "^7.0.6"
22
+ },
19
23
  "license": "UNLICENSED",
20
24
  "publishConfig": {
21
25
  "access": "public"
@@ -0,0 +1,42 @@
1
+ import { CLI_VERSION } from "./constants.mjs";
2
+ import { currentCredential, refreshCurrentCredential } from "./auth.mjs";
3
+
4
+ export async function authenticatedRequest(store, url, init = {}, options = {}) {
5
+ const fetchImpl = options.fetchImpl ?? fetch;
6
+ let credential = await currentCredential(store, { ...options, fetchImpl });
7
+ if (!credential?.access_token) {
8
+ throw new Error("Fargo CLI is not authenticated. Run 'fargo auth login' first");
9
+ }
10
+
11
+ let response = await send(credential);
12
+ if (response.status === 401) {
13
+ credential = await refreshCurrentCredential(store, credential, { ...options, fetchImpl });
14
+ if (!credential?.access_token) {
15
+ throw new Error("Fargo CLI authentication expired. Run 'fargo auth login' again");
16
+ }
17
+ response = await send(credential);
18
+ }
19
+ return response;
20
+
21
+ function send(activeCredential) {
22
+ const headers = new Headers(init.headers);
23
+ headers.set("Authorization", `Bearer ${activeCredential.access_token}`);
24
+ headers.set("User-Agent", `fargo-cli/${CLI_VERSION}`);
25
+ return fetchImpl(url, { ...init, headers });
26
+ }
27
+ }
28
+
29
+ export async function responsePayload(response) {
30
+ const text = await response.text();
31
+ let payload;
32
+ try {
33
+ payload = text.trim() ? JSON.parse(text) : {};
34
+ } catch {
35
+ payload = { message: text.trim() };
36
+ }
37
+ if (!response.ok) {
38
+ const message = payload?.message || payload?.error || `Fargo API returned HTTP ${response.status}`;
39
+ throw new Error(message);
40
+ }
41
+ return payload;
42
+ }
package/src/auth.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL, DEVICE_CODE_GRANT_TYPE, OAUTH_CLIENT_ID, OAUTH_SCOPE } from "./constants.mjs";
3
+ import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL, DEVICE_CODE_GRANT_TYPE, OAUTH_CLIENT_ID, OAUTH_SCOPE, SUPPORTED_AGENT_TYPES } from "./constants.mjs";
4
4
  import { createLoopbackReceiver } from "./loopback-receiver.mjs";
5
5
 
6
6
  export async function authorizePkce(store, options = {}) {
@@ -12,6 +12,8 @@ export async function authorizePkce(store, options = {}) {
12
12
  const verifier = random(32).toString("base64url");
13
13
  const state = random(32).toString("base64url");
14
14
  const challenge = createHash("sha256").update(verifier).digest("base64url");
15
+ const agentType = normalizeAgentType(options.agentType ?? process.env.FARGO_AGENT ?? "codex");
16
+ const clientName = agentClientName(agentType);
15
17
  const receiver = await (options.loopbackReceiverFactory ?? createLoopbackReceiver)({
16
18
  expectedState: state,
17
19
  randomBytes: random,
@@ -21,10 +23,10 @@ export async function authorizePkce(store, options = {}) {
21
23
  try {
22
24
  const registrationResponse = await fetchImpl(metadata.registration_endpoint, {
23
25
  method: "POST",
24
- headers: { "Content-Type": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
26
+ headers: { "Content-Type": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION}` },
25
27
  body: JSON.stringify({
26
- agent_type: "workbuddy",
27
- client_name: "Fargo CLI for WorkBuddy",
28
+ agent_type: agentType,
29
+ client_name: clientName,
28
30
  grant_types: ["authorization_code", "refresh_token"],
29
31
  redirect_uris: [receiver.redirectUri],
30
32
  response_types: ["code"],
@@ -63,7 +65,7 @@ export async function authorizePkce(store, options = {}) {
63
65
 
64
66
  const tokenResponse = await fetchImpl(metadata.token_endpoint, {
65
67
  method: "POST",
66
- headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
68
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
67
69
  body: new URLSearchParams({
68
70
  client_id: registration.client_id,
69
71
  code,
@@ -74,6 +76,7 @@ export async function authorizePkce(store, options = {}) {
74
76
  });
75
77
  const payload = await readOAuthResponse(tokenResponse);
76
78
  const credential = credentialFromToken(payload, authUrl, {
79
+ agent_type: agentType,
77
80
  authorization_method: "authorization_code_pkce",
78
81
  client_id: registration.client_id,
79
82
  resource_url: mcpUrl,
@@ -92,7 +95,7 @@ export async function startDeviceAuthorization(options = {}) {
92
95
  const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
93
96
  const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/device_authorization`, {
94
97
  method: "POST",
95
- headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
98
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
96
99
  body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, resource: mcpUrl, scope: OAUTH_SCOPE })
97
100
  });
98
101
  return readOAuthResponse(response);
@@ -108,12 +111,13 @@ export async function pollDeviceAuthorization(device, store, options = {}) {
108
111
  await sleep(interval * 1000);
109
112
  const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/token`, {
110
113
  method: "POST",
111
- headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
114
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
112
115
  body: new URLSearchParams({ grant_type: DEVICE_CODE_GRANT_TYPE, client_id: OAUTH_CLIENT_ID, device_code: device.device_code })
113
116
  });
114
117
  const payload = await response.json().catch(() => ({}));
115
118
  if (response.ok) {
116
119
  const credential = credentialFromToken(payload, authUrl, {
120
+ agent_type: "workbuddy",
117
121
  authorization_method: "device_flow",
118
122
  client_id: OAUTH_CLIENT_ID,
119
123
  resource_url: options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL,
@@ -136,11 +140,12 @@ export async function refreshCredential(store, credential, options = {}) {
136
140
  const tokenEndpoint = credential.token_endpoint ?? `${authUrl}/v1/mcp/oauth/token`;
137
141
  const response = await fetchImpl(tokenEndpoint, {
138
142
  method: "POST",
139
- headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
143
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION}` },
140
144
  body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: credential.refresh_token })
141
145
  });
142
146
  const payload = await readOAuthResponse(response);
143
147
  const refreshed = credentialFromToken(payload, authUrl, {
148
+ agent_type: credential.agent_type,
144
149
  authorization_method: credential.authorization_method,
145
150
  client_id: clientId,
146
151
  resource_url: credential.resource_url,
@@ -194,6 +199,34 @@ export function browserLaunchCommand(url, platform = process.platform) {
194
199
  return { command: platform === "darwin" ? "open" : "xdg-open", args: [url] };
195
200
  }
196
201
 
202
+ export function normalizeAgentType(value) {
203
+ const normalized = String(value ?? "").trim().toLowerCase().replaceAll("_", "-");
204
+ const aliases = {
205
+ cc: "claude-code",
206
+ claude: "claude-code",
207
+ "claude-code": "claude-code",
208
+ codex: "codex",
209
+ cursor: "cursor",
210
+ workbuddy: "workbuddy",
211
+ "work-buddy": "workbuddy"
212
+ };
213
+ const agentType = aliases[normalized];
214
+ if (!agentType || !SUPPORTED_AGENT_TYPES.includes(agentType)) {
215
+ throw new Error(`Unsupported Agent '${value}'. Expected: ${SUPPORTED_AGENT_TYPES.join(", ")}`);
216
+ }
217
+ return agentType;
218
+ }
219
+
220
+ function agentClientName(agentType) {
221
+ const labels = {
222
+ "claude-code": "Claude Code",
223
+ codex: "Codex",
224
+ cursor: "Cursor",
225
+ workbuddy: "WorkBuddy"
226
+ };
227
+ return `Fargo CLI for ${labels[agentType]}`;
228
+ }
229
+
197
230
  function credentialFromToken(payload, authUrl, metadata = {}) {
198
231
  if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
199
232
  throw new Error("OAuth token response did not contain rotating credentials");
@@ -209,7 +242,7 @@ async function readOAuthResponse(response) {
209
242
 
210
243
  async function discoverOAuthMetadata(authUrl, fetchImpl) {
211
244
  const response = await fetchImpl(`${authUrl.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, {
212
- headers: { "Accept": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` }
245
+ headers: { "Accept": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION}` }
213
246
  });
214
247
  const metadata = await readOAuthResponse(response);
215
248
  for (const field of ["authorization_endpoint", "registration_endpoint", "token_endpoint"]) {
package/src/cli.mjs CHANGED
@@ -1,6 +1,10 @@
1
+ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
1
3
  import { createCredentialStore } from "./credential-store.mjs";
2
4
  import { authorizePkce, currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
3
5
  import { CLI_VERSION } from "./constants.mjs";
6
+ import { downloadLegoSourceSnapshot, listLegoSourceSnapshots, uploadLegoSourceSnapshot } from "./lego-snapshots.mjs";
7
+ import { callMcpTool, listMcpTools } from "./mcp-client.mjs";
4
8
  import { runMcpProxy } from "./mcp-proxy.mjs";
5
9
  import { installWorkBuddyMcp } from "./workbuddy-install.mjs";
6
10
 
@@ -23,7 +27,7 @@ export async function runCli(args, options = {}) {
23
27
  }
24
28
  if (args[0] === "auth" && args[1] === "status") {
25
29
  const credential = await store.load();
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`);
30
+ stdout.write(`${JSON.stringify({ logged_in: Boolean(credential?.refresh_token), method: credential?.authorization_method ?? (credential?.refresh_token ? "oauth" : null), agent_type: credential?.agent_type ?? null, credential_store: store.kind, expires_at: credential?.expires_at ?? null })}\n`);
27
31
  return;
28
32
  }
29
33
  if (args[0] === "auth" && (args[1] === "logout" || args[1] === "revoke")) {
@@ -36,6 +40,21 @@ export async function runCli(args, options = {}) {
36
40
  await runMcpProxy(store, options);
37
41
  return;
38
42
  }
43
+ if (args[0] === "mcp" && args[1] === "tools") {
44
+ stdout.write(`${JSON.stringify(await listMcpTools(store, options))}\n`);
45
+ return;
46
+ }
47
+ if (args[0] === "mcp" && args[1] === "call") {
48
+ const toolName = args[2]?.trim();
49
+ if (!toolName || toolName.startsWith("-")) throw new Error("Usage: fargo mcp call <tool> [--args JSON | --args-file PATH]");
50
+ const toolArgs = await readToolArgs(args);
51
+ stdout.write(`${JSON.stringify(await callMcpTool(store, toolName, toolArgs, options))}\n`);
52
+ return;
53
+ }
54
+ if (args[0] === "lego" && args[1] === "snapshot") {
55
+ await runLegoSnapshotCommand(args, stdout, store, options);
56
+ return;
57
+ }
39
58
  if (args[0] === "workbuddy" && args[1] === "install") {
40
59
  let credential = await store.load();
41
60
  if (credential) {
@@ -49,11 +68,11 @@ export async function runCli(args, options = {}) {
49
68
  if (credential?.authorization_method !== "authorization_code_pkce") {
50
69
  credential = undefined;
51
70
  }
52
- if (!credential?.refresh_token) await authorizeWithPkce(args, stdout, store, options);
71
+ if (!credential?.refresh_token) await authorizeWithPkce(args, stdout, store, { ...options, agentType: "workbuddy" });
53
72
  stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
54
73
  return;
55
74
  }
56
- throw new Error("Usage: fargo auth login|device|status|logout | fargo workbuddy install | fargo mcp serve | fargo version");
75
+ throw new Error("Usage: fargo auth login|device|status|logout | fargo mcp serve|tools|call | fargo lego snapshot upload|list|download | fargo workbuddy install | fargo version");
57
76
  } catch (error) {
58
77
  stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
59
78
  if (options.throwOnError) throw error;
@@ -65,6 +84,7 @@ async function authorizeWithPkce(args, stdout, store, options) {
65
84
  const browserMode = optionValue(args, "--open-browser") ?? "auto";
66
85
  await authorizePkce(store, {
67
86
  ...options,
87
+ agentType: options.agentType ?? optionValue(args, "--agent") ?? process.env.FARGO_AGENT ?? "codex",
68
88
  openBrowser: browserMode !== "never" && options.openBrowser !== false,
69
89
  async onAuthorization(details) {
70
90
  stdout.write(`${JSON.stringify({
@@ -79,6 +99,71 @@ async function authorizeWithPkce(args, stdout, store, options) {
79
99
  stdout.write(`${JSON.stringify({ type: "authorization_complete", logged_in: true, method: "authorization_code_pkce" })}\n`);
80
100
  }
81
101
 
102
+ async function runLegoSnapshotCommand(args, stdout, store, options) {
103
+ const action = args[2];
104
+ const appId = requiredOption(args, "--app");
105
+ if (action === "upload") {
106
+ const result = await uploadLegoSourceSnapshot(store, {
107
+ appId,
108
+ dryRun: args.includes("--dry-run"),
109
+ source: optionValue(args, "--source") ?? ".",
110
+ version: optionValue(args, "--version")
111
+ }, options);
112
+ stdout.write(`${JSON.stringify(result)}\n`);
113
+ return;
114
+ }
115
+ if (action === "list") {
116
+ stdout.write(`${JSON.stringify(await listLegoSourceSnapshots(store, appId, options))}\n`);
117
+ return;
118
+ }
119
+ if (action === "download") {
120
+ const snapshotId = requiredOption(args, "--snapshot");
121
+ const result = await downloadLegoSourceSnapshot(store, { appId, snapshotId }, options);
122
+ const outputPath = resolve(optionValue(args, "--output") ?? result.fileName);
123
+ if (!args.includes("--force") && await pathExists(outputPath)) {
124
+ throw new Error(`Output file already exists: ${outputPath}. Pass --force to replace it`);
125
+ }
126
+ await mkdir(dirname(outputPath), { recursive: true });
127
+ await writeFile(outputPath, result.archive);
128
+ stdout.write(`${JSON.stringify({ archiveSha256: result.archiveSha256, outputPath, sizeBytes: result.archive.byteLength, snapshotId })}\n`);
129
+ return;
130
+ }
131
+ throw new Error("Usage: fargo lego snapshot upload|list|download --app <appId>");
132
+ }
133
+
134
+ async function readToolArgs(args) {
135
+ const inline = optionValue(args, "--args");
136
+ const file = optionValue(args, "--args-file");
137
+ if (inline && file) throw new Error("Use only one of --args or --args-file");
138
+ const text = file ? await readFile(resolve(file), "utf8") : inline ?? "{}";
139
+ let value;
140
+ try {
141
+ value = JSON.parse(text);
142
+ } catch {
143
+ throw new Error("MCP tool arguments must be valid JSON");
144
+ }
145
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
146
+ throw new Error("MCP tool arguments must be a JSON object");
147
+ }
148
+ return value;
149
+ }
150
+
151
+ async function pathExists(path) {
152
+ try {
153
+ await lstat(path);
154
+ return true;
155
+ } catch (error) {
156
+ if (error?.code === "ENOENT") return false;
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ function requiredOption(args, name) {
162
+ const value = optionValue(args, name)?.trim();
163
+ if (!value) throw new Error(`Missing required option ${name}`);
164
+ return value;
165
+ }
166
+
82
167
  async function authorizeDevice(args, stdout, store, options) {
83
168
  const device = await startDeviceAuthorization(options);
84
169
  stdout.write(`${JSON.stringify({
package/src/constants.mjs CHANGED
@@ -1,6 +1,8 @@
1
- export const CLI_VERSION = "0.4.2";
1
+ export const CLI_VERSION = "0.5.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
+ export const DEFAULT_API_URL = "https://mcp.fargowealth.app";
4
5
  export const DEFAULT_MCP_URL = "https://mcp.fargowealth.app/mcp";
5
6
  export const OAUTH_CLIENT_ID = "fargo-cli-workbuddy";
6
7
  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";
8
+ export const SUPPORTED_AGENT_TYPES = ["codex", "claude-code", "cursor", "workbuddy"];
@@ -10,10 +10,10 @@ export function createCredentialStore(options = {}) {
10
10
  const home = options.home ?? homedir();
11
11
  const storeKind = env.FARGO_CREDENTIAL_STORE;
12
12
  if (storeKind === "keychain") {
13
- throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by the WorkBuddy CLI; use file on macOS");
13
+ throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by Fargo CLI; use file on macOS");
14
14
  }
15
15
  if (storeKind === "dpapi") {
16
- throw new Error("FARGO_CREDENTIAL_STORE=dpapi is not supported by the WorkBuddy CLI; use file on Windows");
16
+ throw new Error("FARGO_CREDENTIAL_STORE=dpapi is not supported by Fargo CLI; use file on Windows");
17
17
  }
18
18
  if (storeKind && storeKind !== "file") {
19
19
  throw new Error(`Unsupported FARGO_CREDENTIAL_STORE: ${storeKind}`);
@@ -0,0 +1,75 @@
1
+ import { DEFAULT_API_URL } from "./constants.mjs";
2
+ import { authenticatedRequest, responsePayload } from "./api-client.mjs";
3
+ import { createSourceSnapshot } from "./source-snapshot.mjs";
4
+
5
+ export async function uploadLegoSourceSnapshot(store, input, options = {}) {
6
+ const snapshot = await createSourceSnapshot(input.source ?? ".", options.snapshotOptions);
7
+ if (input.dryRun) {
8
+ return { dryRun: true, snapshot: snapshotSummary(snapshot) };
9
+ }
10
+
11
+ const url = snapshotCollectionUrl(input.appId, options);
12
+ if (input.version) url.searchParams.set("version", input.version);
13
+ const response = await authenticatedRequest(store, url, {
14
+ method: "POST",
15
+ headers: {
16
+ Accept: "application/json",
17
+ "Content-Type": "application/zip",
18
+ "Content-Length": String(snapshot.archive.byteLength),
19
+ "X-Fargo-Source-Archive-Sha256": snapshot.archiveSha256,
20
+ "X-Fargo-Source-Content-Sha256": snapshot.contentSha256,
21
+ "X-Fargo-Source-File-Count": String(snapshot.fileCount),
22
+ "X-Fargo-Source-Size": String(snapshot.sourceSizeBytes)
23
+ },
24
+ body: snapshot.archive
25
+ }, options);
26
+ const payload = await responsePayload(response);
27
+ return {
28
+ ...payload,
29
+ local: snapshotSummary(snapshot)
30
+ };
31
+ }
32
+
33
+ export async function listLegoSourceSnapshots(store, appId, options = {}) {
34
+ const response = await authenticatedRequest(store, snapshotCollectionUrl(appId, options), {
35
+ headers: { Accept: "application/json" }
36
+ }, options);
37
+ return responsePayload(response);
38
+ }
39
+
40
+ export async function downloadLegoSourceSnapshot(store, input, options = {}) {
41
+ const base = snapshotCollectionUrl(input.appId, options);
42
+ base.pathname = `${base.pathname}/${encodeURIComponent(input.snapshotId)}/download`;
43
+ const response = await authenticatedRequest(store, base, {
44
+ headers: { Accept: "application/zip" }
45
+ }, options);
46
+ if (!response.ok) await responsePayload(response);
47
+ return {
48
+ archive: new Uint8Array(await response.arrayBuffer()),
49
+ archiveSha256: response.headers.get("x-fargo-source-archive-sha256") ?? undefined,
50
+ fileName: responseFileName(response) ?? `${input.appId}-${input.snapshotId}.zip`
51
+ };
52
+ }
53
+
54
+ function snapshotCollectionUrl(appId, options) {
55
+ const apiUrl = (options.apiUrl ?? process.env.FARGO_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
56
+ return new URL(`${apiUrl}/api/lego/apps/${encodeURIComponent(appId)}/source-snapshots`);
57
+ }
58
+
59
+ function snapshotSummary(snapshot) {
60
+ return {
61
+ archiveName: snapshot.archiveName,
62
+ archiveSha256: snapshot.archiveSha256,
63
+ archiveSizeBytes: snapshot.archive.byteLength,
64
+ contentSha256: snapshot.contentSha256,
65
+ fileCount: snapshot.fileCount,
66
+ root: snapshot.root,
67
+ skippedSymlinkCount: snapshot.skippedSymlinkCount,
68
+ sourceSizeBytes: snapshot.sourceSizeBytes
69
+ };
70
+ }
71
+
72
+ function responseFileName(response) {
73
+ const disposition = response.headers.get("content-disposition") ?? "";
74
+ return disposition.match(/filename="([^"]+)"/i)?.[1];
75
+ }
@@ -25,7 +25,7 @@ export async function createLoopbackReceiver(options = {}) {
25
25
  }
26
26
  if (settled) {
27
27
  response.writeHead(409, secureHtmlHeaders());
28
- response.end(completionPage(false, "这次授权回调已经处理,请返回 WorkBuddy。"));
28
+ response.end(completionPage(false, "这次授权回调已经处理,请返回发起授权的 Agent。"));
29
29
  return;
30
30
  }
31
31
  settled = true;
@@ -0,0 +1,34 @@
1
+ import { DEFAULT_MCP_URL } from "./constants.mjs";
2
+ import { authenticatedRequest, responsePayload } from "./api-client.mjs";
3
+
4
+ export async function listMcpTools(store, options = {}) {
5
+ const result = await mcpRequest(store, "tools/list", {}, options);
6
+ return result;
7
+ }
8
+
9
+ export async function callMcpTool(store, name, args, options = {}) {
10
+ return mcpRequest(store, "tools/call", { name, arguments: args }, options);
11
+ }
12
+
13
+ async function mcpRequest(store, method, params, options) {
14
+ const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
15
+ const response = await authenticatedRequest(store, mcpUrl, {
16
+ method: "POST",
17
+ headers: {
18
+ Accept: "application/json",
19
+ "Content-Type": "application/json",
20
+ "MCP-Protocol-Version": "2025-06-18"
21
+ },
22
+ body: JSON.stringify({
23
+ jsonrpc: "2.0",
24
+ id: crypto.randomUUID(),
25
+ method,
26
+ params
27
+ })
28
+ }, options);
29
+ const rpc = await responsePayload(response);
30
+ if (rpc.error) {
31
+ throw new Error(rpc.error.message || "Fargo MCP request failed");
32
+ }
33
+ return rpc.result ?? {};
34
+ }
package/src/mcp-proxy.mjs CHANGED
@@ -34,7 +34,7 @@ export async function runMcpProxy(store, options = {}) {
34
34
  "Authorization": `Bearer ${credential.access_token}`,
35
35
  "Content-Type": "application/json",
36
36
  "MCP-Protocol-Version": "2025-11-25",
37
- "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy`
37
+ "User-Agent": `fargo-cli/${CLI_VERSION}`
38
38
  };
39
39
  if (sessionId) headers["Mcp-Session-Id"] = sessionId;
40
40
  const response = await fetchImpl(mcpUrl, { method: "POST", headers, body: JSON.stringify(message) });
@@ -0,0 +1,184 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, opendir, readFile } from "node:fs/promises";
3
+ import { basename, join, resolve, sep } from "node:path";
4
+ import { strToU8, zipSync } from "fflate";
5
+ import ignore from "ignore";
6
+
7
+ export const MAX_ARCHIVE_BYTES = 50_000_000;
8
+ export const MAX_FILE_BYTES = 20_000_000;
9
+ export const MAX_FILE_COUNT = 10_000;
10
+ export const MAX_SOURCE_BYTES = 100_000_000;
11
+
12
+ export const DEFAULT_IGNORE_RULES = [
13
+ ".git/",
14
+ "node_modules/",
15
+ "dist/",
16
+ "build/",
17
+ ".next/",
18
+ ".nuxt/",
19
+ ".cache/",
20
+ ".turbo/",
21
+ ".wrangler/",
22
+ "coverage/",
23
+ ".fargo-snapshot/",
24
+ ".DS_Store",
25
+ "Thumbs.db",
26
+ "*.log",
27
+ "*.zip",
28
+ "*.tar",
29
+ "*.tar.gz",
30
+ "*.tgz",
31
+ ".env",
32
+ ".env.*",
33
+ "!.env.example",
34
+ "!.env.sample",
35
+ "!.env.template",
36
+ ".npmrc",
37
+ ".pypirc",
38
+ "*.pem",
39
+ "*.key",
40
+ "id_rsa*",
41
+ "credentials*.json",
42
+ "*.mp4",
43
+ "*.mov",
44
+ "*.avi",
45
+ "*.mkv",
46
+ "*.webm",
47
+ "*.mp3",
48
+ "*.wav",
49
+ "*.flac",
50
+ "*.psd",
51
+ "*.sketch"
52
+ ];
53
+
54
+ const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0, 0);
55
+ const MANIFEST_PATH = ".fargo-snapshot/manifest.json";
56
+
57
+ export async function createSourceSnapshot(sourceDirectory, options = {}) {
58
+ const root = resolve(sourceDirectory || ".");
59
+ const rootStat = await lstat(root).catch(() => undefined);
60
+ if (!rootStat?.isDirectory()) {
61
+ throw new Error(`Snapshot source is not a directory: ${root}`);
62
+ }
63
+
64
+ const matcher = ignore().add(DEFAULT_IGNORE_RULES);
65
+ const customRules = await readOptionalText(join(root, ".legoignore"));
66
+ if (customRules) matcher.add(customRules);
67
+
68
+ const sourceFiles = [];
69
+ const skippedSymlinks = [];
70
+ await walk(root, "");
71
+ sourceFiles.sort((left, right) => compareText(left.path, right.path));
72
+ if (sourceFiles.length === 0) {
73
+ throw new Error("No source files remain after applying snapshot exclusions");
74
+ }
75
+
76
+ const files = [];
77
+ let sourceSizeBytes = 0;
78
+ const contentHash = createHash("sha256");
79
+ const archiveEntries = {};
80
+ for (const file of sourceFiles) {
81
+ const bytes = await readFile(file.absolutePath);
82
+ const sha256 = digest(bytes);
83
+ sourceSizeBytes += bytes.byteLength;
84
+ if (sourceSizeBytes > (options.maxSourceBytes ?? MAX_SOURCE_BYTES)) {
85
+ throw new Error(`Snapshot source exceeds ${formatBytes(options.maxSourceBytes ?? MAX_SOURCE_BYTES)}; add generated or media files to .legoignore`);
86
+ }
87
+ const item = { path: file.path, sha256, sizeBytes: bytes.byteLength };
88
+ files.push(item);
89
+ contentHash.update(item.path).update("\0").update(String(item.sizeBytes)).update("\0").update(item.sha256).update("\n");
90
+ archiveEntries[item.path] = [bytes, { level: 6, mtime: FIXED_ZIP_DATE }];
91
+ }
92
+
93
+ const contentSha256 = contentHash.digest("hex");
94
+ const manifest = {
95
+ schemaVersion: 1,
96
+ kind: "fargo-lego-source-snapshot",
97
+ archiveFormat: "zip",
98
+ contentSha256,
99
+ fileCount: files.length,
100
+ sourceSizeBytes,
101
+ files
102
+ };
103
+ archiveEntries[MANIFEST_PATH] = [strToU8(`${JSON.stringify(manifest, null, 2)}\n`), { level: 6, mtime: FIXED_ZIP_DATE }];
104
+ const archive = zipSync(archiveEntries, { level: 6, mtime: FIXED_ZIP_DATE });
105
+ const maxArchiveBytes = options.maxArchiveBytes ?? MAX_ARCHIVE_BYTES;
106
+ if (archive.byteLength > maxArchiveBytes) {
107
+ throw new Error(`Snapshot archive is ${formatBytes(archive.byteLength)}, above the ${formatBytes(maxArchiveBytes)} limit; update .legoignore`);
108
+ }
109
+
110
+ return {
111
+ archive,
112
+ archiveName: `${safeFileName(basename(root))}-${contentSha256.slice(0, 12)}.zip`,
113
+ archiveSha256: digest(archive),
114
+ contentSha256,
115
+ fileCount: files.length,
116
+ manifest,
117
+ root,
118
+ skippedSymlinkCount: skippedSymlinks.length,
119
+ sourceSizeBytes
120
+ };
121
+
122
+ async function walk(absoluteDirectory, relativeDirectory) {
123
+ const directory = await opendir(absoluteDirectory);
124
+ const entries = [];
125
+ for await (const entry of directory) entries.push(entry);
126
+ entries.sort((left, right) => compareText(left.name, right.name));
127
+
128
+ for (const entry of entries) {
129
+ const relativePath = normalizePath(relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name);
130
+ const absolutePath = join(absoluteDirectory, entry.name);
131
+ if (entry.isSymbolicLink()) {
132
+ skippedSymlinks.push(relativePath);
133
+ continue;
134
+ }
135
+ if (entry.isDirectory()) {
136
+ if (!matcher.ignores(`${relativePath}/`)) await walk(absolutePath, relativePath);
137
+ continue;
138
+ }
139
+ if (!entry.isFile() || matcher.ignores(relativePath)) continue;
140
+ if (relativePath === MANIFEST_PATH) continue;
141
+
142
+ const stat = await lstat(absolutePath);
143
+ const maxFileBytes = options.maxFileBytes ?? MAX_FILE_BYTES;
144
+ if (stat.size > maxFileBytes) {
145
+ throw new Error(`Source file '${relativePath}' is ${formatBytes(stat.size)}, above the ${formatBytes(maxFileBytes)} per-file limit; add it to .legoignore or raise the limit explicitly`);
146
+ }
147
+ sourceFiles.push({ absolutePath, path: relativePath });
148
+ if (sourceFiles.length > (options.maxFileCount ?? MAX_FILE_COUNT)) {
149
+ throw new Error(`Snapshot contains more than ${options.maxFileCount ?? MAX_FILE_COUNT} files; check dependency and build-output exclusions`);
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ function digest(value) {
156
+ return createHash("sha256").update(value).digest("hex");
157
+ }
158
+
159
+ function compareText(left, right) {
160
+ return left < right ? -1 : left > right ? 1 : 0;
161
+ }
162
+
163
+ function normalizePath(value) {
164
+ return value.split(sep).join("/").replace(/^\.\//, "");
165
+ }
166
+
167
+ async function readOptionalText(path) {
168
+ try {
169
+ return await readFile(path, "utf8");
170
+ } catch (error) {
171
+ if (error?.code === "ENOENT") return "";
172
+ throw error;
173
+ }
174
+ }
175
+
176
+ function safeFileName(value) {
177
+ return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
178
+ }
179
+
180
+ export function formatBytes(value) {
181
+ if (value < 1_000) return `${value} B`;
182
+ if (value < 1_000_000) return `${(value / 1_000).toFixed(1)} KB`;
183
+ return `${(value / 1_000_000).toFixed(1)} MB`;
184
+ }