Skip to content

[bug] teamai init cannot run unattended: provider login blocks for minutes when there is no terminal #711

Description

@SaulMoro

Description

teamai init <repo> --role <id> --force is documented as "a fully non-interactive init (suitable for CI/CD or AI agents)" (docs/usage-guide.md). It is not. When no credential is present and stdin is not a terminal, init spawns an interactive provider login (gh auth login --web with stdio: 'inherit') and waits for the device flow to hit its deadline, about five minutes. It then exits 1 with the provider's error. The output never says which credential was missing.

The prompt layer already handles this case. askQuestion throws without a TTY and askConfirmation returns its default (src/utils/prompt.ts). The gap is the second way init talks to the user, a child process that takes over the terminal:

init
  provider.authenticate()
    github   ghAuthLogin()        spawns `gh auth login --web`, stdio inherit   blocks until the device-flow deadline
    tgit     gfAuthLogin()        spawns `gf auth login`, stdio inherit         blocks (documented in e2e.test.ts:704)
    cnb      cnbLogin()           spawns `cnb login`, stdio inherit             blocks
    gitcode  askQuestion(...)     throws without a TTY and names GITCODE_TOKEN   correct, the pattern to copy
    gitlab   ensureGitLabAvailable() throws and names GITLAB_TOKEN               correct
  provider.cloneRepo()
    git clone                     GIT_TERMINAL_PROMPT is not set, so a credential helper can prompt (GUI keychain dialog on macOS)

Who this affects:

  • The e2e suite. src/__tests__/e2e/e2e.test.ts:704-708 describes gf auth login as "an interactive (inheritStdio) login that no amount of stdin piping can satisfy" and skips the init test for every provider except GitHub. Each PR verification script rebuilds the same shims by hand (</dev/null, GIT_TERMINAL_PROMPT=0, GIT_CONFIG_NOSYSTEM=1, a token in the environment).
  • Agents in a sandbox without a terminal (CI jobs, Claude Code or Codex cloud runs). teamai init sits for five minutes and dies with failed to authenticate via web browser: context deadline exceeded. Nothing in the log points at GITHUB_TOKEN.
  • Agents that allocate a pseudo-TTY. process.stdin.isTTY is true, nobody types, and askQuestion waits forever. The CLI has no explicit way to declare an unattended run. CI, TEAMAI_NONINTERACTIVE, --non-interactive and --yes are all unrecognised today.

#591 fixed the same class of bug for teamai remove.

Reproduction

Deterministic, about eight seconds, no network. A fake gh on PATH reports no session, and its auth login behaves like the real device flow when nobody opens the browser.

  1. Create bin/gh and make it executable:

    #!/bin/bash
    case "$1 $2" in
      "auth status") exit 1 ;;
      "auth login")  echo "! First copy your one-time code: XXXX-XXXX"; sleep 300; exit 1 ;;
      "api user")    exit 1 ;;
      *) exit 0 ;;
    esac
  2. Run init with an isolated HOME, no token, and stdin closed:

    H=$(mktemp -d); mkdir -p "$H/.claude"
    env -i HOME="$H" PATH="$PWD/bin:/usr/bin:/bin:$(dirname "$(command -v node)")" FORCE_COLOR=0 \
      node dist/index.js init https://github.com/acme/team --scope user --role dev --agent claude --force </dev/null
  3. The process is still running after eight seconds. With the real gh it exits 1 after about five minutes with the same lines, which is what happened twice while verifying feat(skill): serve builtin skill content from the CLI, deploy a discovery stub #699 on 2026-09-21.

Expected. With no terminal and no credential, init exits 1 at once and names the credential. A throwaway patch that checks process.stdin.isTTY before ghAuthLogin() gives exactly that against the same reproduction:

ℹ Not logged in — starting authentication
✖ Authentication failed: GitHub authentication unavailable without a terminal. Export GITHUB_TOKEN (or GH_TOKEN) with repo scope, or run `gh auth login` in an interactive shell first.
exit=1 after 0s

Environment

  • OS: macOS 26.5 (Darwin 25.5.0)
  • Node.js: v24.21.0
  • teamai: 0.22.0, built from main at 2c0ae96
  • Provider: GitHub (the tgit and cnb login paths have the same shape)
  • AI tool(s): Claude Code

Logs

Output of the reproduction on main, killed after eight seconds
ℹ Initializing teamai...
ℹ Scope: user
ℹ   config    → <HOME>/.teamai/config.yaml
ℹ   resources → <HOME>/.claude/skills, ...
- Checking authentication...
ℹ Not logged in — starting authentication
ℹ Starting GitHub authentication via gh CLI...
! First copy your one-time code: XXXX-XXXX
  Open this URL to continue in your web browser: https://github.com/login/device
<still running after 8s, killed>

Proposed fix

Three steps. The first one alone turns the reproduction green. The other two close the gaps it cannot see.

 ensureGhAuthenticated / gfAuthLogin / cnbLogin
   whoami succeeded? return the login
+  not interactive? throw "…unavailable without a terminal. Export <TOKEN_VAR>…"
   spawn the login with stdio inherit

 utils/prompt.ts
-  process.stdin.isTTY                (repeated at 12 call sites)
+  isInteractive() = stdin.isTTY && !CI && !TEAMAI_NONINTERACTIVE && !--non-interactive

 clone.ts / source.ts, every git spawn
+  env.GIT_TERMINAL_PROMPT = '0'      when not interactive

 src/__tests__/e2e/e2e.test.ts
-  PROVIDER_IS_GITHUB skip and the blank-line stdin "defense in depth"
+  run init for every provider with a token in the environment only
  1. Guard every subprocess login with the rule prompt.ts already applies. In ensureGhAuthenticated, gfAuthLogin and cnbLogin, throw when the run is not interactive and name the variable (GITHUB_TOKEN or GH_TOKEN, TGIT_TOKEN, the CNB token), the way providers/gitcode/index.ts:57-62 does. Check: the reproduction exits 1 in under a second and the message contains the variable name, once per provider with a fake gh, gf and cnb binary.
  2. Replace the scattered process.stdin.isTTY checks with one isInteractive() in utils/prompt.ts that also honours CI, TEAMAI_NONINTERACTIVE and a flag on init. When it is false, every prompt takes its default or fails naming the flag to pass (--role, --agent, the token variable). Check: run the reproduction inside script -q /dev/null with CI=1 and it still exits fast.
  3. Spawn git with GIT_TERMINAL_PROMPT=0 when not interactive, so a missing credential surfaces as fatal: could not read Username instead of a keychain dialog or a hang on a pseudo-TTY. Check: a clone of a private URL with no credential fails in under two seconds.

Then remove the workaround in e2e.test.ts:704-733 and update docs/usage-guide.md:173 with the token variable each provider needs for an unattended init.

Open question for the maintainer. --force means "overwrite existing config" today, and promptForSelfModeAgents already treats it as "do not prompt" (init.ts:670). Reusing it avoids a new flag. The cost is that an unattended run cannot both refuse to overwrite and refuse to prompt. A separate --non-interactive keeps the two meanings apart.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions