- A skill the configured registry does not know is looked up on the default
hub, without the registry's key
- Installers may record a display_description shown in place of SKILL.md's;
the agent still reads SKILL.md
- New skills are stamped with installed_at
Co-authored-by: cowagent <cow@cowagent.ai>
Add skill_hub_url and skill_hub_api_key. When set, bare-name installs,
skill search and the remote listing go to that registry, with the key
sent as a bearer token to it only; provider installs and the default
setup are unchanged. Display fields returned by the registry, or passed
as install meta from the console, are recorded in skills_config.json.
Sensitive values are masked when remote config is logged.
Co-authored-by: cowagent <cow@cowagent.ai>
The Linux branch hard-coded ["sudo", "apt-get", ...] for the CJK font, so on a
system without a `sudo` binary (e.g. the official Docker image, where we are
already root) the call raised an unhandled FileNotFoundError that aborted the
whole installer before Chromium was downloaded.
Add an _apt_install helper that only escalates when not root and sudo is
actually available, and returns non-zero otherwise so the caller falls back to
its existing "install it manually" hint. Also drop an unused `as e` binding so
the file passes the repo's changed-files F-class ruff gate. Covered by
tests/test_cli_install_browser_sudo.py.
Windows Notepad / PowerShell write UTF-8 with a BOM by default, and the JSON /
txt files CowAgent asks users to edit by hand are exactly the ones that get
re-saved that way. Plain `utf-8` turns the leading BOM into a `\ufeff`
character, and what happens next depends on the reader:
- `plugins/*/config.json` (godcmd's is created on first run for the operator
to fill in): `json.load` raises "Unexpected UTF-8 BOM", the plugin's
`__init__` fails, and `PluginManager.activate_plugins` answers a failed init
by disabling the plugin and **persisting** `enabled=false` -- so it stayed
off across restarts even after the file was put right.
- `banwords.txt`: the first line is read as `\ufeffword`, `str.strip()` does
not remove U+FEFF (it is not whitespace), so the word is registered with the
BOM glued to it and can never match. No error -- the first word of the list
is silently unfiltered.
- `skills_config.json`: the read helper swallows the error and answers `{}`,
and `cow skill list` rebuilds the file assuming `enabled: True` -- a skill
the user had turned off comes back on, silently.
- `config.json` in `cow backup`: same swallow, so every caller treats the
install as unconfigured and `cow backup` archives `~/cow` instead of the
configured `agent_workspace`. `restore` hits the same read on the copy inside
the archive, where it is not caught and aborts the restore.
- `mcp.json`: `_read_mcp_file` wraps it as `McpConfigError("mcp.json is not
valid JSON: ...")`, which names the JSON rather than the BOM, so the file
looks fine in an editor.
Apply the convention `agent/admin.py` already documents -- "utf-8-sig tolerates
a UTF-8 BOM (e.g. config.json edited with Windows Notepad / PowerShell)" -- at
every one of those read sites, replacing what were five separate PRs (#3419,
#3421, #3422, #3423) with one change.
`Plugin.load_config` also restores the `if os.path.exists(plugin_config_path)`
check inside the `if not plugin_conf` block that assigns `plugin_config_path`:
pulled out of it, the check ran on a name that was never assigned whenever
`pconf()` already returned a config, i.e. for every plugin with an entry in
`plugins/config.json`, and `load_config` raised `UnboundLocalError` -- the same
"plugin gets disabled" outcome the change was meant to avoid.
`tests/test_plugin_config_defaults.py` covers that case; it fails on the
previous revision with `UnboundLocalError: cannot access local variable
'plugin_config_path' where it is not associated with a value`.
Three places replayed a log tail with `readlines()` and then sliced the result:
the web logs view (last 200 lines), the chat `cow logs` command (up to 50) and
`cow logs` on the CLI (50). `run.log` is appended to for as long as CowAgent
runs and is never rotated, so all three read every line ever written to display
a handful of them -- and the chat command does it inside the bot process, where
a long-lived instance gets OOM-killed by its own help command.
Add `common.utils.tail_lines`, which walks the file backwards in 8 KiB blocks
and stops once enough lines have been collected, and use it from all three:
- `channel/web/api/logs.py` for the initial replay,
- `plugins/cow_cli/cow_cli.py` for `cow logs` in chat,
- `cli/commands/process.py::_print_last_lines`, imported on use like the CLI's
other project imports (the CLI ships as its own package and only puts the
project root on `sys.path` when it needs something from it).
Measured on a 20000-line / 1.0 MB run.log: the three views went from reading
100% of the file to 1.6%, 0.8% and 0.8%, with no `readlines()` call left.
Also drops the two pre-existing pyflakes findings in `cli/commands/process.py`,
since the CI lint gate requires the files a PR touches to be F-clean: the dead
`flag` assignment in `_kill_pid` (the `/F` flag is appended directly) and the
`f` prefix on a string with no placeholders.
Resolve the import conflicts in channel/web/api/{knowledge,skills}.py:
master moved _multipart_lists into channel.web.core._common, so the local
copy in skills.py is dropped. SkillCreateHandler now reads the form through
_multipart_lists as well, so every attachment survives on newer web.py.
Adjust tests to the merged code: the skill create endpoint's fake input,
the knowledge service fake in test_web_multi_file_upload, and the traversal
message expected from the shared archive extractor.
Co-authored-by: cowagent <cow@cowagent.ai>
_download_repo_zip creates its scratch directory before opening the
archive, and callers only learn the path from the return value, so any
failure after mkdtemp left the downloaded repo.zip behind in the system
temp directory. Clean up on the failure path, the way _clone_repo does.
ClawHub slugs are only unique per publisher, so a bare slug shared by
several publishers returns 409. Accept owner/slug, @owner/slug and skill
page URLs, pass the owner as ownerHandle, skip the slug-only mirror when
an owner is given, and explain the conflict instead of surfacing a raw 409.
Co-authored-by: cowagent <cow@cowagent.ai>
`team.migrate()` keeps the roster beside the workspaces, so `restore` read an
empty `agents` list from config.json and every Agent landed on the layout the
archive implies: the real workspace was left untouched and `team.compact()`
dropped its custom `workspace`, silently relocating the Agent.
Replace the per-module sibling-file-and-rename copies with
common/atomic_write.py, which uses a unique dot-prefixed temp name,
fsyncs, and keeps the target's permissions.
Co-authored-by: cowagent <cow@cowagent.ai>
Take master as the base for the skills view: the Capabilities page with
MCP tools, the add-skill dialog with market install, and staged uploads
that are previewed before they are installed.
Keep from this branch the skill file tree and viewer card, the nested
frontmatter parser (now the shared pages/skills/frontmatter.ts), the
form-based create dialog beside "Add skill", background knowledge
reindexing with the protected-path check, and the trigram probe.
Drop this branch's direct upload install (SkillService.install_upload,
its handler, the upload tab and its strings) in favour of master's
staged flow, so every upload goes through a preview first.
Co-authored-by: cowagent <cow@cowagent.ai>
The sibling is now `.skills_config.json.<pid>.<tid>.tmp` instead of
`skills_config.json.tmp`. agent/evolution/executor.py lists the dot-prefixed
shape in _WATCH_IGNORE_PREFIXES, so the workspace scanner does not read the
in-flight replacement as a real change, and a per-writer name keeps two
overlapping saves from renaming each other's partial file.
The test assertion that named the old temp path would have passed vacuously
after this, so it now checks the directory contents instead.
Problem
-------
skills_config.json is the installed-skill registry: both `cow skill`
(``cli.utils.load_skills_config``) and the runtime SkillManager read it.
All five writers in cli/commands/skill.py rewrote it in place with
``open(..., "w")``, which truncates before the new bytes are there, so a
save that fails halfway -- a full disk, the process being killed, a power
cut -- leaves a half-written document behind.
Reachability
------------
cli.utils.load_skills_config() (cli/utils.py:84) cannot parse that
document and answers ``{}``, so the very next
``_register_installed_skill`` / ``_set_enabled`` / ``uninstall`` /
``_merge_builtin_into_config`` starts from an empty baseline and, when it
saves, drops every other skill's entry -- source, enabled,
display_name -- from the registry. The runtime side reads the same path:
SkillManager._load_skills_config answers None for an unreadable file and
_sync_skills_config then falls back to its own view, so the user's
``enabled: false`` choices are reset as well.
Precedent
---------
agent/skills/manager.py:131 already writes this same file through a
sibling file and documents why: "Written to a temporary file and renamed
into place, so a concurrent reader sees either the old file or the new
one, never a partial write." Its reader docstring goes further -- the
None return exists "so the caller never mistakes it for an empty config
and writes the defaults over the user's choices". The CLI writers are the
copies that picked up neither half of that convention.
Fix
---
Add one module-level ``_write_skills_config`` that serialises into
``skills_config.json.tmp``, fsyncs and os.replace()s it into place, and
route all five call sites through it.
Scope
-----
Only the write path changes: the readers, the merge semantics and the
best-effort error handling at each call site are untouched. The same file
is also written by plugins/cow_cli/cow_cli.py (the inline console); that
entry point is left for a separate change so this diff stays focused on
cli/commands/skill.py.
Testing
-------
tests/test_skills_config_atomic_write.py: 2 failed, 1 passed against the
unfixed source (JSONDecodeError on the truncated ``{\n "half``), 3
passed with the fix. Existing skill suites unchanged (see the PR body).
- Rename the Skills menu to Capabilities, covering built-in tools, MCP tools
and skills; built-in tools collapse to two rows by default.
- MCP editor: form / JSON tabs (accepts pasted mcpServers configs, several at
once), unified transport dropdown, structured tool list in test results,
and a connection check on save with a "save anyway" fallback. Server
status refreshes on its own while servers are still loading.
- Add skill dialog: install from Cow Skill Hub, GitHub or ClawHub, or upload
an archive, SKILL.md or folder. Skills are staged and previewed first and
only installed on confirm; stale previews are cleaned up.
- Parse multipart uploads without collapsing repeated fields on newer web.py.
- Same UI on the desktop app; docs and tests updated.
Co-authored-by: cowagent <cow@cowagent.ai>