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
32 changes: 32 additions & 0 deletions docs/user-guide/asset-registry-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,38 @@ Behavior:
- On success the command logs a single confirmation line with the absolute path of the written file.
- A missing skill or file returns a clear error such as `Problem getting SKILL.md for 'platform/missing': ...`.

### Download an Entire Skill

Download a complete skill bundle — its `SKILL.md` plus all reference files — into a new directory named after the skill. Folder structure inside the skill is preserved. Use this to vendor a full copy of a skill into a repo or filesystem in a single command.

Download a platform skill into the current directory:

```
content-cli asset-registry skills download --path platform/content-cli-setup

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have content-cli asset-registry skills get --path {path of the skill} --file {specific file}. Adding content-cli asset-registry skills download seems confusing, since we can just change the behavior of asset-registry skills get to download the whole bundle when the --file option is ommitted.

cc: Zgjim Haziri (@ZgjimHaziri) Kastriot Salihu (@ksalihu) Would appreciate your opinions here too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree 👍

```

Download an asset skill into a specific parent directory:

```
content-cli asset-registry skills download \
--path asset/BOARD_V2/asset-studio-board-v2 \
--output ./skills
```

The command creates a new directory named after the last segment of `--path` (the skill name) inside `--output`. For example, the second command above writes to `./skills/asset-studio-board-v2/`.

Options:

- `--path <path>` (required) – Skill path from `asset-registry skills list` (e.g. `platform/<skill>` or `asset/<assetType>/<skill>`).
- `--output <output>` – Parent directory in which the skill directory will be created. Defaults to the current working directory. Created automatically if it does not exist.

Behavior:

- The CLI first fetches the skill's file manifest, then downloads each file to `{output}/{skillName}/{relativePath}`, creating intermediate directories as needed.
- Existing local files are overwritten without prompting (consistent with `skills get`).
- On success the command logs a summary line with the number of files written and the absolute path of the skill directory.
- A missing skill returns a clear error such as `Problem listing skill files for 'platform/missing': ...`. If a listed file cannot be downloaded, the command stops on the first failure and reports which file failed; files already written are left in place.

## Troubleshooting

If the asset registry is disabled on your team, commands fail with:
Expand Down
7 changes: 7 additions & 0 deletions src/commands/asset-registry/asset-registry-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export class AssetRegistryApi {
.catch((e) => handleAssetRegistryApiError(operation, e));
}

public async listSkillFiles(skillPath: string): Promise<string[]> {
const url = AssetRegistryApi.endpointUrl("skills", encodePathSegments(skillPath), "files");
return this.httpClient()
.get(url)
.catch((e) => handleAssetRegistryApiError(`listing skill files for '${skillPath}'`, e));
}

private buildSkillFileUrl(skillPath: string, filePath?: string): string {
const segments = ["skills", encodePathSegments(skillPath)];
if (filePath) {
Expand Down
5 changes: 5 additions & 0 deletions src/commands/asset-registry/asset-registry.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,8 @@ export interface GetSkillFileOptions {
file?: string;
output?: string;
}

export interface DownloadSkillOptions {
path: string;
output?: string;
}
64 changes: 63 additions & 1 deletion src/commands/asset-registry/asset-registry.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { AssetRegistryApi } from "./asset-registry-api";
import { AgentSkill, AssetRegistryDescriptor, GetSkillFileOptions, ValidateOptions } from "./asset-registry.interfaces";
import {
AgentSkill,
AssetRegistryDescriptor,
DownloadSkillOptions,
GetSkillFileOptions,
ValidateOptions,
} from "./asset-registry.interfaces";
import { Context } from "../../core/command/cli-context";
import { fileService, FileService } from "../../core/utils/file-service";
import { FatalError, logger } from "../../core/utils/logger";
Expand Down Expand Up @@ -43,6 +49,30 @@ export class AssetRegistryService {
logger.info(FileService.fileDownloadedMessage + absolutePath);
}

public async downloadSkill(opts: DownloadSkillOptions): Promise<void> {
const skillName = this.resolveSkillName(opts.path);
const parentDir = opts.output ?? ".";
const skillDir = path.resolve(process.cwd(), parentDir, skillName);

const files = await this.api.listSkillFiles(opts.path);
if (!Array.isArray(files) || files.length === 0) {
logger.info(`No files found for skill '${opts.path}'.`);
return;
}

for (const rawFilePath of files) {
const filePath = this.normalizeManifestPath(rawFilePath, opts.path);
this.assertPathWithinSkillDir(skillDir, filePath, opts.path);

const buffer = await this.api.getSkillFile(opts.path, filePath);
fileService.writeBufferToPath(skillDir, filePath, buffer);
}

logger.info(
`Downloaded ${files.length} file(s) for skill '${opts.path}' to ${skillDir}`
);
}

private resolveLocalFilename(file?: string): string {
if (!file) {
return "SKILL.md";
Expand All @@ -55,6 +85,38 @@ export class AssetRegistryService {
return base;
}

private resolveSkillName(skillPath: string): string {
const trimmed = trimSlashes(skillPath ?? "");
const name = trimmed ? path.basename(trimmed) : "";
if (!name) {
throw new FatalError(`--path must identify a skill, got '${skillPath}'.`);
}
return name;
}

private normalizeManifestPath(rawFilePath: unknown, skillPath: string): string {
if (typeof rawFilePath !== "string") {
throw new FatalError(
`Skill manifest for '${skillPath}' contained a non-string entry: ${JSON.stringify(rawFilePath)}.`
);
}
const trimmed = trimSlashes(rawFilePath);
if (!trimmed) {
throw new FatalError(`Skill manifest for '${skillPath}' contained an empty file path.`);
}
return trimmed;
}

private assertPathWithinSkillDir(skillDir: string, relativeFilePath: string, skillPath: string): void {
const resolved = path.resolve(skillDir, relativeFilePath);
const rel = path.relative(skillDir, resolved);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
throw new FatalError(
`Refusing to write file '${relativeFilePath}' from skill '${skillPath}': path escapes the skill directory.`
);
}
}

public async listSkills(jsonResponse: boolean): Promise<void> {
const response = await this.api.listSkills();

Expand Down
13 changes: 13 additions & 0 deletions src/commands/asset-registry/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ class Module extends IModule {
.option("--file <file>", "Relative path of a reference file within the skill (defaults to SKILL.md)")
.option("--output <output>", "Destination directory (defaults to current working directory)")
.action(this.getSkillFile);

skillsCommand.command("download")
.description("Download an entire skill (SKILL.md and all reference files) into a new directory, preserving folder structure. Existing files are overwritten.")
.requiredOption("--path <path>", "Skill path from 'skills list' (e.g. platform/<skill> or asset/<assetType>/<skill>)")
.option("--output <output>", "Parent directory in which the skill directory will be created (defaults to current working directory)")
.action(this.downloadSkill);
}

private async listTypes(context: Context, command: Command, options: OptionValues): Promise<void> {
Expand Down Expand Up @@ -96,6 +102,13 @@ class Module extends IModule {
output: options.output,
});
}

private async downloadSkill(context: Context, command: Command, options: OptionValues): Promise<void> {
await new AssetRegistryService(context).downloadSkill({
path: options.path,
output: options.output,
});
}
}

export = Module;
2 changes: 1 addition & 1 deletion src/core/utils/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class FileService {
public writeBufferToPath(targetDir: string, filename: string, data: Buffer): string {
const resolvedDir = path.resolve(process.cwd(), targetDir);
const absolutePath = path.join(resolvedDir, filename);
this.mkdirRecursive(resolvedDir);
this.mkdirRecursive(path.dirname(absolutePath));
this.writeBufferToFileWithGivenName(data, absolutePath);
return absolutePath;
}
Expand Down
Loading
Loading