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
7 changes: 7 additions & 0 deletions .agents/plugins.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"entries": [
{
"path": "plugins"
}
]
}
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

## Repository 模型

- `.agents/plugins/marketplace.json` 是 repo marketplace catalog。
- 每個 plugin 位於 `plugins/<name>/`,manifest 固定為 `plugins/<name>/.codex-plugin/plugin.json`。
- `.agents/plugins/marketplace.json` 是 Codex repo marketplace catalog。
- `.agents/plugins.json` 是 Antigravity workspace plugin 註冊設定檔(宣告 `plugins/` 路徑)。
- 每個 plugin 位於 `plugins/<name>/`,採雙平台相容架構:
- Codex manifest 固定為 `plugins/<name>/.codex-plugin/plugin.json`。
- Antigravity manifest 固定為 `plugins/<name>/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。

## 版本規則
Expand All @@ -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/<name>` 表示,並保留 `policy.installation`、`policy.authentication` 與 `category`。
- 新增 plugin 時同步更新 `README.md` 的 Plugins 表格與相關測試;不要把 plugin 名稱硬編碼進升版腳本。
- 修改 manifest 的非版本欄位時,保留既有 metadata、assets 與使用者未提交的內容。
Expand Down
34 changes: 20 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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` 是由升版腳本同步的必要衍生值。

## 版本管理

Expand All @@ -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/<name>/.codex-plugin/plugin.json` 與需要的 `skills/`、`assets/` 等內容。
2. Manifest 必須包含穩定的 kebab-case `name`、與 `VERSION` 相同的 `version`,以及實際使用的元件路徑,例如 `"skills": "./skills/"`。
1. 建立 `plugins/<name>/.codex-plugin/plugin.json`(Codex manifest)與 `plugins/<name>/plugin.json`(Antigravity manifest),以及需要的 `skills/`、`rules/` 等內容。
2. 雙平台 Manifest 必須包含穩定的 kebab-case `name`、與 `VERSION` 相同的 `version`。
3. 在 `.agents/plugins/marketplace.json` 新增 local source entry,路徑使用相對於 repository root 的 `./plugins/<name>`。
4. 更新本 README 的 Plugins 表格。
5. 執行完整驗證:
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions plugins/cmd/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "cmd",
"version": "0.1.21",
"description": "Prompt-based wrapper commands for explicit agent workflows in Antigravity."
}
5 changes: 5 additions & 0 deletions plugins/dev/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "dev",
"version": "0.1.21",
"description": "Software development utilities for Antigravity."
}
52 changes: 29 additions & 23 deletions scripts/bump-plugin-versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
90 changes: 69 additions & 21 deletions test/bump-plugin-versions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down Expand Up @@ -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));

Expand All @@ -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);

Expand Down Expand Up @@ -140,24 +174,38 @@ 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.
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.*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);

Expand Down Expand Up @@ -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);

Expand Down