Skip to content

Commit b5a1c2b

Browse files
authored
Merge pull request #73 from Yif-Yang/fix/bare-subscription-auth
fix(sleep): make --bare conditional on ANTHROPIC_API_KEY (#68)
2 parents 24b5a25 + 552ddef commit b5a1c2b

2 files changed

Lines changed: 109 additions & 7 deletions

File tree

skillopt_sleep/backend.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -553,21 +553,56 @@ def __init__(self, model: str = "", claude_path: str = "claude", timeout: int =
553553
timeout=timeout)
554554
self.claude_path = claude_path
555555

556+
# Known CLI error prefixes that indicate auth or config failures.
557+
# When detected, we log a warning so the user doesn't mistake a
558+
# broken auth for "nothing to optimize" (issue #68).
559+
# Keep these specific to avoid false positives on normal model output.
560+
_CLI_ERROR_MARKERS = (
561+
"Not logged in",
562+
"Please run /login",
563+
"Authentication required",
564+
"Invalid API key",
565+
"Unauthorized: invalid x-api-key",
566+
)
567+
568+
def _detect_cli_error(self, stdout: str, stderr: str) -> None:
569+
"""Log a warning if CLI output looks like an auth/config error.
570+
571+
Only checks stderr and short stdout (< 300 chars) to avoid
572+
false-positives on legitimate model responses that mention
573+
auth-related terms.
574+
"""
575+
import logging
576+
# Long stdout is almost certainly a real model response, not an error.
577+
check_stdout = stdout if len(stdout) < 300 else ""
578+
combined = check_stdout + "\n" + stderr
579+
for marker in self._CLI_ERROR_MARKERS:
580+
if marker in combined:
581+
logging.getLogger("skillopt_sleep").warning(
582+
"Claude CLI returned a likely auth error: %s",
583+
combined[:200].replace("\n", " "),
584+
)
585+
self.last_call_error = combined[:500]
586+
return
587+
556588
def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
557589
# Run ISOLATED so the ambient Claude Code environment does not leak into
558590
# the optimizer/target call. Critically, the user's GLOBAL skills
559591
# (~/.claude/skills) are injected regardless of cwd, so we must disable
560592
# them explicitly — without this, reflect/attempt sometimes reply with a
561593
# list of the user's installed skills instead of doing the task.
562594
# --bare skip hooks, LSP, plugins (minimal mode)
595+
# Only safe with ANTHROPIC_API_KEY auth;
596+
# breaks subscription-token auth (#68).
563597
# --disable-slash-commands disable all skills
564598
# --disallowedTools '*' no tool use
565599
# --exclude-dynamic-... drop per-machine cwd/env/memory/git sections
566600
# cwd=<clean temp> no project CLAUDE.md
567601
import tempfile
568-
cmd = [
569-
self.claude_path, "-p", "--output-format", "text",
570-
"--bare",
602+
cmd = [self.claude_path, "-p", "--output-format", "text"]
603+
if os.environ.get("ANTHROPIC_API_KEY"):
604+
cmd.append("--bare")
605+
cmd += [
571606
"--disable-slash-commands",
572607
"--disallowedTools", "*",
573608
"--exclude-dynamic-system-prompt-sections",
@@ -588,7 +623,9 @@ def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
588623
shutil.rmtree(clean_cwd, ignore_errors=True)
589624
except Exception:
590625
pass
591-
return (proc.stdout or "").strip()
626+
out = (proc.stdout or "").strip()
627+
self._detect_cli_error(out, proc.stderr or "")
628+
return out
592629

593630
def attempt_with_tools(self, task, skill, memory, tools):
594631
# Expose a REAL, callable `search` tool (a shell shim that logs each
@@ -625,9 +662,11 @@ def attempt_with_tools(self, task, skill, memory, tools):
625662
f"# Task\n{task.intent}\n\n{task.context_excerpt}\n\n"
626663
"Return ONLY the final answer text."
627664
)
628-
cmd = [
629-
self.claude_path, "-p", "--output-format", "text",
630-
"--bare", "--disable-slash-commands",
665+
cmd = [self.claude_path, "-p", "--output-format", "text"]
666+
if os.environ.get("ANTHROPIC_API_KEY"):
667+
cmd.append("--bare")
668+
cmd += [
669+
"--disable-slash-commands",
631670
"--allowedTools", "Bash",
632671
"--exclude-dynamic-system-prompt-sections",
633672
]
@@ -639,6 +678,7 @@ def attempt_with_tools(self, task, skill, memory, tools):
639678
cmd, capture_output=True, text=True, timeout=self.timeout, cwd=work,
640679
)
641680
resp = (proc.stdout or "").strip()
681+
self._detect_cli_error(resp, proc.stderr or "")
642682
except Exception:
643683
resp = ""
644684
self._tokens += len(prompt) // 4 + len(resp) // 4

tests/test_sleep_engine.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,5 +944,67 @@ def test_attempt_with_tools_honest_detection(self):
944944
shutil.rmtree(stub_dir, ignore_errors=True)
945945

946946

947+
class TestClaudeCliBackendBare(unittest.TestCase):
948+
"""Issue #68: --bare must be conditional on ANTHROPIC_API_KEY."""
949+
950+
def test_bare_included_when_api_key_set(self):
951+
"""With ANTHROPIC_API_KEY, --bare should appear in the command."""
952+
from skillopt_sleep.backend import ClaudeCliBackend
953+
be = ClaudeCliBackend(claude_path="/usr/bin/false", timeout=5)
954+
with unittest.mock.patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}):
955+
# We can't run the real CLI, but we can inspect cmd construction
956+
# by monkeypatching subprocess.run to capture the command.
957+
captured = {}
958+
def fake_run(cmd, **kwargs):
959+
captured["cmd"] = cmd
960+
class FakeProc:
961+
stdout = "hello"
962+
stderr = ""
963+
returncode = 0
964+
return FakeProc()
965+
with unittest.mock.patch("subprocess.run", side_effect=fake_run):
966+
be._call("test prompt")
967+
self.assertIn("--bare", captured["cmd"])
968+
969+
def test_bare_omitted_without_api_key(self):
970+
"""Without ANTHROPIC_API_KEY, --bare should NOT appear."""
971+
from skillopt_sleep.backend import ClaudeCliBackend
972+
be = ClaudeCliBackend(claude_path="/usr/bin/false", timeout=5)
973+
env = os.environ.copy()
974+
env.pop("ANTHROPIC_API_KEY", None)
975+
with unittest.mock.patch.dict(os.environ, env, clear=True):
976+
captured = {}
977+
def fake_run(cmd, **kwargs):
978+
captured["cmd"] = cmd
979+
class FakeProc:
980+
stdout = "hello"
981+
stderr = ""
982+
returncode = 0
983+
return FakeProc()
984+
with unittest.mock.patch("subprocess.run", side_effect=fake_run):
985+
be._call("test prompt")
986+
self.assertNotIn("--bare", captured["cmd"])
987+
988+
def test_cli_error_detected_and_logged(self):
989+
"""Auth errors in CLI output should trigger a warning."""
990+
from skillopt_sleep.backend import ClaudeCliBackend
991+
be = ClaudeCliBackend(claude_path="/usr/bin/false", timeout=5)
992+
captured = {}
993+
def fake_run(cmd, **kwargs):
994+
captured["cmd"] = cmd
995+
class FakeProc:
996+
stdout = "Not logged in · Please run /login"
997+
stderr = ""
998+
returncode = 0
999+
return FakeProc()
1000+
with unittest.mock.patch.dict(os.environ, {}, clear=False):
1001+
with unittest.mock.patch("subprocess.run", side_effect=fake_run):
1002+
result = be._call("test prompt")
1003+
# The error string is returned as output (backwards-compat)
1004+
self.assertIn("Not logged in", result)
1005+
# But it's also recorded for detection
1006+
self.assertIn("Not logged in", getattr(be, "last_call_error", ""))
1007+
1008+
9471009
if __name__ == "__main__":
9481010
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)