Skip to content

Auth

auth

Authenticates AI coding agents via OAuth or API key.

Two public entry points:

  • authenticate(project_id, provider, *, mounts_dir, image) — dispatches based on the provider's modes field: prompts for an API key (no container) or launches an auth container with the vendor CLI.
  • store_api_key(provider, api_key) — stores an API key directly in the credential DB (non-interactive fast path for CI).

AUTH_PROVIDERS is a registry dict populated from the YAML roster at package load time; authenticate looks up the provider by name and delegates to the matching flow.

AUTH_PROVIDERS = {} module-attribute

All known auth providers (agents + tools), keyed by name. Loaded from resources/agents/*.yaml.

AuthProvider(name, label, host_dir_name, container_mount, command, banner_hint, extra_run_args=tuple(), modes=('api_key',), device_auth=False, api_key_hint='', post_capture_state=dict(), credential_provider='') dataclass

Describes how to authenticate one tool/agent.

name instance-attribute

Short key used in CLI and TUI dispatch (e.g. "codex").

label instance-attribute

Human-readable display name (e.g. "Codex").

host_dir_name instance-attribute

Single-segment directory name under mounts_dir() (e.g. "_codex-config").

container_mount instance-attribute

Mount point inside the container (e.g. "/home/dev/.codex").

command instance-attribute

Command to execute inside the container (OAuth mode only).

banner_hint instance-attribute

Provider-specific help text shown before the container runs.

extra_run_args = field(default_factory=tuple) class-attribute instance-attribute

Additional podman run arguments (e.g. port forwarding).

modes = ('api_key',) class-attribute instance-attribute

Supported auth modes: "oauth" (container), "api_key" (fast path).

device_auth = False class-attribute instance-attribute

Whether the OAuth flow has a headless device-code variant.

When set, the auth prompt offers a device-code method alongside the normal OAuth login: it runs <command> --device-auth with the normal flow's port-forwarding (extra_run_args) dropped — the device-code flow polls a remote endpoint and needs no localhost callback, so it works where the operator can't complete the forwarded-port login.

api_key_hint = '' class-attribute instance-attribute

Hint shown when prompting for an API key (URL to get one).

post_capture_state = field(default_factory=dict) class-attribute instance-attribute

JSON state files to write after credential capture.

Maps filename → key-value dict to merge into a JSON file in the auth mount directory. Example: {".claude.json": {"hasCompletedOnboarding": true}} marks Claude Code onboarding as complete so the first-run wizard is skipped.

credential_provider = '' class-attribute instance-attribute

Provider name the captured credential is stored under (e.g. "anthropic").

terok-executor auth claude authenticates Claude's default provider, so the credential is keyed under anthropic — matching the vault route and the v3-migrated DB. Populated by the roster loader from the agent's provider.default binding; empty falls back to the entry's own name.

supports_oauth property

Whether this provider supports OAuth (container-based) auth.

supports_api_key property

Whether this provider supports direct API key entry.

supports_device_auth property

Whether this provider offers a headless device-code login variant.

__post_init__()

Validate fields that become filesystem paths.

Source code in src/terok_executor/credentials/auth.py
def __post_init__(self) -> None:
    """Validate fields that become filesystem paths."""
    p = Path(self.host_dir_name)
    if p.is_absolute() or ".." in p.parts or len(p.parts) != 1:
        raise ValueError(
            f"host_dir_name must be a single directory segment, got {self.host_dir_name!r}"
        )

AuthKeyConfig(label, key_url, env_var, config_path, printf_template, tool_name) dataclass

Describes how to prompt for and store an API key.

label instance-attribute

Human name shown in the prompt (e.g. "Claude").

key_url instance-attribute

URL where the user can obtain the key.

env_var instance-attribute

Name shown in the read -p prompt (e.g. "ANTHROPIC_API_KEY").

config_path instance-attribute

Destination inside the container (e.g. "~/.claude/config.json").

printf_template instance-attribute

printf format string (e.g. '{"api_key": "%s"}').

tool_name instance-attribute

Name shown in the success message (e.g. "claude").

Authenticator(provider) dataclass

Vendor-credential acquisition for a single agent.

Wraps the authenticate flow behind a stable class so callers that orchestrate a multi-step setup (terok project init, the standalone terok-executor auth command, the TUI auth flow) talk to one named surface bound to self.provider.

The discovery counterparts (list_authenticated_agents, scan_leaked_credentials) stay as module-level fns in their owning submodules — folding them in here would create a tach cycle through terok_executor.acp and terok_executor.credentials.vault_commands, which already depend on this module transitively.

provider instance-attribute

Auth provider name (e.g. "claude").

run(project_id, *, mounts_dir, image=None, expose_token=False, oauth_enabled=True, credential_set='default', device_auth=False)

Run the auth flow for self.provider; see module-level docs.

Mirrors the parameters of the underlying authenticate free function — instance-bound self.provider replaces the old positional provider arg. The device-code login is normally offered as a method in that flow's prompt; pass device_auth to skip the prompt and force it (the headless --device-auth path).

Source code in src/terok_executor/credentials/auth.py
def run(
    self,
    project_id: str | None,
    *,
    mounts_dir: Path,
    image: str | Callable[[], str] | None = None,
    expose_token: bool = False,
    oauth_enabled: bool = True,
    credential_set: str = "default",
    device_auth: bool = False,
) -> None:
    """Run the auth flow for ``self.provider``; see module-level docs.

    Mirrors the parameters of the underlying ``authenticate`` free
    function — instance-bound ``self.provider`` replaces the old
    positional ``provider`` arg.  The device-code login is normally
    offered as a method in that flow's prompt; pass *device_auth* to
    skip the prompt and force it (the headless ``--device-auth`` path).
    """
    authenticate(
        project_id,
        self.provider,
        mounts_dir=mounts_dir,
        image=image,
        expose_token=expose_token,
        oauth_enabled=oauth_enabled,
        credential_set=credential_set,
        device_auth=device_auth,
    )

prepare_oauth(project_id, *, mounts_dir, image, expose_token=False, credential_set='default', device_auth=False)

Build an AuthSession without running it.

Frontends that own their own UI loop (e.g. the terok Textual TUI, which wants to dispatch the OAuth container into a new terminal tab or via tmux instead of inline) build the session here, run session.argv however they like, then call session.capture() on success. The CLI's blocking authenticate path is just another such caller — see _run_auth_container.

With device_auth, the session runs the provider's headless device-code login (see prepare_oauth_session).

Source code in src/terok_executor/credentials/auth.py
def prepare_oauth(
    self,
    project_id: str | None,
    *,
    mounts_dir: Path,
    image: str,
    expose_token: bool = False,
    credential_set: str = "default",
    device_auth: bool = False,
) -> AuthSession:
    """Build an [`AuthSession`][terok_executor.AuthSession] without running it.

    Frontends that own their own UI loop (e.g. the terok Textual TUI,
    which wants to dispatch the OAuth container into a new terminal
    tab or via tmux instead of inline) build the session here, run
    ``session.argv`` however they like, then call ``session.capture()``
    on success.  The CLI's blocking ``authenticate`` path is just
    another such caller — see ``_run_auth_container``.

    With *device_auth*, the session runs the provider's headless
    device-code login (see [`prepare_oauth_session`][terok_executor.prepare_oauth_session]).
    """
    info = AUTH_PROVIDERS.get(self.provider)
    if not info:
        available = ", ".join(AUTH_PROVIDERS)
        raise SystemExit(f"Unknown auth provider: {self.provider}. Available: {available}")
    if not info.supports_oauth:
        raise SystemExit(
            f"Provider {self.provider!r} does not support OAuth — use store_api_key() instead."
        )
    if device_auth and not info.supports_device_auth:
        raise SystemExit(f"Provider {self.provider!r} has no device-auth login.")
    return prepare_oauth_session(
        info,
        project_id,
        mounts_dir=mounts_dir,
        image=image,
        expose_token=expose_token,
        credential_set=credential_set,
        device_auth=device_auth,
    )

AuthSession(provider, project_id, container_name, argv, banner, auth_dir, mounts_dir, credential_set='default', expose_token=False, _tmpdir=None) dataclass

A prepared-but-not-run OAuth auth container session.

Built by Authenticator.prepare_oauth (or the module-level prepare_oauth_session helper). Hold-don't-call: the caller is responsible for running argv (synchronously, in a new terminal tab, suspended TUI, etc.) and calling capture() afterwards. Use as a context manager so the temp dir and any dangling container are cleaned up on exit.

provider instance-attribute

Provider descriptor (label, banner hint, mount points).

project_id instance-attribute

Project scope for the banner; None for host-wide auth.

container_name instance-attribute

Podman container name (used for cleanup and -it log clarity).

argv instance-attribute

The podman run … command line — run this however you like.

banner instance-attribute

Banner text to display before launching argv.

auth_dir instance-attribute

Temp dir bind-mounted as the container's auth config target.

Lives until cleanup() (or __exit__). Credential extraction in capture() reads from here, so don't remove it manually.

mounts_dir instance-attribute

Base directory for the shared post-capture mount (OAuth providers only).

credential_set = 'default' class-attribute instance-attribute

Which credential set in the vault DB receives the captured token.

expose_token = False class-attribute instance-attribute

When True, real credential files are copied into the shared mount (tier 3).

title property

Short human-readable title ("Authenticating Claude (host-wide)").

capture()

Extract credentials from auth_dir, store them in the vault DB.

Call after argv exits successfully. Safe to call multiple times (the underlying extractor is idempotent on a stable credential file).

Source code in src/terok_executor/credentials/auth.py
def capture(self) -> None:
    """Extract credentials from ``auth_dir``, store them in the vault DB.

    Call after ``argv`` exits successfully.  Safe to call multiple
    times (the underlying extractor is idempotent on a stable
    credential file).
    """
    _capture_credentials(
        self.provider.name,
        self.auth_dir,
        self.credential_set,
        mounts_base=self.mounts_dir,
        auth_provider=self.provider,
        expose_token=self.expose_token,
    )

cleanup()

Release the temp dir and force-remove any lingering container.

Idempotent. __exit__ calls this automatically.

Source code in src/terok_executor/credentials/auth.py
def cleanup(self) -> None:
    """Release the temp dir and force-remove any lingering container.

    Idempotent.  ``__exit__`` calls this automatically.
    """
    subprocess.run(
        ["podman", "rm", "-f", self.container_name],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    if self._tmpdir is not None:
        self._tmpdir.cleanup()
        self._tmpdir = None

__enter__()

Return self; the heavy lifting already happened in the factory.

Source code in src/terok_executor/credentials/auth.py
def __enter__(self) -> AuthSession:
    """Return self; the heavy lifting already happened in the factory."""
    return self

__exit__(*_exc)

Run cleanup on context-manager exit.

Source code in src/terok_executor/credentials/auth.py
def __exit__(self, *_exc: object) -> None:
    """Run ``cleanup`` on context-manager exit."""
    self.cleanup()

authenticate(project_id, provider, *, mounts_dir, image=None, expose_token=False, oauth_enabled=True, credential_set='default', device_auth=False)

Run the auth flow for provider, optionally scoped to a project.

Offers every auth method the provider supports — OAuth (vendor CLI in a container), the OAuth device-code variant (for providers that declare device_auth), and a direct API key — as the effective mode set: what the roster declares (modes:, device_auth) intersected with what the caller permits via oauth_enabled. One method runs straight through; two or more prompt the user to choose.

Pass device_auth to force the device-code variant non-interactively (the headless --device-auth path): the menu is skipped and the device-code login runs straight through. Raises SystemExit if the provider has no device-code login or OAuth is gated off.

Parameters:

Name Type Description Default
project_id str | None

Project identifier used for container naming and the banner line. Pass None for host-wide auth — the banner drops the project reference and the container gets a neutral host-auth-<provider> name.

required
provider str

Auth provider name (e.g. "claude").

required
mounts_dir Path

Base directory for shared config bind-mounts.

required
image str | Callable[[], str] | None

Container image for the OAuth container. Either a tag string (eager) or a zero-arg callable returning the tag (lazy — invoked only when the user actually chooses the OAuth path). None is fine for API-key-only providers, where no container is launched. Use the lazy form to avoid paying the L1 build cost when the user might pick API key from the OAuth-or-API-key prompt.

None
expose_token bool

When True, copy the real credential files into the shared mount instead of writing a phantom marker. Used by tier 3 (expose_oauth_token) where containers need the actual token.

False
oauth_enabled bool

External gate for the OAuth path. True (default) means the roster's modes list is honored verbatim. False instructs the function to skip the OAuth prompt and go straight to the API-key flow regardless of what the roster declares — terok passes False for providers whose OAuth path requires unset config flags (e.g. agent.codex.allow_oauth=true plus experimental: true). When the provider declares only OAuth and the gate is closed, raises SystemExit with a clear hint.

True
credential_set str

Storage namespace in the vault DB. Defaults to "default" — the shared host-wide bucket every standalone and pre-existing terok caller uses. Per-project callers pass a project-specific value (e.g. project.id) to keep each project's tokens isolated. The DB schema keys on (credential_set, provider), so two projects can hold independent logins for the same provider side-by-side.

'default'

Raises SystemExit if the provider name is unknown or no usable auth mode remains after gating.

Source code in src/terok_executor/credentials/auth.py
def authenticate(
    project_id: str | None,
    provider: str,
    *,
    mounts_dir: Path,
    image: str | Callable[[], str] | None = None,
    expose_token: bool = False,
    oauth_enabled: bool = True,
    credential_set: str = "default",
    device_auth: bool = False,
) -> None:
    """Run the auth flow for *provider*, optionally scoped to a project.

    Offers every auth method the provider supports — OAuth (vendor CLI in a
    container), the OAuth device-code variant (for providers that declare
    ``device_auth``), and a direct API key — as the *effective* mode set: what
    the roster declares (``modes:``, ``device_auth``) intersected with what the
    caller permits via *oauth_enabled*.  One method runs straight through; two or
    more prompt the user to choose.

    Pass *device_auth* to force the device-code variant non-interactively
    (the headless ``--device-auth`` path): the menu is skipped and the
    device-code login runs straight through.  Raises ``SystemExit`` if the
    provider has no device-code login or OAuth is gated off.

    Args:
        project_id: Project identifier used for container naming and the
            banner line.  Pass ``None`` for host-wide auth — the banner
            drops the project reference and the container gets a neutral
            ``host-auth-<provider>`` name.
        provider: Auth provider name (e.g. ``"claude"``).
        mounts_dir: Base directory for shared config bind-mounts.
        image: Container image for the OAuth container.  Either a tag
            string (eager) or a zero-arg callable returning the tag
            (lazy — invoked only when the user actually chooses the
            OAuth path).  ``None`` is fine for API-key-only providers,
            where no container is launched.  Use the lazy form to avoid
            paying the L1 build cost when the user might pick API key
            from the OAuth-or-API-key prompt.
        expose_token: When True, copy the real credential files into
            the shared mount instead of writing a phantom marker.  Used
            by tier 3 (``expose_oauth_token``) where containers need
            the actual token.
        oauth_enabled: External gate for the OAuth path.  ``True``
            (default) means the roster's ``modes`` list is honored
            verbatim.  ``False`` instructs the function to skip the
            OAuth prompt and go straight to the API-key flow regardless
            of what the roster declares — terok passes ``False`` for
            providers whose OAuth path requires unset config flags
            (e.g. ``agent.codex.allow_oauth=true`` plus ``experimental:
            true``).  When the provider declares only OAuth and the
            gate is closed, raises ``SystemExit`` with a clear hint.
        credential_set: Storage namespace in the vault DB.  Defaults to
            ``"default"`` — the shared host-wide bucket every standalone
            and pre-existing terok caller uses.  Per-project callers
            pass a project-specific value (e.g. ``project.id``) to
            keep each project's tokens isolated.  The DB schema keys on
            ``(credential_set, provider)``, so two projects can hold
            independent logins for the same provider side-by-side.

    Raises ``SystemExit`` if the provider name is unknown or no usable
    auth mode remains after gating.
    """
    info = AUTH_PROVIDERS.get(provider)
    if not info:
        available = ", ".join(AUTH_PROVIDERS)
        raise SystemExit(f"Unknown auth provider: {provider}. Available: {available}")

    # Gating: a provider's roster may declare OAuth, but the deployment
    # may not allow it (terok's ``allow_oauth`` + ``experimental`` gate).
    has_oauth = info.supports_oauth and oauth_enabled
    has_api_key = info.supports_api_key

    # OAuth (and its device-code variant) run the vendor CLI in a container; the
    # image is resolved lazily so the OAuth-or-API-key prompt never pays the L1
    # build cost when the user picks the container-free API-key path.
    def _oauth(*, device: bool) -> None:
        _run_auth_container(
            project_id,
            info,
            mounts_dir=mounts_dir,
            image=_resolve_image(image, provider),
            expose_token=expose_token,
            credential_set=credential_set,
            device_auth=device,
        )

    def _api_key() -> None:
        store_api_key(provider, _prompt_api_key(info), credential_set=credential_set)

    methods: list[tuple[str, Callable[[], None]]] = []
    if has_oauth:
        methods.append(("OAuth login", lambda: _oauth(device=False)))
        if info.supports_device_auth:
            # Same OAuth credential, obtained via a device code the operator
            # enters on a second device instead of a forwarded callback port.
            methods.append(("OAuth login — device code", lambda: _oauth(device=True)))
    if has_api_key:
        methods.append(("API key", _api_key))

    if device_auth:
        # Headless force: bypass the chooser and run the device-code login
        # directly.  Reuses the OAuth gate's hint when the path is closed.
        if not has_oauth:
            raise SystemExit(
                f"Device-code auth for {provider!r} requires OAuth, but it is "
                f"disabled by the caller's gating policy.  For terok this "
                f"typically means the experimental flag and/or the "
                f"provider-specific allow_oauth/expose_oauth_token config "
                f"keys are unset."
            )
        if not info.supports_device_auth:
            raise SystemExit(f"Provider {provider!r} has no device-code login.")
        _oauth(device=True)
        return

    if not methods:
        # Provider declares only OAuth and the caller's gate is closed.
        raise SystemExit(
            f"Auth for {provider!r} requires OAuth, but it is disabled by "
            f"the caller's gating policy.  For terok this typically means "
            f"the experimental flag and/or the provider-specific "
            f"allow_oauth/expose_oauth_token config keys are unset."
        )

    if len(methods) == 1:
        methods[0][1]()
        return

    print(f"Authenticate {info.label}:\n")
    for i, (label, _) in enumerate(methods, 1):
        print(f"  {i}. {label}")
    print()
    choice = input(f"Choose [1-{len(methods)}] (default 1): ").strip() or "1"
    try:
        index = int(choice) - 1
        if not 0 <= index < len(methods):
            raise ValueError
    except ValueError:
        raise SystemExit(f"Invalid choice: {choice!r}") from None
    methods[index][1]()

credential_provider(name)

Resolve an auth target (agent name) to the provider its credential is keyed under.

terok-executor auth claude authenticates Claude's default provider, so the credential is stored under anthropic — the same key the vault route and the v3-migrated DB use, so routed = stored & routes intersects at runtime. Tools resolve the same way (ghgithub). Names not in the auth registry (harnesses with no credentials, or already a provider) pass through unchanged.

Reads the auth registry rather than the roster so the credentials layer stays below it — the resolved provider rides on each AuthProvider, populated by the loader.

Source code in src/terok_executor/credentials/auth.py
def credential_provider(name: str) -> str:
    """Resolve an auth target (agent name) to the provider its credential is keyed under.

    ``terok-executor auth claude`` authenticates Claude's *default provider*, so
    the credential is stored under ``anthropic`` — the same key the vault route
    and the v3-migrated DB use, so ``routed = stored & routes`` intersects at
    runtime.  Tools resolve the same way (``gh`` → ``github``).  Names not in the
    auth registry (harnesses with no credentials, or already a provider) pass
    through unchanged.

    Reads the auth registry rather than the roster so the credentials layer
    stays below it — the resolved provider rides on each
    [`AuthProvider`][terok_executor.credentials.auth.AuthProvider], populated by
    the loader.
    """
    info = AUTH_PROVIDERS.get(name)
    return info.credential_provider if (info and info.credential_provider) else name

store_api_key(provider, api_key, credential_set='default')

Store an API key directly in the credential DB (no container needed).

This is the non-interactive fast path for automated workflows and CI. The key is stored as {"type": "api_key", "key": "<value>"} under the resolved provider name (see credential_provider).

Source code in src/terok_executor/credentials/auth.py
def store_api_key(
    provider: str,
    api_key: str,
    credential_set: str = "default",
) -> None:
    """Store an API key directly in the credential DB (no container needed).

    This is the non-interactive fast path for automated workflows and CI.
    The key is stored as ``{"type": "api_key", "key": "<value>"}`` under the
    resolved provider name (see [`credential_provider`][terok_executor.credentials.auth.credential_provider]).
    """
    from terok_executor.integrations.sandbox import SandboxConfig

    cred_provider = credential_provider(provider)
    cfg = SandboxConfig()
    db = cfg.open_credential_db(prompt_on_tty=True)
    try:
        db.store_credential(credential_set, cred_provider, {"type": "api_key", "key": api_key})
        print(f"API key stored for {cred_provider} (set: {credential_set})")
    finally:
        db.close()

prepare_oauth_session(provider, project_id, *, mounts_dir, image, expose_token=False, credential_set='default', device_auth=False)

Build an AuthSession without running it.

Creates a fresh temp dir, computes the podman run argv, and cleans up any leftover container of the same name (so re-auth after a previous abort isn't blocked). The caller drives execution and credential capture; see AuthSession.

The temp dir uses a clean slate so the vendor auth flow re-runs end to end — no stale config, no cached sessions.

With device_auth, the command gains --device-auth and the port-forwarding extra_run_args are dropped: the device-code flow polls a remote endpoint, so there is no localhost callback to forward.

Source code in src/terok_executor/credentials/auth.py
def prepare_oauth_session(
    provider: AuthProvider,
    project_id: str | None,
    *,
    mounts_dir: Path,
    image: str,
    expose_token: bool = False,
    credential_set: str = "default",
    device_auth: bool = False,
) -> AuthSession:
    """Build an [`AuthSession`][terok_executor.AuthSession] without running it.

    Creates a fresh temp dir, computes the ``podman run`` argv, and
    cleans up any leftover container of the same name (so re-auth
    after a previous abort isn't blocked).  The caller drives execution
    and credential capture; see [`AuthSession`][terok_executor.AuthSession].

    The temp dir uses a clean slate so the vendor auth flow re-runs end
    to end — no stale config, no cached sessions.

    With *device_auth*, the command gains ``--device-auth`` and the
    port-forwarding ``extra_run_args`` are dropped: the device-code flow
    polls a remote endpoint, so there is no localhost callback to forward.
    """
    _check_podman()

    tmpdir = tempfile.TemporaryDirectory(prefix=f"terok-auth-{provider.name}-")
    host_dir = Path(tmpdir.name)

    # ``project_id`` must lead the container name; Podman rejects names
    # starting with ``_`` or other non-alphanumeric chars, so the
    # host-wide caller passes ``None`` and we fall back to ``host``.
    name_prefix = project_id or "host"
    container_name = f"{name_prefix}-auth-{provider.name}"
    _cleanup_existing_container(container_name)

    command = [*provider.command, "--device-auth"] if device_auth else list(provider.command)
    run_args = () if device_auth else provider.extra_run_args

    cmd = ["podman", "run", "--rm", *podman_userns_args(), "-it"]
    if run_args:
        cmd.extend(run_args)
    cmd.extend(["-v", f"{host_dir}:{provider.container_mount}:Z"])
    cmd.extend(["--name", container_name])
    cmd.append(image)
    cmd.extend(command)

    scope = f"for project: {project_id}" if project_id else "(host-wide)"
    banner_lines = [
        f"Authenticating {provider.label} {scope}",
        "",
        *provider.banner_hint.splitlines(),
        "",
        f"$ {' '.join(map(str, cmd))}",
        "",
    ]

    return AuthSession(
        provider=provider,
        project_id=project_id,
        container_name=container_name,
        argv=cmd,
        banner="\n".join(banner_lines),
        auth_dir=host_dir,
        mounts_dir=mounts_dir,
        credential_set=credential_set,
        expose_token=expose_token,
        _tmpdir=tmpdir,
    )

api_key_command(cfg)

Build a bash command that prompts for an API key and writes it to a config file.

Source code in src/terok_executor/credentials/auth.py
def api_key_command(cfg: AuthKeyConfig) -> list[str]:
    """Build a bash command that prompts for an API key and writes it to a config file."""
    config_dir = cfg.config_path.rsplit("/", 1)[0]
    parts = [
        f"echo 'Enter your {cfg.label} API key (get one at {cfg.key_url}):'",
        f"read -r -p '{cfg.env_var}=' api_key",
        f"mkdir -p {config_dir}",
        f"printf '{cfg.printf_template}\\n' \"$api_key\" > {cfg.config_path}",
        "echo",
        f"echo 'API key saved to {cfg.config_path}'",
        f"echo 'You can now use {cfg.tool_name} in task containers.'",
    ]
    return ["bash", "-c", " && ".join(parts)]