Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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}
Comment thread
mckornfield marked this conversation as resolved.
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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,32 @@

"""Base types and protocol for agent skill installers."""

from dataclasses import dataclass
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}"
Comment thread
mckornfield marked this conversation as resolved.


@dataclass
class Skill:
Expand All @@ -27,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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -26,8 +26,16 @@ 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -107,12 +107,24 @@ 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")
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,
raw=raw,
preconditions=preconditions,
source_dir=entry,
source_plugin=source_plugin,
source_dist=source_dist,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -692,9 +693,10 @@ 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.
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,
Expand All @@ -706,15 +708,17 @@ def format_output(
)
print(output)
elif output_format == "markdown":
assert isinstance(output_columns, list)
# Markdown table format
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":
assert isinstance(output_columns, list)
# CSV format
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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ 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
user-invocable: true
allowed-tools: Bash, Read, Grep
---
Expand Down Expand Up @@ -75,7 +77,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 `<name>` 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 `<name>` as positional.
- **`nemo inference virtual-models create`** takes `<name>` as positional.
- **There is no `nemo inference chat completions create` command.** Use
`nemo inference gateway model post <path> <vm-name> --workspace <ws> --body '<json>'`.
- **`example` is not a valid `--services` arg.** Valid services: `audit`,
Expand Down Expand Up @@ -250,7 +252,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"}
Expand All @@ -271,7 +273,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"}
Expand All @@ -294,7 +296,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}}]'
Expand All @@ -315,7 +317,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-entity-id>","backend_format":"OPENAI_CHAT"}]' \
--response-middleware '[{
"name":"nemo-guardrails",
Expand All @@ -327,7 +329,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-entity-id>","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"}]'
Expand Down Expand Up @@ -356,7 +358,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"},
Expand Down Expand Up @@ -550,10 +552,25 @@ nemo inference providers get nvidia-inference --workspace my-workspace \
## Cleanup

```bash
# Delete all switchyard test VMs
for vm in $(nemo virtual-models list --workspace my-workspace --output-format json \
| jq -r '.data[].name' | grep vm-); do
nemo virtual-models delete "$vm" --workspace my-workspace
# 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 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
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
done

nemo inference providers delete nvidia-inference --workspace my-workspace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>-spec/agent.yaml; validates through nemo agents create; supports nemo-agents-spec-v1 configs; safe under sandbox.
maturity: active
license: Apache-2.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading