Add a Gemini CLI extension package so users can install, update, and remove GSD through Gemini's own extension lifecycle and have it appear in `gemini extensions list`: gemini extensions install https://github.com/open-gsd/gsd-core gemini extensions update gsd-core gemini extensions uninstall gsd-core gemini extensions link /path/to/gsd-core # dev This mirrors the additive Claude Code plugin manifest (#766): a thin, version-stamped manifest enforced by an in-repo drift test. The extension ships the context-file payload (GEMINI.md), loaded into every Gemini session; slash-command/agent/hook TOML projection into the extension is a documented follow-up. The manual `npx gsd-core --gemini` installer (which provides the /gsd:* commands) is unchanged — purely additive, no breaking change. - gemini-extension.json: name=binName, version tracks package.json, description, contextFileName=GEMINI.md (minimal; no mcpServers — gsd ships no MCP server) - GEMINI.md: Gemini-session context payload - package.json: add both artifacts to files[] so they publish - CONTEXT.md: add "Gemini Extension Package" glossary entry - docs: USER-GUIDE + install-on-your-runtime how-to - tests/issue-775-gemini-extension.test.cjs: manifest validity, version parity with package.json, contextFileName existence, files[] publication Closes #775 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
5.2 KiB
JavaScript
120 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression tests for issue #775: additive Gemini CLI extension manifest.
|
|
*
|
|
* Asserts structural and semantic correctness of the Gemini Extension Package:
|
|
* gemini-extension.json — extension manifest (consumed by
|
|
* `gemini extensions install <git-url>`)
|
|
* GEMINI.md — the extension's context-file payload, referenced
|
|
* by the manifest's `contextFileName` field.
|
|
*
|
|
* This mirrors tests/issue-766-plugin-manifest.test.cjs (the parallel Claude
|
|
* Code plugin manifest) — the Gemini extension is the same artifact-surface
|
|
* projection onto Gemini CLI's package contract.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
|
const pkg = require(path.join(ROOT, 'package.json'));
|
|
|
|
const MANIFEST_PATH = path.join(ROOT, 'gemini-extension.json');
|
|
|
|
// ─── Section A: gemini-extension.json ────────────────────────────────────────
|
|
describe('A: gemini-extension.json', () => {
|
|
|
|
let manifest;
|
|
|
|
test('exists and is valid JSON', () => {
|
|
assert.ok(fs.existsSync(MANIFEST_PATH), 'gemini-extension.json must exist at repo root');
|
|
const raw = fs.readFileSync(MANIFEST_PATH, 'utf-8');
|
|
manifest = JSON.parse(raw); // throws on invalid JSON
|
|
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
|
|
});
|
|
|
|
test('name equals identity.binName ("gsd-core")', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
|
|
});
|
|
|
|
test('name is lowercase/dashes only (Gemini extension naming rule)', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
// Gemini reference: "lowercase or numbers and use dashes instead of
|
|
// underscores or spaces".
|
|
assert.match(
|
|
manifest.name,
|
|
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
'name must be lowercase letters/numbers with dashes (no underscores, spaces, or uppercase)'
|
|
);
|
|
});
|
|
|
|
test('version is a non-empty string matching package.json version', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(
|
|
manifest.version,
|
|
pkg.version,
|
|
`gemini-extension.json version (${manifest.version}) must match package.json version (${pkg.version}). ` +
|
|
`When bumping the package version, update gemini-extension.json \`version\` to match — ` +
|
|
`Gemini CLI's \`gemini extensions update\` keys off the manifest version field. (#775)`
|
|
);
|
|
});
|
|
|
|
test('description is a non-empty string (required by Gemini manifest schema)', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(
|
|
typeof manifest.description === 'string' && manifest.description.trim().length > 0,
|
|
'description must be a non-empty string'
|
|
);
|
|
});
|
|
|
|
test('contextFileName points to an existing repo-root file', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.contextFileName, 'GEMINI.md', 'contextFileName must be "GEMINI.md"');
|
|
const ctx = path.join(ROOT, manifest.contextFileName);
|
|
assert.ok(fs.existsSync(ctx), `context file must exist on disk: ${ctx}`);
|
|
const body = fs.readFileSync(ctx, 'utf-8');
|
|
assert.ok(body.trim().length > 0, 'context file must be non-empty');
|
|
});
|
|
|
|
test('only declares schema-known top-level keys', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
// Per Gemini extension reference. We intentionally ship the minimal
|
|
// context-loading subset; this guards against typos / unknown keys that
|
|
// would fail `gemini extensions install` manifest validation.
|
|
const ALLOWED = new Set([
|
|
'name', 'version', 'description', 'contextFileName',
|
|
'mcpServers', 'excludeTools', 'migratedTo', 'plan', 'settings', 'themes',
|
|
]);
|
|
for (const key of Object.keys(manifest)) {
|
|
assert.ok(ALLOWED.has(key), `Unknown top-level manifest key "${key}" is not in the Gemini extension schema`);
|
|
}
|
|
});
|
|
|
|
test('does not declare mcpServers (gsd-core ships no MCP server)', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(manifest, 'mcpServers'),
|
|
'manifest must NOT declare mcpServers — gsd-core has no MCP server'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Section B: package publication ──────────────────────────────────────────
|
|
describe('B: package.json publication', () => {
|
|
|
|
test('files[] includes gemini-extension.json and GEMINI.md', () => {
|
|
const files = pkg.files || [];
|
|
for (const required of ['gemini-extension.json', 'GEMINI.md']) {
|
|
assert.ok(
|
|
files.includes(required),
|
|
`package.json "files" must include "${required}" so it ships to npm consumers`
|
|
);
|
|
}
|
|
});
|
|
});
|