diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2e18dab..f718d839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@
- `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id.
- `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt.
- `base44 dev --remote` serves the frontend against the production backend: it runs `site.serveCommand` with `VITE_BASE44_APP_ID` and `VITE_BASE44_APP_BASE_URL` pointing at the app's own published URL, without starting the local backend. Fails if the app has no published URL.
+- `base44 sandbox push-skills
` copies local agent skills — the whole skill directory, bundled files included — into `.agents/skills/` in an app's remote sandbox. A directory holding a `SKILL.md` is one skill; otherwise its immediate subdirectories are scanned and you pick from them, with `--all` / `--name` skipping the picker. Binary files cannot cross the sandbox bridge and are reported as unsupported rather than copied.
+- `base44 sandbox write --file ` reads the content from a local file, as an alternative to `--content` or piped stdin.
### Fixed
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 4a48fb8c..13dd0b1e 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -68,6 +68,7 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th
| [`sandbox grep`](https://docs.base44.com/developers/references/cli/commands/sandbox-grep) | Search files for a pattern in an app's remote sandbox |
| [`sandbox run`](https://docs.base44.com/developers/references/cli/commands/sandbox-run) | Run a shell command in an app's remote sandbox |
| [`sandbox checkpoint`](https://docs.base44.com/developers/references/cli/commands/sandbox-checkpoint) | Create a restore-point checkpoint of an app's remote sandbox |
+| `sandbox push-skills` | Copy local agent skills into an app's remote sandbox |
| [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting |
| [`site open`](https://docs.base44.com/developers/references/cli/commands/site-open) | Open the published site in your browser |
| [`types generate`](https://docs.base44.com/developers/references/cli/commands/types-generate) | Generate TypeScript types from project resources |
diff --git a/packages/cli/src/cli/commands/sandbox/index.ts b/packages/cli/src/cli/commands/sandbox/index.ts
index 3b8edb3e..50ab583d 100644
--- a/packages/cli/src/cli/commands/sandbox/index.ts
+++ b/packages/cli/src/cli/commands/sandbox/index.ts
@@ -3,6 +3,7 @@ import { getSandboxCheckpointCommand } from "./checkpoint.js";
import { getSandboxEditFileCommand } from "./edit-file.js";
import { getSandboxGrepCommand } from "./grep.js";
import { getSandboxListDirectoryCommand } from "./list-directory.js";
+import { getSandboxPushSkillsCommand } from "./push-skills.js";
import { getSandboxReadFileCommand } from "./read-file.js";
import { getSandboxRunCommandCommand } from "./run-command.js";
import { getSandboxWriteFileCommand } from "./write-file.js";
@@ -16,5 +17,6 @@ export function getSandboxCommand(): Command {
.addCommand(getSandboxEditFileCommand())
.addCommand(getSandboxGrepCommand())
.addCommand(getSandboxRunCommandCommand())
- .addCommand(getSandboxCheckpointCommand());
+ .addCommand(getSandboxCheckpointCommand())
+ .addCommand(getSandboxPushSkillsCommand());
}
diff --git a/packages/cli/src/cli/commands/sandbox/push-skills.ts b/packages/cli/src/cli/commands/sandbox/push-skills.ts
new file mode 100644
index 00000000..e292e822
--- /dev/null
+++ b/packages/cli/src/cli/commands/sandbox/push-skills.ts
@@ -0,0 +1,210 @@
+import type { Option as PromptOption } from "@clack/prompts";
+import { isCancel, multiselect } from "@clack/prompts";
+import type { Command } from "commander";
+import type { CLIContext, RunCommandResult } from "@/cli/types.js";
+import { Base44Command, onPromptCancel } from "@/cli/utils/index.js";
+import { InvalidInputError } from "@/core/errors.js";
+import { getAppContext } from "@/core/project/index.js";
+import type { CopySkillResult, LocalSkill } from "@/core/skills/index.js";
+import {
+ copySkill,
+ discoverLocalSkills,
+ SKILLS_DEST_DIR,
+} from "@/core/skills/index.js";
+import { toJsonStdout } from "./shared.js";
+
+const HINT_DESCRIPTION_LENGTH = 60;
+
+interface PushSkillsOptions {
+ name?: string[];
+ all?: boolean;
+ overwrite?: boolean;
+}
+
+function truncate(text: string, max: number): string {
+ const collapsed = text.replace(/\s+/g, " ").trim();
+ return collapsed.length > max
+ ? `${collapsed.slice(0, max - 1).trimEnd()}…`
+ : collapsed;
+}
+
+/**
+ * Show the frontmatter name next to the directory name so a mismatch between
+ * the two is visible rather than silently resolved -- the directory name is
+ * what the skill is copied as.
+ */
+function buildHint(skill: LocalSkill): string | undefined {
+ const parts = [
+ skill.name && skill.name !== skill.dirName ? skill.name : "",
+ skill.description
+ ? truncate(skill.description, HINT_DESCRIPTION_LENGTH)
+ : "",
+ ].filter(Boolean);
+ return parts.length > 0 ? parts.join(" — ") : undefined;
+}
+
+function selectByName(skills: LocalSkill[], names: string[]): LocalSkill[] {
+ const byDirName = new Map(skills.map((skill) => [skill.dirName, skill]));
+ const unknown = names.filter((name) => !byDirName.has(name));
+ if (unknown.length > 0) {
+ throw new InvalidInputError(
+ `No such skill: ${unknown.join(", ")}. Found: ${skills
+ .map((skill) => skill.dirName)
+ .join(", ")}.`,
+ );
+ }
+ // Preserve discovery order rather than the order the flags were passed.
+ return skills.filter((skill) => names.includes(skill.dirName));
+}
+
+/**
+ * `null` is the "all skills" entry. Using null rather than a sentinel string
+ * means it can never collide with a real directory name.
+ */
+async function promptForSkills(skills: LocalSkill[]): Promise {
+ const options: PromptOption[] = [
+ { value: null, label: "All skills", hint: `${skills.length} found` },
+ ...skills.map((skill) => ({
+ value: skill.dirName,
+ label: skill.dirName,
+ hint: buildHint(skill),
+ })),
+ ];
+
+ const picked = await multiselect({
+ message: "Which skills do you want to copy?",
+ options,
+ required: true,
+ });
+ if (isCancel(picked)) {
+ onPromptCancel();
+ }
+
+ const selected = picked as (string | null)[];
+ if (selected.includes(null)) {
+ return skills;
+ }
+ return skills.filter((skill) => selected.includes(skill.dirName));
+}
+
+async function resolveSelection(
+ skills: LocalSkill[],
+ options: PushSkillsOptions,
+ isNonInteractive: boolean,
+): Promise {
+ const named = options.name ?? [];
+ if (options.all && named.length > 0) {
+ throw new InvalidInputError(
+ "Pass either --all or --name, not both. --name would otherwise silently narrow --all to a subset.",
+ );
+ }
+ if (named.length > 0) {
+ return selectByName(skills, named);
+ }
+ if (options.all || skills.length === 1) {
+ return skills;
+ }
+ // Mirrors confirmPush: never leave --json or CI waiting on a prompt.
+ if (isNonInteractive) {
+ throw new InvalidInputError(
+ `Found ${skills.length} skills. Pass --all or --name to choose in non-interactive mode.`,
+ );
+ }
+ return await promptForSkills(skills);
+}
+
+function reportBinarySkips(
+ log: CLIContext["log"],
+ results: CopySkillResult[],
+): void {
+ const skipped = results.flatMap((result) =>
+ result.skippedBinary.map((path) => `${result.skill}/${path}`),
+ );
+ if (skipped.length === 0) {
+ return;
+ }
+ log.warn(
+ [
+ "Binary files are not supported by the sandbox bridge and were not copied:",
+ ...skipped.map((path) => ` ${path}`),
+ " The rest of the skill was copied. Remove them or replace them with text",
+ " equivalents if the skill depends on them.",
+ ].join("\n"),
+ );
+}
+
+async function pushSkillsAction(
+ { isNonInteractive, jsonMode, log, runTask }: CLIContext,
+ dir: string,
+ options: PushSkillsOptions,
+): Promise {
+ const { id: appId } = getAppContext();
+
+ const skills = await discoverLocalSkills(dir);
+ const selected = await resolveSelection(
+ skills,
+ options,
+ isNonInteractive || jsonMode,
+ );
+
+ const results: CopySkillResult[] = [];
+ for (const skill of selected) {
+ const result = await runTask(
+ `Copying ${skill.dirName}`,
+ () => copySkill(appId, skill, { overwrite: options.overwrite }),
+ {
+ successMessage: `Copied ${skill.dirName}`,
+ errorMessage: `Failed to copy ${skill.dirName}`,
+ },
+ );
+ results.push(result);
+ }
+
+ const fileCount = results.reduce(
+ (total, result) => total + result.written.length,
+ 0,
+ );
+ log.success(
+ `Copied ${results.length} skill(s): ${results
+ .map((result) => result.skill)
+ .join(", ")}`,
+ );
+ reportBinarySkips(log, results);
+
+ return {
+ outroMessage: `Copied ${fileCount} file(s) to ${SKILLS_DEST_DIR}`,
+ // Automation needs to know exactly what landed and what was dropped; the
+ // binary warning above only reaches stderr.
+ ...(jsonMode
+ ? {
+ stdout: toJsonStdout({
+ destination: SKILLS_DEST_DIR,
+ skills: results,
+ }),
+ }
+ : {}),
+ };
+}
+
+export function getSandboxPushSkillsCommand(): Command {
+ return new Base44Command("push-skills")
+ .description(
+ `Copy local agent skills into an app's remote sandbox (${SKILLS_DEST_DIR})`,
+ )
+ .argument("", "Local skill directory, or a directory of skills")
+ .option(
+ "--name ",
+ "Copy only these skills, by directory name (skips the picker)",
+ )
+ .option("--all", "Copy every discovered skill (skips the picker)")
+ .option("--overwrite", "Overwrite files that already exist in the sandbox")
+ .addHelpText(
+ "after",
+ `
+Examples:
+ $ base44 sandbox push-skills ~/.claude/skills --app-id app_123
+ $ base44 sandbox push-skills ./.claude/skills/deploy-check --overwrite
+ $ base44 sandbox push-skills ~/.claude/skills --name grill-me --name tidy-up`,
+ )
+ .action(pushSkillsAction);
+}
diff --git a/packages/cli/src/cli/commands/sandbox/shared.ts b/packages/cli/src/cli/commands/sandbox/shared.ts
index baaaf2dd..c44d9a26 100644
--- a/packages/cli/src/cli/commands/sandbox/shared.ts
+++ b/packages/cli/src/cli/commands/sandbox/shared.ts
@@ -1,5 +1,7 @@
+import { resolve } from "node:path";
import { readStdin } from "@/cli/utils/index.js";
import { InvalidInputError } from "@/core/errors.js";
+import { pathExists, readTextFile } from "@/core/utils/fs.js";
// Re-exported from the shared util so both sandbox and workspace commands use
// one implementation of the `--json` serializer.
@@ -25,6 +27,36 @@ export async function resolveFlagOrStdin(
return readStdin(flagName, { trim: false });
}
+/**
+ * Resolve file content from `--content`, `--file `, or piped stdin, in
+ * that order. Exactly one source may be given; passing both flags is an error
+ * rather than a silent precedence win.
+ */
+export async function resolveContentSource(
+ content: string | undefined,
+ file: string | undefined,
+): Promise {
+ if (content !== undefined && file !== undefined) {
+ throw new InvalidInputError("Pass either --content or --file, not both.");
+ }
+ if (content !== undefined) {
+ return content;
+ }
+ if (file !== undefined) {
+ const filePath = resolve(file);
+ if (!(await pathExists(filePath))) {
+ throw new InvalidInputError(`File not found: ${filePath}`);
+ }
+ return await readTextFile(filePath);
+ }
+ if (process.stdin.isTTY) {
+ throw new InvalidInputError(
+ "Provide --content, --file , or pipe the value via stdin (e.g. echo | base44 sandbox write ...).",
+ );
+ }
+ return readStdin("--content", { trim: false });
+}
+
/**
* Parse a CLI option string as a positive integer, or return undefined when
* the option was not provided. Throws InvalidInputError on a malformed value.
diff --git a/packages/cli/src/cli/commands/sandbox/write-file.ts b/packages/cli/src/cli/commands/sandbox/write-file.ts
index 2a8b3127..e80120bf 100644
--- a/packages/cli/src/cli/commands/sandbox/write-file.ts
+++ b/packages/cli/src/cli/commands/sandbox/write-file.ts
@@ -3,10 +3,11 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { getAppContext } from "@/core/project/index.js";
import { writeFile } from "@/core/resources/sandbox/api.js";
-import { resolveFlagOrStdin, toJsonStdout } from "./shared.js";
+import { resolveContentSource, toJsonStdout } from "./shared.js";
interface WriteFileOptions {
content?: string;
+ file?: string;
overwrite?: boolean;
}
@@ -16,7 +17,7 @@ async function writeFileAction(
options: WriteFileOptions,
): Promise {
const { id: appId } = getAppContext();
- const content = await resolveFlagOrStdin(options.content, "--content");
+ const content = await resolveContentSource(options.content, options.file);
const result = await runTask("Writing file", () =>
writeFile(appId, { path, content, overwrite: options.overwrite }),
@@ -30,13 +31,15 @@ export function getSandboxWriteFileCommand(): Command {
.description("Create or overwrite a file in an app's remote sandbox")
.argument("", "File path relative to the app root")
.option("--content ", "File content (if omitted, read from stdin)")
+ .option("--file ", "Read the content from a local file")
.option("--overwrite", "Overwrite the file if it already exists")
.addHelpText(
"after",
`
Examples:
$ echo "hello" | base44 sandbox write notes.txt
- $ base44 sandbox write notes.txt --content "hello" --overwrite`,
+ $ base44 sandbox write notes.txt --content "hello" --overwrite
+ $ base44 sandbox write notes.txt --file ./local-notes.txt`,
)
.action(writeFileAction);
}
diff --git a/packages/cli/src/core/skills/copy.ts b/packages/cli/src/core/skills/copy.ts
new file mode 100644
index 00000000..a3e3ed03
--- /dev/null
+++ b/packages/cli/src/core/skills/copy.ts
@@ -0,0 +1,55 @@
+import { writeFile as writeSandboxFile } from "@/core/resources/sandbox/api.js";
+import { readFile } from "@/core/utils/fs.js";
+import type { LocalSkill } from "./schema.js";
+import { isBinary, SKILLS_DEST_DIR } from "./schema.js";
+
+export interface CopySkillResult {
+ /** The skill's directory name, which is also its destination folder. */
+ skill: string;
+ /** Remote paths written, relative to the app root. */
+ written: string[];
+ /** Paths (relative to the skill directory) skipped because they are binary. */
+ skippedBinary: string[];
+}
+
+/**
+ * Destination path for a file inside a skill. Remote paths are always POSIX,
+ * so this joins with "/" rather than path.join.
+ */
+function remoteSkillPath(dirName: string, relativePath: string): string {
+ return `${SKILLS_DEST_DIR}/${dirName}/${relativePath}`;
+}
+
+/**
+ * Copy one skill's directory into SKILLS_DEST_DIR in the app's sandbox.
+ * Binary files are reported back rather than written — see isBinary().
+ */
+export async function copySkill(
+ appId: string,
+ skill: LocalSkill,
+ options: { overwrite?: boolean } = {},
+): Promise {
+ const written: string[] = [];
+ const skippedBinary: string[] = [];
+
+ // Sequential on purpose: the bridge takes one file per request, and a skill
+ // with a large references/ directory would otherwise fire dozens of
+ // concurrent writes at it.
+ for (const file of skill.files) {
+ const buffer = await readFile(file.absolutePath);
+ if (isBinary(buffer)) {
+ skippedBinary.push(file.relativePath);
+ continue;
+ }
+
+ const path = remoteSkillPath(skill.dirName, file.relativePath);
+ await writeSandboxFile(appId, {
+ path,
+ content: buffer.toString("utf-8"),
+ overwrite: options.overwrite,
+ });
+ written.push(path);
+ }
+
+ return { skill: skill.dirName, written, skippedBinary };
+}
diff --git a/packages/cli/src/core/skills/discover.ts b/packages/cli/src/core/skills/discover.ts
new file mode 100644
index 00000000..01820d55
--- /dev/null
+++ b/packages/cli/src/core/skills/discover.ts
@@ -0,0 +1,160 @@
+import { lstat, readdir, realpath, stat } from "node:fs/promises";
+import { basename, join, resolve } from "node:path";
+import frontmatter from "front-matter";
+import { globby } from "globby";
+import { InvalidInputError } from "@/core/errors.js";
+import { pathExists, readTextFile } from "@/core/utils/fs.js";
+import type { LocalSkill, SkillFile } from "./schema.js";
+import {
+ assertSafeDirName,
+ assertSafeRelativePath,
+ assertWithinSkillRoot,
+ IGNORED_PATTERNS,
+ SKILL_FILE,
+} from "./schema.js";
+
+interface SkillFrontmatter {
+ name?: unknown;
+ description?: unknown;
+}
+
+/**
+ * Pull `name` and `description` out of a SKILL.md. Both are display-only —
+ * neither is required, and neither affects where the skill is copied.
+ */
+async function readSkillMetadata(
+ skillDir: string,
+): Promise<{ name: string; description: string }> {
+ const raw = await readTextFile(join(skillDir, SKILL_FILE));
+ const { attributes } = frontmatter(raw);
+ return {
+ name: typeof attributes.name === "string" ? attributes.name.trim() : "",
+ description:
+ typeof attributes.description === "string"
+ ? attributes.description.trim()
+ : "",
+ };
+}
+
+async function readSkill(skillDir: string): Promise {
+ const dirName = basename(skillDir);
+ assertSafeDirName(dirName);
+
+ const { name, description } = await readSkillMetadata(skillDir);
+
+ // globby yields forward slashes on every platform, which is what the remote
+ // paths need — do not normalize these to the OS separator.
+ //
+ // followSymbolicLinks is off so a link pointing at a parent directory cannot
+ // pull unrelated local files into the upload. The relative path alone cannot
+ // catch that: it still looks like it sits inside the skill.
+ const relativePaths = await globby("**/*", {
+ cwd: skillDir,
+ onlyFiles: true,
+ followSymbolicLinks: false,
+ ignore: IGNORED_PATTERNS,
+ });
+
+ // Resolve the root once so links are compared against the real directory,
+ // not the path we were handed (which may itself be a link).
+ const realRoot = await realpath(skillDir);
+
+ const files: SkillFile[] = await Promise.all(
+ relativePaths.sort().map(async (relativePath) => {
+ assertSafeRelativePath(relativePath, dirName);
+ const absolutePath = join(skillDir, relativePath);
+ assertWithinSkillRoot(realRoot, await realpath(absolutePath), dirName);
+ return { relativePath, absolutePath };
+ }),
+ );
+
+ return { dirName, name, description, absolutePath: skillDir, files };
+}
+
+async function isDirectory(path: string): Promise {
+ try {
+ return (await stat(path)).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Does this directory carry a usable skill marker?
+ *
+ * lstat, not stat: a symlinked SKILL.md reads fine but is excluded from the
+ * copy by followSymbolicLinks, which would ship a skill with no SKILL.md and
+ * report success. Refuse it outright rather than let that through.
+ */
+async function hasRegularSkillFile(dir: string): Promise {
+ const markerPath = join(dir, SKILL_FILE);
+ try {
+ const stats = await lstat(markerPath);
+ if (stats.isFile()) {
+ return true;
+ }
+ throw new InvalidInputError(
+ `${markerPath} must be a regular file (found a ${stats.isSymbolicLink() ? "symlink" : "directory"}). A ${SKILL_FILE} that is not a regular file is never copied, which would leave the skill unusable.`,
+ );
+ } catch (error) {
+ if (error instanceof InvalidInputError) {
+ throw error;
+ }
+ return false;
+ }
+}
+
+async function findSkillSubdirectories(dir: string): Promise {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const candidates = entries
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => join(dir, entry.name))
+ .sort();
+
+ const skillDirs: string[] = [];
+ for (const candidate of candidates) {
+ if (await hasRegularSkillFile(candidate)) {
+ skillDirs.push(candidate);
+ }
+ }
+ return skillDirs;
+}
+
+/**
+ * Resolve a local directory into the skills it contains.
+ *
+ * - `/SKILL.md` exists -> the directory is itself one skill.
+ * - otherwise -> every immediate subdirectory holding a SKILL.md is a skill.
+ *
+ * Only one level of nesting is scanned; that matches how skills are laid out
+ * on disk and keeps the picker free of unrelated directories.
+ *
+ * @throws InvalidInputError when the path is missing, is not a directory,
+ * holds no SKILL.md at either level, or holds a SKILL.md that is not a
+ * regular file.
+ */
+export async function discoverLocalSkills(dir: string): Promise {
+ const skillsDir = resolve(dir);
+
+ if (!(await pathExists(skillsDir))) {
+ throw new InvalidInputError(`Directory not found: ${skillsDir}`);
+ }
+ if (!(await isDirectory(skillsDir))) {
+ throw new InvalidInputError(
+ `Not a directory: ${skillsDir}. Point this command at a skill directory, or at a directory containing skill directories.`,
+ );
+ }
+
+ if (await hasRegularSkillFile(skillsDir)) {
+ return [await readSkill(skillsDir)];
+ }
+
+ const skillDirs = await findSkillSubdirectories(skillsDir);
+ if (skillDirs.length === 0) {
+ throw new InvalidInputError(
+ `No skills found in ${skillsDir}. Expected a ${SKILL_FILE} in that directory, or in one of its immediate subdirectories.`,
+ );
+ }
+
+ return await Promise.all(skillDirs.map(readSkill));
+}
diff --git a/packages/cli/src/core/skills/index.ts b/packages/cli/src/core/skills/index.ts
new file mode 100644
index 00000000..55eceed1
--- /dev/null
+++ b/packages/cli/src/core/skills/index.ts
@@ -0,0 +1,3 @@
+export * from "./copy.js";
+export * from "./discover.js";
+export * from "./schema.js";
diff --git a/packages/cli/src/core/skills/schema.ts b/packages/cli/src/core/skills/schema.ts
new file mode 100644
index 00000000..b67d7331
--- /dev/null
+++ b/packages/cli/src/core/skills/schema.ts
@@ -0,0 +1,106 @@
+import { isAbsolute, relative } from "node:path";
+import { InvalidInputError } from "@/core/errors.js";
+
+/** Filename that marks a directory as a skill. */
+export const SKILL_FILE = "SKILL.md";
+
+/**
+ * Where skills land inside the app repo. Deliberately not configurable — the
+ * in-sandbox agent looks here, so a per-invocation override would only ever
+ * produce skills it cannot find.
+ */
+export const SKILLS_DEST_DIR = ".agents/skills";
+
+/** Glob patterns never copied, regardless of where they appear in a skill. */
+export const IGNORED_PATTERNS = [
+ "**/node_modules/**",
+ "**/.git/**",
+ "**/.DS_Store",
+];
+
+export interface SkillFile {
+ /** Path relative to the skill directory. Always POSIX-separated. */
+ relativePath: string;
+ absolutePath: string;
+}
+
+export interface LocalSkill {
+ /**
+ * Directory basename. This is the destination folder name — a skill's
+ * directory name is what identifies it on disk.
+ */
+ dirName: string;
+ /** `name` from SKILL.md frontmatter. Empty when absent; display only. */
+ name: string;
+ /** `description` from SKILL.md frontmatter. Empty when absent; display only. */
+ description: string;
+ absolutePath: string;
+ files: SkillFile[];
+}
+
+/**
+ * Guard a skill directory name before it becomes a remote path segment.
+ * A name carrying a separator or a `..` would let a skill write outside
+ * SKILLS_DEST_DIR.
+ */
+export function assertSafeDirName(dirName: string): void {
+ if (
+ dirName === "" ||
+ dirName === "." ||
+ dirName === ".." ||
+ dirName.includes("/") ||
+ dirName.includes("\\")
+ ) {
+ throw new InvalidInputError(
+ `Invalid skill directory name: "${dirName}". Skill directory names cannot be empty, "." or "..", or contain path separators.`,
+ );
+ }
+}
+
+/**
+ * Guard a collected file path before it becomes a remote path segment. globby
+ * yields paths relative to the skill root, but a symlink or an unusual entry
+ * could still produce something that escapes it.
+ */
+export function assertSafeRelativePath(
+ relativePath: string,
+ dirName: string,
+): void {
+ if (
+ relativePath === "" ||
+ isAbsolute(relativePath) ||
+ relativePath.split("/").includes("..")
+ ) {
+ throw new InvalidInputError(
+ `Skill "${dirName}" contains a file path that escapes its directory: "${relativePath}".`,
+ );
+ }
+}
+
+/**
+ * Guard against a symlink that points outside the skill. The apparent path
+ * stays inside the skill directory, so assertSafeRelativePath cannot catch
+ * this — only the resolved real path can. Both arguments must already be
+ * realpath-resolved.
+ */
+export function assertWithinSkillRoot(
+ realRoot: string,
+ realPath: string,
+ dirName: string,
+): void {
+ const rel = relative(realRoot, realPath);
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
+ throw new InvalidInputError(
+ `Skill "${dirName}" contains a link that resolves outside its directory: "${realPath}". Remove it and try again.`,
+ );
+ }
+}
+
+/**
+ * The sandbox bridge's write_file takes `content` as a plain string with no
+ * encoding field, so a binary payload cannot survive the round trip. Detect it
+ * up front with a NUL-byte scan and skip rather than write corrupted bytes.
+ */
+export function isBinary(buffer: Buffer): boolean {
+ return buffer.includes(0);
+}
diff --git a/packages/cli/tests/cli/sandbox.spec.ts b/packages/cli/tests/cli/sandbox.spec.ts
index 18c20dfa..5fdf8e06 100644
--- a/packages/cli/tests/cli/sandbox.spec.ts
+++ b/packages/cli/tests/cli/sandbox.spec.ts
@@ -1,3 +1,5 @@
+import { writeFile as writeLocalFile } from "node:fs/promises";
+import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { setupCLITests } from "./testkit/index.js";
@@ -133,6 +135,82 @@ describe("sandbox commands", () => {
t.expectResult(result).toContain('"bytesWritten": 6');
});
+ it("write reads content from a local file with --file", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ const localPath = join(t.getTempDir(), "local-notes.txt");
+ await writeLocalFile(localPath, "from a file");
+ t.api.mockRoute("POST", `${base}/write_file`, (req, res) => {
+ res.status(200).json({
+ path: req.body.path,
+ bytes_written: (req.body.content ?? "").length,
+ created: true,
+ overwritten: false,
+ });
+ });
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "write",
+ "notes.txt",
+ "--file",
+ localPath,
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then — 11 bytes ("from a file"), read from disk rather than stdin
+ t.expectResult(result).toSucceed();
+ t.expectResult(result).toContain('"bytesWritten": 11');
+ });
+
+ it("write rejects --content and --file together", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ const localPath = join(t.getTempDir(), "local-notes.txt");
+ await writeLocalFile(localPath, "from a file");
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "write",
+ "notes.txt",
+ "--content",
+ "inline",
+ "--file",
+ localPath,
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then
+ t.expectResult(result).toFail();
+ t.expectResult(result).toContain(
+ "Pass either --content or --file, not both.",
+ );
+ });
+
+ it("write reports a missing --file path", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "write",
+ "notes.txt",
+ "--file",
+ join(t.getTempDir(), "nope.txt"),
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then
+ t.expectResult(result).toFail();
+ t.expectResult(result).toContain("File not found");
+ });
+
it("run surfaces the remote exit code without failing the CLI", async () => {
// Given
await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
diff --git a/packages/cli/tests/cli/skills_push.spec.ts b/packages/cli/tests/cli/skills_push.spec.ts
new file mode 100644
index 00000000..d1488a99
--- /dev/null
+++ b/packages/cli/tests/cli/skills_push.spec.ts
@@ -0,0 +1,259 @@
+import { describe, expect, it } from "vitest";
+import { fixture, setupCLITests } from "./testkit/index.js";
+
+const APP_ID = "test-app-id";
+const base = `/api/apps/${APP_ID}/sandbox-bridge`;
+
+interface WriteCall {
+ path: string;
+ content: string;
+ overwrite?: boolean;
+}
+
+describe("sandbox push-skills", () => {
+ const t = setupCLITests();
+
+ /** Mock write_file and record every call the CLI makes. */
+ function captureWrites(): WriteCall[] {
+ const calls: WriteCall[] = [];
+ t.api.mockRoute("POST", `${base}/write_file`, (req, res) => {
+ calls.push({
+ path: req.body.path,
+ content: req.body.content,
+ overwrite: req.body.overwrite,
+ });
+ res.status(200).json({
+ path: req.body.path,
+ bytes_written: (req.body.content ?? "").length,
+ created: true,
+ overwritten: false,
+ });
+ });
+ return calls;
+ }
+
+ it("copies a single-skill directory without asking which to copy", async () => {
+ // Given — the directory itself holds SKILL.md, so there is nothing to pick
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ const calls = captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/single"),
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then
+ t.expectResult(result).toSucceed();
+ expect(calls.map((call) => call.path).sort()).toEqual([
+ ".agents/skills/single/SKILL.md",
+ ".agents/skills/single/scripts/run.sh",
+ ]);
+ });
+
+ it("--all copies every discovered skill under .agents/skills", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ const calls = captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/multi"),
+ "--all",
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then — bundled files travel too, and non-skill directories are ignored
+ t.expectResult(result).toSucceed();
+ expect(calls.map((call) => call.path).sort()).toEqual([
+ ".agents/skills/grill-me/SKILL.md",
+ ".agents/skills/grill-me/references/api.md",
+ ".agents/skills/tidy-up/SKILL.md",
+ ]);
+ t.expectResult(result).toNotContain("not-a-skill");
+ });
+
+ it("--name copies only the named skills", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ const calls = captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/multi"),
+ "--name",
+ "tidy-up",
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then
+ t.expectResult(result).toSucceed();
+ expect(calls.map((call) => call.path)).toEqual([
+ ".agents/skills/tidy-up/SKILL.md",
+ ]);
+ });
+
+ it("fails when --name does not match a discovered skill", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/multi"),
+ "--name",
+ "nope",
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then — the error names what was actually found
+ t.expectResult(result).toFail();
+ t.expectResult(result).toContain("No such skill: nope");
+ t.expectResult(result).toContain("grill-me");
+ });
+
+ it("requires --all or --name in non-interactive mode", async () => {
+ // Given — more than one skill, so a choice is needed and no prompt is possible
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/multi"),
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then
+ t.expectResult(result).toFail();
+ t.expectResult(result).toContain(
+ "Pass --all or --name to choose in non-interactive mode",
+ );
+ });
+
+ it("--overwrite is forwarded to the sandbox bridge", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ const calls = captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/single"),
+ "--overwrite",
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then
+ t.expectResult(result).toSucceed();
+ expect(calls.every((call) => call.overwrite === true)).toBe(true);
+ });
+
+ it("never writes binary files, and explains that they are unsupported", async () => {
+ // Given — the skill bundles a file containing NUL bytes
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ const calls = captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/with-binary"),
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then — the text file still ships, the binary one does not
+ t.expectResult(result).toSucceed();
+ expect(calls.map((call) => call.path)).toEqual([
+ ".agents/skills/with-binary/SKILL.md",
+ ]);
+ t.expectResult(result).toContain("Binary files are not supported");
+ t.expectResult(result).toContain("with-binary/assets/logo.bin");
+ });
+
+ it("rejects --all and --name together", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/multi"),
+ "--all",
+ "--name",
+ "tidy-up",
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then — --name would otherwise silently narrow --all
+ t.expectResult(result).toFail();
+ t.expectResult(result).toContain("Pass either --all or --name, not both");
+ });
+
+ it("--json reports what was written and what was skipped", async () => {
+ // Given — the skill bundles a binary file that cannot be copied
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+ captureWrites();
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/with-binary"),
+ "--app-id",
+ APP_ID,
+ "--json",
+ );
+
+ // Then — stdout parses cleanly and carries the full outcome
+ t.expectResult(result).toSucceed();
+ const parsed = JSON.parse(result.stdout);
+ expect(parsed.destination).toBe(".agents/skills");
+ expect(parsed.skills).toEqual([
+ {
+ skill: "with-binary",
+ written: [".agents/skills/with-binary/SKILL.md"],
+ skippedBinary: ["assets/logo.bin"],
+ },
+ ]);
+ expect(result.stdout).not.toContain("Copied");
+ });
+
+ it("explains when the directory holds no skills", async () => {
+ // Given
+ await t.givenLoggedIn({ email: "test@example.com", name: "Test User" });
+
+ // When
+ const result = await t.run(
+ "sandbox",
+ "push-skills",
+ fixture("local-skills/empty"),
+ "--app-id",
+ APP_ID,
+ );
+
+ // Then
+ t.expectResult(result).toFail();
+ t.expectResult(result).toContain("No skills found");
+ });
+});
diff --git a/packages/cli/tests/core/skills_discover.spec.ts b/packages/cli/tests/core/skills_discover.spec.ts
new file mode 100644
index 00000000..4763cbac
--- /dev/null
+++ b/packages/cli/tests/core/skills_discover.spec.ts
@@ -0,0 +1,287 @@
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { InvalidInputError } from "../../src/core/errors.js";
+import { discoverLocalSkills } from "../../src/core/skills/discover.js";
+import {
+ assertSafeDirName,
+ assertSafeRelativePath,
+ assertWithinSkillRoot,
+ isBinary,
+} from "../../src/core/skills/schema.js";
+
+const SKILL_MD =
+ "---\nname: grill-me\ndescription: Ask hard questions.\n---\n\nBody.\n";
+
+describe("discoverLocalSkills", () => {
+ let dir: string;
+
+ beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), "local-skills-"));
+ });
+
+ afterEach(async () => {
+ await rm(dir, { recursive: true, force: true });
+ });
+
+ async function writeSkill(skillDir: string, contents = SKILL_MD) {
+ await mkdir(skillDir, { recursive: true });
+ await writeFile(join(skillDir, "SKILL.md"), contents);
+ }
+
+ it("treats a directory holding SKILL.md as a single skill", async () => {
+ // Given
+ await writeSkill(dir);
+ await mkdir(join(dir, "references"), { recursive: true });
+ await writeFile(join(dir, "references", "api.md"), "# Reference\n");
+
+ // When
+ const skills = await discoverLocalSkills(dir);
+
+ // Then
+ expect(skills).toHaveLength(1);
+ expect(skills[0]?.name).toBe("grill-me");
+ expect(skills[0]?.description).toBe("Ask hard questions.");
+ expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([
+ "SKILL.md",
+ "references/api.md",
+ ]);
+ });
+
+ it("scans one level of subdirectories when there is no root SKILL.md", async () => {
+ // Given — two skills plus a directory that is not one
+ await writeSkill(join(dir, "grill-me"));
+ await writeSkill(join(dir, "tidy-up"));
+ await mkdir(join(dir, "not-a-skill"), { recursive: true });
+ await writeFile(join(dir, "not-a-skill", "README.md"), "nope\n");
+
+ // When
+ const skills = await discoverLocalSkills(dir);
+
+ // Then — only the qualifying directories, in sorted order
+ expect(skills.map((skill) => skill.dirName)).toEqual([
+ "grill-me",
+ "tidy-up",
+ ]);
+ });
+
+ it("does not descend past the first level of subdirectories", async () => {
+ // Given — the SKILL.md is two levels down
+ await writeSkill(join(dir, "nested", "grill-me"));
+
+ // When / Then
+ await expect(discoverLocalSkills(dir)).rejects.toThrow(InvalidInputError);
+ });
+
+ it("uses the directory name as the skill identity, not the frontmatter name", async () => {
+ // Given — frontmatter name deliberately disagrees with the directory
+ await writeSkill(join(dir, "on-disk-name"));
+
+ // When
+ const skills = await discoverLocalSkills(dir);
+
+ // Then
+ expect(skills[0]?.dirName).toBe("on-disk-name");
+ expect(skills[0]?.name).toBe("grill-me");
+ });
+
+ it("tolerates a SKILL.md with no frontmatter", async () => {
+ // Given
+ await writeSkill(join(dir, "bare"), "Just a body, no frontmatter.\n");
+
+ // When
+ const skills = await discoverLocalSkills(dir);
+
+ // Then
+ expect(skills[0]).toMatchObject({
+ dirName: "bare",
+ name: "",
+ description: "",
+ });
+ });
+
+ it("ignores node_modules and dotfiles inside a skill", async () => {
+ // Given
+ await writeSkill(dir);
+ await mkdir(join(dir, "node_modules", "pkg"), { recursive: true });
+ await writeFile(join(dir, "node_modules", "pkg", "index.js"), "module\n");
+ await writeFile(join(dir, ".DS_Store"), "junk\n");
+
+ // When
+ const skills = await discoverLocalSkills(dir);
+
+ // Then
+ expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([
+ "SKILL.md",
+ ]);
+ });
+
+ it("does not follow a symlinked directory out of the skill", async () => {
+ // Given — a link inside the skill pointing at an unrelated local directory
+ await writeSkill(dir);
+ const outside = await mkdtemp(join(tmpdir(), "outside-"));
+ try {
+ await writeFile(join(outside, "secret.txt"), "not yours\n");
+ await symlink(outside, join(dir, "escape"), "dir");
+
+ // When
+ const skills = await discoverLocalSkills(dir);
+
+ // Then — the linked file is never collected
+ expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([
+ "SKILL.md",
+ ]);
+ } finally {
+ await rm(outside, { recursive: true, force: true });
+ }
+ });
+
+ it("does not follow a symlinked file out of the skill", async () => {
+ // Given
+ await writeSkill(dir);
+ const outside = await mkdtemp(join(tmpdir(), "outside-"));
+ try {
+ const secret = join(outside, "secret.txt");
+ await writeFile(secret, "not yours\n");
+ await symlink(secret, join(dir, "secret.txt"));
+
+ // When
+ const skills = await discoverLocalSkills(dir);
+
+ // Then
+ expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([
+ "SKILL.md",
+ ]);
+ } finally {
+ await rm(outside, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects a symlinked SKILL.md at the root rather than shipping a skill without it", async () => {
+ // Given — the marker reads fine but would be excluded from the copy
+ const outside = await mkdtemp(join(tmpdir(), "outside-"));
+ try {
+ await writeFile(join(outside, "real.md"), SKILL_MD);
+ await symlink(join(outside, "real.md"), join(dir, "SKILL.md"));
+
+ // When / Then
+ await expect(discoverLocalSkills(dir)).rejects.toThrow(
+ /SKILL\.md must be a regular file \(found a symlink\)/,
+ );
+ } finally {
+ await rm(outside, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects a symlinked SKILL.md in a subdirectory", async () => {
+ // Given
+ const outside = await mkdtemp(join(tmpdir(), "outside-"));
+ try {
+ await writeFile(join(outside, "real.md"), SKILL_MD);
+ await mkdir(join(dir, "grill-me"), { recursive: true });
+ await symlink(
+ join(outside, "real.md"),
+ join(dir, "grill-me", "SKILL.md"),
+ );
+
+ // When / Then
+ await expect(discoverLocalSkills(dir)).rejects.toThrow(
+ /must be a regular file/,
+ );
+ } finally {
+ await rm(outside, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects a directory named SKILL.md", async () => {
+ // Given
+ await mkdir(join(dir, "SKILL.md"), { recursive: true });
+
+ // When / Then
+ await expect(discoverLocalSkills(dir)).rejects.toThrow(
+ /must be a regular file \(found a directory\)/,
+ );
+ });
+
+ it("rejects a missing path", async () => {
+ await expect(
+ discoverLocalSkills(join(dir, "does-not-exist")),
+ ).rejects.toThrow(/Directory not found/);
+ });
+
+ it("rejects a file path", async () => {
+ // Given
+ const filePath = join(dir, "SKILL.md");
+ await writeFile(filePath, SKILL_MD);
+
+ // When / Then
+ await expect(discoverLocalSkills(filePath)).rejects.toThrow(
+ /Not a directory/,
+ );
+ });
+
+ it("explains when no SKILL.md exists at either level", async () => {
+ // Given
+ await writeFile(join(dir, "README.md"), "no skills here\n");
+
+ // When / Then
+ await expect(discoverLocalSkills(dir)).rejects.toThrow(/No skills found/);
+ });
+});
+
+describe("skill path safety", () => {
+ it("rejects directory names that could escape the destination", () => {
+ for (const name of ["", ".", "..", "a/b", "a\\b"]) {
+ expect(() => assertSafeDirName(name)).toThrow(InvalidInputError);
+ }
+ });
+
+ it("accepts ordinary directory names", () => {
+ expect(() => assertSafeDirName("grill-me")).not.toThrow();
+ });
+
+ it("rejects relative paths that climb out of the skill directory", () => {
+ for (const path of ["", "../secrets", "a/../../b", "/etc/passwd"]) {
+ expect(() => assertSafeRelativePath(path, "grill-me")).toThrow(
+ InvalidInputError,
+ );
+ }
+ });
+
+ it("accepts ordinary nested paths", () => {
+ expect(() =>
+ assertSafeRelativePath("references/api.md", "grill-me"),
+ ).not.toThrow();
+ });
+
+ it("rejects resolved paths that land outside the skill root", () => {
+ expect(() =>
+ assertWithinSkillRoot("/skills/grill-me", "/etc/passwd", "grill-me"),
+ ).toThrow(InvalidInputError);
+ // The root itself is not a file inside the skill.
+ expect(() =>
+ assertWithinSkillRoot("/skills/grill-me", "/skills/grill-me", "grill-me"),
+ ).toThrow(InvalidInputError);
+ });
+
+ it("accepts resolved paths beneath the skill root", () => {
+ expect(() =>
+ assertWithinSkillRoot(
+ "/skills/grill-me",
+ "/skills/grill-me/references/api.md",
+ "grill-me",
+ ),
+ ).not.toThrow();
+ });
+});
+
+describe("isBinary", () => {
+ it("flags a buffer containing a NUL byte", () => {
+ expect(isBinary(Buffer.from([0x50, 0x4e, 0x47, 0x00]))).toBe(true);
+ });
+
+ it("passes ordinary text through", () => {
+ expect(isBinary(Buffer.from("# Reference\n", "utf-8"))).toBe(false);
+ });
+});
diff --git a/packages/cli/tests/fixtures/local-skills/empty/README.md b/packages/cli/tests/fixtures/local-skills/empty/README.md
new file mode 100644
index 00000000..5afa8e27
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/empty/README.md
@@ -0,0 +1 @@
+no skills here
diff --git a/packages/cli/tests/fixtures/local-skills/multi/grill-me/SKILL.md b/packages/cli/tests/fixtures/local-skills/multi/grill-me/SKILL.md
new file mode 100644
index 00000000..35e363e3
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/multi/grill-me/SKILL.md
@@ -0,0 +1,6 @@
+---
+name: grill-me
+description: Interview the user relentlessly about a proposed change before any code is written.
+---
+
+Ask one question at a time. Recommend an answer for each.
diff --git a/packages/cli/tests/fixtures/local-skills/multi/grill-me/references/api.md b/packages/cli/tests/fixtures/local-skills/multi/grill-me/references/api.md
new file mode 100644
index 00000000..a208e363
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/multi/grill-me/references/api.md
@@ -0,0 +1,3 @@
+# Reference
+
+Bundled reference material the skill reads on demand.
diff --git a/packages/cli/tests/fixtures/local-skills/multi/not-a-skill/README.md b/packages/cli/tests/fixtures/local-skills/multi/not-a-skill/README.md
new file mode 100644
index 00000000..48cdce85
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/multi/not-a-skill/README.md
@@ -0,0 +1 @@
+placeholder
diff --git a/packages/cli/tests/fixtures/local-skills/multi/tidy-up/SKILL.md b/packages/cli/tests/fixtures/local-skills/multi/tidy-up/SKILL.md
new file mode 100644
index 00000000..cbeb39fa
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/multi/tidy-up/SKILL.md
@@ -0,0 +1,6 @@
+---
+name: tidy-up
+description: Pre-PR tidy-up review.
+---
+
+Check the diff for leftovers before opening a pull request.
diff --git a/packages/cli/tests/fixtures/local-skills/single/SKILL.md b/packages/cli/tests/fixtures/local-skills/single/SKILL.md
new file mode 100644
index 00000000..0c8a631b
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/single/SKILL.md
@@ -0,0 +1,6 @@
+---
+name: deploy-check
+description: Verify a deploy is safe before shipping it.
+---
+
+Confirm the build passes and the changelog is updated.
diff --git a/packages/cli/tests/fixtures/local-skills/single/scripts/run.sh b/packages/cli/tests/fixtures/local-skills/single/scripts/run.sh
new file mode 100644
index 00000000..8b2fe543
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/single/scripts/run.sh
@@ -0,0 +1 @@
+echo hi
diff --git a/packages/cli/tests/fixtures/local-skills/with-binary/SKILL.md b/packages/cli/tests/fixtures/local-skills/with-binary/SKILL.md
new file mode 100644
index 00000000..9a7b5688
--- /dev/null
+++ b/packages/cli/tests/fixtures/local-skills/with-binary/SKILL.md
@@ -0,0 +1,6 @@
+---
+name: with-binary
+description: A skill that bundles a binary asset.
+---
+
+The bundled asset cannot travel over the sandbox bridge.
diff --git a/packages/cli/tests/fixtures/local-skills/with-binary/assets/logo.bin b/packages/cli/tests/fixtures/local-skills/with-binary/assets/logo.bin
new file mode 100644
index 00000000..7e0b61d4
Binary files /dev/null and b/packages/cli/tests/fixtures/local-skills/with-binary/assets/logo.bin differ