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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` 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 <path>` reads the content from a local file, as an alternative to `--content` or piped stdin.

### Fixed

Expand Down
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/cli/commands/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,5 +17,6 @@ export function getSandboxCommand(): Command {
.addCommand(getSandboxEditFileCommand())
.addCommand(getSandboxGrepCommand())
.addCommand(getSandboxRunCommandCommand())
.addCommand(getSandboxCheckpointCommand());
.addCommand(getSandboxCheckpointCommand())
.addCommand(getSandboxPushSkillsCommand());
}
210 changes: 210 additions & 0 deletions packages/cli/src/cli/commands/sandbox/push-skills.ts
Original file line number Diff line number Diff line change
@@ -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<LocalSkill[]> {
const options: PromptOption<string | null>[] = [
{ 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<string | null>({
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<LocalSkill[]> {
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 <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<RunCommandResult> {
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("<dir>", "Local skill directory, or a directory of skills")
.option(
"--name <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);
}
32 changes: 32 additions & 0 deletions packages/cli/src/cli/commands/sandbox/shared.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -25,6 +27,36 @@ export async function resolveFlagOrStdin(
return readStdin(flagName, { trim: false });
}

/**
* Resolve file content from `--content`, `--file <path>`, 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<string> {
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 <path>, or pipe the value via stdin (e.g. echo <value> | 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.
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/cli/commands/sandbox/write-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -16,7 +17,7 @@ async function writeFileAction(
options: WriteFileOptions,
): Promise<RunCommandResult> {
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 }),
Expand All @@ -30,13 +31,15 @@ export function getSandboxWriteFileCommand(): Command {
.description("Create or overwrite a file in an app's remote sandbox")
.argument("<path>", "File path relative to the app root")
.option("--content <content>", "File content (if omitted, read from stdin)")
.option("--file <path>", "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);
}
55 changes: 55 additions & 0 deletions packages/cli/src/core/skills/copy.ts
Original file line number Diff line number Diff line change
@@ -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<CopySkillResult> {
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 };
}
Loading