@gengjiawen/os-init 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.19.0](https://github.com/gengjiawen/os-init/compare/v1.18.1...v1.19.0) (2026-03-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * refresh codex model catalog during setup ([9ed724a](https://github.com/gengjiawen/os-init/commit/9ed724accda9901a813bb9265895db6879d406bb))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * add model metadata to opencode config ([59ade2c](https://github.com/gengjiawen/os-init/commit/59ade2c9e9daa307de56d30968a1c3e8977ea945))
14
+
15
+ ## [1.18.1](https://github.com/gengjiawen/os-init/compare/v1.18.0...v1.18.1) (2026-03-16)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * set codex compact threshold to 128k ([634e5c2](https://github.com/gengjiawen/os-init/commit/634e5c2fef634aeca50f882108857be3f1298219))
21
+
3
22
  ## [1.18.0](https://github.com/gengjiawen/os-init/compare/v1.17.0...v1.18.0) (2026-03-13)
4
23
 
5
24
 
package/bin/bin.js CHANGED
@@ -65,9 +65,11 @@ program
65
65
  return
66
66
  }
67
67
  try {
68
- const { configPath, authPath } = writeCodexConfig(apiKey)
68
+ const { configPath, authPath, catalogPath } =
69
+ await writeCodexConfig(apiKey)
69
70
  console.log(`Codex config written to: ${configPath}`)
70
71
  console.log(`Codex auth written to: ${authPath}`)
72
+ console.log(`Codex model catalog written to: ${catalogPath}`)
71
73
  await installCodexDeps()
72
74
  } catch (err) {
73
75
  console.error('Failed to setup Codex:', err.message)
@@ -146,7 +148,7 @@ program
146
148
  : 'Setting up Claude Code + Codex + OpenCode...\n'
147
149
  )
148
150
 
149
- const result = writeAllAgentsConfig(apiKey, { full })
151
+ const result = await writeAllAgentsConfig(apiKey, { full })
150
152
 
151
153
  console.log('Claude Code:')
152
154
  console.log(` Settings written to: ${result.claude.settingsPath}`)
@@ -157,6 +159,7 @@ program
157
159
  console.log('\nCodex:')
158
160
  console.log(` Config written to: ${result.codex.configPath}`)
159
161
  console.log(` Auth written to: ${result.codex.authPath}`)
162
+ console.log(` Model catalog written to: ${result.codex.catalogPath}`)
160
163
 
161
164
  console.log('\nOpenCode:')
162
165
  console.log(` Config written to: ${result.opencode.configPath}`)
@@ -9,6 +9,7 @@ export interface AllAgentsResult {
9
9
  codex: {
10
10
  configPath: string;
11
11
  authPath: string;
12
+ catalogPath: string;
12
13
  };
13
14
  opencode: {
14
15
  configPath: string;
@@ -18,5 +19,5 @@ export interface AllAgentsResult {
18
19
  settingsPath: string;
19
20
  };
20
21
  }
21
- export declare function writeAllAgentsConfig(apiKey: string, options?: AllAgentsOptions): AllAgentsResult;
22
+ export declare function writeAllAgentsConfig(apiKey: string, options?: AllAgentsOptions): Promise<AllAgentsResult>;
22
23
  export declare function installAllAgentsDeps(options?: AllAgentsOptions): Promise<void>;
@@ -6,9 +6,9 @@ const claude_code_1 = require("./claude-code");
6
6
  const codex_1 = require("./codex");
7
7
  const gemini_cli_1 = require("./gemini-cli");
8
8
  const opencode_1 = require("./opencode");
9
- function writeAllAgentsConfig(apiKey, options = {}) {
9
+ async function writeAllAgentsConfig(apiKey, options = {}) {
10
10
  const claudeResult = (0, claude_code_1.writeClaudeConfig)(apiKey);
11
- const codexResult = (0, codex_1.writeCodexConfig)(apiKey);
11
+ const codexResult = await (0, codex_1.writeCodexConfig)(apiKey);
12
12
  const opencodeResult = (0, opencode_1.writeOpencodeConfig)(apiKey);
13
13
  const result = {
14
14
  claude: claudeResult,
package/build/codex.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export declare function writeCodexConfig(apiKey: string): {
1
+ export declare function writeCodexConfig(apiKey: string): Promise<{
2
2
  configPath: string;
3
3
  authPath: string;
4
- };
4
+ catalogPath: string;
5
+ }>;
5
6
  export declare function installCodexDeps(): Promise<void>;
package/build/codex.js CHANGED
@@ -5,33 +5,92 @@ exports.installCodexDeps = installCodexDeps;
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const os = require("os");
8
+ const TOML = require("@iarna/toml");
8
9
  const execa_1 = require("execa");
9
10
  const utils_1 = require("./utils");
11
+ const CODEX_BASE_URL = 'https://ai.gengjiawen.com/api/openai';
12
+ const CODEX_MODEL_CATALOG_CONFIG_PATH = '~/.codex/remote-model-catalog.json';
13
+ const CODEX_MODEL_CATALOG_FILENAME = 'remote-model-catalog.json';
10
14
  function getCodexConfigDir() {
11
15
  return path.join(os.homedir(), '.codex');
12
16
  }
13
- const CODEX_CONFIG_TOML_TEMPLATE = `model_provider = "jw"
17
+ function getCodexModelCatalogPath() {
18
+ return path.join(getCodexConfigDir(), CODEX_MODEL_CATALOG_FILENAME);
19
+ }
20
+ function getCodexConfigTomlTemplate() {
21
+ return `model_provider = "jw"
14
22
  model = "gpt-5.4"
15
23
  model_reasoning_effort = "high"
16
24
  plan_mode_reasoning_effort = "xhigh"
25
+ model_auto_compact_token_limit = 131072
17
26
  disable_response_storage = true
18
27
  preferred_auth_method = "apikey"
19
28
  service_tier = "fast"
29
+ model_catalog_json = "${CODEX_MODEL_CATALOG_CONFIG_PATH}"
20
30
 
21
31
  [model_providers.jw]
22
32
  name = "jw"
23
- base_url = "https://ai.gengjiawen.com/api/openai"
33
+ base_url = "${CODEX_BASE_URL}"
24
34
  wire_api = "responses"
25
35
  `;
26
- function writeCodexConfig(apiKey) {
36
+ }
37
+ function isTomlTable(value) {
38
+ return (value !== undefined &&
39
+ typeof value === 'object' &&
40
+ !Array.isArray(value) &&
41
+ !(value instanceof Date));
42
+ }
43
+ function mergeTomlTables(existingConfig, templateConfig) {
44
+ const mergedConfig = { ...existingConfig };
45
+ for (const [key, templateValue] of Object.entries(templateConfig)) {
46
+ const existingValue = mergedConfig[key];
47
+ mergedConfig[key] =
48
+ isTomlTable(existingValue) && isTomlTable(templateValue)
49
+ ? mergeTomlTables(existingValue, templateValue)
50
+ : templateValue;
51
+ }
52
+ return mergedConfig;
53
+ }
54
+ function getMergedCodexConfig(existingContent) {
55
+ const existingConfig = TOML.parse(existingContent);
56
+ const templateConfig = TOML.parse(getCodexConfigTomlTemplate());
57
+ return TOML.stringify(mergeTomlTables(existingConfig, templateConfig));
58
+ }
59
+ async function refreshCodexModelCatalog(apiKey) {
60
+ if (typeof fetch !== 'function') {
61
+ throw new Error('Global fetch API is unavailable in this Node.js runtime.');
62
+ }
63
+ const response = await fetch(`${CODEX_BASE_URL}/models`, {
64
+ headers: {
65
+ Accept: 'application/json',
66
+ Authorization: `Bearer ${apiKey}`,
67
+ 'User-Agent': '@gengjiawen/os-init',
68
+ },
69
+ });
70
+ if (!response.ok) {
71
+ throw new Error(`Failed to refresh Codex model catalog: ${response.status} ${response.statusText}`);
72
+ }
73
+ const catalog = (await response.json());
74
+ if (!Array.isArray(catalog?.models) || catalog.models.length === 0) {
75
+ throw new Error('Failed to refresh Codex model catalog: response does not contain any models.');
76
+ }
77
+ const catalogPath = getCodexModelCatalogPath();
78
+ fs.writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
79
+ return catalogPath;
80
+ }
81
+ async function writeCodexConfig(apiKey) {
27
82
  const configDir = getCodexConfigDir();
28
83
  (0, utils_1.ensureDir)(configDir);
84
+ const catalogPath = await refreshCodexModelCatalog(apiKey);
29
85
  const configPath = path.join(configDir, 'config.toml');
30
- fs.writeFileSync(configPath, CODEX_CONFIG_TOML_TEMPLATE);
86
+ const configContent = fs.existsSync(configPath)
87
+ ? getMergedCodexConfig(fs.readFileSync(configPath, 'utf8'))
88
+ : getCodexConfigTomlTemplate();
89
+ fs.writeFileSync(configPath, configContent);
31
90
  const authPath = path.join(configDir, 'auth.json');
32
91
  const authContent = JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2);
33
92
  fs.writeFileSync(authPath, authContent);
34
- return { configPath, authPath };
93
+ return { configPath, authPath, catalogPath };
35
94
  }
36
95
  async function installCodexDeps() {
37
96
  const packages = ['@openai/codex'];
package/build/opencode.js CHANGED
@@ -12,6 +12,16 @@ const OPENCODE_MODEL_ID = 'code';
12
12
  const OPENCODE_GLM_MODEL_ID = 'glm';
13
13
  const OPENCODE_KIMI_MODEL_ID = 'kimi';
14
14
  const OPENCODE_BASE_URL = 'https://ai.gengjiawen.com/api/openai/v1';
15
+ function createOpencodeModelConfig(modelId) {
16
+ return {
17
+ name: modelId,
18
+ attachment: true,
19
+ modalities: {
20
+ input: ['text', 'image'],
21
+ output: ['text'],
22
+ },
23
+ };
24
+ }
15
25
  function getOpencodeConfigDir() {
16
26
  return path.join(os.homedir(), '.config', 'opencode');
17
27
  }
@@ -29,15 +39,9 @@ function writeOpencodeConfig(apiKey) {
29
39
  apiKey,
30
40
  },
31
41
  models: {
32
- [OPENCODE_MODEL_ID]: {
33
- name: OPENCODE_MODEL_ID,
34
- },
35
- [OPENCODE_GLM_MODEL_ID]: {
36
- name: OPENCODE_GLM_MODEL_ID,
37
- },
38
- [OPENCODE_KIMI_MODEL_ID]: {
39
- name: OPENCODE_KIMI_MODEL_ID,
40
- },
42
+ [OPENCODE_MODEL_ID]: createOpencodeModelConfig(OPENCODE_MODEL_ID),
43
+ [OPENCODE_GLM_MODEL_ID]: createOpencodeModelConfig(OPENCODE_GLM_MODEL_ID),
44
+ [OPENCODE_KIMI_MODEL_ID]: createOpencodeModelConfig(OPENCODE_KIMI_MODEL_ID),
41
45
  },
42
46
  },
43
47
  },
@@ -12,12 +12,18 @@ describe('writeAllAgentsConfig', () => {
12
12
  let tempHome: string
13
13
  let homedirSpy: jest.SpiedFunction<typeof os.homedir>
14
14
  let originalAppData: string | undefined
15
+ let originalFetch: typeof global.fetch | undefined
15
16
 
16
17
  beforeEach(() => {
17
18
  tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-all-agents-'))
18
19
  homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome)
19
20
  originalAppData = process.env.APPDATA
21
+ originalFetch = global.fetch
20
22
  process.env.APPDATA = path.join(tempHome, 'AppData', 'Roaming')
23
+ global.fetch = jest.fn().mockResolvedValue({
24
+ ok: true,
25
+ json: async () => ({ models: [{ id: 'gpt-5.4' }] }),
26
+ }) as unknown as typeof global.fetch
21
27
  })
22
28
 
23
29
  afterEach(() => {
@@ -27,21 +33,27 @@ describe('writeAllAgentsConfig', () => {
27
33
  } else {
28
34
  process.env.APPDATA = originalAppData
29
35
  }
36
+ if (originalFetch === undefined) {
37
+ global.fetch = undefined as unknown as typeof global.fetch
38
+ } else {
39
+ global.fetch = originalFetch
40
+ }
30
41
  fs.rmSync(tempHome, { recursive: true, force: true })
31
42
  })
32
43
 
33
- test('writes Claude, Codex and OpenCode config by default', () => {
34
- const result = writeAllAgentsConfig('test-api-key')
44
+ test('writes Claude, Codex and OpenCode config by default', async () => {
45
+ const result = await writeAllAgentsConfig('test-api-key')
35
46
 
36
47
  expect(fs.existsSync(result.claude.settingsPath)).toBe(true)
37
48
  expect(fs.existsSync(result.codex.configPath)).toBe(true)
38
49
  expect(fs.existsSync(result.codex.authPath)).toBe(true)
50
+ expect(fs.existsSync(result.codex.catalogPath)).toBe(true)
39
51
  expect(fs.existsSync(result.opencode.configPath)).toBe(true)
40
52
  expect(result.gemini).toBeUndefined()
41
53
  })
42
54
 
43
- test('includes Gemini config when full option is enabled', () => {
44
- const result = writeAllAgentsConfig('test-api-key', { full: true })
55
+ test('includes Gemini config when full option is enabled', async () => {
56
+ const result = await writeAllAgentsConfig('test-api-key', { full: true })
45
57
 
46
58
  expect(result.gemini).toBeDefined()
47
59
  expect(fs.existsSync(result.gemini!.envPath)).toBe(true)
@@ -10,18 +10,18 @@ export interface AllAgentsOptions {
10
10
 
11
11
  export interface AllAgentsResult {
12
12
  claude: { settingsPath: string; vscodeSettingsPath: string }
13
- codex: { configPath: string; authPath: string }
13
+ codex: { configPath: string; authPath: string; catalogPath: string }
14
14
  opencode: { configPath: string }
15
15
  gemini?: { envPath: string; settingsPath: string }
16
16
  }
17
17
 
18
18
  /** Write configuration for the combined setup (Claude Code + Codex + OpenCode, optional Gemini CLI). */
19
- export function writeAllAgentsConfig(
19
+ export async function writeAllAgentsConfig(
20
20
  apiKey: string,
21
21
  options: AllAgentsOptions = {}
22
- ): AllAgentsResult {
22
+ ): Promise<AllAgentsResult> {
23
23
  const claudeResult = writeClaudeConfig(apiKey)
24
- const codexResult = writeCodexConfig(apiKey)
24
+ const codexResult = await writeCodexConfig(apiKey)
25
25
  const opencodeResult = writeOpencodeConfig(apiKey)
26
26
 
27
27
  const result: AllAgentsResult = {
@@ -0,0 +1,194 @@
1
+ import * as fs from 'fs'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+ import * as TOML from '@iarna/toml'
5
+
6
+ jest.mock('execa', () => ({
7
+ execa: jest.fn(),
8
+ }))
9
+
10
+ import { writeCodexConfig } from './codex'
11
+
12
+ describe('writeCodexConfig', () => {
13
+ let tempHome: string
14
+ let homedirSpy: jest.SpiedFunction<typeof os.homedir>
15
+ let originalFetch: typeof global.fetch | undefined
16
+
17
+ function mockCatalogFetch(
18
+ catalog: { models: Array<Record<string, unknown>> } = {
19
+ models: [{ id: 'gpt-5.4' }],
20
+ }
21
+ ): jest.Mock {
22
+ const fetchMock = jest.fn().mockResolvedValue({
23
+ ok: true,
24
+ json: async () => catalog,
25
+ })
26
+ global.fetch = fetchMock as unknown as typeof global.fetch
27
+ return fetchMock
28
+ }
29
+
30
+ beforeEach(() => {
31
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-codex-'))
32
+ homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome)
33
+ originalFetch = global.fetch
34
+ mockCatalogFetch()
35
+ })
36
+
37
+ afterEach(() => {
38
+ homedirSpy.mockRestore()
39
+ if (originalFetch === undefined) {
40
+ global.fetch = undefined as unknown as typeof global.fetch
41
+ } else {
42
+ global.fetch = originalFetch
43
+ }
44
+ fs.rmSync(tempHome, { recursive: true, force: true })
45
+ })
46
+
47
+ test('writes config with 128k auto compact threshold', async () => {
48
+ const result = await writeCodexConfig('test-api-key')
49
+ const config = TOML.parse(fs.readFileSync(result.configPath, 'utf8')) as {
50
+ model_auto_compact_token_limit: number
51
+ model_catalog_json: string
52
+ }
53
+
54
+ expect(config.model_auto_compact_token_limit).toBe(131072)
55
+ expect(config.model_catalog_json).toBe('~/.codex/remote-model-catalog.json')
56
+ expect(fs.existsSync(result.catalogPath)).toBe(true)
57
+ })
58
+
59
+ test('merges template keys and keeps custom config', async () => {
60
+ const configDir = path.join(tempHome, '.codex')
61
+ fs.mkdirSync(configDir, { recursive: true })
62
+ const configPath = path.join(configDir, 'config.toml')
63
+
64
+ fs.writeFileSync(
65
+ configPath,
66
+ `service_tier = "slow"
67
+ custom_flag = true
68
+
69
+ [model_providers.jw]
70
+ base_url = "https://example.com" # keep comment
71
+ custom_model = "keep-me"
72
+ `
73
+ )
74
+
75
+ await writeCodexConfig('test-api-key')
76
+ const config = TOML.parse(fs.readFileSync(configPath, 'utf8')) as {
77
+ service_tier: string
78
+ custom_flag: boolean
79
+ model: string
80
+ model_catalog_json: string
81
+ preferred_auth_method: string
82
+ model_providers: {
83
+ jw: {
84
+ base_url: string
85
+ custom_model: string
86
+ name: string
87
+ }
88
+ }
89
+ }
90
+
91
+ expect(config.service_tier).toBe('fast')
92
+ expect(config.custom_flag).toBe(true)
93
+ expect(config.model).toBe('gpt-5.4')
94
+ expect(config.model_catalog_json).toBe('~/.codex/remote-model-catalog.json')
95
+ expect(config.preferred_auth_method).toBe('apikey')
96
+ expect(config.model_providers.jw.base_url).toBe(
97
+ 'https://ai.gengjiawen.com/api/openai'
98
+ )
99
+ expect(config.model_providers.jw.custom_model).toBe('keep-me')
100
+ expect(config.model_providers.jw.name).toBe('jw')
101
+ })
102
+
103
+ test('adds missing keys without removing custom config', async () => {
104
+ const configDir = path.join(tempHome, '.codex')
105
+ fs.mkdirSync(configDir, { recursive: true })
106
+ const configPath = path.join(configDir, 'config.toml')
107
+
108
+ fs.writeFileSync(
109
+ configPath,
110
+ `service_tier = "slow"
111
+
112
+ [model_providers.jw]
113
+ base_url = "https://example.com"
114
+ `
115
+ )
116
+
117
+ await writeCodexConfig('test-api-key')
118
+ const config = TOML.parse(fs.readFileSync(configPath, 'utf8')) as {
119
+ model: string
120
+ model_catalog_json: string
121
+ preferred_auth_method: string
122
+ service_tier: string
123
+ model_providers: {
124
+ jw: {
125
+ name: string
126
+ base_url: string
127
+ }
128
+ }
129
+ }
130
+
131
+ expect(config.model).toBe('gpt-5.4')
132
+ expect(config.model_catalog_json).toBe('~/.codex/remote-model-catalog.json')
133
+ expect(config.preferred_auth_method).toBe('apikey')
134
+ expect(config.model_providers.jw.name).toBe('jw')
135
+ expect(config.service_tier).toBe('fast')
136
+ })
137
+
138
+ test('refreshes remote model catalog after writing config', async () => {
139
+ const fetchMock = mockCatalogFetch({
140
+ models: [
141
+ { id: 'gpt-5.4' },
142
+ { id: 'gpt-5.4-mini' },
143
+ { id: 'gpt-5.3-codex' },
144
+ ],
145
+ })
146
+
147
+ const result = await writeCodexConfig('test-api-key')
148
+
149
+ expect(fetchMock).toHaveBeenCalledWith(
150
+ 'https://ai.gengjiawen.com/api/openai/models',
151
+ {
152
+ headers: {
153
+ Accept: 'application/json',
154
+ Authorization: 'Bearer test-api-key',
155
+ 'User-Agent': '@gengjiawen/os-init',
156
+ },
157
+ }
158
+ )
159
+
160
+ const catalog = JSON.parse(fs.readFileSync(result.catalogPath, 'utf8')) as {
161
+ models: Array<{ id: string }>
162
+ }
163
+
164
+ expect(catalog).toEqual({
165
+ models: [
166
+ { id: 'gpt-5.4' },
167
+ { id: 'gpt-5.4-mini' },
168
+ { id: 'gpt-5.3-codex' },
169
+ ],
170
+ })
171
+ })
172
+
173
+ test('throws when refreshing the remote model catalog fails', async () => {
174
+ global.fetch = jest.fn().mockResolvedValue({
175
+ ok: false,
176
+ status: 503,
177
+ statusText: 'Service Unavailable',
178
+ }) as unknown as typeof global.fetch
179
+
180
+ await expect(writeCodexConfig('test-api-key')).rejects.toThrow(
181
+ 'Failed to refresh Codex model catalog: 503 Service Unavailable'
182
+ )
183
+
184
+ expect(fs.existsSync(path.join(tempHome, '.codex', 'config.toml'))).toBe(
185
+ false
186
+ )
187
+ expect(fs.existsSync(path.join(tempHome, '.codex', 'auth.json'))).toBe(
188
+ false
189
+ )
190
+ expect(
191
+ fs.existsSync(path.join(tempHome, '.codex', 'remote-model-catalog.json'))
192
+ ).toBe(false)
193
+ })
194
+ })
package/libs/codex.ts CHANGED
@@ -1,45 +1,133 @@
1
1
  import * as fs from 'fs'
2
2
  import * as path from 'path'
3
3
  import * as os from 'os'
4
+ import * as TOML from '@iarna/toml'
4
5
  import { execa } from 'execa'
5
6
  import { ensureDir, commandExists, PNPM_INSTALL_ENV } from './utils'
6
7
 
8
+ type TomlTable = ReturnType<typeof TOML.parse>
9
+ type CodexModelCatalog = { models: unknown[] }
10
+
11
+ const CODEX_BASE_URL = 'https://ai.gengjiawen.com/api/openai'
12
+ const CODEX_MODEL_CATALOG_CONFIG_PATH = '~/.codex/remote-model-catalog.json'
13
+ const CODEX_MODEL_CATALOG_FILENAME = 'remote-model-catalog.json'
14
+
7
15
  /** Return Codex configuration directory path */
8
16
  function getCodexConfigDir(): string {
9
17
  return path.join(os.homedir(), '.codex')
10
18
  }
11
19
 
12
- /** Template for Codex config.toml */
13
- const CODEX_CONFIG_TOML_TEMPLATE = `model_provider = "jw"
20
+ function getCodexModelCatalogPath(): string {
21
+ return path.join(getCodexConfigDir(), CODEX_MODEL_CATALOG_FILENAME)
22
+ }
23
+
24
+ function getCodexConfigTomlTemplate(): string {
25
+ return `model_provider = "jw"
14
26
  model = "gpt-5.4"
15
27
  model_reasoning_effort = "high"
16
28
  plan_mode_reasoning_effort = "xhigh"
29
+ model_auto_compact_token_limit = 131072
17
30
  disable_response_storage = true
18
31
  preferred_auth_method = "apikey"
19
32
  service_tier = "fast"
33
+ model_catalog_json = "${CODEX_MODEL_CATALOG_CONFIG_PATH}"
20
34
 
21
35
  [model_providers.jw]
22
36
  name = "jw"
23
- base_url = "https://ai.gengjiawen.com/api/openai"
37
+ base_url = "${CODEX_BASE_URL}"
24
38
  wire_api = "responses"
25
39
  `
40
+ }
41
+
42
+ function isTomlTable(value: unknown): value is TomlTable {
43
+ return (
44
+ value !== undefined &&
45
+ typeof value === 'object' &&
46
+ !Array.isArray(value) &&
47
+ !(value instanceof Date)
48
+ )
49
+ }
50
+
51
+ function mergeTomlTables(
52
+ existingConfig: TomlTable,
53
+ templateConfig: TomlTable
54
+ ): TomlTable {
55
+ const mergedConfig: TomlTable = { ...existingConfig }
56
+
57
+ for (const [key, templateValue] of Object.entries(templateConfig)) {
58
+ const existingValue = mergedConfig[key]
59
+
60
+ mergedConfig[key] =
61
+ isTomlTable(existingValue) && isTomlTable(templateValue)
62
+ ? mergeTomlTables(existingValue, templateValue)
63
+ : templateValue
64
+ }
65
+
66
+ return mergedConfig
67
+ }
68
+
69
+ function getMergedCodexConfig(existingContent: string): string {
70
+ const existingConfig = TOML.parse(existingContent) as TomlTable
71
+ const templateConfig = TOML.parse(getCodexConfigTomlTemplate()) as TomlTable
72
+
73
+ return TOML.stringify(mergeTomlTables(existingConfig, templateConfig))
74
+ }
75
+
76
+ async function refreshCodexModelCatalog(apiKey: string): Promise<string> {
77
+ if (typeof fetch !== 'function') {
78
+ throw new Error('Global fetch API is unavailable in this Node.js runtime.')
79
+ }
80
+
81
+ const response = await fetch(`${CODEX_BASE_URL}/models`, {
82
+ headers: {
83
+ Accept: 'application/json',
84
+ Authorization: `Bearer ${apiKey}`,
85
+ 'User-Agent': '@gengjiawen/os-init',
86
+ },
87
+ })
88
+
89
+ if (!response.ok) {
90
+ throw new Error(
91
+ `Failed to refresh Codex model catalog: ${response.status} ${response.statusText}`
92
+ )
93
+ }
94
+
95
+ const catalog = (await response.json()) as CodexModelCatalog
96
+
97
+ if (!Array.isArray(catalog?.models) || catalog.models.length === 0) {
98
+ throw new Error(
99
+ 'Failed to refresh Codex model catalog: response does not contain any models.'
100
+ )
101
+ }
102
+
103
+ const catalogPath = getCodexModelCatalogPath()
104
+ fs.writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`)
105
+
106
+ return catalogPath
107
+ }
26
108
 
27
109
  /** Write Codex config.toml and auth.json */
28
- export function writeCodexConfig(apiKey: string): {
110
+ export async function writeCodexConfig(apiKey: string): Promise<{
29
111
  configPath: string
30
112
  authPath: string
31
- } {
113
+ catalogPath: string
114
+ }> {
32
115
  const configDir = getCodexConfigDir()
33
116
  ensureDir(configDir)
34
117
 
118
+ const catalogPath = await refreshCodexModelCatalog(apiKey)
119
+
35
120
  const configPath = path.join(configDir, 'config.toml')
36
- fs.writeFileSync(configPath, CODEX_CONFIG_TOML_TEMPLATE)
121
+ const configContent = fs.existsSync(configPath)
122
+ ? getMergedCodexConfig(fs.readFileSync(configPath, 'utf8'))
123
+ : getCodexConfigTomlTemplate()
124
+ fs.writeFileSync(configPath, configContent)
37
125
 
38
126
  const authPath = path.join(configDir, 'auth.json')
39
127
  const authContent = JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)
40
128
  fs.writeFileSync(authPath, authContent)
41
129
 
42
- return { configPath, authPath }
130
+ return { configPath, authPath, catalogPath }
43
131
  }
44
132
 
45
133
  /** Install Codex dependency */
@@ -12,6 +12,15 @@ describe('writeOpencodeConfig', () => {
12
12
  let tempHome: string
13
13
  let homedirSpy: jest.SpiedFunction<typeof os.homedir>
14
14
 
15
+ const expectedModelConfig = (name: string) => ({
16
+ name,
17
+ attachment: true,
18
+ modalities: {
19
+ input: ['text', 'image'],
20
+ output: ['text'],
21
+ },
22
+ })
23
+
15
24
  beforeEach(() => {
16
25
  tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-opencode-'))
17
26
  homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome)
@@ -40,15 +49,15 @@ describe('writeOpencodeConfig', () => {
40
49
  'https://ai.gengjiawen.com/api/openai/v1'
41
50
  )
42
51
  expect(config.provider.MyCustomProvider.options.apiKey).toBe('test-api-key')
43
- expect(config.provider.MyCustomProvider.models.code).toEqual({
44
- name: 'code',
45
- })
46
- expect(config.provider.MyCustomProvider.models.glm).toEqual({
47
- name: 'glm',
48
- })
49
- expect(config.provider.MyCustomProvider.models.kimi).toEqual({
50
- name: 'kimi',
51
- })
52
+ expect(config.provider.MyCustomProvider.models.code).toEqual(
53
+ expectedModelConfig('code')
54
+ )
55
+ expect(config.provider.MyCustomProvider.models.glm).toEqual(
56
+ expectedModelConfig('glm')
57
+ )
58
+ expect(config.provider.MyCustomProvider.models.kimi).toEqual(
59
+ expectedModelConfig('kimi')
60
+ )
52
61
  expect(config.model).toBe('MyCustomProvider/code')
53
62
  expect(config.small_model).toBe('MyCustomProvider/code')
54
63
  })
package/libs/opencode.ts CHANGED
@@ -10,6 +10,17 @@ const OPENCODE_GLM_MODEL_ID = 'glm'
10
10
  const OPENCODE_KIMI_MODEL_ID = 'kimi'
11
11
  const OPENCODE_BASE_URL = 'https://ai.gengjiawen.com/api/openai/v1'
12
12
 
13
+ function createOpencodeModelConfig(modelId: string) {
14
+ return {
15
+ name: modelId,
16
+ attachment: true,
17
+ modalities: {
18
+ input: ['text', 'image'],
19
+ output: ['text'],
20
+ },
21
+ }
22
+ }
23
+
13
24
  /** Return OpenCode configuration directory path */
14
25
  function getOpencodeConfigDir(): string {
15
26
  return path.join(os.homedir(), '.config', 'opencode')
@@ -31,15 +42,13 @@ export function writeOpencodeConfig(apiKey: string): { configPath: string } {
31
42
  apiKey,
32
43
  },
33
44
  models: {
34
- [OPENCODE_MODEL_ID]: {
35
- name: OPENCODE_MODEL_ID,
36
- },
37
- [OPENCODE_GLM_MODEL_ID]: {
38
- name: OPENCODE_GLM_MODEL_ID,
39
- },
40
- [OPENCODE_KIMI_MODEL_ID]: {
41
- name: OPENCODE_KIMI_MODEL_ID,
42
- },
45
+ [OPENCODE_MODEL_ID]: createOpencodeModelConfig(OPENCODE_MODEL_ID),
46
+ [OPENCODE_GLM_MODEL_ID]: createOpencodeModelConfig(
47
+ OPENCODE_GLM_MODEL_ID
48
+ ),
49
+ [OPENCODE_KIMI_MODEL_ID]: createOpencodeModelConfig(
50
+ OPENCODE_KIMI_MODEL_ID
51
+ ),
43
52
  },
44
53
  },
45
54
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gengjiawen/os-init",
3
3
  "private": false,
4
- "version": "1.18.0",
4
+ "version": "1.19.0",
5
5
  "description": "",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@gengjiawen/unzip-url": "^1.1.0",
22
+ "@iarna/toml": "^2.2.5",
22
23
  "commander": "^12.1.0",
23
24
  "execa": "^8.0.1",
24
25
  "ip": "^2.0.1",