Skip to content
Open
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
137 changes: 114 additions & 23 deletions docs/plans/skill-format.mdx

Large diffs are not rendered by default.

34 changes: 32 additions & 2 deletions docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2074,7 +2074,7 @@ Manage agent memory: run day-zero onboarding and view memory statistics.
Author and manage agent skills. A skill is a directory whose only required file is `SKILL.md`; GAIA implements the [Agent Skills](https://agentskills.io) open standard, so a Claude Code skill loads unchanged.

```bash
gaia skill {list,info,create,import,export} [OPTIONS]
gaia skill {list,info,create,import,export,migrate} [OPTIONS]
```

Skills are discovered from three roots, highest precedence first:
Expand All @@ -2094,6 +2094,7 @@ Skills are discovered from three roots, highest precedence first:
| `create <name>` | Scaffold a new skill directory |
| `import <source>` | Copy a skill folder, `.zip`, or URL into `~/.gaia/skills/` |
| `export <name>` | Export a skill to a `.zip` bundle |
| `migrate <source>` | Convert an OpenClaw or Hermes skill to GAIA format |

<Tabs>
<Tab title="list">
Expand Down Expand Up @@ -2167,6 +2168,29 @@ Skills are discovered from three roots, highest precedence first:

Bundles the whole skill directory. Import it elsewhere with `gaia skill import <file>.zip`.
</Tab>

<Tab title="migrate">
```bash
gaia skill migrate <source> [--from openclaw|hermes|auto] [OPTIONS]
```

| Option | Description |
|--------|-------------|
| `--from` | Source format: `openclaw` \| `hermes` \| `auto` (default — detect from `metadata.<vendor>`) |
| `--out` | Write migrated skills here instead of installing into `~/.gaia/skills/` |
| `--name` | Migrate under this name (single-skill sources only) |
| `--force` | Replace an existing skill of the same name |
| `--dry-run` | Report what would be migrated without writing anything |
| `--json` | Emit the migration report as JSON |

`<source>` is one skill directory, its `SKILL.md`, or a directory **of** skill directories — so a whole ClawHub checkout migrates in one command.

The foreign `metadata.<vendor>` namespace becomes a `metadata.gaia` block. Fields GAIA models fully are consumed; anything partially or un-modeled stays under `metadata.<vendor>` and is listed in the report, so nothing is dropped silently. **Every migrated skill lands at the `experimental` tier**, whatever the source claimed — the same trust reset `import` applies.

<Warning>
**A source needing a local capability is refused, not downgraded.** `requires.bins` maps to `shell:execute:<bin>` and `requires.config` to `filesystem:read:<path>`; both need the deferred permission sandbox ([#1019](https://github.com/amd/gaia/issues/1019)), so the skill is reported unmigratable with the reason rather than quietly stripped of the permission. Other skills in the same batch still migrate — exit code is `4` when any were refused.
</Warning>
</Tab>
</Tabs>

**Examples:**
Expand All @@ -2193,6 +2217,12 @@ gaia skill import ~/.claude/skills/my-skill # take ownership, resets to exper
gaia skill export web-research --output ./web-research.zip
gaia skill import ./web-research.zip --name web-research-copy
```

```bash Bring an OpenClaw skill over
gaia skill migrate ./my-openclaw-skill --from openclaw
gaia skill migrate ~/clawhub-skills --dry-run # whole collection, no writes
gaia skill migrate ~/clawhub-skills --json | jq '.skills[] | select(.migrated == false)'
```
</CodeGroup>

**Exit codes:** `0` success · `2` missing/unknown subcommand · `3` skill not found · `4` invalid skill (bad manifest, name collision without `--force`, or a discovery root containing a malformed skill).
Expand All @@ -2214,7 +2244,7 @@ class WebAgent(Agent):
</Warning>

<Note>
`gaia skill install`, `remove`, `search`, `publish`, and `migrate` are **not implemented** and do not parse — they need the skill registry and land with the marketplace ([#2467](https://github.com/amd/gaia/issues/2467)). Use `import` / `export` for local distribution.
`gaia skill install`, `remove`, `search`, and `publish` are **not implemented** and do not parse — they need the skill registry and land with the marketplace ([#2467](https://github.com/amd/gaia/issues/2467)). Use `import` / `export` for local distribution, and `migrate` to bring an OpenClaw or Hermes skill over.
</Note>

[→ Agent Skills Spec](/spec/agent-skills) · [→ Skill Format Reference](/plans/skill-format)
Expand Down
13 changes: 12 additions & 1 deletion docs/spec/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ icon: "puzzle-piece"
**Shipped:** the `gaia.skills` runtime — `SKILL.md` parser + validator, the three
discovery roots with auditable precedence, progressive disclosure,
`<skill>/<tool>` tool registration, `Agent.load_skill` / `unload_skill`, and
`gaia skill list|info|create|import|export`.
`gaia skill list|info|create|import|export`. Plus `gaia skill migrate --from
openclaw|hermes|auto` ([#692](https://github.com/amd/gaia/issues/692)), which
converts a third-party skill to GAIA format at the `experimental` tier.

**Still proposed:** the permission sandbox and tier-ceiling enforcement
([#1019](https://github.com/amd/gaia/issues/1019)), the skill marketplace and
Expand Down Expand Up @@ -451,6 +453,15 @@ as an instruction-only skill with no changes. Claude Code skill libraries in
import <path>` copies one into `~/.gaia/skills/` and stamps it
`experimental` for explicit promotion.

**Third-party format → GAIA.** Hermes and OpenClaw/ClawHub nest their fields
under `metadata.<vendor>`, the same pattern `metadata.gaia` uses, so
`gaia skill migrate --from openclaw|hermes|auto` reads the foreign namespace and
writes a `metadata.gaia` block ([#692](https://github.com/amd/gaia/issues/692)).
Fields GAIA models are consumed; the rest stay under `metadata.<vendor>` and are
reported. Migrated skills land `experimental`, and one needing a local capability
GAIA cannot yet enforce is refused with the reason rather than downgraded — see
[Skill Format → Cross-format compatibility](/plans/skill-format#cross-format-compatibility--migration).

**GAIA skill → standard runtime.** A GAIA skill degrades gracefully: the
frontmatter `name`/`description` and Markdown body are standard, so the
instructions work anywhere. Bundled scripts run if the host can execute them.
Expand Down
31 changes: 31 additions & 0 deletions src/gaia/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def _register_tools(self):
parse_skill,
parse_skill_file,
parse_skill_metadata,
reset_security_tier,
split_frontmatter,
validate_skill,
)
from gaia.skills.loader import register_skill_tools, unregister_skill_tools
Expand All @@ -62,6 +64,20 @@ def _register_tools(self):
reset_default_manager,
user_skills_dir,
)
from gaia.skills.migrate import (
HERMES_NAMESPACES,
OPENCLAW_NAMESPACES,
VENDOR_HERMES,
VENDOR_OPENCLAW,
VENDORS,
MigrationOutcome,
detect_vendor,
find_source_skills,
format_report,
install_migrated,
migrate_skill_dir,
migrate_text,
)
from gaia.skills.permissions import (
CONNECTOR_BRIDGED_DOMAINS,
LOCAL_CAPABILITY_DOMAINS,
Expand All @@ -80,6 +96,8 @@ def _register_tools(self):
"parse_skill",
"parse_skill_file",
"parse_skill_metadata",
"split_frontmatter",
"reset_security_tier",
"validate_skill",
"SKILL_FILENAME",
"SKILL_TOOLS_FILENAME",
Expand All @@ -96,6 +114,19 @@ def _register_tools(self):
"get_default_manager",
"reset_default_manager",
"user_skills_dir",
# Migration (OpenClaw / Hermes → GAIA)
"MigrationOutcome",
"detect_vendor",
"migrate_text",
"migrate_skill_dir",
"find_source_skills",
"install_migrated",
"format_report",
"VENDORS",
"VENDOR_OPENCLAW",
"VENDOR_HERMES",
"OPENCLAW_NAMESPACES",
"HERMES_NAMESPACES",
# Tools
"register_skill_tools",
"unregister_skill_tools",
Expand Down
149 changes: 147 additions & 2 deletions src/gaia/skills/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@
Skill,
SkillTool,
parse_skill_file,
reset_security_tier,
)
from gaia.skills.manager import SkillManager
from gaia.skills.migrate import (
VENDORS,
find_source_skills,
format_report,
install_migrated,
migrate_skill_dir,
)

log = get_logger(__name__)

Expand Down Expand Up @@ -122,6 +130,54 @@ def add_subparser(subparsers: argparse._SubParsersAction) -> None:
"--output", default=None, help="Destination .zip (default: ./<name>.zip)"
)

p_migrate = sub.add_parser(
"migrate",
help="Convert an OpenClaw or Hermes skill to GAIA format (stamped experimental)",
description=(
"Convert a foreign skill to GAIA's SKILL.md. Point it at one skill "
"directory or at a directory of them (a ClawHub checkout). Vendor fields "
"GAIA does not model are preserved under metadata.<vendor> and reported. "
"Every migrated skill lands at the experimental security tier."
),
)
p_migrate.add_argument(
"source", help="Skill directory, its SKILL.md, or a directory of skills"
)
p_migrate.add_argument(
"--from",
dest="vendor",
default="auto",
choices=[*VENDORS, "auto"],
help="Source format (default: auto-detect from metadata.<vendor>)",
)
p_migrate.add_argument(
"--out",
dest="out",
default=None,
help="Write migrated skills here instead of installing into ~/.gaia/skills",
)
p_migrate.add_argument(
"--name",
default=None,
help="Migrate under this name (single-skill sources only)",
)
p_migrate.add_argument(
"--force",
action="store_true",
help="Replace an existing skill of the same name",
)
p_migrate.add_argument(
"--dry-run",
action="store_true",
help="Report what would be migrated without writing anything",
)
p_migrate.add_argument(
"--json",
action="store_true",
dest="as_json",
help="Emit the migration report as JSON",
)


def handle(args: argparse.Namespace) -> int:
"""Dispatch a parsed ``gaia skill ...`` command. Returns an exit code."""
Expand All @@ -136,6 +192,7 @@ def handle(args: argparse.Namespace) -> int:
"create": _handle_create,
"import": _handle_import,
"export": _handle_export,
"migrate": _handle_migrate,
}
handler = handlers.get(action)
if handler is None:
Expand Down Expand Up @@ -328,8 +385,7 @@ def _handle_import(args: argparse.Namespace) -> int:
# Imported skills re-earn trust: stamp experimental regardless of claim.
imported = parse_skill_file(target, check_directory_name=False)
imported.name = name
previous_tier = imported.gaia.security_tier
imported.gaia.security_tier = "experimental"
previous_tier = reset_security_tier(imported)
imported.write(target / SKILL_FILENAME)

print(f"✅ Imported skill '{name}' into {target}")
Expand Down Expand Up @@ -362,6 +418,95 @@ def _handle_export(args: argparse.Namespace) -> int:
return EXIT_OK


def _handle_migrate(args: argparse.Namespace) -> int:
sources = find_source_skills(args.source)
if len(sources) > 1 and args.name:
sys.stderr.write(
f"❌ --name applies to a single skill, but {args.source} holds "
f"{len(sources)} skills. Migrate them one at a time to rename, or drop "
"--name to keep each skill's own name.\n"
)
return EXIT_USAGE

destination = Path(args.out).expanduser() if args.out else _manager().user_root
outcomes = [
migrate_skill_dir(source, vendor=args.vendor, name=args.name)
for source in sources
]

installed: dict[str, str] = {}
install_errors: dict[str, str] = {}
if not args.dry_run:
for outcome in outcomes:
if not outcome.migrated:
continue
try:
target = install_migrated(outcome, destination, force=args.force)
except SkillError as exc:
# One collision must not hide the report for the rest of a batch.
# Tracked apart from `blockers`: the skill migrated fine, it just
# could not be written, which is a different thing to tell a user.
install_errors[outcome.name] = f"{exc}"
continue
installed[outcome.name] = str(target)

migrated = [o for o in outcomes if o.migrated]
refused = [o for o in outcomes if not o.migrated]

if getattr(args, "as_json", False):
payload = {
"source": str(args.source),
"destination": None if args.dry_run else str(destination),
"dry_run": bool(args.dry_run),
"total": len(outcomes),
"migrated": len(migrated),
"unmigratable": len(refused),
"install_errors": install_errors,
"skills": [
{
**o.to_dict(),
"installed_at": installed.get(o.name),
"install_error": install_errors.get(o.name),
}
for o in outcomes
],
}
print(json.dumps(payload, indent=2))
return EXIT_INVALID if (refused or install_errors) else EXIT_OK

report = format_report(outcomes)
if report:
print(report, end="")

verb = "Would migrate" if args.dry_run else "Migrated"
print(f"{verb} {len(migrated)}/{len(outcomes)} skill(s) to GAIA format.")
if installed and not args.dry_run:
print(f" Installed {len(installed)} into {destination}")
print(
" Every migrated skill is at the experimental tier — review it, then: "
f"gaia skill info {next(iter(installed))}"
)
if install_errors:
print(
f"\n{len(install_errors)} skill(s) migrated but could not be written:",
file=sys.stderr,
)
for name, message in install_errors.items():
print(f" {name}: {message}", file=sys.stderr)
if not refused:
return EXIT_INVALID
if refused:
print(
f"\n{len(refused)} skill(s) could not be migrated (see ✗ above). v1 accepts "
"instruction-only and connector-backed skills; a skill needing local "
"shell, filesystem, database, desktop, or env access is refused rather "
"than silently stripped of the permission.",
file=sys.stderr,
)
return EXIT_INVALID
return EXIT_OK


# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
Expand Down
Loading
Loading