grails-cli
v0.5.0
Published
Grails Code Sync from the command line: reads a component library's code and uploads its design tokens and component specs to Grails.
Maintainers
Readme
grails-cli
Grails Code Sync from the command line: grails-cli reads a component library's code and uploads its design
tokens and each component's spec to Grails, so the vault shows what the code actually
holds, next to Figma. The product is Grails Code Sync the way the Figma plugin is Grails Sync; this is its
command line. Full documentation: https://docs.grails.design/cli/overview/
Requirements: Node 20 or newer. The package is free to install and use with Grails; see LICENSE.md.
npm i -D grails-cli # or run it without installing: npx grails-cli <command>
npx grails-cli scan /path/to/library # the manifest of components
npx grails-cli tokens /path/to/library # the design tokens Grails would read; sends nothing
npx grails-cli sync /path/to/library --dry-run # what would be uploaded; sends nothing, needs no key
npx grails-cli connect # once: keeps the API key you copied in the system keychain; see "The key"
npx grails-cli sync /path/to/library # in CI, GRAILS_API_KEY from a secret does the same job
npx grails-cli statusThe examples below write grails-cli; use npx grails-cli if it is not installed globally.
Commands
| Command | What it does |
|---|---|
| scan [root] [--out f] [--profile f] [--tier syntax\|typed] [--in-repo] | The manifest (stdout without --out). Refuses to write it inside a git repository unless --in-repo. Exits 1 when the scan is incomplete. |
| chi [root] [--profile f] [--tier …] [--json] | The Component Health Index of every component, worst first. |
| check-classes [root] [--unknown] [--json] | Probable Tailwind typos; exits 1 when there is one. |
| init [root] [--write] [--force] | Proposes a grails.code.json from the code, with the evidence. |
| tokens [root] [--profile f] [--json] | The design tokens Grails would read (a Tailwind theme, CSS listed in the profile's css, token sources in its tokens), and every value left out. --json is the exact token list a sync uploads. Sends nothing, needs no key. |
| tokens baseline [root] --out <dir> [--in-repo] [--profile f] | The scanner's tokens as DTCG files and a resolver, a first DTCG version for the team to own (see "Moving from the scanner to DTCG"). Writes only where --out says, refuses a path inside a git repository unless --in-repo, and never touches a folder that has files. Sends nothing, needs no key. |
| sync [root] [--dry-run] [--json] [--trigger ci\|local] [--profile f] [--tier …] [--name n] [--platform p] | Tokens first, then components, in one step. Records the tokens in one request, then opens a component sync, sends the components, finalizes, and prints the result and the vault link. See "Exit codes". |
| unsync [root] --collection <name> \| --component <key-or-name> [--dry-run] [--json] [--profile f] | Takes a token collection or a component out of grails.code.json and out of Grails, in one step: edits the profile so it is never synced again, then runs the same sync with the edited profile, which marks what is no longer declared as deleted in code. --dry-run writes nothing and shows the sync of the edited profile. See "unsync". |
| status | The code sources of the key's project, with the last component sync and the last token sync of each, and how many changes asked for in Grails wait for it. |
| tokens pending [root] [--name n] [--collection c] [--request id] [--limit 1..1000] [--json] | The token changes people asked for in Grails, by collection, with each one's type, before → after and id; --collection and --request narrow it. Writes nothing (see "Changes asked for in Grails") |
| tokens discard <change-id> \| --all [--collection c] \| --request id --reason "…" [--dry-run] [root] [--name n] [--json] | Closes one change, or many in one step, that your team will not carry into the repository, with the reason; --dry-run lists what would go and changes nothing (see "Changes asked for in Grails") |
| rules [root] [--set code=figma \| --delete code \| --threshold n] [--json] | The naming rules of the Code ↔ Figma match (see "Naming rules"). |
| connect [--prompt] | Keeps your API key in the operating system's keychain, so no command needs GRAILS_API_KEY in your shell. Reads the key from the clipboard (then clears it) or, with --prompt, from a prompt that shows nothing as you type; checks it against Grails first, and stores nothing if Grails refuses it. See "The key". |
| disconnect | Removes the key connect stored for the API GRAILS_API_URL points at. |
| update [root] | Asks the npm registry now for the latest grails-cli and prints the installed version, the latest, and the command that updates it: for your package manager when the project depends on grails-cli, for the one that installed it when it is a global install. It does not run it. Exits 4 when the registry cannot be reached. See "Updates". |
| skill | Prints the agent skill the package ships (templates/skill/grails-cli/SKILL.md), for a team to save where its coding agent reads skills. Sends nothing, writes nothing. |
sync
Only from the declared branch. grails.code.json says which branch IS the
source in Grails: "branch": "main" (or preview, or whatever merges land on); init proposes the branch the
checkout is on. A real sync:
- without a declared branch is refused before anything is read (exit 2), with that sentence;
- on another branch uploads nothing and exits 0, so a pull request's job stays green, and says so where a CI
shows it: the terminal, the job summary, a
::noticeon the run, and the step outputsresult=skipped-branchandexit_code=0(GITHUB_OUTPUT). A detached checkout is judged byCI_COMMIT_REF_NAME/GITHUB_REF_NAME, so a GitHub pull request run (<number>/merge) is "another branch"; a folder that is not a checkout has no branch; - on the declared branch syncs as below, and writes
result=synced(orfailed, with its exit code). A dry run is never held back: it stores nothing. The server enforces the version that carries this guard: the two storing routes refuse a CLI older than 0.2.0 with 400GRAILS_CODE_CLI_OUTDATED(exit 3).
Tokens go first. The token list is read and
recorded with POST /v1/code/token-syncs in one request. Grails builds the DTCG document from it and answers what
changed against the source's previous snapshot; the same content is stored once. A token step that fails (a
project Grails cannot read, the server refusing the list) does not stop the components: they sync anyway, and
the run exits with the token step's code. Once the tokens are recorded, Grails also compares the changes people asked
for in Grails with what the repository now holds, marks the ones it satisfies applied, and the sync says how many (see
"Changes asked for in Grails"); that comparison never fails the sync. Then the components:
- Opens the sync with every component key the scan found (
declared_keys). That is how the server tells a component deleted from the code from one that failed to arrive. - Sends the components in requests of at most 50 components and 1 MB (the API refuses more than 50, and accepts 2 MB). Grails answers the open with the hash of the spec it already holds for each key; the spec travels only for a key that is new or whose hash differs.
- Finalizes with the scan's own
complete. Only a complete scan lets the server mark anything deleted, and even then it refuses to delete 5 or more components and more than 20 % of the source in one sync. Since 0.3.0 the finalize also carries what each component uses (canonical entities D1): the components of the same source it renders, resolved with the TypeScript checker to their keys, and the tokens its styles reference, as the collection and name of the token list just recorded. Grails keeps them as edges of the project graph. They are sent only when the tokens were read and recorded (or there are none): otherwise Grails keeps the usage it had. A reference no token of the source answers to (a framework default, a source missing fromgrails.code.json) is counted in a warning and not sent.
If a sync fails after it was opened, the CLI closes it as incomplete before raising the error. Otherwise it stays open for two hours and the next run of the same source (a CI retry) is answered "another sync is running".
The source (what Grails calls this library) is named after the package's own name, so it is the same on a
laptop and in CI; --name sets another one, for a monorepo whose packages share nothing else. The platform is
react-native when the package depends on React Native or Expo or any file imports react-native, and web
otherwise; --platform overrides. The commit and branch come from git when the root is in a checkout (the branch
from CI_COMMIT_REF_NAME or GITHUB_REF_NAME when the checkout is detached, as GitLab's is). --trigger is ci
when CI=true and local otherwise.
unsync
grails-cli unsync --collection Color --dry-run # what would change in the profile and in Grails; writes nothing
grails-cli unsync --collection Color # edits grails.code.json, then syncs it
grails-cli unsync --component src/ui/Legacy.tsx#LegacyRemoving something from grails.code.json and removing it from Grails are one gesture (the owner's decision,
2026-10-19). unsync edits the profile, so it is never synced again, and runs the same code path as sync with the
edited profile: the sync marks what the profile no longer declares deleted_in_code (a token collection and its
tokens; a component only at a sync that read every component). Grails keeps the row, its match with design and its
documentation, and a later sync that has it again revives the same row. There is no confirmation: --dry-run is the
preview, like tokens discard. Exactly one of the two flags. It needs a grails.code.json (a detected profile has
nothing to take an entry out of: grails-cli init --write creates one; --profile <file> edits another file) and, for
a real run, the key and a declared branch, checked before anything is written.
It prints what changes in the profile (entries removed and added, as JSON fragments), then what leaves Grails. The file is written the way it was found: its indentation (a tab, or the width of its first indented line), its line endings, its final newline and the order of its keys. What it cannot keep is a line laid out by hand: an array that was on one line is written one item per line, and the run says so.
--collection <name>takes a collectiongrails-cli tokenslists. If it comes from atokensentry of the profile, that entry is removed; the text-style collection the entry made (Type-typography) goes with it, and can be named too. Every source that stays and owes its name to its folders gets"collection"written, equal to the name it has now (Colorandv2-Color: removingsrc/constants/Colorwrites"collection": "v2-Color"onto the v2 entry), so nothing else moves; without it the other source would be renamed and every token under it would change path in Grails. It then reads the tokens again with the edited profile and refuses, writing nothing, unless the collections that remain are exactly the ones there were minus this entry's. Refused (exit 2): a collection from Tailwind or CSS (the profile has no entry for it: it names the file to edit by hand), one fromdtcg(it lives in the DTCG files), and the last token the repository has (a sync with no tokens uploads none, so Grails would keep the collection).--component <key-or-name>takes a key (file#export) or a name; an ambiguous name lists the keys. Its file goes intoexclude. A file that holds other components is refused (exit 2), listing them: excluding a file takes them all.
If the sync after the edit fails or does not complete (the network, an API error, an incomplete scan) the file is
not restored: the edit is what you asked for. The run ends with one line, grails.code.json was edited (…); Grails
still has the collection "Color": the next grails-cli sync from main removes it, and exits with the sync's own code.
From a branch that is not the declared one the file is edited and nothing is sent: commit it and sync from the declared
branch, which removes it from Grails. With --json, stdout is one document, { "unsync": { target, profile_file,
written, changes, leaves_grails }, "sync": <the sync report> }.
--dry-run, --json and the job summary
--dry-runwith a key (GRAILS_API_KEY, or the oneconnectkept) asks Grails what each step would change, through the preview routes (POST /v1/code/token-syncs:preview,POST /v1/code/syncs:preview), which store nothing: tokens added, changed and removed; components added, changed, reactivated and those that would be deleted, with the same floor the real sync applies. It also says what the real sync would do with the changes asked for in Grails (see below): how many it would mark applied, close because their token is gone, or leave open, with their ids (tokens.preview.token_changesin--json, and in the job summary). The same verdict the real sync applies, written nowhere.--dry-runwithout the key counts locally and sends nothing, as it always did.--jsonprints one report on stdout (mode,tokens,components,exit_code); the human lines go to stderr, so the output can be piped.- In GitHub Actions (
GITHUB_STEP_SUMMARYis set) the same report is appended to the job summary: counts, and the changed tokens and would-be-deleted components folded. There is no comment on a pull request.
No state is written to the repository: what Grails holds is the only source of truth, and the comparison is always against it.
Code ↔ Figma
After a real sync records the tokens, sync asks Grails how they compare with the Figma ones
(GET /v1/code/token-match) and prints one line: same, differ, conflicts, by value only, only in code, only in
Figma, and the colour threshold it used (ΔE2000, 1.5 by default). The job summary adds the table, what differs,
the conflicts (code tokens that disagree about one design token, never resolved) and the suggested naming
rules, which are never applied: naming rules live in Grails, not in this repository.
- A project with no Figma snapshot yet says so instead of calling every token missing.
- A dry run does not ask: Grails would compare the code tokens it already stores, not the ones on disk.
- It is a report, not a step of the sync: a failure is said and kept in the report, and the exit code does not change.
Naming rules
A naming rule tells the match that a word of your code is another word in Figma: gray in the code is neutral
in Figma. Rules belong to one source and live in Grails, never in the repository: the CLI,
the REST API and MCP all edit the same rules.
grails-cli rules # this source's rules and the project's colour threshold
grails-cli rules --set gray=neutral # create, or move its target; the same rule twice is one rule
grails-cli rules --delete gray
grails-cli rules --threshold 2 # ΔE2000 under which two colours are equal, for the whole projectThe source is named as sync names it (--name, else the package name, else the folder). Listing needs the
usual key; editing needs a key with the write:tokens scope. The job summary of a sync lists the rules applied
and the ones Grails suggests from tokens with the same value and one different word.
Changes asked for in Grails
In Grails, an admin or an editor can ask for changes to this repository's tokens: a new value, a new token, collection or mode, a removal. Grails never writes your repository, and neither does this CLI.
grails-cli tokens pending # by collection: type, before → after as DTCG values, and the id
grails-cli tokens pending --json # the list as Grails answers it, every value exact
grails-cli tokens pending --limit 1000 # the read answers 500 by default, at most 1000
grails-cli tokens pending --collection color # only that collection's changes
grails-cli tokens pending --request <request-id> # only one change request's (its id is under the heading)Carry each change into your token source, or into the rules that generate it (a Style Dictionary build, a Tokens Studio export, your CSS). The loop closes by itself:
The next
syncmarks a change applied once your repository holds its value. Grails compares the resolved value of the token in that mode (an alias counts by the value it resolves to), with the tolerance the Diff uses for colours and sizes. A token you removed closes its change; a created token, collection or mode closes when it exists. The sync prints how many (token_changesin--json).If the repository holds another value, the change stays. Grails keeps proposing its value, checks again on every sync, and records what your code holds. The "before" that
tokens pendingprints is what your repository holds now.A change your team will not carry is closed with a reason:
grails-cli tokens discard <change-id> --reason "Handled in the design-tokens repo"The id is the one
tokens pendingprints beside the change. Grails keeps the change, closed, with who discarded it (the owner of the API key: it is the only identity the API can verify), that it came from code, and the reason, which is required (1 to 1000 characters, one line, no control characters). Discarding a collection takes its modes and tokens with it; one value of a token being created cannot go alone, discard the token. A change that is already applied or discarded answers exit 3.Many at once, in one step. Exactly one of three picks the changes, and the reason is required:
grails-cli tokens discard --all --reason "Handled in the design-tokens repo" # everything waiting for this repository grails-cli tokens discard --all --collection color --reason "…" # one collection's grails-cli tokens discard --request <request-id> --reason "…" # one change request'sThe set is closed all together or not at all, and at most 1000 at once (more is refused with the way out: a collection, or a request).
--dry-runlists what would go (id, token, before → after) and changes nothing; the reason is optional then. There is no--yes: the reason is the commitment. The answer, and--json, say how many were discarded and which.--collectiongoes with--allonly, and the same words select the same changes intokens pending, so you can look before you close. A change someone discards in Grails is a different path and is deleted; these stay, with the reason.A dry-run sync shows what the next sync would close:
grails-cli sync --dry-run(with the key) prints how many changes it would mark applied, close because their token is gone, or leave open, and their ids, without storing anything.grails-cli statusshows how many changes wait for each source.
An sRGB colour shows as its hex and a dimension as 24px; anything else shows as its DTCG JSON, and --json has the
exact components. The source is named as sync names it (--name, else the package name); a change must belong to it.
Both commands need the usual key (write:code).
Exit codes
A contract a CI step can branch on. Only 4 is worth retrying. A sync on another branch is 0; its own
result, skipped-branch, is in the step output result (see sync above), since any other code would turn the step
red.
| code | meaning | retry? |
|---|---|---|
| 0 | done: synced, nothing changed, or a dry run | – |
| 1 | the component sync ran and ended incomplete (unreadable components, or deletions held back) | no: read the warnings |
| 2 | misconfigured: a flag (a flag that takes a value, given none: --profile needs a value), grails.code.json (including no declared branch on a real sync), no key, or a project Grails cannot read (its own tailwindcss missing, or of a major other than 3 and 4); for connect, a key of the wrong shape, an empty or unreadable clipboard, or no keychain on the machine | no: fix it |
| 3 | the server refused it: the key (also the one connect was given), the plan, content it does not accept, or a CLI older than the minimum (400) | no: fix it |
| 4 | the server could not be reached or failed (network, 5xx, a 429 after one retry); for update, the npm registry | yes |
Where the tokens come from
The team's own DTCG files, when grails.code.json names them (the official way in):
"dtcg": { "files": ["tokens/**/*.json"], "resolver": "tokens/resolver.json" }One source. With
dtcg, these files are the only token source. Nothing is read from Tailwind, CSS ortokens, and a run that also listscssortokenssays so.Sent as written.
syncuploads them as they are (dtcg: { files, resolver? }withclient: grails-cli), and the API reads them with@grails/dtcg'sreadDtcg.Checked here first, so a wrong repository sends nothing (exit 2):
- the file count (500) and the body size (4 MiB);
- a file outside the resolver's folder (a resolver names its files from where it is, so every key is relative to it);
- two keys that differ only in case;
- JSON that does not parse.
What the API refuses comes back as findings (422, or 413 for a limit). The run prints each one with the file the person knows and its JSON pointer, and exits 3.
Usage. Component uses go with the finalize, and so do the token uses a
var(--x)can be tied to. Nothing in the files says which token a custom property is, so the team says how its CSS is named:"dtcg": { "files": [...], "cssVariables": { "prefix": "ds", "exceptions": { "--brand": "color.brand.500" }, "ignore": ["--radix-*", "--tw-*"] } }- The convention is Style Dictionary's default, its
csstransform group (name/kebab): the token's path segments joined with-, lower case, behind the build'sprefix(color.brand.500is--ds-color-brand-500). A digit stays with its letters (2xl,body2), camelCase is split, any other character is a separator, and a name is case-sensitive.cssVariableNamein@grails/dtcgis that rule, pinned against the real Style Dictionary. - The direction is token → name. A name does not say its token (
brandPrimaryandbrand-primaryare two tokens and one name), so the CLI names every token of the upload and looks a variable up in that index. A name two tokens share is reported as ambiguous, never given to one of them. exceptionsmap a custom property that does not follow the convention to the token it is. They win over the convention, and an exception whose token is not in the upload is reported (and the name does not fall back to the convention).ignoreis a list of patterns for custom properties that are not the team's tokens at all (a library's, the framework's):["--radix-*", "--tw-*"]. A pattern starts with--,*is the only wildcard, and there are at most 200. A name it covers is taken out first: not resolved (so not sent, even when the convention would have found a token of that name), not counted as used, not listed. An exception for the same name wins overignore: it names a token on purpose. A pattern that covers nothing used is not an error;--jsonlists it incss_variables.ignore_unusedso the team can delete it. The terminal line says how many were ignored, never which. Useexceptionsfor what a name IS,ignorefor "this is not ours".- The files are read here with
readDtcg, the reader the API runs, to know the tokens:sync --dry-runlists the result with no key and sends nothing. A token goes to the API as{ collection, name }(the first level of its path, and the rest joined with/), which the server turns back into the path withtokenPath; a token with a/inside a level cannot be spelt that way and is reported. - Every
var(--x)that resolves to no token is listed (the terminal prints 20,--jsoncarries 200 incss_variables, the job summary folds them), with an exception that names a missing token. It never changes the exit code. - What it reads. A
var(--x)a component can use, in three places, asusesoutside the spec (no hash moves, and the manifest keeps its shape and its version):- the
styleof any element of the component, every property (margin,border,boxShadow, …), also*Style,cssandsxprops, a constant or style sheet it refers to, and every branch of a condition or lookup; - the stylesheet its file imports (
.css, CSS modules,.scss,.less,.pcss), for every component that file holds; a file two files import counts for both. Each file is read once per scan, only inside the project and never undernode_modules, at most 1 MiB, and 16 MiB of CSS in all (what is left out is a warning).@importis not followed: a stylesheet that imports a tokens sheet would hand the component the aliases of the whole token layer. A custom property a rule declares in:root, a theme selector or@themeis a token's definition, not a use; - Tailwind classes in
classNameand incn,clsx,classnames,cx,twMergeandcvacalls, variants included:bg-[var(--x)]in either major,bg-(--x)(alsobg-(color:--x)) in Tailwind 4 andbg-[--x]in Tailwind 3. Measured on 4.2.1 and 3.4.17: 4 compilesbg-[--x]to a literal--x, and 3 does not knowbg-(--x), so each major reads the form it compiles. With no Tailwind version found, the 4 form is read.
- the
- What it does not read. A name a template assembles at run time, a class helper it does not know (
tv()), CSS-in-JS template literals, a component its file defines but does not export, a property set from JavaScript. Other kinds of token reference (a Tailwind theme class such asbg-primary, a TypeScript token object) are counted, not tied. Abg-[var(--x)]stays a literal colour in the spec itself, as before: onlyuseschanged.
- The convention is Style Dictionary's default, its
Without dtcg, the scanner reads (a convenience for a first sync):
- A Tailwind 3 theme, read with the project's own
tailwindcss(loadConfig+resolveConfig), defaults included. ⚠️ That executestailwind.config.*, as the project's build does. Install the project's dependencies first (npm ci): without them the run exits 2, and never reads the theme with a copy of its own. - Tailwind 4: the
@themestylesheet, read with the project'stailwindcss(__unstable__loadDesignSystem), defaults included; its CSS variables become a collection with a mode per selector (:rootlight,.darkdark). - CSS files listed in
grails.code.json'scss, read with postcss. - TypeScript token objects and enums listed in
grails.code.json'stokens. Each entry is a collection named after its export; two entries that export the same name (Colorinsrc/constants/Colorand insrc/constants/v2/Color) are named by their folders,Colorandv2-Color."collection"fixes the name instead:{ "import": "src/constants/v2/Color", "name": "Color", "collection": "v2-Color" }(1 to 100 characters, none of.{}, not starting with$; one entry per name). A fixed name is used as written and nothing moves it: not the other entry going away, not a collision (the unfixed entry is the one that is renamed). Its text-style collection follows it (v2-Color-typography).grails-cli unsyncwrites it for the sources it leaves behind. An entry whose file is found but gives no token says why: the profile names a wrapper (StyleSheet.create(typographyObject)) and the object is another name in the file, the name is not declared there, or none of its values is a token.
calc() is computed (DTCG has no expressions). Values it cannot express are left out and listed, never guessed.
Text styles are tokens too (typography, one per style, all five fields): a Tailwind 4 --text-X with its
--text-X--line-height, --letter-spacing and --font-weight; a Tailwind 3 theme.fontSize entry ([size],
[size, lineHeight] or [size, { lineHeight, letterSpacing, fontWeight }]); or a TypeScript object with a
fontSize and another typography key. Each field says where it comes from: the style's own value, a token it
inherits (the family of fontFamily.sans), a CSS rule that is not a token (body { font-family }), or CSS's initial
value (weight 400, letter spacing 0). An em letter spacing is converted against the size and keeps its spelling. A
family written as a next/font variable becomes the font the layout names, marked as a heuristic. A style that
cannot be formed (no family to inherit, a size that is not a length) is left out and listed.
Moving from the scanner to DTCG: tokens baseline
grails-cli tokens baseline --out ../acme-tokens # outside the repository: nothing else to say
grails-cli tokens baseline --out tokens --in-repo # inside it: only because you askedThe scanner's tokens, written as the DTCG files and the resolver Grails' own export writes (exportProject from
@grails/dtcg, through the same conversion the API runs, fromCodeUpload). It is the team's first DTCG version
: review the files, commit them, and declare them in dtcg. From then on the team owns its
generation rules and the scanner stops being the source. The command prints the dtcg block to
add and lists the values left out, which are not in the files: the ones the scanner could not express (the list
tokens prints, with the reason for each) and what the DTCG export could not carry.
- It writes only where
--outsays. A folder inside the git repository also needs--in-repo, exactly likescan --out. A folder that already has files is refused: a baseline never overwrites anything. - It refuses while
dtcgis set. Those files are the token source, and a baseline from the scanner would compete with them (exit 2). - A round trip pins it: what
readDtcgreads back from the files equalssnapshotProjectof the scanner's upload, a text style included: its five fields and where each comes from.
The key
The CLI reads the API key from two places, in this order, and from nowhere else:
GRAILS_API_KEYin the environment: CI, and anyone who prefers it. When it is set the keychain is not even asked.- The operating system's keychain, where
grails-cli connectput it.
With neither, a command stops with exit 2 and says to run grails-cli connect or set GRAILS_API_KEY.
A flag would land in the shell history, in the process list and in a CI log that prints the command line, so the CLI
refuses any flag that looks like a credential (--api-key, --token, …), and any argument that is itself a key,
before it reads anything. The key goes in one header, is never printed, and is scrubbed from everything the CLI
prints in case an upstream message echoes it.
Create it in Team Settings › Integrations › API Keys with the write:code scope. It is a personal key: if its
owner is demoted to viewer or leaves the team the key is deleted, and the API answers it like a key that never
existed (GRAILS_AUTH_INVALID_TOKEN). The CLI says so in those words.
connect and disconnect
The API shows a key once, when it is created. Copy it, then:
grails-cli connect # reads the clipboard
grails-cli connect --prompt # asks for it instead; nothing is shown as you type, and the clipboard is not used
grails-cli disconnect # removes it from the keychainIt checks the key before keeping it. The shape first (
grails_sk_and 43 characters), then the same authenticated readstatusmakes. It prints the code sources of the key's project; Grails does not say the team's name yet. A key Grails refuses (unknown, revoked, expired, without thewrite:codescope) stores nothing, and so does one it could not ask about because the API was unreachable.The clipboard is cleared once it has been read, whatever came of the key, because a key Grails refuses may still be a real one (for another environment, say). It is cleared only if it still holds what was read, so something you copied meanwhile is left alone, and a clipboard with nothing like a key in it is not touched. Clipboard managers can keep a history of what was copied, and nothing here can undo that:
--promptavoids the clipboard altogether.No keychain, no key stored. On a machine with none (a headless Linux box without
secret-tool, a platform without an integration)connectsays so and stores nothing: useGRAILS_API_KEY. It checks this before it reads the clipboard, so the key you copied is still there, and it never writes the key to a file.The key never goes in an argument, which every process on the machine can read. It reaches the keychain on standard input of the platform's own tool, so there is no native module in the bundle:
| Platform | Keychain | Clipboard | |---|---|---| | macOS | the Keychain, through
/usr/bin/security -i(the command is written to its stdin) |pbpaste,pbcopy| | Linux | the Secret Service (GNOME Keyring, KWallet), throughsecret-toolfromlibsecret-tools|wl-pasteandwl-copy, elsexclip| | Windows | the Credential Manager'sPasswordVault, through Windows PowerShell (the key on its stdin) |Get-Clipboard,Set-Clipboard|One entry per API. The service is
grails-cliand the account is the host ofGRAILS_API_URL, so a staging key and a production key coexist, and aGRAILS_API_URLthat something else points at another server finds no key: a stored key is never offered to an address you did not connect it to.disconnectremoves the entry. It does not revoke the key: that is done in Team Settings.
GRAILS_API_URL points the CLI at another API (default https://api.grails.design; the docs' …/v1 form is
accepted). http:// is accepted only for this machine, and a redirect is never followed, so the key cannot be sent
to an address nobody chose.
One retry on a network error, and one on a 429 after the retry_after_seconds the API gives. The Worker counts
rate limits but does not block yet, so no real 429 exists today: that retry is the contract's, not a measured
behaviour. Opening a sync is retried only when the connection was refused: if the server may have received it, a
second open would be answered "another sync is running" and the first would hold the source for two hours.
Updates
Once a day, grails-cli asks the npm registry whether a newer version exists: one plain
GET https://registry.npmjs.org/grails-cli/latest, with a 1.5 second limit, no retry, no header of its own, no query,
and nothing that says who is asking (not the key, the team, the project or the repository). It never fails or delays a
command beyond that limit; offline is silence. The answer is remembered in update-check.json in the config folder
($XDG_CONFIG_HOME/grails-cli, else ~/.config/grails-cli; %APPDATA%\grails-cli on Windows), a file with no secret in it.
While the installed version is behind, every run ends with one line on stderr that names both versions and the
command. CI included: a workflow that pins a version is where nobody sees a release go by. A comparison follows
semver, so 0.5.0-rc.1 is behind 0.5.0. GRAILS_CLI_NO_UPDATE_CHECK=1 turns it off.
grails-cli update [root] asks now, ignoring the cache, and prints the command for the project's package manager,
read from the lockfile in the root: pnpm-lock.yaml is pnpm add -D grails-cli@latest, yarn.lock is
yarn add -D grails-cli@latest, anything else is npm i -D grails-cli@latest. A project that does not depend on
grails-cli gets the command for the way this copy was installed, read from where the running file is: npm i -g
grails-cli@latest for a global npm install, pnpm add -g grails-cli@latest or yarn global add grails-cli@latest
for those managers', and npx grails-cli@latest only when npx (or pnpm dlx) ran it, which fetches the latest each
time and leaves a global copy as old as it was. It does not run it: the CLI never changes your repository on its own.
It exits 0 whether you are behind or not, and 4 when the registry cannot be reached.
What leaves the repository
An allow-list, built field by field (never a copy with fields deleted), enforced again by the API and a last time by the database:
- the source: name, platform, package name, and the repository address without userinfo, query or fragment
(
https://gitlab-ci-token:<token>@gitlab.com/…and?private_token=…are what a CI checkout's remote can look like; an SSH remote becomes itshttps://address, or is dropped); - git: the commit sha and the branch;
- per component: key (
<file>#<export>), name, file path, export name, the spec hash and the spec, the stories summary (files, titles, count, tags), the coverage numbers,unresolvedas{ what, line }and other props as{ name, kind }; - at finalize, per component: the keys of the components of the same source it renders, and the collection and name of each token its styles reference.
The spec is the component's public API: prop names, their values, types as declared, anatomy names and resolved
style values. It is what a design system documents. Not sent: source code, the CHI (the server computes it),
unresolved[].detail (it quotes a fragment of the code), otherProps[].tsType, the scan root, any absolute path.
sync --dry-run prints what would go, in counts and bytes, and sends nothing.
Tokens: token names, their declared type, a literal or an alias per mode, and the file each came from, relative
to the root. Not sent: file contents, comments, absolute paths, or any environment value a Tailwind config reads
outside its theme. grails-cli tokens --json prints exactly what would go.
With dtcg, the files go whole. The token half sends the DTCG files dtcg.files matches, and the resolver, as
they are written: every $description, $extensions and $deprecated in them goes too. Grails keeps the
descriptions and the extensions with each token. Their keys are paths relative to the resolver's folder (or to the
root), never absolute. Only the files the globs match leave; node_modules and dot folders are never walked.
grails-cli tokens --json prints the upload exactly.
The GitHub workflow
templates/grails-cli-sync.yml (in this package, and shown in Grails under Team Settings › Integrations) runs
npx --yes grails-cli sync --trigger ci on a push to the branch under branches: (main by default), which must
be the one grails.code.json declares. Copy it to .github/workflows/grails-cli-sync.yml. Its step has
id: grails, so a later step reads steps.grails.outputs.result.
The key reaches it through the step's env: from a repository secret named GRAILS_API_KEY, and never appears in a
run: line. Do not run it on pull_request: a fork's run has no secrets and no business syncing.
A new release of the CLI can change what a push uploads. To choose when that happens, pin a version in the
workflow: npx --yes grails-cli@<version> sync --trigger ci.
License
Proprietary. You may install and use grails-cli to work with Grails; no other right is granted. See LICENSE.md.
