Skip to content
Merged
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
@@ -0,0 +1 @@
- Added a prompt to `firebase init` to install Agent Skills for Firebase.
71 changes: 71 additions & 0 deletions src/agentSkills.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
});
76 changes: 76 additions & 0 deletions src/agentSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { spawn, spawnSync } from "child_process";
import * as utils from "./utils";
import * as prompt from "./prompt";
import { getErrMsg } from "./error";

export async function promptForAgentSkills(): Promise<boolean> {

Check warning on line 6 in src/agentSkills.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
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<void> {

Check warning on line 20 in src/agentSkills.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
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) {
utils.logBullet("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();
utils.logSuccess("Agent skills installation started");
} catch (err: unknown) {
utils.logWarning(`Could not start Agent skills installation: ${getErrMsg(err)}`);
}
} else {
utils.logBullet("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}`);

Check warning on line 69 in src/agentSkills.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "number | null" of template literal expression
}
utils.logSuccess("Added Agent skills");
} catch (err: unknown) {
utils.logWarning(`Could not add Agent skills: ${getErrMsg(err)}`);
}
}
}
3 changes: 3 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@

const setup: Setup = {
config: config.src,
rcfile: config.readProjectFile(".firebaserc", {

Check warning on line 215 in src/commands/init.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
json: true,
fallback: {},
}),
Expand Down Expand Up @@ -277,6 +277,9 @@
setup.features = setup.features.filter((f) => 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);

Expand All @@ -288,7 +291,7 @@
}
}

export async function postInitSaves(setup: Setup, config: Config): Promise<void> {

Check warning on line 294 in src/commands/init.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
logger.info();
config.writeProjectFile("firebase.json", setup.config);
config.writeProjectFile(".firebaserc", setup.rcfile);
Expand Down
51 changes: 18 additions & 33 deletions src/firebase_studio/migrate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
import * as track from "../track";
import * as secrets from "../apphosting/secrets";
import * as utils from "../utils";
import * as agentSkills from "../agentSkills";

describe("migrate", () => {
let sandbox: sinon.SinonSandbox;
let installSkillsStub: sinon.SinonStub;
const testRoot = "/test/root";

beforeEach(() => {
Expand All @@ -23,9 +25,9 @@

describe("extractMetadata", () => {
beforeEach(() => {
sandbox.stub(fs, "readFile").callsFake(async (p: any) => {

Check warning on line 28 in src/firebase_studio/migrate.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
const pStr = p.toString();

Check warning on line 29 in src/firebase_studio/migrate.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe call of an `any` typed value

Check warning on line 29 in src/firebase_studio/migrate.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .toString on an `any` value

Check warning on line 29 in src/firebase_studio/migrate.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
if (pStr.endsWith("metadata.json")) {

Check warning on line 30 in src/firebase_studio/migrate.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .endsWith on an `any` value
return JSON.stringify({ projectId: "original-project" });
}
if (pStr.endsWith("blueprint.md")) {
Expand Down Expand Up @@ -114,6 +116,7 @@
.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");
Expand All @@ -124,6 +127,7 @@
// 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 () => {
Expand Down Expand Up @@ -234,23 +238,13 @@
),
).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;
});

Expand Down Expand Up @@ -395,6 +389,8 @@
throw new Error(`Unexpected readFile: ${pStr}`);
});

commandStub.withArgs("npx").returns(false);

await migrate(testRoot);

expect(
Expand Down Expand Up @@ -536,24 +532,13 @@

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;
});

Expand Down
Loading
Loading