From e37b0aa03a134b7c652ed5b9d7d6d16d0e55030b Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 23 Mar 2026 09:16:48 -0700 Subject: [PATCH 1/6] Prompt to install skills on firebase init --- CHANGELOG.md | 1 + scripts/agent-evals/.mocharc.yml | 1 + src/agentSkills.spec.ts | 71 ++++++++++++++++++++++++++ src/agentSkills.ts | 78 +++++++++++++++++++++++++++++ src/commands/init.ts | 11 ++++ src/firebase_studio/migrate.spec.ts | 51 +++++++------------ src/firebase_studio/migrate.ts | 41 +++------------ 7 files changed, 188 insertions(+), 66 deletions(-) create mode 100644 src/agentSkills.spec.ts create mode 100644 src/agentSkills.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..ed975f4a1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Added a prompt to `firebase init` to install Agent Skills for Firebase. diff --git a/scripts/agent-evals/.mocharc.yml b/scripts/agent-evals/.mocharc.yml index 589984b5e56..3c4b9761fd0 100644 --- a/scripts/agent-evals/.mocharc.yml +++ b/scripts/agent-evals/.mocharc.yml @@ -2,5 +2,6 @@ import: - ./lib/scripts/agent-evals/src/helpers/mocha-bootstrap.js timeout: 120000 recursive: true +parallel: true node-options: - no-experimental-strip-types diff --git a/src/agentSkills.spec.ts b/src/agentSkills.spec.ts new file mode 100644 index 00000000000..3f3ed9a108a --- /dev/null +++ b/src/agentSkills.spec.ts @@ -0,0 +1,71 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import * as utils from "./utils"; +import * as prompt from "./prompt"; +import { promptForAgentSkills, installAgentSkills } from "./agentSkills"; + +describe("agentSkills", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("promptForAgentSkills", () => { + it("should return true if user confirms", async () => { + sandbox.stub(prompt, "confirm").resolves(true); + const result = await promptForAgentSkills(); + expect(result).to.be.true; + }); + + it("should return false if user declines", async () => { + sandbox.stub(prompt, "confirm").resolves(false); + const result = await promptForAgentSkills(); + expect(result).to.be.false; + }); + }); + + describe("installAgentSkills", () => { + let testRoot: string; + + beforeEach(() => { + testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agent-skills-test-")); + }); + + afterEach(() => { + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it("should install skills locally and create .agents directory", async () => { + await installAgentSkills({ cwd: testRoot }); + + const agentsDir = path.join(testRoot, ".agents"); + const skillsDir = path.join(agentsDir, "skills"); + const lockFile = path.join(testRoot, "skills-lock.json"); + + expect(fs.existsSync(agentsDir), "Expected .agents directory to exist").to.be.true; + expect(fs.existsSync(skillsDir), "Expected .agents/skills directory to exist").to.be.true; + expect(fs.existsSync(lockFile), "Expected skills-lock.json to exist").to.be.true; + + // Check if at least one skill was installed (e.g., firebase-basics) + const skills = fs.readdirSync(skillsDir); + expect(skills.length).to.be.greaterThan(0); + expect(skills).to.include("firebase-basics"); + }).timeout(60000); + + it("should skip if npx is not available", async () => { + sandbox.stub(utils, "commandExistsSync").withArgs("npx").returns(false); + + await installAgentSkills({ cwd: testRoot }); + + expect(fs.existsSync(path.join(testRoot, ".agents"))).to.be.false; + }); + }); +}); diff --git a/src/agentSkills.ts b/src/agentSkills.ts new file mode 100644 index 00000000000..2d9a53d5104 --- /dev/null +++ b/src/agentSkills.ts @@ -0,0 +1,78 @@ +import { spawn, spawnSync } from "child_process"; +import * as utils from "./utils"; +import { logger } from "./logger"; +import * as prompt from "./prompt"; + +export async function promptForAgentSkills(): Promise { + return prompt.confirm({ + message: "Would you like to install agent skills for Firebase?", + default: true, + }); +} + +export interface InstallAgentSkillsOptions { + cwd: string; + global?: boolean; + background?: boolean; + agentName?: string; +} + +export async function installAgentSkills(options: InstallAgentSkillsOptions): Promise { + if (!utils.commandExistsSync("npx")) { + return; + } + + const args = [ + "-y", // npx -y to auto-confirm package install + "skills", + "add", + "firebase/agent-skills", + "--skill", + "*", + "-y", + ]; + + if (options.agentName) { + args.push("-a", options.agentName); + } + + if (options.global) { + args.push("-g"); + } + + if (options.background) { + logger.info("⏳ Installing Agent skills in the background..."); + try { + const child = spawn("npx", args, { + cwd: options.cwd, + stdio: "ignore", + detached: true, + shell: process.platform === "win32", + }); + child.unref(); + logger.info("✅ Agent skills installation started"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + utils.logWarning(`Could not start Agent skills installation: ${message}`); + } + } else { + logger.info("⏳ Adding Agent skills..."); + try { + const result = spawnSync("npx", args, { + cwd: options.cwd, + stdio: "ignore", + shell: process.platform === "win32", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`npx skills add exited with code ${result.status}`); + } + logger.info(`✅ Added Agent skills`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + utils.logWarning(`Could not add Agent skills: ${message}`); + } + } +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 4d0e8701425..066bfb7ee36 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -15,6 +15,7 @@ import { isEnabled } from "../experiments"; import { readTemplateSync } from "../templates"; import { FirebaseError } from "../error"; import { logBullet } from "../utils"; +import { promptForAgentSkills, installAgentSkills } from "../agentSkills"; const homeDir = os.homedir(); @@ -280,6 +281,16 @@ export async function initAction(feature: string, options: Options): Promise { let sandbox: sinon.SinonSandbox; + let installSkillsStub: sinon.SinonStub; const testRoot = "/test/root"; beforeEach(() => { @@ -114,6 +116,7 @@ describe("migrate", () => { .resolves({ backends: [], unreachable: [] }); commandStub = sandbox.stub(utils, "commandExistsSync").returns(false); + commandStub.withArgs("npx").returns(true); trackStub = sandbox.stub(track, "trackGA4").resolves(); confirmStub = sandbox.stub(prompt, "confirm").resolves(true); selectStub = sandbox.stub(prompt, "select").resolves("local"); @@ -124,6 +127,7 @@ describe("migrate", () => { // No-op for testing }, } as unknown as import("child_process").ChildProcess); + installSkillsStub = sandbox.stub(agentSkills, "installAgentSkills").resolves(); }); it("should fail if the directory does not exist", async () => { @@ -234,23 +238,13 @@ describe("migrate", () => { ), ).to.be.true; - const cp = require("child_process"); expect( - (cp.spawnSync as sinon.SinonStub).calledWith( - "npx", - [ - "-y", - "skills", - "add", - "firebase/agent-skills", - "-a", - "gemini-cli", - "--skill", - "*", - "-y", - ], - sinon.match.any, - ), + installSkillsStub.calledWith({ + cwd: testRoot, + global: false, + background: false, + agentName: "gemini-cli", + }), ).to.be.true; }); @@ -395,6 +389,8 @@ describe("migrate", () => { throw new Error(`Unexpected readFile: ${pStr}`); }); + commandStub.withArgs("npx").returns(false); + await migrate(testRoot); expect( @@ -536,24 +532,13 @@ describe("migrate", () => { await migrate(testRoot); - const cp = require("child_process"); expect( - (cp.spawnSync as sinon.SinonStub).calledWith( - "npx", - [ - "-y", - "skills", - "add", - "firebase/agent-skills", - "-a", - "gemini-cli", - "--skill", - "*", - "-y", - "-g", - ], - sinon.match.any, - ), + installSkillsStub.calledWith({ + cwd: testRoot, + global: true, + background: false, + agentName: "gemini-cli", + }), ).to.be.true; }); diff --git a/src/firebase_studio/migrate.ts b/src/firebase_studio/migrate.ts index 38d2342c2fc..775b6c4f2a2 100644 --- a/src/firebase_studio/migrate.ts +++ b/src/firebase_studio/migrate.ts @@ -1,6 +1,6 @@ import * as fs from "fs/promises"; import * as path from "path"; -import { spawn, spawnSync } from "child_process"; +import { spawn } from "child_process"; import * as semver from "semver"; import { logger } from "../logger"; @@ -13,6 +13,7 @@ import { apphostingSecretsSetAction } from "../apphosting/secrets"; import * as env from "../functions/env"; import { FirebaseError } from "../error"; import * as os from "os"; +import { installAgentSkills } from "../agentSkills"; export interface MigrateOptions { project?: string; @@ -305,38 +306,12 @@ async function injectAntigravityContext( nonInteractive: process.env.NODE_ENV === "test", }); - logger.info("⏳ Adding Antigravity skills..."); - try { - const args = [ - "-y", - "skills", - "add", - "firebase/agent-skills", - "-a", - "gemini-cli", - "--skill", - "*", - "-y", - ]; - if (installLocation === "global") { - args.push("-g"); - } - - const result = spawnSync("npx", args, { - cwd: rootPath, - stdio: "ignore", - shell: process.platform === "win32", - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error(`npx skills add exited with code ${result.status}`); - } - logger.info(`✅ Added Antigravity skills`); - } catch (err: unknown) { - utils.logWarning(`Could not add Antigravity skills, skipping. ${err}`); - } + await installAgentSkills({ + cwd: rootPath, + global: installLocation === "global", + background: false, + agentName: "gemini-cli", + }); // System Instructions const systemInstructionsTemplate = await readTemplate( From b78fc459012fd8c8426aed7409732f628d2c328c Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 23 Mar 2026 09:21:44 -0700 Subject: [PATCH 2/6] Parallel wont work --- scripts/agent-evals/.mocharc.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/agent-evals/.mocharc.yml b/scripts/agent-evals/.mocharc.yml index 3c4b9761fd0..589984b5e56 100644 --- a/scripts/agent-evals/.mocharc.yml +++ b/scripts/agent-evals/.mocharc.yml @@ -2,6 +2,5 @@ import: - ./lib/scripts/agent-evals/src/helpers/mocha-bootstrap.js timeout: 120000 recursive: true -parallel: true node-options: - no-experimental-strip-types From c5859314657c1790365f109655da10aaaebabc83 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 23 Mar 2026 09:41:23 -0700 Subject: [PATCH 3/6] PR fixes --- src/agentSkills.ts | 15 +++++++-------- src/commands/init.ts | 4 ++-- src/firebase_studio/migrate.ts | 29 ++++++++++------------------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/agentSkills.ts b/src/agentSkills.ts index 2d9a53d5104..11051497887 100644 --- a/src/agentSkills.ts +++ b/src/agentSkills.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync } from "child_process"; import * as utils from "./utils"; import { logger } from "./logger"; import * as prompt from "./prompt"; +import { getErrMsg } from "./error"; export async function promptForAgentSkills(): Promise { return prompt.confirm({ @@ -41,7 +42,7 @@ export async function installAgentSkills(options: InstallAgentSkillsOptions): Pr } if (options.background) { - logger.info("⏳ Installing Agent skills in the background..."); + utils.logBullet("Installing Agent skills in the background..."); try { const child = spawn("npx", args, { cwd: options.cwd, @@ -50,13 +51,12 @@ export async function installAgentSkills(options: InstallAgentSkillsOptions): Pr shell: process.platform === "win32", }); child.unref(); - logger.info("✅ Agent skills installation started"); + utils.logSuccess("Agent skills installation started"); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - utils.logWarning(`Could not start Agent skills installation: ${message}`); + utils.logWarning(`Could not start Agent skills installation: ${getErrMsg(err)}`); } } else { - logger.info("⏳ Adding Agent skills..."); + utils.logBullet("Adding Agent skills..."); try { const result = spawnSync("npx", args, { cwd: options.cwd, @@ -69,10 +69,9 @@ export async function installAgentSkills(options: InstallAgentSkillsOptions): Pr if (result.status !== 0) { throw new Error(`npx skills add exited with code ${result.status}`); } - logger.info(`✅ Added Agent skills`); + utils.logSuccess("Added Agent skills"); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - utils.logWarning(`Could not add Agent skills: ${message}`); + utils.logWarning(`Could not add Agent skills: ${getErrMsg(err)}`); } } } diff --git a/src/commands/init.ts b/src/commands/init.ts index 066bfb7ee36..700ee102898 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -13,7 +13,7 @@ import * as utils from "../utils"; import { Options } from "../options"; import { isEnabled } from "../experiments"; import { readTemplateSync } from "../templates"; -import { FirebaseError } from "../error"; +import { FirebaseError, getErrMsg } from "../error"; import { logBullet } from "../utils"; import { promptForAgentSkills, installAgentSkills } from "../agentSkills"; @@ -288,7 +288,7 @@ export async function initAction(feature: string, options: Options): Promise; } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.debug(`Could not read ${settingsPath}: ${message}`); + logger.debug(`Could not read ${settingsPath}: ${getErrMsg(err)}`); } const cleanSettings: Record = {}; @@ -590,8 +586,7 @@ async function cleanupUnusedFiles(rootPath: string): Promise { logger.info("✅ Removed empty docs directory"); } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.debug(`Could not remove ${docsDir}: ${message}`); + logger.debug(`Could not remove ${docsDir}: ${getErrMsg(err)}`); } const modifiedPath = path.join(rootPath, ".modified"); @@ -599,8 +594,7 @@ async function cleanupUnusedFiles(rootPath: string): Promise { await fs.unlink(modifiedPath); logger.info("✅ Cleaned up .modified"); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.debug(`Could not delete ${modifiedPath}: ${message}`); + logger.debug(`Could not delete ${modifiedPath}: ${getErrMsg(err)}`); } const mcpJsonPath = path.join(rootPath, ".idx", "mcp.json"); @@ -608,8 +602,7 @@ async function cleanupUnusedFiles(rootPath: string): Promise { await fs.unlink(mcpJsonPath); logger.info("✅ Cleaned up .idx/mcp.json"); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.debug(`Could not delete ${mcpJsonPath}: ${message}`); + logger.debug(`Could not delete ${mcpJsonPath}: ${getErrMsg(err)}`); } } @@ -656,8 +649,7 @@ async function upgradeGenkitVersion(rootPath: string): Promise { logger.info("✅ Upgraded genkit-cli version to 1.29 in package.json"); } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.debug(`Could not upgrade Genkit version: ${message}`); + logger.debug(`Could not upgrade Genkit version: ${getErrMsg(err)}`); } } @@ -697,8 +689,7 @@ export async function uploadSecrets( logger.debug("Skipping GEMINI_API_KEY upload: key is missing or blank in .env"); } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - utils.logWarning(`Failed to upload GEMINI_API_KEY secret: ${message}`); + utils.logWarning(`Failed to upload GEMINI_API_KEY secret: ${getErrMsg(err)}`); } } From 9e618658865b22744dc4c601a3fb1860a019b69a Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 23 Mar 2026 10:34:27 -0700 Subject: [PATCH 4/6] Format --- src/agentSkills.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agentSkills.ts b/src/agentSkills.ts index 11051497887..6f5fd34988b 100644 --- a/src/agentSkills.ts +++ b/src/agentSkills.ts @@ -1,6 +1,5 @@ import { spawn, spawnSync } from "child_process"; import * as utils from "./utils"; -import { logger } from "./logger"; import * as prompt from "./prompt"; import { getErrMsg } from "./error"; From 51fd03e95bafc15bf4445e2fee4e034b6eb12ebe Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 23 Mar 2026 15:47:15 -0700 Subject: [PATCH 5/6] Refactor --- src/commands/init.ts | 16 ++++------------ src/init/features/index.ts | 5 +++++ src/init/index.ts | 7 +++++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 700ee102898..9118edd63ab 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -13,9 +13,8 @@ import * as utils from "../utils"; import { Options } from "../options"; import { isEnabled } from "../experiments"; import { readTemplateSync } from "../templates"; -import { FirebaseError, getErrMsg } from "../error"; +import { FirebaseError } from "../error"; import { logBullet } from "../utils"; -import { promptForAgentSkills, installAgentSkills } from "../agentSkills"; const homeDir = os.homedir(); @@ -278,19 +277,12 @@ export async function initAction(feature: string, options: Options): Promise f !== "dataconnect:sdk"); } + // Always prompt for agent skills at the end of init + setup.features.push("agentSkills"); + await init(setup, config, options); await postInitSaves(setup, config); - // Prompt for agent skills at the end of init - try { - const shouldInstall = await promptForAgentSkills(); - if (shouldInstall) { - void installAgentSkills({ background: true, cwd }); - } - } catch (err: unknown) { - logger.debug(`Could not prompt for agent skills: ${getErrMsg(err)}`); - } - if (setup.instructions.length) { logger.info(`\n${clc.bold("To get started:")}\n`); for (const i of setup.instructions) { diff --git a/src/init/features/index.ts b/src/init/features/index.ts index 9fa8fad31ec..3d4ace315ec 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -56,3 +56,8 @@ export { actuate as aiLogicActuate, } from "./ailogic"; export { askQuestions as authAskQuestions, actuate as authActuate, AuthInfo } from "./auth"; +export { + askQuestions as agentSkillsAskQuestions, + actuate as agentSkillsActuate, + AgentSkillsInfo, +} from "./agentSkills"; diff --git a/src/init/index.ts b/src/init/index.ts index 3445b9523cf..dda64f48834 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -44,6 +44,7 @@ export interface SetupInfo { ailogic?: features.AiLogicInfo; hosting?: features.HostingInfo; auth?: features.AuthInfo; + agentSkills?: features.AgentSkillsInfo; } interface Feature { @@ -127,6 +128,12 @@ const featuresList: Feature[] = [ askQuestions: features.authAskQuestions, actuate: features.authActuate, }, + { + name: "agentSkills", + displayName: "Agent Skills", + askQuestions: features.agentSkillsAskQuestions, + actuate: features.agentSkillsActuate, + }, ]; const featureMap = new Map(featuresList.map((feature) => [feature.name, feature])); From 3d98a87bce99b2cf759d5a935e6619ee06165caa Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Tue, 24 Mar 2026 10:37:56 -0700 Subject: [PATCH 6/6] missed a file --- src/init/features/agentSkills.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/init/features/agentSkills.ts diff --git a/src/init/features/agentSkills.ts b/src/init/features/agentSkills.ts new file mode 100644 index 00000000000..d4dac0631bc --- /dev/null +++ b/src/init/features/agentSkills.ts @@ -0,0 +1,32 @@ +import { Config } from "../../config"; +import { Setup } from ".."; +import { promptForAgentSkills, installAgentSkills } from "../../agentSkills"; +import { logger } from "../../logger"; +import { getErrMsg } from "../../error"; + +export interface AgentSkillsInfo { + shouldInstall: boolean; +} + +export async function askQuestions(setup: Setup): Promise { + try { + logger.info( + "If you are using an AI coding agent, Firebase Agent Skills make it an expert at Firebase.", + ); + const shouldInstall = await promptForAgentSkills(); + setup.featureInfo = setup.featureInfo || {}; + setup.featureInfo.agentSkills = { shouldInstall }; + } catch (err: unknown) { + logger.debug(`Could not prompt for agent skills: ${getErrMsg(err)}`); + } +} + +export async function actuate(setup: Setup, config: Config): Promise { + const info = setup.featureInfo?.agentSkills; + if (!info || !info.shouldInstall) { + return; + } + + const cwd = config.projectDir; + void installAgentSkills({ background: true, cwd }); +}