← Files JuicyLucy AdsARCHIVED FILE

skills/ad-naming/scripts/naming.mjs

26.6 KB · Oct 5, 2026 · 18:34 UTC

↓ Download file

#!/usr/bin/env node
// The naming tool — export filenames and ad-set folders, for both mediums.
//
// Exported files leave the production tree and enter a human pipeline: a
// designer reviews them, drops the survivors into a flat delivery folder, and
// an automation uploads them to the ad platform. That automation READS THE
// FILENAME, and the folder is a flat namespace, so the filename is the only
// thing distinguishing one creative, market, or version from another. Ad-set
// folders carry a global sequence number that must never collide across
// dates, languages, or mediums.
//
// Nobody hand-writes a filename or a sequence number beside this tool.
//
//   node naming.mjs get     --project .
//   node naming.mjs set     --project . --creative-name "Boxes Stop Motion" --funnel MOF --source winner --style videotextoverlay [--medium video|static]
//   node naming.mjs expand  --project . --ratio 9x16 --markets en,de,pt-br
//   node naming.mjs name    --medium static --creative-name "Sticky Note" --funnel TOF --source winner --style imagestatic --ratio 4x5 --markets en,de [--date YYYY.MM.DD]
//   node naming.mjs inherit --filename "<source filename>" --market de
//   node naming.mjs parse   "<filename>"
//   node naming.mjs reserve --root <campaign parent> --count N
//   node naming.mjs folder  --seq 13 --language de --batch GEN --ads 8 --icp "Mixed ICP" --format "Static Format"
//   node naming.mjs where   # which copy of the conventions every command above is reading
//
// A video run authors ONE record per ad (`set`) and expands it into one
// filename per market (`expand`); the record lives in the project as
// export-naming.json. A statics run names each image directly (`name`),
// derives a localized name from a source file (`inherit`), and reserves its
// folder numbers against the live filesystem (`reserve`, `folder`).
//
// Grammar and rationale: ../SKILL.md. Dependency-free: it must run from an
// installed skill dir with no node_modules.

import { readdirSync, readFileSync, realpathSync, writeFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

export const RECORD_FILE = "export-naming.json";

/** The name the record carried before it was renamed; it collides with the grammar file. */
export const LEGACY_RECORD_FILE = "naming.json";

/** The four fields the agent authors. Ratio, market and date are the system's. */
export const AUTHORED_FIELDS = ["creativeName", "funnelStage", "source", "style"];

export const DEFAULT_MEDIUM = "video";

// ── The grammar's single home ───────────────────────────────────────────────
//
// The token grammar and the folder grammar are agency convention, shared by
// both mediums, and live in the workspace conventions skill's naming.json and
// foldering.json — this script reads the constants from there rather than
// restating them. Anything it cannot find fails loud: a filename authored
// from remembered constants is exactly the fork this file exists to prevent.
//
// The conventions skill is found the way Codex finds it. Codex reads skills
// from a project's `.agents/skills` (walked up from the working directory),
// from `~/.agents/skills`, from `/etc/codex/skills`, and from the plugin — and
// a copy in any of the first three SHADOWS the plugin's copy of the same name.
// So when a user carries their own `juicylucy` skill, the agent is reading
// that copy, and this tool has to name files from the same one; a tool that
// read the plugin's copy regardless would produce filenames the prose in front
// of the agent contradicts. The plugin's copy — a sibling skill directory — is
// therefore not the first candidate but the fallback, and the repo checkout
// is the last. `where` reports which one was read.

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));

/** The catalogue name of the workspace conventions skill. */
export const WORKSPACE_SKILL = "juicylucy";

/**
 * Codex's skill roots, in the order Codex ranks them: every `.agents/skills`
 * from the working directory up to (not including) the home directory, then
 * the user root, then the system root. A skill in any of these shadows the
 * plugin's. `cwd` and `home` are parameters so the order is testable without
 * touching the developer's own roots.
 */
export function codexSkillRoots({ cwd = process.cwd(), home = homedir() } = {}) {
  // Physical paths on both sides: a home reached through a symlink (/var →
  // /private/var on a Mac) would otherwise never equal the working directory's
  // ancestor and the walk would run past it.
  const physical = (path) => {
    try {
      return realpathSync(path);
    } catch {
      return resolve(path);
    }
  };
  const roots = [];
  const stop = physical(home);
  let dir = physical(cwd);
  while (dir !== stop) {
    roots.push({ dir: join(dir, ".agents", "skills"), origin: "project" });
    const parent = dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  roots.push({ dir: join(stop, ".agents", "skills"), origin: "user" });
  roots.push({ dir: "/etc/codex/skills", origin: "system" });
  return roots;
}

/** Where `file` of the conventions skill may be, first match wins. */
export function conventionCandidates(file, roots = codexSkillRoots()) {
  return [
    ...roots.map(({ dir, origin }) => ({ path: join(dir, WORKSPACE_SKILL, file), origin })),
    { path: join(SCRIPT_DIR, "..", "..", WORKSPACE_SKILL, file), origin: "plugin" },
    { path: join(SCRIPT_DIR, "..", "..", "..", "..", "workspace", WORKSPACE_SKILL, file), origin: "checkout" },
  ];
}

/** Origins that are the shipped conventions; anything else is a local copy. */
export const SHIPPED_ORIGINS = ["plugin", "checkout"];

/** The first candidate that exists — `{ path, origin }` — or a loud failure. */
export function resolveConventions(file, roots = codexSkillRoots()) {
  for (const candidate of conventionCandidates(file, roots)) {
    if (statSync(candidate.path, { throwIfNoEntry: false })?.isFile()) return candidate;
  }
  throw new Error(
    `workspace conventions not found: the workspace skill's ${file} is not installed ` +
      "beside this skill, not in a Codex skill root, and this is not a repo checkout. Install " +
      "the workspace conventions skill, or stop and ask — never hand-write a filename or a " +
      "folder from memory.",
  );
}

function loadConventions(file, roots) {
  return JSON.parse(readFileSync(resolveConventions(file, roots).path, "utf8"));
}

export function loadGrammar(roots = codexSkillRoots()) {
  return loadConventions("naming.json", roots);
}

export function loadFoldering(roots = codexSkillRoots()) {
  return loadConventions("foldering.json", roots);
}

export const GRAMMAR_SOURCE = resolveConventions("naming.json");
export const FOLDERING_SOURCE = resolveConventions("foldering.json");

const GRAMMAR = JSON.parse(readFileSync(GRAMMAR_SOURCE.path, "utf8"));
const FOLDERING = JSON.parse(readFileSync(FOLDERING_SOURCE.path, "utf8"));

/**
 * A local copy of the conventions is in effect when either file resolved
 * outside the plugin. It is honoured — that is what the resolution order is
 * for — and it is said, because the person reading the filenames may not
 * know their machine names them differently from everyone else's.
 */
export function localConventionsNote(sources = [GRAMMAR_SOURCE, FOLDERING_SOURCE]) {
  const local = sources.filter((source) => !SHIPPED_ORIGINS.includes(source.origin));
  if (local.length === 0) return null;
  const where = [...new Set(local.map((source) => dirname(source.path)))].join(", ");
  return (
    `note: conventions read from a local copy of the ${WORKSPACE_SKILL} skill (${where}), ` +
    "which shadows the shipped one. Filenames and folders follow that copy; say so to the user."
  );
}

export const MEDIUMS = Object.keys(GRAMMAR.mediums);
export const FUNNEL_STAGES = GRAMMAR.funnel_stages;

/** The medium's constants: author, format, extensions, styles. */
export function mediumConstants(medium = DEFAULT_MEDIUM) {
  const constants = GRAMMAR.mediums[medium];
  if (constants === undefined) throw new Error(`unknown medium "${medium}" (${MEDIUMS.join(", ")})`);
  return constants;
}

/**
 * The FB- treatment vocabulary of a medium — never shared across mediums.
 * Each set is OPEN: new values are added as treatments come into use. Unknown
 * values validate (shape-checked) but are reported so a rephrasing of an
 * existing treatment doesn't quietly become a second name for it.
 */
export function stylesFor(medium = DEFAULT_MEDIUM) {
  return mediumConstants(medium).styles;
}

/** The video vocabulary, kept under its historical name for the blueprint lint. */
export const KNOWN_STYLES = stylesFor("video");

const CREATIVE_NAME_RE = /^[^_]+$/;
const SOURCE_RE = /^[a-z0-9-]+$/;
const STYLE_RE = /^[a-z]+$/;
const DATE_RE = /^\d{4}\.\d{2}\.\d{2}$/;
const RATIO_RE = /^\d+x\d+$/;

/** Validate a record. Returns the offending field names — empty means valid. */
export function invalidFields(record) {
  const bad = [];
  const { creativeName, funnelStage, source, style, date, medium } = record;

  if (creativeName !== undefined) {
    // An underscore would split into a phantom segment: the pattern is
    // underscore-delimited after the creative name.
    if (typeof creativeName !== "string" || creativeName.trim() === "") bad.push("creativeName");
    else if (!CREATIVE_NAME_RE.test(creativeName)) bad.push("creativeName");
  }
  if (funnelStage !== undefined && !FUNNEL_STAGES.includes(funnelStage)) bad.push("funnelStage");
  if (source !== undefined && !SOURCE_RE.test(String(source))) bad.push("source");
  // A style with a capital letter or a space is the usual culprit.
  if (style !== undefined && !STYLE_RE.test(String(style))) bad.push("style");
  if (date !== undefined && !DATE_RE.test(String(date))) bad.push("date");
  if (medium !== undefined && !MEDIUMS.includes(medium)) bad.push("medium");

  return bad;
}

/** Fields still missing before the record can produce a filename. */
export function missingFields(record) {
  return AUTHORED_FIELDS.filter((key) => record[key] === undefined || record[key] === "");
}

/**
 * Merge a patch into a record: fill one field at a time, validate before
 * writing, and NEVER move an existing date stamp.
 *
 * The date is stamped once, on the first write, and preserved on every rewrite —
 * that is what makes a re-download or a single-language re-export weeks later
 * reproduce the original filename. `today` is passed in rather than read from
 * the clock so the merge stays deterministic and testable.
 */
export function mergeRecord(current, patch, today) {
  const incoming = { ...patch };
  // A caller may only restamp the date by passing one explicitly — that is the
  // deliberate "correct a wrong stamp" path.
  const bad = invalidFields(incoming);
  if (bad.length > 0) {
    return { record: current, written: false, invalidFields: bad, complete: false, missing: [] };
  }

  const record = { ...current, ...incoming };
  if (record.medium === undefined) record.medium = DEFAULT_MEDIUM;
  if (record.date === undefined) {
    if (today === undefined) throw new Error("mergeRecord needs `today` to stamp a new record");
    record.date = today;
  }

  const missing = missingFields(record);
  return {
    record,
    written: true,
    invalidFields: [],
    complete: missing.length === 0,
    missing,
  };
}

/** `de` → `DE`, `pt-br` → `PT-BR`. The only segment that varies across a set. */
export function marketToken(language) {
  return String(language).trim().toUpperCase();
}

/** Reduce composition dimensions to the `R-` segment, e.g. 1080x1920 → `9x16`. */
export function ratioFromDimensions(width, height) {
  const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
  const divisor = gcd(width, height);
  if (divisor === 0) throw new Error("invalid dimensions");
  return `${width / divisor}x${height / divisor}`;
}

/** Build one filename. Throws when the record is incomplete or a field is bad. */
export function buildFilename({ record, ratio, market }) {
  const bad = invalidFields(record);
  if (bad.length > 0) throw new Error(`invalid field(s): ${bad.join(", ")}`);

  const missing = missingFields(record);
  if (missing.length > 0) throw new Error(`incomplete record, missing: ${missing.join(", ")}`);
  if (record.date === undefined) throw new Error("record has no date stamp");
  if (!RATIO_RE.test(String(ratio))) throw new Error(`invalid ratio: "${ratio}"`);

  const constants = mediumConstants(record.medium ?? DEFAULT_MEDIUM);
  const segments = [
    constants.author,
    constants.format,
    `R-${ratio}`,
    `AL-${record.funnelStage}`,
    `TB-${record.source}`,
    "TH-na",
    `FB-${record.style}`,
    record.date,
  ].join("_");

  return `${marketToken(market)} - ${record.creativeName} - ${segments}.${constants.extensions[0]}`;
}

/**
 * Expand the record into one filename per market. Only the leading market token
 * differs across a localization set — everything after it is identical, which is
 * why the record is authored once rather than per file.
 */
export function expand({ record, ratio, markets }) {
  return markets.map((market) => buildFilename({ record, ratio, market }));
}

/** Which medium a filename's author/format pair belongs to, or null. */
function mediumOfSegments(author, format) {
  for (const [medium, constants] of Object.entries(GRAMMAR.mediums)) {
    if (constants.format !== format) continue;
    if (constants.author === author) return medium;
    // Pre-2026.07 archives carry a legacy author constant where AUTO now stands.
    if ((GRAMMAR.legacy_authors ?? []).includes(author)) return medium;
  }
  return null;
}

/** Reverse a filename back into its fields. Returns null when off-pattern. */
export function parseFilename(filename) {
  const m = /^(.+?) - (.+?) - (.+)\.([a-z0-9]+)$/.exec(filename);
  if (m === null) return null;
  const [, market, creativeName, tail, ext] = m;
  const seg = tail.split("_");
  if (seg.length !== 8) return null;
  const [author, format, ratio, funnel, source, thna, style, date] = seg;
  const medium = mediumOfSegments(author, format);
  if (medium === null || thna !== "TH-na") return null;
  if (!mediumConstants(medium).extensions.includes(ext)) return null;
  if (!ratio.startsWith("R-") || !funnel.startsWith("AL-")) return null;
  if (!source.startsWith("TB-") || !style.startsWith("FB-")) return null;
  if (!DATE_RE.test(date)) return null;

  return {
    medium,
    market,
    creativeName,
    ratio: ratio.slice(2),
    funnelStage: funnel.slice(3),
    source: source.slice(3),
    style: style.slice(3),
    date,
    extension: ext,
  };
}

/**
 * A localized copy's filename: only the leading market token changes. The
 * remaining basename — version number, metadata block, date, extension — is
 * inherited byte-for-byte from the source (naming.json § market.localization_rule).
 */
export function inheritFilename(sourceFilename, market) {
  if (parseFilename(sourceFilename) === null) throw new Error(`off-pattern source filename: ${sourceFilename}`);
  const rest = sourceFilename.slice(sourceFilename.indexOf(" - "));
  return `${marketToken(market)}${rest}`;
}

/** Report a style that is shape-valid but not in the medium's known vocabulary. */
export function unknownStyle(style, medium = DEFAULT_MEDIUM) {
  return typeof style === "string" && STYLE_RE.test(style) && !stylesFor(medium).includes(style);
}

// ── Ad-set folders and the global sequence ──────────────────────────────────

export const SEQUENCE_DIGITS = FOLDERING.sequence.digits;

/** `13` → `#0013`. */
export function sequenceToken(number) {
  return `#${String(number).padStart(SEQUENCE_DIGITS, "0")}`;
}

/**
 * The highest sequence number among a set of names (folders or files, any
 * depth). Pure over the list so the scan is testable; `reserve` feeds it the
 * live tree. Zero when nothing carries a number yet.
 */
export function highestSequence(names) {
  const re = new RegExp(`#(\\d{${SEQUENCE_DIGITS}})(?!\\d)`, "g");
  let max = 0;
  for (const name of names) {
    for (const hit of String(name).matchAll(re)) max = Math.max(max, Number(hit[1]));
  }
  return max;
}

/** One contiguous block of `count` numbers after `max`. */
export function nextBlock(max, count) {
  if (!Number.isInteger(count) || count < 1) throw new Error(`count must be a positive integer, got ${count}`);
  return Array.from({ length: count }, (_, i) => max + 1 + i);
}

/** Every name under `root`, recursively — folders and files alike. */
export function walkNames(root) {
  const names = [];
  const visit = (dir) => {
    let entries;
    try {
      entries = readdirSync(dir, { withFileTypes: true });
    } catch {
      return;
    }
    for (const entry of entries) {
      names.push(entry.name);
      if (entry.isDirectory()) visit(join(dir, entry.name));
    }
  };
  if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`not a directory: ${root}`);
  visit(root);
  return names;
}

/** The allowed values of a `|`-separated token description, e.g. "Mix ICP | Mixed ICP". */
function allowedValues(description) {
  return String(description)
    .split("|")
    .map((value) => value.trim().replace(/\.$/, ""))
    .filter((value) => value !== "");
}

/**
 * Compose an ad-set folder name from the workspace pattern. Fixed tokens stay
 * as the pattern spells them; the sequence, language, batch, count, ICP and
 * format are filled in. Unknown ICP or format values are refused — the
 * vocabulary is the pattern's, not the caller's.
 */
export function folderName({ seq, language, batch, ads, icp, format }) {
  const tokens = FOLDERING.tokens;
  const problems = [];
  if (!Number.isInteger(seq) || seq < 1) problems.push("seq");
  if (typeof language !== "string" || language.trim() === "") problems.push("language");
  if (typeof batch !== "string" || batch.trim() === "") problems.push("batch");
  if (!Number.isInteger(ads) || ads < 1) problems.push("ads");
  if (!allowedValues(tokens.ICP).includes(icp)) problems.push("icp");
  if (!allowedValues(tokens.FORMAT).includes(format)) problems.push("format");
  if (problems.length > 0) throw new Error(`invalid field(s): ${problems.join(", ")}`);

  return FOLDERING.ad_set_pattern
    .replace("#NNNN", sequenceToken(seq))
    .replace("LL", marketToken(language))
    .replace("BATCH", batch.trim())
    .replace("N Ads", `${ads} Ads`)
    .replace("ICP", icp)
    .replace("FORMAT", format);
}

// ── Persistence ─────────────────────────────────────────────────────────────

export function readRecord(projectDir) {
  const path = join(projectDir, RECORD_FILE);
  if (!statSync(path, { throwIfNoEntry: false })?.isFile()) return null;
  return JSON.parse(readFileSync(path, "utf8"));
}

export function writeRecord(projectDir, record) {
  writeFileSync(join(projectDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
}

/** A project still carrying the record under its old name. */
export function hasLegacyRecord(projectDir) {
  return statSync(join(projectDir, LEGACY_RECORD_FILE), { throwIfNoEntry: false })?.isFile() === true;
}

/** YYYY.MM.DD in UTC — the stamp format the delivery automation expects. */
export function todayStamp(now) {
  const iso = now.toISOString().slice(0, 10);
  return iso.replace(/-/g, ".");
}

// ── CLI ─────────────────────────────────────────────────────────────────────

function parseArgv(argv) {
  const out = { _: [] };
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i];
    if (!arg.startsWith("--")) {
      out._.push(arg);
      continue;
    }
    const key = arg.slice(2);
    const next = argv[i + 1];
    if (next === undefined || next.startsWith("--")) {
      out[key] = true;
    } else {
      out[key] = next;
      i++;
    }
  }
  return out;
}

function patchFromArgs(args) {
  const patch = {};
  if (typeof args["creative-name"] === "string") patch.creativeName = args["creative-name"];
  if (typeof args.funnel === "string") patch.funnelStage = args.funnel;
  if (typeof args.source === "string") patch.source = args.source;
  if (typeof args.style === "string") patch.style = args.style;
  if (typeof args.date === "string") patch.date = args.date;
  if (typeof args.medium === "string") patch.medium = args.medium;
  return patch;
}

function reportStatus(record) {
  const missing = missingFields(record);
  console.log(JSON.stringify({ record, complete: missing.length === 0, missing }, null, 2));
  const medium = record.medium ?? DEFAULT_MEDIUM;
  if (unknownStyle(record.style, medium)) {
    console.error(
      `note: style "${record.style}" is not in the ${medium} vocabulary (${stylesFor(medium).join(", ")}). ` +
        "Use an existing value if this is a rephrasing of one; otherwise the medium's catch-all value.",
    );
  }
}

function warnLegacy(projectDir) {
  if (hasLegacyRecord(projectDir)) {
    console.error(
      `note: ${projectDir} carries a ${LEGACY_RECORD_FILE} — the record is now ${RECORD_FILE}. ` +
        "Rename it if it is this project's naming record; it is not read under the old name.",
    );
  }
}

function printNames(names) {
  for (const name of names) console.log(name);
}

function main(argv) {
  const args = parseArgv(argv);
  const command = args._[0];
  const projectDir = typeof args.project === "string" ? args.project : ".";

  // On every command, not only `where`: the agent that runs `name` never runs
  // `where` first, and the one thing it must not do is present a locally
  // conventioned filename as the shipped one.
  const note = localConventionsNote();
  if (note !== null) console.error(note);

  if (command === "where") {
    console.log(
      JSON.stringify(
        {
          naming: GRAMMAR_SOURCE,
          foldering: FOLDERING_SOURCE,
          shipped: note === null,
          roots: codexSkillRoots().map(({ dir, origin }) => ({ dir, origin })),
        },
        null,
        2,
      ),
    );
    return 0;
  }

  if (command === "get") {
    warnLegacy(projectDir);
    const record = readRecord(projectDir);
    if (record === null) {
      console.log(
        JSON.stringify({ record: null, complete: false, missing: AUTHORED_FIELDS }, null, 2),
      );
      return 0;
    }
    reportStatus(record);
    return 0;
  }

  if (command === "set") {
    warnLegacy(projectDir);
    const current = readRecord(projectDir) ?? {};
    const result = mergeRecord(current, patchFromArgs(args), todayStamp(new Date()));
    if (!result.written) {
      console.error(`invalid field(s): ${result.invalidFields.join(", ")} — nothing written`);
      return 1;
    }
    writeRecord(projectDir, result.record);
    reportStatus(result.record);
    return 0;
  }

  if (command === "expand") {
    const record = readRecord(projectDir);
    if (record === null) {
      console.error(`no ${RECORD_FILE} in ${projectDir} — run \`set\` first`);
      return 1;
    }
    const markets = typeof args.markets === "string" ? args.markets.split(",") : ["en"];
    try {
      printNames(expand({ record, ratio: args.ratio, markets }));
    } catch (error) {
      console.error(error instanceof Error ? error.message : String(error));
      return 1;
    }
    return 0;
  }

  if (command === "name") {
    const patch = patchFromArgs(args);
    if (patch.medium === undefined) {
      console.error(`name needs --medium <${MEDIUMS.join("|")}>`);
      return 1;
    }
    const result = mergeRecord({}, patch, todayStamp(new Date()));
    if (!result.written) {
      console.error(`invalid field(s): ${result.invalidFields.join(", ")}`);
      return 1;
    }
    if (!result.complete) {
      console.error(`missing field(s): ${result.missing.join(", ")}`);
      return 1;
    }
    const markets = typeof args.markets === "string" ? args.markets.split(",") : ["en"];
    try {
      printNames(expand({ record: result.record, ratio: args.ratio, markets }));
    } catch (error) {
      console.error(error instanceof Error ? error.message : String(error));
      return 1;
    }
    if (unknownStyle(result.record.style, result.record.medium)) {
      console.error(`note: style "${result.record.style}" is not in the ${result.record.medium} vocabulary`);
    }
    return 0;
  }

  if (command === "inherit") {
    if (typeof args.filename !== "string" || typeof args.market !== "string") {
      console.error("inherit needs --filename <source filename> --market <code>");
      return 1;
    }
    try {
      console.log(inheritFilename(args.filename, args.market));
    } catch (error) {
      console.error(error instanceof Error ? error.message : String(error));
      return 1;
    }
    return 0;
  }

  if (command === "parse") {
    const parsed = parseFilename(args._[1] ?? "");
    if (parsed === null) {
      console.error("off-pattern filename");
      return 1;
    }
    console.log(JSON.stringify(parsed, null, 2));
    return 0;
  }

  if (command === "reserve") {
    if (typeof args.root !== "string") {
      console.error("reserve needs --root <campaign parent> [--count N]");
      return 1;
    }
    const count = args.count === undefined ? 1 : Number(args.count);
    try {
      // Scanned at call time, never from memory: the number that counts is the
      // one on disk the moment before mkdir.
      const max = highestSequence(walkNames(args.root));
      const next = nextBlock(max, count);
      console.log(
        JSON.stringify(
          { root: args.root, digits: SEQUENCE_DIGITS, max, next, tokens: next.map(sequenceToken) },
          null,
          2,
        ),
      );
    } catch (error) {
      console.error(error instanceof Error ? error.message : String(error));
      return 1;
    }
    return 0;
  }

  if (command === "folder") {
    try {
      console.log(
        folderName({
          seq: Number(args.seq),
          language: args.language,
          batch: args.batch,
          ads: Number(args.ads),
          icp: args.icp,
          format: args.format,
        }),
      );
    } catch (error) {
      console.error(error instanceof Error ? error.message : String(error));
      return 1;
    }
    return 0;
  }

  console.error(
    "usage: naming.mjs <get|set|expand|name|inherit|parse|reserve|folder|where> [--project .] [flags]",
  );
  return 1;
}

const invokedDirectly =
  process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;

if (invokedDirectly) {
  try {
    process.exitCode = main(process.argv.slice(2));
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exitCode = 1;
  }
}

SHA-256: 799c50aee0489454f4570c6bdf6b6dd036fb748b59b6ec0d2037c07995c2957d