2026-01-11 12:11:12 +00:00
import fs from "node:fs" ;
2026-02-13 04:11:26 +02:00
import os from "node:os" ;
2026-01-12 01:16:39 +00:00
import path from "node:path" ;
2026-02-19 15:41:24 +01:00
import type { Command } from "commander" ;
2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "../config/config.js" ;
2026-02-01 10:03:47 +09:00
import { loadConfig , writeConfigFile } from "../config/config.js" ;
2026-02-13 04:11:26 +02:00
import { resolveStateDir } from "../config/paths.js" ;
2026-01-17 07:08:04 +00:00
import { resolveArchiveKind } from "../infra/archive.js" ;
2026-03-02 21:22:32 +00:00
import { type BundledPluginSource , findBundledPluginSource } from "../plugins/bundled-sources.js" ;
2026-02-21 19:37:26 -08:00
import { enablePluginInConfig } from "../plugins/enable.js" ;
2026-03-02 21:22:32 +00:00
import {
installPluginFromNpmSpec ,
installPluginFromPath ,
PLUGIN_INSTALL_ERROR_CODE ,
} from "../plugins/install.js" ;
2026-01-16 05:54:47 +00:00
import { recordPluginInstall } from "../plugins/installs.js" ;
2026-02-15 23:12:28 +00:00
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js" ;
2026-02-19 15:41:24 +01:00
import type { PluginRecord } from "../plugins/registry.js" ;
2026-01-18 11:26:50 -05:00
import { applyExclusiveSlotSelection } from "../plugins/slots.js" ;
2026-02-09 13:05:41 -06:00
import { resolvePluginSourceRoots , formatPluginSourceForTable } from "../plugins/source-display.js" ;
2026-01-11 12:11:12 +00:00
import { buildPluginStatusReport } from "../plugins/status.js" ;
2026-02-13 04:11:26 +02:00
import { resolveUninstallDirectoryTarget , uninstallPlugin } from "../plugins/uninstall.js" ;
2026-01-20 15:56:48 +00:00
import { updateNpmInstalledPlugins } from "../plugins/update.js" ;
2026-01-11 12:11:12 +00:00
import { defaultRuntime } from "../runtime.js" ;
import { formatDocsLink } from "../terminal/links.js" ;
2026-01-21 04:47:35 +00:00
import { renderTable } from "../terminal/table.js" ;
2026-01-11 12:11:12 +00:00
import { theme } from "../terminal/theme.js" ;
2026-01-23 03:43:32 +00:00
import { resolveUserPath , shortenHomeInString , shortenHomePath } from "../utils.js" ;
2026-03-02 19:48:38 +00:00
import { looksLikeLocalInstallSpec } from "./install-spec.js" ;
2026-02-21 21:59:53 +00:00
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js" ;
2026-02-21 21:56:05 +00:00
import { setPluginEnabledInConfig } from "./plugins-config.js" ;
2026-02-13 04:11:26 +02:00
import { promptYesNo } from "./prompt.js" ;
2026-01-11 12:11:12 +00:00
export type PluginsListOptions = {
json? : boolean ;
enabled? : boolean ;
verbose? : boolean ;
} ;
export type PluginInfoOptions = {
json? : boolean ;
} ;
2026-01-16 05:54:47 +00:00
export type PluginUpdateOptions = {
all? : boolean ;
dryRun? : boolean ;
} ;
2026-02-13 04:11:26 +02:00
export type PluginUninstallOptions = {
keepFiles? : boolean ;
keepConfig? : boolean ;
force? : boolean ;
dryRun? : boolean ;
} ;
2026-02-15 02:52:32 +00:00
function resolveFileNpmSpecToLocalPath (
raw : string ,
) : { ok : true ; path : string } | { ok : false ; error : string } | null {
const trimmed = raw . trim ( ) ;
if ( ! trimmed . toLowerCase ( ) . startsWith ( "file:" ) ) {
return null ;
}
const rest = trimmed . slice ( "file:" . length ) ;
if ( ! rest ) {
return { ok : false , error : "unsupported file: spec: missing path" } ;
}
if ( rest . startsWith ( "///" ) ) {
// file:///abs/path -> /abs/path
return { ok : true , path : rest.slice ( 2 ) } ;
}
if ( rest . startsWith ( "//localhost/" ) ) {
// file://localhost/abs/path -> /abs/path
return { ok : true , path : rest.slice ( "//localhost" . length ) } ;
}
if ( rest . startsWith ( "//" ) ) {
return {
ok : false ,
error : 'unsupported file: URL host (expected "file:<path>" or "file:///abs/path")' ,
} ;
}
return { ok : true , path : rest } ;
}
2026-01-11 12:11:12 +00:00
function formatPluginLine ( plugin : PluginRecord , verbose = false ) : string {
const status =
plugin . status === "loaded"
2026-01-21 04:47:35 +00:00
? theme . success ( "loaded" )
2026-01-11 12:11:12 +00:00
: plugin . status === "disabled"
2026-01-21 04:47:35 +00:00
? theme . warn ( "disabled" )
: theme . error ( "error" ) ;
const name = theme . command ( plugin . name || plugin . id ) ;
const idSuffix = plugin . name && plugin . name !== plugin . id ? theme . muted ( ` ( ${ plugin . id } ) ` ) : "" ;
2026-01-11 12:11:12 +00:00
const desc = plugin . description
2026-01-21 04:47:35 +00:00
? theme . muted (
2026-01-11 12:11:12 +00:00
plugin . description . length > 60
? ` ${ plugin . description . slice ( 0 , 57 ) } ... `
: plugin . description ,
)
2026-01-21 04:47:35 +00:00
: theme . muted ( "(no description)" ) ;
2026-01-11 12:11:12 +00:00
if ( ! verbose ) {
return ` ${ name } ${ idSuffix } ${ status } - ${ desc } ` ;
}
const parts = [
` ${ name } ${ idSuffix } ${ status } ` ,
2026-01-23 03:43:32 +00:00
` source: ${ theme . muted ( shortenHomeInString ( plugin . source ) ) } ` ,
2026-01-11 12:11:12 +00:00
` origin: ${ plugin . origin } ` ,
] ;
2026-01-31 16:19:20 +09:00
if ( plugin . version ) {
parts . push ( ` version: ${ plugin . version } ` ) ;
}
2026-01-16 00:39:22 +00:00
if ( plugin . providerIds . length > 0 ) {
parts . push ( ` providers: ${ plugin . providerIds . join ( ", " ) } ` ) ;
}
2026-01-31 16:19:20 +09:00
if ( plugin . error ) {
parts . push ( theme . error ( ` error: ${ plugin . error } ` ) ) ;
}
2026-01-11 12:11:12 +00:00
return parts . join ( "\n" ) ;
}
2026-01-18 11:26:50 -05:00
function applySlotSelectionForPlugin (
2026-01-30 03:15:10 +01:00
config : OpenClawConfig ,
2026-01-18 11:26:50 -05:00
pluginId : string ,
2026-01-30 03:15:10 +01:00
) : { config : OpenClawConfig ; warnings : string [ ] } {
2026-01-18 11:26:50 -05:00
const report = buildPluginStatusReport ( { config } ) ;
const plugin = report . plugins . find ( ( entry ) = > entry . id === pluginId ) ;
if ( ! plugin ) {
return { config , warnings : [ ] } ;
}
const result = applyExclusiveSlotSelection ( {
config ,
selectedId : plugin.id ,
selectedKind : plugin.kind ,
registry : report ,
} ) ;
return { config : result.config , warnings : result.warnings } ;
}
2026-02-15 05:31:41 +00:00
function createPluginInstallLogger ( ) : { info : ( msg : string ) = > void ; warn : ( msg : string ) = > void } {
return {
info : ( msg ) = > defaultRuntime . log ( msg ) ,
warn : ( msg ) = > defaultRuntime . log ( theme . warn ( msg ) ) ,
} ;
}
2026-01-18 11:26:50 -05:00
function logSlotWarnings ( warnings : string [ ] ) {
2026-01-31 16:19:20 +09:00
if ( warnings . length === 0 ) {
return ;
}
2026-01-18 11:26:50 -05:00
for ( const warning of warnings ) {
2026-01-21 04:47:35 +00:00
defaultRuntime . log ( theme . warn ( warning ) ) ;
2026-01-18 11:26:50 -05:00
}
}
2026-03-02 20:46:28 +00:00
function isBareNpmPackageName ( spec : string ) : boolean {
const trimmed = spec . trim ( ) ;
return /^[a-z0-9][a-z0-9-._~]*$/ . test ( trimmed ) ;
2026-03-02 11:34:20 -08:00
}
2026-03-02 20:46:28 +00:00
async function installBundledPluginSource ( params : {
config : OpenClawConfig ;
rawSpec : string ;
bundledSource : BundledPluginSource ;
warning : string ;
} ) {
const existing = params . config . plugins ? . load ? . paths ? ? [ ] ;
const mergedPaths = Array . from ( new Set ( [ . . . existing , params . bundledSource . localPath ] ) ) ;
let next : OpenClawConfig = {
. . . params . config ,
plugins : {
. . . params . config . plugins ,
load : {
. . . params . config . plugins ? . load ,
paths : mergedPaths ,
} ,
entries : {
. . . params . config . plugins ? . entries ,
[ params . bundledSource . pluginId ] : {
. . . ( params . config . plugins ? . entries ? . [ params . bundledSource . pluginId ] as
| object
| undefined ) ,
enabled : true ,
} ,
} ,
} ,
} ;
next = recordPluginInstall ( next , {
pluginId : params.bundledSource.pluginId ,
source : "path" ,
spec : params.rawSpec ,
sourcePath : params.bundledSource.localPath ,
installPath : params.bundledSource.localPath ,
} ) ;
const slotResult = applySlotSelectionForPlugin ( next , params . bundledSource . pluginId ) ;
next = slotResult . config ;
await writeConfigFile ( next ) ;
logSlotWarnings ( slotResult . warnings ) ;
defaultRuntime . log ( theme . warn ( params . warning ) ) ;
defaultRuntime . log ( ` Installed plugin: ${ params . bundledSource . pluginId } ` ) ;
defaultRuntime . log ( ` Restart the gateway to load plugins. ` ) ;
}
2026-03-02 21:22:32 +00:00
async function runPluginInstallCommand ( params : {
raw : string ;
opts : { link? : boolean ; pin? : boolean } ;
} ) {
const { raw , opts } = params ;
const fileSpec = resolveFileNpmSpecToLocalPath ( raw ) ;
if ( fileSpec && ! fileSpec . ok ) {
defaultRuntime . error ( fileSpec . error ) ;
process . exit ( 1 ) ;
}
const normalized = fileSpec && fileSpec . ok ? fileSpec.path : raw ;
const resolved = resolveUserPath ( normalized ) ;
const cfg = loadConfig ( ) ;
if ( fs . existsSync ( resolved ) ) {
if ( opts . link ) {
const existing = cfg . plugins ? . load ? . paths ? ? [ ] ;
const merged = Array . from ( new Set ( [ . . . existing , resolved ] ) ) ;
const probe = await installPluginFromPath ( { path : resolved , dryRun : true } ) ;
if ( ! probe . ok ) {
defaultRuntime . error ( probe . error ) ;
process . exit ( 1 ) ;
}
let next : OpenClawConfig = enablePluginInConfig (
{
. . . cfg ,
plugins : {
. . . cfg . plugins ,
load : {
. . . cfg . plugins ? . load ,
paths : merged ,
} ,
} ,
} ,
probe . pluginId ,
) . config ;
next = recordPluginInstall ( next , {
pluginId : probe.pluginId ,
source : "path" ,
sourcePath : resolved ,
installPath : resolved ,
version : probe.version ,
} ) ;
const slotResult = applySlotSelectionForPlugin ( next , probe . pluginId ) ;
next = slotResult . config ;
await writeConfigFile ( next ) ;
logSlotWarnings ( slotResult . warnings ) ;
defaultRuntime . log ( ` Linked plugin path: ${ shortenHomePath ( resolved ) } ` ) ;
defaultRuntime . log ( ` Restart the gateway to load plugins. ` ) ;
return ;
}
const result = await installPluginFromPath ( {
path : resolved ,
logger : createPluginInstallLogger ( ) ,
} ) ;
if ( ! result . ok ) {
defaultRuntime . error ( result . error ) ;
process . exit ( 1 ) ;
}
// Plugin CLI registrars may have warmed the manifest registry cache before install;
// force a rescan so config validation sees the freshly installed plugin.
clearPluginManifestRegistryCache ( ) ;
let next = enablePluginInConfig ( cfg , result . pluginId ) . config ;
const source : "archive" | "path" = resolveArchiveKind ( resolved ) ? "archive" : "path" ;
next = recordPluginInstall ( next , {
pluginId : result.pluginId ,
source ,
sourcePath : resolved ,
installPath : result.targetDir ,
version : result.version ,
} ) ;
const slotResult = applySlotSelectionForPlugin ( next , result . pluginId ) ;
next = slotResult . config ;
await writeConfigFile ( next ) ;
logSlotWarnings ( slotResult . warnings ) ;
defaultRuntime . log ( ` Installed plugin: ${ result . pluginId } ` ) ;
defaultRuntime . log ( ` Restart the gateway to load plugins. ` ) ;
return ;
}
if ( opts . link ) {
defaultRuntime . error ( "`--link` requires a local path." ) ;
process . exit ( 1 ) ;
}
if (
looksLikeLocalInstallSpec ( raw , [
".ts" ,
".js" ,
".mjs" ,
".cjs" ,
".tgz" ,
".tar.gz" ,
".tar" ,
".zip" ,
] )
) {
defaultRuntime . error ( ` Path not found: ${ resolved } ` ) ;
process . exit ( 1 ) ;
}
const bundledByPluginId = isBareNpmPackageName ( raw )
? findBundledPluginSource ( {
lookup : { kind : "pluginId" , value : raw } ,
} )
: undefined ;
if ( bundledByPluginId ) {
await installBundledPluginSource ( {
config : cfg ,
rawSpec : raw ,
bundledSource : bundledByPluginId ,
warning : ` Using bundled plugin " ${ bundledByPluginId . pluginId } " from ${ shortenHomePath ( bundledByPluginId . localPath ) } for bare install spec " ${ raw } ". To install an npm package with the same name, use a scoped package name (for example @scope/ ${ raw } ). ` ,
} ) ;
return ;
}
const result = await installPluginFromNpmSpec ( {
spec : raw ,
logger : createPluginInstallLogger ( ) ,
} ) ;
if ( ! result . ok ) {
const bundledFallback =
result . code === PLUGIN_INSTALL_ERROR_CODE . NPM_PACKAGE_NOT_FOUND
? findBundledPluginSource ( {
lookup : { kind : "npmSpec" , value : raw } ,
} )
: undefined ;
if ( ! bundledFallback ) {
defaultRuntime . error ( result . error ) ;
process . exit ( 1 ) ;
}
await installBundledPluginSource ( {
config : cfg ,
rawSpec : raw ,
bundledSource : bundledFallback ,
warning : ` npm package unavailable for ${ raw } ; using bundled plugin at ${ shortenHomePath ( bundledFallback . localPath ) } . ` ,
} ) ;
return ;
}
// Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup.
clearPluginManifestRegistryCache ( ) ;
let next = enablePluginInConfig ( cfg , result . pluginId ) . config ;
const installRecord = resolvePinnedNpmInstallRecordForCli (
raw ,
Boolean ( opts . pin ) ,
result . targetDir ,
result . version ,
result . npmResolution ,
defaultRuntime . log ,
theme . warn ,
) ;
next = recordPluginInstall ( next , {
pluginId : result.pluginId ,
. . . installRecord ,
} ) ;
const slotResult = applySlotSelectionForPlugin ( next , result . pluginId ) ;
next = slotResult . config ;
await writeConfigFile ( next ) ;
logSlotWarnings ( slotResult . warnings ) ;
defaultRuntime . log ( ` Installed plugin: ${ result . pluginId } ` ) ;
defaultRuntime . log ( ` Restart the gateway to load plugins. ` ) ;
}
2026-01-11 12:11:12 +00:00
export function registerPluginsCli ( program : Command ) {
2026-01-15 06:12:54 +00:00
const plugins = program
. command ( "plugins" )
2026-02-16 22:06:25 +01:00
. description ( "Manage OpenClaw plugins and extensions" )
2026-01-15 06:12:54 +00:00
. addHelpText (
"after" ,
( ) = >
2026-01-30 03:15:10 +01:00
` \ n ${ theme . muted ( "Docs:" ) } ${ formatDocsLink ( "/cli/plugins" , "docs.openclaw.ai/cli/plugins" ) } \ n ` ,
2026-01-15 06:12:54 +00:00
) ;
2026-01-11 12:11:12 +00:00
plugins
. command ( "list" )
. description ( "List discovered plugins" )
. option ( "--json" , "Print JSON" )
. option ( "--enabled" , "Only show enabled plugins" , false )
. option ( "--verbose" , "Show detailed entries" , false )
. action ( ( opts : PluginsListOptions ) = > {
const report = buildPluginStatusReport ( ) ;
const list = opts . enabled
? report . plugins . filter ( ( p ) = > p . status === "loaded" )
: report . plugins ;
if ( opts . json ) {
const payload = {
workspaceDir : report.workspaceDir ,
plugins : list ,
diagnostics : report.diagnostics ,
} ;
defaultRuntime . log ( JSON . stringify ( payload , null , 2 ) ) ;
return ;
}
if ( list . length === 0 ) {
2026-01-21 04:47:35 +00:00
defaultRuntime . log ( theme . muted ( "No plugins found." ) ) ;
2026-01-11 12:11:12 +00:00
return ;
}
const loaded = list . filter ( ( p ) = > p . status === "loaded" ) . length ;
2026-01-21 04:47:35 +00:00
defaultRuntime . log (
` ${ theme . heading ( "Plugins" ) } ${ theme . muted ( ` ( ${ loaded } / ${ list . length } loaded) ` ) } ` ,
2026-01-11 12:11:12 +00:00
) ;
2026-01-21 04:47:35 +00:00
if ( ! opts . verbose ) {
const tableWidth = Math . max ( 60 , ( process . stdout . columns ? ? 120 ) - 1 ) ;
2026-02-09 13:05:41 -06:00
const sourceRoots = resolvePluginSourceRoots ( {
workspaceDir : report.workspaceDir ,
} ) ;
const usedRoots = new Set < keyof typeof sourceRoots > ( ) ;
2026-01-23 04:02:36 +00:00
const rows = list . map ( ( plugin ) = > {
const desc = plugin . description ? theme . muted ( plugin . description ) : "" ;
2026-02-09 13:05:41 -06:00
const formattedSource = formatPluginSourceForTable ( plugin , sourceRoots ) ;
if ( formattedSource . rootKey ) {
usedRoots . add ( formattedSource . rootKey ) ;
}
const sourceLine = desc ? ` ${ formattedSource . value } \ n ${ desc } ` : formattedSource . value ;
2026-01-23 04:02:36 +00:00
return {
Name : plugin.name || plugin . id ,
ID : plugin.name && plugin . name !== plugin . id ? plugin . id : "" ,
Status :
plugin . status === "loaded"
? theme . success ( "loaded" )
: plugin . status === "disabled"
? theme . warn ( "disabled" )
: theme . error ( "error" ) ,
Source : sourceLine ,
Version : plugin.version ? ? "" ,
} ;
} ) ;
2026-02-09 13:05:41 -06:00
if ( usedRoots . size > 0 ) {
defaultRuntime . log ( theme . muted ( "Source roots:" ) ) ;
for ( const key of [ "stock" , "workspace" , "global" ] as const ) {
if ( ! usedRoots . has ( key ) ) {
continue ;
}
const dir = sourceRoots [ key ] ;
if ( ! dir ) {
continue ;
}
defaultRuntime . log ( ` ${ theme . command ( ` ${ key } : ` ) } ${ theme . muted ( dir ) } ` ) ;
}
defaultRuntime . log ( "" ) ;
}
2026-01-21 04:47:35 +00:00
defaultRuntime . log (
renderTable ( {
width : tableWidth ,
columns : [
{ key : "Name" , header : "Name" , minWidth : 14 , flex : true } ,
{ key : "ID" , header : "ID" , minWidth : 10 , flex : true } ,
{ key : "Status" , header : "Status" , minWidth : 10 } ,
2026-01-23 04:02:36 +00:00
{ key : "Source" , header : "Source" , minWidth : 26 , flex : true } ,
2026-01-21 04:47:35 +00:00
{ key : "Version" , header : "Version" , minWidth : 8 } ,
] ,
rows ,
} ) . trimEnd ( ) ,
) ;
return ;
}
const lines : string [ ] = [ ] ;
2026-01-11 12:11:12 +00:00
for ( const plugin of list ) {
2026-01-21 04:47:35 +00:00
lines . push ( formatPluginLine ( plugin , true ) ) ;
lines . push ( "" ) ;
2026-01-11 12:11:12 +00:00
}
defaultRuntime . log ( lines . join ( "\n" ) . trim ( ) ) ;
} ) ;
plugins
. command ( "info" )
. description ( "Show plugin details" )
. argument ( "<id>" , "Plugin id" )
. option ( "--json" , "Print JSON" )
. action ( ( id : string , opts : PluginInfoOptions ) = > {
const report = buildPluginStatusReport ( ) ;
const plugin = report . plugins . find ( ( p ) = > p . id === id || p . name === id ) ;
if ( ! plugin ) {
defaultRuntime . error ( ` Plugin not found: ${ id } ` ) ;
process . exit ( 1 ) ;
}
2026-01-16 05:54:47 +00:00
const cfg = loadConfig ( ) ;
const install = cfg . plugins ? . installs ? . [ plugin . id ] ;
2026-01-11 12:11:12 +00:00
if ( opts . json ) {
defaultRuntime . log ( JSON . stringify ( plugin , null , 2 ) ) ;
return ;
}
const lines : string [ ] = [ ] ;
2026-01-21 04:47:35 +00:00
lines . push ( theme . heading ( plugin . name || plugin . id ) ) ;
2026-01-11 12:11:12 +00:00
if ( plugin . name && plugin . name !== plugin . id ) {
2026-01-21 04:47:35 +00:00
lines . push ( theme . muted ( ` id: ${ plugin . id } ` ) ) ;
2026-01-11 12:11:12 +00:00
}
2026-01-31 16:19:20 +09:00
if ( plugin . description ) {
lines . push ( plugin . description ) ;
}
2026-01-11 12:11:12 +00:00
lines . push ( "" ) ;
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Status:" ) } ${ plugin . status } ` ) ;
2026-01-23 03:43:32 +00:00
lines . push ( ` ${ theme . muted ( "Source:" ) } ${ shortenHomeInString ( plugin . source ) } ` ) ;
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Origin:" ) } ${ plugin . origin } ` ) ;
2026-01-31 16:19:20 +09:00
if ( plugin . version ) {
lines . push ( ` ${ theme . muted ( "Version:" ) } ${ plugin . version } ` ) ;
}
2026-01-11 12:11:12 +00:00
if ( plugin . toolNames . length > 0 ) {
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Tools:" ) } ${ plugin . toolNames . join ( ", " ) } ` ) ;
2026-01-11 12:11:12 +00:00
}
2026-01-18 06:14:05 +00:00
if ( plugin . hookNames . length > 0 ) {
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Hooks:" ) } ${ plugin . hookNames . join ( ", " ) } ` ) ;
2026-01-18 06:14:05 +00:00
}
2026-01-11 12:11:12 +00:00
if ( plugin . gatewayMethods . length > 0 ) {
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Gateway methods:" ) } ${ plugin . gatewayMethods . join ( ", " ) } ` ) ;
2026-01-11 12:11:12 +00:00
}
2026-01-16 00:39:22 +00:00
if ( plugin . providerIds . length > 0 ) {
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Providers:" ) } ${ plugin . providerIds . join ( ", " ) } ` ) ;
2026-01-16 00:39:22 +00:00
}
2026-01-11 12:11:12 +00:00
if ( plugin . cliCommands . length > 0 ) {
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "CLI commands:" ) } ${ plugin . cliCommands . join ( ", " ) } ` ) ;
2026-01-11 12:11:12 +00:00
}
if ( plugin . services . length > 0 ) {
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Services:" ) } ${ plugin . services . join ( ", " ) } ` ) ;
2026-01-11 12:11:12 +00:00
}
2026-01-31 16:19:20 +09:00
if ( plugin . error ) {
lines . push ( ` ${ theme . error ( "Error:" ) } ${ plugin . error } ` ) ;
}
2026-01-16 05:54:47 +00:00
if ( install ) {
lines . push ( "" ) ;
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Install:" ) } ${ install . source } ` ) ;
2026-01-31 16:19:20 +09:00
if ( install . spec ) {
lines . push ( ` ${ theme . muted ( "Spec:" ) } ${ install . spec } ` ) ;
}
if ( install . sourcePath ) {
2026-01-23 03:43:32 +00:00
lines . push ( ` ${ theme . muted ( "Source path:" ) } ${ shortenHomePath ( install . sourcePath ) } ` ) ;
2026-01-31 16:19:20 +09:00
}
if ( install . installPath ) {
2026-01-23 03:43:32 +00:00
lines . push ( ` ${ theme . muted ( "Install path:" ) } ${ shortenHomePath ( install . installPath ) } ` ) ;
2026-01-31 16:19:20 +09:00
}
if ( install . version ) {
lines . push ( ` ${ theme . muted ( "Recorded version:" ) } ${ install . version } ` ) ;
}
if ( install . installedAt ) {
2026-01-21 04:47:35 +00:00
lines . push ( ` ${ theme . muted ( "Installed at:" ) } ${ install . installedAt } ` ) ;
2026-01-31 16:19:20 +09:00
}
2026-01-16 05:54:47 +00:00
}
2026-01-11 12:11:12 +00:00
defaultRuntime . log ( lines . join ( "\n" ) ) ;
} ) ;
plugins
. command ( "enable" )
. description ( "Enable a plugin in config" )
. argument ( "<id>" , "Plugin id" )
. action ( async ( id : string ) = > {
const cfg = loadConfig ( ) ;
2026-02-21 19:37:26 -08:00
const enableResult = enablePluginInConfig ( cfg , id ) ;
let next : OpenClawConfig = enableResult . config ;
2026-01-18 11:26:50 -05:00
const slotResult = applySlotSelectionForPlugin ( next , id ) ;
next = slotResult . config ;
2026-01-11 12:11:12 +00:00
await writeConfigFile ( next ) ;
2026-01-18 11:26:50 -05:00
logSlotWarnings ( slotResult . warnings ) ;
2026-02-21 19:37:26 -08:00
if ( enableResult . enabled ) {
defaultRuntime . log ( ` Enabled plugin " ${ id } ". Restart the gateway to apply. ` ) ;
return ;
}
defaultRuntime . log (
theme . warn (
` Plugin " ${ id } " could not be enabled ( ${ enableResult . reason ? ? "unknown reason" } ). ` ,
) ,
) ;
2026-01-11 12:11:12 +00:00
} ) ;
plugins
. command ( "disable" )
. description ( "Disable a plugin in config" )
. argument ( "<id>" , "Plugin id" )
. action ( async ( id : string ) = > {
const cfg = loadConfig ( ) ;
2026-02-21 21:56:05 +00:00
const next = setPluginEnabledInConfig ( cfg , id , false ) ;
2026-01-11 12:11:12 +00:00
await writeConfigFile ( next ) ;
2026-01-14 14:31:43 +00:00
defaultRuntime . log ( ` Disabled plugin " ${ id } ". Restart the gateway to apply. ` ) ;
2026-01-11 12:11:12 +00:00
} ) ;
2026-02-13 04:11:26 +02:00
plugins
. command ( "uninstall" )
. description ( "Uninstall a plugin" )
. argument ( "<id>" , "Plugin id" )
. option ( "--keep-files" , "Keep installed files on disk" , false )
. option ( "--keep-config" , "Deprecated alias for --keep-files" , false )
. option ( "--force" , "Skip confirmation prompt" , false )
. option ( "--dry-run" , "Show what would be removed without making changes" , false )
. action ( async ( id : string , opts : PluginUninstallOptions ) = > {
const cfg = loadConfig ( ) ;
const report = buildPluginStatusReport ( { config : cfg } ) ;
const extensionsDir = path . join ( resolveStateDir ( process . env , os . homedir ) , "extensions" ) ;
const keepFiles = Boolean ( opts . keepFiles || opts . keepConfig ) ;
if ( opts . keepConfig ) {
defaultRuntime . log ( theme . warn ( "`--keep-config` is deprecated, use `--keep-files`." ) ) ;
}
// Find plugin by id or name
const plugin = report . plugins . find ( ( p ) = > p . id === id || p . name === id ) ;
const pluginId = plugin ? . id ? ? id ;
// Check if plugin exists in config
const hasEntry = pluginId in ( cfg . plugins ? . entries ? ? { } ) ;
const hasInstall = pluginId in ( cfg . plugins ? . installs ? ? { } ) ;
if ( ! hasEntry && ! hasInstall ) {
if ( plugin ) {
defaultRuntime . error (
` Plugin " ${ pluginId } " is not managed by plugins config/install records and cannot be uninstalled. ` ,
) ;
} else {
defaultRuntime . error ( ` Plugin not found: ${ id } ` ) ;
}
process . exit ( 1 ) ;
}
const install = cfg . plugins ? . installs ? . [ pluginId ] ;
const isLinked = install ? . source === "path" ;
// Build preview of what will be removed
const preview : string [ ] = [ ] ;
if ( hasEntry ) {
preview . push ( "config entry" ) ;
}
if ( hasInstall ) {
preview . push ( "install record" ) ;
}
if ( cfg . plugins ? . allow ? . includes ( pluginId ) ) {
preview . push ( "allowlist entry" ) ;
}
if (
isLinked &&
install ? . sourcePath &&
cfg . plugins ? . load ? . paths ? . includes ( install . sourcePath )
) {
preview . push ( "load path" ) ;
}
if ( cfg . plugins ? . slots ? . memory === pluginId ) {
preview . push ( ` memory slot (will reset to "memory-core") ` ) ;
}
const deleteTarget = ! keepFiles
? resolveUninstallDirectoryTarget ( {
pluginId ,
hasInstall ,
installRecord : install ,
extensionsDir ,
} )
: null ;
if ( deleteTarget ) {
preview . push ( ` directory: ${ shortenHomePath ( deleteTarget ) } ` ) ;
}
const pluginName = plugin ? . name || pluginId ;
defaultRuntime . log (
` Plugin: ${ theme . command ( pluginName ) } ${ pluginName !== pluginId ? theme . muted ( ` ( ${ pluginId } ) ` ) : "" } ` ,
) ;
defaultRuntime . log ( ` Will remove: ${ preview . length > 0 ? preview . join ( ", " ) : "(nothing)" } ` ) ;
if ( opts . dryRun ) {
defaultRuntime . log ( theme . muted ( "Dry run, no changes made." ) ) ;
return ;
}
if ( ! opts . force ) {
const confirmed = await promptYesNo ( ` Uninstall plugin " ${ pluginId } "? ` ) ;
if ( ! confirmed ) {
defaultRuntime . log ( "Cancelled." ) ;
return ;
}
}
const result = await uninstallPlugin ( {
config : cfg ,
pluginId ,
deleteFiles : ! keepFiles ,
extensionsDir ,
} ) ;
if ( ! result . ok ) {
defaultRuntime . error ( result . error ) ;
process . exit ( 1 ) ;
}
for ( const warning of result . warnings ) {
defaultRuntime . log ( theme . warn ( warning ) ) ;
}
await writeConfigFile ( result . config ) ;
const removed : string [ ] = [ ] ;
if ( result . actions . entry ) {
removed . push ( "config entry" ) ;
}
if ( result . actions . install ) {
removed . push ( "install record" ) ;
}
if ( result . actions . allowlist ) {
removed . push ( "allowlist" ) ;
}
if ( result . actions . loadPath ) {
removed . push ( "load path" ) ;
}
if ( result . actions . memorySlot ) {
removed . push ( "memory slot" ) ;
}
if ( result . actions . directory ) {
removed . push ( "directory" ) ;
}
defaultRuntime . log (
` Uninstalled plugin " ${ pluginId } ". Removed: ${ removed . length > 0 ? removed . join ( ", " ) : "nothing" } . ` ,
) ;
defaultRuntime . log ( "Restart the gateway to apply changes." ) ;
} ) ;
2026-01-11 12:11:12 +00:00
plugins
. command ( "install" )
2026-01-12 01:16:39 +00:00
. description ( "Install a plugin (path, archive, or npm spec)" )
2026-01-17 07:08:04 +00:00
. argument ( "<path-or-spec>" , "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec" )
. option ( "-l, --link" , "Link a local path instead of copying" , false )
2026-02-19 15:10:57 +01:00
. option ( "--pin" , "Record npm installs as exact resolved <name>@<version>" , false )
. action ( async ( raw : string , opts : { link? : boolean ; pin? : boolean } ) = > {
2026-03-02 21:22:32 +00:00
await runPluginInstallCommand ( { raw , opts } ) ;
2026-01-11 12:11:12 +00:00
} ) ;
2026-01-16 05:54:47 +00:00
plugins
. command ( "update" )
. description ( "Update installed plugins (npm installs only)" )
. argument ( "[id]" , "Plugin id (omit with --all)" )
. option ( "--all" , "Update all tracked plugins" , false )
. option ( "--dry-run" , "Show what would change without writing" , false )
. action ( async ( id : string | undefined , opts : PluginUpdateOptions ) = > {
const cfg = loadConfig ( ) ;
const installs = cfg . plugins ? . installs ? ? { } ;
const targets = opts . all ? Object . keys ( installs ) : id ? [ id ] : [ ] ;
if ( targets . length === 0 ) {
2026-01-21 05:23:22 +00:00
if ( opts . all ) {
defaultRuntime . log ( "No npm-installed plugins to update." ) ;
return ;
}
2026-01-16 05:54:47 +00:00
defaultRuntime . error ( "Provide a plugin id or use --all." ) ;
process . exit ( 1 ) ;
}
2026-01-20 15:56:48 +00:00
const result = await updateNpmInstalledPlugins ( {
config : cfg ,
pluginIds : targets ,
dryRun : opts.dryRun ,
logger : {
info : ( msg ) = > defaultRuntime . log ( msg ) ,
2026-01-21 04:47:35 +00:00
warn : ( msg ) = > defaultRuntime . log ( theme . warn ( msg ) ) ,
2026-01-20 15:56:48 +00:00
} ,
2026-02-19 15:10:57 +01:00
onIntegrityDrift : async ( drift ) = > {
const specLabel = drift . resolvedSpec ? ? drift . spec ;
defaultRuntime . log (
theme . warn (
` Integrity drift detected for " ${ drift . pluginId } " ( ${ specLabel } ) ` +
` \ nExpected: ${ drift . expectedIntegrity } ` +
` \ nActual: ${ drift . actualIntegrity } ` ,
) ,
) ;
if ( drift . dryRun ) {
return true ;
}
return await promptYesNo ( ` Continue updating " ${ drift . pluginId } " with this artifact? ` ) ;
} ,
2026-01-20 15:56:48 +00:00
} ) ;
2026-01-16 05:54:47 +00:00
2026-01-20 15:56:48 +00:00
for ( const outcome of result . outcomes ) {
if ( outcome . status === "error" ) {
2026-01-21 04:47:35 +00:00
defaultRuntime . log ( theme . error ( outcome . message ) ) ;
2026-01-16 05:54:47 +00:00
continue ;
}
2026-01-20 15:56:48 +00:00
if ( outcome . status === "skipped" ) {
2026-01-21 04:47:35 +00:00
defaultRuntime . log ( theme . warn ( outcome . message ) ) ;
2026-01-16 05:54:47 +00:00
continue ;
}
2026-01-20 15:56:48 +00:00
defaultRuntime . log ( outcome . message ) ;
2026-01-16 05:54:47 +00:00
}
2026-01-20 15:56:48 +00:00
if ( ! opts . dryRun && result . changed ) {
await writeConfigFile ( result . config ) ;
2026-01-16 05:54:47 +00:00
defaultRuntime . log ( "Restart the gateway to load plugins." ) ;
}
} ) ;
2026-01-11 12:11:12 +00:00
plugins
. command ( "doctor" )
. description ( "Report plugin load issues" )
. action ( ( ) = > {
const report = buildPluginStatusReport ( ) ;
const errors = report . plugins . filter ( ( p ) = > p . status === "error" ) ;
const diags = report . diagnostics . filter ( ( d ) = > d . level === "error" ) ;
if ( errors . length === 0 && diags . length === 0 ) {
defaultRuntime . log ( "No plugin issues detected." ) ;
return ;
}
const lines : string [ ] = [ ] ;
if ( errors . length > 0 ) {
2026-01-21 04:47:35 +00:00
lines . push ( theme . error ( "Plugin errors:" ) ) ;
2026-01-11 12:11:12 +00:00
for ( const entry of errors ) {
2026-01-14 14:31:43 +00:00
lines . push ( ` - ${ entry . id } : ${ entry . error ? ? "failed to load" } ( ${ entry . source } ) ` ) ;
2026-01-11 12:11:12 +00:00
}
}
if ( diags . length > 0 ) {
2026-01-31 16:19:20 +09:00
if ( lines . length > 0 ) {
lines . push ( "" ) ;
}
2026-01-21 04:47:35 +00:00
lines . push ( theme . warn ( "Diagnostics:" ) ) ;
2026-01-11 12:11:12 +00:00
for ( const diag of diags ) {
const target = diag . pluginId ? ` ${ diag . pluginId } : ` : "" ;
lines . push ( ` - ${ target } ${ diag . message } ` ) ;
}
}
2026-01-30 03:15:10 +01:00
const docs = formatDocsLink ( "/plugin" , "docs.openclaw.ai/plugin" ) ;
2026-01-11 12:11:12 +00:00
lines . push ( "" ) ;
lines . push ( ` ${ theme . muted ( "Docs:" ) } ${ docs } ` ) ;
defaultRuntime . log ( lines . join ( "\n" ) ) ;
} ) ;
}