Skip to content
Closed
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
24 changes: 19 additions & 5 deletions docs/user-guide/asset-registry-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,18 +201,32 @@ content-cli asset-registry skills get \
--output ./skills
```

Download an entire skill directory:

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

The command creates a new directory named after the last segment of `--path` (the skill name) inside `--output`. For example, the 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>`).
- `--file <file>` – Relative path of a reference file within the skill. Defaults to `SKILL.md` when omitted.
- `--file <file>` – Relative path of a reference file within the skill. Defaults to `SKILL.md` when omitted. Mutually exclusive with `--all`.
- `--output <output>` – Destination directory. Defaults to the current working directory. Created automatically if it does not exist.
- `--all` – Download every file in the skill (`SKILL.md` and all reference files). Files are written into a new directory named after the skill under `--output`, and their folder structure inside the skill is preserved. Mutually exclusive with `--file`.

Behavior:

- The local filename is the basename of `--file` (or `SKILL.md` when `--file` is omitted). Subdirectories in `--file` are not preserved on the local side.
- Re-running the command overwrites the existing local file without prompting.
- 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': ...`.
- Without `--all`, the command downloads a single file (defaults to `SKILL.md`, or the file identified by `--file`). The local filename is the basename of `--file`; subdirectories in `--file` are not preserved on the local side.
- With `--all`, the command first fetches the skill's file manifest, then downloads each file to `{output}/{skillName}/{relativePath}`, creating intermediate directories as needed. If the manifest is empty, the command logs `No files found for skill '<path>'.` and writes nothing.
- Re-running the command overwrites existing local files without prompting.
- On success the command logs the absolute path of the written file (single-file mode), or a summary line like `Downloaded N file(s) for skill '<path>' to <dir>` (`--all` mode).
- Passing both `--file` and `--all` fails with `Options --file and --all are mutually exclusive. ...`.
- A missing skill or file returns a clear error. In single-file mode this is `Problem getting SKILL.md for '<path>': ...` (or `Problem getting skill file '<file>' for '<path>': ...` when `--file` is set). In `--all` mode a missing manifest reports `Problem listing skill files for '<path>': ...`; a manifest entry that fails to download reports `Problem getting skill file '<file>' for '<path>': ...` and stops on the first failure (files already written are left in place).

## Troubleshooting

Expand Down
8 changes: 8 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,14 @@ 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)
.then(result => result?.files ?? [])
.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
3 changes: 2 additions & 1 deletion src/commands/asset-registry/asset-registry.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ export interface AgentSkillsResponse {
skills: AgentSkill[];
}

export interface GetSkillFileOptions {
export interface GetSkillOptions {
path: string;
file?: string;
output?: string;
all?: boolean;
}
85 changes: 79 additions & 6 deletions src/commands/asset-registry/asset-registry.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { AssetRegistryApi } from "./asset-registry-api";
import { AgentSkill, AssetRegistryDescriptor, GetSkillFileOptions, ValidateOptions } from "./asset-registry.interfaces";
import {
AgentSkill,
AssetRegistryDescriptor,
GetSkillOptions,
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 @@ -33,7 +38,24 @@ export class AssetRegistryService {
}
}

public async getSkillFile(opts: GetSkillFileOptions): Promise<void> {
public async getSkill(opts: GetSkillOptions): Promise<void> {
const hasFileOption = !!opts.file
const hasAllOption = opts.all

if (hasFileOption && hasAllOption) {
throw new FatalError(
"Options --file and --all are mutually exclusive. Use --file to download an individual file (defaults to SKILL.md) or --all to download all files (SKILL.md and reference files)."
);
}

if (hasAllOption) {
await this.getSkillDirectory(opts);
} else {
await this.getSingleSkillFile(opts);
}
}

private async getSingleSkillFile(opts: GetSkillOptions): Promise<void> {
const filename = this.resolveLocalFilename(opts.file);
const targetDir = opts.output ?? ".";

Expand All @@ -43,6 +65,28 @@ export class AssetRegistryService {
logger.info(FileService.fileDownloadedMessage + absolutePath);
}

private async getSkillDirectory(opts: GetSkillOptions): 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 +99,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 Expand Up @@ -109,13 +185,10 @@ export class AssetRegistryService {
const hasFile = !!opts.file;

if (hasFile && (hasNodeKey || hasConfig || !!opts.packageKey)) {
throw new FatalError(
"Option -f is mutually exclusive with --packageKey, --nodeKey and --configuration."
);
throw new FatalError("Option -f is mutually exclusive with --packageKey, --nodeKey and --configuration.");
}

if (hasFile) {

return this.parseJson(fileService.readFile(opts.file), `-f ${opts.file}`);
}

Expand Down
10 changes: 6 additions & 4 deletions src/commands/asset-registry/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ class Module extends IModule {
.action(this.listSkills);

skillsCommand.command("get")
.description("Download a skill file (defaults to SKILL.md)")
.description("Download a skill file or the entire skill directory.")
.requiredOption("--path <path>", "Skill path from 'skills list' (e.g. platform/<skill> or asset/<assetType>/<skill>)")
.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);
.option("--all", "Download all skill files (SKILL.md and all reference files). Files are stored to a new directory identical to the skill name under the path provided with --output.")
.action(this.getSkill);
}

private async listTypes(context: Context, command: Command, options: OptionValues): Promise<void> {
Expand Down Expand Up @@ -89,11 +90,12 @@ class Module extends IModule {
await new AssetRegistryService(context).listSkills(!!options.json);
}

private async getSkillFile(context: Context, command: Command, options: OptionValues): Promise<void> {
await new AssetRegistryService(context).getSkillFile({
private async getSkill(context: Context, command: Command, options: OptionValues): Promise<void> {
await new AssetRegistryService(context).getSkill({
path: options.path,
file: options.file,
output: options.output,
all: options.all,
});
}
}
Expand Down
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