2026-05-22 22:25:12 +08:00
"""CLI-Anything catalog, install state, and safe CLI execution."""
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
2026-07-29 21:37:11 +08:00
from collections.abc import Iterable
2026-05-22 22:25:12 +08:00
from dataclasses import dataclass
2026-05-25 20:07:02 +08:00
from importlib import metadata as importlib_metadata
2026-05-22 22:25:12 +08:00
from pathlib import Path
2026-07-29 21:37:11 +08:00
from typing import Any , cast
2026-05-22 22:25:12 +08:00
from urllib.parse import urlparse
import httpx
2026-07-03 18:17:52 +08:00
from loguru import logger
2026-05-22 22:25:12 +08:00
2026-08-11 16:02:21 +09:00
from nanobot.agent.skills import parse_skill_metadata , valid_skill_metadata
2026-05-25 20:07:02 +08:00
from nanobot.apps.protocol import app_manifest , compact_dict
2026-05-22 22:25:12 +08:00
from nanobot.config.paths import get_runtime_subdir
2026-05-29 03:42:53 +08:00
from nanobot.security.workspace_policy import is_path_within
2026-05-22 22:25:12 +08:00
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
2026-08-11 16:02:21 +09:00
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
2026-05-29 04:40:26 +08:00
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = (
( "harness" , CLI_ANYTHING_REGISTRY_URL , CLI_ANYTHING_RAW_BASE , True ),
( "public" , CLI_ANYTHING_PUBLIC_REGISTRY_URL , CLI_ANYTHING_RAW_BASE , True ),
( "extensions" , NANOBOT_EXTENSION_REGISTRY_URL , NANOBOT_EXTENSION_RAW_BASE , False ),
)
2026-05-22 22:25:12 +08:00
_MAX_TOOL_OUTPUT_CHARS = 12_000
2026-05-23 01:39:46 +08:00
_MAX_ARTIFACT_SCAN_PATHS = 4_000
_MAX_ARTIFACT_REPORT = 12
2026-05-22 22:25:12 +08:00
_SAFE_NAME_RE = re . compile ( r "[^a-z0-9_-]+" )
2026-05-29 14:14:56 +08:00
_SAFE_NPM_DIR_RE = re . compile ( r "^[a-z0-9._-]+$" , re . IGNORECASE )
2026-05-22 22:25:12 +08:00
_MENTION_RE = re . compile ( r "(^|[\s([{])@([a-z0-9_-]+)\b" , re . IGNORECASE )
_SHELL_META_CHARS = ( "|" , "&&" , "||" , ";" , "$(" , "`" , ">" , "<" )
2026-05-29 03:42:53 +08:00
_ENDORSEMENT_WORD_RE = re . compile ( r "\bofficial\s+" , re . IGNORECASE )
2026-05-23 01:39:46 +08:00
_ARTIFACT_EXTENSIONS = frozenset ({
".csv" ,
".drawio" ,
".gif" ,
".html" ,
".jpeg" ,
".jpg" ,
".json" ,
".md" ,
".pdf" ,
".png" ,
".svg" ,
".txt" ,
".vsdx" ,
".webp" ,
".xml" ,
})
_INLINE_ARTIFACT_EXTENSIONS = frozenset ({ ".gif" , ".jpeg" , ".jpg" , ".png" , ".webp" })
_ARTIFACT_IGNORE_DIRS = frozenset ({
".git" ,
".hg" ,
".mypy_cache" ,
".nanobot" ,
".pytest_cache" ,
".ruff_cache" ,
".venv" ,
"__pycache__" ,
"build" ,
"dist" ,
"node_modules" ,
"venv" ,
})
2026-05-22 22:25:12 +08:00
class CliAppError ( ValueError ):
"""User-facing CLI Apps failure."""
def __init__ ( self , message : str , * , status : int = 400 ) -> None :
super () . __init__ ( message )
self . message = message
self . status = status
@dataclass ( slots = True )
class CliAppsRuntimeConfig :
"""Runtime knobs for CLI Apps."""
install_timeout : int = 300
run_timeout : int = 60
catalog_ttl_seconds : int = 3600
_BRANDS : dict [ str , tuple [ str , str ]] = {
"1password-cli" : ( "1password" , "#3B66BC" ),
2026-06-06 19:49:33 +08:00
"arcgis" : ( "arcgis" , "#2C7AC3" ),
"arcgis-pro" : ( "arcgis" , "#2C7AC3" ),
2026-05-22 22:25:12 +08:00
"audacity" : ( "audacity" , "#0000CC" ),
"blender" : ( "blender" , "#E87D0D" ),
"browser" : ( "googlechrome" , "#4285F4" ),
"calibre" : ( "calibre" , "#45B29D" ),
"chromadb" : ( "chroma" , "#FFDE2D" ),
"comfyui" : ( "comfyui" , "#111827" ),
"contentful" : ( "contentful" , "#2478CC" ),
"dify" : ( "dify" , "#155EEF" ),
"drawio" : ( "diagramsdotnet" , "#F08705" ),
"elevenlabs" : ( "elevenlabs" , "#000000" ),
"eth2-quickstart" : ( "ethereum" , "#627EEA" ),
"firefly-iii" : ( "fireflyiii" , "#CD5029" ),
"freecad" : ( "freecad" , "#418FDE" ),
"generate-veo-video" : ( "googlegemini" , "#8E75B2" ),
"gimp" : ( "gimp" , "#5C5543" ),
"godot" : ( "godotengine" , "#478CBF" ),
"hacker-feeds-cli" : ( "rss" , "#FFA500" ),
"inkscape" : ( "inkscape" , "#000000" ),
"intelwatch" : ( "intel" , "#0071C5" ),
"iterm2" : ( "iterm2" , "#000000" ),
"jimeng" : ( "bytedance" , "#3C8CFF" ),
2026-06-06 19:49:33 +08:00
"joplin" : ( "joplin" , "#1071D3" ),
2026-05-22 22:25:12 +08:00
"kdenlive" : ( "kdenlive" , "#527EB2" ),
"krita" : ( "krita" , "#3BABFF" ),
"libreoffice" : ( "libreoffice" , "#18A303" ),
"mailchimp" : ( "mailchimp" , "#FFE01B" ),
"mermaid" : ( "mermaid" , "#FF3670" ),
"minimax" : ( "minimax" , "#111827" ),
"musescore" : ( "musescore" , "#1A70B8" ),
"n8n" : ( "n8n" , "#EA4B71" ),
"notebooklm" : ( "googlenotebooklm" , "#4285F4" ),
"obs-studio" : ( "obsstudio" , "#302E31" ),
"obsidian" : ( "obsidian" , "#7C3AED" ),
"ollama" : ( "ollama" , "#000000" ),
"pm2" : ( "pm2" , "#2B037A" ),
"qgis" : ( "qgis" , "#589632" ),
"safari" : ( "safari" , "#006CFF" ),
"sanity" : ( "sanity" , "#F03E2F" ),
"sentry" : ( "sentry" , "#362D59" ),
"sketch" : ( "sketch" , "#F7B500" ),
"shopify" : ( "shopify" , "#7AB55C" ),
"nsight-graphics" : ( "nvidia" , "#76B900" ),
"unrealinsights" : ( "unrealengine" , "#0E1128" ),
"ueatelier" : ( "unrealengine" , "#0E1128" ),
"ve-twini" : ( "x" , "#000000" ),
"wecom" : ( "wechat" , "#07C160" ),
"suno" : ( "suno" , "#000000" ),
"lldb" : ( "llvm" , "#262D3A" ),
"android-cli" : ( "android" , "#3DDC84" ),
"adguardhome" : ( "adguard" , "#68BC71" ),
"zotero" : ( "zotero" , "#CC2936" ),
"zoom" : ( "zoom" , "#0B5CFF" ),
}
_BRAND_DOMAINS : dict [ str , tuple [ str , str ]] = {
"3mf" : ( "3mf.io" , "#00A1DE" ),
2026-05-25 20:07:02 +08:00
"anygen" : ( "anygen.io" , "#111827" ),
2026-05-22 22:25:12 +08:00
"clibrowser" : ( "github.com/allthingssecurity/clibrowser" , "#24292F" ),
"cloudanalyzer" : ( "github.com/rsasaki0109/CloudAnalyzer" , "#2563EB" ),
"cloudcompare" : ( "cloudcompare.org" , "#4D83C3" ),
"deployhq" : ( "deployhq.com" , "#00A2D9" ),
"exa" : ( "exa.ai" , "#111827" ),
"feishu" : ( "larksuite.com" , "#00A5FF" ),
"inkstitch" : ( "inkstitch.org" , "#222222" ),
"macrocli" : ( "github.com/HKUDS/CLI-Anything/tree/main/macrocli" , "#24292F" ),
"mubu" : ( "mubu.com" , "#16A085" ),
"nslogger" : ( "github.com/fpillet/NSLogger" , "#24292F" ),
"novita" : ( "novita.ai" , "#7C3AED" ),
"openscreen" : ( "openscreen.com" , "#2563EB" ),
"py4csr" : ( "github.com/yanmingyu92/py4csr" , "#24292F" ),
"quietshrink" : ( "github.com/achiya-automation/quietshrink" , "#111827" ),
"renderdoc" : ( "renderdoc.org" , "#2C7DB8" ),
"rms" : ( "rms.teltonika-networks.com" , "#0054A6" ),
"sbox" : ( "sbox.game" , "#F59E0B" ),
"seaclip" : ( "github.com/SeaClip-Lite/SeaClip" , "#0284C7" ),
"shotcut" : ( "shotcut.org" , "#3B82F6" ),
"slay-the-spire-ii" : ( "megacrit.com" , "#B91C1C" ),
"stata" : ( "stata.com" , "#1F4E79" ),
"unimol-tools" : ( "github.com/deepmodeling/Uni-Mol" , "#4F46E5" ),
"videocaptioner" : ( "github.com/WEIFENG2333/VideoCaptioner" , "#2563EB" ),
"wiremock" : ( "wiremock.org" , "#FF6A00" ),
}
_BRAND_ALIASES : dict [ str , str ] = {
"1password" : "1password-cli" ,
"dify-workflow" : "dify" ,
"feishu-lark" : "feishu" ,
"lark-cli" : "feishu" ,
"minimax-cli" : "minimax" ,
"obsidian-cli" : "obsidian" ,
2026-07-15 00:34:22 +08:00
"obsidian-agent" : "obsidian" ,
"obsidian-agent-cli" : "obsidian" ,
2026-05-22 22:25:12 +08:00
"slay-the-spire-2" : "slay-the-spire-ii" ,
"slay-the-spire-ii" : "slay-the-spire-ii" ,
"unimol-tools" : "unimol-tools" ,
"unimol" : "unimol-tools" ,
"veo" : "generate-veo-video" ,
}
_BRAND_TRAILING_WORDS = ( "cli" , "workflow" , "workflows" , "app" , "apps" , "tool" , "tools" )
def _now () -> float :
return time . time ()
2026-07-29 21:37:11 +08:00
def _as_object_dict ( value : object ) -> dict [ str , Any ] | None :
"""Narrow a JSON-like object to the string-keyed mapping used by this module."""
return cast ( dict [ str , Any ], value ) if isinstance ( value , dict ) else None
2026-08-11 16:02:21 +09:00
def _skill_name ( name : str , * , legacy : bool = False ) -> str :
2026-05-22 22:25:12 +08:00
clean = _SAFE_NAME_RE . sub ( "-" , name . lower ()) . strip ( "-" )
2026-08-11 16:02:21 +09:00
if not legacy :
clean = clean . replace ( "_" , "-" )
2026-05-22 22:25:12 +08:00
return f "cli-app- { clean or 'app' } "
2026-08-11 16:02:21 +09:00
def _plugin_skill_relative_path ( name : str ) -> str :
skill_name = _skill_name ( name )
return f "plugins/ { skill_name } /skills/ { skill_name } /SKILL.md"
def cli_app_skill_relative_path ( workspace : Path , name : str ) -> str :
"""Return a CLI App's skill path, including the legacy location."""
canonical = _plugin_skill_relative_path ( name )
legacy = f "skills/ { _skill_name ( name , legacy = True ) } /SKILL.md"
if not ( workspace / canonical ) . is_file () and ( workspace / legacy ) . is_file ():
return legacy
return canonical
2026-05-22 22:25:12 +08:00
def _has_shell_meta ( command : str ) -> bool :
return any ( char in command for char in _SHELL_META_CHARS )
def _command_exists ( command : str ) -> bool :
try :
parts = shlex . split ( command )
except ValueError :
return False
if not parts :
return False
return shutil . which ( parts [ 0 ]) is not None
def _is_pip_install_command ( command : str ) -> bool :
try :
tokens = shlex . split ( command )
except ValueError :
return False
return (
len ( tokens ) >= 3
and tokens [: 2 ] == [ "pip" , "install" ]
) or (
len ( tokens ) >= 5
and tokens [ 1 : 4 ] == [ "-m" , "pip" , "install" ]
and tokens [ 0 ] in { "python" , "python3" , sys . executable }
)
def _pip_uninstall_args_from_command ( command : str ) -> list [ str ] | None :
if not command or _has_shell_meta ( command ):
return None
try :
tokens = shlex . split ( command )
except ValueError :
return None
if tokens [: 2 ] == [ "pip" , "uninstall" ]:
args = tokens [ 2 :]
elif (
len ( tokens ) >= 5
and tokens [ 1 : 4 ] == [ "-m" , "pip" , "uninstall" ]
and tokens [ 0 ] in { "python" , "python3" , sys . executable }
):
args = tokens [ 4 :]
else :
return None
packages = [ arg for arg in args if arg not in { "-y" , "--yes" }]
if not packages or any ( arg . startswith ( "-" ) for arg in packages ):
return None
return packages
2026-05-25 20:07:02 +08:00
def _console_script_distribution ( entry_point : str ) -> str | None :
if not entry_point :
return None
try :
distributions = importlib_metadata . distributions ()
except Exception :
return None
for distribution in distributions :
try :
entry_points = distribution . entry_points
except Exception :
continue
for item in entry_points :
if item . group != "console_scripts" or item . name != entry_point :
continue
try :
2026-07-29 21:37:11 +08:00
name : object = cast ( Any , distribution . metadata ) . get ( "Name" )
2026-05-25 20:07:02 +08:00
except Exception :
name = None
2026-07-29 21:37:11 +08:00
fallback_name = cast ( object , getattr ( distribution , "name" , "" ))
return str ( name or fallback_name or "" ) . strip () or None
2026-05-25 20:07:02 +08:00
return None
2026-05-22 22:25:12 +08:00
def _brand_key ( value : str ) -> str :
return _SAFE_NAME_RE . sub ( "-" , value . lower ()) . replace ( "_" , "-" ) . strip ( "-" )
def _brand_candidates ( app : dict [ str , Any ]) -> list [ str ]:
values = [
str ( app . get ( "name" ) or "" ),
str ( app . get ( "display_name" ) or "" ),
str ( app . get ( "entry_point" ) or "" ) . removeprefix ( "cli-anything-" ),
]
seen : set [ str ] = set ()
candidates : list [ str ] = []
for value in values :
key = _brand_key ( value )
while key and key not in seen :
seen . add ( key )
candidates . append ( key )
parts = key . split ( "-" )
if len ( parts ) <= 1 or parts [ - 1 ] not in _BRAND_TRAILING_WORDS :
break
key = "-" . join ( parts [: - 1 ])
return candidates
def _brand_payload ( app : dict [ str , Any ]) -> tuple [ str | None , str | None ]:
2026-05-29 13:17:32 +08:00
declared_logo = str ( app . get ( "logo_url" ) or "" ) . strip ()
if declared_logo . startswith (( "https://" , "/" )):
declared_color = str ( app . get ( "brand_color" ) or "" ) . strip ()
return declared_logo , declared_color or None
2026-05-22 22:25:12 +08:00
brand = None
domain_brand = None
for candidate in _brand_candidates ( app ):
key = _BRAND_ALIASES . get ( candidate , candidate )
brand = _BRANDS . get ( key )
if brand :
break
domain_brand = _BRAND_DOMAINS . get ( key )
if domain_brand :
break
if not brand :
if not domain_brand :
return None , None
domain , color = domain_brand
return f "https://www.google.com/s2/favicons?domain= { domain } &sz=64" , color
slug , color = brand
return f "https://cdn.simpleicons.org/ { slug } / { color . lstrip ( '#' ) } " , color
def _read_json ( path : Path ) -> dict [ str , Any ] | None :
try :
2026-07-29 21:37:11 +08:00
data : object = json . loads ( path . read_text ( encoding = "utf-8" ))
2026-05-22 22:25:12 +08:00
except ( OSError , json . JSONDecodeError ):
return None
2026-07-29 21:37:11 +08:00
return _as_object_dict ( data )
2026-05-22 22:25:12 +08:00
def _write_json ( path : Path , data : dict [ str , Any ]) -> None :
path . parent . mkdir ( parents = True , exist_ok = True )
payload = json . dumps ( data , indent = 2 , ensure_ascii = False )
tmp_path = path . with_name ( f ". { path . name } . { os . getpid () } . { int ( _now () * 1_000_000 ) } .tmp" )
try :
tmp_path . write_text ( payload , encoding = "utf-8" )
tmp_path . replace ( path )
finally :
if tmp_path . exists ():
tmp_path . unlink ()
def _safe_skill_path ( value : str ) -> str | None :
if not value . startswith ( "skills/" ):
return None
parts = value . split ( "/" )
if any ( part in { "" , "." , ".." } for part in parts ):
return None
return value if parts [ - 1 ] == "SKILL.md" else None
2026-05-29 04:40:26 +08:00
def _skill_content_url ( skill_md : str , * , raw_base : str = CLI_ANYTHING_RAW_BASE ) -> str | None :
2026-05-22 22:25:12 +08:00
safe_path = _safe_skill_path ( skill_md )
if safe_path :
2026-05-29 04:40:26 +08:00
return f " { raw_base . rstrip ( '/' ) } / { safe_path } "
2026-05-22 22:25:12 +08:00
parsed = urlparse ( skill_md )
if parsed . scheme != "https" or parsed . netloc != "raw.githubusercontent.com" :
return None
2026-05-29 04:40:26 +08:00
raw_prefix = raw_base . rstrip ( "/" ) + "/"
if not skill_md . startswith ( raw_prefix ):
2026-05-22 22:25:12 +08:00
return None
2026-05-29 04:40:26 +08:00
suffix = skill_md . removeprefix ( raw_prefix )
2026-05-22 22:25:12 +08:00
return skill_md if _safe_skill_path ( suffix ) else None
def _truncate ( text : str , limit : int = _MAX_TOOL_OUTPUT_CHARS ) -> str :
if len ( text ) <= limit :
return text
omitted = len ( text ) - limit
return text [: limit ] + f " \n\n ... truncated { omitted } characters ..."
2026-05-29 03:42:53 +08:00
def _catalog_description ( app : dict [ str , Any ]) -> str :
"""Return catalog copy without implying vendor endorsement."""
description = str ( app . get ( "description" ) or "" )
return _ENDORSEMENT_WORD_RE . sub ( "" , description ) . strip ()
2026-05-22 22:25:12 +08:00
class CliAppManager :
"""Manage CLI-Anything registry entries and local install state."""
def __init__ (
self ,
* ,
workspace : Path ,
data_dir : Path | None = None ,
runtime : CliAppsRuntimeConfig | None = None ,
) -> None :
self . workspace = Path ( workspace ) . expanduser ()
self . data_dir = Path ( data_dir ) if data_dir is not None else get_runtime_subdir ( "cli-apps" )
self . runtime = runtime or CliAppsRuntimeConfig ()
@property
def installed_path ( self ) -> Path :
return self . data_dir / "installed.json"
def _cache_path ( self , source : str ) -> Path :
return self . data_dir / f " { source } _registry_cache.json"
2026-06-18 18:05:53 +08:00
def _cached_registry ( self , cache_path : Path ) -> tuple [ dict [ str , Any ] | None , float ]:
cached = _read_json ( cache_path )
if not cached :
return None , 0.0
2026-07-29 21:37:11 +08:00
data = _as_object_dict ( cached . get ( "data" ))
if data is None :
2026-06-18 18:05:53 +08:00
return None , 0.0
try :
cached_at = float ( cached . get ( "_cached_at" , 0 ))
except ( TypeError , ValueError ):
cached_at = 0.0
return data , cached_at
2026-05-22 22:25:12 +08:00
def _load_installed ( self ) -> dict [ str , Any ]:
data = _read_json ( self . installed_path ) or {}
2026-07-29 21:37:11 +08:00
apps = _as_object_dict ( data . get ( "apps" ))
return apps if apps is not None else data
2026-05-22 22:25:12 +08:00
def _save_installed ( self , installed : dict [ str , Any ]) -> None :
_write_json ( self . installed_path , { "schema_version" : 1 , "apps" : installed })
def installed_names ( self ) -> list [ str ]:
"""Return registry names explicitly installed through CLI Apps."""
return sorted ( str ( name ) for name in self . _load_installed ())
2026-08-11 16:02:21 +09:00
def installed_skill_aliases ( self ) -> dict [ str , str ]:
"""Map pre-plugin CLI App skill names to their portable identities."""
aliases : dict [ str , str ] = {}
for name in self . installed_names ():
legacy = _skill_name ( name , legacy = True )
canonical = _skill_name ( name )
if legacy != canonical :
aliases [ legacy ] = canonical
return aliases
2026-05-22 22:25:12 +08:00
def _fetch_registry (
self ,
url : str ,
cache_path : Path ,
* ,
force_refresh : bool = False ,
) -> dict [ str , Any ]:
2026-06-18 18:05:53 +08:00
data , cached_at = self . _cached_registry ( cache_path )
2026-05-22 22:25:12 +08:00
if (
not force_refresh
2026-06-18 18:05:53 +08:00
and data is not None
and _now () - cached_at < self . runtime . catalog_ttl_seconds
2026-05-22 22:25:12 +08:00
):
2026-06-18 18:05:53 +08:00
return data
2026-05-22 22:25:12 +08:00
try :
response = httpx . get ( url , timeout = 15.0 , follow_redirects = True )
response . raise_for_status ()
2026-07-29 21:37:11 +08:00
fetched = _as_object_dict ( response . json ())
if fetched is None :
2026-05-22 22:25:12 +08:00
raise ValueError ( "registry response must be an object" )
except Exception :
2026-06-18 18:05:53 +08:00
if data is not None :
return data
2026-05-22 22:25:12 +08:00
raise
2026-06-18 18:05:53 +08:00
_write_json ( cache_path , { "_cached_at" : _now (), "data" : fetched })
return fetched
2026-05-22 22:25:12 +08:00
2026-06-19 00:41:40 +08:00
async def _fetch_registry_async (
self ,
url : str ,
cache_path : Path ,
* ,
force_refresh : bool = False ,
) -> dict [ str , Any ]:
data , cached_at = self . _cached_registry ( cache_path )
if (
not force_refresh
and data is not None
and _now () - cached_at < self . runtime . catalog_ttl_seconds
):
return data
try :
async with httpx . AsyncClient ( timeout = 15.0 , follow_redirects = True ) as client :
response = await client . get ( url )
response . raise_for_status ()
2026-07-29 21:37:11 +08:00
fetched = _as_object_dict ( response . json ())
if fetched is None :
2026-06-19 00:41:40 +08:00
raise ValueError ( "registry response must be an object" )
except Exception :
if data is not None :
return data
raise
_write_json ( cache_path , { "_cached_at" : _now (), "data" : fetched })
return fetched
async def refresh_catalog_cache ( self , * , force_refresh : bool = False ) -> None :
for source , url , _raw_base , required in _CATALOG_SOURCES :
try :
await self . _fetch_registry_async (
url ,
self . _cache_path ( source ),
force_refresh = force_refresh ,
)
except Exception :
if required :
raise
2026-06-18 18:05:53 +08:00
def catalog (
self ,
* ,
force_refresh : bool = False ,
cache_only : bool = False ,
) -> tuple [ list [ dict [ str , Any ]], str | None ]:
2026-05-29 04:40:26 +08:00
registries : list [ tuple [ str , str , dict [ str , Any ]]] = []
for source , url , raw_base , required in _CATALOG_SOURCES :
try :
2026-06-18 18:05:53 +08:00
cache_path = self . _cache_path ( source )
if cache_only :
registry , _ = self . _cached_registry ( cache_path )
if registry is None :
continue
else :
registry = self . _fetch_registry (
url ,
cache_path ,
force_refresh = force_refresh ,
)
2026-05-29 04:40:26 +08:00
except Exception :
if required :
raise
continue
registries . append (( source , raw_base , registry ))
2026-05-22 22:25:12 +08:00
apps_by_name : dict [ str , dict [ str , Any ]] = {}
updated_values : list [ str ] = []
2026-05-29 04:40:26 +08:00
for source , raw_base , registry in registries :
2026-07-29 21:37:11 +08:00
meta = _as_object_dict ( registry . get ( "meta" ))
if meta is not None and isinstance ( meta . get ( "updated" ), str ):
2026-05-22 22:25:12 +08:00
updated_values . append ( meta [ "updated" ])
2026-07-29 21:37:11 +08:00
for row in cast ( Iterable [ object ], registry . get ( "clis" , [])):
entry = _as_object_dict ( row )
if entry is None or not entry . get ( "name" ):
2026-05-22 22:25:12 +08:00
continue
2026-07-29 21:37:11 +08:00
entry = dict ( entry )
2026-05-22 22:25:12 +08:00
entry [ "_source" ] = source
2026-05-29 04:40:26 +08:00
entry [ "_raw_base" ] = raw_base
2026-05-22 22:25:12 +08:00
key = str ( entry [ "name" ]) . lower ()
previous = apps_by_name . get ( key )
if previous :
previous_source = str ( previous . get ( "_source" ) or source )
merged_source = (
previous_source if previous_source == source else f " { previous_source } + { source } "
)
apps_by_name [ key ] = { ** previous , ** entry , "_source" : merged_source }
else :
apps_by_name [ key ] = entry
return list ( apps_by_name . values ()), max ( updated_values ) if updated_values else None
2026-06-19 00:29:57 +08:00
def catalog_cache_fresh ( self , * , include_optional : bool = False ) -> bool :
2026-06-18 18:05:53 +08:00
for source , _url , _raw_base , required in _CATALOG_SOURCES :
2026-06-19 00:29:57 +08:00
if not required and not include_optional :
2026-06-18 18:05:53 +08:00
continue
data , cached_at = self . _cached_registry ( self . _cache_path ( source ))
if data is None or _now () - cached_at >= self . runtime . catalog_ttl_seconds :
return False
return True
2026-05-29 04:40:26 +08:00
def _manifest_source ( self , app : dict [ str , Any ]) -> str :
source = str ( app . get ( "_source" ) or "harness" )
if source == "extensions" :
return "nanobot-extension"
return f "cli-anything: { source } "
def _trust_registry ( self , app : dict [ str , Any ]) -> str :
return "nanobot-extension" if str ( app . get ( "_source" ) or "" ) == "extensions" else "cli-anything"
2026-05-22 22:25:12 +08:00
def get_app ( self , name : str , * , force_refresh : bool = False ) -> dict [ str , Any ]:
wanted = name . lower ()
for app in self . catalog ( force_refresh = force_refresh )[ 0 ]:
if str ( app . get ( "name" , "" )) . lower () == wanted :
return app
raise CliAppError ( f "CLI app ' { name } ' not found" , status = 404 )
def mentioned_installed_apps ( self , text : str ) -> list [ dict [ str , str ]]:
"""Return installed CLI Apps referenced as ``@name`` in user text."""
if "@" not in text :
return []
installed = self . _load_installed ()
if not installed :
return []
installed_by_name = {
2026-07-29 21:37:11 +08:00
str ( name ) . lower (): ( str ( name ), _as_object_dict ( data ) or {})
2026-05-22 22:25:12 +08:00
for name , data in installed . items ()
}
seen : set [ str ] = set ()
mentions : list [ dict [ str , str ]] = []
for match in _MENTION_RE . finditer ( text ):
wanted = str ( match . group ( 2 )) . lower ()
if wanted in seen or wanted not in installed_by_name :
continue
installed_name , data = installed_by_name [ wanted ]
seen . add ( wanted )
entry_point = str ( data . get ( "entry_point" ) or "" )
mentions . append (
{
"name" : installed_name ,
"entry_point" : entry_point ,
"source" : str ( data . get ( "source" ) or "" ),
2026-08-11 16:02:21 +09:00
"skill" : cli_app_skill_relative_path ( self . workspace , installed_name ),
2026-05-22 22:25:12 +08:00
"tool" : "run_cli_app" ,
}
)
return mentions
def _strategy ( self , app : dict [ str , Any ]) -> str :
package_manager = str ( app . get ( "package_manager" ) or "" ) . lower ()
install_strategy = str ( app . get ( "install_strategy" ) or "" ) . lower ()
if package_manager == "bundled" or install_strategy == "bundled" :
return "bundled"
if package_manager in { "npm" , "brew" , "uv" , "pip" }:
return package_manager
if app . get ( "npm_package" ):
return "npm"
install_cmd = str ( app . get ( "install_cmd" ) or "" )
if _is_pip_install_command ( install_cmd ):
return "pip"
return "unsupported"
def _install_supported ( self , app : dict [ str , Any ]) -> bool :
if self . _strategy ( app ) == "unsupported" :
return False
install_cmd = str ( app . get ( "install_cmd" ) or "" )
return not _has_shell_meta ( install_cmd )
def _app_payload (
self ,
app : dict [ str , Any ],
installed : dict [ str , Any ],
) -> dict [ str , Any ]:
name = str ( app [ "name" ])
entry_point = str ( app . get ( "entry_point" ) or "" )
install_supported = self . _install_supported ( app )
is_installed = name in installed
available = bool ( entry_point and shutil . which ( entry_point ))
if is_installed and available :
status = "installed"
elif is_installed :
status = "missing"
elif not install_supported :
status = "unsupported"
elif available :
status = "available"
else :
status = "not_installed"
logo_url , brand_color = _brand_payload ( app )
return {
"name" : name ,
"display_name" : app . get ( "display_name" ) or name ,
"category" : app . get ( "category" ) or "uncategorized" ,
2026-05-29 03:42:53 +08:00
"description" : _catalog_description ( app ),
2026-05-22 22:25:12 +08:00
"requires" : app . get ( "requires" ) or "" ,
"source" : app . get ( "_source" ) or "harness" ,
"entry_point" : entry_point ,
"install_supported" : install_supported ,
"installed" : is_installed ,
"available" : available ,
"status" : status ,
"logo_url" : logo_url ,
"brand_color" : brand_color ,
2026-08-11 16:02:21 +09:00
"skill_installed" : ( self . workspace / cli_app_skill_relative_path ( self . workspace , name )) . is_file (),
2026-05-25 20:07:02 +08:00
"manifest" : self . _manifest_payload ( app , logo_url = logo_url , brand_color = brand_color ),
2026-05-22 22:25:12 +08:00
}
2026-05-25 20:07:02 +08:00
def _package_ref ( self , app : dict [ str , Any ]) -> dict [ str , Any ] | None :
strategy = self . _strategy ( app )
name = ""
if strategy == "pip" :
try :
uninstall = self . _pip_uninstall_argv ( app )
except CliAppError :
uninstall = None
name = uninstall [ - 1 ] if uninstall else ""
elif strategy == "npm" :
name = str ( app . get ( "npm_package" ) or "" ) . strip ()
elif strategy in { "brew" , "uv" }:
try :
uninstall = self . _argv_for_action ( app , "uninstall" )
except CliAppError :
uninstall = None
if uninstall :
name = uninstall [ - 1 ]
if not strategy or strategy in { "unsupported" , "bundled" }:
return None
return compact_dict ({ "manager" : strategy , "name" : name })
def _manifest_payload (
self ,
app : dict [ str , Any ],
* ,
logo_url : str | None ,
brand_color : str | None ,
) -> dict [ str , Any ]:
name = str ( app [ "name" ])
entry_point = str ( app . get ( "entry_point" ) or "" )
strategy = self . _strategy ( app )
2026-08-11 16:02:21 +09:00
skill_path = _plugin_skill_relative_path ( name )
plugin_path = f "plugins/ { _skill_name ( name ) } "
2026-05-25 20:07:02 +08:00
capabilities = [
compact_dict ({
"type" : "cli" ,
"entry_point" : entry_point ,
"package" : self . _package_ref ( app ),
}),
{ "type" : "skill" , "path" : skill_path },
]
install_supported = self . _install_supported ( app )
install = compact_dict ({
"supported" : install_supported ,
"strategy" : strategy ,
2026-08-11 16:02:21 +09:00
"managed_paths" : [ plugin_path ],
2026-05-25 20:07:02 +08:00
"verification" : [ "entry_point_available" ] if entry_point else [],
})
remove = compact_dict ({
"supported" : strategy != "unsupported" ,
"strategy" : strategy ,
2026-08-11 16:02:21 +09:00
"managed_paths" : [ plugin_path ],
2026-05-25 20:07:02 +08:00
"verification" : (
[ "package_manager_ok" , "entry_point_absent" , "managed_paths_absent" ]
if strategy not in { "bundled" , "unsupported" }
else [ "nanobot_state_absent" , "managed_paths_absent" ]
),
})
return app_manifest (
app_id = name ,
display_name = str ( app . get ( "display_name" ) or name ),
version = str ( app . get ( "version" ) or "" ),
2026-05-29 03:42:53 +08:00
description = _catalog_description ( app ),
2026-05-25 20:07:02 +08:00
category = str ( app . get ( "category" ) or "uncategorized" ),
2026-05-29 04:40:26 +08:00
source = self . _manifest_source ( app ),
2026-05-25 20:07:02 +08:00
logo_url = logo_url ,
brand_color = brand_color ,
capabilities = capabilities ,
install = install ,
remove = remove ,
trust = {
2026-05-29 04:40:26 +08:00
"registry" : self . _trust_registry ( app ),
2026-05-25 20:07:02 +08:00
"level" : "catalog" ,
"review_status" : "catalog_entry" ,
},
)
2026-06-18 18:05:53 +08:00
def payload ( self , * , force_refresh : bool = False , cache_only : bool = False ) -> dict [ str , Any ]:
apps , updated = self . catalog ( force_refresh = force_refresh , cache_only = cache_only )
2026-05-22 22:25:12 +08:00
installed = self . _load_installed ()
rows = [ self . _app_payload ( app , installed ) for app in apps ]
rows . sort ( key = lambda item : ( str ( item [ "category" ]), str ( item [ "display_name" ]) . lower ()))
return {
"apps" : rows ,
"installed_count" : sum ( 1 for item in rows if item [ "installed" ]),
"catalog_updated_at" : updated ,
}
2026-06-13 13:26:49 +08:00
def installed_payload ( self ) -> dict [ str , Any ]:
installed = self . _load_installed ()
2026-07-15 00:34:22 +08:00
cached_apps , _ = self . catalog ( cache_only = True )
cached_by_name = {
str ( app . get ( "name" ) or "" ) . lower (): app
for app in cached_apps
if app . get ( "name" )
}
2026-07-29 21:37:11 +08:00
rows : list [ dict [ str , Any ]] = []
2026-06-13 13:26:49 +08:00
for name , raw_entry in sorted ( installed . items ()):
2026-07-29 21:37:11 +08:00
entry = _as_object_dict ( raw_entry )
if entry is None :
entry = {}
2026-06-13 13:26:49 +08:00
strategy = str ( entry . get ( "strategy" ) or "bundled" )
2026-07-15 00:34:22 +08:00
cached_app = cached_by_name . get ( str ( name ) . lower (), {})
2026-07-29 21:37:11 +08:00
app : dict [ str , Any ] = {
2026-06-13 13:26:49 +08:00
"name" : str ( name ),
2026-07-15 00:34:22 +08:00
"display_name" : str (
cached_app . get ( "display_name" ) or entry . get ( "display_name" ) or name
),
"category" : str ( cached_app . get ( "category" ) or entry . get ( "category" ) or "installed" ),
"description" : str ( cached_app . get ( "description" ) or entry . get ( "description" ) or "" ),
"requires" : str ( cached_app . get ( "requires" ) or entry . get ( "requires" ) or "" ),
2026-06-13 13:26:49 +08:00
"_source" : str ( entry . get ( "source" ) or "local" ),
"entry_point" : str ( entry . get ( "entry_point" ) or "" ),
"package_manager" : strategy ,
2026-07-15 00:34:22 +08:00
"logo_url" : cached_app . get ( "logo_url" ) or entry . get ( "logo_url" ),
"brand_color" : cached_app . get ( "brand_color" ) or entry . get ( "brand_color" ),
2026-06-13 13:26:49 +08:00
}
rows . append ( self . _app_payload ( app , installed ))
return {
"apps" : rows ,
"installed_count" : len ( rows ),
"catalog_updated_at" : None ,
}
2026-05-22 22:25:12 +08:00
def _pip_package_from_install ( self , app : dict [ str , Any ]) -> str | None :
install_cmd = str ( app . get ( "install_cmd" ) or "" )
try :
tokens = shlex . split ( install_cmd )
except ValueError :
return None
if tokens [: 2 ] == [ "pip" , "install" ]:
args = tokens [ 2 :]
elif len ( tokens ) >= 5 and tokens [ 1 : 4 ] == [ "-m" , "pip" , "install" ]:
args = tokens [ 4 :]
else :
return None
args = [ arg for arg in args if not arg . startswith ( "-" )]
if len ( args ) != 1 or args [ 0 ] . startswith ( "git+" ):
return None
return args [ 0 ]
2026-06-02 20:42:08 +08:00
@staticmethod
def _pip_available () -> bool :
"""Return True if pip is importable for the current interpreter."""
from importlib.util import find_spec
return find_spec ( "pip" ) is not None
2026-05-22 22:25:12 +08:00
def _pip_install_argv ( self , app : dict [ str , Any ], * , update : bool = False ) -> list [ str ]:
install_cmd = str ( app . get ( "install_cmd" ) or "" )
if not _is_pip_install_command ( install_cmd ) or _has_shell_meta ( install_cmd ):
raise CliAppError ( "unsupported pip install command" )
tokens = shlex . split ( install_cmd )
args = tokens [ 2 :] if tokens [: 2 ] == [ "pip" , "install" ] else tokens [ 4 :]
2026-06-02 20:42:08 +08:00
pip_available = self . _pip_available ()
if pip_available :
prefix = [ sys . executable , "-m" , "pip" , "install" ]
elif shutil . which ( "uv" ):
prefix = [ "uv" , "pip" , "install" , "--python" , sys . executable ]
else :
raise CliAppError ( "pip is not available and uv is not installed" )
2026-05-22 22:25:12 +08:00
if update :
2026-06-02 20:42:08 +08:00
if pip_available :
prefix . extend ([ "--upgrade" , "--force-reinstall" ])
else :
2026-06-04 15:54:11 +08:00
prefix . extend ([ "--upgrade" , "--reinstall" ])
2026-05-22 22:25:12 +08:00
return prefix + args
2026-05-25 20:07:02 +08:00
def _pip_uninstall_argv (
self ,
app : dict [ str , Any ],
installed_entry : dict [ str , Any ] | None = None ,
) -> list [ str ]:
2026-06-02 20:42:08 +08:00
if self . _pip_available ():
prefix = [ sys . executable , "-m" , "pip" , "uninstall" , "-y" ]
elif shutil . which ( "uv" ):
2026-06-03 10:56:43 +08:00
prefix = [ "uv" , "pip" , "uninstall" , "--python" , sys . executable ]
2026-06-02 20:42:08 +08:00
else :
raise CliAppError ( "pip is not available and uv is not installed" )
2026-05-25 20:07:02 +08:00
distribution = str (( installed_entry or {}) . get ( "pip_distribution" ) or "" ) . strip ()
if distribution :
2026-06-02 20:42:08 +08:00
return [ * prefix , distribution ]
2026-05-22 22:25:12 +08:00
uninstall_cmd = str ( app . get ( "uninstall_cmd" ) or "" )
packages = _pip_uninstall_args_from_command ( uninstall_cmd )
if packages :
2026-06-02 20:42:08 +08:00
return [ * prefix , * packages ]
2026-05-22 22:25:12 +08:00
package = str ( app . get ( "pip_package" ) or "" ) . strip () or self . _pip_package_from_install ( app )
if not package :
entry_point = str ( app . get ( "entry_point" ) or "" ) . strip ()
package = entry_point if entry_point . startswith ( "cli-anything-" ) else f "cli-anything- { _brand_key ( str ( app [ 'name' ])) } "
2026-06-02 20:42:08 +08:00
return [ * prefix , package ]
2026-05-22 22:25:12 +08:00
def _npm_argv ( self , app : dict [ str , Any ], action : str ) -> list [ str ]:
npm = shutil . which ( "npm" )
if not npm :
raise CliAppError ( "npm is not installed" )
package = str ( app . get ( "npm_package" ) or "" )
if not package :
raise CliAppError ( "registry entry has no npm_package" )
if action == "install" :
return [ npm , "install" , "-g" , package ]
if action == "update" :
return [ npm , "install" , "-g" , package + "@latest" ]
return [ npm , "uninstall" , "-g" , package ]
2026-05-29 14:14:56 +08:00
def _cleanup_stale_npm_install ( self , app : dict [ str , Any ]) -> bool :
npm = shutil . which ( "npm" )
package = str ( app . get ( "npm_package" ) or "" ) . strip ()
if not npm or not package or "/" in package or _SAFE_NPM_DIR_RE . match ( package ) is None :
return False
result = self . _run_argv ([ npm , "root" , "-g" ], timeout = min ( self . runtime . install_timeout , 30 ))
if result . returncode != 0 :
return False
root = Path ( result . stdout . strip ()) . expanduser ()
try :
root = root . resolve ( strict = True )
except OSError :
return False
targets = [ root / package , * root . glob ( f ". { package } -*" )]
removed = False
for target in targets :
try :
resolved = target . resolve ( strict = False )
if not is_path_within ( resolved , root ) or not target . is_dir ():
continue
shutil . rmtree ( target )
removed = True
except OSError :
continue
return removed
def _retry_stale_npm_install (
self ,
app : dict [ str , Any ],
argv : list [ str ],
result : subprocess . CompletedProcess [ str ],
) -> subprocess . CompletedProcess [ str ]:
output = f " { result . stderr } \n { result . stdout } "
if "ENOTEMPTY" not in output or "rename" not in output :
return result
if not self . _cleanup_stale_npm_install ( app ):
return result
return self . _run_argv ( argv , timeout = self . runtime . install_timeout )
2026-05-22 22:25:12 +08:00
def _split_safe_command ( self , app : dict [ str , Any ], key : str , expected : str ) -> list [ str ]:
command = str ( app . get ( key ) or "" )
if not command :
raise CliAppError ( f "no { key } is defined for { app [ 'name' ] } " )
if _has_shell_meta ( command ):
raise CliAppError ( "script-style install commands are disabled in this MVP" )
try :
argv = shlex . split ( command )
except ValueError as exc :
raise CliAppError ( f "invalid command: { exc } " ) from exc
if not argv or argv [ 0 ] != expected :
raise CliAppError ( f "unsupported { expected } command" )
return argv
2026-05-25 20:07:02 +08:00
def _argv_for_action (
self ,
app : dict [ str , Any ],
action : str ,
installed_entry : dict [ str , Any ] | None = None ,
) -> list [ str ] | None :
2026-05-22 22:25:12 +08:00
strategy = self . _strategy ( app )
if strategy == "pip" :
if action == "install" :
return self . _pip_install_argv ( app )
if action == "update" :
return self . _pip_install_argv ( app , update = True )
2026-05-25 20:07:02 +08:00
return self . _pip_uninstall_argv ( app , installed_entry = installed_entry )
2026-05-22 22:25:12 +08:00
if strategy == "npm" :
return self . _npm_argv ( app , action )
if strategy == "brew" :
key = { "install" : "install_cmd" , "update" : "update_cmd" , "uninstall" : "uninstall_cmd" }[ action ]
return self . _split_safe_command ( app , key , "brew" )
if strategy == "uv" :
key = { "install" : "install_cmd" , "update" : "update_cmd" , "uninstall" : "uninstall_cmd" }[ action ]
return self . _split_safe_command ( app , key , "uv" )
if strategy == "bundled" :
return None
raise CliAppError ( "this CLI app uses an unsupported install strategy" )
2026-08-06 19:53:09 +08:00
def _subprocess_env ( self ) -> dict [ str , str ]:
"""Minimal env for CLI app subprocesses — no API keys or secrets.
Mirrors the shell tool's allowlist so installed apps cannot read
provider credentials from the parent process environment.
"""
if sys . platform == "win32" :
sr = os . environ . get ( "SYSTEMROOT" , r "C:\Windows" )
env = {
"SYSTEMROOT" : sr ,
"COMSPEC" : os . environ . get ( "COMSPEC" , f " { sr } \\ system32 \\ cmd.exe" ),
"USERPROFILE" : os . environ . get ( "USERPROFILE" , "" ),
"HOMEDRIVE" : os . environ . get ( "HOMEDRIVE" , "C:" ),
"HOMEPATH" : os . environ . get ( "HOMEPATH" , " \\ " ),
"TEMP" : os . environ . get ( "TEMP" , f " { sr } \\ Temp" ),
"TMP" : os . environ . get ( "TMP" , f " { sr } \\ Temp" ),
"PATHEXT" : os . environ . get ( "PATHEXT" , ".COM;.EXE;.BAT;.CMD" ),
"PATH" : os . environ . get ( "PATH" , f " { sr } \\ system32; { sr } " ),
"PYTHONUNBUFFERED" : "1" ,
}
return { k : v for k , v in env . items () if v is not None }
return {
"HOME" : os . environ . get ( "HOME" , "/tmp" ),
"LANG" : os . environ . get ( "LANG" , "C.UTF-8" ),
"TERM" : os . environ . get ( "TERM" , "dumb" ),
"PATH" : os . environ . get ( "PATH" , "/usr/bin:/bin" ),
"PYTHONUNBUFFERED" : "1" ,
}
def _run_argv ( self , argv : list [ str ], * , timeout : int ) -> subprocess . CompletedProcess [ str ]:
2026-07-03 18:17:52 +08:00
command = subprocess . list2cmdline ( argv )
logger . info ( "CLI Apps: running {} " , command )
result = subprocess . run (
2026-05-22 22:25:12 +08:00
argv ,
capture_output = True ,
text = True ,
2026-07-18 19:32:10 +08:00
encoding = "utf-8" ,
errors = "replace" ,
2026-05-22 22:25:12 +08:00
timeout = timeout ,
)
2026-07-03 18:17:52 +08:00
logger . info ( "CLI Apps: command exited with code {} : {} " , result . returncode , command )
output = ( result . stderr or result . stdout or "" ) . strip ()
if output :
logger . info ( "CLI Apps command output: \n {} " , _truncate ( output , 4000 ))
return result
2026-05-22 22:25:12 +08:00
def _installed_entry ( self , app : dict [ str , Any ]) -> dict [ str , Any ]:
2026-05-25 20:07:02 +08:00
entry_point = str ( app . get ( "entry_point" ) or "" )
strategy = self . _strategy ( app )
entry : dict [ str , Any ] = {
2026-05-22 22:25:12 +08:00
"version" : app . get ( "version" ) or "unknown" ,
2026-05-25 20:07:02 +08:00
"entry_point" : entry_point ,
2026-05-22 22:25:12 +08:00
"source" : app . get ( "_source" ) or "harness" ,
2026-05-25 20:07:02 +08:00
"strategy" : strategy ,
2026-05-22 22:25:12 +08:00
"installed_at" : int ( _now ()),
}
2026-07-15 00:34:22 +08:00
for field in (
"display_name" ,
"category" ,
"description" ,
"requires" ,
"logo_url" ,
"brand_color" ,
):
value = app . get ( field )
if value not in ( None , "" ):
entry [ field ] = value
2026-05-25 20:07:02 +08:00
resolved = shutil . which ( entry_point ) if entry_point else None
if resolved :
entry [ "entry_point_path" ] = resolved
if strategy == "pip" :
distribution = _console_script_distribution ( entry_point )
if distribution :
entry [ "pip_distribution" ] = distribution
return entry
2026-05-22 22:25:12 +08:00
def _fetch_skill_content ( self , app : dict [ str , Any ]) -> str | None :
skill_md = str ( app . get ( "skill_md" ) or "" ) . strip ()
if not skill_md :
return None
2026-05-29 04:40:26 +08:00
url = _skill_content_url ( skill_md , raw_base = str ( app . get ( "_raw_base" ) or CLI_ANYTHING_RAW_BASE ))
2026-05-22 22:25:12 +08:00
if not url :
return None
try :
response = httpx . get ( url , timeout = 15.0 , follow_redirects = True )
response . raise_for_status ()
text = response . text
except Exception :
return None
if "SKILL.md" not in url and not text . lstrip () . startswith ( "---" ):
return None
return text if len ( text ) < 250_000 else None
def _fallback_skill ( self , app : dict [ str , Any ]) -> str :
name = str ( app . get ( "name" ) or "unknown" )
display = str ( app . get ( "display_name" ) or name )
entry = str ( app . get ( "entry_point" ) or f "cli-anything- { name } " )
2026-08-11 16:02:21 +09:00
description = ( _catalog_description ( app ) or f "Use { display } from nanobot." )[: 1024 ]
2026-05-22 22:25:12 +08:00
return f """---
2026-08-11 16:02:21 +09:00
name: { _skill_name ( name ) }
description: { json . dumps ( description , ensure_ascii = False ) }
2026-05-22 22:25:12 +08:00
---
# { display }
Use this skill when the user asks nanobot to operate { display } through its installed CLI app.
If the user attached `@ { name } ` in chat, treat that as the selected app for the current turn.
## Commands
```bash
{ entry } --help
{ entry } --json --help
```
Prefer machine-readable output when the CLI supports `--json`.
"""
def _with_nanobot_skill_note ( self , content : str , app : dict [ str , Any ]) -> str :
2026-08-11 16:02:21 +09:00
name = str ( app . get ( "name" ) or "unknown" )
skill_name = _skill_name ( name )
metadata = parse_skill_metadata ( content )
if metadata is None or not valid_skill_metadata ( metadata | { "name" : skill_name }, skill_name ):
content = self . _fallback_skill ( app )
content , replaced = re . subn ( r "(?m)^name\s*:.*$" , f "name: { skill_name } " , content , count = 1 )
if not replaced :
content = content . replace ( "--- \n " , f "--- \n name: { skill_name } \n " , 1 )
2026-05-22 22:25:12 +08:00
marker = "<!-- nanobot-cli-app-note -->"
if marker in content :
return content
note = f """ { marker }
## Nanobot execution
Use the `run_cli_app` tool with `name=" { name } "` for command execution. Do not invoke this CLI through shell unless the user explicitly asks. Prefer this skill when Runtime Context mentions `@ { name } ` as a CLI App Attachment.
"""
lines = content . splitlines ( keepends = True )
if lines and lines [ 0 ] . strip () == "---" :
for index , line in enumerate ( lines [ 1 :], start = 1 ):
if line . strip () == "---" :
return "" . join ( lines [: index + 1 ]) + " \n " + note + " \n " + "" . join ( lines [ index + 1 :])
return note + " \n " + content
def install_skill ( self , app : dict [ str , Any ]) -> Path :
2026-08-11 16:02:21 +09:00
name = str ( app [ "name" ])
path = self . workspace / _plugin_skill_relative_path ( name )
2026-05-22 22:25:12 +08:00
path . parent . mkdir ( parents = True , exist_ok = True )
content = self . _fetch_skill_content ( app ) or self . _fallback_skill ( app )
content = self . _with_nanobot_skill_note ( content , app )
path . write_text ( content , encoding = "utf-8" )
2026-08-11 16:02:21 +09:00
plugin_root = path . parents [ 2 ]
manifest = compact_dict ({
"$schema" : AGENT_PLUGIN_SCHEMA ,
"name" : _skill_name ( str ( app [ "name" ])),
"version" : str ( app . get ( "version" ) or "" ),
"description" : _catalog_description ( app ),
})
_write_json ( plugin_root / "plugin.json" , manifest )
legacy_dir = self . workspace / "skills" / _skill_name ( str ( app [ "name" ]), legacy = True )
if legacy_dir . is_dir ():
shutil . rmtree ( legacy_dir )
2026-05-22 22:25:12 +08:00
return path
def remove_skill ( self , name : str ) -> None :
2026-08-11 16:02:21 +09:00
plugin_root = ( self . workspace / _plugin_skill_relative_path ( name )) . parents [ 2 ]
if plugin_root . is_dir ():
shutil . rmtree ( plugin_root )
legacy_dir = self . workspace / "skills" / _skill_name ( name , legacy = True )
if legacy_dir . is_dir ():
shutil . rmtree ( legacy_dir )
2026-05-22 22:25:12 +08:00
2026-05-25 20:07:02 +08:00
def _record_installed ( self , app : dict [ str , Any ]) -> dict [ str , Any ]:
2026-08-11 16:02:21 +09:00
from nanobot.agent.plugins import set_agent_plugin_enabled
2026-05-22 22:25:12 +08:00
installed = self . _load_installed ()
2026-05-25 20:07:02 +08:00
entry = self . _installed_entry ( app )
installed [ str ( app [ "name" ])] = entry
2026-05-22 22:25:12 +08:00
self . _save_installed ( installed )
self . install_skill ( app )
2026-08-11 16:02:21 +09:00
set_agent_plugin_enabled ( self . workspace , _skill_name ( str ( app [ "name" ])), True )
2026-05-25 20:07:02 +08:00
return entry
2026-05-22 22:25:12 +08:00
def install ( self , name : str ) -> dict [ str , Any ]:
app = self . get_app ( name )
if not self . _install_supported ( app ):
raise CliAppError ( "this CLI app uses an unsupported install strategy" )
strategy = self . _strategy ( app )
2026-05-29 14:14:56 +08:00
entry_point = str ( app . get ( "entry_point" ) or "" )
if entry_point and shutil . which ( entry_point ):
self . _record_installed ( app )
return self . payload () | {
"last_action" : {
"ok" : True ,
"message" : f "CLI for { app [ 'display_name' ] } is already available." ,
"installed" : True ,
"verification" : [ "entry_point_available" , "state_recorded" , "managed_paths_present" ],
}
}
2026-05-22 22:25:12 +08:00
if strategy == "bundled" :
detect_cmd = str ( app . get ( "detect_cmd" ) or app . get ( "entry_point" ) or "" )
if detect_cmd and _command_exists ( detect_cmd ):
self . _record_installed ( app )
2026-05-25 20:07:02 +08:00
return self . payload () | {
"last_action" : {
"ok" : True ,
"message" : f "CLI for { app [ 'display_name' ] } is available." ,
"installed" : True ,
"verification" : [ "entry_point_available" , "state_recorded" ],
}
}
2026-05-22 22:25:12 +08:00
note = app . get ( "install_notes" ) or f " { app [ 'display_name' ] } is bundled with its parent app."
raise CliAppError ( str ( note ))
argv = self . _argv_for_action ( app , "install" )
assert argv is not None
result = self . _run_argv ( argv , timeout = self . runtime . install_timeout )
2026-05-29 14:14:56 +08:00
if strategy == "npm" and result . returncode != 0 :
result = self . _retry_stale_npm_install ( app , argv , result )
2026-05-22 22:25:12 +08:00
if result . returncode != 0 :
raise CliAppError ( _truncate ( result . stderr or result . stdout or "install failed" ), status = 500 )
self . _record_installed ( app )
2026-05-25 20:07:02 +08:00
return self . payload () | {
"last_action" : {
"ok" : True ,
"message" : f "Installed CLI for { app [ 'display_name' ] } ." ,
"installed" : True ,
"verification" : [ "package_manager_ok" , "state_recorded" , "managed_paths_present" ],
}
}
2026-05-22 22:25:12 +08:00
def update ( self , name : str ) -> dict [ str , Any ]:
app = self . get_app ( name , force_refresh = True )
if str ( app [ "name" ]) not in self . _load_installed ():
raise CliAppError ( "CLI app is not installed" )
if self . _strategy ( app ) == "bundled" :
self . _record_installed ( app )
2026-05-25 20:07:02 +08:00
return self . payload () | {
"last_action" : {
"ok" : True ,
"message" : f "Checked { app [ 'display_name' ] } ." ,
"installed" : True ,
"verification" : [ "state_recorded" ],
}
}
2026-05-22 22:25:12 +08:00
argv = self . _argv_for_action ( app , "update" )
assert argv is not None
result = self . _run_argv ( argv , timeout = self . runtime . install_timeout )
if result . returncode != 0 :
raise CliAppError ( _truncate ( result . stderr or result . stdout or "update failed" ), status = 500 )
self . _record_installed ( app )
2026-05-25 20:07:02 +08:00
return self . payload () | {
"last_action" : {
"ok" : True ,
"message" : f "Updated CLI for { app [ 'display_name' ] } ." ,
"installed" : True ,
"verification" : [ "package_manager_ok" , "state_recorded" , "managed_paths_present" ],
}
}
2026-05-22 22:25:12 +08:00
def uninstall ( self , name : str ) -> dict [ str , Any ]:
app = self . get_app ( name )
installed = self . _load_installed ()
if str ( app [ "name" ]) not in installed :
raise CliAppError ( "CLI app is not installed" )
2026-05-25 20:07:02 +08:00
raw_installed_entry = installed . get ( str ( app [ "name" ]))
2026-07-29 21:37:11 +08:00
installed_entry = _as_object_dict ( raw_installed_entry )
if installed_entry is None :
installed_entry = {}
2026-05-25 20:07:02 +08:00
strategy = self . _strategy ( app )
entry_point = str ( app . get ( "entry_point" ) or "" ) . strip ()
managed_entry_path = str ( installed_entry . get ( "entry_point_path" ) or "" ) . strip ()
if strategy != "bundled" :
argv = self . _argv_for_action ( app , "uninstall" , installed_entry = installed_entry )
2026-05-22 22:25:12 +08:00
assert argv is not None
result = self . _run_argv ( argv , timeout = self . runtime . install_timeout )
if result . returncode != 0 :
raise CliAppError ( _truncate ( result . stderr or result . stdout or "uninstall failed" ), status = 500 )
2026-05-25 20:07:02 +08:00
still_managed = bool ( managed_entry_path and Path ( managed_entry_path ) . exists ())
still_available = bool ( entry_point and shutil . which ( entry_point ))
if still_managed or ( not managed_entry_path and still_available ):
reason = (
f "the recorded entry point at { managed_entry_path } still exists"
if still_managed
else f " { entry_point } is still available on PATH"
)
message = (
f "Uninstall for { app [ 'display_name' ] } completed, but { reason } , "
"so nanobot kept it installed."
)
return self . payload () | {
"last_action" : {
"ok" : False ,
"message" : message ,
"removed" : False ,
"still_available" : True ,
"verification_failed" : [ "entry_point_absent" ],
}
}
else :
still_available = bool ( entry_point and shutil . which ( entry_point ))
2026-05-22 22:25:12 +08:00
installed . pop ( str ( app [ "name" ]), None )
self . _save_installed ( installed )
self . remove_skill ( str ( app [ "name" ]))
2026-05-25 20:07:02 +08:00
if strategy == "bundled" and still_available :
message = (
f "Removed { app [ 'display_name' ] } from nanobot. { entry_point } "
"is still available because it is managed outside nanobot."
)
elif still_available :
message = (
f "Uninstalled CLI for { app [ 'display_name' ] } , but another { entry_point } "
"is still available on PATH."
)
else :
message = f "Uninstalled CLI for { app [ 'display_name' ] } ."
return self . payload () | {
"last_action" : {
"ok" : True ,
"message" : message ,
"removed" : True ,
"still_available" : still_available ,
"verification" : [ "state_absent" , "managed_paths_absent" ]
if still_available
else [ "entry_point_absent" , "state_absent" , "managed_paths_absent" ],
}
}
2026-05-22 22:25:12 +08:00
def test ( self , name : str ) -> dict [ str , Any ]:
app = self . get_app ( name )
entry = str ( app . get ( "entry_point" ) or "" )
resolved = shutil . which ( entry )
if not entry or not resolved :
raise CliAppError ( f " { entry or name } is not available on PATH" )
result = self . _run_argv ([ resolved , "--help" ], timeout = min ( self . runtime . run_timeout , 30 ))
ok = result . returncode == 0
output = _truncate (( result . stdout or result . stderr or "" ) . strip (), 3000 )
return self . payload () | {
"last_action" : {
"ok" : ok ,
"message" : f " { entry } --help exited { result . returncode } " ,
"output" : output ,
}
}
def _resolve_cwd (
self ,
working_dir : str | None ,
* ,
restrict_to_workspace : bool ,
) -> Path :
cwd = Path ( working_dir ) . expanduser () if working_dir else self . workspace
cwd = cwd . resolve ( strict = False )
workspace = self . workspace . resolve ( strict = False )
2026-05-29 03:42:53 +08:00
if restrict_to_workspace and not is_path_within ( cwd , workspace ):
2026-05-22 22:25:12 +08:00
raise CliAppError ( "working_dir is outside the configured workspace" )
return cwd
2026-05-23 01:39:46 +08:00
def _iter_artifact_candidates ( self , cwd : Path ) -> list [ Path ]:
if not cwd . is_dir ():
return []
out : list [ Path ] = []
stack = [ cwd ]
scanned = 0
while stack and scanned < _MAX_ARTIFACT_SCAN_PATHS :
directory = stack . pop ()
try :
entries = sorted ( directory . iterdir (), key = lambda path : path . name . lower ())
except OSError :
continue
for path in entries :
if scanned >= _MAX_ARTIFACT_SCAN_PATHS :
break
scanned += 1
try :
if path . is_dir () and not path . is_symlink ():
if path . name not in _ARTIFACT_IGNORE_DIRS :
stack . append ( path )
continue
if path . is_file () and path . suffix . lower () in _ARTIFACT_EXTENSIONS :
out . append ( path . resolve ( strict = False ))
except OSError :
continue
return out
def _artifact_snapshot ( self , cwd : Path ) -> dict [ Path , tuple [ int , int ]]:
snapshot : dict [ Path , tuple [ int , int ]] = {}
for path in self . _iter_artifact_candidates ( cwd ):
try :
stat = path . stat ()
except OSError :
continue
snapshot [ path ] = ( stat . st_mtime_ns , stat . st_size )
return snapshot
def _changed_artifacts (
self ,
cwd : Path ,
before : dict [ Path , tuple [ int , int ]],
) -> list [ Path ]:
changed : list [ tuple [ int , Path ]] = []
for path , stamp in self . _artifact_snapshot ( cwd ) . items ():
if before . get ( path ) == stamp :
continue
changed . append (( stamp [ 0 ], path ))
changed . sort ( key = lambda item : ( item [ 0 ], item [ 1 ] . name . lower ()))
return [ path for _ , path in changed [ - _MAX_ARTIFACT_REPORT :]]
def _format_artifact_path ( self , cwd : Path , path : Path ) -> str :
try :
return path . relative_to ( cwd ) . as_posix ()
except ValueError :
return path . name
@staticmethod
def _format_artifact_size ( path : Path ) -> str :
try :
size = path . stat () . st_size
except OSError :
return "unknown size"
if size < 1024 :
return f " { size } B"
if size < 1024 * 1024 :
return f " { size / 1024 : .1f } KB"
return f " { size / ( 1024 * 1024 ) : .1f } MB"
def _format_artifact_lines ( self , cwd : Path , paths : list [ Path ]) -> list [ str ]:
lines : list [ str ] = []
for path in paths :
rel = self . _format_artifact_path ( cwd , path )
ext = path . suffix . lower ()
kind = (
"previewable image"
if ext in _INLINE_ARTIFACT_EXTENSIONS
else ext . lstrip ( "." ) or "file"
)
lines . append ( f "- { rel } ( { kind } , { self . _format_artifact_size ( path ) } )" )
return lines
2026-05-22 22:25:12 +08:00
def run (
self ,
name : str ,
args : list [ str ] | None = None ,
* ,
json_output : bool = False ,
working_dir : str | None = None ,
timeout : int | None = None ,
restrict_to_workspace : bool = False ,
) -> str :
app = self . get_app ( name )
installed = self . _load_installed ()
if str ( app [ "name" ]) not in installed :
raise CliAppError ( f "CLI app ' { name } ' is not installed" )
cwd = self . _resolve_cwd ( working_dir , restrict_to_workspace = restrict_to_workspace )
entry = str ( installed [ str ( app [ "name" ])] . get ( "entry_point" ) or app . get ( "entry_point" ) or "" )
resolved = shutil . which ( entry )
if not entry or not resolved :
raise CliAppError ( f " { entry or name } is not available on PATH" )
clean_args = [ str ( arg ) for arg in ( args or [])]
if json_output and "--json" not in clean_args :
clean_args = [ "--json" , * clean_args ]
effective_timeout = max ( 1 , min ( timeout or self . runtime . run_timeout , 600 ))
2026-05-23 01:39:46 +08:00
artifact_snapshot = self . _artifact_snapshot ( cwd )
2026-05-22 22:25:12 +08:00
try :
result = subprocess . run (
[ resolved , * clean_args ],
cwd = str ( cwd ),
capture_output = True ,
text = True ,
2026-07-18 19:32:10 +08:00
encoding = "utf-8" ,
errors = "replace" ,
2026-05-22 22:25:12 +08:00
timeout = effective_timeout ,
2026-08-06 19:53:09 +08:00
env = self . _subprocess_env (),
2026-05-22 22:25:12 +08:00
)
except subprocess . TimeoutExpired :
return f "CLI app ' { name } ' timed out after { effective_timeout } s"
output = [
f "CLI app ' { name } ' exited { result . returncode } ." ,
f "Command: { entry } { ' ' . join ( shlex . quote ( arg ) for arg in clean_args ) } " . rstrip (),
]
if result . stdout :
output . append ( " \n STDOUT: \n " + result . stdout . rstrip ())
if result . stderr :
output . append ( " \n STDERR: \n " + result . stderr . rstrip ())
2026-05-23 01:39:46 +08:00
artifacts = self . _changed_artifacts ( cwd , artifact_snapshot )
if artifacts :
output . append (
" \n Artifacts created or updated: \n "
+ " \n " . join ( self . _format_artifact_lines ( cwd , artifacts ))
)
if any ( path . suffix . lower () in _INLINE_ARTIFACT_EXTENSIONS for path in artifacts ):
output . append (
" \n To show a preview in WebUI, reference a raster artifact with Markdown "
"using its workspace-relative path, for example ``."
)
2026-05-22 22:25:12 +08:00
return _truncate ( " \n " . join ( output ))