Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers.
- Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization).
- Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355)
- Add `extdeprecationwarnings` experiment to display phased deprecation notices and guidance across `ext:*` CLI commands.
31 changes: 31 additions & 0 deletions src/commands/ailogic-providers-disable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Command } from "../command";
import { requirePermissions } from "../requirePermissions";
import { needProjectId } from "../projectUtils";
import * as ailogic from "../gcp/ailogic";
import * as clc from "colorette";
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { confirm } from "../prompt";

import { Options } from "../options";

export const command = new Command("ailogic:providers:disable <providerType>")
.description("disable a Gemini API provider service")
.option("-f, --force", "bypass confirmation prompt")
.before(requirePermissions, ["serviceusage.services.disable", "firebasevertexai.config.update"])
.action(async (providerType: string, options: Options) => {
const projectId = needProjectId(options);
const provider = ailogic.parseProviderType(providerType);
// confirm() aborts (throws) in non-interactive mode unless --force is set, so a
// separate non-interactive guard is unnecessary here.
const confirmed = await confirm({
message: `You are about to disable ${clc.bold(provider)}. This will stop running apps from invoking it. Are you sure?`,
force: options.force,
nonInteractive: options.nonInteractive,
});
if (!confirmed) {
throw new FirebaseError("Command aborted.", { exit: 1 });
}
await ailogic.disableProvider(projectId, provider);
logger.info(clc.green(`Successfully disabled provider: ${clc.bold(provider)}`));
});
18 changes: 18 additions & 0 deletions src/commands/ailogic-providers-enable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Command } from "../command";
import { requirePermissions } from "../requirePermissions";
import { needProjectId } from "../projectUtils";
import * as ailogic from "../gcp/ailogic";
import * as clc from "colorette";
import { logger } from "../logger";

import { Options } from "../options";

export const command = new Command("ailogic:providers:enable <providerType>")
.description("enable a Gemini API provider service")
.before(requirePermissions, ["serviceusage.services.enable", "firebasevertexai.config.update"])
.action(async (providerType: string, options: Options) => {
const projectId = needProjectId(options);
const provider = ailogic.parseProviderType(providerType);
await ailogic.enableProvider(projectId, provider);
logger.info(clc.green(`Successfully enabled provider: ${clc.bold(provider)}`));
});
34 changes: 34 additions & 0 deletions src/commands/ailogic-providers-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Command } from "../command";
import { requirePermissions } from "../requirePermissions";
import { needProjectId } from "../projectUtils";
import * as ailogic from "../gcp/ailogic";
import * as clc from "colorette";
import { logger } from "../logger";
import * as Table from "cli-table3";

import { Options } from "../options";

export const command = new Command("ailogic:providers:list")
.description("list which Gemini API providers are enabled")
.before(requirePermissions, ["serviceusage.services.get"])
.action(async (options: Options) => {
const projectId = needProjectId(options);
const enabledProviders = await ailogic.listProviders(projectId);

if (enabledProviders.length === 0) {
logger.info(clc.bold("No Gemini API providers are enabled."));
return enabledProviders;
}

const tableHead = ["Provider", "Status"];
const table = new Table({ head: tableHead, style: { head: ["green"] } });

// Show every possible provider, indicating whether it is enabled or disabled.
for (const provider of ailogic.PROVIDER_TYPES) {
const isEnabled = enabledProviders.includes(provider);
table.push([clc.bold(provider), isEnabled ? clc.green("Enabled") : clc.red("Disabled")]);
}

logger.info(table.toString());
return enabledProviders;
});
59 changes: 59 additions & 0 deletions src/commands/help.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as sinon from "sinon";
import { expect } from "chai";
import { command as helpCommand } from "./help";
import { logger } from "../logger";

describe("help command namespace listing", () => {
let loggerStub: sinon.SinonStub;

beforeEach(() => {
loggerStub = sinon.stub(logger, "info");
});

afterEach(() => {
sinon.restore();
});

it("should show help for namespace subcommands", async () => {
const mockEnableCommand = {
name: () => "ailogic:providers:enable",
description: () => "enable a provider",
outputHelp: sinon.stub(),
};
const mockDisableCommand = {
name: () => "ailogic:providers:disable",
description: () => "disable a provider",
outputHelp: sinon.stub(),
};

const mockClient = {
cli: {
commands: [mockEnableCommand, mockDisableCommand],
outputHelp: sinon.stub(),
},
getCommand: sinon.stub().returns(undefined),
ailogic: {
providers: {},
},
};

// Run help command action with mock context
// `actionFn` is a private member of Command; cast through `unknown` to invoke it
// directly with a mocked command context. The target type is fully specified (no `any`).
const actionFn = (
helpCommand as unknown as {
actionFn: (this: { client: typeof mockClient }, commandName: string) => Promise<void>;
}
).actionFn;
await actionFn.call({ client: mockClient }, "ailogic:providers");

// It should log the subcommands and descriptions
expect(loggerStub).to.have.been.called;
const allArgs = loggerStub.args.map((a) => a.join(" ")).join("\n");
expect(allArgs).to.include("Commands under ailogic:providers:");
expect(allArgs).to.include("ailogic:providers:enable");
expect(allArgs).to.include("enable a provider");
expect(allArgs).to.include("ailogic:providers:disable");
expect(allArgs).to.include("disable a provider");
});
});
47 changes: 46 additions & 1 deletion src/commands/help.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import * as clc from "colorette";
import type { Command as CommanderCommand } from "commander";

import { Command } from "../command";
import { logger } from "../logger";
Expand All @@ -10,11 +11,55 @@
// This must stay `function (commandName)`.
.action(function (commandName) {
// @ts-ignore
const client = this.client; // eslint-disable-line @typescript-eslint/no-invalid-this

Check warning on line 14 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .client on an `any` value

Check warning on line 14 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value

Check warning on line 14 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .client on an `any` value

Check warning on line 14 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
const cmd = commandName ? client.getCommand(commandName) : undefined;

Check warning on line 15 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 15 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .getCommand on an `any` value

Check warning on line 15 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value

Check warning on line 15 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 15 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .getCommand on an `any` value

Check warning on line 15 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
if (cmd) {
cmd.outputHelp();

Check warning on line 17 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 17 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .outputHelp on an `any` value

Check warning on line 17 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 17 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .outputHelp on an `any` value
} else if (commandName) {
return;
}

if (commandName) {
// Treat the argument as a command namespace (e.g. "ailogic:providers") and walk
// the nested client command tree segment by segment ("ailogic" -> "providers") to
// check whether it resolves to a group of subcommands rather than a leaf command.
const keys = commandName.split(":");

Check warning on line 25 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 25 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .split on an `any` value

Check warning on line 25 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value

Check warning on line 25 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 25 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .split on an `any` value

Check warning on line 25 in src/commands/help.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
let current = client;
let matched = true;
for (const key of keys) {
if (!current || typeof current !== "object") {
matched = false;
break;
}
const nextKey = Object.keys(current).find((k) => k.toLowerCase() === key.toLowerCase());
if (nextKey) {
current = current[nextKey];
} else {
matched = false;
break;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is a little vague in its intent. Needs a comment to describe what the goal is here. The block below this too

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in dc501fe. Added comments to both blocks: the first walks the nested client command tree to resolve a namespace (for example ailogic:providers), and the second lists every registered command under that prefix.


// If it resolved to a namespace, print every registered command under that prefix
// (e.g. `firebase help ailogic:providers` lists enable/disable/list).
if (matched && current && typeof current === "object") {
const prefix = commandName + ":";
const subcmds = (client.cli.commands as CommanderCommand[]).filter((c) =>
c.name().startsWith(prefix),
);
if (subcmds.length > 0) {
logger.info();
logger.info(clc.bold(`Commands under ${clc.green(commandName)}:`));
logger.info();
for (const subcmd of subcmds) {
logger.info(` ${clc.bold(subcmd.name().padEnd(45))} ${subcmd.description()}`);
}
logger.info();
return;
}
}
}

if (commandName) {
logger.warn();
utils.logWarning(
clc.bold(commandName) + " is not a valid command. See below for valid commands",
Expand Down
10 changes: 10 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ export function load(client: CLIClient): CLIClient {
client.apphosting.rollouts.list = loadCommand("apphosting-rollouts-list");
}
}
// Gated behind the `ailogic` experiment until the underlying API is API-council
// approved, since the surface may still change.
if (experiments.isEnabled("ailogic")) {
client.ailogic = {};
client.ailogic.providers = {};
client.ailogic.providers.enable = loadCommand("ailogic-providers-enable");
client.ailogic.providers.disable = loadCommand("ailogic-providers-disable");
client.ailogic.providers.list = loadCommand("ailogic-providers-list");
}

client.login = loadCommand("login");
client.login.add = loadCommand("login-add");
client.login.ci = loadCommand("login-ci");
Expand Down
15 changes: 15 additions & 0 deletions src/ensureApiEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,18 @@ function cacheEnabledAPI(projectId: string, apiName: string) {
cache[projectId][apiName] = true;
configstore.set(API_ENABLEMENT_CACHE_KEY, cache);
}

/**
* Removes a single API from the local "enabled" cache so the next check re-queries the server.
* Call this after enabling or disabling an API to keep the cache consistent with the server state.
*/
export function uncacheEnabledAPI(projectId: string, apiName: string): void {
const cache = (configstore.get(API_ENABLEMENT_CACHE_KEY) || {}) as Record<
string,
Record<string, true>
>;
if (cache[projectId]) {
delete cache[projectId][apiName];
configstore.set(API_ENABLEMENT_CACHE_KEY, cache);
}
}
8 changes: 8 additions & 0 deletions src/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ export const ALL_EXPERIMENTS = experiments({
"without a notice.",
},

ailogic: {
shortDescription: "Manage Firebase AI Logic from the CLI.",
fullDescription:
"Enables the `firebase ailogic` command surface for managing Firebase AI Logic, " +
"starting with the Gemini API providers. These commands are in preview and may " +
"change until the underlying API is finalized.",
},

apphosting: {
shortDescription: "Allow CLI option for Frameworks",
default: true,
Expand Down
Loading
Loading