packages/hub/cli.ts (646 lines of code) (raw):
#! /usr/bin/env node
import { parseArgs } from "node:util";
import { typedEntries } from "./src/utils/typedEntries";
import { createBranch, createRepo, deleteBranch, deleteRepo, repoExists, uploadFilesWithProgress } from "./src";
import { pathToFileURL } from "node:url";
import { stat } from "node:fs/promises";
import { basename, join } from "node:path";
import { HUB_URL } from "./src/consts";
import { version } from "./package.json";
// Didn't find the import from "node:util", so duplicated it here
type OptionToken =
| { kind: "option"; index: number; name: string; rawName: string; value: string; inlineValue: boolean }
| {
kind: "option";
index: number;
name: string;
rawName: string;
value: undefined;
inlineValue: undefined;
};
// const command = process.argv[2]; // Replaced by mainCommandName and subCommandName
// const args = process.argv.slice(3); // Replaced by cliArgs
type Camelize<T extends string> = T extends `${infer A}-${infer B}` ? `${A}${Camelize<Capitalize<B>>}` : T;
interface ArgDef {
name: string;
short?: string;
positional?: boolean;
description?: string;
required?: boolean;
boolean?: boolean;
enum?: Array<string>;
default?: string | boolean | (() => string | boolean); // Allow boolean defaults
}
interface SingleCommand {
description: string;
args: readonly ArgDef[];
}
interface CommandGroup {
description: string;
subcommands: Record<string, SingleCommand>;
}
const commands = {
upload: {
description: "Upload a folder to a repo on the Hub",
args: [
{
name: "repo-name" as const,
description: "The name of the repo to upload to",
positional: true,
required: true,
},
{
name: "local-folder" as const,
description: "The local folder to upload. Defaults to the current working directory",
positional: true,
default: () => process.cwd(),
},
{
name: "path-in-repo" as const,
description: "The path in the repo to upload the folder to. Defaults to the root of the repo",
positional: true,
default: ".",
},
{
name: "quiet" as const,
short: "q",
description: "Suppress all output",
boolean: true,
},
{
name: "repo-type" as const,
enum: ["dataset", "model", "space"],
description:
"The type of repo to upload to. Defaults to model. You can also prefix the repo name with the type, e.g. datasets/username/repo-name",
},
{
name: "revision" as const,
description: "The revision to upload to. Defaults to the main branch",
default: "main",
},
{
name: "commit-message" as const,
description: "The commit message to use. Defaults to 'Upload files using @huggingface/hub'",
default: "Upload files using @huggingface/hub",
},
{
name: "private" as const,
description: "If creating a new repo, make it private",
boolean: true,
},
{
name: "token" as const,
description:
"The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.",
default: process.env.HF_TOKEN,
},
] as const,
} satisfies SingleCommand,
branch: {
description: "Manage repository branches",
subcommands: {
create: {
description: "Create a new branch in a repo, or update an existing one",
args: [
{
name: "repo-name" as const,
description: "The name of the repo to create the branch in",
positional: true,
required: true,
},
{
name: "branch" as const,
description: "The name of the branch to create",
positional: true,
required: true,
},
{
name: "repo-type" as const,
enum: ["dataset", "model", "space"],
description:
"The type of the repo to create the branch into. Defaults to model. You can also prefix the repo name with the type, e.g. datasets/username/repo-name",
},
{
name: "revision" as const,
description:
"The revision to create the branch from. Defaults to the main branch, or existing branch if it exists.",
},
{
name: "empty" as const,
boolean: true,
description: "Create an empty branch. This will erase all previous commits on the branch if it exists.",
},
{
name: "force" as const,
short: "f",
boolean: true,
description:
"Overwrite the branch if it already exists. Otherwise, throws an error if the branch already exists. No-ops if no revision is provided and the branch exists.",
},
{
name: "token" as const,
description:
"The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.",
default: process.env.HF_TOKEN,
},
] as const,
},
delete: {
description: "Delete a branch in a repo",
args: [
{
name: "repo-name" as const,
description: "The name of the repo to delete the branch from",
positional: true,
required: true,
},
{
name: "branch" as const,
description: "The name of the branch to delete",
positional: true,
required: true,
},
{
name: "repo-type" as const,
enum: ["dataset", "model", "space"],
description:
"The type of repo to delete the branch from. Defaults to model. You can also prefix the repo name with the type, e.g. datasets/username/repo-name",
},
{
name: "token" as const,
description:
"The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.",
default: process.env.HF_TOKEN,
},
] as const,
},
},
} satisfies CommandGroup,
repo: {
description: "Manage repositories on the Hub",
subcommands: {
delete: {
description: "Delete a repository from the Hub",
args: [
{
name: "repo-name" as const,
description:
"The name of the repo to delete. You can also prefix the repo name with the type, e.g. datasets/username/repo-name",
positional: true,
required: true,
},
{
name: "repo-type" as const,
enum: ["dataset", "model", "space"],
description:
"The type of the repo to delete. Defaults to model. You can also prefix the repo name with the type, e.g. datasets/username/repo-name",
},
{
name: "token" as const,
description:
"The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.",
default: process.env.HF_TOKEN,
},
] as const,
},
},
} satisfies CommandGroup,
version: {
description: "Print the version of the CLI",
args: [] as const,
} satisfies SingleCommand,
} satisfies Record<string, SingleCommand | CommandGroup>;
type TopLevelCommandName = keyof typeof commands;
const mainCommandName = process.argv[2];
let subCommandName: string | undefined;
let cliArgs: string[];
if (
mainCommandName &&
mainCommandName in commands &&
commands[mainCommandName as keyof typeof commands] &&
"subcommands" in commands[mainCommandName as keyof typeof commands]
) {
subCommandName = process.argv[3];
cliArgs = process.argv.slice(4);
} else {
cliArgs = process.argv.slice(3);
}
async function run() {
switch (mainCommandName) {
case undefined:
case "--help":
case "help": {
const helpArgs = mainCommandName === "help" ? process.argv.slice(3) : [];
if (helpArgs.length > 0) {
const cmdName = helpArgs[0] as TopLevelCommandName;
if (cmdName && commands[cmdName]) {
const cmdDef = commands[cmdName];
if ("subcommands" in cmdDef) {
if (helpArgs.length > 1) {
const subCmdName = helpArgs[1];
if (
subCmdName in cmdDef.subcommands &&
cmdDef.subcommands[subCmdName as keyof typeof cmdDef.subcommands]
) {
console.log(detailedUsageForSubcommand(cmdName, subCmdName as keyof typeof cmdDef.subcommands));
break;
} else {
console.error(`Error: Unknown subcommand '${subCmdName}' for command '${cmdName}'.`);
console.log(listSubcommands(cmdName, cmdDef));
process.exitCode = 1;
break;
}
} else {
console.log(listSubcommands(cmdName, cmdDef));
break;
}
} else {
console.log(detailedUsageForCommand(cmdName));
break;
}
} else {
console.error(`Error: Unknown command '${cmdName}' for help.`);
process.exitCode = 1;
}
} else {
// General help
console.log(
`Hugging Face CLI Tools (hfjs)\n\nAvailable commands:\n\n` +
typedEntries(commands)
.map(([name, def]) => ` ${usage(name)}: ${def.description}`)
.join("\n")
);
console.log("\nTo get help on a specific command, run `hfjs help <command>` or `hfjs <command> --help`");
console.log(
"For commands with subcommands (like 'branch'), run `hfjs help <command> <subcommand>` or `hfjs <command> <subcommand> --help`"
);
if (mainCommandName === undefined) {
process.exitCode = 1;
}
}
break;
}
case "upload": {
const cmdDef = commands.upload;
if (cliArgs[0] === "--help" || cliArgs[0] === "-h") {
console.log(detailedUsageForCommand("upload"));
break;
}
const parsedArgs = advParseArgs(cliArgs, cmdDef.args, "upload");
const {
repoName,
localFolder,
repoType,
revision,
token,
quiet,
commitMessage,
pathInRepo,
private: isPrivate,
} = parsedArgs;
const repoId = repoType ? { type: repoType as "model" | "dataset" | "space", name: repoName } : repoName;
if (
!(await repoExists({ repo: repoId, revision, accessToken: token, hubUrl: process.env.HF_ENDPOINT ?? HUB_URL }))
) {
if (!quiet) {
console.log(`Repo ${repoName} does not exist. Creating it...`);
}
await createRepo({
repo: repoId,
accessToken: token,
private: !!isPrivate,
hubUrl: process.env.HF_ENDPOINT ?? HUB_URL,
});
}
const isFile = (await stat(localFolder)).isFile();
const files = isFile
? [
{
content: pathToFileURL(localFolder),
path: join(pathInRepo, `${basename(localFolder)}`).replace(/^[.]?\//, ""),
},
]
: [{ content: pathToFileURL(localFolder), path: pathInRepo.replace(/^[.]?\//, "") }];
for await (const event of uploadFilesWithProgress({
repo: repoId,
files,
branch: revision,
accessToken: token,
commitTitle: commitMessage?.trim().split("\n")[0],
commitDescription: commitMessage?.trim().split("\n").slice(1).join("\n").trim(),
hubUrl: process.env.HF_ENDPOINT ?? HUB_URL,
})) {
if (!quiet) {
console.log(event);
}
}
break;
}
case "branch": {
const branchCommandGroup = commands.branch;
const currentSubCommandName = subCommandName as keyof typeof branchCommandGroup.subcommands | undefined;
if (cliArgs[0] === "--help" || cliArgs[0] === "-h") {
if (currentSubCommandName && branchCommandGroup.subcommands[currentSubCommandName]) {
console.log(detailedUsageForSubcommand("branch", currentSubCommandName));
} else {
console.log(listSubcommands("branch", branchCommandGroup));
}
break;
}
if (!currentSubCommandName || !branchCommandGroup.subcommands[currentSubCommandName]) {
console.error(`Error: Missing or invalid subcommand for 'branch'.`);
console.log(listSubcommands("branch", branchCommandGroup));
process.exitCode = 1;
break;
}
const subCmdDef = branchCommandGroup.subcommands[currentSubCommandName];
switch (currentSubCommandName) {
case "create": {
const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "branch create");
const { repoName, branch, revision, empty, repoType, token, force } = parsedArgs;
await createBranch({
repo: repoType ? { type: repoType as "model" | "dataset" | "space", name: repoName } : repoName,
branch,
accessToken: token,
revision,
empty: empty ?? undefined,
overwrite: force ?? undefined,
hubUrl: process.env.HF_ENDPOINT ?? HUB_URL,
});
console.log(`Branch '${branch}' created successfully in repo '${repoName}'.`);
break;
}
case "delete": {
const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "branch delete");
const { repoName, branch, repoType, token } = parsedArgs;
await deleteBranch({
repo: repoType ? { type: repoType as "model" | "dataset" | "space", name: repoName } : repoName,
branch,
accessToken: token,
hubUrl: process.env.HF_ENDPOINT ?? HUB_URL,
});
console.log(`Branch '${branch}' deleted successfully from repo '${repoName}'.`);
break;
}
default:
// Should be caught by the check above
console.error(`Error: Unknown subcommand '${currentSubCommandName}' for 'branch'.`);
console.log(listSubcommands("branch", branchCommandGroup));
process.exitCode = 1;
break;
}
break;
}
case "repo": {
const repoCommandGroup = commands.repo;
const currentSubCommandName = subCommandName as keyof typeof repoCommandGroup.subcommands | undefined;
if (cliArgs[0] === "--help" || cliArgs[0] === "-h") {
if (currentSubCommandName && repoCommandGroup.subcommands[currentSubCommandName]) {
console.log(detailedUsageForSubcommand("repo", currentSubCommandName));
} else {
console.log(listSubcommands("repo", repoCommandGroup));
}
break;
}
if (!currentSubCommandName || !repoCommandGroup.subcommands[currentSubCommandName]) {
console.error(`Error: Missing or invalid subcommand for 'repo'.`);
console.log(listSubcommands("repo", repoCommandGroup));
process.exitCode = 1;
break;
}
const subCmdDef = repoCommandGroup.subcommands[currentSubCommandName];
switch (currentSubCommandName) {
case "delete": {
const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, `repo ${currentSubCommandName}`);
const { repoName, repoType, token } = parsedArgs;
const repoDesignation: Parameters<typeof deleteRepo>[0]["repo"] = repoType
? { type: repoType as "model" | "dataset" | "space", name: repoName }
: repoName;
await deleteRepo({
repo: repoDesignation,
accessToken: token,
hubUrl: process.env.HF_ENDPOINT ?? HUB_URL,
});
console.log(`Repository '${repoName}' deleted successfully.`);
break;
}
default:
// This case should ideally be caught by the check above
console.error(`Error: Unknown subcommand '${currentSubCommandName}' for 'repo'.`);
console.log(listSubcommands("repo", repoCommandGroup));
process.exitCode = 1;
break;
}
break;
}
case "version": {
if (cliArgs[0] === "--help" || cliArgs[0] === "-h") {
console.log(detailedUsageForCommand("version"));
break;
}
console.log(`hfjs version: ${version}`);
break;
}
default:
console.error("Command not found: " + mainCommandName);
// Print general help
console.log(
`\nAvailable commands:\n\n` +
typedEntries(commands)
.map(([name, def]) => ` ${usage(name)}: ${def.description}`)
.join("\n")
);
console.log("\nTo get help on a specific command, run `hfjs help <command>` or `hfjs <command> --help`");
process.exitCode = 1;
break;
}
}
run().catch((err) => {
console.error("\x1b[31mError:\x1b[0m", err.message);
//if (process.env.DEBUG) {
console.error(err);
// }
process.exitCode = 1;
});
function usage(commandName: TopLevelCommandName, subCommandName?: string): string {
const commandEntry = commands[commandName];
let cmdArgs: readonly ArgDef[];
let fullCommandName = commandName as string;
if ("subcommands" in commandEntry) {
if (subCommandName && subCommandName in commandEntry.subcommands) {
cmdArgs = commandEntry.subcommands[subCommandName as keyof typeof commandEntry.subcommands].args;
fullCommandName = `${commandName} ${subCommandName}`;
} else {
return `${commandName} <subcommand>`;
}
} else {
cmdArgs = commandEntry.args;
}
return `${fullCommandName} ${(cmdArgs || [])
.map((arg) => {
if (arg.positional) {
return arg.required ? `<${arg.name}>` : `[${arg.name}]`;
}
return `[--${arg.name}${arg.short ? `|-${arg.short}` : ""}${
arg.enum ? ` {${arg.enum.join("|")}}` : arg.boolean ? "" : ` <${arg.name.toUpperCase().replace(/-/g, "_")}>`
}]`;
})
.join(" ")}`.trim();
}
function _detailedUsage(args: readonly ArgDef[], usageLine: string, commandDescription?: string): string {
let ret = `usage: hfjs ${usageLine}\n`;
if (commandDescription) {
ret += `\n${commandDescription}\n`;
}
const positionals = args.filter((p) => p.positional);
const options = args.filter((p) => !p.positional);
if (positionals.length > 0) {
ret += `\nPositional arguments:\n`;
for (const arg of positionals) {
ret += ` ${arg.name}\t${arg.description}${
arg.default ? ` (default: ${typeof arg.default === "function" ? arg.default() : arg.default})` : ""
}\n`;
}
}
if (options.length > 0) {
ret += `\nOptions:\n`;
for (const arg of options) {
const nameAndAlias = `--${arg.name}${arg.short ? `, -${arg.short}` : ""}`;
const valueHint = arg.enum
? `{${arg.enum.join("|")}}`
: arg.boolean
? ""
: `<${arg.name.toUpperCase().replace(/-/g, "_")}>`;
ret += ` ${nameAndAlias}${valueHint ? " " + valueHint : ""}\t${arg.description}${
arg.default !== undefined
? ` (default: ${typeof arg.default === "function" ? arg.default() : arg.default})`
: ""
}\n`;
}
}
ret += `\n`;
return ret;
}
function detailedUsageForCommand(commandName: TopLevelCommandName): string {
const commandDef = commands[commandName];
if ("subcommands" in commandDef) {
return listSubcommands(commandName, commandDef);
}
return _detailedUsage(commandDef.args, usage(commandName), commandDef.description);
}
function detailedUsageForSubcommand(
commandName: TopLevelCommandName,
subCommandName: keyof CommandGroup["subcommands"]
): string {
const commandGroup = commands[commandName];
if (!("subcommands" in commandGroup) || !(subCommandName in commandGroup.subcommands)) {
throw new Error(`Subcommand ${subCommandName as string} not found for ${commandName}`);
}
const subCommandDef = commandGroup.subcommands[subCommandName as keyof typeof commandGroup.subcommands];
return _detailedUsage(subCommandDef.args, usage(commandName, subCommandName as string), subCommandDef.description);
}
function listSubcommands(commandName: TopLevelCommandName, commandGroup: CommandGroup): string {
let ret = `usage: hfjs ${commandName} <subcommand> [options]\n\n`;
ret += `${commandGroup.description}\n\n`;
ret += `Available subcommands for '${commandName}':\n`;
ret += typedEntries(commandGroup.subcommands)
.map(([subName, subDef]) => ` ${subName}\t${subDef.description}`)
.join("\n");
ret += `\n\nRun \`hfjs help ${commandName} <subcommand>\` for more information on a specific subcommand.`;
return ret;
}
type ParsedArgsResult<TArgsDef extends readonly ArgDef[]> = {
[K in TArgsDef[number] as Camelize<K["name"]>]: K["boolean"] extends true
? boolean
: K["required"] extends true
? string
: K["default"] extends undefined
? string | undefined // Optional strings without default can be undefined
: string; // Strings with default or required are strings
};
function advParseArgs<TArgsDef extends readonly ArgDef[]>(
args: string[],
argDefs: TArgsDef,
commandNameForError: string
): ParsedArgsResult<TArgsDef> {
const { tokens } = parseArgs({
options: Object.fromEntries(
argDefs
.filter((arg) => !arg.positional)
.map((arg) => {
const optionConfig = {
type: arg.boolean ? ("boolean" as const) : ("string" as const),
...(arg.short && { short: arg.short }),
...(arg.default !== undefined && {
default: typeof arg.default === "function" ? arg.default() : arg.default,
}),
};
return [arg.name, optionConfig];
})
),
args,
allowPositionals: true,
strict: false, // We do custom validation based on tokens and argDefs
tokens: true,
});
const expectedPositionals = argDefs.filter((arg) => arg.positional);
const providedPositionalTokens = tokens.filter((token) => token.kind === "positional");
if (providedPositionalTokens.length < expectedPositionals.filter((arg) => arg.required).length) {
throw new Error(
`Command '${commandNameForError}': Missing required positional arguments. Usage: hfjs ${usage(
commandNameForError.split(" ")[0] as TopLevelCommandName,
commandNameForError.split(" ")[1]
)}`
);
}
if (providedPositionalTokens.length > expectedPositionals.length) {
throw new Error(
`Command '${commandNameForError}': Too many positional arguments. Usage: hfjs ${usage(
commandNameForError.split(" ")[0] as TopLevelCommandName,
commandNameForError.split(" ")[1]
)}`
);
}
const result: Record<string, string | boolean> = {};
// Populate from defaults first
for (const argDef of argDefs) {
if (argDef.default !== undefined) {
result[argDef.name] = typeof argDef.default === "function" ? argDef.default() : argDef.default;
} else if (argDef.boolean) {
result[argDef.name] = false; // Booleans default to false if no other default
}
}
// Populate positionals
providedPositionalTokens.forEach((token, i) => {
if (expectedPositionals[i]) {
result[expectedPositionals[i].name] = token.value;
}
});
// Populate options from tokens, overriding defaults
tokens
.filter((token): token is OptionToken => token.kind === "option")
.forEach((token) => {
const argDef = argDefs.find((def) => def.name === token.name || def.short === token.name);
if (!argDef) {
throw new Error(`Command '${commandNameForError}': Unknown option: ${token.rawName}`);
}
if (argDef.boolean) {
result[argDef.name] = true;
} else {
if (token.value === undefined) {
throw new Error(`Command '${commandNameForError}': Missing value for option: ${token.rawName}`);
}
if (argDef.enum && !argDef.enum.includes(token.value)) {
throw new Error(
`Command '${commandNameForError}': Invalid value '${token.value}' for option ${
token.rawName
}. Expected one of: ${argDef.enum.join(", ")}`
);
}
result[argDef.name] = token.value;
}
});
// Final check for required arguments
for (const argDef of argDefs) {
if (argDef.required && result[argDef.name] === undefined) {
throw new Error(`Command '${commandNameForError}': Missing required argument: ${argDef.name}`);
}
}
return Object.fromEntries(
Object.entries(result).map(([name, val]) => [kebabToCamelCase(name), val])
) as ParsedArgsResult<TArgsDef>;
}
function kebabToCamelCase(str: string) {
return str.replace(/-./g, (match) => match[1].toUpperCase());
}