From 158510cbbe66ee658a959ff8c8999307bd31cd5f Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 26 Mar 2026 16:49:42 +0800 Subject: [PATCH] feat(cli): imporve cow cli and skill hub integration --- agent/skills/config.py | 41 ++++++++++ agent/skills/formatter.py | 67 +++++++++++++++- agent/skills/frontmatter.py | 1 + agent/skills/loader.py | 1 - agent/skills/manager.py | 126 +++++++++++++++++++++---------- agent/skills/types.py | 1 + channel/web/static/js/console.js | 11 ++- channel/web/web_channel.py | 8 ++ cli/VERSION | 1 + cli/__init__.py | 12 ++- cli/cli.py | 53 ++++++++++++- cli/commands/context.py | 76 +++---------------- cli/commands/process.py | 52 +++++++++---- cli/commands/skill.py | 57 +++++++++----- cli/utils.py | 2 +- skills/linkai-agent/SKILL.md | 1 + 16 files changed, 367 insertions(+), 143 deletions(-) create mode 100644 cli/VERSION diff --git a/agent/skills/config.py b/agent/skills/config.py index 86979c92..788009f9 100644 --- a/agent/skills/config.py +++ b/agent/skills/config.py @@ -139,6 +139,47 @@ def should_include_skill( return True +def get_missing_requirements( + entry: SkillEntry, + current_platform: Optional[str] = None, +) -> Dict[str, List[str]]: + """ + Return a dict of missing requirements for a skill. + Empty dict means all requirements are met. + + :param entry: SkillEntry to check + :param current_platform: Current platform (default: auto-detect) + :return: Dict like {"bins": ["curl"], "env": ["API_KEY"]} + """ + missing: Dict[str, List[str]] = {} + metadata = entry.metadata + + if not metadata or not metadata.requires: + return missing + + required_bins = metadata.requires.get('bins', []) + if required_bins: + missing_bins = [b for b in required_bins if not has_binary(b)] + if missing_bins: + missing['bins'] = missing_bins + + any_bins = metadata.requires.get('anyBins', []) + if any_bins and not has_any_binary(any_bins): + missing['anyBins'] = any_bins + + required_env = metadata.requires.get('env', []) + if required_env: + missing_env = [e for e in required_env if not has_env_var(e)] + if missing_env: + missing['env'] = missing_env + + any_env = metadata.requires.get('anyEnv', []) + if any_env and not any(has_env_var(e) for e in any_env): + missing['anyEnv'] = any_env + + return missing + + def is_config_path_truthy(config: Dict, path: str) -> bool: """ Check if a config path resolves to a truthy value. diff --git a/agent/skills/formatter.py b/agent/skills/formatter.py index 86abf1e4..d1eebe05 100644 --- a/agent/skills/formatter.py +++ b/agent/skills/formatter.py @@ -2,7 +2,7 @@ Skill formatter for generating prompts from skills. """ -from typing import List +from typing import Dict, List from agent.skills.types import Skill, SkillEntry @@ -51,6 +51,71 @@ def format_skill_entries_for_prompt(entries: List[SkillEntry]) -> str: return format_skills_for_prompt(skills) +def format_unavailable_skills_for_prompt( + entries: List[SkillEntry], + missing_map: Dict[str, Dict[str, List[str]]], +) -> str: + """ + Format unavailable (requires-not-met) skills as brief setup hints + so the AI can guide users to configure them. + + :param entries: List of unavailable skill entries + :param missing_map: Dict mapping skill name to its missing requirements + :return: Formatted prompt text + """ + if not entries: + return "" + + lines = [ + "", + "", + "The following skills are installed but not yet ready. " + "Guide the user to complete the setup when relevant.", + ] + + for entry in entries: + skill = entry.skill + missing = missing_map.get(skill.name, {}) + + missing_parts = [] + for key, values in missing.items(): + missing_parts.append(f"{key}: {', '.join(values)}") + missing_str = "; ".join(missing_parts) if missing_parts else "unknown" + + setup_hint = _extract_setup_hint(skill) + + lines.append(" ") + lines.append(f" {_escape_xml(skill.name)}") + lines.append(f" {_escape_xml(skill.description)}") + lines.append(f" {_escape_xml(missing_str)}") + if setup_hint: + lines.append(f" {_escape_xml(setup_hint)}") + lines.append(" ") + + lines.append("") + return "\n".join(lines) + + +def _extract_setup_hint(skill: Skill) -> str: + """ + Extract the Setup section from SKILL.md content as a brief hint. + Returns the first few lines of the ## Setup section. + """ + content = skill.content + if not content: + return "" + + import re + match = re.search(r'^##\s+Setup\s*\n(.*?)(?=\n##\s|\Z)', content, re.MULTILINE | re.DOTALL) + if not match: + return "" + + setup_text = match.group(1).strip() + lines = setup_text.split('\n') + hint_lines = [l.strip() for l in lines[:6] if l.strip()] + return ' '.join(hint_lines)[:300] + + def _escape_xml(text: str) -> str: """Escape XML special characters.""" return (text diff --git a/agent/skills/frontmatter.py b/agent/skills/frontmatter.py index 9905e299..2f29283d 100644 --- a/agent/skills/frontmatter.py +++ b/agent/skills/frontmatter.py @@ -128,6 +128,7 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: return SkillMetadata( always=meta_obj.get('always', False), + default_enabled=meta_obj.get('default_enabled', True), skill_key=meta_obj.get('skillKey'), primary_env=meta_obj.get('primaryEnv'), emoji=meta_obj.get('emoji'), diff --git a/agent/skills/loader.py b/agent/skills/loader.py index f02346d1..a39dba28 100644 --- a/agent/skills/loader.py +++ b/agent/skills/loader.py @@ -184,7 +184,6 @@ class SkillLoader: config_path = os.path.join(skill_dir, "config.json") - # Without config.json, skip this skill entirely (return empty to trigger exclusion) if not os.path.exists(config_path): logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found") return "" diff --git a/agent/skills/manager.py b/agent/skills/manager.py index a70daaea..99f5e643 100644 --- a/agent/skills/manager.py +++ b/agent/skills/manager.py @@ -84,10 +84,10 @@ class SkillManager: """ Merge directory-scanned skills with the persisted config file. - - New skills discovered on disk are added with enabled=True. + - New skills: use metadata.default_enabled as initial enabled state. + - Existing skills: preserve their persisted enabled state. - Skills that no longer exist on disk are removed. - - Existing entries preserve their enabled state; name/description/source - are refreshed from the latest scan. + - name/description/source are always refreshed from the latest scan. """ saved = self._load_skills_config() merged: Dict[str, dict] = {} @@ -95,13 +95,18 @@ class SkillManager: for name, entry in self.skills.items(): skill = entry.skill prev = saved.get(name, {}) - # category priority: persisted config (set by cloud) > default "skill" category = prev.get("category", "skill") + + if name in saved: + enabled = prev.get("enabled", True) + else: + enabled = entry.metadata.default_enabled if entry.metadata else True + merged[name] = { "name": name, "description": skill.description, "source": skill.source, - "enabled": prev.get("enabled", True), + "enabled": enabled, "category": category, } @@ -157,69 +162,114 @@ class SkillManager: """ return list(self.skills.values()) + @staticmethod + def _normalize_skill_filter(skill_filter: Optional[List[str]]) -> Optional[List[str]]: + """Normalize a skill_filter list into a flat list of stripped names.""" + if skill_filter is None: + return None + normalized = [] + for item in skill_filter: + if isinstance(item, str): + name = item.strip() + if name: + normalized.append(name) + elif isinstance(item, list): + for subitem in item: + if isinstance(subitem, str): + name = subitem.strip() + if name: + normalized.append(name) + return normalized or None + def filter_skills( self, skill_filter: Optional[List[str]] = None, include_disabled: bool = False, ) -> List[SkillEntry]: """ - Filter skills based on criteria. - - Simple rule: Skills are auto-enabled if requirements are met. - - Has required API keys -> included - - Missing API keys -> excluded + Filter skills that are eligible (enabled + requirements met). :param skill_filter: List of skill names to include (None = all) :param include_disabled: Whether to include disabled skills - :return: Filtered list of skill entries + :return: Filtered list of eligible skill entries """ from agent.skills.config import should_include_skill entries = list(self.skills.values()) - # Check requirements (platform, binaries, env vars) entries = [e for e in entries if should_include_skill(e, self.config)] - # Apply skill filter - if skill_filter is not None: - normalized = [] - for item in skill_filter: - if isinstance(item, str): - name = item.strip() - if name: - normalized.append(name) - elif isinstance(item, list): - for subitem in item: - if isinstance(subitem, str): - name = subitem.strip() - if name: - normalized.append(name) - if normalized: - entries = [e for e in entries if e.skill.name in normalized] + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] - # Filter out disabled skills based on skills_config.json if not include_disabled: entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] return entries - + + def filter_unavailable_skills( + self, + skill_filter: Optional[List[str]] = None, + ) -> tuple: + """ + Find skills that are enabled but have unmet requirements. + + :param skill_filter: Optional list of skill names to include + :return: Tuple of (entries, missing_map) where missing_map maps + skill name to its missing requirements dict + """ + from agent.skills.config import should_include_skill, get_missing_requirements + + entries = list(self.skills.values()) + + # Only enabled skills + entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] + + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] + + # Keep only those that fail should_include_skill (requirements not met) + unavailable = [] + missing_map: Dict[str, dict] = {} + for e in entries: + if not should_include_skill(e, self.config): + missing = get_missing_requirements(e) + if missing: + unavailable.append(e) + missing_map[e.skill.name] = missing + + return unavailable, missing_map + def build_skills_prompt( self, skill_filter: Optional[List[str]] = None, ) -> str: """ - Build a formatted prompt containing available skills. - + Build a formatted prompt containing available skills + and brief hints for unavailable ones. + :param skill_filter: Optional list of skill names to include :return: Formatted skills prompt """ from common.log import logger - entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False) - logger.debug(f"[SkillManager] Filtered {len(entries)} skills for prompt (total: {len(self.skills)})") - if entries: - skill_names = [e.skill.name for e in entries] - logger.debug(f"[SkillManager] Skills to include: {skill_names}") - result = format_skill_entries_for_prompt(entries) + from agent.skills.formatter import format_unavailable_skills_for_prompt + + eligible = self.filter_skills(skill_filter=skill_filter, include_disabled=False) + logger.debug(f"[SkillManager] Eligible: {len(eligible)} skills (total: {len(self.skills)})") + if eligible: + skill_names = [e.skill.name for e in eligible] + logger.debug(f"[SkillManager] Eligible skills: {skill_names}") + + result = format_skill_entries_for_prompt(eligible) + + unavailable, missing_map = self.filter_unavailable_skills(skill_filter=skill_filter) + if unavailable: + unavailable_names = [e.skill.name for e in unavailable] + logger.debug(f"[SkillManager] Unavailable skills (setup needed): {unavailable_names}") + result += format_unavailable_skills_for_prompt(unavailable, missing_map) + logger.debug(f"[SkillManager] Generated prompt length: {len(result)}") return result diff --git a/agent/skills/types.py b/agent/skills/types.py index 1b27479b..a6a467e5 100644 --- a/agent/skills/types.py +++ b/agent/skills/types.py @@ -29,6 +29,7 @@ class SkillInstallSpec: class SkillMetadata: """Metadata for a skill from frontmatter.""" always: bool = False # Always include this skill + default_enabled: bool = True # Initial enabled state when first discovered skill_key: Optional[str] = None # Override skill key primary_env: Optional[str] = None # Primary environment variable emoji: Optional[str] = None diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index b5070c28..bc45cb18 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -3,9 +3,9 @@ ===================================================================== */ // ===================================================================== -// Version — update this before each release +// Version — fetched from backend (single source: /VERSION file) // ===================================================================== -const APP_VERSION = 'v2.0.4'; +let APP_VERSION = ''; // ===================================================================== // i18n @@ -2236,7 +2236,12 @@ navigateTo = function(viewId) { // ===================================================================== applyTheme(); applyI18n(); -document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; +fetch('/api/version').then(r => r.json()).then(data => { + APP_VERSION = `v${data.version}`; + document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; +}).catch(() => { + document.getElementById('sidebar-version').textContent = 'CowAgent'; +}); chatInput.focus(); // Re-enable color transition AFTER first paint so the theme applied in diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 2979cd9e..0561961d 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -390,6 +390,7 @@ class WebChannel(ChatChannel): '/api/scheduler', 'SchedulerHandler', '/api/history', 'HistoryHandler', '/api/logs', 'LogsHandler', + '/api/version', 'VersionHandler', '/assets/(.*)', 'AssetsHandler', ) app = web.application(urls, globals(), autoreload=False) @@ -1429,3 +1430,10 @@ class AssetsHandler: except Exception as e: logger.error(f"Error serving static file: {e}", exc_info=True) # 添加更详细的错误信息 raise web.notfound() + + +class VersionHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + from cli import __version__ + return json.dumps({"version": __version__}) diff --git a/cli/VERSION b/cli/VERSION new file mode 100644 index 00000000..2165f8f9 --- /dev/null +++ b/cli/VERSION @@ -0,0 +1 @@ +2.0.4 diff --git a/cli/__init__.py b/cli/__init__.py index 2a3b711e..f3bda36f 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -1,3 +1,13 @@ """CowAgent CLI - Manage your CowAgent from the command line.""" -__version__ = "0.0.1" +import os as _os + +def _read_version(): + version_file = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "VERSION") + try: + with open(version_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + return "0.0.0" + +__version__ = _read_version() diff --git a/cli/cli.py b/cli/cli.py index 7ea983a5..d6f1349a 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -7,11 +7,56 @@ from cli.commands.process import start, stop, restart, status, logs from cli.commands.context import context -@click.group() -@click.version_option(__version__, '--version', '-v', prog_name='cow') -def main(): +HELP_TEXT = """Usage: cow COMMAND [ARGS]... + + CowAgent CLI - Manage your CowAgent instance. + +Commands: + help Show this message. + version Show the version. + start Start CowAgent. + stop Stop CowAgent. + restart Restart CowAgent. + status Show CowAgent running status. + logs View CowAgent logs. + context View or manage conversation context. + skill Manage CowAgent skills. + +Tip: You can also send /help, /skill list, etc. in chat.""" + + +class CowCLI(click.Group): + + def format_help(self, ctx, formatter): + formatter.write(HELP_TEXT.strip()) + formatter.write("\n") + + def parse_args(self, ctx, args): + if args and args[0] == 'help': + click.echo(HELP_TEXT.strip()) + ctx.exit(0) + return super().parse_args(ctx, args) + + +@click.group(cls=CowCLI, invoke_without_command=True, context_settings=dict(help_option_names=[])) +@click.pass_context +def main(ctx): """CowAgent CLI - Manage your CowAgent instance.""" - pass + if ctx.invoked_subcommand is None: + click.echo(HELP_TEXT.strip()) + + +@main.command() +def version(): + """Show the version.""" + click.echo(f"cow {__version__}") + + +@main.command(name='help') +@click.pass_context +def help_cmd(ctx): + """Show this message.""" + click.echo(HELP_TEXT.strip()) main.add_command(skill) diff --git a/cli/commands/context.py b/cli/commands/context.py index 41532f2e..38864585 100644 --- a/cli/commands/context.py +++ b/cli/commands/context.py @@ -1,13 +1,14 @@ """cow context - Context management commands.""" -import os -import sys -import json -import glob as glob_mod - import click -from cli.utils import get_workspace_dir + +CHAT_HINT = ( + "Context commands operate on the running agent's memory.\n" + "Please send the command in a chat conversation instead:\n\n" + " /context - View current context info\n" + " /context clear - Clear conversation context" +) @click.group(invoke_without_command=True) @@ -15,67 +16,14 @@ from cli.utils import get_workspace_dir def context(ctx): """View or manage conversation context. - Without a subcommand, shows context info for the current workspace. + Context commands need access to the running agent's memory. + Use them in chat conversations: /context or /context clear """ if ctx.invoked_subcommand is None: - _show_context_info() + click.echo(f"\n {CHAT_HINT}\n") @context.command() -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") -def clear(yes): +def clear(): """Clear conversation context (messages history).""" - workspace = get_workspace_dir() - sessions_dir = os.path.join(workspace, "sessions") - - if not os.path.isdir(sessions_dir): - click.echo("No conversation data found.") - return - - db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db")) - if not db_files: - click.echo("No conversation data found.") - return - - if not yes: - click.confirm("Clear all conversation context? This cannot be undone.", abort=True) - - removed = 0 - for db_file in db_files: - try: - os.remove(db_file) - removed += 1 - except Exception as e: - click.echo(f"Warning: Failed to remove {db_file}: {e}", err=True) - - click.echo(click.style(f"✓ Cleared {removed} conversation database(s).", fg="green")) - - -def _show_context_info(): - """Display conversation context status.""" - workspace = get_workspace_dir() - sessions_dir = os.path.join(workspace, "sessions") - - click.echo(f"\n Context info") - click.echo(f" Workspace: {workspace}") - - if not os.path.isdir(sessions_dir): - click.echo(" Sessions: none\n") - return - - db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db")) - total_size = sum(os.path.getsize(f) for f in db_files if os.path.exists(f)) - - click.echo(f" Sessions dir: {sessions_dir}") - click.echo(f" Database files: {len(db_files)}") - click.echo(f" Total size: {_format_size(total_size)}") - click.echo(f"\n Use 'cow context clear' to reset.\n") - - -def _format_size(size_bytes): - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" + click.echo(f"\n {CHAT_HINT}\n") diff --git a/cli/commands/process.py b/cli/commands/process.py index 03dd7f20..01748bef 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -47,7 +47,8 @@ def _remove_pid(): @click.command() @click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)") -def start(foreground): +@click.option("--no-logs", is_flag=True, help="Don't tail logs after starting") +def start(foreground, no_logs): """Start CowAgent.""" pid = _read_pid() if pid: @@ -81,6 +82,10 @@ def start(foreground): click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green")) click.echo(f" Logs: {log_file}") + if not no_logs: + click.echo(" Press Ctrl+C to stop tailing logs.\n") + _tail_log(log_file) + @click.command() def stop(): @@ -109,23 +114,39 @@ def stop(): @click.command() +@click.option("--no-logs", is_flag=True, help="Don't tail logs after restarting") @click.pass_context -def restart(ctx): +def restart(ctx, no_logs): """Restart CowAgent.""" ctx.invoke(stop) time.sleep(1) - ctx.invoke(start) + ctx.invoke(start, no_logs=no_logs) @click.command() def status(): """Show CowAgent running status.""" + from cli import __version__ + from cli.utils import load_config_json + pid = _read_pid() if pid: click.echo(click.style(f"● CowAgent is running (PID: {pid})", fg="green")) else: click.echo(click.style("● CowAgent is not running", fg="red")) + click.echo(f" 版本: v{__version__}") + + cfg = load_config_json() + if cfg: + channel = cfg.get("channel_type", "unknown") + if isinstance(channel, list): + channel = ", ".join(channel) + click.echo(f" 通道: {channel}") + click.echo(f" 模型: {cfg.get('model', 'unknown')}") + mode = "Agent" if cfg.get("agent") else "Chat" + click.echo(f" 模式: {mode}") + @click.command() @click.option("--follow", "-f", is_flag=True, help="Follow log output") @@ -138,18 +159,23 @@ def logs(follow, lines): return if follow: - try: - proc = subprocess.Popen( - ["tail", "-f", "-n", str(lines), log_file], - stdout=sys.stdout, - stderr=sys.stderr, - ) - proc.wait() - except KeyboardInterrupt: - pass + _tail_log(log_file, lines) else: - proc = subprocess.run( + subprocess.run( ["tail", "-n", str(lines), log_file], stdout=sys.stdout, stderr=sys.stderr, ) + + +def _tail_log(log_file: str, lines: int = 50): + """Follow log file output. Blocks until Ctrl+C.""" + try: + proc = subprocess.Popen( + ["tail", "-f", "-n", str(lines), log_file], + stdout=sys.stdout, + stderr=sys.stderr, + ) + proc.wait() + except KeyboardInterrupt: + pass diff --git a/cli/commands/skill.py b/cli/commands/skill.py index 1d622274..c4378e2d 100644 --- a/cli/commands/skill.py +++ b/cli/commands/skill.py @@ -29,11 +29,12 @@ def skill(): # cow skill list # ------------------------------------------------------------------ @skill.command("list") -@click.option("--remote", is_flag=True, help="List skills available on Skill Hub") -def skill_list(remote): - """List installed skills or browse remote Skill Hub.""" +@click.option("--remote", is_flag=True, help="Browse skills on Skill Hub") +@click.option("--page", default=1, type=int, help="Page number for remote listing") +def skill_list(remote, page): + """List installed skills or browse Skill Hub.""" if remote: - _list_remote() + _list_remote(page=page) else: _list_local() @@ -95,10 +96,17 @@ def _print_skill_table(entries): click.echo() -def _list_remote(): - """List skills from remote Skill Hub.""" +_REMOTE_PAGE_SIZE = 10 + + +def _list_remote(page: int = 1): + """List skills from remote Skill Hub with server-side pagination.""" try: - resp = requests.get(f"{SKILL_HUB_API}/skills", timeout=10) + resp = requests.get( + f"{SKILL_HUB_API}/skills", + params={"page": page, "limit": _REMOTE_PAGE_SIZE}, + timeout=10, + ) resp.raise_for_status() data = resp.json() except Exception as e: @@ -106,26 +114,40 @@ def _list_remote(): sys.exit(1) skills = data.get("skills", []) - if not skills: + total = data.get("total", len(skills)) + + if not skills and page == 1: click.echo("No skills available on Skill Hub.") return - name_w = max(len(s.get("name", "")) for s in skills) + total_pages = max(1, (total + _REMOTE_PAGE_SIZE - 1) // _REMOTE_PAGE_SIZE) + page = min(page, total_pages) + installed = set(load_skills_config().keys()) + + name_w = max((len(s.get("name", "")) for s in skills), default=4) name_w = max(name_w, 4) + 2 - click.echo(f"\n Skill Hub ({len(skills)} available)\n") - click.echo(f" {'Name':<{name_w}} {'Downloads':<12} {'Description'}") + click.echo(f"\n Skill Hub ({total} available) — page {page}/{total_pages}\n") + click.echo(f" {'Name':<{name_w}} {'Status':<12} {'Description'}") click.echo(f" {'─' * (name_w + 12 + 50)}") for s in skills: name = s.get("name", "") - downloads = s.get("downloads", 0) desc = s.get("description", "") or s.get("display_name", "") if len(desc) > 50: desc = desc[:47] + "..." - click.echo(f" {name:<{name_w}} {downloads:<12} {desc}") + status = click.style("installed", fg="green") if name in installed else "—" + click.echo(f" {name:<{name_w}} {status:<12} {desc}") - click.echo(f"\n Install with: cow skill install \n") + click.echo() + nav_parts = [] + if page > 1: + nav_parts.append(f"cow skill list --remote --page {page - 1}") + if page < total_pages: + nav_parts.append(f"cow skill list --remote --page {page + 1}") + if nav_parts: + click.echo(f" Navigate: {' | '.join(nav_parts)}") + click.echo(f" Install: cow skill install \n") # ------------------------------------------------------------------ @@ -148,20 +170,21 @@ def search(query): click.echo(f'No skills found for "{query}".') return + installed = set(load_skills_config().keys()) name_w = max(len(s.get("name", "")) for s in skills) name_w = max(name_w, 4) + 2 click.echo(f'\n Search results for "{query}" ({len(skills)} found)\n') - click.echo(f" {'Name':<{name_w}} {'Downloads':<12} {'Description'}") + click.echo(f" {'Name':<{name_w}} {'Status':<12} {'Description'}") click.echo(f" {'─' * (name_w + 12 + 50)}") for s in skills: name = s.get("name", "") - downloads = s.get("downloads", 0) desc = s.get("description", "") or s.get("display_name", "") if len(desc) > 50: desc = desc[:47] + "..." - click.echo(f" {name:<{name_w}} {downloads:<12} {desc}") + status = click.style("installed", fg="green") if name in installed else "—" + click.echo(f" {name:<{name_w}} {status:<12} {desc}") click.echo(f"\n Install with: cow skill install \n") diff --git a/cli/utils.py b/cli/utils.py index 8dc229dd..b40f8dd5 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -59,4 +59,4 @@ def ensure_sys_path(): sys.path.insert(0, root) -SKILL_HUB_API = "https://cow-skill-hub.pages.dev/api" +SKILL_HUB_API = "https://skills.cowagent.ai/api" diff --git a/skills/linkai-agent/SKILL.md b/skills/linkai-agent/SKILL.md index 34af6799..3464e490 100644 --- a/skills/linkai-agent/SKILL.md +++ b/skills/linkai-agent/SKILL.md @@ -4,6 +4,7 @@ description: Call LinkAI applications and workflows. Use bash with curl to invok homepage: https://link-ai.tech metadata: emoji: 🤖 + default_enabled: false requires: bins: ["curl"] env: ["LINKAI_API_KEY"]