Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ library plus CoreFoundation/Security.
Run from the repo root:

```bash
zig build test --summary all # unit tests (~3s, 300 at last count)
zig build test --summary all # unit tests (~3s, 303 at last count)
zig build # debug binary → zig-out/bin/lcc
zig build -Doptimize=ReleaseFast # what PATH should be serving
zig build run -- list # run without installing
Expand Down Expand Up @@ -124,9 +124,26 @@ Do not "simplify" `build.zig`'s separate `test_mod`: reusing the executable's mo
code signature, so `build.zig` signs the installed binary to keep one "Always Allow"
valid across rebuilds. Removing or bypassing that (`-Dsign=none`) brings back a login
password prompt on every rebuild, from a process that blocks with no output.
- **"No token" and "the Keychain would not give it to me" are different answers.**
`oauth.readToken` separates `.missing` from `.unreadable`, and every caller has to keep
them apart — collapsing them back into `getToken() == null` compiles, reads tidier, and
tells a user whose token is right there to run `lcc auth` again: a browser round trip for
a Keychain dialog that only had to be answered. The refusal is the *likelier* of the two
on a machine that rebuilds lcc, because a renewed signing certificate changes the
designated requirement and macOS asks once more — and that prompt can be denied, escaped,
or missed behind a full-screen terminal. `keychain.describeLast` is what turns the
OSStatus into that sentence; leaving it uncalled is how the distinction quietly dies.
- **`src/keychain.zig` imports five narrow C headers on purpose.** The umbrella
`CoreFoundation.h` / `Security.h` do not translate on this SDK. Do not tidy them into one
import.
- **`start.zig`'s `bail` only exits when nobody is waiting on it.** It returns
`error{Failed}` under `opts.returns_to_caller`, which is what `lcc open`'s `n` sets: the
dashboard calls `start.run` in-process, so a `std.process.exit` in there takes the whole
dashboard down — every other session's row with it — over one bad answer. That is why
every call site reads `return bail(…)` and why `cancel` has the same shape. A new call
site written as a bare `bail(…)` is a compile error rather than a silent fall-through,
but only because the returned error value cannot be discarded; do not "fix" that by
ignoring it.
- **A hook event that reports no `permission_mode` must not clear the one already known.**
Only some events carry it — `Notification` does not (see the test in `watch_hooks.zig`).
`watch_session.setPlan` is guarded on `permission_mode.len > 0` for that reason, and
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ Failures come back in the same shape, on stdout, with exit code 1:
{ "error": { "code": "issue_not_found", "message": "No issue PE-999 in Linear." } }
```

Codes: `usage`, `not_authenticated`, `auth_failed`, `bad_identifier`, `issue_not_found`, `linear_failed`, `worktree_path_exists`, `git_failed`, `bad_repo`, `plan_not_found`, `plan_unreadable`, `repo_unconfirmed` — the last one is the picker above in a mode with nobody to ask: pass `--repo <path>`, or run it once interactively and the answer is remembered. Progress lines and the human-readable error go to stderr, so stdout holds nothing but the payload — including git's own output, which is captured rather than inherited in this mode.
Codes: `usage`, `not_authenticated`, `keychain_unreadable`, `auth_failed`, `bad_identifier`, `issue_not_found`, `linear_failed`, `worktree_path_exists`, `git_failed`, `bad_repo`, `plan_not_found`, `plan_unreadable`, `repo_unconfirmed` — the last one is the picker above in a mode with nobody to ask: pass `--repo <path>`, or run it once interactively and the answer is remembered. Progress lines and the human-readable error go to stderr, so stdout holds nothing but the payload — including git's own output, which is captured rather than inherited in this mode.

### `lcc issue`

Expand Down Expand Up @@ -521,7 +521,7 @@ Some details that are easy to get wrong, and are decided here rather than left t

`gh` is optional throughout: without it rule 4 falls back to the commit-distance contest, which needs no network, no auth and no remote — the absence costs one note.

Failures take the same shape as `lcc start --json` — JSON on stdout, the human line on stderr, exit 1. Codes shared by every subcommand: `usage`, `not_authenticated`, `auth_failed`, `bad_identifier`, `issue_not_found`, `linear_failed`. `comment` adds `body_not_found`, `body_unreadable`, `body_empty`, `body_too_large`.
Failures take the same shape as `lcc start --json` — JSON on stdout, the human line on stderr, exit 1. Codes shared by every subcommand: `usage`, `not_authenticated`, `keychain_unreadable`, `auth_failed`, `bad_identifier`, `issue_not_found`, `linear_failed`. `comment` adds `body_not_found`, `body_unreadable`, `body_empty`, `body_too_large`.

### `lcc open`

Expand Down Expand Up @@ -805,6 +805,16 @@ The designated requirement becomes `identifier lcc and … certificate leaf[subj

A self-signed certificate works and never expires on someone else's schedule (Keychain Access → Certificate Assistant → *Create a Certificate*, type *Code Signing*); name it `lcc-dev` and it is preferred automatically. An Apple Development certificate is equally fine, with the caveat that it expires — the requirement changes with the certificate, so the first run after a renewal asks once more.

That one prompt is answerable with *Deny*, or with Escape, or it can be missed entirely behind a full-screen terminal — and a refused read is not a missing login. It is reported as one:

```
✗ The Linear token is in the Keychain, but reading it failed: authorization failed
(keychain prompt denied?) (OSStatus -25293). Answer `Always Allow` if macOS asks
again; `lcc auth` re-stores it if it stays refused.
```

`lcc auth --status` says the same thing rather than "Not authenticated", and `--json` calls it `keychain_unreadable` rather than `not_authenticated`. Re-running `lcc auth` does fix it, by storing the item afresh under the current signature — but it is a full browser round trip for a dialog that only had to be answered, which is why the two are told apart.

### MCP servers

Symlinks cannot solve the same problem for MCP servers, because they are not in the repository. `claude mcp add` without `-s user` stores a server under `projects["<absolute cwd>"].mcpServers` in `~/.claude.json` — the key *is* the directory. A worktree is a different directory, so it starts with none of them: the checkout where `linear-server` was added is the only place it exists.
Expand Down
18 changes: 15 additions & 3 deletions src/commands/auth.zig
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,17 @@ fn withPersonalToken(app: app_mod.App, pat: []const u8) !void {
}

fn status(app: app_mod.App) !void {
const stored = oauth.getToken(app.gpa) orelse {
app.ui.warn("Not authenticated. Run `lcc auth`.", .{});
return;
const stored = switch (oauth.readToken(app.gpa)) {
.token => |t| t,
.missing => {
app.ui.warn("Not authenticated. Run `lcc auth`.", .{});
return;
},
.unreadable => |why| {
app.ui.fail("The Linear token is stored, but reading it failed: {s}", .{why});
app.ui.hint("Answer `Always Allow` if macOS asks again; `lcc auth` re-stores it if it stays refused.", .{});
std.process.exit(1);
},
};
const cfg = try config.load(app.gpa, app.io, app.environ);

Expand Down Expand Up @@ -123,6 +131,10 @@ fn reportAuthError(app: app_mod.App, err: anyerror) noreturn {
error.CallbackTimedOut => app.ui.fail("Timed out waiting for browser authorization", .{}),
error.AuthorizationDenied => app.ui.fail("Linear returned error: {s}", .{detail}),
error.NotAuthenticated => app.ui.fail("Not authenticated. Run `lcc auth` first.", .{}),
error.KeychainUnreadable => app.ui.fail(
"The Linear token is stored, but reading it failed: {s}",
.{detail},
),
error.TokenExpiredNoRefresh => app.ui.fail(
"Access token expired and no refresh token available. Run `lcc auth` again.",
.{},
Expand Down
13 changes: 11 additions & 2 deletions src/commands/issue.zig
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,17 @@ pub fn run(app: app_mod.App, opts: Opts) !void {
fn authorize(app: app_mod.App, opts: Opts) !oauth.Token {
app.ui.hint("Reading the Linear token from the Keychain...", .{});
app.ui.flush();
if (oauth.getToken(app.gpa) == null) {
bail(app, opts.json, "not_authenticated", "Not authenticated. Run `lcc auth` first.", .{});
switch (oauth.readToken(app.gpa)) {
.token => {},
.missing => bail(app, opts.json, "not_authenticated", "Not authenticated. Run `lcc auth` first.", .{}),
.unreadable => |why| bail(
app,
opts.json,
"keychain_unreadable",
"The Linear token is in the Keychain, but reading it failed: {s}. " ++
"Answer `Always Allow` if macOS asks again; `lcc auth` re-stores it if it stays refused.",
.{why},
),
}

const cfg = try config.load(app.gpa, app.io, app.environ);
Expand Down
15 changes: 12 additions & 3 deletions src/commands/list.zig
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,18 @@ fn issueTask(app: app_mod.App, refs: []const linear.Ref, out: *IssueColumn) void
out.note = "Linear column skipped — could not read the config.";
return;
};
if (oauth.getToken(app.gpa) == null) {
out.note = "Linear column needs `lcc auth`.";
return;
switch (oauth.readToken(app.gpa)) {
.token => {},
.missing => {
out.note = "Linear column needs `lcc auth`.";
return;
},
.unreadable => |why| {
out.note = std.fmt.allocPrint(app.gpa, "Linear column skipped — the Keychain refused the token: {s}", .{
why,
}) catch "Linear column skipped — the Keychain refused the token.";
return;
},
}
const token = oauth.ensureFreshToken(app.gpa, app.io, cfg.clientId) catch {
out.note = "Could not refresh the Linear token — run `lcc auth`.";
Expand Down
Loading
Loading