From 0f22f62d814ed79c7c233ddaf3111c7037e00443 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 15 Jul 2026 17:17:11 +0700 Subject: [PATCH 1/3] fix: target the config file CoDev Code actually writes (codev.jsonc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoDev Code loads BOTH ~/.config/codev/codev.json and codev.jsonc, merging them with mergeDeep in that order: result = mergeConfig(result, yield* loadFile(.../"codev.json", env)) result = mergeConfig(result, yield* loadFile(.../"codev.jsonc", env)) so a jsonc wins leaf-by-leaf over anything we write to codev.json — no error, just a config that is half ours and half theirs. This is not hypothetical. The fork's own writers (`codev configure`, and the loader's auto-seeded `$schema` stub) go through globalConfigFile(), which prefers .jsonc and *creates* one when neither file exists. So any user who launches `codev` before `codevhub install` gets a codev.jsonc, and from then on the hub and the fork write to different files with the fork's always winning. A stale apiKey in a jsonc would silently defeat ensureFreshGatewayKey's rotation. Resolve the target the way the fork does, in priority order: 1. a `*.backup` pins the file we already configured — without this a jsonc appearing after configure would send restore to the wrong candidate and strand the backup forever; 2. an existing jsonc, which is the fork's write target and would otherwise shadow us; 3. otherwise codev.json — which also stops the fork from auto-seeding a jsonc later, since globalConfigFile() finds codev.json first. Keeps the established configure model (wholesale write, backed up once), so a targeted jsonc is replaced and its original preserved as codev.jsonc.backup, exactly as with every other agent config. Reading now goes through jsonc-parser (already a dependency, and what the agents themselves use): a hand-written jsonc may carry comments or trailing commas, and JSON.parse would throw — taking `codevhub upload` down with it via readAgentConfig. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/configure.ts | 54 +++++++++++++++++---- tests/lib/configure.test.ts | 93 +++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/src/lib/configure.ts b/src/lib/configure.ts index 2f56d2d..398092e 100644 --- a/src/lib/configure.ts +++ b/src/lib/configure.ts @@ -11,6 +11,7 @@ import { import { homedir } from "node:os"; import { dirname, join } from "node:path"; import TOML from "@iarna/toml"; +import { type ParseError, parse } from "jsonc-parser"; import { AI_GATEWAY_OPENAI_URL, AI_GATEWAY_URL, @@ -221,15 +222,26 @@ function readCodexConfig(): AgentConfigResult { } } -// Shared by opencode and codev-code — the fork reads the same config shape, -// just from ~/.config/codev/codev.json instead of ~/.config/opencode/opencode.json. +// Both agents accept .json and .jsonc, and codev-code's config may legitimately +// be a .jsonc (see codevCodeConfigPath). Parse the superset so a comment or a +// trailing comma can't throw — matching how the agents themselves read it. Still +// throws on genuinely malformed input, per the contract above. +function parseJsonc(text: string): unknown { + const errors: ParseError[] = []; + const value: unknown = parse(text, errors, { allowTrailingComma: true }); + if (errors.length > 0) throw new Error("invalid JSON/JSONC"); + return value; +} + +// Shared by opencode and codev-code — the fork reads the same config shape, just +// from ~/.config/codev/codev.json(c) instead of ~/.config/opencode/opencode.json. function readOpenCodeConfig( kind: "opencode-config" | "codev-code-config", ): AgentConfigResult { const path = sourcePathOf(kind); if (!existsSync(path)) return {}; try { - const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown; + const raw = parseJsonc(readFileSync(path, "utf-8")); // Guard: skip non-CoDev configs (no aigateway provider). if (!hasNestedKey(raw, OPENCODE_K.provider, OPENCODE_K.providerKey)) return {}; @@ -270,6 +282,34 @@ const CONTINUE_K = { configVersion: atob("MC4wLjE="), }; +// The codev-code fork renamed both halves of upstream's path: the XDG app dir +// (its `Global.Path` constant is "codev") and the config filename ("codev.json"). +// Neither old name is read anymore — the fork dropped the fallback. +// +// It reads *both* codev.json and codev.jsonc, deep-merging json then jsonc, so a +// jsonc silently wins leaf-by-leaf over anything we write to json. Its own +// writers (`codev configure`, and the loader's auto-seeded `$schema` stub) go +// through `globalConfigFile()`, which prefers .jsonc. Target the same file the +// fork would, so exactly one gateway block exists. +// +// The order matters, and each rule earns its place: +// 1. A `*.backup` pins the file we already configured. Without this, a jsonc +// appearing after configure would send restore to the wrong candidate and +// strand the backup forever. +// 2. An existing jsonc is the fork's write target, and would shadow us. +// 3. Otherwise codev.json — which also keeps the fork's loader from ever +// auto-seeding a jsonc later, since `globalConfigFile()` finds codev.json +// first and leaves well enough alone. +function codevCodeConfigPath(): string { + const dir = join(homedir(), ".config", "codev"); + const jsonc = join(dir, "codev.jsonc"); + const json = join(dir, "codev.json"); + for (const candidate of [jsonc, json]) { + if (existsSync(`${candidate}.backup`)) return candidate; + } + return existsSync(jsonc) ? jsonc : json; +} + function sourcePathOf(kind: BackupKind): string { switch (kind) { case "claude-settings": @@ -282,12 +322,8 @@ function sourcePathOf(kind: BackupKind): string { return join(homedir(), ".codex", "config.toml"); case "opencode-config": return join(homedir(), ".config", "opencode", "opencode.json"); - // The codev-code fork renamed both halves of upstream's path: the XDG app - // dir (its `Global.Path` constant is "codev") and the config filename - // ("codev.json"). Neither old name is read anymore — the fork dropped the - // fallback — so this must stay in lockstep with the fork. case "codev-code-config": - return join(homedir(), ".config", "codev", "codev.json"); + return codevCodeConfigPath(); case "continue-config": return join(homedir(), ".continue", "config.yaml"); } @@ -365,7 +401,7 @@ function isCodevOpenCodeConfig( const path = sourcePathOf(kind); if (!existsSync(path)) return false; try { - const config = JSON.parse(readFileSync(path, "utf-8")) as unknown; + const config = parseJsonc(readFileSync(path, "utf-8")); return hasNestedKey(config, OPENCODE_K.provider, OPENCODE_K.providerKey); } catch { return false; diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts index 2563504..fe81d1a 100644 --- a/tests/lib/configure.test.ts +++ b/tests/lib/configure.test.ts @@ -892,6 +892,99 @@ describe("configureContinue", () => { }); }); +// The fork loads codev.json *and* codev.jsonc, deep-merging json then jsonc, so +// a jsonc it wrote (via `codev configure`, or the loader's auto-seeded stub) +// would silently shadow anything we put in codev.json. We target whichever file +// the fork's own globalConfigFile() would. +describe("CoDev Code config targeting (codev.json vs codev.jsonc)", () => { + const codevDir = () => join(tempDir, ".config", "codev"); + const seed = (name: string, body: string) => { + mkdirSync(codevDir(), { recursive: true }); + writeFileSync(join(codevDir(), name), body); + }; + const target = async () => { + const { getBackupStatus } = await import("@/lib/configure.js"); + return getBackupStatus("codev-code")[0]?.sourcePath; + }; + + test("targets codev.json when neither file exists", async () => { + // Also keeps the fork's loader from auto-seeding a jsonc later: its + // globalConfigFile() finds codev.json first and leaves it alone. + expect(await target()).toBe(join(codevDir(), "codev.json")); + }); + + test("targets an existing codev.jsonc, which would otherwise shadow us", async () => { + seed("codev.jsonc", '{"$schema":"https://opencode.ai/config.json"}'); + expect(await target()).toBe(join(codevDir(), "codev.jsonc")); + }); + + test("prefers codev.jsonc over codev.json when both exist, matching the fork's merge order", async () => { + seed("codev.json", "{}"); + seed("codev.jsonc", "{}"); + expect(await target()).toBe(join(codevDir(), "codev.jsonc")); + }); + + test("a backup pins the file we already configured, even once a jsonc appears", async () => { + // Without this the backup would strand: restore would follow the live + // jsonc, find no codev.jsonc.backup, and never restore codev.json. + seed("codev.json", "{}"); + seed("codev.json.backup", '{"original":true}'); + seed("codev.jsonc", "{}"); + expect(await target()).toBe(join(codevDir(), "codev.json")); + }); + + test("configures a jsonc in place and backs it up under the .jsonc name", async () => { + seed("codev.jsonc", '{"marker":"original"}'); + const { configureCodevCode } = await import("@/lib/configure.js"); + const [result] = configureCodevCode({ + apiKey: "k", + baseUrl: "https://gw.test/v1", + model: "m", + }); + + expect(result?.sourcePath).toBe(join(codevDir(), "codev.jsonc")); + expect(result?.backupPath).toBe(join(codevDir(), "codev.jsonc.backup")); + // No stray codev.json — one gateway block, in the file the fork reads. + expect(existsSync(join(codevDir(), "codev.json"))).toBe(false); + expect( + JSON.parse(readFileSync(join(codevDir(), "codev.jsonc.backup"), "utf-8")), + ).toEqual({ marker: "original" }); + const written = JSON.parse( + readFileSync(join(codevDir(), "codev.jsonc"), "utf-8"), + ); + expect(written.provider.aigateway.options.apiKey).toBe("k"); + }); + + test("reads a jsonc containing comments and trailing commas", async () => { + // A hand-written jsonc is the whole reason .jsonc exists; JSON.parse would + // throw here and take `codevhub upload` down with it. + seed( + "codev.jsonc", + `{ + // the gateway CoDev configured + "provider": { "aigateway": { "options": { "baseURL": "https://gw.test/v1" } } }, + }`, + ); + const { readAgentConfig } = await import("@/lib/configure.js"); + expect(readAgentConfig("codev-code")).toEqual({ + baseUrl: "https://gw.test/v1", + }); + }); + + test("restores a configured jsonc from its backup", async () => { + seed("codev.jsonc", '{"marker":"live"}'); + seed("codev.jsonc.backup", '{"marker":"backup"}'); + const { restoreTool } = await import("@/lib/configure.js"); + const [result] = restoreTool("codev-code"); + + expect(result?.status).toBe("restored"); + expect( + JSON.parse(readFileSync(join(codevDir(), "codev.jsonc"), "utf-8")), + ).toEqual({ marker: "backup" }); + expect(existsSync(join(codevDir(), "codev.jsonc.backup"))).toBe(false); + }); +}); + describe("getBackupStatus", () => { test("returns claude-settings for claude-code", async () => { const { getBackupStatus } = await import("@/lib/configure.js"); From 2855a2959cb6860dc2d75b354e08c729f36a762b Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 15 Jul 2026 17:27:59 +0700 Subject: [PATCH 2/3] fix: apply the same jsonc targeting to plain OpenCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit fixed jsonc shadowing for codev-code only, on the reasoning that upstream OpenCode "lacks the auto-seed that makes the codev-code case routine". That was wrong. Verified against the shipped opencode-ai@1.17.18 binary, upstream has both halves: function OW(){ let $=["opencode.jsonc","opencode.json","config.json"].map(...) for (let _ of $) if(_2(_)) return _ return $[0] // <- opencode.jsonc } if(!D.OPENCODE_CONFIG && !D.OPENCODE_CONFIG_DIR && !D.OPENCODE_CONFIG_CONTENT){ let N=OW(); if(!_2(N)) yield* $.writeWithDirs(N, JSON.stringify({$schema:...})) } and the same merge order, json before jsonc, so the jsonc wins: Z=F$(Z,yield*F(m.join(r.Path.config,"config.json"),Q)), Z=F$(Z,yield*F(m.join(r.Path.config,"opencode.json"),Q)), Z=F$(Z,yield*F(m.join(r.Path.config,"opencode.jsonc"),Q)) The fork inherited this loader; it did not invent it. It only renamed the files. So plain OpenCode is exposed identically — and more widely. This is observable, not theoretical: this machine already carries a ~/.config/opencode/opencode.jsonc holding exactly the auto-seeded {"$schema": ...} stub, alongside the hub's own opencode.json. Harmless only because the stub's single key matches ours; it also means globalConfigFile() now resolves there, so anything written through OpenCode's config API would outrank our gateway block. Generalize codevCodeConfigPath into openCodeConfigPath(dir, base) and use it for both agents. The rules are unchanged (backup pins > existing jsonc > .json). Upstream's third candidate, config.json, is deliberately never targeted: it is merged first, i.e. lowest priority, so writing there would leave us shadowed by both of the others. Tests are parameterized over both agents via describe.each, since the hazard and the rules are identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/configure.ts | 55 ++++++++++-------- tests/lib/configure.test.ts | 110 +++++++++++++++++++----------------- 2 files changed, 91 insertions(+), 74 deletions(-) diff --git a/src/lib/configure.ts b/src/lib/configure.ts index 398092e..bfa8324 100644 --- a/src/lib/configure.ts +++ b/src/lib/configure.ts @@ -222,10 +222,10 @@ function readCodexConfig(): AgentConfigResult { } } -// Both agents accept .json and .jsonc, and codev-code's config may legitimately -// be a .jsonc (see codevCodeConfigPath). Parse the superset so a comment or a -// trailing comma can't throw — matching how the agents themselves read it. Still -// throws on genuinely malformed input, per the contract above. +// Both agents accept .json and .jsonc, and either config may legitimately be a +// .jsonc (see openCodeConfigPath). Parse the superset so a comment or a trailing +// comma can't throw — matching how the agents themselves read it. Still throws +// on genuinely malformed input, per the contract above. function parseJsonc(text: string): unknown { const errors: ParseError[] = []; const value: unknown = parse(text, errors, { allowTrailingComma: true }); @@ -282,28 +282,33 @@ const CONTINUE_K = { configVersion: atob("MC4wLjE="), }; -// The codev-code fork renamed both halves of upstream's path: the XDG app dir -// (its `Global.Path` constant is "codev") and the config filename ("codev.json"). -// Neither old name is read anymore — the fork dropped the fallback. +// OpenCode and the codev-code fork share one config loader, so they share this +// hazard: each reads *both* `.json` and `.jsonc` from its config +// dir and deep-merges them, json first, jsonc second — so a jsonc silently wins +// leaf-by-leaf over anything we write to the json. // -// It reads *both* codev.json and codev.jsonc, deep-merging json then jsonc, so a -// jsonc silently wins leaf-by-leaf over anything we write to json. Its own -// writers (`codev configure`, and the loader's auto-seeded `$schema` stub) go -// through `globalConfigFile()`, which prefers .jsonc. Target the same file the -// fork would, so exactly one gateway block exists. +// Their own writers go through the loader's `globalConfigFile()`, which prefers +// .jsonc and *creates* one when no config exists — upstream seeds a `$schema` +// stub on any default run, and `codev configure` patches into whatever it picks. +// A user who launches the agent before `codevhub install` therefore already has +// a jsonc waiting to shadow us. Target the same file the agent would, so exactly +// one gateway block exists. // // The order matters, and each rule earns its place: // 1. A `*.backup` pins the file we already configured. Without this, a jsonc // appearing after configure would send restore to the wrong candidate and // strand the backup forever. -// 2. An existing jsonc is the fork's write target, and would shadow us. -// 3. Otherwise codev.json — which also keeps the fork's loader from ever -// auto-seeding a jsonc later, since `globalConfigFile()` finds codev.json -// first and leaves well enough alone. -function codevCodeConfigPath(): string { - const dir = join(homedir(), ".config", "codev"); - const jsonc = join(dir, "codev.jsonc"); - const json = join(dir, "codev.json"); +// 2. An existing jsonc is the agent's write target, and would shadow us. +// 3. Otherwise `.json` — which also keeps the agent from auto-seeding a +// jsonc later, since `globalConfigFile()` finds `.json` first and +// leaves well enough alone. +// +// Upstream lists a third candidate, `config.json`, that we deliberately never +// target: it is merged *first*, i.e. lowest priority, so writing there would +// leave us shadowed by both of the others. +function openCodeConfigPath(dir: string, base: string): string { + const jsonc = join(dir, `${base}.jsonc`); + const json = join(dir, `${base}.json`); for (const candidate of [jsonc, json]) { if (existsSync(`${candidate}.backup`)) return candidate; } @@ -321,9 +326,15 @@ function sourcePathOf(kind: BackupKind): string { case "codex-config": return join(homedir(), ".codex", "config.toml"); case "opencode-config": - return join(homedir(), ".config", "opencode", "opencode.json"); + return openCodeConfigPath( + join(homedir(), ".config", "opencode"), + "opencode", + ); + // The fork renamed both halves of upstream's path: the XDG app dir (its + // `Global.Path` constant is "codev") and the config basename. Neither old + // name is read anymore — the fork dropped the fallback. case "codev-code-config": - return codevCodeConfigPath(); + return openCodeConfigPath(join(homedir(), ".config", "codev"), "codev"); case "continue-config": return join(homedir(), ".continue", "config.yaml"); } diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts index fe81d1a..6773e84 100644 --- a/tests/lib/configure.test.ts +++ b/tests/lib/configure.test.ts @@ -892,66 +892,72 @@ describe("configureContinue", () => { }); }); -// The fork loads codev.json *and* codev.jsonc, deep-merging json then jsonc, so -// a jsonc it wrote (via `codev configure`, or the loader's auto-seeded stub) -// would silently shadow anything we put in codev.json. We target whichever file -// the fork's own globalConfigFile() would. -describe("CoDev Code config targeting (codev.json vs codev.jsonc)", () => { - const codevDir = () => join(tempDir, ".config", "codev"); - const seed = (name: string, body: string) => { - mkdirSync(codevDir(), { recursive: true }); - writeFileSync(join(codevDir(), name), body); +// OpenCode and the codev-code fork share a config loader, so they share its +// hazard: each reads .json *and* .jsonc, deep-merging json then +// jsonc, so a jsonc the agent wrote (via its auto-seeded stub, or `codev +// configure`) would silently shadow anything we put in the json. We target +// whichever file the agent's own globalConfigFile() would. +describe.each([ + { tool: "opencode", dir: "opencode", base: "opencode" }, + { tool: "codev-code", dir: "codev", base: "codev" }, +] as const)("$tool config targeting ($base.json vs $base.jsonc)", (agent) => { + const configDir = () => join(tempDir, ".config", agent.dir); + const at = (suffix: string) => join(configDir(), `${agent.base}${suffix}`); + const seed = (suffix: string, body: string) => { + mkdirSync(configDir(), { recursive: true }); + writeFileSync(at(suffix), body); }; const target = async () => { const { getBackupStatus } = await import("@/lib/configure.js"); - return getBackupStatus("codev-code")[0]?.sourcePath; + return getBackupStatus(agent.tool)[0]?.sourcePath; + }; + const configure = async (creds: { apiKey: string; model: string }) => { + const mod = await import("@/lib/configure.js"); + const fn = + agent.tool === "opencode" + ? mod.configureOpenCode + : mod.configureCodevCode; + return fn({ ...creds, baseUrl: "https://gw.test/v1" }); }; - test("targets codev.json when neither file exists", async () => { - // Also keeps the fork's loader from auto-seeding a jsonc later: its - // globalConfigFile() finds codev.json first and leaves it alone. - expect(await target()).toBe(join(codevDir(), "codev.json")); + test("targets the .json when neither file exists", async () => { + // Also keeps the agent from auto-seeding a jsonc later: its + // globalConfigFile() finds the .json first and leaves it alone. + expect(await target()).toBe(at(".json")); }); - test("targets an existing codev.jsonc, which would otherwise shadow us", async () => { - seed("codev.jsonc", '{"$schema":"https://opencode.ai/config.json"}'); - expect(await target()).toBe(join(codevDir(), "codev.jsonc")); + test("targets an existing .jsonc, which would otherwise shadow us", async () => { + seed(".jsonc", '{"$schema":"https://opencode.ai/config.json"}'); + expect(await target()).toBe(at(".jsonc")); }); - test("prefers codev.jsonc over codev.json when both exist, matching the fork's merge order", async () => { - seed("codev.json", "{}"); - seed("codev.jsonc", "{}"); - expect(await target()).toBe(join(codevDir(), "codev.jsonc")); + test("prefers the .jsonc when both exist, matching the agent's merge order", async () => { + seed(".json", "{}"); + seed(".jsonc", "{}"); + expect(await target()).toBe(at(".jsonc")); }); test("a backup pins the file we already configured, even once a jsonc appears", async () => { // Without this the backup would strand: restore would follow the live - // jsonc, find no codev.jsonc.backup, and never restore codev.json. - seed("codev.json", "{}"); - seed("codev.json.backup", '{"original":true}'); - seed("codev.jsonc", "{}"); - expect(await target()).toBe(join(codevDir(), "codev.json")); + // jsonc, find no .jsonc.backup, and never restore the .json. + seed(".json", "{}"); + seed(".json.backup", '{"original":true}'); + seed(".jsonc", "{}"); + expect(await target()).toBe(at(".json")); }); test("configures a jsonc in place and backs it up under the .jsonc name", async () => { - seed("codev.jsonc", '{"marker":"original"}'); - const { configureCodevCode } = await import("@/lib/configure.js"); - const [result] = configureCodevCode({ - apiKey: "k", - baseUrl: "https://gw.test/v1", - model: "m", + seed(".jsonc", '{"marker":"original"}'); + const [result] = await configure({ apiKey: "k", model: "m" }); + + expect(result?.sourcePath).toBe(at(".jsonc")); + expect(result?.backupPath).toBe(at(".jsonc.backup")); + // No stray .json — one gateway block, in the file the agent reads. + expect(existsSync(at(".json"))).toBe(false); + expect(JSON.parse(readFileSync(at(".jsonc.backup"), "utf-8"))).toEqual({ + marker: "original", }); - - expect(result?.sourcePath).toBe(join(codevDir(), "codev.jsonc")); - expect(result?.backupPath).toBe(join(codevDir(), "codev.jsonc.backup")); - // No stray codev.json — one gateway block, in the file the fork reads. - expect(existsSync(join(codevDir(), "codev.json"))).toBe(false); - expect( - JSON.parse(readFileSync(join(codevDir(), "codev.jsonc.backup"), "utf-8")), - ).toEqual({ marker: "original" }); - const written = JSON.parse( - readFileSync(join(codevDir(), "codev.jsonc"), "utf-8"), - ); + const written = JSON.parse(readFileSync(at(".jsonc"), "utf-8")); expect(written.provider.aigateway.options.apiKey).toBe("k"); }); @@ -959,29 +965,29 @@ describe("CoDev Code config targeting (codev.json vs codev.jsonc)", () => { // A hand-written jsonc is the whole reason .jsonc exists; JSON.parse would // throw here and take `codevhub upload` down with it. seed( - "codev.jsonc", + ".jsonc", `{ // the gateway CoDev configured "provider": { "aigateway": { "options": { "baseURL": "https://gw.test/v1" } } }, }`, ); const { readAgentConfig } = await import("@/lib/configure.js"); - expect(readAgentConfig("codev-code")).toEqual({ + expect(readAgentConfig(agent.tool)).toEqual({ baseUrl: "https://gw.test/v1", }); }); test("restores a configured jsonc from its backup", async () => { - seed("codev.jsonc", '{"marker":"live"}'); - seed("codev.jsonc.backup", '{"marker":"backup"}'); + seed(".jsonc", '{"marker":"live"}'); + seed(".jsonc.backup", '{"marker":"backup"}'); const { restoreTool } = await import("@/lib/configure.js"); - const [result] = restoreTool("codev-code"); + const [result] = restoreTool(agent.tool); expect(result?.status).toBe("restored"); - expect( - JSON.parse(readFileSync(join(codevDir(), "codev.jsonc"), "utf-8")), - ).toEqual({ marker: "backup" }); - expect(existsSync(join(codevDir(), "codev.jsonc.backup"))).toBe(false); + expect(JSON.parse(readFileSync(at(".jsonc"), "utf-8"))).toEqual({ + marker: "backup", + }); + expect(existsSync(at(".jsonc.backup"))).toBe(false); }); }); From 71760ade6ae5c5fbe8907bc5c4e5580a9cf455e6 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 15 Jul 2026 17:44:02 +0700 Subject: [PATCH 3/3] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d74726..5488fea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codev-ai", - "version": "0.4.0", + "version": "0.4.1", "description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.", "keywords": [ "ai",