From 593c858d67b65faa71fd173066035af734150945 Mon Sep 17 00:00:00 2001 From: Taylor Gagne Date: Wed, 14 Oct 2026 19:17:19 -0700 Subject: [PATCH 1/4] feat(profiles): one profile per Linear workspace A Linear API key belongs to one workspace, so a project in a second workspace (talora-health) could never be read with the shared key. --profile or LINEAR_PROFILE now keeps config, cache, queue and uploads under ~/.linear-cli/profiles// and resolves the key from the profile config, then Keychain linear-api-key with account . LINEAR_API_KEY / LINEAR_TEAM_ID are not read under a profile. With neither set, the cwd's agents project picks the profile through the same `agents projects view` call the project auto-scope already makes, now done once in main() and reused. setup --keychain keeps the key out of config.json. The projects/initiatives shorthand now survives a leading --team or --profile. Version 0.32.0. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 19 ++++ README.md | 15 ++- linear | 237 +++++++++++++++++++++++++++++++++++++----------- test_linear.py | 242 ++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 459 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e51ce..275f721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.32.0] - 2026-10-14 + +### Added + +- Profiles, one per Linear workspace: `--profile ` or `LINEAR_PROFILE` + keeps config, cache, queue and uploads under + `~/.linear-cli/profiles//`. The key resolves from the profile config, + then Keychain `linear-api-key` with account ``; `LINEAR_API_KEY` and + `LINEAR_TEAM_ID` are not read under a profile. With neither flag nor env + set, the `linear.profile` of the cwd's `agents projects` project is used, + from the same `agents projects view` call the project auto-scope makes. +- `setup --keychain` stores the API key in the macOS Keychain instead of + `config.json` (account = the profile name, or `default`). + +### Fixed + +- `projects ` and `initiatives ` shorthand works after a leading + `--team` or `--profile` flag. + ## [0.31.0] - 2026-10-12 ### Fixed diff --git a/README.md b/README.md index 37548a9..05ca8f9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,20 @@ linear setup --api-key lin_api_... --agent claude Config is written to `~/.linear-cli/config.json`. If you already have `~/.agents/linear.json` from an earlier setup, linear-cli auto-migrates it on first run. -> **Security:** treat the API key like a password. **Do not paste it into chat with an LLM in plaintext.** Pass it from your shell env (`linear setup --api-key "$LINEAR_API_KEY"`), or let linear-cli read it from the macOS Keychain. Resolution order is: config file → `LINEAR_API_KEY` env → Keychain service `linear-api-key`. +> **Security:** treat the API key like a password. **Do not paste it into chat with an LLM in plaintext.** Pass it from your shell env (`linear setup --api-key "$LINEAR_API_KEY"`), or let linear-cli read it from the macOS Keychain. Resolution order is: config file → `LINEAR_API_KEY` env → Keychain service `linear-api-key`. `linear setup --keychain` stores the key in the Keychain (service `linear-api-key`, account `default`) and leaves it out of `config.json`. + +### Profiles (several workspaces) + +A Linear API key belongs to one workspace. To work in a second one, give it a profile: + +```bash +linear --profile personal setup --api-key "$KEY" --keychain # key -> Keychain linear-api-key / account personal +linear --profile personal tasks # or: LINEAR_PROFILE=personal linear tasks +``` + +A profile keeps its own config, board cache, close queue and downloaded images under `~/.linear-cli/profiles//`. Names are lowercase letters, digits, `.`, `_` and `-` (up to 64, starting with a letter or digit). `--profile` wins over `LINEAR_PROFILE`. With neither set, linear-cli uses the `linear.profile` of the `agents projects` project that owns the current directory, so an agent launched in that folder reaches that project's workspace; no `agents` CLI or no profile on the project means the default. + +Under a profile the key comes from the profile's `config.json`, then Keychain service `linear-api-key` with account ``; the team id from `teamId` in that config, then Keychain `linear-team-id` / ``. `LINEAR_API_KEY` and `LINEAR_TEAM_ID` are ignored there because they belong to the default workspace. With no profile, nothing changes. ### Fleet setup for agents diff --git a/linear b/linear index 8b3508d..1de7a43 100755 --- a/linear +++ b/linear @@ -28,6 +28,8 @@ Commands: Config: ~/.linear-cli/config.json Cache (team board for --similar, optional embedding vectors): ~/.linear-cli/cache.sqlite +Profiles (one per Linear workspace): --profile or LINEAR_PROFILE keeps +config, cache, queue and uploads under ~/.linear-cli/profiles// """ from __future__ import annotations @@ -73,7 +75,7 @@ _LOCK_CONTENDED_ERRNOS = tuple( if e is not None ) -__version__ = "0.31.0" +__version__ = "0.32.0" # Sentinel for "flag not supplied" — distinct from None, which means an explicit # clear (e.g. `--project none`). Lets update pre-resolve a field once and pass @@ -103,6 +105,16 @@ MAX_QUEUE_ATTEMPTS = 10 QUEUE_BACKOFF_BASE_SECONDS = 2 QUEUE_BACKOFF_MAX_SECONDS = 300 # 5 minutes +# A profile is one Linear workspace: Linear API keys are per workspace, so a +# second workspace needs its own key, team, states cache and board cache. None +# is the default profile, which keeps every path and credential lookup as it +# was before profiles existed. +PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +ACTIVE_PROFILE: str | None = None +# Where ACTIVE_PROFILE came from ("--profile", "LINEAR_PROFILE", or the cwd's +# agents project), so a missing-key error can say why that profile is active. +PROFILE_SOURCE: str | None = None + # --------------------------------------------------------------------------- # Config @@ -139,11 +151,39 @@ def write_config_file(cfg: dict) -> None: CONFIG_PATH.chmod(CONFIG_FILE_MODE) +def activate_profile(name: str, source: str) -> None: + """Point every per-workspace path at ~/.linear-cli/profiles//. + + Runs in main() before load_config(), so config, the board cache, the close + queue and downloaded images of one workspace never mix with another's. + Raises ValueError for a name that is not a profile slug (it becomes a + directory name, so `..` or `/` must never get through). + """ + global ACTIVE_PROFILE, PROFILE_SOURCE, CONFIG_PATH, QUEUE_DIR, CACHE_DB_PATH, UPLOADS_DIR + # A project YAML can hand over any type (`profile: 7`), not just a string. + if not isinstance(name, str) or not PROFILE_RE.match(name): + raise ValueError(f"invalid profile name {name!r} (from {source}): use lowercase " + "letters, digits, '.', '_' or '-', starting with a letter or digit") + root = Path.home() / ".linear-cli" / "profiles" / name + ACTIVE_PROFILE = name + PROFILE_SOURCE = source + CONFIG_PATH = root / "config.json" + QUEUE_DIR = root / "queue" + CACHE_DB_PATH = root / "cache.sqlite" + UPLOADS_DIR = root / "uploads" + + +def setup_command() -> str: + """The setup invocation for the active profile, for error messages.""" + return f"linear --profile {ACTIVE_PROFILE} setup" if ACTIVE_PROFILE else "linear setup" + + def load_config() -> dict: if CONFIG_PATH.exists(): _harden_config_perms() return json.loads(CONFIG_PATH.read_text()) - if LEGACY_CONFIG_PATH.exists(): + # The legacy file is the default workspace's config; a profile starts empty. + if ACTIVE_PROFILE is None and LEGACY_CONFIG_PATH.exists(): cfg = json.loads(LEGACY_CONFIG_PATH.read_text()) write_config_file(cfg) print(f"Migrated config: {LEGACY_CONFIG_PATH} -> {CONFIG_PATH}", file=sys.stderr) @@ -173,38 +213,43 @@ def save_config(cfg: dict) -> bool: return False -def get_api_key(cfg: dict) -> str: - """Resolve API key: config > env > Keychain.""" - key = cfg.get("apiKey") or os.environ.get("LINEAR_API_KEY") - if key: - return key +def _keychain_secret(service: str) -> str: + """Read a generic password from the macOS Keychain ("" when absent). + + Under a profile the item is the one whose account is the profile name; the + default profile keeps the account-less lookup it has always used. + """ + argv = ["security", "find-generic-password", "-s", service] + if ACTIVE_PROFILE: + argv += ["-a", ACTIVE_PROFILE] + argv.append("-w") try: - result = subprocess.run( - ["security", "find-generic-password", "-s", "linear-api-key", "-w"], - capture_output=True, text=True, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() + result = subprocess.run(argv, capture_output=True, text=True) except FileNotFoundError: - pass + return "" + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() return "" +def get_api_key(cfg: dict) -> str: + """Resolve API key: config > env > Keychain. + + Under a profile: config > Keychain only. LINEAR_API_KEY is the default + workspace's key, and using it here would silently read the wrong workspace. + """ + if ACTIVE_PROFILE: + return cfg.get("apiKey") or _keychain_secret("linear-api-key") + return (cfg.get("apiKey") or os.environ.get("LINEAR_API_KEY") + or _keychain_secret("linear-api-key")) + + def get_team_id(cfg: dict) -> str: - """Resolve team ID: config > env > Keychain.""" - tid = cfg.get("teamId") or os.environ.get("LINEAR_TEAM_ID") - if tid: - return tid - try: - result = subprocess.run( - ["security", "find-generic-password", "-s", "linear-team-id", "-w"], - capture_output=True, text=True, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - except FileNotFoundError: - pass - return "" + """Resolve team ID: config > env > Keychain (env skipped under a profile).""" + if ACTIVE_PROFILE: + return cfg.get("teamId") or _keychain_secret("linear-team-id") + return (cfg.get("teamId") or os.environ.get("LINEAR_TEAM_ID") + or _keychain_secret("linear-team-id")) # --------------------------------------------------------------------------- @@ -1194,6 +1239,34 @@ def resolve_project_id(api_key: str, team_id: str, value: str, return match["id"] +# main() asks `agents` about the cwd once (to pick the workspace profile) and +# keeps the answer here, so the project auto-scope that follows in `tasks` / +# `create` reuses it instead of paying for a second subprocess. +_CWD_BINDING: dict | None = None + + +def query_cwd_binding(timeout: float = 3.0) -> dict: + """The cwd's `agents projects` Linear binding: {projectId, name, profile}, + each None when absent. Never raises; see resolve_cwd_project for why.""" + empty = {"projectId": None, "name": None, "profile": None} + try: + out = subprocess.run(["agents", "projects", "view", ".", "--json"], + capture_output=True, text=True, timeout=timeout) + except (OSError, ValueError, subprocess.SubprocessError): + return empty + if out.returncode != 0 or not out.stdout.strip(): + return empty + try: + data = json.loads(out.stdout) + except (ValueError, TypeError): + return empty + if not isinstance(data, dict): + return empty + lin = data.get("linear") or {} + return {"projectId": lin.get("projectId"), "name": lin.get("name"), + "profile": lin.get("profile")} + + def resolve_cwd_project(timeout: float = 3.0) -> tuple[str | None, str | None]: """Best-effort: which Linear project owns the current working directory? @@ -1213,24 +1286,12 @@ def resolve_cwd_project(timeout: float = 3.0) -> tuple[str | None, str | None]: One bounded subprocess call (~0.3s on an idle box): `agents projects view . --json` longest-matches the cwd and returns `{name, linear:{name, - projectId}, root}` in a single shot (an unmatched cwd returns those fields - as null with exit 0). + projectId, profile}, root}` in a single shot (an unmatched cwd returns + those fields as null with exit 0). When main() already asked, its answer is + reused and no subprocess runs. """ - try: - out = subprocess.run(["agents", "projects", "view", ".", "--json"], - capture_output=True, text=True, timeout=timeout) - except (OSError, ValueError, subprocess.SubprocessError): - return (None, None) - if out.returncode != 0 or not out.stdout.strip(): - return (None, None) - try: - data = json.loads(out.stdout) - except (ValueError, TypeError): - return (None, None) - if not isinstance(data, dict): - return (None, None) - lin = data.get("linear") or {} - return (lin.get("projectId"), lin.get("name")) + binding = _CWD_BINDING if _CWD_BINDING is not None else query_cwd_binding(timeout) + return (binding["projectId"], binding["name"]) def resolve_auto_project_id(api_key: str, team_id: str, value: str) -> str | None: @@ -2117,6 +2178,25 @@ def apply_embeddings_choice(cfg: dict, backend: str, model: str | None) -> str: return f"Embeddings ranker: {backend} ({chosen})" +def store_keychain_api_key(api_key: str) -> str: + """`setup --keychain`: save the key as Keychain item linear-api-key under the + profile's account ("default" for the default profile). Exits on failure, + since setup would otherwise finish with no key anywhere.""" + account = ACTIVE_PROFILE or "default" + try: + result = subprocess.run(["security", "add-generic-password", "-U", "-s", + "linear-api-key", "-a", account, "-w", api_key], + capture_output=True, text=True) + except FileNotFoundError: + print("Setup failed: --keychain needs the macOS `security` tool.", file=sys.stderr) + sys.exit(1) + if result.returncode != 0: + print(f"Setup failed: could not store the key in the Keychain: " + f"{result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + return f"API key: macOS Keychain (service linear-api-key, account {account})" + + def cmd_setup(args, cfg): if args.embeddings_model and not args.embeddings: print("--embeddings-model needs --embeddings ollama|gemini", file=sys.stderr) @@ -2138,7 +2218,12 @@ def cmd_setup(args, cfg): print("API key required.") sys.exit(1) - cfg["apiKey"] = api_key + # With --keychain the key never touches config.json, and an old apiKey there + # goes too: config wins over the Keychain, so it would shadow the new key. + if args.keychain: + cfg.pop("apiKey", None) + else: + cfg["apiKey"] = api_key data = gql(api_key, "{ teams { nodes { id name key } } }") if check_errors(data): @@ -2176,12 +2261,19 @@ def cmd_setup(args, cfg): cfg.pop("states", None) states = get_states(api_key, team["id"], cfg) + # Stored only after the teams query accepted the key, so a typo never lands. + if args.keychain: + key_note = store_keychain_api_key(api_key) + else: + key_note = f"API key: {CONFIG_PATH}" + # Here persistence IS the operation — a setup that didn't land is a failure, # not a warning, or the next command silently runs unconfigured. if not save_config(cfg): print(f"Setup failed: could not persist {CONFIG_PATH}", file=sys.stderr) sys.exit(1) print(f"\nConfigured: team={team['name']}, states={len(states)}") + print(key_note) if agent: print(f"Agent identity: {agent}") if embeddings_note: @@ -2608,7 +2700,9 @@ def _cache_db() -> sqlite3.Connection: modes as the config that holds the API key. """ CACHE_DB_PATH.parent.mkdir(mode=CONFIG_DIR_MODE, parents=True, exist_ok=True) - if not CACHE_DB_PATH.exists() and _LEGACY_EMBEDDINGS_DB_PATH.exists(): + # The pre-0.25 vector file belongs to the default workspace, never a profile. + if (ACTIVE_PROFILE is None and not CACHE_DB_PATH.exists() + and _LEGACY_EMBEDDINGS_DB_PATH.exists()): with contextlib.suppress(FileNotFoundError): # a parallel first run already moved it os.replace(_LEGACY_EMBEDDINGS_DB_PATH, CACHE_DB_PATH) CACHE_DB_PATH.touch(mode=CONFIG_FILE_MODE, exist_ok=True) @@ -6868,12 +6962,21 @@ def rewrite_group_argv(argv: list[str]) -> list[str]: name, not a verb) is shorthand for ` show `. Insert the implicit `show` so the detail view keeps working alongside the other subcommands.""" argv = list(argv) - if len(argv) < 2: + # Skip the global options (`--profile p projects Foo`) to find the group. + g = 0 + while g < len(argv): + if argv[g] in ("--team", "--profile"): + g += 2 + elif argv[g].startswith(("--team=", "--profile=")): + g += 1 + else: + break + if len(argv) < g + 2: return argv - verbs = {"projects": _PROJECTS_VERBS, "initiatives": _INITIATIVES_VERBS}.get(argv[0]) + verbs = {"projects": _PROJECTS_VERBS, "initiatives": _INITIATIVES_VERBS}.get(argv[g]) if not verbs: return argv - for i in range(1, len(argv)): + for i in range(g + 1, len(argv)): if not argv[i].startswith("-"): if argv[i] not in verbs: argv = argv[:i] + ["show"] + argv[i:] @@ -6889,12 +6992,19 @@ def main(): parser.add_argument("--version", action="version", version=f"linear-cli {__version__}") parser.add_argument("--team", default=None, help="Override team for this invocation (key like 'ANT' or UUID)") + parser.add_argument("--profile", default=None, metavar="NAME", + help="Linear workspace profile (overrides LINEAR_PROFILE): " + "config, cache and key under ~/.linear-cli/profiles/NAME/") sub = parser.add_subparsers(dest="command") # setup p_setup = sub.add_parser("setup", help="Configure Linear connection and agent identity") p_setup.add_argument("--api-key", help="Linear API key") p_setup.add_argument("--agent", help="Default agent identity") + p_setup.add_argument("--keychain", action="store_true", + help="Store the API key in the macOS Keychain (service " + "linear-api-key, account = the profile name or " + "'default') instead of config.json") p_setup.add_argument("--embeddings", choices=["ollama", "gemini", "none"], help="Optional embeddings ranker for `tasks --similar`: " "ollama (local, OLLAMA_HOST), gemini (GEMINI_API_KEY " @@ -7253,6 +7363,25 @@ def main(): parser.print_help() sys.exit(0) + # Pick the workspace before anything reads config: --profile, then + # LINEAR_PROFILE, then the profile the cwd's agents project names. + global _CWD_BINDING + if args.profile is not None: + profile, source = args.profile, "--profile" + elif os.environ.get("LINEAR_PROFILE"): + profile, source = os.environ["LINEAR_PROFILE"], "LINEAR_PROFILE" + elif args.command != "setup": + _CWD_BINDING = query_cwd_binding() + profile, source = _CWD_BINDING["profile"], "this directory's agents project" + else: + profile = None + if profile is not None: + try: + activate_profile(profile, source) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + cfg = load_config() register_embeddings_ranker(cfg) @@ -7265,7 +7394,11 @@ def main(): team_id = get_team_id(cfg) if not api_key: - print("No API key configured. Run: linear setup", file=sys.stderr) + if ACTIVE_PROFILE: + print(f"No API key configured for profile '{ACTIVE_PROFILE}' (from " + f"{PROFILE_SOURCE}). Run: {setup_command()}", file=sys.stderr) + else: + print("No API key configured. Run: linear setup", file=sys.stderr) sys.exit(1) if args.team: @@ -7278,7 +7411,7 @@ def main(): cfg = {**cfg, "states": {}, "__volatile__": True} if not team_id: - print("No team configured. Run: linear setup", file=sys.stderr) + print(f"No team configured. Run: {setup_command()}", file=sys.stderr) sys.exit(1) if args.command == "tasks": diff --git a/test_linear.py b/test_linear.py index f47fe2c..ee6bf62 100644 --- a/test_linear.py +++ b/test_linear.py @@ -3800,7 +3800,7 @@ def test_no_backend_means_no_ranker_and_no_noise(self): def _setup_args(self, **overrides): args = types.SimpleNamespace(api_key=None, agent=None, embeddings=None, - embeddings_model=None) + embeddings_model=None, keychain=False) for k, v in overrides.items(): setattr(args, k, v) return args @@ -4653,3 +4653,243 @@ def test_todo_done_only_closes_a_todo_of_todays_goal(self): finally: (linear_cli.goals_state, linear_cli._issue_write, linear_cli._emit_goal, linear_cli.get_states) = orig + + +# --- profiles (one per Linear workspace) --------------------------------------- + +def _fake_bin(directory, name, body): + """Write an executable shell script `name` into `directory`.""" + path = Path(directory) / name + path.write_text("#!/bin/sh\n" + body) + path.chmod(0o755) + return path + + +class ProfileTest(unittest.TestCase): + """--profile / LINEAR_PROFILE / the cwd's agents project pick a workspace: + its own paths, and a key that never comes from the default workspace's env.""" + + _GLOBALS = ("CONFIG_PATH", "LEGACY_CONFIG_PATH", "QUEUE_DIR", "CACHE_DB_PATH", + "UPLOADS_DIR", "ACTIVE_PROFILE", "PROFILE_SOURCE", "_CWD_BINDING", "API_URL") + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.home = Path(tmp.name) / "home" + self.home.mkdir() + self.bin = Path(tmp.name) / "bin" + self.bin.mkdir() + self.log = Path(tmp.name) / "calls.log" + saved = {n: getattr(linear_cli, n) for n in self._GLOBALS} + self.addCleanup(lambda: [setattr(linear_cli, n, v) for n, v in saved.items()]) + for name, value in (("HOME", str(self.home)), + ("PATH", f"{self.bin}:/usr/bin:/bin"), + ("LINEAR_API_KEY", None), ("LINEAR_TEAM_ID", None), + ("LINEAR_PROFILE", None)): + self._set_env(name, value) + + def _set_env(self, name, value): + saved = os.environ.get(name) + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + self.addCleanup(lambda: os.environ.__setitem__(name, saved) if saved is not None + else os.environ.pop(name, None)) + + def _fake_security(self, key=None): + """A `security` that logs its argv and answers find-generic-password with + `key` (or "not found", exit 44, like the real one).""" + found = f"printf '%s\\n' '{key}'; exit 0" if key else "exit 44" + _fake_bin(self.bin, "security", + f'echo "security $*" >> "{self.log}"\n' + f'case "$1" in find-generic-password) {found};; esac\nexit 0\n') + + def _calls(self): + return self.log.read_text().splitlines() if self.log.exists() else [] + + def _cli(self, *argv, env=None): + proc = subprocess.run([sys.executable, os.path.join(_HERE, "linear"), *argv], + capture_output=True, text=True, cwd=str(self.home), + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1", **(env or {})}) + return proc.returncode, proc.stderr + + # -- paths ---------------------------------------------------------------- + + def test_activate_profile_rebinds_every_workspace_path(self): + linear_cli.activate_profile("work", "--profile") + root = self.home / ".linear-cli" / "profiles" / "work" + self.assertEqual(linear_cli.CONFIG_PATH, root / "config.json") + self.assertEqual(linear_cli.queue_dir(), root / "queue") + self.assertEqual(linear_cli.CACHE_DB_PATH, root / "cache.sqlite") + self.assertEqual(linear_cli.UPLOADS_DIR, root / "uploads") + self.assertTrue(linear_cli.save_config({"teamId": "t"})) + self.assertEqual(json.loads((root / "config.json").read_text()), {"teamId": "t"}) + self.assertFalse((self.home / ".linear-cli" / "config.json").exists()) + + def test_invalid_profile_names_are_refused_and_change_nothing(self): + before = linear_cli.CONFIG_PATH + for bad in ("../etc", "a/b", "Work", "", "-x", ".x", "x" * 65, "a b", 7): + with self.assertRaises(ValueError, msg=bad): + linear_cli.activate_profile(bad, "--profile") + self.assertEqual(linear_cli.CONFIG_PATH, before) + self.assertIsNone(linear_cli.ACTIVE_PROFILE) + for good in ("personal", "my.ws_1-x", "0", "x" * 64): + linear_cli.activate_profile(good, "--profile") + self.assertEqual(linear_cli.CONFIG_PATH.parent.name, good) + + def test_legacy_config_is_not_migrated_into_a_profile(self): + legacy = self.home / ".agents" / "linear.json" + legacy.parent.mkdir() + legacy.write_text(json.dumps({"apiKey": "turing-key", "teamId": "turing"})) + linear_cli.LEGACY_CONFIG_PATH = legacy + linear_cli.activate_profile("personal", "--profile") + self.assertEqual(linear_cli.load_config(), {}) + self.assertFalse(linear_cli.CONFIG_PATH.exists()) + + # -- credentials ---------------------------------------------------------- + + def test_profile_key_skips_env_and_asks_keychain_for_its_account(self): + self._fake_security(key="personal-secret") + self._set_env("LINEAR_API_KEY", "turing-key") + self._set_env("LINEAR_TEAM_ID", "turing-team") + linear_cli.activate_profile("personal", "LINEAR_PROFILE") + self.assertEqual(linear_cli.get_api_key({}), "personal-secret") + self.assertEqual(linear_cli.get_team_id({}), "personal-secret") + self.assertEqual(self._calls(), [ + "security find-generic-password -s linear-api-key -a personal -w", + "security find-generic-password -s linear-team-id -a personal -w"]) + # The profile's own config still wins over its Keychain item. + self.assertEqual(linear_cli.get_api_key({"apiKey": "cfg-key"}), "cfg-key") + + def test_profile_without_a_key_is_empty_even_with_env_set(self): + self._fake_security(key=None) + self._set_env("LINEAR_API_KEY", "turing-key") + linear_cli.activate_profile("personal", "LINEAR_PROFILE") + self.assertEqual(linear_cli.get_api_key({}), "") + + def test_default_profile_keeps_env_then_account_less_keychain(self): + self._fake_security(key="default-secret") + self._set_env("LINEAR_API_KEY", "env-key") + self.assertEqual(linear_cli.get_api_key({}), "env-key") + self._set_env("LINEAR_API_KEY", None) + self.assertEqual(linear_cli.get_api_key({}), "default-secret") + self.assertEqual(self._calls(), + ["security find-generic-password -s linear-api-key -w"]) + + # -- setup --keychain ----------------------------------------------------- + + def _graphql_server(self, seen): + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + seen.append(self.headers.get("Authorization")) + query = json.loads(self.rfile.read(int(self.headers["Content-Length"])))["query"] + if "teams" in query: + data = {"teams": {"nodes": [{"id": "team-p", "name": "Personal", "key": "PER"}]}} + else: + data = {"workflowStates": {"nodes": [{"id": "s1", "name": "Todo", "type": "unstarted"}]}} + body = json.dumps({"data": data}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + linear_cli.API_URL = f"http://127.0.0.1:{server.server_port}" + + def test_setup_keychain_keeps_the_key_out_of_config(self): + seen = [] + self._graphql_server(seen) + self._fake_security() + linear_cli.activate_profile("personal", "--profile") + args = types.SimpleNamespace(api_key="lin_api_new", agent="claude", embeddings=None, + embeddings_model=None, keychain=True) + out = io.StringIO() + with contextlib.redirect_stdout(out): + # A stale apiKey in the profile config would shadow the Keychain item. + linear_cli.cmd_setup(args, {"apiKey": "lin_api_old"}) + written = linear_cli.CONFIG_PATH.read_text() + self.assertNotIn("apiKey", json.loads(written)) + self.assertNotIn("lin_api_", written) + self.assertEqual(json.loads(written)["teamId"], "team-p") + self.assertEqual(seen, ["lin_api_new", "lin_api_new"]) + self.assertEqual(self._calls(), [ + "security add-generic-password -U -s linear-api-key -a personal -w lin_api_new"]) + self.assertIn("API key: macOS Keychain (service linear-api-key, account personal)", + out.getvalue()) + + def test_setup_keychain_default_profile_uses_account_default(self): + self._graphql_server([]) + self._fake_security() + linear_cli.CONFIG_PATH = self.home / ".linear-cli" / "config.json" + args = types.SimpleNamespace(api_key="lin_api_new", agent="claude", embeddings=None, + embeddings_model=None, keychain=True) + with contextlib.redirect_stdout(io.StringIO()): + linear_cli.cmd_setup(args, {}) + self.assertEqual(self._calls(), [ + "security add-generic-password -U -s linear-api-key -a default -w lin_api_new"]) + self.assertNotIn("apiKey", json.loads(linear_cli.CONFIG_PATH.read_text())) + + # -- selection in main() -------------------------------------------------- + + def _fake_agents(self, profile): + _fake_bin(self.bin, "agents", + f'echo "agents $*" >> "{self.log}"\n' + "echo '" + json.dumps({"name": "talora", "root": "/x", "linear": { + "projectId": None, "name": None, "profile": profile}}) + "'\n") + + def test_cwd_agents_project_picks_the_profile(self): + self._fake_agents("talora") + self._fake_security(key=None) + rc, err = self._cli("tasks") + self.assertEqual(rc, 1) + self.assertIn("No API key configured for profile 'talora' (from this directory's " + "agents project). Run: linear --profile talora setup", err) + # One `agents` call, and the Keychain lookup used the profile's account. + self.assertEqual(self._calls(), [ + "agents projects view . --json", + "security find-generic-password -s linear-api-key -a talora -w", + "security find-generic-password -s linear-team-id -a talora -w"]) + + def test_flag_beats_env_and_env_skips_the_cwd_lookup(self): + self._fake_agents("talora") + self._fake_security(key=None) + rc, err = self._cli("--profile", "flagged", "tasks", env={"LINEAR_PROFILE": "fromenv"}) + self.assertEqual(rc, 1) + self.assertIn("profile 'flagged' (from --profile). Run: linear --profile flagged setup", err) + rc, err = self._cli("tasks", env={"LINEAR_PROFILE": "fromenv"}) + self.assertIn("profile 'fromenv' (from LINEAR_PROFILE)", err) + self.assertNotIn("agents projects view . --json", self._calls()) + + def test_invalid_profile_flag_exits_1(self): + rc, err = self._cli("--profile", "../escape", "tasks") + self.assertEqual(rc, 1) + self.assertIn("invalid profile name '../escape' (from --profile)", err) + self.assertFalse((self.home / ".linear-cli").exists()) + + def test_no_agents_binary_keeps_the_default_profile(self): + self._fake_security(key=None) + rc, err = self._cli("tasks") + self.assertEqual(rc, 1) + self.assertIn("No API key configured. Run: linear setup", err) + self.assertEqual(self._calls(), ["security find-generic-password -s linear-api-key -w", + "security find-generic-password -s linear-team-id -w"]) + + def test_project_scope_reuses_mains_cwd_answer(self): + # main() already asked `agents`; no binary on PATH proves no second call. + linear_cli._CWD_BINDING = {"projectId": "p-1", "name": "Talora", "profile": "talora"} + self.assertEqual(linear_cli.resolve_cwd_project(), ("p-1", "Talora")) + + def test_group_shorthand_survives_a_leading_profile_flag(self): + self.assertEqual(linear_cli.rewrite_group_argv(["--profile", "p", "projects", "Foo"]), + ["--profile", "p", "projects", "show", "Foo"]) + self.assertEqual(linear_cli.rewrite_group_argv(["--profile=p", "--team", "T", + "initiatives", "Q4"]), + ["--profile=p", "--team", "T", "initiatives", "show", "Q4"]) From 56e58f7bdc270140252ff1b16105af342eac3aa8 Mon Sep 17 00:00:00 2001 From: Taylor Gagne Date: Wed, 14 Oct 2026 19:42:31 -0700 Subject: [PATCH 2/4] fix(profiles): own Keychain service per profile, scope the cwd lookup A profile's key now lives under its own Keychain service (linear-api-key., linear-team-id.) instead of an account under the shared one: find-generic-password without -a matches any account, so the default profile could pick up a profile's key. The default lookup is unchanged. Review fixes: the cwd `agents` lookup in main() is skipped for commands that never scope by project and when the default config has autoScope: false; a cwd project only auto-scopes when its profile is the active one; profile names use fullmatch so a trailing newline is refused; an invalid profile in a project YAML is ignored with a warning instead of failing every command; and a test runs `tasks --similar --json` under a profile against a local Linear to prove its key, config and cache are the ones used. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 15 ++-- README.md | 8 +-- linear | 92 ++++++++++++++++++------ test_linear.py | 188 +++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 236 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 275f721..23c373f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profiles, one per Linear workspace: `--profile ` or `LINEAR_PROFILE` keeps config, cache, queue and uploads under `~/.linear-cli/profiles//`. The key resolves from the profile config, - then Keychain `linear-api-key` with account ``; `LINEAR_API_KEY` and - `LINEAR_TEAM_ID` are not read under a profile. With neither flag nor env - set, the `linear.profile` of the cwd's `agents projects` project is used, - from the same `agents projects view` call the project auto-scope makes. + then Keychain service `linear-api-key.` (team id: + `linear-team-id.`); `LINEAR_API_KEY` and `LINEAR_TEAM_ID` are not + read under a profile. With neither flag nor env set, the `linear.profile` + of the cwd's `agents projects` project is used, from the same `agents + projects view` call the project auto-scope makes; commands that never scope + by project, and `autoScope: false`, skip that call. An invalid profile there + is ignored with a warning. A cwd project whose profile is not the active + one is not auto-scoped to. - `setup --keychain` stores the API key in the macOS Keychain instead of - `config.json` (account = the profile name, or `default`). + `config.json`: service `linear-api-key.`, or `linear-api-key` for + the default profile. ### Fixed diff --git a/README.md b/README.md index 05ca8f9..5905c95 100644 --- a/README.md +++ b/README.md @@ -50,20 +50,20 @@ linear setup --api-key lin_api_... --agent claude Config is written to `~/.linear-cli/config.json`. If you already have `~/.agents/linear.json` from an earlier setup, linear-cli auto-migrates it on first run. -> **Security:** treat the API key like a password. **Do not paste it into chat with an LLM in plaintext.** Pass it from your shell env (`linear setup --api-key "$LINEAR_API_KEY"`), or let linear-cli read it from the macOS Keychain. Resolution order is: config file → `LINEAR_API_KEY` env → Keychain service `linear-api-key`. `linear setup --keychain` stores the key in the Keychain (service `linear-api-key`, account `default`) and leaves it out of `config.json`. +> **Security:** treat the API key like a password. **Do not paste it into chat with an LLM in plaintext.** Pass it from your shell env (`linear setup --api-key "$LINEAR_API_KEY"`), or let linear-cli read it from the macOS Keychain. Resolution order is: config file → `LINEAR_API_KEY` env → Keychain service `linear-api-key`. `linear setup --keychain` stores the key in the Keychain (service `linear-api-key`) and leaves it out of `config.json`. ### Profiles (several workspaces) A Linear API key belongs to one workspace. To work in a second one, give it a profile: ```bash -linear --profile personal setup --api-key "$KEY" --keychain # key -> Keychain linear-api-key / account personal +linear --profile personal setup --api-key "$KEY" --keychain # key -> Keychain service linear-api-key.personal linear --profile personal tasks # or: LINEAR_PROFILE=personal linear tasks ``` -A profile keeps its own config, board cache, close queue and downloaded images under `~/.linear-cli/profiles//`. Names are lowercase letters, digits, `.`, `_` and `-` (up to 64, starting with a letter or digit). `--profile` wins over `LINEAR_PROFILE`. With neither set, linear-cli uses the `linear.profile` of the `agents projects` project that owns the current directory, so an agent launched in that folder reaches that project's workspace; no `agents` CLI or no profile on the project means the default. +A profile keeps its own config, board cache, close queue and downloaded images under `~/.linear-cli/profiles//`. Names are lowercase letters, digits, `.`, `_` and `-` (up to 64, starting with a letter or digit). `--profile` wins over `LINEAR_PROFILE`. With neither set, linear-cli uses the `linear.profile` of the `agents projects` project that owns the current directory, so an agent launched in that folder reaches that project's workspace; no `agents` CLI, no profile on the project, or an invalid one (ignored with a warning) means the default. `setup`, `queue`, `states`, `labels`, `users`, `agents` and `inbox` skip that lookup, as does everything when the default config has `autoScope: false`. The directory's project auto-scope only applies when its `linear.profile` is the active profile; a project of another workspace is not scoped to. -Under a profile the key comes from the profile's `config.json`, then Keychain service `linear-api-key` with account ``; the team id from `teamId` in that config, then Keychain `linear-team-id` / ``. `LINEAR_API_KEY` and `LINEAR_TEAM_ID` are ignored there because they belong to the default workspace. With no profile, nothing changes. +Under a profile the key comes from the profile's `config.json`, then Keychain service `linear-api-key.`; the team id from `teamId` in that config, then Keychain service `linear-team-id.`. Each profile has its own service name, so the default lookup (`linear-api-key`, any account) can never pick up a profile's key. `LINEAR_API_KEY` and `LINEAR_TEAM_ID` are ignored there because they belong to the default workspace. With no profile, nothing changes. ### Fleet setup for agents diff --git a/linear b/linear index 1de7a43..da8823b 100755 --- a/linear +++ b/linear @@ -109,7 +109,8 @@ QUEUE_BACKOFF_MAX_SECONDS = 300 # 5 minutes # second workspace needs its own key, team, states cache and board cache. None # is the default profile, which keeps every path and credential lookup as it # was before profiles existed. -PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +# Matched with fullmatch: `$` alone would let a trailing newline through. +PROFILE_RE = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}") ACTIVE_PROFILE: str | None = None # Where ACTIVE_PROFILE came from ("--profile", "LINEAR_PROFILE", or the cwd's # agents project), so a missing-key error can say why that profile is active. @@ -161,7 +162,7 @@ def activate_profile(name: str, source: str) -> None: """ global ACTIVE_PROFILE, PROFILE_SOURCE, CONFIG_PATH, QUEUE_DIR, CACHE_DB_PATH, UPLOADS_DIR # A project YAML can hand over any type (`profile: 7`), not just a string. - if not isinstance(name, str) or not PROFILE_RE.match(name): + if not isinstance(name, str) or not PROFILE_RE.fullmatch(name): raise ValueError(f"invalid profile name {name!r} (from {source}): use lowercase " "letters, digits, '.', '_' or '-', starting with a letter or digit") root = Path.home() / ".linear-cli" / "profiles" / name @@ -213,16 +214,20 @@ def save_config(cfg: dict) -> bool: return False -def _keychain_secret(service: str) -> str: - """Read a generic password from the macOS Keychain ("" when absent). +def keychain_service(base: str) -> str: + """The Keychain service holding `base` for the active profile. - Under a profile the item is the one whose account is the profile name; the - default profile keeps the account-less lookup it has always used. + A profile gets its own service name (`linear-api-key.personal`), not an + account under the shared one: `find-generic-password -s linear-api-key` + with no `-a` matches any account, so an account-scoped profile item could + be handed to the default profile's lookup. """ - argv = ["security", "find-generic-password", "-s", service] - if ACTIVE_PROFILE: - argv += ["-a", ACTIVE_PROFILE] - argv.append("-w") + return f"{base}.{ACTIVE_PROFILE}" if ACTIVE_PROFILE else base + + +def _keychain_secret(service: str) -> str: + """Read a generic password from the macOS Keychain ("" when absent).""" + argv = ["security", "find-generic-password", "-s", keychain_service(service), "-w"] try: result = subprocess.run(argv, capture_output=True, text=True) except FileNotFoundError: @@ -1284,6 +1289,10 @@ def resolve_cwd_project(timeout: float = 3.0) -> tuple[str | None, str | None]: the caller falls back to the unscoped team view. Never raises — this runs on the hot path of the most-used command and must not brick it. + Only a binding whose `linear.profile` is the active profile counts (both + None for the default): under `--profile work`, a directory bound to a + project of another workspace is treated as unbound, with a stderr note. + One bounded subprocess call (~0.3s on an idle box): `agents projects view . --json` longest-matches the cwd and returns `{name, linear:{name, projectId, profile}, root}` in a single shot (an unmatched cwd returns @@ -1291,9 +1300,20 @@ def resolve_cwd_project(timeout: float = 3.0) -> tuple[str | None, str | None]: reused and no subprocess runs. """ binding = _CWD_BINDING if _CWD_BINDING is not None else query_cwd_binding(timeout) + # A project in another workspace has an id this workspace does not know. + if binding["profile"] != ACTIVE_PROFILE: + if binding["projectId"] or binding["name"]: + print(f"linear: the current directory's project '{binding['name'] or binding['projectId']}' " + f"is in profile {_profile_label(binding['profile'])}, not " + f"{_profile_label(ACTIVE_PROFILE)} — not scoping to it.", file=sys.stderr) + return (None, None) return (binding["projectId"], binding["name"]) +def _profile_label(profile: str | None) -> str: + return "the default profile" if profile is None else f"'{profile}'" + + def resolve_auto_project_id(api_key: str, team_id: str, value: str) -> str | None: """Validate an auto-detected (cwd) project id/name against the team's live projects. Returns the matching projectId, or None when it doesn't match. @@ -2179,13 +2199,15 @@ def apply_embeddings_choice(cfg: dict, backend: str, model: str | None) -> str: def store_keychain_api_key(api_key: str) -> str: - """`setup --keychain`: save the key as Keychain item linear-api-key under the - profile's account ("default" for the default profile). Exits on failure, - since setup would otherwise finish with no key anywhere.""" + """`setup --keychain`: save the key under the profile's Keychain service + (linear-api-key., or linear-api-key for the default profile; the + account is informational). Exits on failure, since setup would otherwise + finish with no key anywhere.""" + service = keychain_service("linear-api-key") account = ACTIVE_PROFILE or "default" try: result = subprocess.run(["security", "add-generic-password", "-U", "-s", - "linear-api-key", "-a", account, "-w", api_key], + service, "-a", account, "-w", api_key], capture_output=True, text=True) except FileNotFoundError: print("Setup failed: --keychain needs the macOS `security` tool.", file=sys.stderr) @@ -2194,7 +2216,7 @@ def store_keychain_api_key(api_key: str) -> str: print(f"Setup failed: could not store the key in the Keychain: " f"{result.stderr.strip()}", file=sys.stderr) sys.exit(1) - return f"API key: macOS Keychain (service linear-api-key, account {account})" + return f"API key: macOS Keychain (service {service})" def cmd_setup(args, cfg): @@ -6957,6 +6979,24 @@ _PROJECTS_VERBS = frozenset({"show", "overview", "create", "update", "archive", _INITIATIVES_VERBS = frozenset({"show", "create", "update", "link", "unlink", "archive"}) +# Commands that never scope to a project, so they skip the cwd `agents` lookup +# (and with it the cwd's profile; --profile / LINEAR_PROFILE still apply). +UNSCOPED_COMMANDS = frozenset({"setup", "queue", "states", "labels", "users", + "agents", "inbox"}) + + +def _default_auto_scope() -> bool: + """`autoScope` from the default profile's config.json, read as a plain file + (no legacy migration, no permission fix-up) because main() needs it before + it knows which profile's config to load. `autoScope: false` turns off the + cwd lookup, and with it the cwd's profile.""" + try: + cfg = json.loads(CONFIG_PATH.read_text()) + except (OSError, ValueError): + return True + return not (isinstance(cfg, dict) and cfg.get("autoScope", True) is False) + + def rewrite_group_argv(argv: list[str]) -> list[str]: """Back-compat + ergonomics: `projects ` / `initiatives ` (a bare name, not a verb) is shorthand for ` show `. Insert the implicit @@ -7003,8 +7043,8 @@ def main(): p_setup.add_argument("--agent", help="Default agent identity") p_setup.add_argument("--keychain", action="store_true", help="Store the API key in the macOS Keychain (service " - "linear-api-key, account = the profile name or " - "'default') instead of config.json") + "linear-api-key, or linear-api-key. under " + "--profile) instead of config.json") p_setup.add_argument("--embeddings", choices=["ollama", "gemini", "none"], help="Optional embeddings ranker for `tasks --similar`: " "ollama (local, OLLAMA_HOST), gemini (GEMINI_API_KEY " @@ -7366,15 +7406,25 @@ def main(): # Pick the workspace before anything reads config: --profile, then # LINEAR_PROFILE, then the profile the cwd's agents project names. global _CWD_BINDING + profile = None if args.profile is not None: profile, source = args.profile, "--profile" elif os.environ.get("LINEAR_PROFILE"): profile, source = os.environ["LINEAR_PROFILE"], "LINEAR_PROFILE" - elif args.command != "setup": + elif args.command not in UNSCOPED_COMMANDS and _default_auto_scope(): _CWD_BINDING = query_cwd_binding() - profile, source = _CWD_BINDING["profile"], "this directory's agents project" - else: - profile = None + cwd_profile = _CWD_BINDING["profile"] + if cwd_profile is not None and not (isinstance(cwd_profile, str) + and PROFILE_RE.fullmatch(cwd_profile)): + # Fail-open like the rest of the cwd path: a bad YAML value must not + # brick every command in the directory. Its project is then unbound, + # since it is not known to live in the default workspace. + print(f"linear: ignoring invalid profile {cwd_profile!r} from this " + f"directory's agents project; using the default profile.", + file=sys.stderr) + _CWD_BINDING = {"projectId": None, "name": None, "profile": None} + else: + profile, source = cwd_profile, "this directory's agents project" if profile is not None: try: activate_profile(profile, source) diff --git a/test_linear.py b/test_linear.py index ee6bf62..b44f8b4 100644 --- a/test_linear.py +++ b/test_linear.py @@ -4667,7 +4667,8 @@ def _fake_bin(directory, name, body): class ProfileTest(unittest.TestCase): """--profile / LINEAR_PROFILE / the cwd's agents project pick a workspace: - its own paths, and a key that never comes from the default workspace's env.""" + its own paths, and a key that never comes from the default workspace's env + or Keychain item.""" _GLOBALS = ("CONFIG_PATH", "LEGACY_CONFIG_PATH", "QUEUE_DIR", "CACHE_DB_PATH", "UPLOADS_DIR", "ACTIVE_PROFILE", "PROFILE_SOURCE", "_CWD_BINDING", "API_URL") @@ -4682,6 +4683,12 @@ def setUp(self): self.log = Path(tmp.name) / "calls.log" saved = {n: getattr(linear_cli, n) for n in self._GLOBALS} self.addCleanup(lambda: [setattr(linear_cli, n, v) for n, v in saved.items()]) + # The module's default paths were computed from the real HOME at import. + linear_cli.CONFIG_PATH = self.home / ".linear-cli" / "config.json" + linear_cli.CACHE_DB_PATH = self.home / ".linear-cli" / "cache.sqlite" + linear_cli.QUEUE_DIR = self.home / ".linear-cli" / "queue" + linear_cli.UPLOADS_DIR = self.home / ".linear-cli" / "uploads" + linear_cli.LEGACY_CONFIG_PATH = self.home / ".agents" / "linear.json" for name, value in (("HOME", str(self.home)), ("PATH", f"{self.bin}:/usr/bin:/bin"), ("LINEAR_API_KEY", None), ("LINEAR_TEAM_ID", None), @@ -4697,13 +4704,16 @@ def _set_env(self, name, value): self.addCleanup(lambda: os.environ.__setitem__(name, saved) if saved is not None else os.environ.pop(name, None)) - def _fake_security(self, key=None): - """A `security` that logs its argv and answers find-generic-password with - `key` (or "not found", exit 44, like the real one).""" - found = f"printf '%s\\n' '{key}'; exit 0" if key else "exit 44" + def _fake_security(self, items=None): + """A `security` that logs its argv and answers find-generic-password -s + SERVICE from `items` ({service: secret}), else "not found" (exit 44) + like the real one.""" + cases = "".join(f" {svc}) printf '%s\\n' '{val}'; exit 0;;\n" + for svc, val in (items or {}).items()) _fake_bin(self.bin, "security", f'echo "security $*" >> "{self.log}"\n' - f'case "$1" in find-generic-password) {found};; esac\nexit 0\n') + 'if [ "$1" = find-generic-password ]; then\n' + f' case "$3" in\n{cases} esac\n exit 44\nfi\nexit 0\n') def _calls(self): return self.log.read_text().splitlines() if self.log.exists() else [] @@ -4729,8 +4739,8 @@ def test_activate_profile_rebinds_every_workspace_path(self): def test_invalid_profile_names_are_refused_and_change_nothing(self): before = linear_cli.CONFIG_PATH - for bad in ("../etc", "a/b", "Work", "", "-x", ".x", "x" * 65, "a b", 7): - with self.assertRaises(ValueError, msg=bad): + for bad in ("../etc", "a/b", "Work", "", "-x", ".x", "x" * 65, "a b", 7, "work\n"): + with self.assertRaises(ValueError, msg=repr(bad)): linear_cli.activate_profile(bad, "--profile") self.assertEqual(linear_cli.CONFIG_PATH, before) self.assertIsNone(linear_cli.ACTIVE_PROFILE) @@ -4739,47 +4749,62 @@ def test_invalid_profile_names_are_refused_and_change_nothing(self): self.assertEqual(linear_cli.CONFIG_PATH.parent.name, good) def test_legacy_config_is_not_migrated_into_a_profile(self): - legacy = self.home / ".agents" / "linear.json" + legacy = linear_cli.LEGACY_CONFIG_PATH legacy.parent.mkdir() legacy.write_text(json.dumps({"apiKey": "turing-key", "teamId": "turing"})) - linear_cli.LEGACY_CONFIG_PATH = legacy linear_cli.activate_profile("personal", "--profile") self.assertEqual(linear_cli.load_config(), {}) self.assertFalse(linear_cli.CONFIG_PATH.exists()) # -- credentials ---------------------------------------------------------- - def test_profile_key_skips_env_and_asks_keychain_for_its_account(self): - self._fake_security(key="personal-secret") + def test_profile_key_skips_env_and_reads_its_own_keychain_service(self): + self._fake_security({"linear-api-key.personal": "personal-key", + "linear-team-id.personal": "personal-team"}) self._set_env("LINEAR_API_KEY", "turing-key") self._set_env("LINEAR_TEAM_ID", "turing-team") linear_cli.activate_profile("personal", "LINEAR_PROFILE") - self.assertEqual(linear_cli.get_api_key({}), "personal-secret") - self.assertEqual(linear_cli.get_team_id({}), "personal-secret") + self.assertEqual(linear_cli.get_api_key({}), "personal-key") + self.assertEqual(linear_cli.get_team_id({}), "personal-team") self.assertEqual(self._calls(), [ - "security find-generic-password -s linear-api-key -a personal -w", - "security find-generic-password -s linear-team-id -a personal -w"]) + "security find-generic-password -s linear-api-key.personal -w", + "security find-generic-password -s linear-team-id.personal -w"]) # The profile's own config still wins over its Keychain item. self.assertEqual(linear_cli.get_api_key({"apiKey": "cfg-key"}), "cfg-key") - def test_profile_without_a_key_is_empty_even_with_env_set(self): - self._fake_security(key=None) + def test_profile_without_a_key_is_empty_even_with_env_and_a_default_item(self): + self._fake_security({"linear-api-key": "turing-key"}) self._set_env("LINEAR_API_KEY", "turing-key") linear_cli.activate_profile("personal", "LINEAR_PROFILE") self.assertEqual(linear_cli.get_api_key({}), "") - def test_default_profile_keeps_env_then_account_less_keychain(self): - self._fake_security(key="default-secret") + def test_default_lookup_names_no_profile(self): + # `find-generic-password` without -a matches any account under a + # service, so the default lookup must use a service no profile writes: + # the bare one, with no profile suffix and no account filter. + self._fake_security({"linear-api-key": "default-key", + "linear-api-key.talora": "talora-key"}) self._set_env("LINEAR_API_KEY", "env-key") self.assertEqual(linear_cli.get_api_key({}), "env-key") self._set_env("LINEAR_API_KEY", None) - self.assertEqual(linear_cli.get_api_key({}), "default-secret") - self.assertEqual(self._calls(), - ["security find-generic-password -s linear-api-key -w"]) + self.assertEqual(linear_cli.get_api_key({}), "default-key") + self.assertEqual(self._calls(), ["security find-generic-password -s linear-api-key -w"]) + linear_cli.activate_profile("talora", "--profile") + self.assertEqual(linear_cli.get_api_key({}), "talora-key") + self.assertEqual(self._calls()[-1], + "security find-generic-password -s linear-api-key.talora -w") # -- setup --keychain ----------------------------------------------------- + _ISSUE = {"id": "i-1", "identifier": "WRK-1", "title": "Token refresh fails", + "description": "", "updatedAt": "2026-10-01T00:00:00.000Z", "url": "u", + "state": {"name": "Todo", "type": "unstarted"}} + def _graphql_server(self, seen): + """A local Linear: answers the queries setup and `tasks --similar` send, + and records the Authorization header of every request.""" + issue = self._ISSUE + class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *a): pass @@ -4787,7 +4812,12 @@ def log_message(self, *a): def do_POST(self): seen.append(self.headers.get("Authorization")) query = json.loads(self.rfile.read(int(self.headers["Content-Length"])))["query"] - if "teams" in query: + if "searchIssues" in query: + data = {"searchIssues": {"nodes": [issue]}} + elif "issues(" in query: + data = {"issues": {"pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [issue]}} + elif "teams" in query: data = {"teams": {"nodes": [{"id": "team-p", "name": "Personal", "key": "PER"}]}} else: data = {"workflowStates": {"nodes": [{"id": "s1", "name": "Todo", "type": "unstarted"}]}} @@ -4821,14 +4851,12 @@ def test_setup_keychain_keeps_the_key_out_of_config(self): self.assertEqual(json.loads(written)["teamId"], "team-p") self.assertEqual(seen, ["lin_api_new", "lin_api_new"]) self.assertEqual(self._calls(), [ - "security add-generic-password -U -s linear-api-key -a personal -w lin_api_new"]) - self.assertIn("API key: macOS Keychain (service linear-api-key, account personal)", - out.getvalue()) + "security add-generic-password -U -s linear-api-key.personal -a personal -w lin_api_new"]) + self.assertIn("API key: macOS Keychain (service linear-api-key.personal)", out.getvalue()) - def test_setup_keychain_default_profile_uses_account_default(self): + def test_setup_keychain_default_profile_writes_the_bare_service(self): self._graphql_server([]) self._fake_security() - linear_cli.CONFIG_PATH = self.home / ".linear-cli" / "config.json" args = types.SimpleNamespace(api_key="lin_api_new", agent="claude", embeddings=None, embeddings_model=None, keychain=True) with contextlib.redirect_stdout(io.StringIO()): @@ -4837,30 +4865,71 @@ def test_setup_keychain_default_profile_uses_account_default(self): "security add-generic-password -U -s linear-api-key -a default -w lin_api_new"]) self.assertNotIn("apiKey", json.loads(linear_cli.CONFIG_PATH.read_text())) + # -- a command really runs inside a profile ------------------------------- + + def _run_main(self, *argv): + out, err = io.StringIO(), io.StringIO() + saved_argv = sys.argv + sys.argv = ["linear", *argv] + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + linear_cli.main() + finally: + sys.argv = saved_argv + return out.getvalue(), err.getvalue() + + def test_tasks_under_a_profile_uses_its_key_config_and_cache(self): + seen = [] + self._graphql_server(seen) + self._fake_security({"linear-api-key.work": "work-key", + "linear-api-key": "turing-key"}) + self._set_env("LINEAR_API_KEY", "turing-env-key") + default_cfg = linear_cli.CONFIG_PATH + default_cfg.parent.mkdir(mode=0o700) + default_cfg.write_text(json.dumps({"apiKey": "turing-cfg-key", "teamId": "team-turing"})) + before = default_cfg.read_bytes() + root = self.home / ".linear-cli" / "profiles" / "work" + root.mkdir(parents=True) + (root / "config.json").write_text(json.dumps({ + "teamId": "team-work", "states": {"Todo": {"id": "s1", "type": "unstarted"}}})) + + out, err = self._run_main("--profile", "work", "tasks", "--similar", "token", "--json") + + self.assertEqual([r["identifier"] for r in json.loads(out)["rows"]], ["WRK-1"], err) + self.assertTrue(seen) + self.assertEqual(set(seen), {"work-key"}) + conn = sqlite3.connect(root / "cache.sqlite") + self.assertEqual(conn.execute("SELECT team_id FROM sync").fetchall(), [("team-work",)]) + conn.close() + self.assertFalse((self.home / ".linear-cli" / "cache.sqlite").exists()) + self.assertEqual(default_cfg.read_bytes(), before) + self.assertEqual(self._calls(), [ + "security find-generic-password -s linear-api-key.work -w"]) + # -- selection in main() -------------------------------------------------- - def _fake_agents(self, profile): + def _fake_agents(self, profile, project_id=None, name=None): _fake_bin(self.bin, "agents", f'echo "agents $*" >> "{self.log}"\n' "echo '" + json.dumps({"name": "talora", "root": "/x", "linear": { - "projectId": None, "name": None, "profile": profile}}) + "'\n") + "projectId": project_id, "name": name, "profile": profile}}) + "'\n") def test_cwd_agents_project_picks_the_profile(self): self._fake_agents("talora") - self._fake_security(key=None) + self._fake_security() rc, err = self._cli("tasks") self.assertEqual(rc, 1) self.assertIn("No API key configured for profile 'talora' (from this directory's " "agents project). Run: linear --profile talora setup", err) - # One `agents` call, and the Keychain lookup used the profile's account. + # One `agents` call, and the Keychain lookups used the profile's services. self.assertEqual(self._calls(), [ "agents projects view . --json", - "security find-generic-password -s linear-api-key -a talora -w", - "security find-generic-password -s linear-team-id -a talora -w"]) + "security find-generic-password -s linear-api-key.talora -w", + "security find-generic-password -s linear-team-id.talora -w"]) def test_flag_beats_env_and_env_skips_the_cwd_lookup(self): self._fake_agents("talora") - self._fake_security(key=None) + self._fake_security() rc, err = self._cli("--profile", "flagged", "tasks", env={"LINEAR_PROFILE": "fromenv"}) self.assertEqual(rc, 1) self.assertIn("profile 'flagged' (from --profile). Run: linear --profile flagged setup", err) @@ -4874,19 +4943,64 @@ def test_invalid_profile_flag_exits_1(self): self.assertIn("invalid profile name '../escape' (from --profile)", err) self.assertFalse((self.home / ".linear-cli").exists()) + def test_invalid_cwd_profile_is_ignored_not_fatal(self): + self._fake_security() + for bad, shown in (("", "''"), (7, "7"), ("Talora", "'Talora'")): + self._fake_agents(bad, project_id="p-1", name="Talora") + rc, err = self._cli("tasks") + self.assertEqual(rc, 1, bad) + self.assertIn(f"ignoring invalid profile {shown} from this directory's agents " + "project; using the default profile.", err) + # It carried on, on the default profile. + self.assertIn("No API key configured. Run: linear setup", err) + self.assertNotIn("linear-api-key.", "\n".join(self._calls())) + def test_no_agents_binary_keeps_the_default_profile(self): - self._fake_security(key=None) + self._fake_security() rc, err = self._cli("tasks") self.assertEqual(rc, 1) self.assertIn("No API key configured. Run: linear setup", err) self.assertEqual(self._calls(), ["security find-generic-password -s linear-api-key -w", "security find-generic-password -s linear-team-id -w"]) + def test_unscoped_commands_and_auto_scope_off_skip_the_cwd_lookup(self): + self._fake_agents("talora") + self._fake_security() + for command in ("states", "queue", "labels", "users", "agents", "inbox"): + self._cli(command) + self.assertNotIn("agents projects view . --json", self._calls()) + cfg = self.home / ".linear-cli" / "config.json" + cfg.parent.mkdir(mode=0o700) + cfg.write_text(json.dumps({"autoScope": False})) + rc, err = self._cli("tasks") + self.assertIn("No API key configured. Run: linear setup", err) + self.assertNotIn("agents projects view . --json", self._calls()) + cfg.write_text(json.dumps({"autoScope": True})) + self._cli("tasks") + self.assertIn("agents projects view . --json", self._calls()) + + # -- project auto-scope follows the profile ------------------------------- + def test_project_scope_reuses_mains_cwd_answer(self): # main() already asked `agents`; no binary on PATH proves no second call. + linear_cli.activate_profile("talora", "this directory's agents project") linear_cli._CWD_BINDING = {"projectId": "p-1", "name": "Talora", "profile": "talora"} self.assertEqual(linear_cli.resolve_cwd_project(), ("p-1", "Talora")) + def test_project_of_another_profile_does_not_scope(self): + cases = (("talora", None), (None, "work"), ("talora", "work")) + for bound, active in cases: + linear_cli.ACTIVE_PROFILE = active + linear_cli._CWD_BINDING = {"projectId": "p-1", "name": "Talora", "profile": bound} + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(linear_cli.resolve_cwd_project(), (None, None), (bound, active)) + self.assertIn("project 'Talora' is in profile", err.getvalue()) + self.assertIn("not scoping to it", err.getvalue()) + linear_cli.ACTIVE_PROFILE = None + linear_cli._CWD_BINDING = {"projectId": "p-1", "name": "Talora", "profile": None} + self.assertEqual(linear_cli.resolve_cwd_project(), ("p-1", "Talora")) + def test_group_shorthand_survives_a_leading_profile_flag(self): self.assertEqual(linear_cli.rewrite_group_argv(["--profile", "p", "projects", "Foo"]), ["--profile", "p", "projects", "show", "Foo"]) From 4ade5ad615dea2a1c92a93832c3c971c2180278d Mon Sep 17 00:00:00 2001 From: Taylor Gagne Date: Wed, 14 Oct 2026 19:44:58 -0700 Subject: [PATCH 3/4] feat(profiles): `default` is reserved for the default profile --profile default and LINEAR_PROFILE=default select the default profile: default paths, the default key chain, and no cwd auto-profile lookup, so agents-cli can pin LINEAR_PROFILE=default for an unprofiled project and the directory can never redirect it to another workspace. activate_profile ('default') rebinds nothing and creates no profiles/default directory, and `profile: default` on an agents project reads as the default profile. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +++- README.md | 2 +- linear | 15 ++++++++++++--- test_linear.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c373f..f60dbc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 projects view` call the project auto-scope makes; commands that never scope by project, and `autoScope: false`, skip that call. An invalid profile there is ignored with a warning. A cwd project whose profile is not the active - one is not auto-scoped to. + one is not auto-scoped to. The name `default` is reserved for the default + profile: `LINEAR_PROFILE=default` pins the default workspace and skips the + cwd's profile, and `profile: default` on a project means the same. - `setup --keychain` stores the API key in the macOS Keychain instead of `config.json`: service `linear-api-key.`, or `linear-api-key` for the default profile. diff --git a/README.md b/README.md index 5905c95..495f4b8 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ linear --profile personal setup --api-key "$KEY" --keychain # key -> Keychain linear --profile personal tasks # or: LINEAR_PROFILE=personal linear tasks ``` -A profile keeps its own config, board cache, close queue and downloaded images under `~/.linear-cli/profiles//`. Names are lowercase letters, digits, `.`, `_` and `-` (up to 64, starting with a letter or digit). `--profile` wins over `LINEAR_PROFILE`. With neither set, linear-cli uses the `linear.profile` of the `agents projects` project that owns the current directory, so an agent launched in that folder reaches that project's workspace; no `agents` CLI, no profile on the project, or an invalid one (ignored with a warning) means the default. `setup`, `queue`, `states`, `labels`, `users`, `agents` and `inbox` skip that lookup, as does everything when the default config has `autoScope: false`. The directory's project auto-scope only applies when its `linear.profile` is the active profile; a project of another workspace is not scoped to. +A profile keeps its own config, board cache, close queue and downloaded images under `~/.linear-cli/profiles//`. Names are lowercase letters, digits, `.`, `_` and `-` (up to 64, starting with a letter or digit). `default` is reserved for the default profile: `--profile default` or `LINEAR_PROFILE=default` uses `~/.linear-cli/config.json` and the default key, and keeps the current directory from choosing another profile. `--profile` wins over `LINEAR_PROFILE`. With neither set, linear-cli uses the `linear.profile` of the `agents projects` project that owns the current directory, so an agent launched in that folder reaches that project's workspace; no `agents` CLI, no profile on the project, or an invalid one (ignored with a warning) means the default. `setup`, `queue`, `states`, `labels`, `users`, `agents` and `inbox` skip that lookup, as does everything when the default config has `autoScope: false`. The directory's project auto-scope only applies when its `linear.profile` is the active profile; a project of another workspace is not scoped to. Under a profile the key comes from the profile's `config.json`, then Keychain service `linear-api-key.`; the team id from `teamId` in that config, then Keychain service `linear-team-id.`. Each profile has its own service name, so the default lookup (`linear-api-key`, any account) can never pick up a profile's key. `LINEAR_API_KEY` and `LINEAR_TEAM_ID` are ignored there because they belong to the default workspace. With no profile, nothing changes. diff --git a/linear b/linear index da8823b..03100cf 100755 --- a/linear +++ b/linear @@ -111,6 +111,9 @@ QUEUE_BACKOFF_MAX_SECONDS = 300 # 5 minutes # was before profiles existed. # Matched with fullmatch: `$` alone would let a trailing newline through. PROFILE_RE = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}") +# Reserved: names the default profile explicitly, so a caller can pin the +# default workspace (LINEAR_PROFILE=default) and keep the cwd from redirecting it. +DEFAULT_PROFILE = "default" ACTIVE_PROFILE: str | None = None # Where ACTIVE_PROFILE came from ("--profile", "LINEAR_PROFILE", or the cwd's # agents project), so a missing-key error can say why that profile is active. @@ -158,13 +161,16 @@ def activate_profile(name: str, source: str) -> None: Runs in main() before load_config(), so config, the board cache, the close queue and downloaded images of one workspace never mix with another's. Raises ValueError for a name that is not a profile slug (it becomes a - directory name, so `..` or `/` must never get through). + directory name, so `..` or `/` must never get through). `default` is the + default profile: nothing is rebound. """ global ACTIVE_PROFILE, PROFILE_SOURCE, CONFIG_PATH, QUEUE_DIR, CACHE_DB_PATH, UPLOADS_DIR # A project YAML can hand over any type (`profile: 7`), not just a string. if not isinstance(name, str) or not PROFILE_RE.fullmatch(name): raise ValueError(f"invalid profile name {name!r} (from {source}): use lowercase " "letters, digits, '.', '_' or '-', starting with a letter or digit") + if name == DEFAULT_PROFILE: + return root = Path.home() / ".linear-cli" / "profiles" / name ACTIVE_PROFILE = name PROFILE_SOURCE = source @@ -1268,8 +1274,10 @@ def query_cwd_binding(timeout: float = 3.0) -> dict: if not isinstance(data, dict): return empty lin = data.get("linear") or {} + profile = lin.get("profile") + # `profile: default` is the default profile, the same as no profile. return {"projectId": lin.get("projectId"), "name": lin.get("name"), - "profile": lin.get("profile")} + "profile": None if profile == DEFAULT_PROFILE else profile} def resolve_cwd_project(timeout: float = 3.0) -> tuple[str | None, str | None]: @@ -7034,7 +7042,8 @@ def main(): help="Override team for this invocation (key like 'ANT' or UUID)") parser.add_argument("--profile", default=None, metavar="NAME", help="Linear workspace profile (overrides LINEAR_PROFILE): " - "config, cache and key under ~/.linear-cli/profiles/NAME/") + "config, cache and key under ~/.linear-cli/profiles/NAME/; " + "'default' is the default workspace") sub = parser.add_subparsers(dest="command") # setup diff --git a/test_linear.py b/test_linear.py index b44f8b4..8dc7f80 100644 --- a/test_linear.py +++ b/test_linear.py @@ -4748,6 +4748,16 @@ def test_invalid_profile_names_are_refused_and_change_nothing(self): linear_cli.activate_profile(good, "--profile") self.assertEqual(linear_cli.CONFIG_PATH.parent.name, good) + def test_default_is_the_default_profile_not_a_directory(self): + before = (linear_cli.CONFIG_PATH, linear_cli.QUEUE_DIR, + linear_cli.CACHE_DB_PATH, linear_cli.UPLOADS_DIR) + linear_cli.activate_profile("default", "LINEAR_PROFILE") + self.assertIsNone(linear_cli.ACTIVE_PROFILE) + self.assertEqual((linear_cli.CONFIG_PATH, linear_cli.QUEUE_DIR, + linear_cli.CACHE_DB_PATH, linear_cli.UPLOADS_DIR), before) + self.assertTrue(linear_cli.save_config({"teamId": "t"})) + self.assertFalse((self.home / ".linear-cli" / "profiles").exists()) + def test_legacy_config_is_not_migrated_into_a_profile(self): legacy = linear_cli.LEGACY_CONFIG_PATH legacy.parent.mkdir() @@ -4906,6 +4916,43 @@ def test_tasks_under_a_profile_uses_its_key_config_and_cache(self): self.assertEqual(self._calls(), [ "security find-generic-password -s linear-api-key.work -w"]) + def test_linear_profile_default_pins_the_default_workspace(self): + # agents-cli pins LINEAR_PROFILE=default for unprofiled projects, so the + # cwd's project (here bound to talora) must not pick the profile. + seen = [] + self._graphql_server(seen) + self._fake_security({"linear-api-key.talora": "talora-key"}) + self._fake_agents("talora", project_id="p-talora", name="Talora") + self._set_env("LINEAR_PROFILE", "default") + default_cfg = linear_cli.CONFIG_PATH + default_cfg.parent.mkdir(mode=0o700) + default_cfg.write_text(json.dumps({ + "apiKey": "turing-cfg-key", "teamId": "team-turing", + "states": {"Todo": {"id": "s1", "type": "unstarted"}}})) + + out, err = self._run_main("tasks", "--similar", "token", "--json") + + self.assertEqual([r["identifier"] for r in json.loads(out)["rows"]], ["WRK-1"], err) + self.assertEqual(set(seen), {"turing-cfg-key"}) + self.assertIsNone(linear_cli.ACTIVE_PROFILE) + self.assertEqual(linear_cli.CONFIG_PATH, default_cfg) + conn = sqlite3.connect(self.home / ".linear-cli" / "cache.sqlite") + self.assertEqual(conn.execute("SELECT team_id FROM sync").fetchall(), [("team-turing",)]) + conn.close() + self.assertFalse((self.home / ".linear-cli" / "profiles").exists()) + self.assertNotIn("agents projects view . --json", self._calls()) + + def test_setup_with_profile_default_writes_the_default_config(self): + self._graphql_server([]) + self._fake_security() + self._run_main("--profile", "default", "setup", "--api-key", "lin_api_new", + "--keychain", "--agent", "claude") + self.assertEqual(json.loads(linear_cli.CONFIG_PATH.read_text())["teamId"], "team-p") + self.assertEqual(linear_cli.CONFIG_PATH, self.home / ".linear-cli" / "config.json") + self.assertFalse((self.home / ".linear-cli" / "profiles").exists()) + self.assertEqual(self._calls(), [ + "security add-generic-password -U -s linear-api-key -a default -w lin_api_new"]) + # -- selection in main() -------------------------------------------------- def _fake_agents(self, profile, project_id=None, name=None): @@ -5001,6 +5048,10 @@ def test_project_of_another_profile_does_not_scope(self): linear_cli._CWD_BINDING = {"projectId": "p-1", "name": "Talora", "profile": None} self.assertEqual(linear_cli.resolve_cwd_project(), ("p-1", "Talora")) + def test_project_marked_profile_default_scopes_under_the_default_profile(self): + self._fake_agents("default", project_id="p-turing", name="Rush") + self.assertEqual(linear_cli.resolve_cwd_project(), ("p-turing", "Rush")) + def test_group_shorthand_survives_a_leading_profile_flag(self): self.assertEqual(linear_cli.rewrite_group_argv(["--profile", "p", "projects", "Foo"]), ["--profile", "p", "projects", "show", "Foo"]) From 75c2cf923ca366b352382f66a820a2202ed14e23 Mon Sep 17 00:00:00 2001 From: Taylor Gagne Date: Wed, 14 Oct 2026 19:52:41 -0700 Subject: [PATCH 4/4] fix(profiles): every command but setup follows the cwd's profile The cwd profile was skipped for queue, states, labels, users, agents and inbox, so in a directory bound to a profile `update --done` queued into profiles//queue while `queue drain` there read the default queue with the default key. Profile selection from `agents projects view` now applies to every command except setup; the project auto-scope half of that answer still only applies in tasks and create. autoScope: false in the default config remains the switch that turns the cwd lookup off entirely. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +-- README.md | 2 +- linear | 17 +++++------ test_linear.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 89 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60dbc1..d069b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `linear-team-id.`); `LINEAR_API_KEY` and `LINEAR_TEAM_ID` are not read under a profile. With neither flag nor env set, the `linear.profile` of the cwd's `agents projects` project is used, from the same `agents - projects view` call the project auto-scope makes; commands that never scope - by project, and `autoScope: false`, skip that call. An invalid profile there + projects view` call the project auto-scope makes. Every command but `setup` + follows it, so `update` and `queue drain` in one directory share a queue; + `autoScope: false` in the default config turns the call off. An invalid profile there is ignored with a warning. A cwd project whose profile is not the active one is not auto-scoped to. The name `default` is reserved for the default profile: `LINEAR_PROFILE=default` pins the default workspace and skips the diff --git a/README.md b/README.md index 495f4b8..be06334 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ linear --profile personal setup --api-key "$KEY" --keychain # key -> Keychain linear --profile personal tasks # or: LINEAR_PROFILE=personal linear tasks ``` -A profile keeps its own config, board cache, close queue and downloaded images under `~/.linear-cli/profiles//`. Names are lowercase letters, digits, `.`, `_` and `-` (up to 64, starting with a letter or digit). `default` is reserved for the default profile: `--profile default` or `LINEAR_PROFILE=default` uses `~/.linear-cli/config.json` and the default key, and keeps the current directory from choosing another profile. `--profile` wins over `LINEAR_PROFILE`. With neither set, linear-cli uses the `linear.profile` of the `agents projects` project that owns the current directory, so an agent launched in that folder reaches that project's workspace; no `agents` CLI, no profile on the project, or an invalid one (ignored with a warning) means the default. `setup`, `queue`, `states`, `labels`, `users`, `agents` and `inbox` skip that lookup, as does everything when the default config has `autoScope: false`. The directory's project auto-scope only applies when its `linear.profile` is the active profile; a project of another workspace is not scoped to. +A profile keeps its own config, board cache, close queue and downloaded images under `~/.linear-cli/profiles//`. Names are lowercase letters, digits, `.`, `_` and `-` (up to 64, starting with a letter or digit). `default` is reserved for the default profile: `--profile default` or `LINEAR_PROFILE=default` uses `~/.linear-cli/config.json` and the default key, and keeps the current directory from choosing another profile. `--profile` wins over `LINEAR_PROFILE`. With neither set, linear-cli uses the `linear.profile` of the `agents projects` project that owns the current directory, so an agent launched in that folder reaches that project's workspace; no `agents` CLI, no profile on the project, or an invalid one (ignored with a warning) means the default. Every command except `setup` follows that profile, so `update` and `queue drain` in the same directory share one queue and one key; only `tasks` and `create` also scope to the directory's project. `autoScope: false` in the default `~/.linear-cli/config.json` turns the lookup off entirely: no profile and no project from the directory. The directory's project auto-scope only applies when its `linear.profile` is the active profile; a project of another workspace is not scoped to. Under a profile the key comes from the profile's `config.json`, then Keychain service `linear-api-key.`; the team id from `teamId` in that config, then Keychain service `linear-team-id.`. Each profile has its own service name, so the default lookup (`linear-api-key`, any account) can never pick up a profile's key. `LINEAR_API_KEY` and `LINEAR_TEAM_ID` are ignored there because they belong to the default workspace. With no profile, nothing changes. diff --git a/linear b/linear index 03100cf..5901e87 100755 --- a/linear +++ b/linear @@ -6987,17 +6987,11 @@ _PROJECTS_VERBS = frozenset({"show", "overview", "create", "update", "archive", _INITIATIVES_VERBS = frozenset({"show", "create", "update", "link", "unlink", "archive"}) -# Commands that never scope to a project, so they skip the cwd `agents` lookup -# (and with it the cwd's profile; --profile / LINEAR_PROFILE still apply). -UNSCOPED_COMMANDS = frozenset({"setup", "queue", "states", "labels", "users", - "agents", "inbox"}) - - def _default_auto_scope() -> bool: """`autoScope` from the default profile's config.json, read as a plain file (no legacy migration, no permission fix-up) because main() needs it before it knows which profile's config to load. `autoScope: false` turns off the - cwd lookup, and with it the cwd's profile.""" + cwd lookup entirely: no project auto-scope and no profile from the cwd.""" try: cfg = json.loads(CONFIG_PATH.read_text()) except (OSError, ValueError): @@ -7413,14 +7407,19 @@ def main(): sys.exit(0) # Pick the workspace before anything reads config: --profile, then - # LINEAR_PROFILE, then the profile the cwd's agents project names. + # LINEAR_PROFILE, then the profile the cwd's agents project names. Every + # command but setup takes the cwd's profile, even ones that never scope to + # its project: `update` queues a close into the profile's queue, so `queue + # drain` in the same directory has to read that queue with that key. The + # project half of the answer is used only where auto-scope always applied + # (tasks, create), through resolve_cwd_project. global _CWD_BINDING profile = None if args.profile is not None: profile, source = args.profile, "--profile" elif os.environ.get("LINEAR_PROFILE"): profile, source = os.environ["LINEAR_PROFILE"], "LINEAR_PROFILE" - elif args.command not in UNSCOPED_COMMANDS and _default_auto_scope(): + elif args.command != "setup" and _default_auto_scope(): _CWD_BINDING = query_cwd_binding() cwd_profile = _CWD_BINDING["profile"] if cwd_profile is not None and not (isinstance(cwd_profile, str) diff --git a/test_linear.py b/test_linear.py index 8dc7f80..f6d02e6 100644 --- a/test_linear.py +++ b/test_linear.py @@ -5010,22 +5010,93 @@ def test_no_agents_binary_keeps_the_default_profile(self): self.assertEqual(self._calls(), ["security find-generic-password -s linear-api-key -w", "security find-generic-password -s linear-team-id -w"]) - def test_unscoped_commands_and_auto_scope_off_skip_the_cwd_lookup(self): + def test_every_command_but_setup_takes_the_cwd_profile(self): + self._fake_agents("talora") + self._fake_security() + for command in ("states", "queue", "labels", "users", "agents", "inbox", "update"): + rc, err = self._cli(command, *(["X-1"] if command == "update" else [])) + self.assertEqual(rc, 1, command) + self.assertIn("No API key configured for profile 'talora' (from this " + "directory's agents project)", err, command) + + def test_auto_scope_off_skips_the_cwd_lookup(self): self._fake_agents("talora") self._fake_security() - for command in ("states", "queue", "labels", "users", "agents", "inbox"): - self._cli(command) - self.assertNotIn("agents projects view . --json", self._calls()) cfg = self.home / ".linear-cli" / "config.json" cfg.parent.mkdir(mode=0o700) cfg.write_text(json.dumps({"autoScope": False})) - rc, err = self._cli("tasks") + rc, err = self._cli("queue") self.assertIn("No API key configured. Run: linear setup", err) self.assertNotIn("agents projects view . --json", self._calls()) cfg.write_text(json.dumps({"autoScope": True})) - self._cli("tasks") + self._cli("queue") self.assertIn("agents projects view . --json", self._calls()) + def _fresh_process(self): + """Module state as a new `linear` process starts with it (temp HOME).""" + linear_cli.CONFIG_PATH = self.home / ".linear-cli" / "config.json" + linear_cli.CACHE_DB_PATH = self.home / ".linear-cli" / "cache.sqlite" + linear_cli.QUEUE_DIR = self.home / ".linear-cli" / "queue" + linear_cli.UPLOADS_DIR = self.home / ".linear-cli" / "uploads" + linear_cli.ACTIVE_PROFILE = linear_cli.PROFILE_SOURCE = linear_cli._CWD_BINDING = None + + def test_update_and_queue_share_the_cwd_profiles_queue(self): + # A directory bound to profile `work`: `update --done` drains (and so + # retries) the work queue, and `queue list` / `queue drain --dry-run` + # in the same directory read that queue, never the default one. + seen = [] + + class RateLimited(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + seen.append(self.headers.get("Authorization")) + self.rfile.read(int(self.headers["Content-Length"])) + body = json.dumps({"errors": [{"message": "Rate limit exceeded", + "extensions": {"status": 429}}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = http.server.HTTPServer(("127.0.0.1", 0), RateLimited) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + linear_cli.API_URL = f"http://127.0.0.1:{server.server_port}" + self._fake_agents("work") + self._fake_security({"linear-api-key.work": "work-key", "linear-api-key": "turing-key"}) + root = self.home / ".linear-cli" / "profiles" / "work" + root.mkdir(parents=True) + (root / "config.json").write_text(json.dumps({ + "teamId": "team-work", "states": {"Done": {"id": "s-done", "type": "completed"}}})) + default_queue = self.home / ".linear-cli" / "queue" + for queue, ident in ((root / "queue", "WRK-1"), (default_queue, "DEF-1")): + linear_cli.QUEUE_DIR = queue + linear_cli.save_queue_intent(linear_cli.build_close_intent(ident, f"id-{ident}", "Done")) + self._fresh_process() + + with self.assertRaises(SystemExit): + self._run_main("update", "WRK-1", "--done", "--proof", "shipped") + self.assertEqual(linear_cli.queue_dir(), root / "queue") + work = json.loads((root / "queue" / "WRK-1.json").read_text()) + self.assertEqual(work["attempts"], 1) # the drain retried it, and kept it + self.assertEqual(json.loads((default_queue / "DEF-1.json").read_text())["attempts"], 0) + + self._fresh_process() + out, _ = self._run_main("queue", "list") + self.assertIn("WRK-1 -> Done (attempts=1", out) + self.assertNotIn("DEF-1", out) + + self._fresh_process() + out, _ = self._run_main("queue", "drain", "--dry-run") + self.assertIn("WRK-1 -> Done (retry 1", out) + self.assertNotIn("DEF-1", out) + self.assertTrue(seen) + self.assertEqual(set(seen), {"work-key"}) + # -- project auto-scope follows the profile ------------------------------- def test_project_scope_reuses_mains_cwd_answer(self):