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"]