From ad9a78e27e044371ab701105dce88524ecd5a206 Mon Sep 17 00:00:00 2001 From: Sam Wang Date: Sat, 15 Aug 2026 13:54:36 +0800 Subject: [PATCH] feat(plugins): support Antigravity plugins alongside Codex plugins - Add .agents/plugins.json to register plugins/ directory for Antigravity discovery - Add Antigravity plugin manifest (plugin.json) for cmd and dev plugins - Refactor scripts/bump-plugin-versions.mjs to discover and validate dual manifests in lockstep - Expand test/bump-plugin-versions.test.mjs with dual manifest bump and drift tests - Update AGENTS.md and README.md with dual-platform plugin guidelines Closes #8 --- .agents/plugins.json | 7 +++ AGENTS.md | 13 +++-- README.md | 34 ++++++----- plugins/cmd/plugin.json | 5 ++ plugins/dev/plugin.json | 5 ++ scripts/bump-plugin-versions.mjs | 52 +++++++++-------- test/bump-plugin-versions.test.mjs | 90 +++++++++++++++++++++++------- 7 files changed, 143 insertions(+), 63 deletions(-) create mode 100644 .agents/plugins.json create mode 100644 plugins/cmd/plugin.json create mode 100644 plugins/dev/plugin.json diff --git a/.agents/plugins.json b/.agents/plugins.json new file mode 100644 index 0000000..f3e8d0b --- /dev/null +++ b/.agents/plugins.json @@ -0,0 +1,7 @@ +{ + "entries": [ + { + "path": "plugins" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index f5a3c68..a26fbe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,13 @@ ## Repository 模型 -- `.agents/plugins/marketplace.json` 是 repo marketplace catalog。 -- 每個 plugin 位於 `plugins//`,manifest 固定為 `plugins//.codex-plugin/plugin.json`。 +- `.agents/plugins/marketplace.json` 是 Codex repo marketplace catalog。 +- `.agents/plugins.json` 是 Antigravity workspace plugin 註冊設定檔(宣告 `plugins/` 路徑)。 +- 每個 plugin 位於 `plugins//`,採雙平台相容架構: + - Codex manifest 固定為 `plugins//.codex-plugin/plugin.json`。 + - Antigravity manifest 固定為 `plugins//plugin.json`。 - `VERSION` 是所有 plugins 的 canonical version。 -- Manifest 的 `version` 是必要衍生值,所有 plugins 採 lockstep versioning。 +- 所有 Manifest 的 `version` 是必要衍生值,所有 plugins 採 lockstep versioning。 - Local marketplace entry 不保存版本;不要在 `.agents/plugins/marketplace.json` 複製 manifest version。 ## 版本規則 @@ -31,8 +34,8 @@ node scripts/bump-plugin-versions.mjs X.Y.Z ## 新增或修改 Plugin -- 新增 plugin 時,目錄名稱、manifest `name` 與 marketplace entry `name` 必須一致。 -- 新 manifest 的 `version` 必須等於 `VERSION`,並由 `node scripts/bump-plugin-versions.mjs --check` 驗證。 +- 新增 plugin 時,目錄名稱、雙平台 manifest `name` 與 marketplace entry `name` 必須一致。 +- 雙平台 manifests 的 `version` 必須等於 `VERSION`,並由 `node scripts/bump-plugin-versions.mjs --check` 驗證。 - Marketplace local path 必須以 `./plugins/` 表示,並保留 `policy.installation`、`policy.authentication` 與 `category`。 - 新增 plugin 時同步更新 `README.md` 的 Plugins 表格與相關測試;不要把 plugin 名稱硬編碼進升版腳本。 - 修改 manifest 的非版本欄位時,保留既有 metadata、assets 與使用者未提交的內容。 diff --git a/README.md b/README.md index 9e9d881..dcab494 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# SamWang Codex Plugins +# SamWang Codex & Antigravity Plugins -這個 repository 以單一 marketplace 管理多個 Codex plugins,並使用 lockstep versioning 讓所有 plugin manifest 保持同一版本。 +這個 repository 以單一架構管理多個 Codex 與 Antigravity 雙平台相容的 plugins,並使用 lockstep versioning 讓所有 plugin manifest 保持同一版本。 ## Plugins @@ -9,23 +9,30 @@ | `cmd` | `plugins/cmd` | 提供 commit、push、建立 branch 與 PR 等明確呼叫的工作流程。 | | `dev` | `plugins/dev` | 提供 SDKMAN/JDK 切換等開發工具。 | -Marketplace 定義位於 `.agents/plugins/marketplace.json`。它只登記 plugin 名稱、來源路徑與安裝政策,不保存 local plugin 的版本。 +- **Codex Marketplace** 定義位於 `.agents/plugins/marketplace.json`。它登記 plugin 名稱、來源路徑與安裝政策,不保存 local plugin 的版本。 +- **Antigravity Workspace 註冊** 定義位於 `.agents/plugins.json`,宣告 workspace 根目錄下的 `plugins/`。 ## Repository 結構 ```text . -├── .agents/plugins/marketplace.json +├── .agents/ +│ ├── plugins.json +│ └── plugins/marketplace.json ├── .github/workflows/plugin-versions.yml ├── plugins/ -│ ├── cmd/.codex-plugin/plugin.json -│ └── dev/.codex-plugin/plugin.json +│ ├── cmd/ +│ │ ├── .codex-plugin/plugin.json +│ │ └── plugin.json +│ └── dev/ +│ ├── .codex-plugin/plugin.json +│ └── plugin.json ├── scripts/bump-plugin-versions.mjs ├── test/bump-plugin-versions.test.mjs └── VERSION ``` -`VERSION` 是 canonical version。每個 `plugins/*/.codex-plugin/plugin.json` 的 `version` 是由升版腳本同步的必要衍生值。 +`VERSION` 是 canonical version。每個 `plugins/*/.codex-plugin/plugin.json` 與 `plugins/*/plugin.json` 的 `version` 是由升版腳本同步的必要衍生值。 ## 版本管理 @@ -47,14 +54,14 @@ node scripts/bump-plugin-versions.mjs major # 0.1.1 -> 1.0.0 node scripts/bump-plugin-versions.mjs 1.0.0 # 指定版本 ``` -腳本會自動掃描 `plugins/*/.codex-plugin/plugin.json`,所以新增 plugin 後不需修改升版清單。它只接受 stable SemVer `X.Y.Z`;更新前會完整檢查 `VERSION`、JSON 與版本漂移,前置檢查失敗時不會寫入任何受管檔案。實際更新會先在各檔案所在目錄完成暫存與備份,再以 atomic rename 提交;一般 I/O 提交錯誤會嘗試回復原始內容。 +腳本會自動掃描所有 `plugins/*/.codex-plugin/plugin.json` 與 `plugins/*/plugin.json`,所以新增 plugin 後不需修改升版清單。它只接受 stable SemVer `X.Y.Z`;更新前會完整檢查 `VERSION`、JSON 與版本漂移,前置檢查失敗時不會寫入任何受管檔案。實際更新會先在各檔案所在目錄完成暫存與備份,再以 atomic rename 提交;一般 I/O 提交錯誤會嘗試回復原始內容。 請勿單獨手改 manifest 的 `version`。若版本已漂移,先修正原因並執行 `--check`,再進行下一次升版。 ## 新增 Plugin -1. 建立 `plugins//.codex-plugin/plugin.json` 與需要的 `skills/`、`assets/` 等內容。 -2. Manifest 必須包含穩定的 kebab-case `name`、與 `VERSION` 相同的 `version`,以及實際使用的元件路徑,例如 `"skills": "./skills/"`。 +1. 建立 `plugins//.codex-plugin/plugin.json`(Codex manifest)與 `plugins//plugin.json`(Antigravity manifest),以及需要的 `skills/`、`rules/` 等內容。 +2. 雙平台 Manifest 必須包含穩定的 kebab-case `name`、與 `VERSION` 相同的 `version`。 3. 在 `.agents/plugins/marketplace.json` 新增 local source entry,路徑使用相對於 repository root 的 `./plugins/`。 4. 更新本 README 的 Plugins 表格。 5. 執行完整驗證: @@ -65,11 +72,10 @@ node --test test/bump-plugin-versions.test.mjs node scripts/bump-plugin-versions.mjs --check ``` -## Local Marketplace 更新 +## Local Plugin 更新與載入 -修改 local plugin 後,重新啟動 ChatGPT desktop app,讓已安裝的 local copy 載入新內容。 - -`codex plugin marketplace upgrade` 只會刷新已設定的 Git marketplace snapshots;它不會替 manifest 升版,也不能取代 local plugin 的重新載入。Codex plugin 結構與 marketplace 行為請參考 [Build plugins](https://learn.chatgpt.com/docs/build-plugins)。 +- **Codex (ChatGPT Desktop App)**:修改 local plugin directory 後,重新啟動 ChatGPT desktop app 以載入已安裝的新 copy。`codex plugin marketplace upgrade` 只會刷新已設定的 Git marketplace snapshots。 +- **Antigravity**:透過 `.agents/plugins.json` 自動識別與載入 `plugins/` 目錄下的各個 plugin。 ## CI diff --git a/plugins/cmd/plugin.json b/plugins/cmd/plugin.json new file mode 100644 index 0000000..2fe200b --- /dev/null +++ b/plugins/cmd/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "cmd", + "version": "0.1.21", + "description": "Prompt-based wrapper commands for explicit agent workflows in Antigravity." +} diff --git a/plugins/dev/plugin.json b/plugins/dev/plugin.json new file mode 100644 index 0000000..b2ae1b6 --- /dev/null +++ b/plugins/dev/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "dev", + "version": "0.1.21", + "description": "Software development utilities for Antigravity." +} diff --git a/scripts/bump-plugin-versions.mjs b/scripts/bump-plugin-versions.mjs index 63d8c85..13695a7 100644 --- a/scripts/bump-plugin-versions.mjs +++ b/scripts/bump-plugin-versions.mjs @@ -30,38 +30,44 @@ async function discoverManifests(root) { const pluginsRoot = path.join(root, "plugins"); const entries = await readdir(pluginsRoot, { withFileTypes: true }); const manifests = []; + const manifestRelativePaths = [ + path.join(".codex-plugin", "plugin.json"), + "plugin.json", + ]; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { if (!entry.isDirectory()) { continue; } - const manifestPath = path.join(pluginsRoot, entry.name, ".codex-plugin", "plugin.json"); - try { - const contents = await readFile(manifestPath, "utf8"); - let manifest; + for (const relativePath of manifestRelativePaths) { + const manifestPath = path.join(pluginsRoot, entry.name, relativePath); try { - manifest = JSON.parse(contents); + const contents = await readFile(manifestPath, "utf8"); + let manifest; + try { + manifest = JSON.parse(contents); + } catch (error) { + throw new Error(`${manifestPath}: invalid JSON: ${error.message}`); + } + + if ( + manifest === null || + Array.isArray(manifest) || + typeof manifest !== "object" || + typeof manifest.name !== "string" || + typeof manifest.version !== "string" + ) { + throw new Error(`${manifestPath}: manifest requires string name and version fields`); + } + + manifests.push({ contents, manifestPath, manifest }); } catch (error) { - throw new Error(`${manifestPath}: invalid JSON: ${error.message}`); - } - - if ( - manifest === null || - Array.isArray(manifest) || - typeof manifest !== "object" || - typeof manifest.name !== "string" || - typeof manifest.version !== "string" - ) { - throw new Error(`${manifestPath}: manifest requires string name and version fields`); - } - - manifests.push({ contents, manifestPath, manifest }); - } catch (error) { - if (error.code === "ENOENT") { - continue; + if (error.code === "ENOENT") { + continue; + } + throw error; } - throw error; } } diff --git a/test/bump-plugin-versions.test.mjs b/test/bump-plugin-versions.test.mjs index a8dd6c0..54b5c69 100644 --- a/test/bump-plugin-versions.test.mjs +++ b/test/bump-plugin-versions.test.mjs @@ -17,32 +17,66 @@ import { fileURLToPath } from "node:url"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const scriptPath = path.join(repositoryRoot, "scripts", "bump-plugin-versions.mjs"); -async function createFixture(testContext, versions = ["1.2.3", "1.2.3"]) { +async function createFixture(testContext, versions = ["1.2.3", "1.2.3", "1.2.3", "1.2.3"]) { const root = await mkdtemp(path.join(os.tmpdir(), "plugin-versions-")); testContext.after(() => rm(root, { recursive: true, force: true })); const fixtureScriptPath = path.join(root, "scripts", "bump-plugin-versions.mjs"); const cwd = path.join(root, "elsewhere", "deep"); - const plugins = ["alpha", "omega"].map((name, index) => ({ - manifest: { - name, - version: versions[index], - description: `${name} description must survive`, - keywords: ["one", "two"], - interface: { displayName: name.toUpperCase(), capabilities: ["Write"] }, - "x-test-metadata": { enabled: true }, + const plugins = [ + { + manifest: { + name: "alpha", + version: versions[0], + description: "alpha codex description must survive", + keywords: ["one", "two"], + interface: { displayName: "ALPHA", capabilities: ["Write"] }, + "x-test-metadata": { enabled: true }, + }, + manifestPath: path.join(root, "plugins", "alpha", ".codex-plugin", "plugin.json"), }, - manifestPath: path.join(root, "plugins", name, ".codex-plugin", "plugin.json"), - })); + { + manifest: { + name: "alpha", + version: versions[1], + description: "alpha antigravity description must survive", + }, + manifestPath: path.join(root, "plugins", "alpha", "plugin.json"), + }, + { + manifest: { + name: "omega", + version: versions[2], + description: "omega codex description must survive", + keywords: ["three", "four"], + interface: { displayName: "OMEGA", capabilities: ["Interactive"] }, + "x-test-metadata": { enabled: true }, + }, + manifestPath: path.join(root, "plugins", "omega", ".codex-plugin", "plugin.json"), + }, + { + manifest: { + name: "omega", + version: versions[3], + description: "omega antigravity description must survive", + }, + manifestPath: path.join(root, "plugins", "omega", "plugin.json"), + }, + ]; await Promise.all([ mkdir(path.dirname(fixtureScriptPath), { recursive: true }), mkdir(cwd, { recursive: true }), mkdir(path.join(root, "plugins", "ignored-directory"), { recursive: true }), + mkdir(path.join(root, ".agents"), { recursive: true }), ...plugins.map(({ manifestPath }) => mkdir(path.dirname(manifestPath), { recursive: true })), ]); await copyFile(scriptPath, fixtureScriptPath); await writeFile(path.join(root, "VERSION"), "1.2.3\n"); + await writeFile( + path.join(root, ".agents", "plugins.json"), + `${JSON.stringify({ entries: [{ path: "plugins" }] }, null, 2)}\n`, + ); await Promise.all( plugins.map(({ manifest, manifestPath }) => writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`), @@ -89,8 +123,8 @@ test("--check succeeds when repository plugin versions match VERSION", () => { assert.equal(result.status, 0, result.stderr); }); -test("patch discovers every plugin from any cwd and preserves manifest data", async (testContext) => { - // Given: two synchronized plugins and an unrelated directory without a manifest. +test("patch discovers every dual manifest from any cwd and preserves manifest data", async (testContext) => { + // Given: two synchronized dual-platform plugins and an unrelated directory without a manifest. const fixture = await createFixture(testContext); const originals = structuredClone(fixture.plugins.map(({ manifest }) => manifest)); @@ -111,7 +145,7 @@ for (const [argument, expected] of [ ["major", "2.0.0"], ["7.8.9", "7.8.9"], ]) { - test(`${argument} sets the expected lockstep version`, async (testContext) => { + test(`${argument} sets the expected lockstep version across dual manifests`, async (testContext) => { // Given: a synchronized plugin repository at version 1.2.3. const fixture = await createFixture(testContext); @@ -140,9 +174,23 @@ test("--check does not rewrite synchronized files", async (testContext) => { await assertManagedFilesEqual(before); }); -test("a version change refuses drift without writing any file", async (testContext) => { - // Given: one manifest is behind VERSION and all managed files are captured. - const fixture = await createFixture(testContext, ["1.2.3", "1.2.2"]); +test("a version change refuses drift in Codex manifest without writing any file", async (testContext) => { + // Given: omega Codex manifest is behind VERSION and all managed files are captured. + const fixture = await createFixture(testContext, ["1.2.3", "1.2.3", "1.2.2", "1.2.3"]); + const before = await snapshotManagedFiles(fixture); + + // When: a minor version change is requested. + const result = runCli(fixture, "minor"); + + // Then: the drift is located and no managed file is modified. + assert.notEqual(result.status, 0); + assert.match(result.stderr, /omega.*\.codex-plugin.*expected 1\.2\.3, found 1\.2\.2/s); + await assertManagedFilesEqual(before); +}); + +test("a version change refuses drift in Antigravity manifest without writing any file", async (testContext) => { + // Given: omega Antigravity manifest is behind VERSION and all managed files are captured. + const fixture = await createFixture(testContext, ["1.2.3", "1.2.3", "1.2.3", "1.2.2"]); const before = await snapshotManagedFiles(fixture); // When: a minor version change is requested. @@ -150,14 +198,14 @@ test("a version change refuses drift without writing any file", async (testConte // Then: the drift is located and no managed file is modified. assert.notEqual(result.status, 0); - assert.match(result.stderr, /omega.*expected 1\.2\.3, found 1\.2\.2/s); + assert.match(result.stderr, /omega\/plugin\.json: expected 1\.2\.3, found 1\.2\.2/s); await assertManagedFilesEqual(before); }); test("a staging failure leaves every managed file unchanged", async (testContext) => { // Given: synchronized files and one manifest directory that cannot accept staged files. const fixture = await createFixture(testContext); - const protectedDirectory = path.dirname(fixture.plugins[1].manifestPath); + const protectedDirectory = path.dirname(fixture.plugins[0].manifestPath); const before = await snapshotManagedFiles(fixture); await chmod(protectedDirectory, 0o555); @@ -191,9 +239,9 @@ for (const invalidVersion of ["1.2", "v1.2.3", "1.2.3-beta", "01.2.3"]) { } test("invalid manifest JSON reports its path without writing", async (testContext) => { - // Given: one plugin manifest contains malformed JSON. + // Given: one plugin Antigravity manifest contains malformed JSON. const fixture = await createFixture(testContext); - const brokenPath = fixture.plugins[1].manifestPath; + const brokenPath = fixture.plugins[3].manifestPath; await writeFile(brokenPath, "{broken\n"); const before = await snapshotManagedFiles(fixture);