From fa0229704a040aeba8903af560f5955b3c9be579 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 23 Sep 2026 12:58:39 -0700 Subject: [PATCH 1/5] feat(cli): add an audience flag to hide unreleased commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands and groups take `audience: "internal" | "public"` (default public); a group's audience covers everything under it. The app config decides which audiences are enabled — the superset CLI enables internal when SUPERSET_CLI_AUDIENCE=internal. Disabled commands are pruned from the tree before help, interactive help, routing and suggestions read it, so they behave as if they don't exist rather than advertising themselves in errors. This is about what we show and support, not access control: anyone can set the env var, so anything sensitive stays gated server-side. plugins, mcp and skills (behind the @superset.sh-only plugins flag) and environments (cloud allowlist) are marked internal. The desktop's boot-time `superset plugins sync` sets the env var itself so skill provisioning keeps working for everyone. --- apps/desktop/src/main/lib/plugin-installs.ts | 1 + packages/cli-framework/src/build.ts | 1 + packages/cli-framework/src/command.ts | 3 ++ packages/cli-framework/src/config.ts | 3 ++ packages/cli-framework/src/dev.ts | 1 + packages/cli-framework/src/index.ts | 4 +- packages/cli-framework/src/router.test.ts | 47 +++++++++++++++++++ packages/cli-framework/src/router.ts | 28 ++++++++++- packages/cli-framework/src/runner.ts | 11 ++++- packages/cli/cli.config.ts | 4 ++ .../cli/src/commands/environments/meta.ts | 1 + packages/cli/src/commands/mcp/meta.ts | 1 + packages/cli/src/commands/plugins/meta.ts | 1 + packages/cli/src/commands/skills/meta.ts | 1 + 14 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 packages/cli-framework/src/router.test.ts diff --git a/apps/desktop/src/main/lib/plugin-installs.ts b/apps/desktop/src/main/lib/plugin-installs.ts index 5e3a2e70223..9fff694c800 100644 --- a/apps/desktop/src/main/lib/plugin-installs.ts +++ b/apps/desktop/src/main/lib/plugin-installs.ts @@ -48,6 +48,7 @@ async function runPluginCli(args: string[]): Promise { try { await execFileAsync(cli, ["plugins", ...args, "--json"], { timeout: 60_000, + env: { ...process.env, SUPERSET_CLI_AUDIENCE: "internal" }, }); } catch (error) { log.warn( diff --git a/packages/cli-framework/src/build.ts b/packages/cli-framework/src/build.ts index 8269294233e..ba4f053f29c 100644 --- a/packages/cli-framework/src/build.ts +++ b/packages/cli-framework/src/build.ts @@ -32,6 +32,7 @@ await run({ tree: { commands, groups, middleware }, globals: config.globals, help: config.help, + audiences: config.audiences?.(), }); `, ); diff --git a/packages/cli-framework/src/command.ts b/packages/cli-framework/src/command.ts index 2e23a6b8695..89c81b49948 100644 --- a/packages/cli-framework/src/command.ts +++ b/packages/cli-framework/src/command.ts @@ -1,5 +1,7 @@ import type { GenericBuilderInternals, TypeOf } from "./option"; +export type Audience = "internal" | "public"; + export type CommandResult = | { data?: unknown; message?: string } | unknown[] @@ -19,6 +21,7 @@ export type CommandConfig< > = { description: string; aliases?: string[]; + audience?: Audience; skipMiddleware?: TSkip; options?: TOpts; args?: TArgs; diff --git a/packages/cli-framework/src/config.ts b/packages/cli-framework/src/config.ts index 6d225e45501..740875c38fd 100644 --- a/packages/cli-framework/src/config.ts +++ b/packages/cli-framework/src/config.ts @@ -1,5 +1,6 @@ import { dirname, resolve } from "node:path"; import type { BunPlugin } from "bun"; +import type { Audience } from "./command"; import type { HelpBranding } from "./help"; import type { GenericBuilderInternals } from "./option"; @@ -18,6 +19,8 @@ export interface CliConfig { globals?: Record; /** Branding/curation for the root help screen and interactive browser. */ help?: HelpBranding; + /** Audiences whose commands exist for this run. Defaults to `["public"]`. */ + audiences?: () => Audience[]; } export function defineConfig(config: CliConfig): CliConfig { diff --git a/packages/cli-framework/src/dev.ts b/packages/cli-framework/src/dev.ts index 4cda5c6bbca..c67d36dec57 100644 --- a/packages/cli-framework/src/dev.ts +++ b/packages/cli-framework/src/dev.ts @@ -61,5 +61,6 @@ export async function runDev(argv: string[]): Promise { tree: { commands, groups, middleware }, globals: config.globals, help: config.help, + audiences: config.audiences?.(), }); } diff --git a/packages/cli-framework/src/index.ts b/packages/cli-framework/src/index.ts index b6720ef2d24..37ef886331e 100644 --- a/packages/cli-framework/src/index.ts +++ b/packages/cli-framework/src/index.ts @@ -1,4 +1,4 @@ -export type { CommandConfig, CommandResult } from "./command"; +export type { Audience, CommandConfig, CommandResult } from "./command"; export { createCommand } from "./command"; export type { CliConfig } from "./config"; export { defineConfig } from "./config"; @@ -22,6 +22,6 @@ export { camelToKebab, isAgentMode, parseArgv } from "./parser"; export type { CommandsPluginOptions } from "./plugin"; export { createCommandsPlugin } from "./plugin"; export type { CliCommand, CliGroup } from "./router"; -export { buildTree, routeCommand } from "./router"; +export { buildTree, filterByAudience, routeCommand } from "./router"; export type { CommandTree, RunOptions } from "./runner"; export { run } from "./runner"; diff --git a/packages/cli-framework/src/router.test.ts b/packages/cli-framework/src/router.test.ts new file mode 100644 index 00000000000..58e74e9262c --- /dev/null +++ b/packages/cli-framework/src/router.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import type { CommandConfig } from "./command"; +import { type CliCommand, type CliGroup, filterByAudience } from "./router"; + +function cmd(path: string[], audience?: CommandConfig["audience"]): CliCommand { + return { + path, + command: { + description: path.join(" "), + audience, + run: async () => undefined, + }, + }; +} + +const paths = (commands: CliCommand[]) => commands.map((c) => c.path.join(" ")); + +describe("filterByAudience", () => { + const groups: CliGroup[] = [ + { path: ["pages"], description: "Pages", audience: "internal" }, + { path: ["tasks"], description: "Tasks" }, + { path: ["lab"], description: "Lab" }, + ]; + const commands = [ + cmd(["pages", "list"]), + cmd(["tasks", "list"]), + cmd(["tasks", "purge"], "internal"), + cmd(["lab", "try"], "internal"), + ]; + + it("drops internal commands and their groups for the public audience", () => { + const result = filterByAudience(groups, commands, ["public"]); + expect(paths(result.commands)).toEqual(["tasks list"]); + expect(result.groups.map((g) => g.path.join(" "))).toEqual(["tasks"]); + }); + + it("keeps everything when internal is enabled", () => { + const result = filterByAudience(groups, commands, ["internal", "public"]); + expect(paths(result.commands)).toEqual(paths(commands)); + expect(result.groups).toEqual(groups); + }); + + it("hides a public command under an internal group", () => { + const result = filterByAudience(groups, commands, ["public"]); + expect(paths(result.commands)).not.toContain("pages list"); + }); +}); diff --git a/packages/cli-framework/src/router.ts b/packages/cli-framework/src/router.ts index bd3e7282932..9a530cbc607 100644 --- a/packages/cli-framework/src/router.ts +++ b/packages/cli-framework/src/router.ts @@ -1,4 +1,4 @@ -import type { CommandConfig } from "./command"; +import type { Audience, CommandConfig } from "./command"; import { CLIError, suggestSimilar } from "./errors"; import type { CommandNode } from "./help"; @@ -6,6 +6,7 @@ export type CliGroup = { path: string[]; description: string; aliases?: string[]; + audience?: Audience; }; export type CliCommand = { @@ -13,6 +14,31 @@ export type CliCommand = { command: CommandConfig; }; +function isPrefix(prefix: string[], path: string[]): boolean { + return prefix.every((segment, i) => path[i] === segment); +} + +export function filterByAudience( + groups: CliGroup[], + commands: CliCommand[], + audiences: Audience[], +): { groups: CliGroup[]; commands: CliCommand[] } { + const isEnabled = (audience: Audience | undefined) => + audiences.includes(audience ?? "public"); + const disabledGroups = groups.filter((g) => !isEnabled(g.audience)); + const visibleCommands = commands.filter( + (c) => + isEnabled(c.command.audience) && + !disabledGroups.some((g) => isPrefix(g.path, c.path)), + ); + const visibleGroups = groups.filter( + (g) => + isEnabled(g.audience) && + visibleCommands.some((c) => isPrefix(g.path, c.path)), + ); + return { groups: visibleGroups, commands: visibleCommands }; +} + export function buildTree( groups: CliGroup[], commands: CliCommand[], diff --git a/packages/cli-framework/src/runner.ts b/packages/cli-framework/src/runner.ts index ec1778a9a0c..076c5dc62db 100644 --- a/packages/cli-framework/src/runner.ts +++ b/packages/cli-framework/src/runner.ts @@ -1,4 +1,4 @@ -import type { CommandConfig } from "./command"; +import type { Audience, CommandConfig } from "./command"; import { CLIError } from "./errors"; import { generateCommandHelp, @@ -15,6 +15,7 @@ import { buildTree, type CliCommand, type CliGroup, + filterByAudience, routeCommand, } from "./router"; @@ -30,6 +31,7 @@ export interface RunOptions { tree: CommandTree; globals?: Record; help?: HelpBranding; + audiences?: Audience[]; } export async function run(opts: RunOptions): Promise { @@ -241,7 +243,12 @@ async function execute( const { name, version } = opts; const { middleware } = loaded; const globalConfigs = processGlobals(opts.globals); - const { root, commandMap } = buildTree(loaded.groups, loaded.commands); + const visible = filterByAudience( + loaded.groups, + loaded.commands, + opts.audiences ?? ["public"], + ); + const { root, commandMap } = buildTree(visible.groups, visible.commands); // EXPERIMENT: bare invocation on a TTY opens the interactive help browser // instead of dumping static help. Agents/CI keep the static output. diff --git a/packages/cli/cli.config.ts b/packages/cli/cli.config.ts index 5a3a1e40690..b38141b5aa4 100644 --- a/packages/cli/cli.config.ts +++ b/packages/cli/cli.config.ts @@ -23,6 +23,10 @@ export default defineConfig({ .env("SUPERSET_API_KEY") .desc("Use a Superset API key (sk_live_…) instead of OAuth login"), }, + audiences: () => + process.env.SUPERSET_CLI_AUDIENCE === "internal" + ? ["internal", "public"] + : ["public"], help: { tagline: "Command your fleet of coding agents from any shell.", docsUrl: "https://docs.superset.sh/cli", diff --git a/packages/cli/src/commands/environments/meta.ts b/packages/cli/src/commands/environments/meta.ts index 5d1d0e6f8e2..0695e0d133b 100644 --- a/packages/cli/src/commands/environments/meta.ts +++ b/packages/cli/src/commands/environments/meta.ts @@ -1,3 +1,4 @@ export default { + audience: "internal", description: "Inspect the environments cloud workspaces start from", }; diff --git a/packages/cli/src/commands/mcp/meta.ts b/packages/cli/src/commands/mcp/meta.ts index 2be58f66836..5e56943e9a9 100644 --- a/packages/cli/src/commands/mcp/meta.ts +++ b/packages/cli/src/commands/mcp/meta.ts @@ -1,3 +1,4 @@ export default { + audience: "internal", description: "Inspect and call the tools your connected plugins expose", }; diff --git a/packages/cli/src/commands/plugins/meta.ts b/packages/cli/src/commands/plugins/meta.ts index 3eefb7c46fb..1f1d723186f 100644 --- a/packages/cli/src/commands/plugins/meta.ts +++ b/packages/cli/src/commands/plugins/meta.ts @@ -1,4 +1,5 @@ export default { + audience: "internal", description: "Install and connect plugins, and author them in a marketplace repo", aliases: ["plugin"], diff --git a/packages/cli/src/commands/skills/meta.ts b/packages/cli/src/commands/skills/meta.ts index 57a46040349..1105dd28663 100644 --- a/packages/cli/src/commands/skills/meta.ts +++ b/packages/cli/src/commands/skills/meta.ts @@ -1,3 +1,4 @@ export default { + audience: "internal", description: "Inspect the skills installed from plugins", }; From 937a0198b89cf6d1ebec4b0610963a1a4eea97bf Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 23 Sep 2026 13:03:29 -0700 Subject: [PATCH 2/5] feat(cli): let a command's own audience override its group's The nearest tag wins: a command's own audience, else its nearest tagged group's, else public. Groups still set the default for a whole feature, so new commands under an internal group start hidden, but a group can now mix public and internal commands in either direction. --- packages/cli-framework/src/router.test.ts | 39 ++++++++++++++++++----- packages/cli-framework/src/router.ts | 22 ++++++------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/packages/cli-framework/src/router.test.ts b/packages/cli-framework/src/router.test.ts index 58e74e9262c..36ba61298c4 100644 --- a/packages/cli-framework/src/router.test.ts +++ b/packages/cli-framework/src/router.test.ts @@ -13,25 +13,41 @@ function cmd(path: string[], audience?: CommandConfig["audience"]): CliCommand { }; } -const paths = (commands: CliCommand[]) => commands.map((c) => c.path.join(" ")); +const paths = (items: { path: string[] }[]) => + items.map((item) => item.path.join(" ")); describe("filterByAudience", () => { const groups: CliGroup[] = [ - { path: ["pages"], description: "Pages", audience: "internal" }, + { path: ["plugins"], description: "Plugins", audience: "internal" }, + { + path: ["plugins", "author"], + description: "Authoring", + audience: "public", + }, { path: ["tasks"], description: "Tasks" }, { path: ["lab"], description: "Lab" }, ]; const commands = [ - cmd(["pages", "list"]), + cmd(["plugins", "list"]), + cmd(["plugins", "search"], "public"), + cmd(["plugins", "author", "init"]), cmd(["tasks", "list"]), cmd(["tasks", "purge"], "internal"), cmd(["lab", "try"], "internal"), ]; - it("drops internal commands and their groups for the public audience", () => { + it("hides internal commands, and groups left empty, for the public audience", () => { const result = filterByAudience(groups, commands, ["public"]); - expect(paths(result.commands)).toEqual(["tasks list"]); - expect(result.groups.map((g) => g.path.join(" "))).toEqual(["tasks"]); + expect(paths(result.commands)).toEqual([ + "plugins search", + "plugins author init", + "tasks list", + ]); + expect(paths(result.groups)).toEqual([ + "plugins", + "plugins author", + "tasks", + ]); }); it("keeps everything when internal is enabled", () => { @@ -40,8 +56,15 @@ describe("filterByAudience", () => { expect(result.groups).toEqual(groups); }); - it("hides a public command under an internal group", () => { + it("gives an untagged command its nearest group's audience", () => { + const result = filterByAudience(groups, commands, ["public"]); + expect(paths(result.commands)).not.toContain("plugins list"); + expect(paths(result.commands)).toContain("plugins author init"); + }); + + it("lets a command's own tag override its group's", () => { const result = filterByAudience(groups, commands, ["public"]); - expect(paths(result.commands)).not.toContain("pages list"); + expect(paths(result.commands)).toContain("plugins search"); + expect(paths(result.commands)).not.toContain("tasks purge"); }); }); diff --git a/packages/cli-framework/src/router.ts b/packages/cli-framework/src/router.ts index 9a530cbc607..3791cf6a46f 100644 --- a/packages/cli-framework/src/router.ts +++ b/packages/cli-framework/src/router.ts @@ -23,18 +23,18 @@ export function filterByAudience( commands: CliCommand[], audiences: Audience[], ): { groups: CliGroup[]; commands: CliCommand[] } { - const isEnabled = (audience: Audience | undefined) => - audiences.includes(audience ?? "public"); - const disabledGroups = groups.filter((g) => !isEnabled(g.audience)); - const visibleCommands = commands.filter( - (c) => - isEnabled(c.command.audience) && - !disabledGroups.some((g) => isPrefix(g.path, c.path)), + const audienceOf = ({ path, command }: CliCommand): Audience => { + if (command.audience) return command.audience; + const nearestTaggedGroup = groups + .filter((g) => g.audience && isPrefix(g.path, path)) + .sort((a, b) => b.path.length - a.path.length)[0]; + return nearestTaggedGroup?.audience ?? "public"; + }; + const visibleCommands = commands.filter((c) => + audiences.includes(audienceOf(c)), ); - const visibleGroups = groups.filter( - (g) => - isEnabled(g.audience) && - visibleCommands.some((c) => isPrefix(g.path, c.path)), + const visibleGroups = groups.filter((g) => + visibleCommands.some((c) => isPrefix(g.path, c.path)), ); return { groups: visibleGroups, commands: visibleCommands }; } From bb15854e38bd96066f8fc0f2a423cadd15fe5900 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 23 Sep 2026 13:12:05 -0700 Subject: [PATCH 3/5] revert(desktop): drop the CLI audience override on plugin installs The desktop's plugin install/uninstall/enable/disable calls are reached only from the flag-gated Plugins page, and the desktop already inherits the user's shell env, so staff with SUPERSET_CLI_AUDIENCE set are unaffected. Forcing the audience from the app was a bypass of the visibility gate rather than a fix; the real fix is to stop shelling out to the CLI for this. --- apps/desktop/src/main/lib/plugin-installs.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/main/lib/plugin-installs.ts b/apps/desktop/src/main/lib/plugin-installs.ts index 9fff694c800..5e3a2e70223 100644 --- a/apps/desktop/src/main/lib/plugin-installs.ts +++ b/apps/desktop/src/main/lib/plugin-installs.ts @@ -48,7 +48,6 @@ async function runPluginCli(args: string[]): Promise { try { await execFileAsync(cli, ["plugins", ...args, "--json"], { timeout: 60_000, - env: { ...process.env, SUPERSET_CLI_AUDIENCE: "internal" }, }); } catch (error) { log.warn( From 06319bc5f76942e5d706164bec820ec19dbe9f2e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 23 Sep 2026 16:22:57 -0700 Subject: [PATCH 4/5] fix(cli): an internal node hides everything below it Replaces nearest-tag-wins: if any group or command in a path is internal, that path is internal, and a public tag further down can't re-expose it. Commands count as nodes too, so an internal command hides commands nested under it rather than letting their names fall through to the parent as arguments. --- packages/cli-framework/src/router.test.ts | 38 +++++++++-------------- packages/cli-framework/src/router.ts | 17 +++++----- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/packages/cli-framework/src/router.test.ts b/packages/cli-framework/src/router.test.ts index 36ba61298c4..f54684f7f0a 100644 --- a/packages/cli-framework/src/router.test.ts +++ b/packages/cli-framework/src/router.test.ts @@ -33,38 +33,28 @@ describe("filterByAudience", () => { cmd(["plugins", "author", "init"]), cmd(["tasks", "list"]), cmd(["tasks", "purge"], "internal"), + cmd(["tasks", "purge", "all"]), cmd(["lab", "try"], "internal"), ]; + const publicView = filterByAudience(groups, commands, ["public"]); - it("hides internal commands, and groups left empty, for the public audience", () => { - const result = filterByAudience(groups, commands, ["public"]); - expect(paths(result.commands)).toEqual([ - "plugins search", - "plugins author init", - "tasks list", - ]); - expect(paths(result.groups)).toEqual([ - "plugins", - "plugins author", - "tasks", - ]); + it("shows only commands with no internal node in their chain", () => { + expect(paths(publicView.commands)).toEqual(["tasks list"]); + expect(paths(publicView.groups)).toEqual(["tasks"]); }); - it("keeps everything when internal is enabled", () => { - const result = filterByAudience(groups, commands, ["internal", "public"]); - expect(paths(result.commands)).toEqual(paths(commands)); - expect(result.groups).toEqual(groups); + it("keeps everything under an internal group internal, even when tagged public", () => { + expect(paths(publicView.commands)).not.toContain("plugins search"); + expect(paths(publicView.commands)).not.toContain("plugins author init"); }); - it("gives an untagged command its nearest group's audience", () => { - const result = filterByAudience(groups, commands, ["public"]); - expect(paths(result.commands)).not.toContain("plugins list"); - expect(paths(result.commands)).toContain("plugins author init"); + it("hides commands nested under an internal command", () => { + expect(paths(publicView.commands)).not.toContain("tasks purge all"); }); - it("lets a command's own tag override its group's", () => { - const result = filterByAudience(groups, commands, ["public"]); - expect(paths(result.commands)).toContain("plugins search"); - expect(paths(result.commands)).not.toContain("tasks purge"); + it("keeps everything when internal is enabled", () => { + const result = filterByAudience(groups, commands, ["internal", "public"]); + expect(paths(result.commands)).toEqual(paths(commands)); + expect(result.groups).toEqual(groups); }); }); diff --git a/packages/cli-framework/src/router.ts b/packages/cli-framework/src/router.ts index 3791cf6a46f..0c6921011e8 100644 --- a/packages/cli-framework/src/router.ts +++ b/packages/cli-framework/src/router.ts @@ -23,15 +23,14 @@ export function filterByAudience( commands: CliCommand[], audiences: Audience[], ): { groups: CliGroup[]; commands: CliCommand[] } { - const audienceOf = ({ path, command }: CliCommand): Audience => { - if (command.audience) return command.audience; - const nearestTaggedGroup = groups - .filter((g) => g.audience && isPrefix(g.path, path)) - .sort((a, b) => b.path.length - a.path.length)[0]; - return nearestTaggedGroup?.audience ?? "public"; - }; - const visibleCommands = commands.filter((c) => - audiences.includes(audienceOf(c)), + const hiddenPaths = [ + ...groups.map((g) => ({ path: g.path, audience: g.audience })), + ...commands.map((c) => ({ path: c.path, audience: c.command.audience })), + ] + .filter((node) => !audiences.includes(node.audience ?? "public")) + .map((node) => node.path); + const visibleCommands = commands.filter( + (c) => !hiddenPaths.some((hidden) => isPrefix(hidden, c.path)), ); const visibleGroups = groups.filter((g) => visibleCommands.some((c) => isPrefix(g.path, c.path)), From ae30e5692c6baa1233cac5a1a327a66ff677e6e4 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 23 Sep 2026 16:23:23 -0700 Subject: [PATCH 5/5] fix(desktop): run plugin CLI actions with the internal audience The Plugins page installs, uninstalls, enables and disables plugins by shelling out to the bundled CLI, and `plugins` is now internal. Staff without SUPERSET_CLI_AUDIENCE in their shell would get installs that report success but never provision skills. Stopgap until SUPER-2435 moves this logic in-process and removes the shell-out. --- apps/desktop/src/main/lib/plugin-installs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/main/lib/plugin-installs.ts b/apps/desktop/src/main/lib/plugin-installs.ts index 5e3a2e70223..9fff694c800 100644 --- a/apps/desktop/src/main/lib/plugin-installs.ts +++ b/apps/desktop/src/main/lib/plugin-installs.ts @@ -48,6 +48,7 @@ async function runPluginCli(args: string[]): Promise { try { await execFileAsync(cli, ["plugins", ...args, "--json"], { timeout: 60_000, + env: { ...process.env, SUPERSET_CLI_AUDIENCE: "internal" }, }); } catch (error) { log.warn(