Specs and commands share one definition

Add flags to a command, or a spec to a part. A spec is a thing written down as a TOML file, and boxes, sandboxes and agents have one each: the box spec, the sandbox spec and the agent spec.

# the flags of every command come from one type, its help from core/plugin/help.toml
cargo test --test e2e flags::
cargo test --test e2e help::
# the spec cases, tests/specs/GROUP/, and every spec the docs show
cargo test --test e2e specs::
cargo test -p link-docs-check-code
lnk box spec --cpus 4 --disk 40GB             # the spec those flags make
lnk sandbox spec --spec app.toml              # a spec read, and printed back

Name a command

Every command reads lnk <group> <verb> [<object>] [--flags], so a word in one place always means one kind of thing.

The positional

A positional is what the verb acts on, and only that. Anything else is a flag: lnk agent move --here --agent work, never lnk agent move work here. The agent is --agent on every lnk agent command but those that make or end it, where it's the object: lnk agent create work, lnk agent delete work, lnk agent restore work.

One slot, one kind

A slot holds Link's own words or names you chose, never either: a harness's commands are lnk agent harness <verb>, not lnk agent <harness> <verb>. Link's words in a name's slot (here, home, all) are refused as names.

An object left out

Leave out an optional object and the command takes the default, and says which. A setting shows what's current and the choices (lnk agent use lists the harnesses); an action takes the one Link would pick (the only agent, the latest release).

Verbs in pairs

Verbs come in pairs, one pair per kind of thing:

  • create and remove for what Link makes: an agent, a box, a cloud's buckets.
  • add and remove for what exists elsewhere and Link takes on: a plugin, a skill, a tool, a box's user, a model runtime.
  • install and uninstall for software Link downloads: a model, and Link itself.
  • connect and disconnect for an account or a connection.
  • start and stop for what runs: an agent, a box, memory, an exit.
  • use <engine> picks which engine does a job: lnk agent use hermes.
  • delete only for files: lnk bucket remove <paths>.

Where a pair's word means something else

Where a pair's word already means something else, the older one stays: a team is made with lnk agent team create and ended with team delete, since team remove takes a member off, which team add put on; the relay's account is lnk auth login and logout, as every sign-in reads; lnk sandbox stop --off opens the wires stop closed, and lnk measure export --off ends an export, each the one way back from the command it undoes. --files (an agent's) and --delete-folder (a box user's) each delete a folder only their command has.

--yes and renamed commands

--yes answers yes to every question a command would ask, and is the only flag that does. A renamed command keeps its old name as a hidden alias for a release, as a renamed flag does, where the old name doesn't now mean something else.

Add a command's flags

Every lnk command takes its arguments as one type, whether they come from flags, a file or a test, so the command line does nothing a file can't say.

#[derive(clap::Args, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Share {
    #[arg(required = true)]
    pub paths: Vec<String>,
    #[arg(long, default_value = "7d")]
    #[serde(default = "seven_days")]
    pub expires: String,
    #[arg(long, value_name = "NAME")]
    pub from: Option<String>,
}
  1. Put the type in the plugin's lib, in its cli module, and make the command its variant, as in Share(Share). The command's code takes the type.
  2. Give each field its help in the component's plugin.toml or Cargo metadata, at the command's table, as in "--expires" = "How long the link works: ...". Never use a doc comment (A command's help).
  3. Name the field what the flag is, so --dry-run is dry_run. A renamed flag keeps its old name as a hidden alias for a release.
  4. Leaving a field out of a file means what leaving its flag out does. Use #[serde(default)] on a switch or a list, and the same default value on both sides.
  5. Take no secret in a flag, as No flag takes a secret says.
  6. List the type in the module's types(), and a new plugin in checked() of tests/e2e/tests/flags.rs.

No flag takes a secret

No flag takes a secret, since other users of the machine see command lines: a secret comes from a variable, a file (--header-file), standard input or the Keychain, and a switch may ask for it (--auth). A field named like a secret (--auth, --key, --key-id) is #[serde(skip)], so it is never in a file and never printed.

A public value, or a secret's name

A field that names something public (a public key, a key's id) says so with .public("key") in types(). One that takes a secret's name, never its value (--secret NPM_TOKEN), is a field like any, in a file and printed, and says so with .names_a_secret("secret").

What flags.rs checks

flags.rs fails on a command whose flags aren't one such type, on a flag not named as its field, on a file of the same values that makes other arguments, on a field named like a secret (a word of it token, key, secret, password, pass, auth, apikey, header, cookie, credential or jitconfig: link_plugin::spec::SECRET_WORDS) that serde doesn't skip, on such a flag that takes a value on the command line, a file's path, a .public one and a secret's name aside, and on such a flag with an alias: an old flag that took the secret itself never comes back as another's name.

A command that takes a spec's flags

A command that takes a spec's flags, such as lnk box create or lnk sandbox run, holds the spec in a field, fields, which a file writes as the spec does. Its type says where each spec flag is in it, as in typed::<Create>("box create").with_spec(spec_key), where --cpus is at fields.machine.cpus. The spec's own test checks those flags' names.

Add a spec

A spec is the type of a command whose command line you'd write down, published. --spec <file> reads it, and lnk <group> spec prints one. Every spec follows these rules, and its own reference page says only what its fields are and mean.

  • TOML, read strictly (link_plugin::spec::parse). An unknown key or table, an empty table, or two fields in one table where one would be ignored is an error naming the line. Values are typed, so a version is a version, a name has a name's characters and a word from a list is one of its words. Nothing from a file reaches a shell or a cloud's CLI as written.

  • Missing means least. A field left out is the narrowest access and the lowest cost that still does the job, and an empty file is the least thing that works. ["*"] asks a list for everything of its kind, so wide open is always written down, with two exceptions.

  • One type is the file and the flags. It lives in the plugin that owns the thing, or in link_plugin beside its contract when another plugin keeps one, as the harness keeps a sandbox's spec for each agent. A flag is its field's name, with these rules:

    • Prefix it with its table where two tables share a name (--internet-host, --lan-host).
    • Make it singular and repeated for a list (--tag).
    • Use the table's own name where the field only lists the table's things, so [tools] names is --tool.
    • Call a top-level field whose name a command argument already has --spec-<field>, as in --spec-name and --spec-version.
    • Call a switch that is on unless written --no-<field>, so [lnk] login is --no-login.

    A test fails on a field with no flag or a flag with no field.

  • No accounts, secrets, or what Link picks per run. Which account, which box and the upstream are the command's arguments. They are the only flags beside the spec's, along with --spec, --yes and --json. A spec still names what belongs to whoever runs it.

  • One plugin, one spec. A spec describes only what its plugin owns, and never runs anything. Commands combine things, never a file: a box's spec has no agents, and a sandbox's no setup script.

  • A flag, then the target's table, then the default table, then Link's default. A table for one target is [<table>.<target>], such as [machine.aws] or [commands.macos], and it replaces the default table's fields, each whole. A list that takes away, such as a sandbox's hide, adds up across all of them instead, so nothing written to close a thing is dropped.

  • Values a person can read. Sizes are strings with their unit, and "8 GB" means GiB. Ranges are { min, max }, or 2..8 on the command line. Paths are relative to the spec's folder or start from ~/, and names beat numbers wherever the thing has one.

  • Name and version at the top. name and version are top-level keys, and version is an integer that is yours to bump. Everything else is in tables, and whatever a spec makes remembers the version it was made from.

  • A round trip. lnk <group> spec <thing> prints the file that makes that thing again, with paths absolute and anything Link picked pinned. It prints from what Link and the cloud know, never from a file the thing could write, and through the toml serializer (link_plugin::spec::print). With flags and no thing, it prints the spec they make.

  • A spec may be someone else's. Its paths, versions and grants are checked when it's read, and one you haven't run is shown before it runs. A spec is never kept where what it makes can write it.

Two exceptions to the least

Missing means least, but for two exceptions: a default that asks you first, such as a box's login, and a limit that only paces what a grant already bounds, such as a sandbox's per_minute.

What a spec still names

A spec still names what belongs to whoever runs it, such as a sandbox's tools, ports and network. So a spec handed on means the same grants and not the same reach, and a grant that can't mean anything fixed on a machine is refused there.

A spec shown, not run

A command shows what a spec would do and does nothing, as lnk box size list and lnk sandbox check do. Decisions says why specs work this way.

Test a spec with files

A test case is a file in tests/specs/<group>/ that holds the spec as a user writes it, and what must come of it. A new bug is a new file, not new test code.

# tests/specs/sandbox/openai-only.toml
[spec]
name = "openai-only"
[spec.internet]
hosts = ["api.openai.com"]
[spec.commands]
allow = ["curl"]

[expect]
reaches = ["api.openai.com"]
refused = ["example.com", "api.openai.com:80", "192.168.1.1", "127.0.0.1:22", "169.254.169.254:80"]
runs = ["curl"]
refused_commands = ["sh", "ls"]

One runner per group reads every case. sandbox_specs.rs runs lnk sandbox check --spec on it, and box_specs.rs runs the box plugin's matching against a fake cloud's sizes, with [expect] size = ... or error = .... A spec that must be refused is a case too, and its [expect] invalid holds the words of the error it gets.

The docs' blocks

Every toml block the docs and the website show is read by its type, so a page never shows a file Link refuses. A box's pages hold box specs and the sandbox's hold sandbox specs. A block elsewhere is one of those, an agent's spec or a team's, or a case whose first line is its path, like the one above.

An agent's spec

An agent's spec differs from the rules above in one way, because missing means Link's and not least. It holds opinions and not grants, and Link's own are a spec of their own beneath it, so a key left out is Link's, and extends = "none" starts from nothing. Its cases in tests/specs/agent/ give the agent's own spec, its teams' files and the union that must come of them (agent_specs.rs).