/** * clustly.yaml — the manifest FORMAT: its shape, its parser, its emitter, its rules. * * WHY THIS IS A SEPARATE MODULE. The browser deploy wizard (F5) must read the same manifest the * CLI writes, and `manifest.ts` imports `node:fs` and `node:path` at module level — so a browser * cannot import it at all. The only two ways out were to extract this or to write a second parser, * and a second parser is the worse one by a distance: the CLI and the wizard would eventually * disagree about a seller's own `clustly.yaml`, and the seller would be told their file is fine in * one place and broken in the other. * * So the split is by DEPENDENCY, not by convenience: * · here — everything that turns TEXT into a manifest and back. No I/O, no paths. * · `manifest.ts` — everything that finds, reads and writes the file. Node only. * * NOTHING IN THIS FILE MAY IMPORT `node:`. That is not a style rule; it is the whole reason the * file exists, and `manifest-schema.test.ts` asserts it against the source rather than trusting it. * The `Framework` import below is `import type` for the same reason — it is erased at compile, so * it does not drag `discovery.ts`'s filesystem imports into a browser bundle. It must stay * type-only. * * The package is dependency-free, so this emits/parses a deliberately CONSTRAINED YAML subset — * enough for the file we generate and for hand edits that keep its shape: * - full-line comments (#…), blank lines * - top-level scalars (`key: value`), string arrays (`key:` + ` - item` lines, or `key: []`) * - ONE nested string map: `listing:` with two-space-indented scalars * - ONE list of maps: `inputs:` — ` - label: …` starting a field, ` key: …` continuing it * - plain or double-quoted strings; numbers for `listing.price`; `[a, b]` for `inputs[].options` * Anything outside the subset is a hard, named error — never a silent misread. * * `./envelope` joins `./discovery` as an allowed relative import (2026-08-28). It carries the * declared-field vocabulary this format now has to spell, and it is pure — no `node:` import, so * the browser-safety property that split this module out is untouched. `manifest-schema.test.ts` * proves that rather than trusting it. */ import type { Framework } from "./discovery"; import { type DeclaredField } from "./envelope"; export interface ListingBlock { title?: string; description?: string; category?: string; price?: number; /** * What a job hands back — `video` | `image` | `pdf` | `markdown` | `file`. * * Spelled `output` here and sent as `output_kind` on the wire, the same one-word-key mapping * `egress` → `egress_allowlist` already uses (this format's keys are `[a-zA-Z]+`, no underscores). * * NOT optional in practice, though it is typed so: the marketplace refuses to let a buyer hire a * listing that has not declared one (`listing_untyped`), so a listing published without it is * ACTIVE and unhireable — visibly fine, silently dead. `completeListing` is what makes sure that * cannot happen; the type stays optional only because a manifest on disk may predate the field. */ output?: string; } /** * How a hosted run pays for model calls (design §1). * * The VOCABULARY lives here, in the format module, because it is part of the file's shape and both * the CLI and the browser wizard must read it identically. What each mode *means* operationally — * which env names it requires, when a token expires — is policy, and lives in `credential-policy`, * which imports from here. That direction is load-bearing: `manifest-schema.test.ts` asserts this * module's only relative import is `./discovery`, so nothing may point the other way. */ export type CredentialMode = "byok" | "subscription" | "mixed"; export declare const CREDENTIAL_MODES: CredentialMode[]; /** Absent `credential:` is byok — every clustly.yaml written before this key keeps its behaviour. */ export declare const DEFAULT_CREDENTIAL_MODE: CredentialMode; /** Whether the marketplace may run this agent's jobs concurrently (design §7). */ export type ConcurrencyMode = "parallel" | "serial"; export declare const CONCURRENCY_MODES: ConcurrencyMode[]; /** Absent `concurrency:` is parallel — one sandbox per order, which is today's behaviour. */ export declare const DEFAULT_CONCURRENCY_MODE: ConcurrencyMode; export interface ClustlyManifest { name: string; framework: Framework; /** Job entrypoint — meaningful for own-code (node/python) tracks; omitted for openclaw/hermes. */ entry?: string; /** How hosted runs pay for model calls. Absent = DEFAULT_CREDENTIAL_MODE. */ credential?: CredentialMode; /** Whether hosted runs may overlap. Absent = DEFAULT_CONCURRENCY_MODE. */ concurrency?: ConcurrencyMode; /** Env var NAMES the agent needs (values live server-side via `clustly secrets set`). */ env: string[]; /** Egress allowlist: domains the agent's tools call (review approves it). */ egress: string[]; /** * Debian packages installed into the hosted image at BUILD time (node/python frameworks only). * Names come from `HOSTED_SYSTEM_PACKAGES` — hosting refuses anything else. Absent = none. * The sandbox cannot install at run time, so this is the only way a handler gets ffmpeg, * tesseract or CJK fonts (bug report 2026-09-17, B7). */ packages?: string[]; include: string[]; exclude: string[]; listing?: ListingBlock; /** * Request fields the buyer fills at hire, forwarded by `clustly publish` as the listing's * `input_schema`. OPTIONAL, and meant to stay that way: an agent that declares nothing still * receives the buyer's brief as the envelope's `criteria`, which is the floor every hosted * agent can rely on. Declare a field only for a DISCRETE machine value the agent cannot * reliably recover from prose — a URL, a count, an enum that switches code paths. A field that * merely restates the brief duplicates `criteria` and gives the buyer a second place to * contradict themselves. */ inputs?: DeclaredField[]; } /** * THE canonical manifest filename, for the CLI and the browser alike. * * The console UI design calls this file `agent.yaml`; that is the name of the Hangar * `@hangar/agent-sdk` file this schema MIRRORS, not the name of the file a seller has. Nothing has * ever written or read `agent.yaml` on the Clustly side. The wizard says `clustly.yaml`. */ export declare const MANIFEST_FILENAME = "clustly.yaml"; /** Never bundled, regardless of `include` (design §5 + spike findings). */ export declare const DEFAULT_EXCLUDES: string[]; /** * Match ONE path segment against a pattern (`*` = any run of non-separator characters). The * secret-filename rules in scanner.ts use it on basenames. */ export declare function matchesPattern(segment: string, pattern: string): boolean; /** * Match a workspace-relative `/`-separated path against an `include:`/`exclude:` pattern. * * Two pattern kinds, told apart by whether the pattern contains a `/`: * · no `/` — a NAME rule, matched against every segment at any depth: `node_modules`, * `*.log`, `.env.*` (what DEFAULT_EXCLUDES are, and what sellers write most). * · with `/` — a PATH rule anchored at the workspace root: `dist/**`, `build/*`, * `src/secret.json`, `**‍/*.log`. `dir/**` also matches `dir` itself. * * Before 2026-09-16 every pattern was matched per segment, so every path rule silently * matched NOTHING — while BundleTooLargeError told the seller to "tighten exclude". * * Lives here (not in scanner.ts) because the browser wizard bundles with the same rule; a * second matcher is how the CLI and the console would ship different files from one manifest. */ export declare function matchesPath(path: string, pattern: string): boolean; export declare function isExcluded(path: string, excludes: readonly string[]): boolean; /** An absent/empty `include:` narrows nothing — the historical "everything ships" default. */ export declare function isIncluded(path: string, includes: readonly string[]): boolean; /** Every framework a manifest may declare — discovery keys on the same list (one source). */ export declare const FRAMEWORKS: readonly Framework[]; /** The frameworks that ship the builder's OWN code and so must name the file the platform imports. */ export declare const OWN_CODE_FRAMEWORKS: readonly Framework[]; /** * The entry filenames the platform's own docs and shim teach, in the order init probes them. * `clustly init` used to read only package.json `main`, so a workspace with `handler.mjs` right * beside it — the name every example uses — got a manifest with no `entry:` that deploy then * refused (field report 2026-09-08, CLI 0.9.0). */ export declare const CONVENTIONAL_ENTRIES: Readonly>; export declare function requiresEntry(framework: Framework): framework is "node" | "python"; /** * Why this manifest cannot deploy as-is, or undefined. ONE rule, used by the deploy wizard (which * refuses before any sandbox work), the dry-run's defensive assertion, and `init`'s warning — so * the three can never disagree about what a valid own-code manifest is. */ export declare function missingEntryProblem(m: Pick): string | undefined; /** * The Debian packages a `packages:` entry may name, and the binaries each one puts on PATH — * the closed vocabulary hosting installs at image build (hangar `blaxel-build.ts` carries the * same list; the two must stay identical). Closed on purpose: a build-time `apt-get` of an * arbitrary name is an arbitrary install, and review scans source, not packages. Grows only * with a hosted run proving the package builds. An empty binaries list is a data-only package * (fonts). Dependency-free so the browser wizard can validate the same file. */ export declare const HOSTED_SYSTEM_PACKAGES: Readonly>; /** * Every word this format RESERVES — its keys and its enum values. * * Exported for ONE consumer: the CLI trail's redactor (`telemetry.ts` `redactForTrail`), which * blanks quoted spans on the premise that "our lines quote user input, never our own". The * messages in this file break that premise — they quote OUR identifiers (`missing required * "name"`, `"framework" must be one of …`) — so the trail shipped `missing required "…"` to the * admin console, blanking the one token support needed (field report 2026-09-19, 3 occurrences). * * Keeping a span whose contents are EXACTLY one of these leaks nothing: the output is a word we * wrote and already know. A builder value that happens to equal one is, by the same argument, * still only ever echoed back as our own word. `telemetry.test.ts` pins both halves. */ export declare const MANIFEST_VOCABULARY: ReadonlySet; /** Mirrors the server's `parseInputSchema` cap — a buyer form longer than this is a brief, * and a brief belongs in `criteria`. */ export declare const MAX_INPUT_FIELDS = 12; /** * One path segment → a safe listing-ish slug. * * Split out of `sanitizeName`, which took a directory PATH and therefore needed `node:path`. The * CLI still calls it with `basename(dir)`; the wizard calls it with a folder name the browser * already has. Same rule, one implementation — a seller who deploys the same folder from the * terminal and from the browser gets the same default name. */ export declare function slugifySegment(segment: string): string; /** `generatedBy` names the command that wrote the file — `init` writes it too, and a header that * blames `deploy` for a file `init` produced misdirects the next reader (field report 2026-09-08). */ export declare function emitManifest(m: ClustlyManifest, generatedBy?: "clustly deploy" | "clustly init"): string; /** Parse the subset. Unknown keys and out-of-subset syntax are hard errors (typo protection). */ export declare function parseManifest(text: string): ClustlyManifest;