Skip to content

feat(cli): sign in by approving on your iPhone with auth --email - #326

Merged
AnnatarHe merged 3 commits into
mainfrom
claude/amazing-carson-xwst3d
Oct 13, 2026
Merged

AnnatarHe merged 3 commits into
mainfrom
claude/amazing-carson-xwst3d

Conversation

@AnnatarHe

@AnnatarHe AnnatarHe commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

shelltime auth --email you@example.com signs in without a browser. The user approves the request in the ShellTime iOS app, which makes this flow usable from an agent. The same flag works on shelltime init.

$ shelltime auth --email you@example.com
Sent a sign-in request to the ShellTime app on your iPhone.
Code: 41. Check that your iPhone shows the same code, then approve it.
Waiting for approval (expires in 5m0s)...
 ✅ You are ready to go!
 ✅ config file created
  • New model.AppApprovalService. It makes unauthenticated POSTs to /api/v1/auth/app-approval/{request,poll}.
    • The request sends the hostname, OS and OS version, the same way handshake init does.
    • Calls go through NewHTTPClient, so the proxy setting applies.
    • A refusal comes back as *AppApprovalError, which carries the HTTP status and the server's message.
  • waitForAppApproval polls at the server's interval and stops when the context is cancelled or the request expires.
    • It retries network errors and 5xx/429 until the request expires.
    • Any other refusal, a denial or an expiry returns ErrAuthNotApproved. main maps that to exit code 1, as it does for doctor, so agents can tell the sign-in didn't complete.
  • Output is plain lines. The spinner shows only when stdout is a terminal.
  • Existing paths are unchanged:
    • The approved token is saved through the existing config write.
    • Plain shelltime auth keeps the browser handshake. The Mac app's setup scrapes that output.
    • --token together with --email is rejected.
  • README documents the flag.

Needs shelltime/server#540. On a server without it, or for an account with no eligible iOS app, the CLI prints the server's message, suggests plain shelltime auth, and exits 1.

Test plan

  • model/app_approval_test.go (httptest): request path, payload, user agent, no Authorization header; poll path and payload; the error envelope, a non-JSON error and an unreachable host
  • commands/auth_app_approval_test.go (fake service, injected sleep):
    • pending → approved, polling at the server's interval
    • denied, expired, gives up after expiry
    • retries transient errors, stops on a 4xx, cancellation
    • intro output, a refused request, --token together with --email
  • go vet ./... and go test ./... pass
  • End to end against a local server with this branch and shelltime/server#540:
    • approved through the iOS GraphQL mutation → token written to config.yaml and accepted as Authorization: CLI <token>
    • denied → red line, exit 1

🤖 Generated with Claude Code

https://claude.ai/code/session_013cY2Zv1kE3eQV5b9c5sQJA


Generated by Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

`shelltime auth --email you@example.com` (also on `init`) asks the
server to push a sign-in request to the ShellTime iOS app of that
account, prints the 2-digit code the iPhone shows, and polls until the
request is approved, denied or expired. On approval it saves the new
token like the browser flow does. It needs no browser, so an agent
can run it and relay the code to the user.

Output is plain lines (the spinner only shows on a terminal). Network
and server errors are retried until the request expires; a request the
API refuses, a denial or an expiry print why in red and exit 1
(ErrAuthNotApproved). `--token` and `--email` together are an error.
Plain `shelltime auth` keeps the browser handshake untouched, which the
Mac app's setup relies on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cY2Zv1kE3eQV5b9c5sQJA
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@claude

claude Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

⏱️ shelltime track performance

✅ No significant change in shelltime track latency

Comparing main @ 639e900 → #326 @ 168c246. Each scenario execs the binary the way the shell hooks do; values are the median of 10 interleaved rounds. Lower is better.

Scenario base head Δ p
Startup/floor 256 µs ±28% 268 µs ±17% ~ 0.436 A/A
Startup/version 3.98 ms ±13% 4.19 ms ±54% ~ 0.529 ✅
Track/daemon/pre 4.31 ms ±15% 4.78 ms ±12% ~ 0.123 ✅
Track/daemon/post 4.32 ms ±19% 4.52 ms ±20% ~ 0.436 ✅
Track/direct/pre 4.80 ms ±47% 4.28 ms ±18% ~ 0.218 ✅
Track/direct/post 8.85 ms ±20% 8.99 ms ±18% ~ 0.739 ✅
Track/direct/post-sync 10.14 ms ±14% 10.99 ms ±13% ~ 0.280 ✅

Binary size: 19.1 MiB → 19.1 MiB (+0.1%).

CPU, tail latency and memory

Per exec, base → head (Δ when significant).

Scenario p95 wall user CPU sys CPU peak RSS
Startup/floor 330 µs → 340 µs 207 µs → 218 µs 12 µs → 12 µs 9.1 MiB → 9.1 MiB
Startup/version 5.09 ms → 5.16 ms 2.32 ms → 2.37 ms 1.65 ms → 1.84 ms 15.0 MiB → 15.3 MiB (+2.34%)
Track/daemon/pre 5.51 ms → 5.95 ms 2.52 ms → 2.53 ms 1.88 ms → 2.14 ms (+14.01%) 15.8 MiB → 16.2 MiB (+2.44%)
Track/daemon/post 5.26 ms → 5.80 ms 2.17 ms → 2.56 ms (+18.17%) 2.17 ms → 1.89 ms 15.7 MiB → 16.1 MiB (+2.75%)
Track/direct/pre 6.30 ms → 5.27 ms 2.47 ms → 2.15 ms 2.40 ms → 2.02 ms 15.7 MiB → 16.1 MiB (+2.26%)
Track/direct/post 12.16 ms → 11.12 ms 5.32 ms → 5.59 ms 3.15 ms → 3.22 ms 16.9 MiB → 17.3 MiB (+2.51%)
Track/direct/post-sync 11.86 ms → 13.38 ms 6.98 ms → 7.06 ms 3.71 ms → 3.93 ms 18.7 MiB → 19.1 MiB (+1.84%)
benchstat
goos: linux
goarch: amd64
pkg: github.com/malamtime/cli/perf
cpu: AMD EPYC
                         │     base     │                head                 │
                         │    sec/op    │    sec/op     vs base               │
Startup/floor-2            255.9µ ± 28%   268.3µ ± 17%       ~ (p=0.436 n=10)
Startup/version-2          3.984m ± 13%   4.193m ± 54%       ~ (p=0.529 n=10)
Track/daemon/pre-2         4.308m ± 15%   4.780m ± 12%       ~ (p=0.123 n=10)
Track/daemon/post-2        4.320m ± 19%   4.522m ± 20%       ~ (p=0.436 n=10)
Track/direct/pre-2         4.802m ± 47%   4.283m ± 18%       ~ (p=0.218 n=10)
Track/direct/post-2        8.849m ± 20%   8.989m ± 18%       ~ (p=0.739 n=10)
Track/direct/post-sync-2   10.14m ± 14%   10.99m ± 13%       ~ (p=0.280 n=10)
geomean                    3.622m         3.743m        +3.34%

                         │     base     │                head                 │
                         │  p50-sec/op  │  p50-sec/op   vs base               │
Startup/floor-2            235.1µ ± 25%   248.5µ ± 12%       ~ (p=0.529 n=10)
Startup/version-2          3.850m ± 16%   4.067m ± 30%       ~ (p=0.436 n=10)
Track/daemon/pre-2         4.172m ± 16%   4.714m ± 11%       ~ (p=0.123 n=10)
Track/daemon/post-2        4.144m ± 10%   4.356m ± 11%       ~ (p=0.143 n=10)
Track/direct/pre-2         4.397m ± 17%   4.020m ± 22%       ~ (p=0.579 n=10)
Track/direct/post-2        7.972m ±  7%   8.510m ± 12%       ~ (p=0.190 n=10)
Track/direct/post-sync-2   10.03m ± 13%   10.66m ±  8%       ~ (p=0.436 n=10)
geomean                    3.423m         3.582m        +4.64%

                         │     base      │                 head                 │
                         │  p95-sec/op   │  p95-sec/op    vs base               │
Startup/floor-2            329.8µ ±  52%   340.3µ ±  50%       ~ (p=0.971 n=10)
Startup/version-2          5.089m ±  13%   5.155m ± 151%       ~ (p=0.971 n=10)
Track/daemon/pre-2         5.511m ±  16%   5.949m ±  16%       ~ (p=0.315 n=10)
Track/daemon/post-2        5.265m ± 117%   5.801m ±  19%       ~ (p=0.739 n=10)
Track/direct/pre-2         6.303m ± 178%   5.266m ±  35%       ~ (p=0.247 n=10)
Track/direct/post-2        12.16m ±  97%   11.12m ± 100%       ~ (p=0.853 n=10)
Track/direct/post-sync-2   11.86m ±  59%   13.38m ±  73%       ~ (p=0.353 n=10)
geomean                    4.610m          4.657m         +1.00%

                         │     base     │                head                 │
                         │  peak-rss-B  │  peak-rss-B   vs base               │
Startup/floor-2            9.135Mi ± 2%   9.130Mi ± 1%       ~ (p=0.565 n=10)
Startup/version-2          14.96Mi ± 0%   15.31Mi ± 0%  +2.34% (p=0.000 n=10)
Track/daemon/pre-2         15.79Mi ± 0%   16.17Mi ± 0%  +2.44% (p=0.000 n=10)
Track/daemon/post-2        15.69Mi ± 0%   16.12Mi ± 0%  +2.75% (p=0.000 n=10)
Track/direct/pre-2         15.70Mi ± 0%   16.05Mi ± 0%  +2.26% (p=0.000 n=10)
Track/direct/post-2        16.86Mi ± 0%   17.28Mi ± 0%  +2.51% (p=0.000 n=10)
Track/direct/post-sync-2   18.74Mi ± 0%   19.09Mi ± 0%  +1.84% (p=0.000 n=10)
geomean                    14.96Mi        15.26Mi       +2.01%

                         │     base     │                 head                 │
                         │  sys-sec/op  │  sys-sec/op   vs base                │
Startup/floor-2            12.36µ ± 52%   11.78µ ± 69%        ~ (p=0.796 n=10)
Startup/version-2          1.651m ± 16%   1.840m ± 40%        ~ (p=0.579 n=10)
Track/daemon/pre-2         1.880m ± 17%   2.143m ± 15%  +14.01% (p=0.015 n=10)
Track/daemon/post-2        2.167m ± 19%   1.893m ± 29%        ~ (p=0.436 n=10)
Track/direct/pre-2         2.404m ± 26%   2.024m ± 29%        ~ (p=0.280 n=10)
Track/direct/post-2        3.148m ± 18%   3.216m ± 35%        ~ (p=0.529 n=10)
Track/direct/post-sync-2   3.706m ± 23%   3.930m ± 27%        ~ (p=0.190 n=10)
geomean                    1.128m         1.123m         -0.51%

                         │     base     │                 head                 │
                         │ user-sec/op  │ user-sec/op   vs base                │
Startup/floor-2            207.0µ ± 19%   217.5µ ±  9%        ~ (p=0.353 n=10)
Startup/version-2          2.322m ± 12%   2.368m ± 19%        ~ (p=0.796 n=10)
Track/daemon/pre-2         2.519m ± 19%   2.534m ± 18%        ~ (p=0.631 n=10)
Track/daemon/post-2        2.169m ± 16%   2.563m ± 19%  +18.17% (p=0.007 n=10)
Track/direct/pre-2         2.467m ± 24%   2.149m ± 22%        ~ (p=0.684 n=10)
Track/direct/post-2        5.319m ±  9%   5.586m ± 16%        ~ (p=0.247 n=10)
Track/direct/post-sync-2   6.982m ± 13%   7.061m ±  7%        ~ (p=0.529 n=10)
geomean                    2.189m         2.241m         +2.38%

Δ compares medians; ~ means no significant difference (Mann-Whitney U, p ≥ 0.05). A scenario is flagged when it is more than 10% and more than 250µs slower with p < 0.05. Startup/floor runs true, not shelltime: it is an A/A check of runner noise. Runner: linux/amd64, AMD EPYC. go1.27.1, 10 rounds × 100 execs per scenario and binary.

@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.08772% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
commands/auth.go 84.50% 11 Missing ⚠️
model/app_approval.go 86.04% 6 Missing ⚠️
Flag Coverage Δ
unittests 86.23% <85.08%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
commands/init.go 0.00% <ø> (ø)
model/app_approval.go 86.04% <86.04%> (ø)
commands/auth.go 62.09% <84.50%> (+62.09%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor Author

/claude review


Generated by Claude Code

Codecov's patch check failed at 65.8% against its 80% target: the
command path itself (ApplyTokenByAppApproval, the config write after
an approval, the sleep and the indicator) had no tests. Make the poll
sleep swappable like the service and cover the approved, denied and
unreachable paths, `shelltime auth --email` writing the token to
config.yaml in a temporary HOME, sleepContext and the piped indicator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cY2Zv1kE3eQV5b9c5sQJA

Copy link
Copy Markdown
Contributor Author

@claude review


Generated by Claude Code

@claude

claude Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @AnnatarHe's task in 22s —— View job


Code review: auth --email (iOS app approval)

No blocking issues. I read the diff of commands/auth.go, model/app_approval.go, commands/init.go and cmd/cli/main.go. I did not run the tests; I relied on your test plan and on CI. A few minor points:

  • Spinner state in ApplyTokenByAppApproval (commands/auth.go). The spinner stops before the red error line is printed, which is correct. The intro text goes to os.Stdout directly, while the status lines go through color. This is fine, but the two are not routed through one writer. If you want to assert on the output of the full path, that is why it is awkward.
  • Poll 4xx handling (waitForAppApproval). Every 4xx except 429 ends the wait. That includes 408 (request timeout) and 425, which would normally be transient. A reverse proxy in front of the API could return 408. Consider also retrying 408.
  • Poll interval from the server. max(ticket.Interval, 1) guards against zero. There is no upper clamp, so a bad interval such as 3600 would make the CLI wait an hour before the first poll. This is low risk, since the server is trusted.
  • init --email. commandInit calls commandAuth(c) and shares emailFlag, so the flag works. If the sign-in fails, init stops before installing hooks and the daemon. That seems right.
  • Coverage. Codecov reports 65.8% patch coverage against the 80% target. The gaps are mostly in ApplyTokenByAppApproval (the orchestration around startWaitingIndicator and the output) and a few error branches in model/app_approval.go (the os.Hostname/OS fallbacks and the marshal and read errors). A test of ApplyTokenByAppApproval with the fake service would cover most of the commands/auth.go gap.
  • -e alias. It doesn't collide with any other alias in cmd or commands.

The design looks good. Plain auth is unchanged, the request is unauthenticated, the proxy applies through NewHTTPClient, and ErrAuthNotApproved is wrapped with %w, so the exit code 1 mapping in main works.

From the review: a 408 or 425 from a proxy in front of the API is
transient like a 5xx or 429, so keep polling instead of ending the
sign-in; and clamp the server's poll interval to 1-30 s so a bad value
can't stall the first poll for an hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cY2Zv1kE3eQV5b9c5sQJA

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

  • 408/425: treated as retryable now, alongside 429 and 5xx, so a proxy timeout doesn't end the sign-in. Fixed in 168c246.
  • Interval: clamped to 1–30 s, also in 168c246.
  • Coverage: 31fcf30 adds tests for ApplyTokenByAppApproval (approved, denied, unreachable). It also runs shelltime auth --email in a temp HOME and checks the token lands in config.yaml, plus sleepContext and the piped indicator. New code in commands/auth.go is at or near 100%.
  • Output routing: stays as is. The intro goes through an io.Writer and is tested directly. The colored status lines match the rest of auth.

Generated by Claude Code

@AnnatarHe
AnnatarHe merged commit 34a67d9 into main Oct 13, 2026
5 checks passed
@AnnatarHe
AnnatarHe deleted the claude/amazing-carson-xwst3d branch October 13, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants