From fa4b7744f55200f57947cb1a491aa84aeaf597de Mon Sep 17 00:00:00 2001 From: Matt Kornfield Date: Wed, 5 Aug 2026 23:04:50 +0000 Subject: [PATCH 1/4] fix: align NeMo skill catalog install contracts NVBugs: 6556546, 6556561, 6556564, 6556565 Signed-off-by: Matt Kornfield --- .../cli/commands/skills/agents/claude.py | 12 +++++--- .../cli/commands/skills/agents/codex.py | 12 +++++--- .../cli/commands/skills/agents/cursor.py | 4 +-- .../cli/commands/skills/agents/opencode.py | 7 +++-- .../cli/commands/skills/base.py | 12 +++++++- .../cli/commands/skills/cli.py | 1 + .../cli/commands/skills/registry.py | 8 ++++++ .../nemo_platform_ext/cli/core/formatters.py | 11 ++++---- .../skills/inference/SKILL.md | 23 +++++++++------ .../skills/nemo-agent-config/SKILL.md | 3 ++ .../skills/nemo-build-agent/SKILL.md | 6 ++++ .../skills/nemo-evaluator/SKILL.md | 2 ++ .../skills/nemo-experiments-upload/SKILL.md | 4 +++ .../skills/nemo-explore/SKILL.md | 2 ++ .../skills/nemo-files/SKILL.md | 3 ++ .../skills/nemo-guardrails/SKILL.md | 5 ++++ .../skills/nemo-intake/SKILL.md | 3 ++ .../skills/nemo-model-selection/SKILL.md | 3 ++ .../skills/nemo-secrets/SKILL.md | 3 ++ .../skills/nemo-skill-selection/SKILL.md | 2 ++ .../skills/nemo-spec/SKILL.md | 4 +++ .../skills/nemo-status/SKILL.md | 2 ++ .../skills/nemo-teardown/SKILL.md | 3 ++ .../skills/nemo-try-agent/SKILL.md | 6 ++++ .../cli/commands/skills/agents/test_claude.py | 27 +++++++++++++++++- .../cli/commands/skills/agents/test_codex.py | 27 +++++++++++++++++- .../cli/commands/skills/agents/test_cursor.py | 6 ++++ .../commands/skills/agents/test_opencode.py | 6 ++++ .../tests/cli/commands/skills/test_base.py | 7 ++++- .../tests/cli/commands/skills/test_cli.py | 13 ++++++++- .../cli/commands/skills/test_skill_content.py | 28 +++++++++++++++++++ .../cli/commands/skills/agents/claude.py | 12 +++++--- .../cli/commands/skills/agents/codex.py | 12 +++++--- .../cli/commands/skills/agents/cursor.py | 4 +-- .../cli/commands/skills/agents/opencode.py | 7 +++-- .../nemo_platform/cli/commands/skills/base.py | 12 +++++++- .../nemo_platform/cli/commands/skills/cli.py | 1 + .../cli/commands/skills/registry.py | 8 ++++++ .../src/nemo_platform/cli/core/formatters.py | 11 ++++---- .../nemo_platform/skills/inference/SKILL.md | 23 +++++++++------ .../skills/nemo-agent-config/SKILL.md | 3 ++ .../skills/nemo-build-agent/SKILL.md | 6 ++++ .../skills/nemo-evaluator/SKILL.md | 2 ++ .../skills/nemo-experiments-upload/SKILL.md | 4 +++ .../skills/nemo-explore/SKILL.md | 2 ++ .../nemo_platform/skills/nemo-files/SKILL.md | 3 ++ .../skills/nemo-guardrails/SKILL.md | 5 ++++ .../nemo_platform/skills/nemo-intake/SKILL.md | 3 ++ .../skills/nemo-model-selection/SKILL.md | 3 ++ .../skills/nemo-secrets/SKILL.md | 3 ++ .../skills/nemo-skill-selection/SKILL.md | 2 ++ .../nemo_platform/skills/nemo-spec/SKILL.md | 4 +++ .../nemo_platform/skills/nemo-status/SKILL.md | 2 ++ .../skills/nemo-teardown/SKILL.md | 3 ++ .../skills/nemo-try-agent/SKILL.md | 6 ++++ .../cli/commands/skills/agents/test_claude.py | 27 +++++++++++++++++- .../cli/commands/skills/agents/test_codex.py | 27 +++++++++++++++++- .../cli/commands/skills/agents/test_cursor.py | 6 ++++ .../commands/skills/agents/test_opencode.py | 6 ++++ .../cli/commands/skills/test_base.py | 7 ++++- .../cli/commands/skills/test_cli.py | 13 ++++++++- .../cli/commands/skills/test_skill_content.py | 28 +++++++++++++++++++ 62 files changed, 446 insertions(+), 64 deletions(-) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/claude.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/claude.py index d4f3d5f9d2..1fb539d694 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/claude.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/claude.py @@ -6,7 +6,7 @@ from pathlib import Path import yaml -from nemo_platform_ext.cli.commands.skills.base import Scope, Skill +from nemo_platform_ext.cli.commands.skills.base import Scope, Skill, installed_skill_name from nemo_platform_ext.cli.commands.skills.installer import BaseAgentInstaller @@ -16,13 +16,17 @@ class ClaudeInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT, Scope.USER] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + target_name = installed_skill_name(skill_name) if scope == Scope.PROJECT: - return project_root / ".claude" / "skills" / f"nemo-{skill_name}" / "SKILL.md" - return Path.home() / ".claude" / "skills" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".claude" / "skills" / target_name / "SKILL.md" + return Path.home() / ".claude" / "skills" / target_name / "SKILL.md" def format_content(self, skill: Skill) -> str: + metadata: dict[str, object] = {"name": installed_skill_name(skill.name), "description": skill.description} + if skill.preconditions: + metadata["preconditions"] = skill.preconditions front_matter = yaml.safe_dump( - {"name": f"nemo-{skill.name}", "description": skill.description}, + metadata, sort_keys=False, allow_unicode=True, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/codex.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/codex.py index a016fd4084..317a38d537 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/codex.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/codex.py @@ -6,7 +6,7 @@ from pathlib import Path import yaml -from nemo_platform_ext.cli.commands.skills.base import Scope, Skill +from nemo_platform_ext.cli.commands.skills.base import Scope, Skill, installed_skill_name from nemo_platform_ext.cli.commands.skills.installer import BaseAgentInstaller @@ -16,16 +16,20 @@ class CodexInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT, Scope.USER] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + target_name = installed_skill_name(skill_name) # Codex discovers skills under `.agents/skills/` (see openai/codex # `codex-rs/core-skills/src/loader.rs`). The older `.codex/skills/` # layout has been deprecated. if scope == Scope.PROJECT: - return project_root / ".agents" / "skills" / f"nemo-{skill_name}" / "SKILL.md" - return Path.home() / ".agents" / "skills" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".agents" / "skills" / target_name / "SKILL.md" + return Path.home() / ".agents" / "skills" / target_name / "SKILL.md" def format_content(self, skill: Skill) -> str: + metadata: dict[str, object] = {"name": installed_skill_name(skill.name), "description": skill.description} + if skill.preconditions: + metadata["preconditions"] = skill.preconditions front_matter = yaml.safe_dump( - {"name": f"nemo-{skill.name}", "description": skill.description}, + metadata, sort_keys=False, allow_unicode=True, ) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/cursor.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/cursor.py index b54f26b5a3..80e7d737f8 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/cursor.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/cursor.py @@ -5,7 +5,7 @@ from pathlib import Path -from nemo_platform_ext.cli.commands.skills.base import Scope +from nemo_platform_ext.cli.commands.skills.base import Scope, installed_skill_name from nemo_platform_ext.cli.commands.skills.installer import BaseAgentInstaller @@ -15,4 +15,4 @@ class CursorInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: - return project_root / ".cursor" / "rules" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".cursor" / "rules" / installed_skill_name(skill_name) / "SKILL.md" diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/opencode.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/opencode.py index bfa9f3562c..dc84834b7b 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/opencode.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/agents/opencode.py @@ -5,7 +5,7 @@ from pathlib import Path -from nemo_platform_ext.cli.commands.skills.base import Scope +from nemo_platform_ext.cli.commands.skills.base import Scope, installed_skill_name from nemo_platform_ext.cli.commands.skills.installer import BaseAgentInstaller @@ -15,6 +15,7 @@ class OpenCodeInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT, Scope.USER] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + target_name = installed_skill_name(skill_name) if scope == Scope.PROJECT: - return project_root / ".opencode" / "commands" / f"nemo-{skill_name}" / "SKILL.md" - return Path.home() / ".opencode" / "commands" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".opencode" / "commands" / target_name / "SKILL.md" + return Path.home() / ".opencode" / "commands" / target_name / "SKILL.md" diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py index 2d4fe90e37..fd08ef9b86 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py @@ -3,11 +3,20 @@ """Base types and protocol for agent skill installers.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Protocol +INSTALLED_SKILL_PREFIX = "nemo-" + + +def installed_skill_name(skill_name: str) -> str: + """Return the skill name exposed to downstream coding agents.""" + if skill_name.startswith(INSTALLED_SKILL_PREFIX): + return skill_name + return f"{INSTALLED_SKILL_PREFIX}{skill_name}" + @dataclass class Skill: @@ -16,6 +25,7 @@ class Skill: version: str content: str raw: str + preconditions: list[str] = field(default_factory=list) source_dir: Path | None = None # Entry-point name under ``nemo.skills`` (e.g. ``"agents"``, ``"platform"``). # Useful for programmatic filtering; the human-friendly label is built from diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/cli.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/cli.py index 169ca5a2c6..58046e7203 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/cli.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/cli.py @@ -192,6 +192,7 @@ def list_skills( "name": skill.name, "version": skill.version, "description": skill.description, + "preconditions": skill.preconditions, # `source` is the human-friendly column shown in `list` output: # the distribution name that registered the skill's entry point, # collapsed to `nemo-platform` for the platform's own packages. diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py index 17b02fa09e..1f21e427c6 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py @@ -107,12 +107,20 @@ def _load_skill(entry: Path, source_plugin: str | None = None, source_dist: str raise ValueError(f"Invalid frontmatter in {skill_file}: {e}") from e if not isinstance(metadata, dict): raise ValueError(f"Invalid frontmatter in {skill_file}: expected a mapping, got {type(metadata).__name__}") + preconditions = metadata.get("preconditions", []) + if preconditions is None: + preconditions = [] + if isinstance(preconditions, str): + preconditions = [preconditions] + if not isinstance(preconditions, list) or not all(isinstance(item, str) for item in preconditions): + raise ValueError(f"Invalid frontmatter in {skill_file}: preconditions must be a list of strings") return Skill( name=metadata.get("name", entry.name), description=metadata.get("description", ""), version=str(metadata.get("version", "0.1")), content=body, raw=raw, + preconditions=preconditions, source_dir=entry, source_plugin=source_plugin, source_dist=source_dist, diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py index 31360500dd..2b7cf70a5c 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py @@ -12,7 +12,7 @@ import sys from collections.abc import Iterator from dataclasses import dataclass -from typing import Any +from typing import Any, Literal import click import yaml @@ -598,7 +598,7 @@ def format_output( *, is_list: bool = False, output_format: str | None = None, - output_columns: str | list[Column] | None = None, + output_columns: Literal["all"] | str | list[Column] | None = None, indent: int = 2, no_truncate: bool | None = None, timestamp_format: str | None = None, @@ -662,6 +662,7 @@ def format_output( # Determine truncate setting (inverse of no_truncate) truncate = not no_truncate + timestamp_format = timestamp_format or "iso" # The "use --no-truncate to see full values" hint only makes sense when # the table actually clips with "..."; in wrap mode nothing is hidden. @@ -692,9 +693,9 @@ def format_output( output = format_yaml(data, syntax_highlight=True, background=False) print(output) elif output_format == "table": - assert isinstance(output_columns, list) # Table format. When wrapping is on and --no-truncate is set, drop the # per-column cap so wrapping uses the full terminal width. + assert isinstance(output_columns, list) effective_wrap_max_width = None if (wrap and not truncate) else wrap_max_width output = format_table( data, @@ -706,15 +707,15 @@ def format_output( ) print(output) elif output_format == "markdown": - assert isinstance(output_columns, list) # Markdown table format + assert isinstance(output_columns, list) output = format_markdown_table( data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format ) print(output) elif output_format == "csv": - assert isinstance(output_columns, list) # CSV format + assert isinstance(output_columns, list) output = format_csv(data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format) print(output, end="") # CSV already includes newlines elif output_format == "raw": diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md index 00a3d4ba03..ff058179a0 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md @@ -12,6 +12,11 @@ description: > or debugging routing and translation failures locally. For platform startup, Switchyard install, and DB-reset prerequisites, see the setup playbook (`SETUP.md` at the repo root). +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - secrets_configured user-invocable: true allowed-tools: Bash, Read, Grep --- @@ -75,7 +80,7 @@ For platform startup (`nemo services run`), Switchyard install, and state reset, - **`nemo secrets create`** uses `--from-file` (pipe key in). No `--value` flag. - **`nemo inference providers create`** takes `` as a **positional** arg. Same for `update-status`, `get`, `delete`. -- **`nemo virtual-models create`** is a **top-level** command (not under `nemo inference`) and takes `` as positional. +- **`nemo inference virtual-models create`** takes `` as positional. - **There is no `nemo inference chat completions create` command.** Use `nemo inference gateway model post --workspace --body ''`. - **`example` is not a valid `--services` arg.** Valid services: `audit`, @@ -250,7 +255,7 @@ instead of resolving it from a registered entity. ### Random routing — same format (deterministic test: `strong_probability=1.0`) ```bash -nemo virtual-models create vm-random-strong --workspace my-workspace \ +nemo inference virtual-models create vm-random-strong --workspace my-workspace \ --models '[ {"model":"my-workspace/nvidia-mistralai-mixtral-8x22b-instruct-v01","backend_format":"OPENAI_CHAT"}, {"model":"my-workspace/nvidia-qwen-qwen3-32b","backend_format":"OPENAI_CHAT"} @@ -271,7 +276,7 @@ Order matters: routing first, translate second. Use `response_middleware` too for full round-trip translation back to the client's format. ```bash -nemo virtual-models create vm-random-cross --workspace my-workspace \ +nemo inference virtual-models create vm-random-cross --workspace my-workspace \ --models '[ {"model":"my-workspace/aws-anthropic-claude-opus-4-5","backend_format":"ANTHROPIC_MESSAGES"}, {"model":"my-workspace/nvidia-nvidia-nemotron-nano-31b-v3","backend_format":"OPENAI_CHAT"} @@ -294,7 +299,7 @@ Client sends OpenAI shape, backend is Anthropic, response comes back as OpenAI. **Must list translate in BOTH `request_middleware` and `response_middleware`.** ```bash -nemo virtual-models create vm-translate-cross --workspace my-workspace \ +nemo inference virtual-models create vm-translate-cross --workspace my-workspace \ --models '[{"model":"my-workspace/aws-anthropic-claude-opus-4-5","backend_format":"ANTHROPIC_MESSAGES"}]' \ --request-middleware '[{"name":"nemo-switchyard","config_type":"translate","config":{"target_format":"anthropic","enable_stats":false}}]' \ --response-middleware '[{"name":"nemo-switchyard","config_type":"translate","config":{"target_format":"anthropic","enable_stats":false}}]' @@ -315,7 +320,7 @@ rails only. **Output rails only** — block bad bot responses (most common): ```bash -nemo virtual-models create vm-guarded --workspace my-workspace \ +nemo inference virtual-models create vm-guarded --workspace my-workspace \ --models '[{"model":"my-workspace/","backend_format":"OPENAI_CHAT"}]' \ --response-middleware '[{ "name":"nemo-guardrails", @@ -327,7 +332,7 @@ nemo virtual-models create vm-guarded --workspace my-workspace \ **Input + output rails** — full coverage. Include the call in **both** lists: ```bash -nemo virtual-models create vm-guarded-full --workspace my-workspace \ +nemo inference virtual-models create vm-guarded-full --workspace my-workspace \ --models '[{"model":"my-workspace/","backend_format":"OPENAI_CHAT"}]' \ --request-middleware '[{"name":"nemo-guardrails","config_type":"guardrail_config","config_id":"my-workspace/content-safety"}]' \ --response-middleware '[{"name":"nemo-guardrails","config_type":"guardrail_config","config_id":"my-workspace/content-safety"}]' @@ -356,7 +361,7 @@ OpenAI form on both sides: guardrails' input rails because the plugin can't parse them. ```bash -nemo virtual-models create vm-guarded-translate --workspace my-workspace \ +nemo inference virtual-models create vm-guarded-translate --workspace my-workspace \ --models '[{"model":"my-workspace/aws-anthropic-claude-opus-4-5","backend_format":"ANTHROPIC_MESSAGES"}]' \ --request-middleware '[ {"name":"nemo-guardrails","config_type":"guardrail_config","config_id":"my-workspace/content-safety"}, @@ -551,9 +556,9 @@ nemo inference providers get nvidia-inference --workspace my-workspace \ ```bash # Delete all switchyard test VMs -for vm in $(nemo virtual-models list --workspace my-workspace --output-format json \ +for vm in $(nemo inference virtual-models list --workspace my-workspace --output-format json \ | jq -r '.data[].name' | grep vm-); do - nemo virtual-models delete "$vm" --workspace my-workspace + nemo inference virtual-models delete "$vm" --workspace my-workspace done nemo inference providers delete nvidia-inference --workspace my-workspace diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-agent-config/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-agent-config/SKILL.md index 4387a4d6f8..fdde181879 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-agent-config/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-agent-config/SKILL.md @@ -21,6 +21,9 @@ not-for: - nemo-spec (use to write AGENT-SPEC.md before implementation) - nemo-model-selection (use when the user only wants model recommendation) - generic YAML editing unrelated to NeMo Platform agents +preconditions: + - nemo_setup_complete + - agents_plugin_available compatibility: nemo-platform >= 0.1.0; writes or edits agents/-spec/agent.yaml; validates through nemo agents create; supports nemo-agents-spec-v1 configs; safe under sandbox. maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-build-agent/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-build-agent/SKILL.md index dea425bd8f..9b9cef258d 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-build-agent/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-build-agent/SKILL.md @@ -21,6 +21,12 @@ not-for: - nemo-setup (use to install the platform first) - deploy-sandbox (use to deploy the built agent as a governed OpenShell sandbox) - generic agent framework development outside NeMo Platform +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - agents_plugin_available + - agent_spec_exists compatibility: nemo-platform >= 0.1.0; running platform; requires agents plugin; writes files under agents/; uses nemo-agents-spec-v1 by default and preserves NAT workflow YAML as a compatibility path; macOS or Linux; safe under sandbox. maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-evaluator/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-evaluator/SKILL.md index 7f44349092..c117fb195b 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-evaluator/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-evaluator/SKILL.md @@ -8,6 +8,8 @@ description: > RAG/agentic, and tool-calling evaluations. Use when a task involves questions/rubrics/responses files, rubric criteria, benchmark scoring, evaluator primitive selection, or reusable evaluation artifacts. +preconditions: + - evaluator_sdk_available compatibility: Designed for installed NeMo Platform skill use; repo-relative SDK paths are developer fallbacks when a checkout is available. metadata: user-invocable: true diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-experiments-upload/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-experiments-upload/SKILL.md index 16400ac9e8..cacd767e42 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-experiments-upload/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-experiments-upload/SKILL.md @@ -16,6 +16,10 @@ not-for: - nemo-evaluator (use to AUTHOR and RUN evaluations/metrics; this skill UPLOADS results) - nemo-status (use for a read-only platform health dashboard) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_setup_complete + - workspace_exists + - clickhouse_ready compatibility: nemo-platform >= 0.1.0; needs a reachable local or remote intake service (with auth and entities) backed by ClickHouse for rollups/results; talks HTTP to /apis/intake/v2 (curl only, no Docker); Experiments viewing in Studio is behind the VITE_FF_EXPERIMENT feature flag. maturity: beta license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-explore/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-explore/SKILL.md index 6943f3f3c7..aba2c229e0 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-explore/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-explore/SKILL.md @@ -17,6 +17,8 @@ not-for: - nemo-build-agent (use after spec exists) - nemo-model-selection (use for the model question in step 5; explore delegates to it) - superpowers:brainstorming (use for design work unrelated to NeMo Platform) +preconditions: + - nemo_cli_available compatibility: nemo-platform >= 0.1.0; dialogue-driven with read-only pre-flight (`ls`, `find`, `Read`); safe under any sandbox; works offline; output is a structured conversation handed to nemo-spec. maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-files/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-files/SKILL.md index d3220a4ba9..7418092190 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-files/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-files/SKILL.md @@ -4,6 +4,9 @@ description: > NeMo files CLI reference for filesets, file upload/download, and dataset management. Use when the task involves filesets, file uploads, file downloads, datasets, JSONL files, or `nemo files` CLI commands. +preconditions: + - nemo_setup_complete + - workspace_exists user-invocable: true allowed-tools: Bash, Read, Grep --- diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md index cba7963111..6bb275a117 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md @@ -8,6 +8,11 @@ description: > MiddlewareCalls. Use when the task involves guardrail configurations, content safety, input/output rails, or `nemo guardrail` / `nemo inference virtual-models` CLI commands for guardrailing inference. +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - guardrails_plugin_available user-invocable: true allowed-tools: Bash, Read, Grep --- diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-intake/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-intake/SKILL.md index 171ec0a485..ec178aae28 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-intake/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-intake/SKILL.md @@ -2,6 +2,9 @@ name: nemo-intake description: Instrument agents, ingest telemetry into NeMo Intake, and query spans, traces, sessions, and evaluator results. Use when connecting agent code or existing telemetry to Intake, choosing among OTLP, chat-completions, or ATIF, checking Intake and ClickHouse readiness, inspecting agent runs, or attaching evaluation scores outside the Experiments leaderboard workflow. license: Apache-2.0 +preconditions: + - nemo_setup_complete + - workspace_exists allowed-tools: [Bash, Read] --- diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md index d36d63a09d..008aee2a7a 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md @@ -13,6 +13,9 @@ not-for: - nemo-explore (use first to capture the agent's job, audience, and tools) - nemo-spec (use to persist the design once model is chosen) - nemo-build-agent (use to scaffold the YAML once the spec is signed off) +preconditions: + - nemo_setup_complete + - provider_registered compatibility: nemo-platform >= 0.1.0; read-only; loads references/benchmark_cache.json if present; works offline; safe under any sandbox. maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-secrets/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-secrets/SKILL.md index d28e57e9d9..c0b39e0852 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-secrets/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-secrets/SKILL.md @@ -4,6 +4,9 @@ description: > NeMo secrets CLI reference for creating, listing, and managing secrets. Use when the task involves creating API key secrets, managing credentials, or `nemo secrets` CLI commands. +preconditions: + - nemo_setup_complete + - workspace_exists user-invocable: true allowed-tools: Bash, Read, Grep --- diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md index 0847099974..2080c3fa64 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md @@ -22,6 +22,8 @@ not-for: - superpowers:brainstorming (use for design work unrelated to NeMo Platform) - running downstream workflow or state-changing platform commands (each downstream skill owns its own commands) - loading multiple downstream skills in one turn +preconditions: + - nemo_cli_available compatibility: nemo-platform >= 0.1.0; selection plus a host scan on macOS or Linux; works without an installed CLI (selector can pick setup, which then tells the user how to run the CLI install). maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-spec/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-spec/SKILL.md index dc1fa20fc0..3c7cbbe2ca 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-spec/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-spec/SKILL.md @@ -13,6 +13,10 @@ not-for: - nemo-explore (use to gather the design before writing the spec) - nemo-build-agent (use to scaffold and deploy once the spec is signed off) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_setup_complete + - workspace_exists + - agent_design_complete compatibility: nemo-platform >= 0.1.0; writes one markdown file under agents/; uploads it to a NeMo Filesets fileset (the canonical copy) — local file is a write-through cache; safe under any sandbox; idempotent if user confirms overwrite. maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-status/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-status/SKILL.md index b8eb61cab3..f286fb52d0 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-status/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-status/SKILL.md @@ -14,6 +14,8 @@ not-for: - nemo-teardown (use to stop the platform) - nemo-try-agent (use to send a query to a deployed agent) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_cli_available compatibility: nemo-platform >= 0.1.0; read-only CLI calls only; no state changes; safe under any sandbox (requires `lsof`, `curl`, and a venv with the `nemo` binary — no Docker); works whether or not the agents plugin is installed (degrades gracefully). maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-teardown/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-teardown/SKILL.md index 2aa5a5927c..9617aef279 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-teardown/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-teardown/SKILL.md @@ -13,6 +13,9 @@ not-for: - nemo-setup (use to install or start the platform) - nemo-status (use for read-only health) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_cli_available + - user_confirmation_required compatibility: nemo-platform >= 0.1.0; uses `nemo services stop`, Docker only to remove an Intake-managed local ClickHouse container before a data wipe, and a targeted `rm -rf` of the platform's data directory (default `~/.local/share/nemo`, overridable via `$NMP_DATA_DIR`); no `pkill`, no `rm` outside the chosen data dir or the working folder; idempotent (re-running after platform is already stopped is a no-op). maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-try-agent/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-try-agent/SKILL.md index 54bab16f27..1f5fe1606c 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-try-agent/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-try-agent/SKILL.md @@ -22,6 +22,12 @@ not-for: - nemo-build-agent (use to deploy an agent before querying) - nemo-skill-selection (use to dispatch when intent is unclear) - nemo-status (use for read-only platform health) +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - agents_plugin_available + - agent_config_exists compatibility: nemo-platform >= 0.1.0; requires agents plugin and either a local agent YAML config or a running platform with a deployed agent; no destructive ops; safe under any sandbox. maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_claude.py b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_claude.py index caaf63d1d0..9db172a0d3 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_claude.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_claude.py @@ -10,13 +10,18 @@ from nemo_platform_ext.cli.commands.skills.base import Scope, Skill -def _make_skill(name: str = "test-skill", source_dir: Path | None = None) -> Skill: +def _make_skill( + name: str = "test-skill", + source_dir: Path | None = None, + preconditions: list[str] | None = None, +) -> Skill: return Skill( name=name, description=f"A {name} skill", version="0.1", content=f"# {name}\nSome content.", raw=f"---\nname: {name}\ndescription: A {name} skill\nversion: '0.1'\n---\n\n# {name}\nSome content.", + preconditions=preconditions or [], source_dir=source_dir, ) @@ -39,6 +44,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".claude" / "skills" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = ClaudeInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".claude" / "skills" / "nemo-files" / "SKILL.md" + + def test_user_install_path(tmp_path: Path): installer = ClaudeInstaller() path = installer.get_install_path(Scope.USER, tmp_path, "inference") @@ -54,6 +65,20 @@ def test_format_content_adds_frontmatter(): assert "# inference" in content +def test_format_content_keeps_existing_nemo_prefix_and_preconditions(): + installer = ClaudeInstaller() + skill = _make_skill("nemo-files", preconditions=["nemo_setup_complete", "platform_running"]) + content = installer.format_content(skill) + front_matter, _, _ = content.partition("\n---\n") + front_matter = front_matter.removeprefix("---\n") + parsed = yaml.safe_load(front_matter) + assert parsed == { + "name": "nemo-files", + "description": "A nemo-files skill", + "preconditions": ["nemo_setup_complete", "platform_running"], + } + + def test_format_content_escapes_yaml_special_chars(): """Descriptions with `:`, `#`, newlines, or leading `- ` must round-trip via YAML.""" installer = ClaudeInstaller() diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_codex.py b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_codex.py index c9cbb21baf..d8f0cc4afe 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_codex.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_codex.py @@ -11,13 +11,18 @@ from nemo_platform_ext.cli.commands.skills.base import Scope, Skill -def _make_skill(name: str = "test-skill", source_dir: Path | None = None) -> Skill: +def _make_skill( + name: str = "test-skill", + source_dir: Path | None = None, + preconditions: list[str] | None = None, +) -> Skill: return Skill( name=name, description=f"A {name} skill", version="0.1", content=f"# {name}\nSome content.", raw=f"---\nname: {name}\ndescription: A {name} skill\nversion: '0.1'\n---\n\n# {name}\nSome content.", + preconditions=preconditions or [], source_dir=source_dir, ) @@ -40,6 +45,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".agents" / "skills" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = CodexInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".agents" / "skills" / "nemo-files" / "SKILL.md" + + def test_user_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("HOME", str(tmp_path)) installer = CodexInstaller() @@ -57,6 +68,20 @@ def test_format_content_adds_frontmatter(): assert "# inference" in content +def test_format_content_keeps_existing_nemo_prefix_and_preconditions(): + installer = CodexInstaller() + skill = _make_skill("nemo-files", preconditions=["nemo_setup_complete", "platform_running"]) + content = installer.format_content(skill) + front_matter, _, _ = content.partition("\n---\n") + front_matter = front_matter.removeprefix("---\n") + parsed = yaml.safe_load(front_matter) + assert parsed == { + "name": "nemo-files", + "description": "A nemo-files skill", + "preconditions": ["nemo_setup_complete", "platform_running"], + } + + def test_format_content_escapes_yaml_special_chars(): """Descriptions with `:`, `#`, newlines, or leading `- ` must round-trip via YAML.""" installer = CodexInstaller() diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_cursor.py b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_cursor.py index bcde96b3bf..38cba6df54 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_cursor.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_cursor.py @@ -36,6 +36,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".cursor" / "rules" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = CursorInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".cursor" / "rules" / "nemo-files" / "SKILL.md" + + def test_install_creates_files(tmp_path: Path): installer = CursorInstaller() skills = {"alpha": _make_skill("alpha")} diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_opencode.py b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_opencode.py index d945499870..bb8f97f46a 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_opencode.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/agents/test_opencode.py @@ -31,6 +31,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".opencode" / "commands" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = OpenCodeInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".opencode" / "commands" / "nemo-files" / "SKILL.md" + + def test_user_install_path(tmp_path: Path): installer = OpenCodeInstaller() path = installer.get_install_path(Scope.USER, tmp_path, "inference") diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py index 1a7d459738..7d6c8f581f 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py @@ -3,7 +3,7 @@ """Tests for the skills base module.""" -from nemo_platform_ext.cli.commands.skills.base import Scope +from nemo_platform_ext.cli.commands.skills.base import Scope, installed_skill_name def test_scope_enum_has_project_and_user(): @@ -13,3 +13,8 @@ def test_scope_enum_has_project_and_user(): def test_scope_enum_members(): assert set(Scope) == {Scope.PROJECT, Scope.USER} + + +def test_installed_skill_name_adds_nemo_prefix_once(): + assert installed_skill_name("inference") == "nemo-inference" + assert installed_skill_name("nemo-files") == "nemo-files" diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_cli.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_cli.py index 3ae0cf01f0..14c953b4fd 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_cli.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_cli.py @@ -8,6 +8,7 @@ import pytest from nemo_platform_ext.cli.app import app +from nemo_platform_ext.cli.commands.skills.base import installed_skill_name from nemo_platform_ext.cli.commands.skills.registry import ( DuplicateSkillError, SkillProvider, @@ -231,7 +232,8 @@ def test_install_claude_project_creates_multiple_files(self, tmp_path: Path, mon # Every built-in skill should install — exercise the multi-skill path # without hardcoding which platform skills exist today. for skill_name in _platform_skill_names(): - assert (tmp_path / ".claude" / "skills" / f"nemo-{skill_name}" / "SKILL.md").exists() + installed_name = installed_skill_name(skill_name) + assert (tmp_path / ".claude" / "skills" / installed_name / "SKILL.md").exists() assert "Installed" in result.stdout def test_install_selective_skills(self, tmp_path: Path, monkeypatch): @@ -241,6 +243,15 @@ def test_install_selective_skills(self, tmp_path: Path, monkeypatch): assert (tmp_path / ".claude" / "skills" / "nemo-inference" / "SKILL.md").exists() assert not (tmp_path / ".claude" / "skills" / "nemo-setup" / "SKILL.md").exists() + def test_install_prefixed_skill_does_not_double_prefix(self, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, "skills install --agent claude --skill nemo-files") + assert_exit_code(result, 0) + skill_file = tmp_path / ".claude" / "skills" / "nemo-files" / "SKILL.md" + assert skill_file.exists() + assert not (tmp_path / ".claude" / "skills" / "nemo-nemo-files").exists() + assert "name: nemo-files" in skill_file.read_text() + @pytest.mark.parametrize( ("agent", "relative_path"), [ diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py index e760f7b379..1908912ab0 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py @@ -13,6 +13,25 @@ from nemo_platform_ext.cli.commands.skills.base import Skill from nemo_platform_ext.cli.commands.skills.registry import _load_skills_cached, load_skills +KNOWN_SKILL_PRECONDITIONS = frozenset( + { + "agent_config_exists", + "agent_design_complete", + "agent_spec_exists", + "agents_plugin_available", + "clickhouse_ready", + "evaluator_sdk_available", + "guardrails_plugin_available", + "nemo_cli_available", + "nemo_setup_complete", + "platform_running", + "provider_registered", + "secrets_configured", + "user_confirmation_required", + "workspace_exists", + } +) + def setup_function() -> None: """Drop both registry caches before every test. @@ -41,6 +60,7 @@ def test_skill_has_required_fields(self): assert skill.description == "A test skill" assert skill.version == "0.1" assert skill.content == "# Test" + assert skill.preconditions == [] def test_skill_has_source_dir(self): skill = Skill( @@ -89,6 +109,14 @@ def test_each_skill_has_source_dir(self): assert skill.source_dir.is_dir() assert (skill.source_dir / "SKILL.md").exists() + def test_platform_skills_declare_known_preconditions(self): + for name, skill in load_skills().items(): + if skill.source_plugin != "platform": + continue + assert len(skill.preconditions) > 0 + unknown = set(skill.preconditions) - KNOWN_SKILL_PRECONDITIONS + assert not unknown, f"{name} has unknown preconditions: {sorted(unknown)}" + def test_build_agent_templates_are_packaged(self): skill = load_skills()["nemo-build-agent"] assert skill.source_dir is not None diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/claude.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/claude.py index e0a93f07a4..724d968ea6 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/claude.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/claude.py @@ -6,7 +6,7 @@ from pathlib import Path import yaml -from nemo_platform.cli.commands.skills.base import Scope, Skill +from nemo_platform.cli.commands.skills.base import Scope, Skill, installed_skill_name from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller @@ -16,13 +16,17 @@ class ClaudeInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT, Scope.USER] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + target_name = installed_skill_name(skill_name) if scope == Scope.PROJECT: - return project_root / ".claude" / "skills" / f"nemo-{skill_name}" / "SKILL.md" - return Path.home() / ".claude" / "skills" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".claude" / "skills" / target_name / "SKILL.md" + return Path.home() / ".claude" / "skills" / target_name / "SKILL.md" def format_content(self, skill: Skill) -> str: + metadata: dict[str, object] = {"name": installed_skill_name(skill.name), "description": skill.description} + if skill.preconditions: + metadata["preconditions"] = skill.preconditions front_matter = yaml.safe_dump( - {"name": f"nemo-{skill.name}", "description": skill.description}, + metadata, sort_keys=False, allow_unicode=True, ) diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/codex.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/codex.py index d497fcd081..ec6c18ec09 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/codex.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/codex.py @@ -6,7 +6,7 @@ from pathlib import Path import yaml -from nemo_platform.cli.commands.skills.base import Scope, Skill +from nemo_platform.cli.commands.skills.base import Scope, Skill, installed_skill_name from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller @@ -16,16 +16,20 @@ class CodexInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT, Scope.USER] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + target_name = installed_skill_name(skill_name) # Codex discovers skills under `.agents/skills/` (see openai/codex # `codex-rs/core-skills/src/loader.rs`). The older `.codex/skills/` # layout has been deprecated. if scope == Scope.PROJECT: - return project_root / ".agents" / "skills" / f"nemo-{skill_name}" / "SKILL.md" - return Path.home() / ".agents" / "skills" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".agents" / "skills" / target_name / "SKILL.md" + return Path.home() / ".agents" / "skills" / target_name / "SKILL.md" def format_content(self, skill: Skill) -> str: + metadata: dict[str, object] = {"name": installed_skill_name(skill.name), "description": skill.description} + if skill.preconditions: + metadata["preconditions"] = skill.preconditions front_matter = yaml.safe_dump( - {"name": f"nemo-{skill.name}", "description": skill.description}, + metadata, sort_keys=False, allow_unicode=True, ) diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/cursor.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/cursor.py index b00f776454..7ba6c19d1d 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/cursor.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/cursor.py @@ -5,7 +5,7 @@ from pathlib import Path -from nemo_platform.cli.commands.skills.base import Scope +from nemo_platform.cli.commands.skills.base import Scope, installed_skill_name from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller @@ -15,4 +15,4 @@ class CursorInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: - return project_root / ".cursor" / "rules" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".cursor" / "rules" / installed_skill_name(skill_name) / "SKILL.md" diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/opencode.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/opencode.py index cab76541e1..facd43ec46 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/opencode.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/agents/opencode.py @@ -5,7 +5,7 @@ from pathlib import Path -from nemo_platform.cli.commands.skills.base import Scope +from nemo_platform.cli.commands.skills.base import Scope, installed_skill_name from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller @@ -15,6 +15,7 @@ class OpenCodeInstaller(BaseAgentInstaller): supported_scopes = [Scope.PROJECT, Scope.USER] def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + target_name = installed_skill_name(skill_name) if scope == Scope.PROJECT: - return project_root / ".opencode" / "commands" / f"nemo-{skill_name}" / "SKILL.md" - return Path.home() / ".opencode" / "commands" / f"nemo-{skill_name}" / "SKILL.md" + return project_root / ".opencode" / "commands" / target_name / "SKILL.md" + return Path.home() / ".opencode" / "commands" / target_name / "SKILL.md" diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py index 2d4fe90e37..fd08ef9b86 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py @@ -3,11 +3,20 @@ """Base types and protocol for agent skill installers.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Protocol +INSTALLED_SKILL_PREFIX = "nemo-" + + +def installed_skill_name(skill_name: str) -> str: + """Return the skill name exposed to downstream coding agents.""" + if skill_name.startswith(INSTALLED_SKILL_PREFIX): + return skill_name + return f"{INSTALLED_SKILL_PREFIX}{skill_name}" + @dataclass class Skill: @@ -16,6 +25,7 @@ class Skill: version: str content: str raw: str + preconditions: list[str] = field(default_factory=list) source_dir: Path | None = None # Entry-point name under ``nemo.skills`` (e.g. ``"agents"``, ``"platform"``). # Useful for programmatic filtering; the human-friendly label is built from diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/cli.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/cli.py index a696f46ea8..86843f2ae3 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/cli.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/cli.py @@ -192,6 +192,7 @@ def list_skills( "name": skill.name, "version": skill.version, "description": skill.description, + "preconditions": skill.preconditions, # `source` is the human-friendly column shown in `list` output: # the distribution name that registered the skill's entry point, # collapsed to `nemo-platform` for the platform's own packages. diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py index 600e3ba00c..b4929493d5 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py @@ -107,12 +107,20 @@ def _load_skill(entry: Path, source_plugin: str | None = None, source_dist: str raise ValueError(f"Invalid frontmatter in {skill_file}: {e}") from e if not isinstance(metadata, dict): raise ValueError(f"Invalid frontmatter in {skill_file}: expected a mapping, got {type(metadata).__name__}") + preconditions = metadata.get("preconditions", []) + if preconditions is None: + preconditions = [] + if isinstance(preconditions, str): + preconditions = [preconditions] + if not isinstance(preconditions, list) or not all(isinstance(item, str) for item in preconditions): + raise ValueError(f"Invalid frontmatter in {skill_file}: preconditions must be a list of strings") return Skill( name=metadata.get("name", entry.name), description=metadata.get("description", ""), version=str(metadata.get("version", "0.1")), content=body, raw=raw, + preconditions=preconditions, source_dir=entry, source_plugin=source_plugin, source_dist=source_dist, diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py b/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py index 1cbb697e74..af80dd81f3 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py @@ -12,7 +12,7 @@ import sys from collections.abc import Iterator from dataclasses import dataclass -from typing import Any +from typing import Any, Literal import click import yaml @@ -598,7 +598,7 @@ def format_output( *, is_list: bool = False, output_format: str | None = None, - output_columns: str | list[Column] | None = None, + output_columns: Literal["all"] | str | list[Column] | None = None, indent: int = 2, no_truncate: bool | None = None, timestamp_format: str | None = None, @@ -662,6 +662,7 @@ def format_output( # Determine truncate setting (inverse of no_truncate) truncate = not no_truncate + timestamp_format = timestamp_format or "iso" # The "use --no-truncate to see full values" hint only makes sense when # the table actually clips with "..."; in wrap mode nothing is hidden. @@ -692,9 +693,9 @@ def format_output( output = format_yaml(data, syntax_highlight=True, background=False) print(output) elif output_format == "table": - assert isinstance(output_columns, list) # Table format. When wrapping is on and --no-truncate is set, drop the # per-column cap so wrapping uses the full terminal width. + assert isinstance(output_columns, list) effective_wrap_max_width = None if (wrap and not truncate) else wrap_max_width output = format_table( data, @@ -706,15 +707,15 @@ def format_output( ) print(output) elif output_format == "markdown": - assert isinstance(output_columns, list) # Markdown table format + assert isinstance(output_columns, list) output = format_markdown_table( data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format ) print(output) elif output_format == "csv": - assert isinstance(output_columns, list) # CSV format + assert isinstance(output_columns, list) output = format_csv(data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format) print(output, end="") # CSV already includes newlines elif output_format == "raw": diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md index 00a3d4ba03..ff058179a0 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md @@ -12,6 +12,11 @@ description: > or debugging routing and translation failures locally. For platform startup, Switchyard install, and DB-reset prerequisites, see the setup playbook (`SETUP.md` at the repo root). +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - secrets_configured user-invocable: true allowed-tools: Bash, Read, Grep --- @@ -75,7 +80,7 @@ For platform startup (`nemo services run`), Switchyard install, and state reset, - **`nemo secrets create`** uses `--from-file` (pipe key in). No `--value` flag. - **`nemo inference providers create`** takes `` as a **positional** arg. Same for `update-status`, `get`, `delete`. -- **`nemo virtual-models create`** is a **top-level** command (not under `nemo inference`) and takes `` as positional. +- **`nemo inference virtual-models create`** takes `` as positional. - **There is no `nemo inference chat completions create` command.** Use `nemo inference gateway model post --workspace --body ''`. - **`example` is not a valid `--services` arg.** Valid services: `audit`, @@ -250,7 +255,7 @@ instead of resolving it from a registered entity. ### Random routing — same format (deterministic test: `strong_probability=1.0`) ```bash -nemo virtual-models create vm-random-strong --workspace my-workspace \ +nemo inference virtual-models create vm-random-strong --workspace my-workspace \ --models '[ {"model":"my-workspace/nvidia-mistralai-mixtral-8x22b-instruct-v01","backend_format":"OPENAI_CHAT"}, {"model":"my-workspace/nvidia-qwen-qwen3-32b","backend_format":"OPENAI_CHAT"} @@ -271,7 +276,7 @@ Order matters: routing first, translate second. Use `response_middleware` too for full round-trip translation back to the client's format. ```bash -nemo virtual-models create vm-random-cross --workspace my-workspace \ +nemo inference virtual-models create vm-random-cross --workspace my-workspace \ --models '[ {"model":"my-workspace/aws-anthropic-claude-opus-4-5","backend_format":"ANTHROPIC_MESSAGES"}, {"model":"my-workspace/nvidia-nvidia-nemotron-nano-31b-v3","backend_format":"OPENAI_CHAT"} @@ -294,7 +299,7 @@ Client sends OpenAI shape, backend is Anthropic, response comes back as OpenAI. **Must list translate in BOTH `request_middleware` and `response_middleware`.** ```bash -nemo virtual-models create vm-translate-cross --workspace my-workspace \ +nemo inference virtual-models create vm-translate-cross --workspace my-workspace \ --models '[{"model":"my-workspace/aws-anthropic-claude-opus-4-5","backend_format":"ANTHROPIC_MESSAGES"}]' \ --request-middleware '[{"name":"nemo-switchyard","config_type":"translate","config":{"target_format":"anthropic","enable_stats":false}}]' \ --response-middleware '[{"name":"nemo-switchyard","config_type":"translate","config":{"target_format":"anthropic","enable_stats":false}}]' @@ -315,7 +320,7 @@ rails only. **Output rails only** — block bad bot responses (most common): ```bash -nemo virtual-models create vm-guarded --workspace my-workspace \ +nemo inference virtual-models create vm-guarded --workspace my-workspace \ --models '[{"model":"my-workspace/","backend_format":"OPENAI_CHAT"}]' \ --response-middleware '[{ "name":"nemo-guardrails", @@ -327,7 +332,7 @@ nemo virtual-models create vm-guarded --workspace my-workspace \ **Input + output rails** — full coverage. Include the call in **both** lists: ```bash -nemo virtual-models create vm-guarded-full --workspace my-workspace \ +nemo inference virtual-models create vm-guarded-full --workspace my-workspace \ --models '[{"model":"my-workspace/","backend_format":"OPENAI_CHAT"}]' \ --request-middleware '[{"name":"nemo-guardrails","config_type":"guardrail_config","config_id":"my-workspace/content-safety"}]' \ --response-middleware '[{"name":"nemo-guardrails","config_type":"guardrail_config","config_id":"my-workspace/content-safety"}]' @@ -356,7 +361,7 @@ OpenAI form on both sides: guardrails' input rails because the plugin can't parse them. ```bash -nemo virtual-models create vm-guarded-translate --workspace my-workspace \ +nemo inference virtual-models create vm-guarded-translate --workspace my-workspace \ --models '[{"model":"my-workspace/aws-anthropic-claude-opus-4-5","backend_format":"ANTHROPIC_MESSAGES"}]' \ --request-middleware '[ {"name":"nemo-guardrails","config_type":"guardrail_config","config_id":"my-workspace/content-safety"}, @@ -551,9 +556,9 @@ nemo inference providers get nvidia-inference --workspace my-workspace \ ```bash # Delete all switchyard test VMs -for vm in $(nemo virtual-models list --workspace my-workspace --output-format json \ +for vm in $(nemo inference virtual-models list --workspace my-workspace --output-format json \ | jq -r '.data[].name' | grep vm-); do - nemo virtual-models delete "$vm" --workspace my-workspace + nemo inference virtual-models delete "$vm" --workspace my-workspace done nemo inference providers delete nvidia-inference --workspace my-workspace diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-agent-config/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-agent-config/SKILL.md index 4387a4d6f8..fdde181879 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-agent-config/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-agent-config/SKILL.md @@ -21,6 +21,9 @@ not-for: - nemo-spec (use to write AGENT-SPEC.md before implementation) - nemo-model-selection (use when the user only wants model recommendation) - generic YAML editing unrelated to NeMo Platform agents +preconditions: + - nemo_setup_complete + - agents_plugin_available compatibility: nemo-platform >= 0.1.0; writes or edits agents/-spec/agent.yaml; validates through nemo agents create; supports nemo-agents-spec-v1 configs; safe under sandbox. maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-build-agent/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-build-agent/SKILL.md index dea425bd8f..9b9cef258d 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-build-agent/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-build-agent/SKILL.md @@ -21,6 +21,12 @@ not-for: - nemo-setup (use to install the platform first) - deploy-sandbox (use to deploy the built agent as a governed OpenShell sandbox) - generic agent framework development outside NeMo Platform +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - agents_plugin_available + - agent_spec_exists compatibility: nemo-platform >= 0.1.0; running platform; requires agents plugin; writes files under agents/; uses nemo-agents-spec-v1 by default and preserves NAT workflow YAML as a compatibility path; macOS or Linux; safe under sandbox. maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-evaluator/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-evaluator/SKILL.md index 7f44349092..c117fb195b 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-evaluator/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-evaluator/SKILL.md @@ -8,6 +8,8 @@ description: > RAG/agentic, and tool-calling evaluations. Use when a task involves questions/rubrics/responses files, rubric criteria, benchmark scoring, evaluator primitive selection, or reusable evaluation artifacts. +preconditions: + - evaluator_sdk_available compatibility: Designed for installed NeMo Platform skill use; repo-relative SDK paths are developer fallbacks when a checkout is available. metadata: user-invocable: true diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-experiments-upload/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-experiments-upload/SKILL.md index 16400ac9e8..cacd767e42 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-experiments-upload/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-experiments-upload/SKILL.md @@ -16,6 +16,10 @@ not-for: - nemo-evaluator (use to AUTHOR and RUN evaluations/metrics; this skill UPLOADS results) - nemo-status (use for a read-only platform health dashboard) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_setup_complete + - workspace_exists + - clickhouse_ready compatibility: nemo-platform >= 0.1.0; needs a reachable local or remote intake service (with auth and entities) backed by ClickHouse for rollups/results; talks HTTP to /apis/intake/v2 (curl only, no Docker); Experiments viewing in Studio is behind the VITE_FF_EXPERIMENT feature flag. maturity: beta license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-explore/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-explore/SKILL.md index 6943f3f3c7..aba2c229e0 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-explore/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-explore/SKILL.md @@ -17,6 +17,8 @@ not-for: - nemo-build-agent (use after spec exists) - nemo-model-selection (use for the model question in step 5; explore delegates to it) - superpowers:brainstorming (use for design work unrelated to NeMo Platform) +preconditions: + - nemo_cli_available compatibility: nemo-platform >= 0.1.0; dialogue-driven with read-only pre-flight (`ls`, `find`, `Read`); safe under any sandbox; works offline; output is a structured conversation handed to nemo-spec. maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-files/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-files/SKILL.md index d3220a4ba9..7418092190 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-files/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-files/SKILL.md @@ -4,6 +4,9 @@ description: > NeMo files CLI reference for filesets, file upload/download, and dataset management. Use when the task involves filesets, file uploads, file downloads, datasets, JSONL files, or `nemo files` CLI commands. +preconditions: + - nemo_setup_complete + - workspace_exists user-invocable: true allowed-tools: Bash, Read, Grep --- diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md index cba7963111..6bb275a117 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md @@ -8,6 +8,11 @@ description: > MiddlewareCalls. Use when the task involves guardrail configurations, content safety, input/output rails, or `nemo guardrail` / `nemo inference virtual-models` CLI commands for guardrailing inference. +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - guardrails_plugin_available user-invocable: true allowed-tools: Bash, Read, Grep --- diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-intake/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-intake/SKILL.md index 171ec0a485..ec178aae28 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-intake/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-intake/SKILL.md @@ -2,6 +2,9 @@ name: nemo-intake description: Instrument agents, ingest telemetry into NeMo Intake, and query spans, traces, sessions, and evaluator results. Use when connecting agent code or existing telemetry to Intake, choosing among OTLP, chat-completions, or ATIF, checking Intake and ClickHouse readiness, inspecting agent runs, or attaching evaluation scores outside the Experiments leaderboard workflow. license: Apache-2.0 +preconditions: + - nemo_setup_complete + - workspace_exists allowed-tools: [Bash, Read] --- diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md index d36d63a09d..008aee2a7a 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md @@ -13,6 +13,9 @@ not-for: - nemo-explore (use first to capture the agent's job, audience, and tools) - nemo-spec (use to persist the design once model is chosen) - nemo-build-agent (use to scaffold the YAML once the spec is signed off) +preconditions: + - nemo_setup_complete + - provider_registered compatibility: nemo-platform >= 0.1.0; read-only; loads references/benchmark_cache.json if present; works offline; safe under any sandbox. maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-secrets/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-secrets/SKILL.md index d28e57e9d9..c0b39e0852 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-secrets/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-secrets/SKILL.md @@ -4,6 +4,9 @@ description: > NeMo secrets CLI reference for creating, listing, and managing secrets. Use when the task involves creating API key secrets, managing credentials, or `nemo secrets` CLI commands. +preconditions: + - nemo_setup_complete + - workspace_exists user-invocable: true allowed-tools: Bash, Read, Grep --- diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md index 0847099974..2080c3fa64 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md @@ -22,6 +22,8 @@ not-for: - superpowers:brainstorming (use for design work unrelated to NeMo Platform) - running downstream workflow or state-changing platform commands (each downstream skill owns its own commands) - loading multiple downstream skills in one turn +preconditions: + - nemo_cli_available compatibility: nemo-platform >= 0.1.0; selection plus a host scan on macOS or Linux; works without an installed CLI (selector can pick setup, which then tells the user how to run the CLI install). maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-spec/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-spec/SKILL.md index dc1fa20fc0..3c7cbbe2ca 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-spec/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-spec/SKILL.md @@ -13,6 +13,10 @@ not-for: - nemo-explore (use to gather the design before writing the spec) - nemo-build-agent (use to scaffold and deploy once the spec is signed off) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_setup_complete + - workspace_exists + - agent_design_complete compatibility: nemo-platform >= 0.1.0; writes one markdown file under agents/; uploads it to a NeMo Filesets fileset (the canonical copy) — local file is a write-through cache; safe under any sandbox; idempotent if user confirms overwrite. maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-status/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-status/SKILL.md index b8eb61cab3..f286fb52d0 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-status/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-status/SKILL.md @@ -14,6 +14,8 @@ not-for: - nemo-teardown (use to stop the platform) - nemo-try-agent (use to send a query to a deployed agent) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_cli_available compatibility: nemo-platform >= 0.1.0; read-only CLI calls only; no state changes; safe under any sandbox (requires `lsof`, `curl`, and a venv with the `nemo` binary — no Docker); works whether or not the agents plugin is installed (degrades gracefully). maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-teardown/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-teardown/SKILL.md index 2aa5a5927c..9617aef279 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-teardown/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-teardown/SKILL.md @@ -13,6 +13,9 @@ not-for: - nemo-setup (use to install or start the platform) - nemo-status (use for read-only health) - nemo-skill-selection (use for dispatch when intent is unclear) +preconditions: + - nemo_cli_available + - user_confirmation_required compatibility: nemo-platform >= 0.1.0; uses `nemo services stop`, Docker only to remove an Intake-managed local ClickHouse container before a data wipe, and a targeted `rm -rf` of the platform's data directory (default `~/.local/share/nemo`, overridable via `$NMP_DATA_DIR`); no `pkill`, no `rm` outside the chosen data dir or the working folder; idempotent (re-running after platform is already stopped is a no-op). maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-try-agent/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-try-agent/SKILL.md index 54bab16f27..1f5fe1606c 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-try-agent/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-try-agent/SKILL.md @@ -22,6 +22,12 @@ not-for: - nemo-build-agent (use to deploy an agent before querying) - nemo-skill-selection (use to dispatch when intent is unclear) - nemo-status (use for read-only platform health) +preconditions: + - nemo_setup_complete + - workspace_exists + - provider_registered + - agents_plugin_available + - agent_config_exists compatibility: nemo-platform >= 0.1.0; requires agents plugin and either a local agent YAML config or a running platform with a deployed agent; no destructive ops; safe under any sandbox. maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_claude.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_claude.py index 7f12c40b59..59cfbf3389 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_claude.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_claude.py @@ -10,13 +10,18 @@ from nemo_platform.cli.commands.skills.base import Scope, Skill -def _make_skill(name: str = "test-skill", source_dir: Path | None = None) -> Skill: +def _make_skill( + name: str = "test-skill", + source_dir: Path | None = None, + preconditions: list[str] | None = None, +) -> Skill: return Skill( name=name, description=f"A {name} skill", version="0.1", content=f"# {name}\nSome content.", raw=f"---\nname: {name}\ndescription: A {name} skill\nversion: '0.1'\n---\n\n# {name}\nSome content.", + preconditions=preconditions or [], source_dir=source_dir, ) @@ -39,6 +44,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".claude" / "skills" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = ClaudeInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".claude" / "skills" / "nemo-files" / "SKILL.md" + + def test_user_install_path(tmp_path: Path): installer = ClaudeInstaller() path = installer.get_install_path(Scope.USER, tmp_path, "inference") @@ -54,6 +65,20 @@ def test_format_content_adds_frontmatter(): assert "# inference" in content +def test_format_content_keeps_existing_nemo_prefix_and_preconditions(): + installer = ClaudeInstaller() + skill = _make_skill("nemo-files", preconditions=["nemo_setup_complete", "platform_running"]) + content = installer.format_content(skill) + front_matter, _, _ = content.partition("\n---\n") + front_matter = front_matter.removeprefix("---\n") + parsed = yaml.safe_load(front_matter) + assert parsed == { + "name": "nemo-files", + "description": "A nemo-files skill", + "preconditions": ["nemo_setup_complete", "platform_running"], + } + + def test_format_content_escapes_yaml_special_chars(): """Descriptions with `:`, `#`, newlines, or leading `- ` must round-trip via YAML.""" installer = ClaudeInstaller() diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_codex.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_codex.py index 9f39dc767e..34712c61dd 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_codex.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_codex.py @@ -11,13 +11,18 @@ from nemo_platform.cli.commands.skills.base import Scope, Skill -def _make_skill(name: str = "test-skill", source_dir: Path | None = None) -> Skill: +def _make_skill( + name: str = "test-skill", + source_dir: Path | None = None, + preconditions: list[str] | None = None, +) -> Skill: return Skill( name=name, description=f"A {name} skill", version="0.1", content=f"# {name}\nSome content.", raw=f"---\nname: {name}\ndescription: A {name} skill\nversion: '0.1'\n---\n\n# {name}\nSome content.", + preconditions=preconditions or [], source_dir=source_dir, ) @@ -40,6 +45,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".agents" / "skills" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = CodexInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".agents" / "skills" / "nemo-files" / "SKILL.md" + + def test_user_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("HOME", str(tmp_path)) installer = CodexInstaller() @@ -57,6 +68,20 @@ def test_format_content_adds_frontmatter(): assert "# inference" in content +def test_format_content_keeps_existing_nemo_prefix_and_preconditions(): + installer = CodexInstaller() + skill = _make_skill("nemo-files", preconditions=["nemo_setup_complete", "platform_running"]) + content = installer.format_content(skill) + front_matter, _, _ = content.partition("\n---\n") + front_matter = front_matter.removeprefix("---\n") + parsed = yaml.safe_load(front_matter) + assert parsed == { + "name": "nemo-files", + "description": "A nemo-files skill", + "preconditions": ["nemo_setup_complete", "platform_running"], + } + + def test_format_content_escapes_yaml_special_chars(): """Descriptions with `:`, `#`, newlines, or leading `- ` must round-trip via YAML.""" installer = CodexInstaller() diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_cursor.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_cursor.py index 143a7c3947..d0b73be9df 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_cursor.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_cursor.py @@ -36,6 +36,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".cursor" / "rules" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = CursorInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".cursor" / "rules" / "nemo-files" / "SKILL.md" + + def test_install_creates_files(tmp_path: Path): installer = CursorInstaller() skills = {"alpha": _make_skill("alpha")} diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_opencode.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_opencode.py index 3040acbce5..0c4b8f0cea 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_opencode.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/agents/test_opencode.py @@ -31,6 +31,12 @@ def test_project_install_path(tmp_path: Path): assert path == tmp_path / ".opencode" / "commands" / "nemo-inference" / "SKILL.md" +def test_project_install_path_keeps_existing_nemo_prefix(tmp_path: Path): + installer = OpenCodeInstaller() + path = installer.get_install_path(Scope.PROJECT, tmp_path, "nemo-files") + assert path == tmp_path / ".opencode" / "commands" / "nemo-files" / "SKILL.md" + + def test_user_install_path(tmp_path: Path): installer = OpenCodeInstaller() path = installer.get_install_path(Scope.USER, tmp_path, "inference") diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py index d11c79f909..f83e13bc1a 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py @@ -3,7 +3,7 @@ """Tests for the skills base module.""" -from nemo_platform.cli.commands.skills.base import Scope +from nemo_platform.cli.commands.skills.base import Scope, installed_skill_name def test_scope_enum_has_project_and_user(): @@ -13,3 +13,8 @@ def test_scope_enum_has_project_and_user(): def test_scope_enum_members(): assert set(Scope) == {Scope.PROJECT, Scope.USER} + + +def test_installed_skill_name_adds_nemo_prefix_once(): + assert installed_skill_name("inference") == "nemo-inference" + assert installed_skill_name("nemo-files") == "nemo-files" diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_cli.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_cli.py index 5a2140d14c..96a184ceac 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_cli.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_cli.py @@ -8,6 +8,7 @@ import pytest from nemo_platform.cli.app import app +from nemo_platform.cli.commands.skills.base import installed_skill_name from nemo_platform.cli.commands.skills.registry import ( DuplicateSkillError, SkillProvider, @@ -231,7 +232,8 @@ def test_install_claude_project_creates_multiple_files(self, tmp_path: Path, mon # Every built-in skill should install — exercise the multi-skill path # without hardcoding which platform skills exist today. for skill_name in _platform_skill_names(): - assert (tmp_path / ".claude" / "skills" / f"nemo-{skill_name}" / "SKILL.md").exists() + installed_name = installed_skill_name(skill_name) + assert (tmp_path / ".claude" / "skills" / installed_name / "SKILL.md").exists() assert "Installed" in result.stdout def test_install_selective_skills(self, tmp_path: Path, monkeypatch): @@ -241,6 +243,15 @@ def test_install_selective_skills(self, tmp_path: Path, monkeypatch): assert (tmp_path / ".claude" / "skills" / "nemo-inference" / "SKILL.md").exists() assert not (tmp_path / ".claude" / "skills" / "nemo-setup" / "SKILL.md").exists() + def test_install_prefixed_skill_does_not_double_prefix(self, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, "skills install --agent claude --skill nemo-files") + assert_exit_code(result, 0) + skill_file = tmp_path / ".claude" / "skills" / "nemo-files" / "SKILL.md" + assert skill_file.exists() + assert not (tmp_path / ".claude" / "skills" / "nemo-nemo-files").exists() + assert "name: nemo-files" in skill_file.read_text() + @pytest.mark.parametrize( ("agent", "relative_path"), [ diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py index 9a1eee1b02..d8871f2222 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py @@ -13,6 +13,25 @@ from nemo_platform.cli.commands.skills.base import Skill from nemo_platform.cli.commands.skills.registry import _load_skills_cached, load_skills +KNOWN_SKILL_PRECONDITIONS = frozenset( + { + "agent_config_exists", + "agent_design_complete", + "agent_spec_exists", + "agents_plugin_available", + "clickhouse_ready", + "evaluator_sdk_available", + "guardrails_plugin_available", + "nemo_cli_available", + "nemo_setup_complete", + "platform_running", + "provider_registered", + "secrets_configured", + "user_confirmation_required", + "workspace_exists", + } +) + def setup_function() -> None: """Drop both registry caches before every test. @@ -41,6 +60,7 @@ def test_skill_has_required_fields(self): assert skill.description == "A test skill" assert skill.version == "0.1" assert skill.content == "# Test" + assert skill.preconditions == [] def test_skill_has_source_dir(self): skill = Skill( @@ -89,6 +109,14 @@ def test_each_skill_has_source_dir(self): assert skill.source_dir.is_dir() assert (skill.source_dir / "SKILL.md").exists() + def test_platform_skills_declare_known_preconditions(self): + for name, skill in load_skills().items(): + if skill.source_plugin != "platform": + continue + assert len(skill.preconditions) > 0 + unknown = set(skill.preconditions) - KNOWN_SKILL_PRECONDITIONS + assert not unknown, f"{name} has unknown preconditions: {sorted(unknown)}" + def test_build_agent_templates_are_packaged(self): skill = load_skills()["nemo-build-agent"] assert skill.source_dir is not None From 8150f6e57944ca9ace9267ed207c3100c5dc6d70 Mon Sep 17 00:00:00 2001 From: Matt Kornfield Date: Thu, 6 Aug 2026 16:27:44 +0000 Subject: [PATCH 2/4] fix: address skill catalog review feedback Signed-off-by: Matt Kornfield --- .../cli/commands/skills/base.py | 14 ++++++++++++- .../cli/commands/skills/installer.py | 3 ++- .../cli/commands/skills/registry.py | 8 +++++-- .../nemo_platform_ext/cli/core/formatters.py | 9 +++++--- .../skills/inference/SKILL.md | 21 +++++++++++++------ .../skills/nemo-guardrails/SKILL.md | 1 - .../skills/nemo-model-selection/SKILL.md | 3 --- .../skills/nemo-skill-selection/SKILL.md | 2 -- .../tests/cli/commands/skills/test_base.py | 20 +++++++++++++++++- .../cli/commands/skills/test_installer.py | 11 +++++++++- .../cli/commands/skills/test_registry.py | 9 ++++++++ .../cli/commands/skills/test_skill_content.py | 7 +++++-- .../nemo_platform/cli/commands/skills/base.py | 18 +++++++++++++--- .../cli/commands/skills/installer.py | 3 ++- .../cli/commands/skills/registry.py | 19 ++++++++++------- .../src/nemo_platform/cli/core/formatters.py | 11 ++++++---- .../nemo_platform/skills/inference/SKILL.md | 21 +++++++++++++------ .../skills/nemo-guardrails/SKILL.md | 1 - .../skills/nemo-model-selection/SKILL.md | 3 --- .../skills/nemo-skill-selection/SKILL.md | 2 -- .../cli/commands/skills/test_base.py | 21 ++++++++++++++++++- .../cli/commands/skills/test_installer.py | 12 ++++++++++- .../cli/commands/skills/test_registry.py | 9 ++++++++ .../cli/commands/skills/test_skill_content.py | 9 +++++--- 24 files changed, 182 insertions(+), 55 deletions(-) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py index fd08ef9b86..5edbfe22ee 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/base.py @@ -3,16 +3,28 @@ """Base types and protocol for agent skill installers.""" +import re from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Protocol INSTALLED_SKILL_PREFIX = "nemo-" +SAFE_SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + +def validate_skill_name(skill_name: str) -> None: + """Reject skill names that cannot be used as a single safe path component.""" + if not SAFE_SKILL_NAME_PATTERN.fullmatch(skill_name): + raise ValueError( + f"Invalid skill name {skill_name!r}: expected a single path component " + "containing only letters, numbers, dots, underscores, and dashes" + ) def installed_skill_name(skill_name: str) -> str: """Return the skill name exposed to downstream coding agents.""" + validate_skill_name(skill_name) if skill_name.startswith(INSTALLED_SKILL_PREFIX): return skill_name return f"{INSTALLED_SKILL_PREFIX}{skill_name}" @@ -25,7 +37,6 @@ class Skill: version: str content: str raw: str - preconditions: list[str] = field(default_factory=list) source_dir: Path | None = None # Entry-point name under ``nemo.skills`` (e.g. ``"agents"``, ``"platform"``). # Useful for programmatic filtering; the human-friendly label is built from @@ -37,6 +48,7 @@ class Skill: # they ``uv add``'d, and is what the ``Source`` column in # ``nemo skills list`` renders. source_dist: str | None = None + preconditions: list[str] = field(default_factory=list) class Scope(str, Enum): diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py index 9e354360fd..7ee3170b45 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py @@ -6,7 +6,7 @@ import shutil from pathlib import Path -from nemo_platform_ext.cli.commands.skills.base import Scope, Skill +from nemo_platform_ext.cli.commands.skills.base import Scope, Skill, validate_skill_name class BaseAgentInstaller: @@ -27,6 +27,7 @@ def install(self, scope: Scope, project_root: Path, skills: dict[str, Skill]) -> """Install all skills. Returns list of paths written.""" paths: list[Path] = [] for skill_name, skill in skills.items(): + validate_skill_name(skill_name) path = self.get_install_path(scope, project_root, skill_name) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(self.format_content(skill)) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py index 1f21e427c6..4424c23c4a 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/registry.py @@ -22,7 +22,7 @@ from nemo_platform_ext.cli.commands.skills.agents.codex import CodexInstaller from nemo_platform_ext.cli.commands.skills.agents.cursor import CursorInstaller from nemo_platform_ext.cli.commands.skills.agents.opencode import OpenCodeInstaller -from nemo_platform_ext.cli.commands.skills.base import Skill +from nemo_platform_ext.cli.commands.skills.base import Skill, validate_skill_name from nemo_platform_ext.cli.commands.skills.installer import BaseAgentInstaller logger = logging.getLogger(__name__) @@ -114,8 +114,12 @@ def _load_skill(entry: Path, source_plugin: str | None = None, source_dist: str preconditions = [preconditions] if not isinstance(preconditions, list) or not all(isinstance(item, str) for item in preconditions): raise ValueError(f"Invalid frontmatter in {skill_file}: preconditions must be a list of strings") + skill_name = metadata.get("name", entry.name) + if not isinstance(skill_name, str): + raise ValueError(f"Invalid frontmatter in {skill_file}: name must be a string") + validate_skill_name(skill_name) return Skill( - name=metadata.get("name", entry.name), + name=skill_name, description=metadata.get("description", ""), version=str(metadata.get("version", "0.1")), content=body, diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py index 2b7cf70a5c..20fdcfaced 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/core/formatters.py @@ -695,7 +695,8 @@ def format_output( elif output_format == "table": # Table format. When wrapping is on and --no-truncate is set, drop the # per-column cap so wrapping uses the full terminal width. - assert isinstance(output_columns, list) + if not isinstance(output_columns, list): + raise ValueError("output columns must resolve to a list before formatting table output") effective_wrap_max_width = None if (wrap and not truncate) else wrap_max_width output = format_table( data, @@ -708,14 +709,16 @@ def format_output( print(output) elif output_format == "markdown": # Markdown table format - assert isinstance(output_columns, list) + if not isinstance(output_columns, list): + raise ValueError("output columns must resolve to a list before formatting markdown output") output = format_markdown_table( data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format ) print(output) elif output_format == "csv": # CSV format - assert isinstance(output_columns, list) + if not isinstance(output_columns, list): + raise ValueError("output columns must resolve to a list before formatting CSV output") output = format_csv(data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format) print(output, end="") # CSV already includes newlines elif output_format == "raw": diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md index ff058179a0..d41ff8775f 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md @@ -14,9 +14,6 @@ description: > (`SETUP.md` at the repo root). preconditions: - nemo_setup_complete - - workspace_exists - - provider_registered - - secrets_configured user-invocable: true allowed-tools: Bash, Read, Grep --- @@ -555,9 +552,21 @@ nemo inference providers get nvidia-inference --workspace my-workspace \ ## Cleanup ```bash -# Delete all switchyard test VMs -for vm in $(nemo inference virtual-models list --workspace my-workspace --output-format json \ - | jq -r '.data[].name' | grep vm-); do +# Delete only the VirtualModels created by the examples in this skill. +created_vms=( + vm-random-strong + vm-random-cross + vm-translate-cross + vm-guarded + vm-guarded-full + vm-guarded-translate +) + +printf 'Delete example VirtualModels in my-workspace? Type DELETE to continue: ' +read -r confirmation +test "$confirmation" = "DELETE" + +for vm in "${created_vms[@]}"; do nemo inference virtual-models delete "$vm" --workspace my-workspace done diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md index 6bb275a117..fdf52582a4 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-guardrails/SKILL.md @@ -11,7 +11,6 @@ description: > preconditions: - nemo_setup_complete - workspace_exists - - provider_registered - guardrails_plugin_available user-invocable: true allowed-tools: Bash, Read, Grep diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md index 008aee2a7a..d36d63a09d 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-model-selection/SKILL.md @@ -13,9 +13,6 @@ not-for: - nemo-explore (use first to capture the agent's job, audience, and tools) - nemo-spec (use to persist the design once model is chosen) - nemo-build-agent (use to scaffold the YAML once the spec is signed off) -preconditions: - - nemo_setup_complete - - provider_registered compatibility: nemo-platform >= 0.1.0; read-only; loads references/benchmark_cache.json if present; works offline; safe under any sandbox. maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md index 2080c3fa64..0847099974 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/nemo-skill-selection/SKILL.md @@ -22,8 +22,6 @@ not-for: - superpowers:brainstorming (use for design work unrelated to NeMo Platform) - running downstream workflow or state-changing platform commands (each downstream skill owns its own commands) - loading multiple downstream skills in one turn -preconditions: - - nemo_cli_available compatibility: nemo-platform >= 0.1.0; selection plus a host scan on macOS or Linux; works without an installed CLI (selector can pick setup, which then tells the user how to run the CLI install). maturity: active license: Apache-2.0 diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py index 7d6c8f581f..8c46dcd4f4 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_base.py @@ -3,7 +3,10 @@ """Tests for the skills base module.""" -from nemo_platform_ext.cli.commands.skills.base import Scope, installed_skill_name +from pathlib import Path + +import pytest +from nemo_platform_ext.cli.commands.skills.base import Scope, Skill, installed_skill_name def test_scope_enum_has_project_and_user(): @@ -18,3 +21,18 @@ def test_scope_enum_members(): def test_installed_skill_name_adds_nemo_prefix_once(): assert installed_skill_name("inference") == "nemo-inference" assert installed_skill_name("nemo-files") == "nemo-files" + + +@pytest.mark.parametrize("name", ["../target", "nemo-../../target", "bad/name", "bad\\name", ""]) +def test_installed_skill_name_rejects_path_like_names(name: str): + with pytest.raises(ValueError, match="Invalid skill name"): + installed_skill_name(name) + + +def test_skill_preserves_source_dir_positional_compatibility(): + source_dir = Path("/tmp/source") + + skill = Skill("name", "description", "0.1", "content", "raw", source_dir) + + assert skill.source_dir == source_dir + assert skill.preconditions == [] diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py index 7f96db14be..7f368a1971 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py @@ -5,6 +5,7 @@ from pathlib import Path +import pytest from nemo_platform_ext.cli.commands.skills.base import Scope, Skill from nemo_platform_ext.cli.commands.skills.installer import BaseAgentInstaller @@ -25,7 +26,7 @@ class FakeInstaller(BaseAgentInstaller): display_name = "Fake Agent" supported_scopes = [Scope.PROJECT, Scope.USER] - def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + def get_install_path(self, _scope: Scope, project_root: Path, skill_name: str) -> Path: return project_root / ".fake" / f"{skill_name}.md" def format_content(self, skill: Skill) -> str: @@ -64,6 +65,14 @@ def test_install_returns_correct_paths(tmp_path: Path): assert paths[0] == tmp_path / ".fake" / "alpha.md" +def test_install_rejects_path_like_skill_names(tmp_path: Path): + installer = FakeInstaller() + skills = {"../escape": _make_skill("../escape")} + + with pytest.raises(ValueError, match="Invalid skill name"): + installer.install(Scope.PROJECT, tmp_path, skills) + + def test_install_copies_companion_files(tmp_path: Path): source_dir = tmp_path / "source" / "my-skill" source_dir.mkdir(parents=True) diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_registry.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_registry.py index dad3ac4c6b..6d4d7ec750 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_registry.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_registry.py @@ -281,6 +281,15 @@ def _write_skill_dir(root: Path, name: str, body: str = "# body\n") -> Path: return skill_dir +def test_load_skill_rejects_path_like_frontmatter_name(tmp_path: Path): + skill_dir = tmp_path / "safe-dir" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("---\nname: ../escape\ndescription: t\n---\n# body\n") + + with pytest.raises(ValueError, match="Invalid skill name"): + _load_skill(skill_dir) + + def _fake_provider_ep(name: str, dist_name: str, path: Path) -> _FakeEntryPoint: return _FakeEntryPoint( name=name, diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py index 1908912ab0..3572cee566 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_skill_content.py @@ -104,18 +104,21 @@ def test_raw_has_frontmatter(self): assert skill.raw.startswith("---") def test_each_skill_has_source_dir(self): - for name, skill in load_skills().items(): + for skill in load_skills().values(): assert skill.source_dir is not None assert skill.source_dir.is_dir() assert (skill.source_dir / "SKILL.md").exists() def test_platform_skills_declare_known_preconditions(self): + skills_with_preconditions = [] for name, skill in load_skills().items(): if skill.source_plugin != "platform": continue - assert len(skill.preconditions) > 0 + if skill.preconditions: + skills_with_preconditions.append(name) unknown = set(skill.preconditions) - KNOWN_SKILL_PRECONDITIONS assert not unknown, f"{name} has unknown preconditions: {sorted(unknown)}" + assert skills_with_preconditions def test_build_agent_templates_are_packaged(self): skill = load_skills()["nemo-build-agent"] diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py index fd08ef9b86..bdb3fce74b 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py @@ -3,16 +3,28 @@ """Base types and protocol for agent skill installers.""" -from dataclasses import dataclass, field +import re from enum import Enum -from pathlib import Path from typing import Protocol +from pathlib import Path +from dataclasses import field, dataclass INSTALLED_SKILL_PREFIX = "nemo-" +SAFE_SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + +def validate_skill_name(skill_name: str) -> None: + """Reject skill names that cannot be used as a single safe path component.""" + if not SAFE_SKILL_NAME_PATTERN.fullmatch(skill_name): + raise ValueError( + f"Invalid skill name {skill_name!r}: expected a single path component " + "containing only letters, numbers, dots, underscores, and dashes" + ) def installed_skill_name(skill_name: str) -> str: """Return the skill name exposed to downstream coding agents.""" + validate_skill_name(skill_name) if skill_name.startswith(INSTALLED_SKILL_PREFIX): return skill_name return f"{INSTALLED_SKILL_PREFIX}{skill_name}" @@ -25,7 +37,6 @@ class Skill: version: str content: str raw: str - preconditions: list[str] = field(default_factory=list) source_dir: Path | None = None # Entry-point name under ``nemo.skills`` (e.g. ``"agents"``, ``"platform"``). # Useful for programmatic filtering; the human-friendly label is built from @@ -37,6 +48,7 @@ class Skill: # they ``uv add``'d, and is what the ``Source`` column in # ``nemo skills list`` renders. source_dist: str | None = None + preconditions: list[str] = field(default_factory=list) class Scope(str, Enum): diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py index ad78f0d362..0396272d22 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py @@ -6,7 +6,7 @@ import shutil from pathlib import Path -from nemo_platform.cli.commands.skills.base import Scope, Skill +from nemo_platform.cli.commands.skills.base import Scope, Skill, validate_skill_name class BaseAgentInstaller: @@ -27,6 +27,7 @@ def install(self, scope: Scope, project_root: Path, skills: dict[str, Skill]) -> """Install all skills. Returns list of paths written.""" paths: list[Path] = [] for skill_name, skill in skills.items(): + validate_skill_name(skill_name) path = self.get_install_path(scope, project_root, skill_name) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(self.format_content(skill)) diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py index b4929493d5..04ef191f66 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py @@ -10,20 +10,21 @@ import hashlib import logging +from pathlib import Path +from functools import lru_cache from collections import defaultdict -from collections.abc import Iterable from dataclasses import dataclass -from functools import lru_cache +from collections.abc import Iterable from importlib.metadata import EntryPoint, entry_points -from pathlib import Path import yaml -from nemo_platform.cli.commands.skills.agents.claude import ClaudeInstaller + +from nemo_platform.cli.commands.skills.base import Skill, validate_skill_name +from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller from nemo_platform.cli.commands.skills.agents.codex import CodexInstaller +from nemo_platform.cli.commands.skills.agents.claude import ClaudeInstaller from nemo_platform.cli.commands.skills.agents.cursor import CursorInstaller from nemo_platform.cli.commands.skills.agents.opencode import OpenCodeInstaller -from nemo_platform.cli.commands.skills.base import Skill -from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller logger = logging.getLogger(__name__) @@ -114,8 +115,12 @@ def _load_skill(entry: Path, source_plugin: str | None = None, source_dist: str preconditions = [preconditions] if not isinstance(preconditions, list) or not all(isinstance(item, str) for item in preconditions): raise ValueError(f"Invalid frontmatter in {skill_file}: preconditions must be a list of strings") + skill_name = metadata.get("name", entry.name) + if not isinstance(skill_name, str): + raise ValueError(f"Invalid frontmatter in {skill_file}: name must be a string") + validate_skill_name(skill_name) return Skill( - name=metadata.get("name", entry.name), + name=skill_name, description=metadata.get("description", ""), version=str(metadata.get("version", "0.1")), content=body, diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py b/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py index af80dd81f3..cec240a1f8 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py @@ -632,7 +632,7 @@ def format_output( stream: Emit newline-delimited JSON records. List responses emit one record per item; entity responses emit one record. """ - from nemo_platform.cli.core.table_config import resolve_and_validate_columns, validate_output_columns + from nemo_platform.cli.core.table_config import validate_output_columns, resolve_and_validate_columns timestamp_format = timestamp_format or "iso" @@ -695,7 +695,8 @@ def format_output( elif output_format == "table": # Table format. When wrapping is on and --no-truncate is set, drop the # per-column cap so wrapping uses the full terminal width. - assert isinstance(output_columns, list) + if not isinstance(output_columns, list): + raise ValueError("output columns must resolve to a list before formatting table output") effective_wrap_max_width = None if (wrap and not truncate) else wrap_max_width output = format_table( data, @@ -708,14 +709,16 @@ def format_output( print(output) elif output_format == "markdown": # Markdown table format - assert isinstance(output_columns, list) + if not isinstance(output_columns, list): + raise ValueError("output columns must resolve to a list before formatting markdown output") output = format_markdown_table( data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format ) print(output) elif output_format == "csv": # CSV format - assert isinstance(output_columns, list) + if not isinstance(output_columns, list): + raise ValueError("output columns must resolve to a list before formatting CSV output") output = format_csv(data, columns=output_columns, truncate=truncate, timestamp_format=timestamp_format) print(output, end="") # CSV already includes newlines elif output_format == "raw": diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md index ff058179a0..d41ff8775f 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md @@ -14,9 +14,6 @@ description: > (`SETUP.md` at the repo root). preconditions: - nemo_setup_complete - - workspace_exists - - provider_registered - - secrets_configured user-invocable: true allowed-tools: Bash, Read, Grep --- @@ -555,9 +552,21 @@ nemo inference providers get nvidia-inference --workspace my-workspace \ ## Cleanup ```bash -# Delete all switchyard test VMs -for vm in $(nemo inference virtual-models list --workspace my-workspace --output-format json \ - | jq -r '.data[].name' | grep vm-); do +# Delete only the VirtualModels created by the examples in this skill. +created_vms=( + vm-random-strong + vm-random-cross + vm-translate-cross + vm-guarded + vm-guarded-full + vm-guarded-translate +) + +printf 'Delete example VirtualModels in my-workspace? Type DELETE to continue: ' +read -r confirmation +test "$confirmation" = "DELETE" + +for vm in "${created_vms[@]}"; do nemo inference virtual-models delete "$vm" --workspace my-workspace done diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md index 6bb275a117..fdf52582a4 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-guardrails/SKILL.md @@ -11,7 +11,6 @@ description: > preconditions: - nemo_setup_complete - workspace_exists - - provider_registered - guardrails_plugin_available user-invocable: true allowed-tools: Bash, Read, Grep diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md index 008aee2a7a..d36d63a09d 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-model-selection/SKILL.md @@ -13,9 +13,6 @@ not-for: - nemo-explore (use first to capture the agent's job, audience, and tools) - nemo-spec (use to persist the design once model is chosen) - nemo-build-agent (use to scaffold the YAML once the spec is signed off) -preconditions: - - nemo_setup_complete - - provider_registered compatibility: nemo-platform >= 0.1.0; read-only; loads references/benchmark_cache.json if present; works offline; safe under any sandbox. maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md index 2080c3fa64..0847099974 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/nemo-skill-selection/SKILL.md @@ -22,8 +22,6 @@ not-for: - superpowers:brainstorming (use for design work unrelated to NeMo Platform) - running downstream workflow or state-changing platform commands (each downstream skill owns its own commands) - loading multiple downstream skills in one turn -preconditions: - - nemo_cli_available compatibility: nemo-platform >= 0.1.0; selection plus a host scan on macOS or Linux; works without an installed CLI (selector can pick setup, which then tells the user how to run the CLI install). maturity: active license: Apache-2.0 diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py index f83e13bc1a..6e4e7727bb 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py @@ -3,7 +3,11 @@ """Tests for the skills base module.""" -from nemo_platform.cli.commands.skills.base import Scope, installed_skill_name +from pathlib import Path + +import pytest + +from nemo_platform.cli.commands.skills.base import Scope, Skill, installed_skill_name def test_scope_enum_has_project_and_user(): @@ -18,3 +22,18 @@ def test_scope_enum_members(): def test_installed_skill_name_adds_nemo_prefix_once(): assert installed_skill_name("inference") == "nemo-inference" assert installed_skill_name("nemo-files") == "nemo-files" + + +@pytest.mark.parametrize("name", ["../target", "nemo-../../target", "bad/name", "bad\\name", ""]) +def test_installed_skill_name_rejects_path_like_names(name: str): + with pytest.raises(ValueError, match="Invalid skill name"): + installed_skill_name(name) + + +def test_skill_preserves_source_dir_positional_compatibility(): + source_dir = Path("/tmp/source") + + skill = Skill("name", "description", "0.1", "content", "raw", source_dir) + + assert skill.source_dir == source_dir + assert skill.preconditions == [] diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py index 3ee1d823ab..593c78c066 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py @@ -5,6 +5,8 @@ from pathlib import Path +import pytest + from nemo_platform.cli.commands.skills.base import Scope, Skill from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller @@ -25,7 +27,7 @@ class FakeInstaller(BaseAgentInstaller): display_name = "Fake Agent" supported_scopes = [Scope.PROJECT, Scope.USER] - def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + def get_install_path(self, _scope: Scope, project_root: Path, skill_name: str) -> Path: return project_root / ".fake" / f"{skill_name}.md" def format_content(self, skill: Skill) -> str: @@ -64,6 +66,14 @@ def test_install_returns_correct_paths(tmp_path: Path): assert paths[0] == tmp_path / ".fake" / "alpha.md" +def test_install_rejects_path_like_skill_names(tmp_path: Path): + installer = FakeInstaller() + skills = {"../escape": _make_skill("../escape")} + + with pytest.raises(ValueError, match="Invalid skill name"): + installer.install(Scope.PROJECT, tmp_path, skills) + + def test_install_copies_companion_files(tmp_path: Path): source_dir = tmp_path / "source" / "my-skill" source_dir.mkdir(parents=True) diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_registry.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_registry.py index f14c83d4a0..f0e887461a 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_registry.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_registry.py @@ -281,6 +281,15 @@ def _write_skill_dir(root: Path, name: str, body: str = "# body\n") -> Path: return skill_dir +def test_load_skill_rejects_path_like_frontmatter_name(tmp_path: Path): + skill_dir = tmp_path / "safe-dir" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("---\nname: ../escape\ndescription: t\n---\n# body\n") + + with pytest.raises(ValueError, match="Invalid skill name"): + _load_skill(skill_dir) + + def _fake_provider_ep(name: str, dist_name: str, path: Path) -> _FakeEntryPoint: return _FakeEntryPoint( name=name, diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py index d8871f2222..e2975f06c4 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py @@ -11,7 +11,7 @@ from pathlib import Path from nemo_platform.cli.commands.skills.base import Skill -from nemo_platform.cli.commands.skills.registry import _load_skills_cached, load_skills +from nemo_platform.cli.commands.skills.registry import load_skills, _load_skills_cached KNOWN_SKILL_PRECONDITIONS = frozenset( { @@ -104,18 +104,21 @@ def test_raw_has_frontmatter(self): assert skill.raw.startswith("---") def test_each_skill_has_source_dir(self): - for name, skill in load_skills().items(): + for skill in load_skills().values(): assert skill.source_dir is not None assert skill.source_dir.is_dir() assert (skill.source_dir / "SKILL.md").exists() def test_platform_skills_declare_known_preconditions(self): + skills_with_preconditions = [] for name, skill in load_skills().items(): if skill.source_plugin != "platform": continue - assert len(skill.preconditions) > 0 + if skill.preconditions: + skills_with_preconditions.append(name) unknown = set(skill.preconditions) - KNOWN_SKILL_PRECONDITIONS assert not unknown, f"{name} has unknown preconditions: {sorted(unknown)}" + assert skills_with_preconditions def test_build_agent_templates_are_packaged(self): skill = load_skills()["nemo-build-agent"] From dcda5148cfbaca4ef65b396e48ec32679ccf61c2 Mon Sep 17 00:00:00 2001 From: Matt Kornfield Date: Thu, 6 Aug 2026 18:30:44 +0000 Subject: [PATCH 3/4] fix: harden skill installer cleanup handling Signed-off-by: Matt Kornfield --- .../cli/commands/skills/installer.py | 9 +++++++ .../skills/inference/SKILL.md | 7 ++++-- .../cli/commands/skills/test_installer.py | 24 +++++++++++++++++-- .../cli/commands/skills/installer.py | 9 +++++++ .../nemo_platform/skills/inference/SKILL.md | 7 ++++-- .../cli/commands/skills/test_installer.py | 24 +++++++++++++++++-- 6 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py index 7ee3170b45..7aae11ab47 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py @@ -26,9 +26,18 @@ def format_content(self, skill: Skill) -> str: def install(self, scope: Scope, project_root: Path, skills: dict[str, Skill]) -> list[Path]: """Install all skills. Returns list of paths written.""" paths: list[Path] = [] + pending: list[tuple[Skill, Path]] = [] + destinations: dict[Path, str] = {} for skill_name, skill in skills.items(): validate_skill_name(skill_name) path = self.get_install_path(scope, project_root, skill_name) + if path in destinations: + raise ValueError( + f"Multiple skills resolve to {path}: {destinations[path]!r} and {skill_name!r}" + ) + destinations[path] = skill_name + pending.append((skill, path)) + for skill, path in pending: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(self.format_content(skill)) self._copy_companion_files(skill, path) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md index d41ff8775f..222903c077 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/skills/inference/SKILL.md @@ -562,9 +562,12 @@ created_vms=( vm-guarded-translate ) -printf 'Delete example VirtualModels in my-workspace? Type DELETE to continue: ' +printf 'Delete example resources in my-workspace (VirtualModels: %s; provider: nvidia-inference; secret: nvidia-inference-key; workspace: my-workspace)? Type DELETE to continue: ' "${created_vms[*]}" read -r confirmation -test "$confirmation" = "DELETE" +if [ "$confirmation" != "DELETE" ]; then + echo "Cleanup cancelled." >&2 + exit 1 +fi for vm in "${created_vms[@]}"; do nemo inference virtual-models delete "$vm" --workspace my-workspace diff --git a/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py b/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py index 7f368a1971..53f31b3c59 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py +++ b/packages/nemo_platform_ext/tests/cli/commands/skills/test_installer.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from nemo_platform_ext.cli.commands.skills.base import Scope, Skill +from nemo_platform_ext.cli.commands.skills.base import Scope, Skill, installed_skill_name from nemo_platform_ext.cli.commands.skills.installer import BaseAgentInstaller @@ -26,13 +26,20 @@ class FakeInstaller(BaseAgentInstaller): display_name = "Fake Agent" supported_scopes = [Scope.PROJECT, Scope.USER] - def get_install_path(self, _scope: Scope, project_root: Path, skill_name: str) -> Path: + def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + del scope return project_root / ".fake" / f"{skill_name}.md" def format_content(self, skill: Skill) -> str: return skill.raw +class NormalizingInstaller(FakeInstaller): + def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + del scope + return project_root / ".fake" / f"{installed_skill_name(skill_name)}.md" + + def test_install_creates_files_for_each_skill(tmp_path: Path): installer = FakeInstaller() skills = {"alpha": _make_skill("alpha"), "beta": _make_skill("beta")} @@ -73,6 +80,19 @@ def test_install_rejects_path_like_skill_names(tmp_path: Path): installer.install(Scope.PROJECT, tmp_path, skills) +def test_install_rejects_duplicate_normalized_destinations_before_writing(tmp_path: Path): + installer = NormalizingInstaller() + skills = { + "foo": _make_skill("foo"), + "nemo-foo": _make_skill("nemo-foo"), + } + + with pytest.raises(ValueError, match="Multiple skills resolve to"): + installer.install(Scope.PROJECT, tmp_path, skills) + + assert not (tmp_path / ".fake" / "nemo-foo.md").exists() + + def test_install_copies_companion_files(tmp_path: Path): source_dir = tmp_path / "source" / "my-skill" source_dir.mkdir(parents=True) diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py index 0396272d22..d60585d767 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py @@ -26,9 +26,18 @@ def format_content(self, skill: Skill) -> str: def install(self, scope: Scope, project_root: Path, skills: dict[str, Skill]) -> list[Path]: """Install all skills. Returns list of paths written.""" paths: list[Path] = [] + pending: list[tuple[Skill, Path]] = [] + destinations: dict[Path, str] = {} for skill_name, skill in skills.items(): validate_skill_name(skill_name) path = self.get_install_path(scope, project_root, skill_name) + if path in destinations: + raise ValueError( + f"Multiple skills resolve to {path}: {destinations[path]!r} and {skill_name!r}" + ) + destinations[path] = skill_name + pending.append((skill, path)) + for skill, path in pending: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(self.format_content(skill)) self._copy_companion_files(skill, path) diff --git a/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md b/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md index d41ff8775f..222903c077 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md +++ b/sdk/python/nemo-platform/src/nemo_platform/skills/inference/SKILL.md @@ -562,9 +562,12 @@ created_vms=( vm-guarded-translate ) -printf 'Delete example VirtualModels in my-workspace? Type DELETE to continue: ' +printf 'Delete example resources in my-workspace (VirtualModels: %s; provider: nvidia-inference; secret: nvidia-inference-key; workspace: my-workspace)? Type DELETE to continue: ' "${created_vms[*]}" read -r confirmation -test "$confirmation" = "DELETE" +if [ "$confirmation" != "DELETE" ]; then + echo "Cleanup cancelled." >&2 + exit 1 +fi for vm in "${created_vms[@]}"; do nemo inference virtual-models delete "$vm" --workspace my-workspace diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py index 593c78c066..9a0acd28a3 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py @@ -7,7 +7,7 @@ import pytest -from nemo_platform.cli.commands.skills.base import Scope, Skill +from nemo_platform.cli.commands.skills.base import Scope, Skill, installed_skill_name from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller @@ -27,13 +27,20 @@ class FakeInstaller(BaseAgentInstaller): display_name = "Fake Agent" supported_scopes = [Scope.PROJECT, Scope.USER] - def get_install_path(self, _scope: Scope, project_root: Path, skill_name: str) -> Path: + def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + del scope return project_root / ".fake" / f"{skill_name}.md" def format_content(self, skill: Skill) -> str: return skill.raw +class NormalizingInstaller(FakeInstaller): + def get_install_path(self, scope: Scope, project_root: Path, skill_name: str) -> Path: + del scope + return project_root / ".fake" / f"{installed_skill_name(skill_name)}.md" + + def test_install_creates_files_for_each_skill(tmp_path: Path): installer = FakeInstaller() skills = {"alpha": _make_skill("alpha"), "beta": _make_skill("beta")} @@ -74,6 +81,19 @@ def test_install_rejects_path_like_skill_names(tmp_path: Path): installer.install(Scope.PROJECT, tmp_path, skills) +def test_install_rejects_duplicate_normalized_destinations_before_writing(tmp_path: Path): + installer = NormalizingInstaller() + skills = { + "foo": _make_skill("foo"), + "nemo-foo": _make_skill("nemo-foo"), + } + + with pytest.raises(ValueError, match="Multiple skills resolve to"): + installer.install(Scope.PROJECT, tmp_path, skills) + + assert not (tmp_path / ".fake" / "nemo-foo.md").exists() + + def test_install_copies_companion_files(tmp_path: Path): source_dir = tmp_path / "source" / "my-skill" source_dir.mkdir(parents=True) From 07df5d9e8a152658d194bc478b370bd2b3f4a955 Mon Sep 17 00:00:00 2001 From: Matt Kornfield Date: Fri, 7 Aug 2026 16:48:44 +0000 Subject: [PATCH 4/4] fix: refresh skill catalog vendored output Signed-off-by: Matt Kornfield --- .../cli/commands/skills/installer.py | 4 +--- .../src/nemo_platform/cli/commands/skills/base.py | 4 ++-- .../nemo_platform/cli/commands/skills/installer.py | 4 +--- .../nemo_platform/cli/commands/skills/registry.py | 13 ++++++------- .../src/nemo_platform/cli/core/formatters.py | 2 +- .../cli/commands/skills/test_base.py | 1 - .../cli/commands/skills/test_installer.py | 1 - .../cli/commands/skills/test_skill_content.py | 2 +- 8 files changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py index 7aae11ab47..c92228bc00 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/skills/installer.py @@ -32,9 +32,7 @@ def install(self, scope: Scope, project_root: Path, skills: dict[str, Skill]) -> validate_skill_name(skill_name) path = self.get_install_path(scope, project_root, skill_name) if path in destinations: - raise ValueError( - f"Multiple skills resolve to {path}: {destinations[path]!r} and {skill_name!r}" - ) + raise ValueError(f"Multiple skills resolve to {path}: {destinations[path]!r} and {skill_name!r}") destinations[path] = skill_name pending.append((skill, path)) for skill, path in pending: diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py index bdb3fce74b..5edbfe22ee 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/base.py @@ -4,10 +4,10 @@ """Base types and protocol for agent skill installers.""" import re +from dataclasses import dataclass, field from enum import Enum -from typing import Protocol from pathlib import Path -from dataclasses import field, dataclass +from typing import Protocol INSTALLED_SKILL_PREFIX = "nemo-" SAFE_SKILL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py index d60585d767..f21635da76 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/installer.py @@ -32,9 +32,7 @@ def install(self, scope: Scope, project_root: Path, skills: dict[str, Skill]) -> validate_skill_name(skill_name) path = self.get_install_path(scope, project_root, skill_name) if path in destinations: - raise ValueError( - f"Multiple skills resolve to {path}: {destinations[path]!r} and {skill_name!r}" - ) + raise ValueError(f"Multiple skills resolve to {path}: {destinations[path]!r} and {skill_name!r}") destinations[path] = skill_name pending.append((skill, path)) for skill, path in pending: diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py index 04ef191f66..466692488c 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/skills/registry.py @@ -10,21 +10,20 @@ import hashlib import logging -from pathlib import Path -from functools import lru_cache from collections import defaultdict -from dataclasses import dataclass from collections.abc import Iterable +from dataclasses import dataclass +from functools import lru_cache from importlib.metadata import EntryPoint, entry_points +from pathlib import Path import yaml - -from nemo_platform.cli.commands.skills.base import Skill, validate_skill_name -from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller -from nemo_platform.cli.commands.skills.agents.codex import CodexInstaller from nemo_platform.cli.commands.skills.agents.claude import ClaudeInstaller +from nemo_platform.cli.commands.skills.agents.codex import CodexInstaller from nemo_platform.cli.commands.skills.agents.cursor import CursorInstaller from nemo_platform.cli.commands.skills.agents.opencode import OpenCodeInstaller +from nemo_platform.cli.commands.skills.base import Skill, validate_skill_name +from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller logger = logging.getLogger(__name__) diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py b/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py index cec240a1f8..6d7d2f8ff4 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/core/formatters.py @@ -632,7 +632,7 @@ def format_output( stream: Emit newline-delimited JSON records. List responses emit one record per item; entity responses emit one record. """ - from nemo_platform.cli.core.table_config import validate_output_columns, resolve_and_validate_columns + from nemo_platform.cli.core.table_config import resolve_and_validate_columns, validate_output_columns timestamp_format = timestamp_format or "iso" diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py index 6e4e7727bb..276947dcf4 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_base.py @@ -6,7 +6,6 @@ from pathlib import Path import pytest - from nemo_platform.cli.commands.skills.base import Scope, Skill, installed_skill_name diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py index 9a0acd28a3..27530ac7c0 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_installer.py @@ -6,7 +6,6 @@ from pathlib import Path import pytest - from nemo_platform.cli.commands.skills.base import Scope, Skill, installed_skill_name from nemo_platform.cli.commands.skills.installer import BaseAgentInstaller diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py index e2975f06c4..0190524524 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/skills/test_skill_content.py @@ -11,7 +11,7 @@ from pathlib import Path from nemo_platform.cli.commands.skills.base import Skill -from nemo_platform.cli.commands.skills.registry import load_skills, _load_skills_cached +from nemo_platform.cli.commands.skills.registry import _load_skills_cached, load_skills KNOWN_SKILL_PRECONDITIONS = frozenset( {