# Custom CLI Apps

import Intro from '@site/src/components/Intro'
import EmbedFile from '@site/src/components/EmbedFile'
import CastEmbed from '@site/src/components/CastEmbed'

<Intro>
A custom CLI app is an executable script with its own arguments, flags, and help.
Add an Atmos shebang, make the file executable, and run it as `./myapp`.
</Intro>

Use standalone apps for project tools, reports, or release commands that run
locally and in CI. Atmos supplies the Starlark interpreter and built-in helpers.
Scripts using only those helpers need no `atmos.yaml`, stacks, or separate
language runtime. External commands still need their tools and credentials.

To add a subcommand such as `atmos capacity`, use
[custom commands](/scripting/custom-commands).

## Executable syntax

Put Atmos on your `PATH` and save this as `myapp`:

```python title="myapp"
#!/usr/bin/env atmos
ui.success("Hello from myapp")
```

Make it executable and run it:

```shell
chmod +x myapp
./myapp
```

The first line selects Atmos as the interpreter. No file extension is required.
A `.star` extension is optional and can help editors recognize Starlark. On
systems without shebang support, use `atmos ./myapp`.

## Declare arguments, flags, and help

Use `cli.command` to declare the inputs and the function that runs the app.
`cli.arg` defines a positional argument; `cli.flag` defines a named option:

```python title="myapp"
#!/usr/bin/env atmos

def main(args, flags):
    ui.success("{}, {}!".format(flags["greeting"], args["name"]))

cli.command(
    description = "Print a greeting",
    args = [cli.arg("name")],
    flags = [cli.flag("greeting", default="Hello")],
    run = main,
)
```

Run `./myapp world --greeting Hi`. Use `./myapp --help` to list the argument
and flag. Scripts without `cli.command` receive raw arguments in `ctx.args`.

## Built-in helpers

Use `components.get` to read resolved stack configuration, `atmos.*` to run
Atmos commands, and `exec.run` to call external tools. `dependencies.tools`
installs pinned tool versions for processes the script starts; installation
may need network access. `atmos.auth` runs commands with a configured identity.

Process files with `fs`, `json`, and `regex`. Run independent functions with
`steps.parallel`; add retries and timeouts with `steps.task`. Results retain
input order. Call registered step handlers through `steps`, including prompts
and HTTP requests.

Use `print` for data, `ui` for status, and `log` for diagnostics. Assign `output`
to return a value, or use `errors.build` to report an error with recovery hints.
See the [function reference](/functions/automation) and
[testing guide](/scripting/testing).

## Work with parsed inputs

The `run` and optional `validate` callbacks receive `args` and `flags`
dictionaries. Arguments and flags retain their declared types. Use their values directly
in the script.

Flags can declare defaults, choices, and environment bindings. Use a validation
callback for rules beyond a flag's type, such as a positive count or a relationship
between two inputs. Keep the work itself in the `run` callback so help and invalid
invocations do not start it.

Environment values parse exactly like command-line values:

- A `string_list` flag splits on commas and honors CSV quoting, so
  `DEPLOY_ZONES=a,b` is `["a", "b"]` and `DEPLOY_ZONES='"a,b",c'` is `["a,b", "c"]`.
  Whitespace is not a separator. With `choices`, every element is checked.
- An `int` flag accepts base-10 digits only, from the command line and from the
  environment. `010` is ten, and `0x10` is rejected instead of being read as sixteen.
- Names are case-insensitive. A script that declares both `Stage` and `stage`, or
  two arguments that differ only by case, fails when `cli.command` runs.

A bool flag never consumes the next word. `--verbose false` would turn the flag on
and pass `false` as a positional argument, so Atmos rejects it. Write
`--verbose=false`, or put `--` before an argument you mean literally.

## Help and usage errors

`--help` lists every flag with what you need to call it correctly: required flags
are marked `(required)`, flags with `choices` show `(one of: dev, prod)`, and a
flag bound to an environment variable shows `[env: DEPLOY_STAGE]`:

```text
Usage:
  release <service> [flags]

Arguments:
  <service>  Service to deploy

Flags:
  -h, --help           Show help for this script
      --stage string   Stage to deploy (one of: dev, prod) [env: DEPLOY_STAGE] (default "dev")
      --token string   API token (required) [env: DEPLOY_TOKEN]
```

Help is compact and shows only your tool's own usage, arguments, and flags.

Unless you pass `name` to `cli.command`, the command name shown in usage is the script's file name without the
`.star` extension, so `release.star` appears as `release`.

Mistakes in the command line are usage errors, not script crashes. An unknown flag,
a missing or surplus argument, an invalid value, a missing required flag, or a value
outside `choices` prints the problem, the usage line, and a hint, with no Starlark
traceback, and exits with status 2:

```text
Error: usage error
missing required argument <service>
Usage: release <service> [flags]
💡 Run ./release.star --help for usage.
```

A `validate` callback that returns `False` is also a usage error, with status 2. Errors your own code
raises, such as `fail()` (including `fail()` inside `validate`, which exits with status 1 and shows its reason) or a
runtime error, keep the Starlark presentation with a traceback.
Tracebacks show paths relative to the working directory when the script is under
it, and log fields use the script's file name, such as `step=release.star`.

## Output

Your program prints data by assigning the top-level `output`. A string is written as
is, and any other value is written as JSON. `None` means no output, so
`output = cli.command(...)` prints nothing when the callback returns nothing or when
you ask for `--help`.

## Interrupt with Ctrl-C

The first Ctrl-C cancels the script, runs its [`defer`](/functions/automation/defer) calls with a 30 second grace
period, and exits with status 130 without printing an error box. A second Ctrl-C exits immediately.

## Global flags

Atmos global flags go between `atmos` and the script path. Flags that take a value
accept the `--flag=value` form, and most also accept the `--flag value` form:

```shell
atmos --chdir=tools ./release.star api
atmos --logs-level=Debug ./release.star api
```

Atmos applies these flags itself, and the first word that is not a flag starts the
script. Everything after the script path belongs to the script, including flags
that look like Atmos flags, such as a script-owned `--chdir` or `--force-color`. A relative script
path is resolved against the directory you were in before `--chdir` (or `ATMOS_CHDIR`) moved the process,
because that is the directory you typed it against. The script then runs in the new working directory.
Usage hints keep the spelling you typed. The `--profile` and `--identity` flags must use the `--flag=value` form. A space form such as
`--profile dev` before the script is rejected with a hint to write `--profile=dev`, because the word `dev` would
otherwise be taken as the script path. A flag Atmos does not define before the script is an error
from the normal command line.

With `ATMOS_USE_VERSION` or `version.use`, Atmos re-runs your script with the pinned
version and forwards the script path and its arguments unchanged.

Paths with a registered extension, such as `.star`, report script errors. An existing directory
such as `./stacks` is left to normal Atmos command handling.

## File names and editor support

A script runs by path, so the `.star` extension is optional. The extension match ignores case, so `TOOL.STAR` and
`Tool.Star` run like `tool.star`. These all run the script:

```shell
./tool                # Shebang: #!/usr/bin/env atmos
atmos ./tool          # A path, with or without .star
atmos tool.star
```

A bare `atmos tool` with no slash and no `.star` is an Atmos command name, so it
never runs a script. Use `./tool`, `atmos ./tool`, or a `.star` name.

The shebang can carry Atmos global flags. Both first lines below run the file as an Atmos script:

```text
#!/usr/bin/env atmos
#!/usr/bin/env -S atmos --logs-level=Debug
```

The `-S` option of `env` splits the line into separate words, so the flags that follow `atmos` reach Atmos before the
script path, where they apply like any global flag written between `atmos` and the script. `env` also accepts
`NAME=value` assignments ahead of `atmos`.

The `.star` extension is what lets editors and GitHub recognize a file as Starlark
without any other hint. An extensionless script needs a file-type hint. Put the
shebang on line 1 and the hint on line 2, because Vim, Emacs, and GitHub all search
the first lines of a file:

```python
#!/usr/bin/env atmos
# vim: set filetype=bzl: -*- mode: bazel-starlark -*-
def main(args, flags):
    ui.success("Hello, " + args["name"] + "!")

cli.command(
    description = "Greet someone",
    args = [cli.arg("name")],
    run = main,
)
```

Atmos reads the shebang on line 1 and treats line 2 as a comment, so the hint does
not change how the script runs.

<dl>
  <dt>Vim</dt>
  <dd>
    Vim has no `starlark` filetype. Its built-in Starlark support is the `bzl`
    filetype, so use `# vim: set filetype=bzl:`. Vim checks the first and last
    five lines by default (`modelines=5`) when `modeline` is on.
  </dd>

  <dt>Emacs</dt>
  <dd>
    Emacs has no built-in Starlark mode. With the
    [bazel-mode](https://github.com/bazelbuild/emacs-bazel-mode) package installed,
    use `-*- mode: bazel-starlark -*-`. Without it, `-*- mode: python -*-` gives
    close syntax highlighting. When line 1 is a `#!` line, Emacs reads the mode line
    from line 2.
  </dd>

  <dt>VS Code</dt>
  <dd>
    VS Code has no built-in Starlark language. Install an extension that provides
    the `starlark` language, then associate your files in `settings.json`, for
    example `"files.associations": { "**/bin/tool": "starlark" }`.
  </dd>

  <dt>GitHub</dt>
  <dd>
    GitHub's [Linguist](https://github.com/github-linguist/linguist) reads Vim and
    Emacs modelines in the first and last five lines, and resolves the value as a
    language name or alias. The Starlark language answers to `starlark` and `bazel`,
    so `# vim: set ft=starlark:` or `# -*- mode: starlark -*-` is detected, while
    `bzl` and `bazel-starlark` are not. Because those two values differ from what
    Vim and Emacs need, the most reliable option for an extensionless file is a
    `.gitattributes` entry such as `bin/tool linguist-language=Starlark`.
  </dd>
</dl>

## Read a script from stdin

Use `-` in place of a script filename:

```shell
atmos - services.json < summarize.star
atmos - -- services.json < summarize.star
```

Both commands pass `services.json` to the script. The optional `--` immediately
following `-` is consumed by Atmos; subsequent arguments and flags belong to the
script. For a script that declares `cli.command`, `atmos - --help < capacity.star`
shows that script's help.

Starlark is the default interpreter for stdin. To select it explicitly, put
`--interpreter` before `-`:

```shell
atmos --interpreter=starlark - services.json < summarize.star
```

Pipes and here-documents work too:

```shell
atmos - foo bar <<'EOF'
print(ctx.args)
EOF
```

```text
["foo", "bar"]
```

When standard input is a terminal, Atmos prints `Reading script from stdin; press Ctrl-D to end.` on stderr so
you know it is waiting for the script. Pipes and here-documents print nothing.

Atmos reads the source through EOF before executing it. Relative `load()` imports
and file operations resolve from the working directory. There is no source
filename, so `ctx.script` is `None`, and tracebacks identify the source as
`<stdin>`. An unnamed `cli.command` uses `stdin` as its command name.

The `-` marker is required: `atmos < summarize.star` does not select script
execution. `--interpreter` selects a registered embedded interpreter; it does not
launch an external interpreter.

## Example: summarize a service manifest {#start-with-an-executable-script}

Save this program as `summarize.star`. It reads the input filename from
`ctx.args`, loads the JSON file, and reports the total number of replicas:

<EmbedFile filePath="examples/starlark-script/summarize.star" language="python" />

Save the input beside it as `services.json`:

<EmbedFile filePath="examples/starlark-script/services.json" language="json" />

Run from the directory containing both files. The shebang on the first line
selects Atmos as the interpreter:

```shell
chmod +x summarize.star
./summarize.star services.json
```

```text
▶ api: 2 replicas
▶ worker: 3 replicas
✓ Total: 2 services, 5 replicas
```

The `ui.info` calls report each service, and `ui.success` highlights the total. These styled
messages use stderr, leaving stdout available for data.

On systems without shebang support, run `atmos ./summarize.star services.json`.
Input paths passed to `fs.read_file` are relative to your working directory.

<CastEmbed src="/casts/examples/starlark-script/summarize.cast" title="Custom CLI app: ./summarize.star services.json" chrome controls scrubber />

## Example: validate arguments and flags

This capacity tool accepts a service name and a replica count:

<EmbedFile filePath="examples/starlark-script/capacity.star" language="python" />

Save this example as `capacity.star`, make it executable, and run:

```shell
chmod +x capacity.star
./capacity.star api --replicas=3
```

```text
✓ api: 3 replicas x 4 workers = 12 workers
```

Omitting `--replicas` uses the declared default of two. A value such as
`--replicas=many` fails integer parsing, while `--replicas=0` parses as an integer
but fails the program's positive-count validation.

Run `./capacity.star --help` to see the required service argument, replica flag,
and default. Help runs neither the validation callback nor `main`.

## Example: release an application {#what-your-program-can-do}

A release tool can build and publish an image, deploy it with
Terraform, and report the result. This program assumes your project defines
container and Terraform components named `api` in the selected stack, with the
required tools and credentials available:

```python
#!/usr/bin/env atmos
def main(args, flags):
    stack = flags["stack"]
    ui.info("Releasing api to " + stack)

    atmos.container("build", "api", flags={"stack": stack})
    atmos.container("push", "api", flags={"stack": stack})
    atmos.terraform("deploy", "api", stack)

    ui.success("Released api to " + stack)

cli.command(
    description = "Build, publish, and deploy the API",
    flags = [cli.flag("stack", shorthand="s", required=True,
                      description="Stack to release to")],
    run = main,
)
```

Save it as `release`, run `chmod +x release`, then run `./release --stack dev`.
Use `./release --help` to list its flags without starting the release.

See the [automation function reference](/functions/automation) for signatures
and examples, and [secret masking](/cli/configuration/settings/mask) for masking
configuration. The bundled `atmos-starlark` agent skill documents the language
and APIs for coding agents; install it with
[`atmos ai skill install atmos-starlark`](/cli/commands/ai/skill).

## Example: prompt for input {#call-the-step-library}

A standalone program can use the same [step handlers](/functions/automation/steps.run)
as a YAML workflow. Save this as `select-service`:

```python
#!/usr/bin/env atmos
service = steps.input(prompt = "Service name?", default = "api").value
ui.success("Selected " + service)
```

Run `chmod +x select-service`, then `./select-service` to prompt in a terminal. Without a terminal,
the input step returns its configured default. Read the result's `.value`,
`.metadata`, or `.outputs` to pass data to subsequent operations. The step library
also includes container operations, HTTP requests, archives, and formatted output.

## Continue building

Read the [command declaration reference](/steps/type/script#declaring-a-standalone-command)
for supported flag types and parsing rules, and the
[language overview](/scripting/language) for helpers,
module loading, processes, and parallel tasks.

Browse the [complete runnable example](/examples/starlark-script) for both scripts
and their input data. To expose your automation under the Atmos CLI itself,
continue with [custom commands](/scripting/custom-commands).
