@fargowealth/fargo-ai-cli 0.1.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/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # Fargo CLI
2
+
3
+ `fargo-cli` provides WorkBuddy with Device Authorization Grant login and a local stdio bridge to the hosted Fargo AI MCP server.
4
+
5
+ ```sh
6
+ npm install --global @fargowealth/fargo-ai-cli
7
+ fargo auth device --open-browser=always --output=jsonl
8
+ fargo auth status --output=json
9
+ fargo mcp serve
10
+ fargo auth logout
11
+ ```
12
+
13
+ OAuth credentials are stored in macOS Keychain, protected with Windows DPAPI, or written to an owner-only file on Linux.
package/bin/fargo.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/cli.mjs";
3
+
4
+ await runCli(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@fargowealth/fargo-ai-cli",
3
+ "version": "0.1.0",
4
+ "description": "Fargo AI authentication and stdio MCP bridge for WorkBuddy",
5
+ "type": "module",
6
+ "bin": {
7
+ "fargo": "bin/fargo.mjs",
8
+ "fargo-cli": "bin/fargo.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20.19.0"
17
+ },
18
+ "license": "UNLICENSED",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ }
22
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import { spawn } from "node:child_process";
2
+ import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL, DEVICE_CODE_GRANT_TYPE, OAUTH_CLIENT_ID, OAUTH_SCOPE } from "./constants.mjs";
3
+
4
+ export async function startDeviceAuthorization(options = {}) {
5
+ const fetchImpl = options.fetchImpl ?? fetch;
6
+ const authUrl = options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL;
7
+ const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
8
+ const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/device_authorization`, {
9
+ method: "POST",
10
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
11
+ body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, resource: mcpUrl, scope: OAUTH_SCOPE })
12
+ });
13
+ return readOAuthResponse(response);
14
+ }
15
+
16
+ export async function pollDeviceAuthorization(device, store, options = {}) {
17
+ const fetchImpl = options.fetchImpl ?? fetch;
18
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
19
+ const authUrl = options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL;
20
+ let interval = Math.max(1, Number(device.interval) || 5);
21
+ const expiresAt = Date.now() + Math.max(1, Number(device.expires_in) || 600) * 1000;
22
+ while (Date.now() < expiresAt) {
23
+ await sleep(interval * 1000);
24
+ const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/token`, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
27
+ body: new URLSearchParams({ grant_type: DEVICE_CODE_GRANT_TYPE, client_id: OAUTH_CLIENT_ID, device_code: device.device_code })
28
+ });
29
+ const payload = await response.json().catch(() => ({}));
30
+ if (response.ok) {
31
+ const credential = credentialFromToken(payload, authUrl);
32
+ await store.save(credential);
33
+ return credential;
34
+ }
35
+ if (payload.error === "authorization_pending") continue;
36
+ if (payload.error === "slow_down") { interval += 5; continue; }
37
+ throw new Error(payload.error_description || payload.message || payload.error || `Token request failed (${response.status})`);
38
+ }
39
+ throw new Error("Device authorization expired");
40
+ }
41
+
42
+ export async function refreshCredential(store, credential, options = {}) {
43
+ const fetchImpl = options.fetchImpl ?? fetch;
44
+ const authUrl = credential.auth_url ?? options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL;
45
+ const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/token`, {
46
+ method: "POST",
47
+ 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: OAUTH_CLIENT_ID, refresh_token: credential.refresh_token })
49
+ });
50
+ const payload = await readOAuthResponse(response);
51
+ const refreshed = credentialFromToken(payload, authUrl);
52
+ await store.save(refreshed);
53
+ return refreshed;
54
+ }
55
+
56
+ export async function currentCredential(store, options = {}) {
57
+ let credential = await store.load();
58
+ if (!credential) return undefined;
59
+ if (!credential.access_token || Number(credential.expires_at) <= Date.now() + 60_000) {
60
+ credential = await refreshCredential(store, credential, options);
61
+ }
62
+ return credential;
63
+ }
64
+
65
+ export function openBrowser(url, platform = process.platform) {
66
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd.exe" : "xdg-open";
67
+ const args = platform === "win32" ? ["/d", "/s", "/c", "start", "", url] : [url];
68
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
69
+ child.unref();
70
+ }
71
+
72
+ function credentialFromToken(payload, authUrl) {
73
+ if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
74
+ throw new Error("OAuth token response did not contain rotating credentials");
75
+ }
76
+ return { ...payload, auth_url: authUrl, obtained_at: Date.now(), expires_at: Date.now() + Math.max(1, Number(payload.expires_in) || 3600) * 1000 };
77
+ }
78
+
79
+ async function readOAuthResponse(response) {
80
+ const payload = await response.json().catch(() => ({}));
81
+ if (!response.ok) throw new Error(payload.error_description || payload.message || payload.error || `OAuth request failed (${response.status})`);
82
+ return payload;
83
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,59 @@
1
+ import { createCredentialStore } from "./credential-store.mjs";
2
+ import { currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
3
+ import { CLI_VERSION } from "./constants.mjs";
4
+ import { runMcpProxy } from "./mcp-proxy.mjs";
5
+
6
+ export async function runCli(args, options = {}) {
7
+ const stdout = options.stdout ?? process.stdout;
8
+ const stderr = options.stderr ?? process.stderr;
9
+ const store = options.store ?? createCredentialStore(options.storeOptions);
10
+ try {
11
+ if (args[0] === "version" || args[0] === "--version" || args[0] === "-v") {
12
+ stdout.write(`fargo-cli version ${CLI_VERSION}\n`);
13
+ return;
14
+ }
15
+ if (args[0] === "auth" && args[1] === "device") {
16
+ const device = await startDeviceAuthorization(options);
17
+ stdout.write(`${JSON.stringify({
18
+ type: "device_authorization",
19
+ user_code: device.user_code,
20
+ verification_uri: device.verification_uri,
21
+ verification_uri_complete: device.verification_uri_complete,
22
+ expires_in: device.expires_in,
23
+ interval: device.interval
24
+ })}\n`);
25
+ const browserMode = optionValue(args, "--open-browser") ?? "auto";
26
+ if (browserMode !== "never" && options.openBrowser !== false) (options.browserOpener ?? openBrowser)(device.verification_uri_complete || device.verification_uri);
27
+ await pollDeviceAuthorization(device, store, options);
28
+ stdout.write(`${JSON.stringify({ type: "authorization_complete", logged_in: true, method: "oauth" })}\n`);
29
+ return;
30
+ }
31
+ if (args[0] === "auth" && args[1] === "status") {
32
+ const credential = await store.load();
33
+ 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`);
34
+ return;
35
+ }
36
+ if (args[0] === "auth" && (args[1] === "logout" || args[1] === "revoke")) {
37
+ await store.clear();
38
+ stdout.write(`${JSON.stringify({ logged_in: false, method: null })}\n`);
39
+ return;
40
+ }
41
+ if (args[0] === "mcp" && args[1] === "serve") {
42
+ await currentCredential(store, options);
43
+ await runMcpProxy(store, options);
44
+ return;
45
+ }
46
+ throw new Error("Usage: fargo auth device|status|logout | fargo mcp serve | fargo version");
47
+ } catch (error) {
48
+ stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
49
+ if (options.throwOnError) throw error;
50
+ process.exitCode = 1;
51
+ }
52
+ }
53
+
54
+ function optionValue(args, name) {
55
+ const inline = args.find((value) => value.startsWith(`${name}=`));
56
+ if (inline) return inline.slice(name.length + 1);
57
+ const index = args.indexOf(name);
58
+ return index >= 0 ? args[index + 1] : undefined;
59
+ }
@@ -0,0 +1,6 @@
1
+ export const CLI_VERSION = "0.1.0";
2
+ export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
3
+ export const DEFAULT_AUTH_URL = "https://auth.fargowealth.app";
4
+ export const DEFAULT_MCP_URL = "https://mcp.fargowealth.app/mcp";
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";
@@ -0,0 +1,100 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+
6
+ const KEYCHAIN_SERVICE = "com.fargowealth.fargo-cli.oauth";
7
+
8
+ export function createCredentialStore(options = {}) {
9
+ const platform = options.platform ?? process.platform;
10
+ const env = options.env ?? process.env;
11
+ const home = options.home ?? homedir();
12
+ const run = options.run ?? runCommand;
13
+ const filePath = env.FARGO_CREDENTIAL_PATH ?? join(home, ".fargo", "credentials.json");
14
+ const forceFile = env.FARGO_CREDENTIAL_STORE === "file";
15
+
16
+ if (!forceFile && platform === "darwin") {
17
+ const account = env.USER || env.LOGNAME || "fargo-user";
18
+ return {
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
+ };
34
+ }
35
+
36
+ if (!forceFile && platform === "win32") {
37
+ const protectedPath = env.FARGO_CREDENTIAL_PATH ?? join(env.APPDATA || home, "Fargo AI", "credentials.bin");
38
+ return {
39
+ kind: "windows-dpapi",
40
+ async load() {
41
+ 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
+ const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath } });
43
+ if (result.code !== 0 || !result.stdout.trim()) return undefined;
44
+ return parseCredential(result.stdout.trim());
45
+ },
46
+ async save(value) {
47
+ const script = "$p=$env:FARGO_CREDENTIAL_PATH;$d=Split-Path -Parent $p;[IO.Directory]::CreateDirectory($d)|Out-Null;$s=[Console]::In.ReadToEnd();$b=[Text.Encoding]::UTF8.GetBytes($s);$e=[Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[IO.File]::WriteAllBytes($p,$e)";
48
+ const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath }, stdin: JSON.stringify(value) });
49
+ if (result.code !== 0) throw new Error("Unable to save Fargo credentials with Windows DPAPI");
50
+ },
51
+ async clear() {
52
+ await rm(protectedPath, { force: true });
53
+ }
54
+ };
55
+ }
56
+
57
+ return {
58
+ kind: "private-file",
59
+ async load() {
60
+ try {
61
+ return parseCredential(await readFile(filePath, "utf8"));
62
+ } catch (error) {
63
+ if (error?.code === "ENOENT") return undefined;
64
+ throw error;
65
+ }
66
+ },
67
+ async save(value) {
68
+ await mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
69
+ await writeFile(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
70
+ },
71
+ async clear() {
72
+ await rm(filePath, { force: true });
73
+ }
74
+ };
75
+ }
76
+
77
+ function parseCredential(value) {
78
+ const parsed = JSON.parse(value);
79
+ if (!parsed || typeof parsed !== "object" || typeof parsed.refresh_token !== "string") {
80
+ throw new Error("Saved Fargo credential is invalid");
81
+ }
82
+ return parsed;
83
+ }
84
+
85
+ function runCommand(command, args, options = {}) {
86
+ return new Promise((resolve, reject) => {
87
+ const child = spawn(command, args, { env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
88
+ let stdout = "";
89
+ let stderr = "";
90
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
91
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
92
+ child.on("error", reject);
93
+ child.on("close", (code) => {
94
+ const result = { code: code ?? 1, stderr, stdout };
95
+ if (!options.allowFailure && result.code !== 0) reject(new Error(stderr.trim() || `${command} failed`));
96
+ else resolve(result);
97
+ });
98
+ child.stdin.end(options.stdin ?? "");
99
+ });
100
+ }
@@ -0,0 +1,56 @@
1
+ import { createInterface } from "node:readline";
2
+ import { CLI_VERSION, DEFAULT_MCP_URL } from "./constants.mjs";
3
+ import { currentCredential, refreshCredential } from "./auth.mjs";
4
+
5
+ export async function runMcpProxy(store, options = {}) {
6
+ const input = options.input ?? process.stdin;
7
+ const output = options.output ?? process.stdout;
8
+ const fetchImpl = options.fetchImpl ?? fetch;
9
+ const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
10
+ let sessionId;
11
+ let queue = Promise.resolve();
12
+ const lines = createInterface({ input, crlfDelay: Infinity });
13
+ for await (const line of lines) {
14
+ if (!line.trim()) continue;
15
+ queue = queue.then(async () => {
16
+ let message;
17
+ try { message = JSON.parse(line); }
18
+ catch { return; }
19
+ try {
20
+ const responses = await forward(message, false);
21
+ for (const response of responses) output.write(`${JSON.stringify(response)}\n`);
22
+ } catch (error) {
23
+ if (message.id !== undefined) output.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32001, message: error instanceof Error ? error.message : "Fargo MCP proxy failed" } })}\n`);
24
+ }
25
+ });
26
+ }
27
+ await queue;
28
+
29
+ async function forward(message, didRefresh) {
30
+ let credential = await currentCredential(store, { fetchImpl });
31
+ if (!credential) throw new Error("Fargo CLI is not authenticated");
32
+ const headers = {
33
+ "Accept": "application/json, text/event-stream",
34
+ "Authorization": `Bearer ${credential.access_token}`,
35
+ "Content-Type": "application/json",
36
+ "MCP-Protocol-Version": "2025-11-25",
37
+ "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy`
38
+ };
39
+ if (sessionId) headers["Mcp-Session-Id"] = sessionId;
40
+ const response = await fetchImpl(mcpUrl, { method: "POST", headers, body: JSON.stringify(message) });
41
+ if (response.status === 401 && !didRefresh) {
42
+ credential = await refreshCredential(store, credential, { fetchImpl });
43
+ return forward(message, true);
44
+ }
45
+ if (!response.ok && response.status !== 202) throw new Error(`Fargo MCP returned HTTP ${response.status}`);
46
+ sessionId = response.headers.get("mcp-session-id") || sessionId;
47
+ if (response.status === 202 || !response.body) return [];
48
+ const text = await response.text();
49
+ if (!text.trim()) return [];
50
+ if (response.headers.get("content-type")?.includes("text/event-stream")) {
51
+ return text.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).filter(Boolean).map(JSON.parse);
52
+ }
53
+ const parsed = JSON.parse(text);
54
+ return Array.isArray(parsed) ? parsed : [parsed];
55
+ }
56
+ }