From a663eb7655b257ca3c7ed383fa760bd16991e637 Mon Sep 17 00:00:00 2001 From: Sil Date: Sat, 10 Oct 2026 16:52:13 +0200 Subject: [PATCH 1/2] Mirror packages/cli from the monorepo (bafed8a) Several organizations in one sign-in: --org, --all-orgs, organizations in fulldev status, a 10-minute sign-in wait and a hint for fulldev login . Source and changeset several-organizations.md from fulldotdev/fulldev at bafed8a (#115). Co-Authored-By: Claude Opus 5.5 --- .changeset/several-organizations.md | 5 + README.md | 17 +++- dist/cli.mjs | 149 +++++++++++++++++++++++----- skills/fulldev/SKILL.md | 12 ++- src/args.test.ts | 32 ++++++ src/args.ts | 81 +++++++++++---- src/errors.ts | 8 +- src/main.ts | 114 +++++++++++++++++++-- src/oauth.ts | 5 +- 9 files changed, 363 insertions(+), 60 deletions(-) create mode 100644 .changeset/several-organizations.md diff --git a/.changeset/several-organizations.md b/.changeset/several-organizations.md new file mode 100644 index 0000000..b846d4f --- /dev/null +++ b/.changeset/several-organizations.md @@ -0,0 +1,5 @@ +--- +"fulldev": minor +--- + +Work in several organizations with one sign-in. `fulldev login` lets you tick the organizations to grant, all of yours at first, instead of choosing one; a call names its organization with `--org `, and `--all-orgs` calls a tool that only reads in every organization that has its app. `fulldev status` lists the organizations with their slugs and apps under `organizations`, instead of `organizationId`. Sign-in waits up to 10 minutes instead of 5, and `fulldev login ` says to run `fulldev login` without the app. diff --git a/README.md b/README.md index 9329a09..923a898 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,16 @@ npx skills add fulldotdev/cli ## Sign in ```sh -fulldev login # once, for every app -fulldev status # signed in, valid, email, organization, expiry +fulldev login # once, for every app and organization +fulldev status # signed in, valid, email, organizations, expiry fulldev logout ``` -`fulldev login` opens your browser at [app.full.dev](https://app.full.dev/sign-in), where you sign in with your Fulldev account, with a code sent to your email or with Google, and choose your organization when you are in more than one. Add `--no-browser` to only print the link. +`fulldev login` opens your browser at [app.full.dev](https://app.full.dev/sign-in), where you sign in with your Fulldev account, with a code sent to your email or with Google, and choose the organizations fulldev may work in: all of yours at first. It waits up to 10 minutes. Add `--no-browser` to only print the link. On a computer without a browser, such as over SSH, run `fulldev login --no-browser` and open the link on your own computer. The page after sign-in does not load there, because it is meant for the computer that runs `fulldev`: copy its address from the address bar and paste it into the terminal. -One sign-in covers every app the organization gives you: the tokens are for the Fulldev MCP server, which checks on every call which apps you may use. When you are in more than one organization, you choose one at every sign-in; to switch organization, run `fulldev login` again. +One sign-in covers every app of the organizations you chose: the tokens are for the Fulldev MCP server, which checks on every call which apps you may use in which organization. To change the organizations, run `fulldev login` again. `fulldev status` lists them, with their slugs and the apps you may use in each. `fulldev logout` revokes the refresh token at the authorization server (access tokens cannot be revoked and expire within 15 minutes) and deletes the tokens from this computer. When revoking fails, for example offline, it still deletes them and says so. @@ -77,6 +77,15 @@ fulldev help cms call # help for any command After `fulldev `, a tool's name may leave out the app's prefix: `fulldev cms call read_file` calls `cms_read_file`. +When the sign-in is for more than one organization, a call names the organization to work in with `--org `; without it the tool answers `ORGANIZATION_REQUIRED`. `fulldev tools` and `fulldev instructions` show the tools of every organization together. `--all-orgs` calls a tool that only reads in every organization that has its app and prints the answers together, each with its organization: + +```sh +fulldev cms call list_repositories --org het-clubhuis +fulldev cms call list_repositories --all-orgs # {"organizations":[{"organization":{...},"result":{...}}, ...]} +``` + +On the server the organization is the tool's `organization` argument, which every app tool takes when the sign-in is for several; `fulldev_list_organizations` lists them. + `call` prints the tool's result as JSON on stdout. When the tool fails, it prints the error on stderr and exits with 1. `fulldev cms form wait` keeps calling `cms_wait_for_form` until the person sends the form, with a progress line on stderr each round, then prints the result. After `--timeout` minutes (default 30) it stops, also in the middle of a request, prints the form id, and exits with 2, so you can run it again. diff --git a/dist/cli.mjs b/dist/cli.mjs index 74aabe2..9f1d072 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -33,8 +33,11 @@ var CliError = class extends Error { }; /** A mistake in how the CLI was called. */ var UsageError = class extends CliError { - constructor(message, help = "fulldev --help") { - super("USAGE", message, exitCodes.usage, { help }); + constructor(message, help = "fulldev --help", details = {}) { + super("USAGE", message, exitCodes.usage, { + help, + ...details + }); this.name = "UsageError"; } }; @@ -379,14 +382,21 @@ function resolveTarget(flagUrl, env) { } //#endregion //#region src/args.ts +const organizationDetails = ` +When the sign-in is for more than one organization, --org names the one to +work in, by its slug from fulldev status. --all-orgs calls a tool that only +reads in every organization of the sign-in that has its app, and prints +{"organizations":[{"organization","result" or "error"}]}; it exits with 1 +when one failed.`; const rootCommands = { login: { usage: "fulldev login [--no-browser] [--url ]", - summary: "Sign in, once for every app", + summary: "Sign in, once for every app and organization", options: ["browser", "url"], - details: `Signs in to the Fulldev MCP server in your browser, where you choose your -organization when you are in more than one. One sign-in covers every app -the organization gives you. Run fulldev login again to switch organization. + details: `Signs in to the Fulldev MCP server in your browser, where you choose the +organizations fulldev may work in: all of yours at first. One sign-in covers +every app they give you. Run fulldev login again to change the choice, and +choose the organization of a call with --org. With --no-browser, open the link in a browser on any computer. When that is another computer, as over SSH, the page after sign-in does not load: copy its @@ -406,8 +416,8 @@ the result says so.` summary: "Show the sign-in", options: ["url"], details: `Prints whether you are signed in, whether the sign-in still works, your -email, organization and when the access token expires, and where the tokens -are stored. Exits with 3 when a sign-in is needed, and with 1 when the +email, the organizations of the sign-in with their slugs and the apps you may +use in each, when the access token expires, and where the tokens are stored. Exits with 3 when a sign-in is needed, and with 1 when the server could not be reached to check it.` }, instructions: { @@ -435,17 +445,20 @@ tool. With a name: its description, input schema, output schema and annotations. Each app's tools start with its name, such as cms_.` }, call: { - usage: "fulldev call [json | -] [--file ]", + usage: "fulldev call [json | -] [--file ] [--org ]", summary: "Call a tool with a JSON object", options: [ "file", + "org", + "all-orgs", "url", "login", "browser" ], details: `The tool's input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on -stdout. When the tool fails, prints its error on stderr and exits with 1.` +stdout. When the tool fails, prints its error on stderr and exits with 1. +${organizationDetails}` }, help: { usage: "fulldev help [command...]", @@ -482,10 +495,12 @@ of the app's tools. With a name, with or without the app's prefix: its description, input schema, output schema and annotations.` }, call: { - usage: "fulldev call [json | -] [--file ]", + usage: "fulldev call [json | -] [--file ] [--org ]", summary: "Call one of the app's tools with a JSON object", options: [ "file", + "org", + "all-orgs", "url", "login", "browser" @@ -494,14 +509,16 @@ description, input schema, output schema and annotations.` list_repositories calls cms_list_repositories. Its input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints -its error on stderr and exits with 1.` +its error on stderr and exits with 1. +${organizationDetails}` } }; const formWait = { - usage: "fulldev form wait [--timeout ]", + usage: "fulldev form wait [--timeout ] [--org ]", summary: "Wait until the person sends a form", options: [ "timeout", + "org", "url", "login", "browser" @@ -515,7 +532,9 @@ const optionHelp = { file: "-f, --file Read the tool's JSON input from a file", timeout: "--timeout How long to wait (default 30)", login: "--no-login Fail with exit code 3 instead of signing in", - browser: "--no-browser Print the sign-in link, for a browser here or elsewhere" + browser: "--no-browser Print the sign-in link, for a browser here or elsewhere", + org: "--org The organization to work in", + "all-orgs": "--all-orgs Every organization of the sign-in (reading tools)" }; const footer = `Data is JSON on stdout; progress lines go to stderr. Every error is one JSON object on stderr: {"error":{"code","message",...}}. @@ -540,7 +559,8 @@ const serverLine = `Server: ${server.url} (${urlVariable} or --url overrides it) function rootHelp() { const width = Math.max(...apps.map((app) => app.name.length)) + 4; return `Fulldev CLI: use Fulldev apps from a terminal or an AI agent, through the -Fulldev MCP server. One sign-in covers every app your organization gives you. +Fulldev MCP server. One sign-in covers every app of the organizations you +choose; --org picks one per call. Usage: fulldev [options] @@ -631,6 +651,8 @@ function parseCommandLine(argv, env = process.env) { type: "string", short: "f" }, + org: { type: "string" }, + "all-orgs": { type: "boolean" }, timeout: { type: "string" }, login: { type: "boolean" }, browser: { type: "boolean" }, @@ -678,7 +700,11 @@ function parseCommandLine(argv, env = process.env) { }; if (first === "login" || first === "logout" || first === "status") { allow(first, rootCommands[first].options); - if (rest.length) throw new UsageError(`fulldev ${first} takes no app: one sign-in covers every app.`, helpFor(first)); + if (rest.length) { + const app = findApp(rest[0]); + const command = `fulldev ${first}${target.urlFromFlag ? ` --url ${target.url}` : ""}`; + throw new UsageError(app ? `One sign-in covers every app, ${app.title} too. Run: ${command}` : `fulldev ${first} takes no app: one sign-in covers every app. Run: ${command}`, helpFor(first), { command }); + } if (first === "login") return { kind: "login", target, @@ -727,13 +753,16 @@ function parseCommandLine(argv, env = process.env) { }; case "call": usage(args.length === 1 || args.length === 2); + if (values.org !== void 0 && values["all-orgs"]) throw new UsageError("Use --org or --all-orgs, not both.", helpFor(topic)); return { kind: "call", target, session, tool: app ? toolName(app, args[0]) : args[0], json: args[1], - file: values.file + file: values.file, + organization: values.org, + allOrganizations: values["all-orgs"] }; default: { usage(app !== void 0 && args[0] === "wait" && args.length === 3); @@ -746,7 +775,8 @@ function parseCommandLine(argv, env = process.env) { app, branchId: args[1], formId: args[2], - timeoutMinutes + timeoutMinutes, + organization: values.org }; } } @@ -1146,7 +1176,7 @@ function openBrowser(url) { * app's MCP URL as the resource. Without the browser, the address after * sign-in can also be pasted, for a browser on another computer. */ -async function browserLogin(target, store, { browser, log, timeoutMs = 3e5, fetchFn, input = process.stdin }) { +async function browserLogin(target, store, { browser, log, timeoutMs = 6e5, fetchFn, input = process.stdin }) { const discovery = await discover(target, fetchFn); if (discovery.metadata.client_id_metadata_document_supported !== true) throw new CliError("CLIENT_METADATA_UNSUPPORTED", `The authorization server ${discovery.issuer} does not support Client ID Metadata Documents, which fulldev needs to sign in to ${target.title}.`); const callback = await listenForCallback(); @@ -1379,6 +1409,18 @@ async function listTools(client) { } while (cursor); return tools; } +/** The server's own tool that lists the organizations of the sign-in. */ +const organizationsTool = "fulldev_list_organizations"; +/** The organizations of the sign-in, with the apps the person may use in each. */ +async function listOrganizations(client) { + const result = await client.callTool({ + name: organizationsTool, + arguments: {} + }, { timeout: requestTimeoutMs }); + if (result.isError) throw new CliError("ERROR", JSON.stringify(toolError(result))); + const { organizations } = toolOutput(result); + return organizations ?? []; +} /** The sign-in, and whether the server still accepts it. */ async function signInStatus(target, io) { const { credentials, storage } = await io.store.read(target.url); @@ -1389,8 +1431,16 @@ async function signInStatus(target, io) { }; let valid = true; let error; + let organizations; try { - await (await connect(target, io)).close().catch(() => {}); + const client = await connect(target, io); + try { + organizations = await listOrganizations(client); + } catch (cause) { + error = `The organizations could not be listed: ${cause.message}`; + } finally { + await client.close().catch(() => {}); + } } catch (cause) { if (isSignInRequired(cause)) valid = false; else { @@ -1399,7 +1449,6 @@ async function signInStatus(target, io) { } } const current = await io.store.get(target.url); - const access = claims(current.tokens?.access_token); const expires = expiresAt(current); return { ...base, @@ -1407,7 +1456,11 @@ async function signInStatus(target, io) { valid, ...error ? { error } : {}, email: current.email, - organizationId: access.org_id, + organizations: organizations?.map(({ slug, name, apps }) => ({ + slug, + name, + apps + })), expiresAt: expires ? new Date(expires).toISOString() : void 0, storage }; @@ -1456,9 +1509,52 @@ async function logout(target, io) { function instructionsFor(text, app) { const [overview = "", ...sections] = text.split(/\n(?=## )/); const own = sections.find((section) => section.split("\n")[0].endsWith(`(${app.name}_)`)); - if (!own) throw new CliError("APP_NOT_AVAILABLE", `${app.title} is not among your apps in this organization. Run fulldev instructions for the apps you may use, or fulldev login to choose another organization.`); + if (!own) throw new CliError("APP_NOT_AVAILABLE", `${app.title} is not among your apps in the organizations of this sign-in. Run fulldev status for the apps you may use in each, or fulldev login to choose other organizations.`); return `${overview.trimEnd()}\n\n${own.trim()}`; } +/** +* Calls a tool that only reads once in every organization of the sign-in +* whose apps include the tool's app, and prints every answer together. +*/ +async function callInEvery(command, args, io) { + const answers = await withClient(command.target, command.session, io, async (client) => { + const tool = (await listTools(client)).find(({ name }) => name === command.tool); + if (!tool) throw new CliError("NOT_FOUND", `You have no tool named ${command.tool}. Run fulldev tools for the list.`); + if (tool.annotations?.readOnlyHint !== true) throw new UsageError(`--all-orgs calls only tools that only read, and ${command.tool} changes data. Name one organization with --org.`, "fulldev help call"); + const app = command.tool.split("_")[0]; + const organizations = (await listOrganizations(client)).filter(({ apps }) => apps.includes(app)); + return Promise.all(organizations.map(async ({ slug, name }) => { + const organization = { + slug, + name + }; + try { + const result = await client.callTool({ + name: command.tool, + arguments: { + ...args, + organization: slug + } + }, { timeout: requestTimeoutMs }); + return result.isError ? { + organization, + ...toolError(result) + } : { + organization, + result: toolOutput(result) + }; + } catch (error) { + if (isSignInRequired(error)) throw error; + return { + organization, + ...describeError(error).body + }; + } + })); + }); + io.stdout(json({ organizations: answers })); + return answers.some((answer) => "error" in answer) ? exitCodes.error : exitCodes.ok; +} async function run(command, io) { const log = (line) => io.stderr(`${line}\n`); switch (command.kind) { @@ -1521,6 +1617,8 @@ async function run(command, io) { json: command.json, file: command.file }); + if (command.allOrganizations) return callInEvery(command, args, io); + if (command.organization !== void 0) args.organization = command.organization; const result = await withClient(command.target, command.session, io, (client) => client.callTool({ name: command.tool, arguments: args @@ -1535,7 +1633,10 @@ async function run(command, io) { case "form-wait": { const outcome = await withClient(command.target, command.session, io, (client) => waitForForm((name, args, signal) => client.callTool({ name, - arguments: args + arguments: command.organization === void 0 ? args : { + ...args, + organization: command.organization + } }, { timeout: requestTimeoutMs, signal diff --git a/skills/fulldev/SKILL.md b/skills/fulldev/SKILL.md index 7ab3816..fe93567 100644 --- a/skills/fulldev/SKILL.md +++ b/skills/fulldev/SKILL.md @@ -49,19 +49,23 @@ Exit codes: ## Sign in -The person signs in once, for every app, with their Fulldev account on app.full.dev, with a code sent to their email or with Google, and picks an organization when they are in more than one: +The person signs in once, for every app, with their Fulldev account on app.full.dev, with a code sent to their email or with Google, and chooses the organizations the CLI may work in: ```sh fulldev login # opens the browser -fulldev status # signed in, still valid, email, organization, expiry +fulldev status # signed in, still valid, email, organizations with their apps, expiry fulldev logout # revokes the sign-in and deletes the tokens ``` -Tell the person a sign-in page opened, or give them the link from stderr. Login waits up to five minutes. +Tell the person a sign-in page opened, or give them the link from stderr. Login waits up to ten minutes. When the person's browser is on another computer, as when you run on a server over SSH, the page after sign-in does not load. Then the person runs `fulldev login --no-browser` in a terminal on this computer, opens the link, and pastes the address of that last page into the terminal. -Without a terminal, as when you run commands, a command never starts a sign-in: it exits with 3 at once. Then run `fulldev login` and ask the person to finish it. The organization is chosen at sign-in; to switch organization, run `fulldev login` again. +Without a terminal, as when you run commands, a command never starts a sign-in: it exits with 3 at once. Then run `fulldev login` and ask the person to finish it. To add or remove an organization, run `fulldev login` again. + +## Organizations + +`fulldev status` lists the organizations of the sign-in, each with its slug and the apps the person may use there. When there is more than one, every call names its organization with `--org `: ask the person which one when it is not clear, and never guess, since a change in the wrong organization reaches another business. To read across all of them, add `--all-orgs` to a tool that only reads. ## First, read the instructions diff --git a/src/args.test.ts b/src/args.test.ts index cb29626..3ca6e9c 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -73,6 +73,34 @@ describe("parseCommandLine", () => { ).toMatchObject({ timeoutMinutes: 30 }) }) + it("reads the organization of a call or a form wait", () => { + expect( + parseCommandLine( + ["cms", "call", "list_repositories", "--org", "clubhuis"], + {}, + ), + ).toMatchObject({ kind: "call", organization: "clubhuis" }) + expect( + parseCommandLine(["call", "cms_list_repositories", "--all-orgs"], {}), + ).toMatchObject({ kind: "call", allOrganizations: true }) + expect( + parseCommandLine(["cms", "form", "wait", "b", "f", "--org", "x"], {}), + ).toMatchObject({ kind: "form-wait", organization: "x" }) + }) + + it("says to sign in without the app, which sign-ins took before", () => { + try { + parseCommandLine(["login", "contacts"], {}) + expect.unreachable() + } catch (error) { + expect(error).toBeInstanceOf(UsageError) + expect((error as UsageError).message).toBe( + "One sign-in covers every app, Fulldev Contacts too. Run: fulldev login", + ) + expect((error as UsageError).details.command).toBe("fulldev login") + } + }) + it("signs in, out and shows the status once, for every app", () => { for (const kind of ["login", "logout", "status"] as const) expect(parseCommandLine([kind], {})).toMatchObject({ @@ -151,6 +179,10 @@ describe("parseCommandLine", () => { [["cms", "tools", "--unknown"]], [["login", "cms"]], [["status", "cms"]], + [["cms", "call", "x", "--org", "a", "--all-orgs"]], + [["cms", "tools", "--org", "a"]], + [["cms", "instructions", "--all-orgs"]], + [["cms", "form", "wait", "a", "b", "--all-orgs"]], [["login", "--no-login"]], [["login", "--device"]], [["cms", "tools", "--url", "not a url"]], diff --git a/src/args.ts b/src/args.ts index dac3976..70633ba 100644 --- a/src/args.ts +++ b/src/args.ts @@ -35,6 +35,10 @@ export type Command = tool: string json?: string file?: string + /** The organization to work in, by slug or id. */ + organization?: string + /** Every organization of the sign-in that has the tool's app. */ + allOrganizations?: boolean } | { kind: "form-wait" @@ -44,6 +48,7 @@ export type Command = branchId: string formId: string timeoutMinutes: number + organization?: string } /** How a command may sign in when it needs to. */ @@ -52,7 +57,14 @@ export interface Session { browser: boolean } -type Option = "url" | "file" | "timeout" | "login" | "browser" +type Option = + | "url" + | "file" + | "timeout" + | "login" + | "browser" + | "org" + | "all-orgs" interface Topic { usage: string @@ -61,14 +73,22 @@ interface Topic { details?: string } +const organizationDetails = ` +When the sign-in is for more than one organization, --org names the one to +work in, by its slug from fulldev status. --all-orgs calls a tool that only +reads in every organization of the sign-in that has its app, and prints +{"organizations":[{"organization","result" or "error"}]}; it exits with 1 +when one failed.` + const rootCommands: Record = { login: { usage: "fulldev login [--no-browser] [--url ]", - summary: "Sign in, once for every app", + summary: "Sign in, once for every app and organization", options: ["browser", "url"], - details: `Signs in to the Fulldev MCP server in your browser, where you choose your -organization when you are in more than one. One sign-in covers every app -the organization gives you. Run fulldev login again to switch organization. + details: `Signs in to the Fulldev MCP server in your browser, where you choose the +organizations fulldev may work in: all of yours at first. One sign-in covers +every app they give you. Run fulldev login again to change the choice, and +choose the organization of a call with --org. With --no-browser, open the link in a browser on any computer. When that is another computer, as over SSH, the page after sign-in does not load: copy its @@ -88,8 +108,8 @@ the result says so.`, summary: "Show the sign-in", options: ["url"], details: `Prints whether you are signed in, whether the sign-in still works, your -email, organization and when the access token expires, and where the tokens -are stored. Exits with 3 when a sign-in is needed, and with 1 when the +email, the organizations of the sign-in with their slugs and the apps you may +use in each, when the access token expires, and where the tokens are stored. Exits with 3 when a sign-in is needed, and with 1 when the server could not be reached to check it.`, }, instructions: { @@ -110,12 +130,13 @@ tool. With a name: its description, input schema, output schema and annotations. Each app's tools start with its name, such as cms_.`, }, call: { - usage: "fulldev call [json | -] [--file ]", + usage: "fulldev call [json | -] [--file ] [--org ]", summary: "Call a tool with a JSON object", - options: ["file", "url", "login", "browser"], + options: ["file", "org", "all-orgs", "url", "login", "browser"], details: `The tool's input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on -stdout. When the tool fails, prints its error on stderr and exits with 1.`, +stdout. When the tool fails, prints its error on stderr and exits with 1. +${organizationDetails}`, }, help: { usage: "fulldev help [command...]", @@ -142,21 +163,24 @@ of the app's tools. With a name, with or without the app's prefix: its description, input schema, output schema and annotations.`, }, call: { - usage: "fulldev call [json | -] [--file ]", + usage: + "fulldev call [json | -] [--file ] [--org ]", summary: "Call one of the app's tools with a JSON object", - options: ["file", "url", "login", "browser"], + options: ["file", "org", "all-orgs", "url", "login", "browser"], details: `The tool's name may leave out the app's prefix: fulldev cms call list_repositories calls cms_list_repositories. Its input is a JSON object: the argument, a file with --file, or stdin with -. Without input it sends {}. Prints the tool's result as JSON on stdout. When the tool fails, prints -its error on stderr and exits with 1.`, +its error on stderr and exits with 1. +${organizationDetails}`, }, } const formWait: Topic = { - usage: "fulldev form wait [--timeout ]", + usage: + "fulldev form wait [--timeout ] [--org ]", summary: "Wait until the person sends a form", - options: ["timeout", "url", "login", "browser"], + options: ["timeout", "org", "url", "login", "browser"], details: `Calls cms_wait_for_form until the person sends the form, with a progress line on stderr each round, then prints the result. After --timeout minutes (default 30) it prints the form id and exits with 2, so you can run it again.`, @@ -169,6 +193,9 @@ const optionHelp: Record = { login: "--no-login Fail with exit code 3 instead of signing in", browser: "--no-browser Print the sign-in link, for a browser here or elsewhere", + org: "--org The organization to work in", + "all-orgs": + "--all-orgs Every organization of the sign-in (reading tools)", } const footer = `Data is JSON on stdout; progress lines go to stderr. Every error is one JSON @@ -199,7 +226,8 @@ const serverLine = `Server: ${server.url} (${urlVariable} or --url overrides it) function rootHelp() { const width = Math.max(...apps.map((app) => app.name.length)) + 4 return `Fulldev CLI: use Fulldev apps from a terminal or an AI agent, through the -Fulldev MCP server. One sign-in covers every app your organization gives you. +Fulldev MCP server. One sign-in covers every app of the organizations you +choose; --org picks one per call. Usage: fulldev [options] @@ -303,6 +331,8 @@ export function parseCommandLine( options: { url: { type: "string" }, file: { type: "string", short: "f" }, + org: { type: "string" }, + "all-orgs": { type: "boolean" }, timeout: { type: "string" }, login: { type: "boolean" }, browser: { type: "boolean" }, @@ -349,11 +379,18 @@ export function parseCommandLine( if (first === "login" || first === "logout" || first === "status") { allow(first, rootCommands[first]!.options) - if (rest.length) + if (rest.length) { + // Sign-ins were per app until 0.4, so `fulldev login cms` is a habit. + const app = findApp(rest[0]!) + const command = `fulldev ${first}${target.urlFromFlag ? ` --url ${target.url}` : ""}` throw new UsageError( - `fulldev ${first} takes no app: one sign-in covers every app.`, + app + ? `One sign-in covers every app, ${app.title} too. Run: ${command}` + : `fulldev ${first} takes no app: one sign-in covers every app. Run: ${command}`, helpFor(first), + { command }, ) + } if (first === "login") return { kind: "login", target, browser: values.browser ?? true } return { kind: first, target } @@ -389,6 +426,11 @@ export function parseCommandLine( return { kind: "tools", target, session, app, name: args[0] } case "call": usage(args.length === 1 || args.length === 2) + if (values.org !== undefined && values["all-orgs"]) + throw new UsageError( + "Use --org or --all-orgs, not both.", + helpFor(topic), + ) return { kind: "call", target, @@ -396,6 +438,8 @@ export function parseCommandLine( tool: app ? toolName(app, args[0]!) : args[0]!, json: args[1], file: values.file, + organization: values.org, + allOrganizations: values["all-orgs"], } default: { usage(app !== undefined && args[0] === "wait" && args.length === 3) @@ -414,6 +458,7 @@ export function parseCommandLine( branchId: args[1]!, formId: args[2]!, timeoutMinutes, + organization: values.org, } } } diff --git a/src/errors.ts b/src/errors.ts index df6e693..37f6e5f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -25,8 +25,12 @@ export class CliError extends Error { /** A mistake in how the CLI was called. */ export class UsageError extends CliError { - constructor(message: string, help = "fulldev --help") { - super("USAGE", message, exitCodes.usage, { help }) + constructor( + message: string, + help = "fulldev --help", + details: Record = {}, + ) { + super("USAGE", message, exitCodes.usage, { help, ...details }) this.name = "UsageError" } } diff --git a/src/main.ts b/src/main.ts index 0010cc8..50ae42d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,7 @@ import type { CredentialStore } from "./credentials.ts" import { CliError, SignInRequiredError, + UsageError, describeError, exitCodes, isSignInRequired, @@ -22,7 +23,6 @@ import { readToolArguments } from "./input.ts" import { StoredTokenAuth, browserLogin, - claims, expiresAt, revokeTokens, } from "./oauth.ts" @@ -117,6 +117,30 @@ async function listTools(client: Client) { return tools } +/** The server's own tool that lists the organizations of the sign-in. */ +const organizationsTool = "fulldev_list_organizations" + +interface Organization { + id: string + slug: string + name: string + apps: Array +} + +/** The organizations of the sign-in, with the apps the person may use in each. */ +async function listOrganizations(client: Client) { + const result = await client.callTool( + { name: organizationsTool, arguments: {} }, + { timeout: requestTimeoutMs }, + ) + if (result.isError) + throw new CliError("ERROR", JSON.stringify(toolError(result))) + const { organizations } = toolOutput(result) as { + organizations?: Array + } + return organizations ?? [] +} + /** The sign-in, and whether the server still accepts it. */ async function signInStatus(target: Target, io: Io) { const { credentials, storage } = await io.store.read(target.url) @@ -125,9 +149,16 @@ async function signInStatus(target: Target, io: Io) { // valid: the server accepts the sign-in; unknown (with error) when it could not be checked. let valid: boolean | undefined = true let error: string | undefined + let organizations: Array | undefined try { const client = await connect(target, io) - await client.close().catch(() => {}) + try { + organizations = await listOrganizations(client) + } catch (cause) { + error = `The organizations could not be listed: ${(cause as Error).message}` + } finally { + await client.close().catch(() => {}) + } } catch (cause) { if (isSignInRequired(cause)) valid = false else { @@ -136,7 +167,6 @@ async function signInStatus(target: Target, io: Io) { } } const current = await io.store.get(target.url) - const access = claims(current.tokens?.access_token) const expires = expiresAt(current) return { ...base, @@ -144,7 +174,11 @@ async function signInStatus(target: Target, io: Io) { valid, ...(error ? { error } : {}), email: current.email, - organizationId: access.org_id, + organizations: organizations?.map(({ slug, name, apps }) => ({ + slug, + name, + apps, + })), expiresAt: expires ? new Date(expires).toISOString() : undefined, storage, } @@ -206,11 +240,70 @@ export function instructionsFor(text: string, app: App) { if (!own) throw new CliError( "APP_NOT_AVAILABLE", - `${app.title} is not among your apps in this organization. Run fulldev instructions for the apps you may use, or fulldev login to choose another organization.`, + `${app.title} is not among your apps in the organizations of this sign-in. Run fulldev status for the apps you may use in each, or fulldev login to choose other organizations.`, ) return `${overview.trimEnd()}\n\n${own.trim()}` } +/** + * Calls a tool that only reads once in every organization of the sign-in + * whose apps include the tool's app, and prints every answer together. + */ +async function callInEvery( + command: Extract, + args: Record, + io: Io, +) { + const answers = await withClient( + command.target, + command.session, + io, + async (client) => { + const tool = (await listTools(client)).find( + ({ name }) => name === command.tool, + ) + if (!tool) + throw new CliError( + "NOT_FOUND", + `You have no tool named ${command.tool}. Run fulldev tools for the list.`, + ) + if (tool.annotations?.readOnlyHint !== true) + throw new UsageError( + `--all-orgs calls only tools that only read, and ${command.tool} changes data. Name one organization with --org.`, + "fulldev help call", + ) + const app = command.tool.split("_")[0]! + const organizations = (await listOrganizations(client)).filter( + ({ apps }) => apps.includes(app), + ) + return Promise.all( + organizations.map(async ({ slug, name }) => { + const organization = { slug, name } + try { + const result = await client.callTool( + { + name: command.tool, + arguments: { ...args, organization: slug }, + }, + { timeout: requestTimeoutMs }, + ) + return result.isError + ? { organization, ...toolError(result) } + : { organization, result: toolOutput(result) } + } catch (error) { + if (isSignInRequired(error)) throw error + return { organization, ...describeError(error).body } + } + }), + ) + }, + ) + io.stdout(json({ organizations: answers })) + return answers.some((answer) => "error" in answer) + ? exitCodes.error + : exitCodes.ok +} + async function run(command: Command, io: Io): Promise { const log = (line: string) => io.stderr(`${line}\n`) switch (command.kind) { @@ -301,6 +394,9 @@ async function run(command: Command, io: Io): Promise { json: command.json, file: command.file, }) + if (command.allOrganizations) return callInEvery(command, args, io) + if (command.organization !== undefined) + args.organization = command.organization const result = await withClient( command.target, command.session, @@ -327,7 +423,13 @@ async function run(command: Command, io: Io): Promise { waitForForm( (name, args, signal) => client.callTool( - { name, arguments: args }, + { + name, + arguments: + command.organization === undefined + ? args + : { ...args, organization: command.organization }, + }, { timeout: requestTimeoutMs, signal }, ), { diff --git a/src/oauth.ts b/src/oauth.ts index 5c2b079..57a101f 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -40,7 +40,7 @@ import type { Target } from "./apps.ts" export const clientMetadataUrl = "https://full.dev/oauth/cli.json" /** The claims of a JWT, or {} for an opaque token. */ -export function claims(token: string | undefined): Record { +function claims(token: string | undefined): Record { try { const value: unknown = JSON.parse( Buffer.from(token?.split(".")[1] ?? "", "base64url").toString(), @@ -401,7 +401,8 @@ export async function browserLogin( { browser, log, - timeoutMs = 5 * 60_000, + // Each step of the sign-in pages is valid for 10 minutes too. + timeoutMs = 10 * 60_000, fetchFn, input = process.stdin, }: BrowserLoginOptions, From 181790f9b4a54dab5ca58ffe4527613a64b05379 Mon Sep 17 00:00:00 2001 From: Sil Date: Sat, 10 Oct 2026 17:05:57 +0200 Subject: [PATCH 2/2] Mirror packages/cli from the monorepo (bddb05c) --all-orgs keeps each call's organization in its error, and a rejected sign-in ends the command as SIGN_IN_REQUIRED (fulldotdev/fulldev#121, from the review here). Co-Authored-By: Claude Opus 5.5 --- dist/cli.mjs | 7 ++++--- src/main.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/dist/cli.mjs b/dist/cli.mjs index 9f1d072..555fa2f 100644 --- a/dist/cli.mjs +++ b/dist/cli.mjs @@ -1538,16 +1538,17 @@ async function callInEvery(command, args, io) { }, { timeout: requestTimeoutMs }); return result.isError ? { organization, - ...toolError(result) + error: toolError(result).error } : { organization, result: toolOutput(result) }; } catch (error) { - if (isSignInRequired(error)) throw error; + const failure = signInError(error, command.target); + if (isSignInRequired(failure)) throw failure; return { organization, - ...describeError(error).body + error: describeError(failure).body.error }; } })); diff --git a/src/main.ts b/src/main.ts index 50ae42d..8e4123c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -287,12 +287,15 @@ async function callInEvery( }, { timeout: requestTimeoutMs }, ) + // The organization of the call stays, whatever the error holds. return result.isError - ? { organization, ...toolError(result) } + ? { organization, error: toolError(result).error } : { organization, result: toolOutput(result) } } catch (error) { - if (isSignInRequired(error)) throw error - return { organization, ...describeError(error).body } + // A rejected sign-in ends the command, which then offers one. + const failure = signInError(error, command.target) + if (isSignInRequired(failure)) throw failure + return { organization, error: describeError(failure).body.error } } }), )