@@ -54,7 +54,7 @@ import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
5454const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set ( [ 'replicate' ] )
5555// 📖 Install Endpoints only lists tools whose persisted config shape is actually supported here.
5656// 📖 Launch-only tools stay out: the Web dashboard configures endpoints, it never starts CLIs.
57- const INSTALL_TARGET_MODES = [ 'opencode' , 'opencode-desktop' , 'opencode-web' , 'openclaw' , 'crush' , 'goose' , 'pi' , 'aider' , 'qwen' , 'openhands' , 'amp' , 'forgecode' , 'fcm_router' ]
57+ const INSTALL_TARGET_MODES = [ 'opencode' , 'opencode-desktop' , 'opencode-web' , 'openclaw' , 'crush' , 'goose' , 'pi' , 'aider' , 'qwen' , 'openhands' , 'amp' , 'forgecode' , 'fcm_router' , 'zcode' ]
5858
5959function getDefaultPaths ( ) {
6060 const home = homedir ( )
@@ -70,6 +70,8 @@ function getDefaultPaths() {
7070 ampConfigPath : join ( home , '.config' , 'amp' , 'settings.json' ) ,
7171 qwenConfigPath : join ( home , '.qwen' , 'settings.json' ) ,
7272 forgeCodeConfigPath : join ( home , '.forge' , '.forge.toml' ) ,
73+ zcodeConfigPath : join ( home , '.zcode' , 'v2' , 'config.json' ) ,
74+ zcodeModelCachePath : join ( home , '.zcode' , 'v2' , 'bots-model-cache.v2.json' ) ,
7375 }
7476}
7577
@@ -625,6 +627,183 @@ function installIntoFcmRouter(providerKey, models, apiKey) {
625627 return { path : `FCM Router (${ baseUrl } )` , backupPath : null , providerId : providerKey , modelCount : models . length }
626628}
627629
630+ // 📖 installIntoZCode: Writes provider + models into ZCode's config.json and updates
631+ // 📖 bots-model-cache.v2.json so the models appear immediately in ZCode's model picker.
632+ // 📖 Uses deterministic provider ID (fcm-{providerKey}) so re-running replaces/merges
633+ // 📖 without duplicating the provider entry.
634+ // 📖 When scope === 'selected' — merges models with existing ones.
635+ // 📖 When scope === 'all' — replaces all models (full sync).
636+ function installIntoZCode ( providerKey , models , apiKey , paths , scope ) {
637+ const configPath = paths . zcodeConfigPath
638+ const cachePath = paths . zcodeModelCachePath
639+ const providerId = `fcm-${ providerKey } `
640+ const baseUrl = resolveProviderBaseUrl ( providerKey )
641+ const providerLabel = getManagedProviderLabel ( providerKey )
642+
643+ if ( ! baseUrl ) {
644+ throw new Error ( `Cannot resolve base URL for ${ getProviderLabel ( providerKey ) } ` )
645+ }
646+
647+ function buildModelEntry ( model ) {
648+ const ctx = parseContextWindow ( model . ctx )
649+ const entry = {
650+ id : model . modelId ,
651+ name : model . label || model . modelId ,
652+ kinds : [ 'openai-compatible' ] ,
653+ defaultKind : 'openai-compatible' ,
654+ modalities : { input : [ 'text' ] , output : [ 'text' ] } ,
655+ contextWindow : ctx ,
656+ }
657+ if ( ctx > 8192 ) {
658+ entry . maxOutputTokens = getDefaultMaxTokens ( ctx )
659+ }
660+ return entry
661+ }
662+
663+ function buildConfigModelEntry ( model ) {
664+ const ctx = parseContextWindow ( model . ctx )
665+ const entry = {
666+ limit : { context : ctx } ,
667+ modalities : { input : [ 'text' ] , output : [ 'text' ] } ,
668+ }
669+ if ( ctx > 8192 ) {
670+ entry . limit . output = getDefaultMaxTokens ( ctx )
671+ }
672+ return entry
673+ }
674+
675+ const newModelIds = new Set ( models . map ( ( m ) => m . modelId ) )
676+ let configModified = false
677+ let cacheModified = false
678+
679+ // ── 1. Write to config.json ───────────────────────────────────────────────
680+ const config = readJson ( configPath , { $schema : 'https://opencode.ai/config.json' } )
681+ if ( ! config . provider || typeof config . provider !== 'object' ) config . provider = { }
682+
683+ const existingProvider = config . provider [ providerId ]
684+
685+ if ( scope === 'selected' && existingProvider ) {
686+ // 📖 Merge mode: add/update selected models, keep existing ones
687+ for ( const model of models ) {
688+ existingProvider . models [ model . modelId ] = buildConfigModelEntry ( model )
689+ }
690+ configModified = true
691+ } else if ( scope === 'selected' && ! existingProvider ) {
692+ // 📖 No existing provider, but scope is selected — create with only selected models
693+ config . provider [ providerId ] = {
694+ name : providerLabel ,
695+ kind : 'openai-compatible' ,
696+ options : { apiKey, baseURL : baseUrl , apiKeyRequired : true } ,
697+ enabled : true ,
698+ source : 'custom' ,
699+ models : Object . fromEntries ( models . map ( ( m ) => [ m . modelId , buildConfigModelEntry ( m ) ] ) ) ,
700+ }
701+ configModified = true
702+ } else {
703+ // 📖 scope === 'all' — replace all models (full sync), skip if identical
704+ const existingModelIds = existingProvider ? Object . keys ( existingProvider . models || { } ) : [ ]
705+ const modelsUnchanged = existingProvider
706+ && existingModelIds . length === models . length
707+ && models . every ( ( m ) => existingModelIds . includes ( m . modelId ) )
708+
709+ if ( ! modelsUnchanged ) {
710+ config . provider [ providerId ] = {
711+ name : providerLabel ,
712+ kind : 'openai-compatible' ,
713+ options : { apiKey, baseURL : baseUrl , apiKeyRequired : true } ,
714+ enabled : true ,
715+ source : 'custom' ,
716+ models : Object . fromEntries ( models . map ( ( m ) => [ m . modelId , buildConfigModelEntry ( m ) ] ) ) ,
717+ }
718+ configModified = true
719+ }
720+ }
721+
722+ const configBackupPath = configModified ? writeJson ( configPath , config ) : null
723+
724+ // ── 2. Write to bots-model-cache.v2.json ──────────────────────────────────
725+ const cache = readJson ( cachePath , { version : 2 , updatedAt : Date . now ( ) , providers : [ ] } )
726+ if ( ! Array . isArray ( cache . providers ) ) cache . providers = [ ]
727+
728+ const existingCacheIdx = cache . providers . findIndex ( ( p ) => p ?. id === providerId )
729+
730+ if ( scope === 'selected' ) {
731+ // 📖 Merge mode: add/update selected models in cache, keep existing ones
732+ if ( existingCacheIdx >= 0 ) {
733+ const existingModels = cache . providers [ existingCacheIdx ] . models || [ ]
734+ for ( const model of models ) {
735+ const modelIdx = existingModels . findIndex ( ( m ) => m . id === model . modelId )
736+ if ( modelIdx >= 0 ) {
737+ existingModels [ modelIdx ] = buildModelEntry ( model )
738+ } else {
739+ existingModels . push ( buildModelEntry ( model ) )
740+ }
741+ }
742+ cache . providers [ existingCacheIdx ] . updatedAt = Date . now ( )
743+ cacheModified = true
744+ } else {
745+ cache . providers . push ( {
746+ id : providerId ,
747+ name : providerLabel ,
748+ enabled : true ,
749+ endpoints : { baseURL : baseUrl , paths : { 'openai-compatible' : '/chat/completions' } } ,
750+ apiFormat : 'openai-chat-completions' ,
751+ source : 'custom' ,
752+ apiKeyRequired : true ,
753+ apiKey : '__zcode_cached_api_key_present__' ,
754+ defaultKind : 'openai-compatible' ,
755+ models : models . map ( buildModelEntry ) ,
756+ createdAt : Date . now ( ) ,
757+ updatedAt : Date . now ( ) ,
758+ } )
759+ cacheModified = true
760+ }
761+ } else {
762+ // 📖 scope === 'all' — replace all models, skip if identical
763+ const cachedModelIds = existingCacheIdx >= 0
764+ ? ( cache . providers [ existingCacheIdx ] . models || [ ] ) . map ( ( m ) => m . id )
765+ : [ ]
766+ const cacheUnchanged = existingCacheIdx >= 0
767+ && cachedModelIds . length === models . length
768+ && models . every ( ( m ) => cachedModelIds . includes ( m . modelId ) )
769+
770+ if ( ! cacheUnchanged ) {
771+ if ( existingCacheIdx >= 0 ) {
772+ cache . providers [ existingCacheIdx ] . models = models . map ( buildModelEntry )
773+ cache . providers [ existingCacheIdx ] . updatedAt = Date . now ( )
774+ } else {
775+ cache . providers . push ( {
776+ id : providerId ,
777+ name : providerLabel ,
778+ enabled : true ,
779+ endpoints : { baseURL : baseUrl , paths : { 'openai-compatible' : '/chat/completions' } } ,
780+ apiFormat : 'openai-chat-completions' ,
781+ source : 'custom' ,
782+ apiKeyRequired : true ,
783+ apiKey : '__zcode_cached_api_key_present__' ,
784+ defaultKind : 'openai-compatible' ,
785+ models : models . map ( buildModelEntry ) ,
786+ createdAt : Date . now ( ) ,
787+ updatedAt : Date . now ( ) ,
788+ } )
789+ }
790+ cache . updatedAt = Date . now ( )
791+ cacheModified = true
792+ }
793+ }
794+
795+ const cacheBackupPath = cacheModified ? writeJson ( cachePath , cache ) : null
796+
797+ return {
798+ path : configPath ,
799+ backupPath : configBackupPath ,
800+ providerId,
801+ modelCount : models . length ,
802+ extraPath : cachePath ,
803+ extraBackupPath : cacheBackupPath ,
804+ }
805+ }
806+
628807export function installProviderEndpoints ( config , providerKey , toolMode , options = { } ) {
629808 const canonicalToolMode = canonicalizeToolMode ( toolMode )
630809 const support = getDirectInstallSupport ( providerKey )
@@ -664,6 +843,8 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
664843 installResult = installIntoFcmRouter ( providerKey , models , apiKey )
665844 } else if ( canonicalToolMode === 'forgecode' ) {
666845 installResult = installIntoForgeCode ( providerKey , models , apiKey , paths )
846+ } else if ( canonicalToolMode === 'zcode' ) {
847+ installResult = installIntoZCode ( providerKey , models , apiKey , paths , scope )
667848 } else {
668849 throw new Error ( `Unsupported install target: ${ toolMode } ` )
669850 }
0 commit comments