chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:33 +08:00
commit fc4fcbab58
1848 changed files with 472303 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: codexbar
description: "CodexBar read. Provider usage, limits, credits, config health. JSON. No writes."
---
# CodexBar
Read CodexBar. Never mutate config/auth.
## Run
```bash
skill="${CODEX_HOME:-$HOME/.codex}/skills/codexbar"
"$skill/scripts/codexbar" doctor
"$skill/scripts/codexbar" providers
"$skill/scripts/codexbar" usage
"$skill/scripts/codexbar" usage --provider codex
"$skill/scripts/codexbar" usage --all
```
All stdout: JSON. Upstream CodexBar shape kept. Less drift, fewer tokens.
## Rules
- Start `doctor` when install/config unknown.
- `usage` reads enabled providers. Prefer this.
- `usage --provider ID` reads one provider.
- `usage --all` expensive; use only when needed.
- Identities hidden by default. `--include-identities` only when user explicitly needs them.
- Secrets always hidden.
- Helper read-only: fixed allowlist only. No config writes, auth repair, enable/disable, key storage.
- Timeout means upstream stuck. Narrow provider or raise `CODEXBAR_TIMEOUT` (default 120 seconds).
## Binary
Auto-find: `CODEXBAR_BIN`, PATH, app bundle, Homebrew cask. If missing: open CodexBar, Preferences > Advanced > Install CLI; or set `CODEXBAR_BIN`.
Each stdout/stderr stream capped at 1 MiB while fully drained. Timeout kills process group.
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""Read-only, bounded CodexBar CLI bridge."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
MAX_CAPTURE_BYTES = 1024 * 1024
DEFAULT_TIMEOUT = 120.0
BIN_ENV = "CODEXBAR_BIN"
TIMEOUT_ENV = "CODEXBAR_TIMEOUT"
SKIP_DISCOVERY_ENV = "CODEXBAR_SKIP_DISCOVERY"
SECRET = "<redacted:secret>"
IDENTITY = "<redacted:identity>"
EMAIL = "<redacted:email>"
EMAIL_RE = re.compile(r"\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b", re.I)
BEARER_RE = re.compile(r"(?i)\bbearer\s+[A-Z0-9._~+/=\-]+")
JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b")
LABELED_SECRET_RE = re.compile(
r"(?i)\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
r"session(?:id)?|secret|password|passwd|cookie|set-cookie)(\s*[:=]\s*)([^\s,;]+)"
)
WORD_SECRET_RE = re.compile(
r"(?i)\b(token|secret|password|passwd|cookie)\s+([A-Z0-9._~+/=\-]{6,})\b"
)
SECRET_KEY_RE = re.compile(
r"(?i)(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
r"session(?:id)?|secret|password|passwd|cookie)"
)
IDENTITY_KEYS = {
"accountemail",
"accountorganization",
"accountid",
"accountname",
"email",
"organization",
"userid",
"username",
}
# ProviderIdentitySnapshot.providerID is UsageProvider, not an account identifier.
@dataclass(frozen=True)
class Binary:
path: str
source: str
@dataclass(frozen=True)
class Result:
returncode: int
stdout: bytes
stderr: bytes
timed_out: bool
def normalized_key(value: str) -> str:
return re.sub(r"[^a-z0-9]", "", value.lower())
def timeout_seconds() -> float:
try:
return max(1.0, float(os.environ.get(TIMEOUT_ENV, DEFAULT_TIMEOUT)))
except ValueError:
return DEFAULT_TIMEOUT
def safe_path(path: str) -> str:
home = str(Path.home())
if path == home:
return "~"
if path.startswith(home + os.sep):
return "~" + path[len(home) :]
return re.sub(r"^/Users/[^/]+", "/Users/<redacted>", path)
def candidates() -> list[tuple[str, Path]]:
found: list[tuple[str, Path]] = []
override = os.environ.get(BIN_ENV)
if override:
found.append(("env", Path(override).expanduser()))
path_hit = shutil.which("codexbar")
if path_hit:
found.append(("path", Path(path_hit)))
if os.environ.get(SKIP_DISCOVERY_ENV) == "1":
return found
home = Path.home()
found.extend(
[
("homebrew", Path("/opt/homebrew/bin/codexbar")),
("homebrew", Path("/usr/local/bin/codexbar")),
("app", Path("/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI")),
("app", home / "Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"),
]
)
for root in (Path("/opt/homebrew/Caskroom/codexbar"), Path("/usr/local/Caskroom/codexbar")):
found.extend(("cask", app / "Contents/Helpers/CodexBarCLI") for app in sorted(root.glob("*/CodexBar.app")))
return found
def resolve_binary() -> Binary | None:
self_path = Path(__file__).resolve()
seen: set[Path] = set()
for source, candidate in candidates():
try:
resolved = candidate.resolve()
if resolved in seen or resolved == self_path:
continue
seen.add(resolved)
if resolved.is_file() and os.access(resolved, os.X_OK):
return Binary(str(resolved), source)
except OSError:
continue
return None
def drain(pipe: Any, target: bytearray) -> None:
try:
while True:
chunk = pipe.read(65536)
if not chunk:
break
room = MAX_CAPTURE_BYTES - len(target)
if room > 0:
target.extend(chunk[:room])
finally:
pipe.close()
def stop_group(process: subprocess.Popen[bytes], sig: signal.Signals) -> None:
try:
os.killpg(process.pid, sig)
except ProcessLookupError:
pass
def run_process(argv: Sequence[str]) -> Result:
process = subprocess.Popen(
list(argv),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
assert process.stdout is not None
assert process.stderr is not None
stdout = bytearray()
stderr = bytearray()
readers = [
threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True),
threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True),
]
for reader in readers:
reader.start()
timed_out = False
try:
process.wait(timeout=timeout_seconds())
except subprocess.TimeoutExpired:
timed_out = True
stop_group(process, signal.SIGTERM)
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
stop_group(process, signal.SIGKILL)
process.wait()
for reader in readers:
reader.join(timeout=3)
return Result(124 if timed_out else process.returncode, bytes(stdout), bytes(stderr), timed_out)
def decode(value: bytes) -> str:
return value.decode("utf-8", errors="replace")
def sanitize_text(value: str, include_identities: bool) -> str:
value = JWT_RE.sub(SECRET, value)
value = BEARER_RE.sub("Bearer " + SECRET, value)
value = LABELED_SECRET_RE.sub(lambda match: match.group(1) + match.group(2) + SECRET, value)
value = WORD_SECRET_RE.sub(lambda match: match.group(1) + " " + SECRET, value)
if not include_identities:
value = EMAIL_RE.sub(EMAIL, value)
return value
def sanitize(value: Any, include_identities: bool, key: str | None = None) -> Any:
if key and SECRET_KEY_RE.search(key):
return SECRET
if key and normalized_key(key) in IDENTITY_KEYS and not include_identities and value is not None:
return EMAIL if "email" in normalized_key(key) else IDENTITY
if isinstance(value, dict):
return {str(child_key): sanitize(child, include_identities, str(child_key)) for child_key, child in value.items()}
if isinstance(value, list):
return [sanitize(child, include_identities) for child in value]
if isinstance(value, str):
return sanitize_text(value, include_identities)
return value
def print_json(value: Any) -> None:
print(json.dumps(value, indent=2, sort_keys=True))
def print_error(kind: str, message: str, *, binary: Binary | None = None) -> None:
payload: dict[str, Any] = {"error": {"kind": kind, "message": sanitize_text(message, False)}}
if binary:
payload["binary"] = {"path": safe_path(binary.path), "source": binary.source}
print_json(payload)
def emit_stderr(result: Result, include_identities: bool) -> None:
text = sanitize_text(decode(result.stderr), include_identities).strip()
if text:
print(text, file=sys.stderr)
def read_json(binary: Binary, argv: Sequence[str], include_identities: bool) -> int:
result = run_process([binary.path, *argv])
emit_stderr(result, include_identities)
if result.timed_out:
print_error("timeout", f"CodexBar exceeded {timeout_seconds():g}s and was stopped.", binary=binary)
return 124
try:
payload = json.loads(decode(result.stdout))
except json.JSONDecodeError as error:
preview = sanitize_text(decode(result.stdout[:400]), False)
print_error("invalid_json", f"CodexBar JSON failed: {error.msg}. stdout={preview!r}", binary=binary)
return result.returncode or 1
print_json(sanitize(payload, include_identities))
return result.returncode
def doctor(binary: Binary, include_identities: bool) -> int:
version = run_process([binary.path, "--version"])
if version.timed_out:
print_error("timeout", f"CodexBar version exceeded {timeout_seconds():g}s.", binary=binary)
return 124
validation = run_process([binary.path, "config", "validate", "--format", "json", "--json-only"])
emit_stderr(version, include_identities)
emit_stderr(validation, include_identities)
if validation.timed_out:
print_error("timeout", f"CodexBar config validation exceeded {timeout_seconds():g}s.", binary=binary)
return 124
try:
issues = json.loads(decode(validation.stdout))
except json.JSONDecodeError as error:
print_error("invalid_json", f"CodexBar config validation failed: {error.msg}.", binary=binary)
return validation.returncode or 1
print_json(
{
"binary": {"path": safe_path(binary.path), "source": binary.source},
"configIssues": sanitize(issues, include_identities),
"version": sanitize_text((decode(version.stdout) or decode(version.stderr)).strip(), include_identities),
}
)
return version.returncode or validation.returncode
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(prog="codexbar", description="CodexBar read. JSON out. No writes.")
commands = root.add_subparsers(dest="command", required=True)
for name in ("doctor", "providers"):
command = commands.add_parser(name)
command.add_argument("--include-identities", action="store_true")
usage = commands.add_parser("usage")
scope = usage.add_mutually_exclusive_group()
scope.add_argument("--all", action="store_true")
scope.add_argument("--provider")
usage.add_argument("--include-identities", action="store_true")
return root
def main(argv: Sequence[str] | None = None) -> int:
args = parser().parse_args(argv)
binary = resolve_binary()
if not binary:
print_error("missing", "CodexBar CLI not found. Install CLI in CodexBar Advanced settings or set CODEXBAR_BIN.")
return 1
try:
if args.command == "doctor":
return doctor(binary, args.include_identities)
if args.command == "providers":
return read_json(
binary,
["config", "providers", "--format", "json", "--json-only"],
args.include_identities,
)
usage_args = ["usage", "--format", "json", "--json-only"]
if args.all:
usage_args.extend(["--provider", "all"])
elif args.provider:
usage_args.extend(["--provider", args.provider])
return read_json(binary, usage_args, args.include_identities)
except OSError as error:
print_error("launch", str(error), binary=binary)
return 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print_error("interrupted", "Interrupted.")
raise SystemExit(130)
@@ -0,0 +1,231 @@
from __future__ import annotations
import json
import os
import runpy
import subprocess
import sys
import tempfile
import textwrap
import time
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("codexbar")
MODULE = runpy.run_path(str(SCRIPT), run_name="codexbar_skill")
MAX_CAPTURE_BYTES = MODULE["MAX_CAPTURE_BYTES"]
run_process = MODULE["run_process"]
FAKE = textwrap.dedent(
"""\
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import time
argv = sys.argv[1:]
log = os.environ.get("FAKE_LOG")
if log:
with open(log, "a", encoding="utf-8") as handle:
handle.write(json.dumps(argv) + "\\n")
if argv == ["--version"]:
key = "VERSION"
elif argv[:2] == ["config", "validate"]:
key = "VALIDATE"
elif argv[:2] == ["config", "providers"]:
key = "PROVIDERS"
elif argv and argv[0] == "usage":
key = "USAGE"
else:
key = "OTHER"
if os.environ.get("FAKE_SPAWN_CHILD") == "1":
marker = os.environ["FAKE_MARKER"]
subprocess.Popen([
sys.executable,
"-c",
f"import pathlib,time; time.sleep(2); pathlib.Path({marker!r}).write_text('alive')",
])
time.sleep(5)
delay = os.environ.get(f"FAKE_{key}_DELAY")
if delay:
time.sleep(float(delay))
stdout = os.environ.get(f"FAKE_{key}_STDOUT", "")
stderr = os.environ.get(f"FAKE_{key}_STDERR", "")
sys.stdout.write(stdout)
sys.stderr.write(stderr)
raise SystemExit(int(os.environ.get(f"FAKE_{key}_EXIT", "0")))
"""
)
def make_env(root: Path, *, install: bool = True) -> tuple[dict[str, str], Path]:
binary = root / "CodexBar.app" / "Contents" / "Helpers" / "CodexBarCLI"
binary.parent.mkdir(parents=True)
binary.write_text(FAKE, encoding="utf-8")
binary.chmod(0o755)
env = os.environ.copy()
env["CODEXBAR_SKIP_DISCOVERY"] = "1"
env["CODEXBAR_TIMEOUT"] = "3"
env["FAKE_LOG"] = str(root / "argv.log")
env["FAKE_VERSION_STDOUT"] = "CodexBar 1.2.3\n"
env["FAKE_VALIDATE_STDOUT"] = "[]"
env["FAKE_PROVIDERS_STDOUT"] = json.dumps(
[{"provider": "codex", "displayName": "Codex", "enabled": True}]
)
env["FAKE_USAGE_STDOUT"] = json.dumps(
[
{
"provider": "codex",
"source": "oauth",
"usage": {
"accountEmail": "alice@example.com",
"accountOrganization": "Example Org",
"identity": {"providerID": "codex", "accountID": "acct-123"},
"primary": {"usedPercent": 42, "windowMinutes": 300},
},
}
]
)
if install:
env["CODEXBAR_BIN"] = str(binary)
else:
env.pop("CODEXBAR_BIN", None)
env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin"
return env, binary
def helper(*args: str, env: dict[str, str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True,
text=True,
check=False,
env=env,
cwd=cwd,
)
class CodexBarSkillTests(unittest.TestCase):
def test_help_is_small_and_read_only(self) -> None:
result = helper("--help", env=os.environ.copy())
self.assertEqual(result.returncode, 0)
self.assertIn("CodexBar read. JSON out. No writes.", result.stdout)
self.assertNotIn("enable", result.stdout)
self.assertNotIn("set-api-key", result.stdout)
def test_missing_binary_is_json(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env, _ = make_env(Path(tmp), install=False)
result = helper("doctor", env=env)
self.assertEqual(result.returncode, 1)
self.assertEqual(json.loads(result.stdout)["error"]["kind"], "missing")
def test_doctor_reports_version_and_raw_validation_shape(self) -> None:
with tempfile.TemporaryDirectory(dir=Path.home()) as tmp:
env, binary = make_env(Path(tmp))
result = helper("doctor", env=env)
self.assertEqual(result.returncode, 0)
payload = json.loads(result.stdout)
self.assertEqual(payload["version"], "CodexBar 1.2.3")
self.assertEqual(payload["configIssues"], [])
self.assertEqual(payload["binary"]["path"], "~" + str(binary)[len(str(Path.home())) :])
def test_providers_passes_upstream_json_without_second_schema(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env, _ = make_env(Path(tmp))
result = helper("providers", env=env)
self.assertEqual(result.returncode, 0)
self.assertEqual(
json.loads(result.stdout),
[{"provider": "codex", "displayName": "Codex", "enabled": True}],
)
def test_usage_defaults_to_enabled_and_runs_from_any_cwd(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
env, _ = make_env(root)
elsewhere = root / "elsewhere"
elsewhere.mkdir()
result = helper("usage", env=env, cwd=elsewhere)
calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()]
self.assertEqual(result.returncode, 0)
self.assertEqual(calls, [["usage", "--format", "json", "--json-only"]])
def test_usage_scope_maps_to_upstream_cli(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
env, _ = make_env(root)
self.assertEqual(helper("usage", "--all", env=env).returncode, 0)
self.assertEqual(helper("usage", "--provider", "zai", env=env).returncode, 0)
calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()]
self.assertEqual(calls[0][-2:], ["--provider", "all"])
self.assertEqual(calls[1][-2:], ["--provider", "zai"])
def test_usage_hides_identity_but_preserves_provider_id(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env, _ = make_env(Path(tmp))
env["FAKE_USAGE_STDERR"] = (
"Authorization: Bearer secret-token alice@example.com; token sk-live-prose-secret\n"
)
result = helper("usage", env=env)
payload = json.loads(result.stdout)[0]
self.assertEqual(payload["provider"], "codex")
self.assertEqual(payload["usage"]["identity"]["providerID"], "codex")
self.assertEqual(payload["usage"]["accountEmail"], "<redacted:email>")
self.assertEqual(payload["usage"]["accountOrganization"], "<redacted:identity>")
self.assertEqual(payload["usage"]["identity"]["accountID"], "<redacted:identity>")
self.assertNotIn("secret-token", result.stderr)
self.assertNotIn("sk-live-prose-secret", result.stderr)
self.assertIn("<redacted:secret>", result.stderr)
def test_include_identities_never_exposes_secret_keys(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env, _ = make_env(Path(tmp))
payload = json.loads(env["FAKE_USAGE_STDOUT"])
payload[0]["usage"]["apiKey"] = "super-secret"
env["FAKE_USAGE_STDOUT"] = json.dumps(payload)
result = helper("usage", "--include-identities", env=env)
usage = json.loads(result.stdout)[0]["usage"]
self.assertEqual(usage["accountEmail"], "alice@example.com")
self.assertEqual(usage["apiKey"], "<redacted:secret>")
def test_each_stream_is_capped_while_fully_drained(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
writer = root / "writer"
writer.write_text(
"#!/usr/bin/env python3\n"
"import sys\n"
"sys.stdout.buffer.write(b'o' * 2000000)\n"
"sys.stderr.buffer.write(b'e' * 2000000)\n",
encoding="utf-8",
)
writer.chmod(0o755)
result = run_process([str(writer)])
self.assertEqual(result.returncode, 0)
self.assertEqual(len(result.stdout), MAX_CAPTURE_BYTES)
self.assertEqual(len(result.stderr), MAX_CAPTURE_BYTES)
def test_timeout_kills_process_group(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
env, _ = make_env(root)
marker = root / "child-survived"
env["CODEXBAR_TIMEOUT"] = "1"
env["FAKE_SPAWN_CHILD"] = "1"
env["FAKE_MARKER"] = str(marker)
result = helper("usage", env=env)
time.sleep(2.2)
self.assertEqual(result.returncode, 124)
self.assertEqual(json.loads(result.stdout)["error"]["kind"], "timeout")
self.assertFalse(marker.exists())
if __name__ == "__main__":
unittest.main()
+118
View File
@@ -0,0 +1,118 @@
---
name: qa-test
description: "CodexBar live QA/e2e testing: run provider usage matrix checks, validate real app config, use Peekaboo for menu proof, use Browser Use/official docs for API spec or logged-in dashboard checks, and handle 1Password credentials safely."
---
# CodexBar Live QA
Use for live provider testing, release smoke tests, menu verification, or debugging “provider works/fails” reports.
## Rules
- Work from the CodexBar repo checkout.
- Use the packaged CLI first: `CodexBar.app/Contents/Helpers/CodexBarCLI`.
- Do not use `CodexBar.app/Contents/MacOS/codexbar`; that is the app binary and may appear to hang as a CLI.
- Never run broad `env`, `set`, or secret regex dumps.
- Use `$one-password` for secrets: all `op` commands inside one persistent tmux session, service account first, no raw secret output.
- Treat browser-cookie/keychain flows as prompt-risky. Prefer CLI/API-token checks and `KeychainNoUIQuery`-safe tests unless the user explicitly requested live UI.
- For current API behavior, browse official provider docs only.
## CLI Matrix
Run the bundled script:
```bash
.agents/skills/qa-test/scripts/live_provider_matrix.sh --enabled
```
Useful modes:
```bash
.agents/skills/qa-test/scripts/live_provider_matrix.sh --provider all
.agents/skills/qa-test/scripts/live_provider_matrix.sh --providers openai,zai,deepseek
.agents/skills/qa-test/scripts/live_provider_matrix.sh --default
```
Interpretation:
- `--enabled` asks `CodexBarCLI config providers` for enabled providers, honoring `CODEXBAR_CONFIG` and default toggles.
- `--default` runs the app-facing default command with no provider override.
- `--provider all` forces every registered provider and is expected to fail for providers without sessions/keys.
- A green app config needs `--enabled` and `--default` clean; `--provider all` is a discovery/triage tool.
## Config QA
Validate config:
```bash
CodexBar.app/Contents/Helpers/CodexBarCLI config validate
stat -f '%Lp %N' "$HOME/.codexbar/config.json"
```
Redact config shape:
```bash
jq '(.providers // []) |= map(.apiKey = (if .apiKey then "<redacted>" else .apiKey end) |
.secretKey = (if .secretKey then "<redacted>" else .secretKey end) |
.cookieHeader = (if .cookieHeader then "<redacted>" else .cookieHeader end) |
(if .id == "stepfun" and has("region") then .region = "<redacted>" else . end) |
.tokenAccounts = (if .tokenAccounts then (.tokenAccounts | .accounts = (.accounts | map(.token = "<redacted>"))) else .tokenAccounts end))' \
"$HOME/.codexbar/config.json"
```
Before editing config, make a backup:
```bash
cp "$HOME/.codexbar/config.json" "$HOME/.codexbar/config.pre-qa-$(date +%Y%m%d%H%M%S).json"
chmod 600 "$HOME/.codexbar"/config.pre-qa-*.json
```
## Live Menu QA
Use Peekaboo after CLI checks:
```bash
pkill -x CodexBar || pkill -f 'CodexBar.app/Contents/MacOS/CodexBar' || true
open -n "$PWD/CodexBar.app"
peekaboo menu list-all --json | rg -i 'codexbar'
peekaboo menu click-extra --title codexbar-merged --json
screencapture -x /tmp/codexbar-live-menu.png
```
Crop top-right menu if needed:
```bash
sips --cropToHeightWidth 900 340 --cropOffset 20 2650 /tmp/codexbar-live-menu.png \
--out /tmp/codexbar-live-menu-crop.png >/dev/null
```
Verify visually with `view_image`. Confirm provider tabs/rows match enabled config and no failing provider dominates the first screen.
## Browser Use
Use `$browser-use` only when a logged-in dashboard, API key page, or provider docs need browser/profile state.
Existing Chrome path:
```bash
mcporter call chrome-devtools.list_pages --args '{}' --output text
mcporter call chrome-devtools.navigate_page --args '{"url":"https://provider.example"}' --output text
mcporter call chrome-devtools.take_snapshot --args '{}' --output text
```
If Browser Use is unavailable, say so and use web search for public official docs; do not substitute isolated Playwright for login/profile-dependent pages.
## Fix Triage
- Missing auth/session: configure key/session if available; otherwise leave provider disabled or report blocked auth.
- Wrong provider API/spec: inspect official docs, then patch fetcher/settings/tests.
- Provider key exists but live API rejects it: keep key stored if useful, disable provider if the menu would show a persistent error.
- User-facing behavior changes need `CHANGELOG.md`.
- Code fixes need focused tests, `make check`, `$autoreview`, and live CLI proof before landing.
## Known CodexBar QA Notes
- OpenAI Admin API key is the useful usage provider key. Project `OPENAI_API_KEY` values can fail legacy credit-balance fallback with 403.
- Deepgram usage requires a key/project with Management API permissions; transcription-only keys can return 403.
- Groq usage uses the Prometheus metrics API, not ordinary inference endpoints.
- MiniMax pay-as-you-go API keys and Token Plan/Coding Plan keys are different; wrong key kind can leave usage unavailable.
@@ -0,0 +1,4 @@
interface:
display_name: "CodexBar QA Test"
short_description: "Run live CodexBar CLI and menu QA safely."
default_prompt: "Run CodexBar live QA with CLI, Peekaboo, browser docs, and 1Password-safe credential checks."
@@ -0,0 +1,10 @@
# API Spec Pointers
Use current official docs for provider API behavior. Prefer these searches/pages before patching fetchers:
- MiniMax: `https://platform.minimax.io/docs/llms.txt`; key types differ between pay-as-you-go API keys and Token Plan/Coding Plan keys.
- Deepgram: `https://developers.deepgram.com/llms.txt`; usage/project APIs require Management permissions and project-scoped keys.
- Groq: `https://console.groq.com/docs/prometheus-metrics`; usage metrics use `https://api.groq.com/v1/metrics/prometheus`.
- LLM Proxy/LiteLLM: `https://docs.litellm.ai/`; CodexBar expects an LLM-API-Key-Proxy compatible `/v1/quota-stats` endpoint plus base URL.
When citing docs in a user-facing answer, browse the current page and include source links.
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}"
TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v gtimeout || command -v timeout || true)}"
WEB_TIMEOUT="${CODEXBAR_QA_WEB_TIMEOUT:-12}"
CASE_TIMEOUT="${CODEXBAR_QA_CASE_TIMEOUT:-60}"
usage() {
cat <<'USAGE'
Usage:
live_provider_matrix.sh --enabled
live_provider_matrix.sh --default
live_provider_matrix.sh --provider all
live_provider_matrix.sh --providers openai,zai,deepseek
Environment:
CODEXBAR_CLI=/path/to/CodexBarCLI
CODEXBAR_CONFIG=/path/to/config.json
CODEXBAR_QA_WEB_TIMEOUT=12
CODEXBAR_QA_CASE_TIMEOUT=60
USAGE
}
if [[ ! -x "$CLI" ]]; then
echo "missing CodexBarCLI at $CLI" >&2
exit 2
fi
if [[ -z "$TIMEOUT_BIN" ]]; then
echo "missing timeout command (install coreutils for gtimeout)" >&2
exit 2
fi
if ! command -v node >/dev/null 2>&1; then
echo "missing node" >&2
exit 2
fi
mode="${1:-}"
shift || true
providers=()
case "$mode" in
--enabled)
provider_status="$(mktemp)"
provider_err="$(mktemp)"
provider_list="$(mktemp)"
if ! "$CLI" config providers --format json --json-only >"$provider_status" 2>"$provider_err"; then
rm -f "$provider_status" "$provider_err" "$provider_list"
echo "failed to list providers via CodexBarCLI config providers" >&2
exit 2
fi
if ! node - "$provider_status" >"$provider_list" <<'NODE'; then
const fs = require("fs");
const path = process.argv[2];
const raw = fs.readFileSync(path, "utf8").trim();
const payload = JSON.parse(raw);
if (!Array.isArray(payload)) {
throw new Error("config providers output is not an array");
}
for (const item of payload) {
if (item && item.enabled === true && typeof item.provider === "string" && item.provider) {
console.log(item.provider);
}
}
NODE
rm -f "$provider_status" "$provider_err" "$provider_list"
echo "failed to parse CodexBarCLI config providers output" >&2
exit 2
fi
while IFS= read -r provider; do
[[ -n "$provider" ]] && providers+=("$provider")
done <"$provider_list"
rm -f "$provider_status" "$provider_err" "$provider_list"
if [[ "${#providers[@]}" -eq 0 ]]; then
echo "no enabled providers found via CodexBarCLI config providers" >&2
exit 2
fi
;;
--default)
providers=("__default__")
;;
--provider)
if [[ -z "${1:-}" ]]; then
echo "missing provider" >&2
exit 2
fi
providers=("${1:-}")
;;
--providers)
if [[ -z "${1:-}" ]]; then
echo "missing providers" >&2
exit 2
fi
IFS=',' read -r -a providers <<< "${1:-}"
;;
-h|--help|"")
usage
exit 0
;;
*)
echo "unknown mode: $mode" >&2
usage >&2
exit 2
;;
esac
redact_node='
const redact = s => String(s || "")
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/g, "<email>")
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED")
.replace(/gsk_[A-Za-z0-9_-]{12,}/g, "gsk_REDACTED")
.replace(/[A-Za-z0-9_-]{32,}/g, m => /[A-Za-z]/.test(m) && /[0-9]/.test(m) ? "<redacted-token>" : m);
'
run_one() {
local name="$1"
shift
local out err start end elapsed st node_status
out="$(mktemp)"
err="$(mktemp)"
start="$(date +%s)"
"$TIMEOUT_BIN" "$CASE_TIMEOUT" "$CLI" usage "$@" --format json --json-only --web-timeout "$WEB_TIMEOUT" >"$out" 2>"$err"
st=$?
end="$(date +%s)"
elapsed=$((end - start))
node - "$name" "$st" "$elapsed" "$out" "$err" <<NODE
const fs = require("fs");
$redact_node
const [name, st, elapsed, outPath, errPath] = process.argv.slice(2);
const raw = fs.readFileSync(outPath, "utf8").trim();
const err = fs.readFileSync(errPath, "utf8").trim();
let rows = [];
let formatterFailed = false;
try {
const payload = raw ? JSON.parse(raw) : [];
const arr = Array.isArray(payload) ? payload : [payload];
for (const p of arr) {
rows.push(
\`\${p.provider || name}:\${p.error ? "fail" : "ok"}:source=\${p.source || "unknown"}\` +
(p.account ? \`,account=\${redact(p.account)}\` : "") +
(p.usage ? ",usage=yes" : "") +
(p.credits ? ",credits=yes" : "") +
(p.error ? \`,error=\${redact(p.error.message).slice(0, 180)}\` : "")
);
}
} catch (error) {
formatterFailed = true;
rows.push(\`\${name}:parse-fail:error=\${redact(error.message)} stdout=\${redact(raw).slice(0, 200)} stderr=\${redact(err).slice(0, 200)}\`);
}
if (!rows.length) {
formatterFailed = true;
rows.push(\`\${name}:empty:stderr=\${redact(err).slice(0, 200)}\`);
}
console.log(\`TEST \${name} exit=\${st} elapsed=\${elapsed}s :: \${rows.join(" | ")}\`);
if (formatterFailed) process.exit(1);
NODE
node_status=$?
rm -f "$out" "$err"
if [[ "$node_status" -ne 0 ]]; then
return 1
fi
return "$st"
}
overall=0
ran=0
for provider in "${providers[@]}"; do
[[ -z "$provider" ]] && continue
ran=$((ran + 1))
if [[ "$provider" == "__default__" ]]; then
run_one default || overall=1
elif [[ "$provider" == "all" ]]; then
run_one all --provider all || overall=1
else
run_one "$provider" --provider "$provider" || overall=1
fi
done
if [[ "$ran" -eq 0 ]]; then
echo "no provider cases ran" >&2
exit 2
fi
exit "$overall"
+141
View File
@@ -0,0 +1,141 @@
---
name: release-codexbar
description: "CodexBar release: versioning, notarization, appcast, Homebrew, post-release bump."
---
# CodexBar Release
Use for releasing signed/notarized macOS apps, especially repos with Sparkle appcasts and Homebrew casks.
## Start
1. Work from the app repo unless asked otherwise.
2. Check repo state, current version, latest tag/release, and release docs/scripts.
3. Confirm `CHANGELOG.md` is complete, user-facing, deduped, and dated for the release.
4. Prefer the repo release script; patch small script/test blockers instead of bypassing the release path.
5. Never print key material. Keep 1Password references and local key paths as references only.
6. Load `$release-private` if it exists before resolving Peter-owned credential locators.
## Key Material
Use `$one-password` for secret handling. `op` only in tmux/persistent shell; no broad `env`, `set`, `export -p`, or secret scans.
Known App Store Connect shape:
- fields: `private_key_p8`, `key_id`, `issuer_id`
- keep all three fields from the same 1Password item; do not mix with stale values from `~/.profile`
- resolve Peter-owned item refs from `$release-private`
Known Sparkle key:
- resolve the private key file from `$release-private`
- pass as `SPARKLE_PRIVATE_KEY_FILE`
Safe env file pattern:
```text
APP_STORE_CONNECT_API_KEY_P8=<1Password ref from release-private>
APP_STORE_CONNECT_KEY_ID=<1Password ref from release-private>
APP_STORE_CONNECT_ISSUER_ID=<1Password ref from release-private>
SPARKLE_PRIVATE_KEY_FILE=<path from release-private>
```
Run with `op run --account my.1password.com --env-file <file> -- <script>`, then delete the temp env file.
## CodexBar
Paths:
- repo: `~/Projects/codexbar`
- release script: `Scripts/release.sh`
- signing/notarization: `Scripts/sign-and-notarize.sh`
- appcast: `Scripts/make_appcast.sh`, `appcast.xml`
- release assets: `CodexBar-macos-universal-<version>.zip`, `CodexBar-macos-universal-<version>.dSYM.zip`
- packaged app: `CodexBar.app`
- version file: `version.env`
- changelog: `CHANGELOG.md`
- Homebrew tap: `~/Projects/homebrew-tap`
- cask: `~/Projects/homebrew-tap/Casks/codexbar.rb`
- formula: `~/Projects/homebrew-tap/Formula/codexbar.rb`
- CLI release workflow: `.github/workflows/release-cli.yml`
Normal release:
```bash
tmux new-session -d -s codexbar-release 'op run --account my.1password.com --env-file /tmp/codexbar-release-op.env -- Scripts/release.sh'
tmux attach -t codexbar-release
```
If notarization fails with `401 Unauthenticated`, rerun using all three App Store Connect fields from the 1Password item above. Mismatched `key_id` / `issuer_id` from `~/.profile` can cause this.
If widget metadata generation times out, `CODEXBAR_WIDGET_METADATA_TIMEOUT_SECONDS=600` is a known-good floor.
CodexBar CLI tarballs are not produced by `Scripts/release.sh` itself. The GitHub release event triggers `.github/workflows/release-cli.yml`, which builds and uploads:
- `CodexBarCLI-v<version>-macos-arm64.tar.gz`
- `CodexBarCLI-v<version>-macos-x86_64.tar.gz`
- `CodexBarCLI-v<version>-linux-aarch64.tar.gz`
- `CodexBarCLI-v<version>-linux-x86_64.tar.gz`
- matching `.sha256` files
If the workflow fails only in `update-homebrew-tap` with GitHub API rate limiting, the CLI assets may already be uploaded. Verify assets live, then update `Formula/codexbar.rb` manually from the tarball checksums.
## Verify
Release is not done until the published chain checks out:
```bash
gh release view v<VERSION> --json tagName,name,isDraft,isPrerelease,url,assets,body
Scripts/check-release-assets.sh v<VERSION>
python3 - <<'PY'
import xml.etree.ElementTree as ET
ns={'sparkle':'http://www.andymatuschak.org/xml-namespaces/sparkle'}
root=ET.parse('appcast.xml').getroot()
item=root.find('channel').find('item')
enc=item.find('enclosure')
print(item.findtext('title'))
print(item.findtext('sparkle:version', namespaces=ns))
print(item.findtext('sparkle:shortVersionString', namespaces=ns))
print(enc.attrib.get('url'))
print(enc.attrib.get('length'))
print(bool(enc.attrib.get('{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature')))
PY
codesign --verify --deep --strict --verbose=2 CodexBar.app
spctl --assess --type execute --verbose CodexBar.app
```
For Homebrew:
```bash
shasum -a 256 CodexBar-macos-universal-<VERSION>.zip
cd /Users/steipete/Projects/homebrew-tap
python3 .github/scripts/update_formula.py --formula codexbar --tag v<VERSION> --repository steipete/CodexBar --artifact-template 'CodexBarCLI-{tag}-{target}.tar.gz' --target-aliases 'darwin_arm64=macos-arm64,darwin_amd64=macos-x86_64,linux_arm64=linux-aarch64,linux_amd64=linux-x86_64'
brew fetch --cask --force --retry codexbar
brew fetch --formula --force --retry steipete/tap/codexbar
```
Update the cask when app zip assets exist. Update the formula only when standalone CLI tarballs for that version exist.
Tap audit can be noisy from unrelated formulae; keep evidence specific to the app cask.
## Closeout
1. Create/push tag and GitHub release through the release script.
2. Verify appcast points to the new GitHub release asset with signature and length.
3. Update/push the Homebrew cask if the app zip changed.
4. Bump the app repo to next patch `Unreleased`:
- `version.env`: next `MARKETING_VERSION`, next `BUILD_NUMBER`
- `CHANGELOG.md`: top `## <next> — Unreleased`
5. Commit, push, then pull `--ff-only`.
6. Restart the local app from the packaged bundle and verify the running bundle version.
7. Check no release/notary/op temp sessions or temp env files remain.
CodexBar restart:
```bash
pkill -x CodexBar || pkill -f CodexBar.app || true
cd "$(git rev-parse --show-toplevel)"
open -n CodexBar.app
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' CodexBar.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' CodexBar.app/Contents/Info.plist
```
+320
View File
@@ -0,0 +1,320 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SWIFT_VERSION: 6.3.3
SWIFTLY_VERSION: 1.1.3
SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069
jobs:
changes:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
macos-tests: ${{ steps.macos-tests.outputs.macos-tests }}
macos-tests-reason: ${{ steps.macos-tests.outputs.macos-tests-reason }}
changed-path-count: ${{ steps.macos-tests.outputs.changed-path-count }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Detect macOS test impact
id: macos-tests
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
base_sha="${{ github.event.pull_request.base.sha }}"
else
base_sha="${{ github.event.before }}"
fi
changed_paths="${RUNNER_TEMP}/changed-paths.txt"
if [[ -z "$base_sha" || "$base_sha" =~ ^0+$ ]]; then
git ls-files | awk '{ print "A\t" $0 }' > "$changed_paths"
elif ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
echo "Base commit ${base_sha} is unavailable; running macOS tests conservatively."
git ls-files | awk '{ print "A\t" $0 }' > "$changed_paths"
else
git diff --name-status "$base_sha" "$GITHUB_SHA" > "$changed_paths"
fi
printf 'Changed paths:\n'
sed 's/^/- /' "$changed_paths"
./Scripts/ci_macos_test_gate.sh "$changed_paths"
- name: Summarize macOS test gate
if: ${{ always() }}
shell: bash
env:
MACOS_TESTS: ${{ steps.macos-tests.outputs.macos-tests }}
MACOS_TESTS_REASON: ${{ steps.macos-tests.outputs.macos-tests-reason }}
CHANGED_PATH_COUNT: ${{ steps.macos-tests.outputs.changed-path-count }}
run: |
set -euo pipefail
reason="${MACOS_TESTS_REASON:-<unset>}"
reason="${reason//|/\\|}"
{
printf '### macOS test gate\n\n'
printf '| Field | Value |\n'
printf '| --- | --- |\n'
printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-<unset>}"
printf '| Reason | %s |\n' "$reason"
printf '| Changed path entries | `%s` |\n' "${CHANGED_PATH_COUNT:-<unset>}"
} >> "$GITHUB_STEP_SUMMARY"
lint:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install lint tools
run: ./Scripts/install_lint_tools.sh swiftlint
- name: Lint
run: ./Scripts/lint.sh lint-linux
swift-test-macos:
needs: changes
if: ${{ needs.changes.outputs.macos-tests == 'true' }}
runs-on: macos-15-intel
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# Two shards amortize SwiftPM's per-shard discovery/cold build.
# Each shard remains within the 50-minute Swift Test timeout.
shard-index: [0, 1]
shard-count: [2]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Select Xcode 26.3 or 26.2
run: |
set -euo pipefail
# Both versions are part of the official macOS 15 runner image.
for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do
if [[ -d "$candidate" ]]; then
sudo xcode-select -s "${candidate}/Contents/Developer"
echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV"
break
fi
done
[[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]]
/usr/bin/xcodebuild -version
- name: Swift toolchain version
run: |
set -euo pipefail
swift --version
swift package --version
- name: Check app locales and Swift formatting
if: ${{ matrix.shard-index == 0 }}
run: ./Scripts/lint.sh lint-macos
- name: Swift Test
# Keep small batches to reduce SwiftPM process overhead without returning to one aggregate run.
timeout-minutes: 50
run: |
CODEXBAR_TEST_GROUP_SIZE=4 \
CODEXBAR_TEST_SUITE_TIMEOUT=120 \
CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES=0 \
CODEXBAR_TEST_SHARD_INDEX=${{ matrix.shard-index }} \
CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }} \
./Scripts/test.sh
- name: Summarize macOS shard
if: ${{ always() }}
shell: bash
env:
SHARD_INDEX: ${{ matrix.shard-index }}
SHARD_COUNT: ${{ matrix.shard-count }}
RUNS_LINT_MACOS: ${{ matrix.shard-index == 0 }}
run: |
set -euo pipefail
display_shard_index=$((SHARD_INDEX + 1))
xcode_version="$(/usr/bin/xcodebuild -version 2>/dev/null | tr '\n' ' ' || true)"
{
printf '### macOS Swift shard\n\n'
printf '| Field | Value |\n'
printf '| --- | --- |\n'
printf '| Shard | `%s / %s` |\n' "$display_shard_index" "$SHARD_COUNT"
printf '| Runner | `%s` |\n' "${RUNNER_NAME:-unknown}"
printf '| Xcode | `%s` |\n' "${xcode_version:-unknown}"
printf '| Runs lint-macos | `%s` |\n' "$RUNS_LINT_MACOS"
} >> "$GITHUB_STEP_SUMMARY"
lint-build-test:
runs-on: ubuntu-24.04
timeout-minutes: 5
needs:
- changes
- lint
- swift-test-macos
if: ${{ always() && !cancelled() }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Verify lint and macOS test jobs
run: |
./Scripts/ci_verify_test_jobs.sh \
"${{ needs.lint.result }}" \
"${{ needs.changes.result }}" \
"${{ needs.changes.outputs.macos-tests }}" \
"${{ needs.swift-test-macos.result }}"
- name: Summarize aggregate CI gate
if: ${{ always() }}
shell: bash
env:
LINT_RESULT: ${{ needs.lint.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
MACOS_TESTS: ${{ needs.changes.outputs.macos-tests }}
MACOS_TESTS_REASON: ${{ needs.changes.outputs.macos-tests-reason }}
MACOS_RESULT: ${{ needs.swift-test-macos.result }}
run: |
set -euo pipefail
reason="${MACOS_TESTS_REASON:-<unset>}"
reason="${reason//|/\\|}"
{
printf '### Aggregate CI gate\n\n'
printf '| Field | Value |\n'
printf '| --- | --- |\n'
printf '| lint result | `%s` |\n' "$LINT_RESULT"
printf '| changes result | `%s` |\n' "$CHANGES_RESULT"
printf '| macOS Swift tests required | `%s` |\n' "${MACOS_TESTS:-<unset>}"
printf '| macOS gate reason | %s |\n' "$reason"
printf '| swift-test-macos result | `%s` |\n' "$MACOS_RESULT"
} >> "$GITHUB_STEP_SUMMARY"
build-linux-cli:
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runs-on: ubuntu-24.04
- name: linux-arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Runner info
run: |
set -euo pipefail
uname -a
uname -m
- name: Restore Swift toolchain cache
id: swift-toolchain-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.local/share/swiftly
key: swift-${{ runner.os }}-${{ runner.arch }}-${{ env.SWIFT_VERSION }}-swiftly-${{ env.SWIFTLY_VERSION }}
- name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly
shell: bash
run: |
set -euo pipefail
missing_packages=()
for package in ca-certificates gpg libcurl4-openssl-dev; do
if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q "install ok installed"; then
missing_packages+=("$package")
fi
done
if [[ "${#missing_packages[@]}" -gt 0 ]]; then
sudo apt-get update
sudo apt-get install -y "${missing_packages[@]}"
fi
SWIFTLY_ARCH="$(uname -m)"
SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz"
SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig"
SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly"
SWIFTLY_BIN_DIR="$HOME/.local/bin"
SWIFT_GNUPGHOME="$(mktemp -d)"
SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc"
POST_INSTALL_SCRIPT="$(mktemp)"
mkdir -p "$SWIFTLY_BIN_DIR"
chmod 700 "$SWIFT_GNUPGHOME"
echo "Swift toolchain cache hit: ${{ steps.swift-toolchain-cache.outputs.cache-hit }}"
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE"
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE"
curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS"
GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS"
SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \
--verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)"
printf '%s\n' "$SIGNATURE_STATUS"
grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS"
tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp
/tmp/swiftly init --assume-yes --skip-install
. "$SWIFTLY_HOME_DIR/env.sh"
echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH"
echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV"
echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV"
swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"
if [[ -s "$POST_INSTALL_SCRIPT" ]]; then
sudo apt-get update
sudo bash "$POST_INSTALL_SCRIPT"
fi
hash -r
swift --version
- name: Build CodexBarCLI (release, static Swift stdlib)
run: swift build -c release --product CodexBarCLI --static-swift-stdlib
- name: Swift Test (Linux only)
run: swift test --parallel
- name: Smoke test CodexBarCLI
shell: bash
run: |
set -euo pipefail
BIN_DIR="$(swift build -c release --product CodexBarCLI --static-swift-stdlib --show-bin-path)"
BIN="$BIN_DIR/CodexBarCLI"
"$BIN" --help >/dev/null
"$BIN" --version >/dev/null
if "$BIN" usage --provider codex --web >/dev/null 2>&1; then
echo "Expected --web to fail on Linux"
exit 1
fi
"$BIN" usage --provider codex --web 2>&1 | tee /tmp/codexbarcli-stderr.txt >/dev/null || true
grep -q "macOS" /tmp/codexbarcli-stderr.txt
- name: Summarize Linux CLI build
if: ${{ always() }}
shell: bash
env:
MATRIX_NAME: ${{ matrix.name }}
MATRIX_RUNS_ON: ${{ matrix.runs-on }}
SWIFT_CACHE_HIT: ${{ steps.swift-toolchain-cache.outputs.cache-hit }}
run: |
set -euo pipefail
{
printf '### Linux CLI build\n\n'
printf '| Field | Value |\n'
printf '| --- | --- |\n'
printf '| Matrix | `%s` |\n' "$MATRIX_NAME"
printf '| Runner label | `%s` |\n' "$MATRIX_RUNS_ON"
printf '| Swift version | `%s` |\n' "$SWIFT_VERSION"
printf '| Swift toolchain cache hit | `%s` |\n' "${SWIFT_CACHE_HIT:-false}"
} >> "$GITHUB_STEP_SUMMARY"
+497
View File
@@ -0,0 +1,497 @@
name: Release CLI
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag/version to package for manual artifact builds; manual runs do not publish."
required: false
type: string
permissions:
contents: write
jobs:
build-cli:
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runs-on: ubuntu-24.04
platform: linux
asset-platform: linux
asset-arch: x86_64
build-arch: ""
static-swift-stdlib: true
swift-sdk: ""
swift-sdk-triple: ""
swift-sdk-arch: ""
file-arch-pattern: x86-64
- name: linux-arm64
runs-on: ubuntu-24.04-arm
platform: linux
asset-platform: linux
asset-arch: aarch64
build-arch: ""
static-swift-stdlib: true
swift-sdk: ""
swift-sdk-triple: ""
swift-sdk-arch: ""
file-arch-pattern: aarch64
- name: linux-musl-x64
runs-on: ubuntu-24.04
platform: linux
asset-platform: linux-musl
asset-arch: x86_64
build-arch: ""
static-swift-stdlib: false
swift-sdk: swift-6.2.1-RELEASE_static-linux-0.0.1
swift-sdk-triple: x86_64-swift-linux-musl
swift-sdk-arch: x86_64
file-arch-pattern: x86-64
- name: linux-musl-arm64
runs-on: ubuntu-24.04-arm
platform: linux
asset-platform: linux-musl
asset-arch: aarch64
build-arch: ""
static-swift-stdlib: false
swift-sdk: swift-6.2.1-RELEASE_static-linux-0.0.1
swift-sdk-triple: aarch64-swift-linux-musl
swift-sdk-arch: aarch64
file-arch-pattern: aarch64
- name: macos-arm64
runs-on: macos-15
platform: macos
asset-platform: macos
asset-arch: arm64
build-arch: arm64
static-swift-stdlib: false
swift-sdk: ""
swift-sdk-triple: ""
swift-sdk-arch: ""
file-arch-pattern: ""
- name: macos-x86_64
runs-on: macos-15-intel
platform: macos
asset-platform: macos
asset-arch: x86_64
build-arch: x86_64
static-swift-stdlib: false
swift-sdk: ""
swift-sdk-triple: ""
swift-sdk-arch: ""
file-arch-pattern: ""
runs-on: ${{ matrix.runs-on }}
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
SWIFT_VERSION: 6.2.1
SWIFTLY_VERSION: 1.1.3
SWIFTLY_SIGNING_FINGERPRINT: E813C892820A6FA13755B268F167DF1ACF9CE069
SWIFT_STATIC_LINUX_SDK_URL: https://download.swift.org/swift-6.2.1-release/static-sdk/swift-6.2.1-RELEASE/swift-6.2.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz
SWIFT_STATIC_LINUX_SDK_CHECKSUM: 08e1939a504e499ec871b36826569173103e4562769e12b9b8c2a50f098374ad
SQLITE_AMALGAMATION_VERSION: "3530300"
SQLITE_AMALGAMATION_SHA3_256: d45c688a8cb23f68611a894a756a12d7eb6ab6e9e2468ca70adbeab3808b5ab9
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Select Xcode 26.3 or 26.2
if: matrix.platform == 'macos'
run: |
set -euo pipefail
# Both versions are part of the official macOS 15 runner image.
for candidate in /Applications/Xcode_26.3.app /Applications/Xcode_26.2.app; do
if [[ -d "$candidate" ]]; then
sudo xcode-select -s "${candidate}/Contents/Developer"
echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV"
break
fi
done
[[ "$(/usr/bin/xcodebuild -version)" == Xcode\ 26.* ]]
/usr/bin/xcodebuild -version
- name: Runner info
run: |
set -euo pipefail
uname -a
uname -m
swift --version
- name: Validate release tag
if: github.event_name == 'release' || inputs.tag != ''
shell: bash
run: |
set -euo pipefail
if [[ -z "$RELEASE_TAG" ]]; then
echo "Missing release tag." >&2
exit 1
fi
if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z._-]+$ ]]; then
echo "Invalid release tag: $RELEASE_TAG" >&2
exit 1
fi
- name: Install Swift ${{ env.SWIFT_VERSION }} via swiftly
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
if ! command -v gpg >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates gpg
fi
SWIFTLY_ARCH="$(uname -m)"
SWIFTLY_ARCHIVE="$RUNNER_TEMP/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz"
SWIFTLY_SIGNATURE="${SWIFTLY_ARCHIVE}.sig"
SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly"
SWIFTLY_BIN_DIR="$HOME/.local/bin"
SWIFT_GNUPGHOME="$(mktemp -d)"
SWIFT_KEYS="$RUNNER_TEMP/swift-signing-keys.asc"
POST_INSTALL_SCRIPT="$(mktemp)"
mkdir -p "$SWIFTLY_BIN_DIR"
chmod 700 "$SWIFT_GNUPGHOME"
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz" -o "$SWIFTLY_ARCHIVE"
curl -fsSL "https://download.swift.org/swiftly/linux/swiftly-${SWIFTLY_VERSION}-${SWIFTLY_ARCH}.tar.gz.sig" -o "$SWIFTLY_SIGNATURE"
curl -fsSL --compressed "https://www.swift.org/keys/all-keys.asc" -o "$SWIFT_KEYS"
GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --import "$SWIFT_KEYS"
SIGNATURE_STATUS="$(GNUPGHOME="$SWIFT_GNUPGHOME" gpg --batch --status-fd=1 \
--verify "$SWIFTLY_SIGNATURE" "$SWIFTLY_ARCHIVE" 2>&1)"
printf '%s\n' "$SIGNATURE_STATUS"
grep -Fq "[GNUPG:] VALIDSIG ${SWIFTLY_SIGNING_FINGERPRINT} " <<< "$SIGNATURE_STATUS"
tar -xzf "$SWIFTLY_ARCHIVE" -C /tmp
/tmp/swiftly init --assume-yes --skip-install
. "$SWIFTLY_HOME_DIR/env.sh"
echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH"
echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV"
echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV"
swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"
if [[ -s "$POST_INSTALL_SCRIPT" ]]; then
sudo apt-get update
sudo bash "$POST_INSTALL_SCRIPT"
fi
hash -r
swift --version
- name: Install Swift Static Linux SDK
if: matrix.swift-sdk != ''
shell: bash
run: |
set -euo pipefail
swift sdk install "$SWIFT_STATIC_LINUX_SDK_URL" --checksum "$SWIFT_STATIC_LINUX_SDK_CHECKSUM"
swift sdk list | grep -Fx "${{ matrix.swift-sdk }}"
sdk_root="$(
find "$HOME" -type d -path "*/${{ matrix.swift-sdk }}.artifactbundle/${{ matrix.swift-sdk }}/swift-linux-musl" | head -n1
)"
if [[ -z "$sdk_root" ]]; then
echo "Swift SDK root not found." >&2
exit 1
fi
python3 - "$sdk_root/swift-sdk.json" "${{ matrix.swift-sdk-triple }}" <<'PY'
import json
import sys
sdk_json_path, target_triple = sys.argv[1], sys.argv[2]
with open(sdk_json_path, encoding="utf-8") as handle:
sdk_json = json.load(handle)
target_triples = sdk_json.get("targetTriples", {})
if target_triple not in target_triples:
raise SystemExit(f"Swift SDK target triple not found: {target_triple}")
sdk_json["targetTriples"] = {target_triple: target_triples[target_triple]}
with open(sdk_json_path, "w", encoding="utf-8") as handle:
json.dump(sdk_json, handle, indent=2)
handle.write("\n")
PY
for sdk_arch in "$sdk_root"/musl-1.2.5.sdk/*; do
if [[ "$(basename "$sdk_arch")" != "${{ matrix.swift-sdk-arch }}" ]]; then
rm -rf "$sdk_arch"
fi
done
- name: Build static SQLite for musl SDK
if: matrix.swift-sdk != ''
shell: bash
run: |
set -euo pipefail
missing_packages=()
for tool in clang openssl unzip; do
if ! command -v "$tool" >/dev/null 2>&1; then
missing_packages+=("$tool")
fi
done
if [[ "${#missing_packages[@]}" -gt 0 ]]; then
sudo apt-get update
sudo apt-get install -y "${missing_packages[@]}"
fi
sqlite_zip="$RUNNER_TEMP/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip"
sqlite_src="$RUNNER_TEMP/sqlite-src"
sqlite_out="$RUNNER_TEMP/sqlite-${{ matrix.swift-sdk-triple }}"
curl -fsSL "https://www.sqlite.org/2026/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip" -o "$sqlite_zip"
actual_sha3="$(openssl dgst -sha3-256 "$sqlite_zip" | awk '{print $NF}')"
if [[ "$actual_sha3" != "$SQLITE_AMALGAMATION_SHA3_256" ]]; then
echo "SQLite amalgamation checksum mismatch: $actual_sha3" >&2
exit 1
fi
rm -rf "$sqlite_src" "$sqlite_out"
mkdir -p "$sqlite_src" "$sqlite_out/build" "$sqlite_out/lib"
unzip -q "$sqlite_zip" -d "$sqlite_src"
sdk_bundle="$(
find "$HOME" -type d -name "${{ matrix.swift-sdk }}.artifactbundle" | head -n1
)"
if [[ -z "$sdk_bundle" ]]; then
echo "Swift SDK artifact bundle not found." >&2
exit 1
fi
sysroot="$sdk_bundle/${{ matrix.swift-sdk }}/swift-linux-musl/musl-1.2.5.sdk/${{ matrix.swift-sdk-arch }}"
if [[ ! -d "$sysroot" ]]; then
echo "Swift SDK sysroot not found: $sysroot" >&2
exit 1
fi
sqlite_amalgamation="$sqlite_src/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}"
mkdir -p "$sysroot/usr/include"
install -m 0644 "$sqlite_amalgamation/sqlite3.h" "$sysroot/usr/include/sqlite3.h"
clang \
-target "${{ matrix.swift-sdk-triple }}" \
--sysroot="$sysroot" \
-O2 \
-DSQLITE_OMIT_LOAD_EXTENSION=1 \
-c "$sqlite_amalgamation/sqlite3.c" \
-o "$sqlite_out/build/sqlite3.o"
llvm-ar crs "$sqlite_out/lib/libsqlite3.a" "$sqlite_out/build/sqlite3.o"
echo "CODEXBAR_SQLITE3_LIB_DIR=$sqlite_out/lib" >> "$GITHUB_ENV"
- name: Build CodexBarCLI (release)
id: build
shell: bash
run: |
set -euo pipefail
BUILD_ARGS=(swift build -c release --product CodexBarCLI)
if [[ -n "${{ matrix.swift-sdk }}" ]]; then
BUILD_ARGS+=(--swift-sdk "${{ matrix.swift-sdk }}" --triple "${{ matrix.swift-sdk-triple }}")
elif [[ -n "${{ matrix.build-arch }}" ]]; then
BUILD_ARGS+=(--arch "${{ matrix.build-arch }}")
fi
if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then
BUILD_ARGS+=(--static-swift-stdlib)
fi
"${BUILD_ARGS[@]}"
SHOW_BIN_ARGS=(swift build -c release --product CodexBarCLI --show-bin-path)
if [[ -n "${{ matrix.swift-sdk }}" ]]; then
SHOW_BIN_ARGS+=(--swift-sdk "${{ matrix.swift-sdk }}" --triple "${{ matrix.swift-sdk-triple }}")
elif [[ -n "${{ matrix.build-arch }}" ]]; then
SHOW_BIN_ARGS+=(--arch "${{ matrix.build-arch }}")
fi
if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then
SHOW_BIN_ARGS+=(--static-swift-stdlib)
fi
echo "bin_dir=$("${SHOW_BIN_ARGS[@]}")" >> "$GITHUB_OUTPUT"
- name: Smoke test CodexBarCLI
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
BIN_DIR="${{ steps.build.outputs.bin_dir }}"
BIN="$BIN_DIR/CodexBarCLI"
run_with_timeout() {
local output="$1"
shift
"$@" > "$output" &
local pid=$!
local run_status=
for _ in {1..20}; do
if ! kill -0 "$pid" 2>/dev/null; then
set +e
wait "$pid"
run_status=$?
set -e
break
fi
sleep 1
done
if [[ -z "$run_status" ]]; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
echo "$* timed out." >&2
exit 124
fi
if [[ "$run_status" -ne 0 ]]; then
cat "$output" >&2 || true
exit "$run_status"
fi
}
echo "BIN=$BIN"
file "$BIN"
if [[ "${{ matrix.platform }}" == "macos" ]]; then
lipo -archs "$BIN" | tr ' ' '\n' | grep -Fx "${{ matrix.asset-arch }}"
run_with_timeout "$RUNNER_TEMP/codexbar-cli-smoke-${{ matrix.name }}.txt" "$BIN" config validate --format json
elif [[ -n "${{ matrix.swift-sdk }}" ]]; then
run_with_timeout "$RUNNER_TEMP/codexbar-cli-help-${{ matrix.name }}.txt" "$BIN" --help
run_with_timeout "$RUNNER_TEMP/codexbar-cli-config-${{ matrix.name }}.json" "$BIN" config validate --format json
file "$BIN" | grep -q "${{ matrix.file-arch-pattern }}"
file "$BIN" | grep -q "statically linked"
else
run_with_timeout "$RUNNER_TEMP/codexbar-cli-help-${{ matrix.name }}.txt" "$BIN" --help
file "$BIN" | grep -q "${{ matrix.file-arch-pattern }}"
fi
printf '%s\n' "${RELEASE_TAG#v}" > "$BIN_DIR/VERSION"
VERSION_OUTPUT="$RUNNER_TEMP/codexbar-cli-version-${{ matrix.name }}.txt"
run_with_timeout "$VERSION_OUTPUT" "$BIN" --version
grep -Fx "CodexBar ${RELEASE_TAG#v}" "$VERSION_OUTPUT"
rm "$BIN_DIR/VERSION"
- name: Package
id: pkg
shell: bash
run: |
set -euo pipefail
REF_NAME="${RELEASE_TAG}"
if [[ -z "$REF_NAME" ]]; then
echo "Missing release tag." >&2
exit 1
fi
SAFE_REF_NAME="${REF_NAME//\//-}"
BIN_DIR="${{ steps.build.outputs.bin_dir }}"
OUT_DIR="$(mktemp -d)"
install -m 0755 "$BIN_DIR/CodexBarCLI" "$OUT_DIR/CodexBarCLI"
ln -s "CodexBarCLI" "$OUT_DIR/codexbar"
printf '%s\n' "${SAFE_REF_NAME#v}" > "$OUT_DIR/VERSION"
ASSET="CodexBarCLI-${SAFE_REF_NAME}-${{ matrix.asset-platform }}-${{ matrix.asset-arch }}.tar.gz"
(cd "$OUT_DIR" && tar czf "$ASSET" CodexBarCLI codexbar VERSION)
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256"
else
shasum -a 256 "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256"
fi
echo "out_dir=$OUT_DIR" >> "$GITHUB_OUTPUT"
echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
- name: Upload release assets
if: github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
OUT_DIR="${{ steps.pkg.outputs.out_dir }}"
ASSET="${{ steps.pkg.outputs.asset }}"
gh release upload "$TAG" "$OUT_DIR/$ASSET" "$OUT_DIR/$ASSET.sha256" --clobber
- name: Upload workflow artifact (manual runs)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: codexbar-cli-${{ matrix.name }}
path: |
${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}
${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}.sha256
update-homebrew-tap:
runs-on: ubuntu-24.04
needs: build-cli
if: github.event_name == 'release'
steps:
- name: Resolve release tag
id: release
env:
RELEASE_TAG: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
tag="${RELEASE_TAG}"
if [[ -z "$tag" ]]; then
echo "Missing release tag." >&2
exit 1
fi
if [[ ! "$tag" =~ ^v[0-9A-Za-z._-]+$ ]]; then
echo "Invalid release tag: $tag" >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "request_id=codexbar-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT"
- name: Dispatch tap update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
REQUEST_ID: ${{ steps.release.outputs.request_id }}
shell: bash
run: |
set -euo pipefail
test -n "$GH_TOKEN"
for attempt in {1..6}; do
if gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
-f formula=codexbar \
-f tag="$RELEASE_TAG" \
-f repository=steipete/CodexBar \
-f artifact_template='CodexBarCLI-{tag}-{target}.tar.gz' \
-f target_aliases='darwin_arm64=macos-arm64,darwin_amd64=macos-x86_64,linux_arm64=linux-aarch64,linux_amd64=linux-x86_64' \
-f cask=codexbar \
-f cask_artifact='CodexBar-macos-universal-{version}.zip' \
-f request_id="$REQUEST_ID"; then
exit 0
fi
if [[ "$attempt" -eq 6 ]]; then
echo "Failed to dispatch tap update after ${attempt} attempts." >&2
exit 1
fi
sleep $((attempt * 30))
done
- name: Wait for tap update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
REQUEST_ID: ${{ steps.release.outputs.request_id }}
shell: bash
run: |
set -euo pipefail
for _ in {1..20}; do
run_id="$(
gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--json databaseId,displayTitle \
--jq '.[] | select(.displayTitle | contains(env.REQUEST_ID)) | .databaseId' 2>/tmp/codexbar-tap-run-list.err \
| head -n1 || true
)"
if [[ -n "$run_id" ]]; then
gh run watch "$run_id" --repo steipete/homebrew-tap --exit-status
exit 0
fi
cat /tmp/codexbar-tap-run-list.err >&2 || true
sleep 5
done
echo "Timed out waiting for tap workflow to appear." >&2
exit 1
+188
View File
@@ -0,0 +1,188 @@
name: Monitor Upstream Changes
on:
schedule:
# Run Monday and Thursday at 9 AM UTC
- cron: '0 9 * * 1,4'
workflow_dispatch:
inputs:
target:
description: 'Which upstream to check'
required: false
default: 'all'
type: choice
options:
- all
- upstream
- quotio
jobs:
check-upstreams:
runs-on: ubuntu-24.04
permissions:
issues: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Configure git
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
- name: Add upstream remotes
run: |
git remote add upstream https://github.com/steipete/CodexBar.git || true
git remote add quotio https://github.com/nguyenphutrong/quotio.git || true
git fetch upstream
git fetch quotio
- name: Check for new commits
id: check
run: |
remote_default_branch() {
local remote="$1"
local branch=""
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
if [ -z "$branch" ]; then
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
fi
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
echo "$branch"
return 0
fi
for candidate in main master; do
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
echo "$candidate"
return 0
fi
done
echo "Could not resolve default branch for ${remote}" >&2
exit 1
}
UPSTREAM_BRANCH=$(remote_default_branch upstream)
QUOTIO_BRANCH=$(remote_default_branch quotio)
UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}"
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
echo "upstream_ref=$UPSTREAM_REF" >> $GITHUB_OUTPUT
echo "quotio_ref=$QUOTIO_REF" >> $GITHUB_OUTPUT
# Count new commits in upstream
UPSTREAM_NEW=$(git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ')
echo "upstream_commits=$UPSTREAM_NEW" >> $GITHUB_OUTPUT
# Count new commits in quotio (last 7 days)
QUOTIO_NEW=$(git log --oneline "$QUOTIO_REF" --since="7 days ago" 2>/dev/null | wc -l | tr -d ' ')
echo "quotio_commits=$QUOTIO_NEW" >> $GITHUB_OUTPUT
# Get commit summaries
echo "upstream_summary<<EOF" >> $GITHUB_OUTPUT
git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "quotio_summary<<EOF" >> $GITHUB_OUTPUT
git log --oneline "$QUOTIO_REF" --since="7 days ago" 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create or update issue
if: steps.check.outputs.upstream_commits > 0 || steps.check.outputs.quotio_commits > 0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const upstreamCommits = '${{ steps.check.outputs.upstream_commits }}';
const quotioCommits = '${{ steps.check.outputs.quotio_commits }}';
const upstreamRef = '${{ steps.check.outputs.upstream_ref }}';
const quotioRef = '${{ steps.check.outputs.quotio_ref }}';
const upstreamBranch = upstreamRef.replace('upstream/', '');
const quotioBranch = quotioRef.replace('quotio/', '');
const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`;
const quotioSummary = `${{ steps.check.outputs.quotio_summary }}`;
const body = `## 🔄 Upstream Changes Detected
**steipete/CodexBar:** ${upstreamCommits} new commits
**quotio:** ${quotioCommits} new commits (last 7 days)
**Source refs:** ${upstreamRef}, ${quotioRef}
### steipete/CodexBar Recent Commits
\`\`\`
${upstreamSummary}
\`\`\`
### quotio Recent Commits
\`\`\`
${quotioSummary}
\`\`\`
### 📋 Review Actions
**Review upstream changes:**
\`\`\`bash
./Scripts/review_upstream.sh upstream
\`\`\`
**Review quotio changes:**
\`\`\`bash
./Scripts/analyze_quotio.sh
\`\`\`
**View detailed diffs:**
\`\`\`bash
git diff main..${upstreamRef}
git log -p ${quotioRef} --since='7 days ago'
\`\`\`
### 🔗 Links
- [steipete commits](https://github.com/steipete/CodexBar/compare/${context.sha}...steipete:CodexBar:${upstreamBranch})
- [quotio commits](https://github.com/nguyenphutrong/quotio/commits/${quotioBranch})
---
*Auto-generated by upstream-monitor workflow*
*Last checked: ${new Date().toISOString()}*`;
// Check for existing open issue
const issues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'upstream-sync',
state: 'open'
});
if (issues.data.length > 0) {
// Update existing issue
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issues.data[0].number,
body: body
});
// Add comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issues.data[0].number,
body: `🔄 Updated with latest changes (${upstreamCommits} upstream, ${quotioCommits} quotio)`
});
} else {
// Create new issue
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '🔄 Upstream Changes Available for Review',
body: body,
labels: ['upstream-sync', 'needs-review']
});
}
- name: No changes detected
if: steps.check.outputs.upstream_commits == 0 && steps.check.outputs.quotio_commits == 0
run: |
echo "✅ No new upstream changes detected"
echo "steipete/CodexBar: up to date"
echo "quotio: no commits in last 7 days"
+47
View File
@@ -0,0 +1,47 @@
# Xcode user/session
xcuserdata/
.swiftpm/xcode/xcshareddata/
.codexbar/config.json
*.env
*.local
# Build products
.build/
DerivedData
# Bundles / artifacts
# Main app bundle (any variation)
CodexBar.app/
CodexBar *.app/
CodexBar_*.app/
Codexbar.app/
# Release artifacts
*.ipa
*.dSYM*
*.xcarchive/
*.xcresult/
*.zip
*.delta
*.dmg
*.pkg
*.tar.gz
*.tgz
Icon.iconset
debug_*.swift
# Misc
.DS_Store
.vscode/
.codex/environments/
.swiftpm-cache/
.tmp-clang/
__pycache__/
# Debug/analysis docs
docs/*-analysis.md
docs/.viewport-audit/
docs/.astro/
# Swift Package Manager metadata (leave sources tracked)
# Packages/
# Package.resolved
+51
View File
@@ -0,0 +1,51 @@
# SwiftFormat configuration for CodexBar
# Compatible with Swift 6 strict concurrency mode
# IMPORTANT: Don't remove self where it's required for Swift 6 concurrency
--self insert # Insert self for member references (required for Swift 6)
--selfrequired # List of functions that require explicit self
--importgrouping testable-bottom # Group @testable imports at the bottom
--extensionacl on-declarations # Set ACL on extension members
# Indentation
--indent 4
--indentcase false
--ifdef no-indent
--xcodeindentation enabled
# Line breaks
--linebreaks lf
--maxwidth 120
# Whitespace
--trimwhitespace always
--emptybraces no-space
--nospaceoperators ...,..<
--ranges no-space
--someAny true
# Wrapping
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--closingparen same-line
# Organization
--organizetypes class,struct,enum,extension
--extensionmark "MARK: - %t + %p"
--marktypes always
--markextensions always
--structthreshold 0
--enumthreshold 0
# Swift 6 specific
--swiftversion 6.3
--disable redundantSendable # Keep explicit concurrency contracts visible
# Other
--stripunusedargs closure-only
--header ignore
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Core/PeekabooCore/Sources/PeekabooCore/Extensions/NSArray+Extensions.swift
+160
View File
@@ -0,0 +1,160 @@
# SwiftLint configuration for Peekaboo - Swift 6 compatible
# Paths to include
included:
- Sources
- Tests
# Paths to exclude
excluded:
- .build
- DerivedData
- "**/Generated"
- "**/Resources"
- "**/.build"
- "**/Package.swift"
- "**/Tests/Resources"
- "Apps/CLI/.build"
- "**/DerivedData"
- "**/.swiftpm"
- Pods
- Carthage
- fastlane
- vendor
- "*.playground"
# Exclude specific files that should not be linted/formatted
- "Core/PeekabooCore/Sources/PeekabooCore/Extensions/NSArray+Extensions.swift"
# Analyzer rules (require compilation)
analyzer_rules:
- unused_declaration
- unused_import
# Enable specific rules
opt_in_rules:
- array_init
- closure_spacing
- contains_over_first_not_nil
- empty_count
- empty_string
- explicit_init
- fallthrough
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- overridden_super_call
- pattern_matching_keywords
- private_outlet
- prohibited_super_call
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- unneeded_parentheses_in_closure_argument
- vertical_parameter_alignment_on_call
# Disable rules that conflict with Swift 6 or our coding style
disabled_rules:
# Swift 6 requires explicit self - disable explicit_self rule
- explicit_self
# SwiftFormat handles these
- trailing_whitespace
- trailing_newline
- trailing_comma
- vertical_whitespace
- indentation_width
# Too restrictive or not applicable
- identifier_name # Single letter names are fine in many contexts
- file_header
- explicit_top_level_acl
- explicit_acl
- explicit_type_interface
- missing_docs
- required_deinit
- prefer_nimble
- quick_discouraged_call
- quick_discouraged_focused_test
- quick_discouraged_pending_test
- anonymous_argument_in_multiline_closure
- no_extension_access_modifier
- no_grouping_extension
- switch_case_on_newline
- strict_fileprivate
- extension_access_modifier
- convenience_type
- no_magic_numbers
- one_declaration_per_file
- vertical_whitespace_between_cases
- vertical_whitespace_closing_braces
- superfluous_else
- number_separator
- prefixed_toplevel_constant
- opening_brace
- trailing_closure
- contrasted_opening_brace
- sorted_imports
- redundant_type_annotation
- shorthand_optional_binding
- untyped_error_in_catch
- file_name
- todo
# Rule configurations
force_cast: warning
force_try: warning
# identifier_name rule disabled - see disabled_rules section
type_name:
min_length:
warning: 2
error: 1
max_length:
warning: 60
error: 80
function_body_length:
warning: 150
error: 300
file_length:
warning: 1500
error: 2500
ignore_comment_only_lines: true
type_body_length:
warning: 800
error: 1200
cyclomatic_complexity:
warning: 20
error: 120
large_tuple:
warning: 4
error: 5
nesting:
type_level:
warning: 4
error: 6
function_level:
warning: 5
error: 7
line_length:
warning: 120
error: 250
ignores_comments: true
ignores_urls: true
# Custom rules can be added here if needed
# Reporter type
reporter: "xcode"
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
+48
View File
@@ -0,0 +1,48 @@
# Repository Guidelines
## Project Structure & Modules
- `Sources/CodexBar`: Swift 6 menu bar app (usage/credits probes, icon renderer, settings). Keep changes small and reuse existing helpers.
- `Tests/CodexBarTests`: XCTest coverage for usage parsing, status probes, icon patterns; mirror new logic with focused tests.
- `Scripts`: build/package helpers (`package_app.sh`, `sign-and-notarize.sh`, `make_appcast.sh`, `build_icon.sh`, `compile_and_run.sh`). Release wrappers call `Scripts/mac-release`, which resolves `MAC_RELEASE_TOOL` or the shared `agent-scripts` checkout.
- `docs`: release notes and process (`docs/RELEASING.md`, screenshots). Root-level zips/appcast are generated artifacts—avoid editing except during releases.
## Build, Test, Run
- Dev loop: `./Scripts/compile_and_run.sh` kills old instances, builds, packages, relaunches `CodexBar.app`, and confirms it stays running; add `--test` for the sharded full suite.
- Quick build/test: `swift build` (debug) or `swift build -c release`; `make test` for the sharded full suite.
- Package locally: `./Scripts/package_app.sh` to refresh `CodexBar.app`, then restart with `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`.
- Release flow: `./Scripts/release.sh`; app metadata lives in `.mac-release.env`, repo build/signing stays in `Scripts/sign-and-notarize.sh`, and validation steps live in `docs/RELEASING.md`.
## Coding Style & Naming
- Enforce SwiftFormat/SwiftLint: run `swiftformat Sources Tests` and `swiftlint --strict`. 4-space indent, 120-char lines, explicit `self` is intentional—do not remove.
- Favor small, typed structs/enums; maintain existing `MARK` organization. Use descriptive symbols; match current commit tone.
## Testing Guidelines
- Add/extend XCTest cases under `Tests/CodexBarTests/*Tests.swift` (`FeatureNameTests` with `test_caseDescription` methods).
- Swift Testing: prefer backticked sentence names; no camelCase.
- Model names in tests/code: released models or clearly fictitious names only; never expose unreleased names.
- Always run `make test` before handoff; add focused `swift test --filter ...` runs for parser/provider fixes when possible.
- After any code change, run `make check` and fix all reported format/lint issues before handoff.
- Prefer CLI/focused tests over app-bundle live tests when behavior can be verified without relaunching CodexBar.
- Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts. Live provider probes, browser-cookie imports, `codexbar usage` against real accounts, and real SecItem reads must be explicitly requested; otherwise use parser tests, stubs, test stores, or `KeychainNoUIQuery`.
- macOS CI is brittle around headless AppKit status/menu tests. Prefer covering menu behavior through stable state/model seams (`MenuDescriptor`, `ProvidersPane`, `CodexAccountsSectionState`, etc.) instead of constructing live `NSStatusBar`/`NSMenu` flows unless the AppKit wiring itself is the thing under test.
## Commit & PR Guidelines
- Commit messages: short imperative clauses (e.g., “Improve usage probe”, “Fix icon dimming”); keep commits scoped.
- PRs/patches should list summary, commands run, screenshots/GIFs for UI changes, and linked issue/reference when relevant.
## Agent Notes
- Use the provided scripts and package manager (SwiftPM); avoid adding dependencies or tooling without confirmation.
- Menu bar automation: capture the target screen first and verify the CodexBar icon is visibly onscreen. Reject `click-extra` success when coordinates fall outside display bounds; hidden menu extras are not click proof.
- Validate UI/runtime behavior against the freshly built bundle; restart via the pkill+open command above to avoid running stale binaries.
- To guarantee the right bundle is running after a rebuild, use: `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`.
- For CLI-testable provider/parser/settings behavior, use CLI/focused tests instead of `Scripts/package_app.sh` or `./Scripts/compile_and_run.sh`.
- Run `./Scripts/compile_and_run.sh` only when UI/runtime behavior needs bundle-level validation; it builds, tests, packages, relaunches, and verifies the app stays running.
- Widget/Tahoe UI issues: use Parallels macOS VM plus screenshots/clicks for autonomous verification.
- Release script: keep it in the foreground; do not background it—wait until it finishes.
- Sparkle release key: use `.mac-release.env` `MAC_RELEASE_SIGNING_KEY_FILE`, the legacy `AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=` key. Do not use `sparkle-private-key-KEEP-SECURE.txt`; that is VibeTunnel's mismatched key.
- Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage.
- Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`.
- Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.).
- Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.***
- Claude CLI status line is custom + user-configurable; never rely on it for usage parsing.
- Cookie imports: default Chrome-only when possible to avoid other browser prompts; override via browser list when needed.
+1622
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

+36
View File
@@ -0,0 +1,36 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.00000,0.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "codexbar.png",
"name" : "codexbar",
"position" : {
"scale" : 1.0,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.2
},
"translucency" : {
"enabled" : false,
"value" : 0.0
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
SHELL := /bin/bash
.PHONY: build check docs-list format lint release restart start start-debug start-release stop test test-live test-tty
start:
./Scripts/compile_and_run.sh
start-debug:
./Scripts/compile_and_run.sh
start-release:
./Scripts/package_app.sh release
pkill -x CodexBar || pkill -f CodexBar.app || true
cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app
restart: start
stop:
pkill -x CodexBar || pkill -f CodexBar.app || true
check lint:
./Scripts/lint.sh lint
format:
./Scripts/lint.sh format
docs-list:
node Scripts/docs-list.mjs
build:
swift build
test:
./Scripts/test.sh
test-tty:
swift test --filter TTYIntegrationTests
test-live:
LIVE_TEST=1 swift test --filter LiveAccountTests
release:
./Scripts/package_app.sh release
+77
View File
@@ -0,0 +1,77 @@
{
"originHash" : "d5ef2ec180d58ea5f869b40e5024f2e23d1ce02e999305b2e78d1b5c3783b83b",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander",
"state" : {
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.2"
}
},
{
"identity" : "keyboardshortcuts",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sindresorhus/KeyboardShortcuts",
"state" : {
"revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
"version" : "2.4.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
},
{
"identity" : "sweetcookiekit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/SweetCookieKit",
"state" : {
"revision" : "21bedea672a3e63ccad24d744051e76cdf0462dd",
"version" : "0.4.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
"version" : "1.7.1"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto",
"state" : {
"revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
"version" : "3.15.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
"version" : "1.13.2"
}
},
{
"identity" : "vortex",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zats/Vortex",
"state" : {
"revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07"
}
}
],
"version" : 3
}
+201
View File
@@ -0,0 +1,201 @@
// swift-tools-version: 6.2
import Foundation
import PackageDescription
let sweetCookieKitPath = "../SweetCookieKit"
let useLocalSweetCookieKit =
ProcessInfo.processInfo.environment["CODEXBAR_USE_LOCAL_SWEETCOOKIEKIT"] == "1"
let sweetCookieKitDependency: Package.Dependency =
useLocalSweetCookieKit && FileManager.default.fileExists(atPath: sweetCookieKitPath)
? .package(path: sweetCookieKitPath)
: .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.4.1")
let sqlite3LibDir = ProcessInfo.processInfo.environment["CODEXBAR_SQLITE3_LIB_DIR"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sqlite3LinkerSettings: [LinkerSetting] = if let sqlite3LibDir, !sqlite3LibDir.isEmpty {
[.unsafeFlags(["-L\(sqlite3LibDir)"], .when(platforms: [.linux]))]
} else {
[]
}
let package = Package(
name: "CodexBar",
defaultLocalization: "en",
platforms: [
.macOS(.v14),
],
products: {
var products: [Product] = [
.library(name: "CodexBarCore", targets: ["CodexBarCore"]),
.executable(name: "CodexBarCLI", targets: ["CodexBarCLI"]),
// Offline adaptive-refresh replay harness. Keep the supporting library package-internal.
.executable(name: "AdaptiveReplayCLI", targets: ["AdaptiveReplayCLI"]),
]
#if os(macOS)
products.append(contentsOf: [
.executable(name: "CodexBar", targets: ["CodexBar"]),
.executable(name: "CodexBarClaudeWatchdog", targets: ["CodexBarClaudeWatchdog"]),
.executable(name: "CodexBarWidget", targets: ["CodexBarWidget"]),
.executable(name: "CodexBarClaudeWebProbe", targets: ["CodexBarClaudeWebProbe"]),
])
#endif
return products
}(),
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.3"),
.package(url: "https://github.com/steipete/Commander", from: "0.2.1"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
.package(url: "https://github.com/apple/swift-log", from: "1.13.2"),
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"),
.package(url: "https://github.com/zats/Vortex", revision: "ef5392088d4aeb255c4eee83157dbdafcd31bf07"),
sweetCookieKitDependency,
],
targets: {
var targets: [Target] = [
// Host pkg-config paths contaminate cross-musl links; the module map supplies sqlite3 linkage.
.systemLibrary(
name: "CSQLite3",
providers: [
.apt(["libsqlite3-dev"]),
.brew(["sqlite3"]),
]),
.target(
name: "CodexBarCore",
dependencies: [
.target(name: "CSQLite3", condition: .when(platforms: [.linux])),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SweetCookieKit", package: "SweetCookieKit"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
],
linkerSettings: sqlite3LinkerSettings),
.executableTarget(
name: "CodexBarCLI",
dependencies: [
"CodexBarCore",
.product(name: "Commander", package: "Commander"),
],
path: "Sources/CodexBarCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
],
linkerSettings: sqlite3LinkerSettings),
// Sole owner of the adaptive refresh decision table. Package-internal so the app and
// offline replay tool share behavior without publishing another library product.
.target(
name: "AdaptiveRefreshCore",
dependencies: [],
path: "Sources/AdaptiveRefreshCore",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
// Offline adaptive-refresh replay harness: pure Foundation,
// no CodexBar/CodexBarCore dependency, so it builds anywhere CodexBarCore does.
.target(
name: "AdaptiveReplayKit",
dependencies: ["AdaptiveRefreshCore"],
path: "Sources/AdaptiveReplayKit",
exclude: ["README.md"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "AdaptiveReplayCLI",
dependencies: ["AdaptiveReplayKit"],
path: "Sources/AdaptiveReplayCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "AdaptiveReplayCLITests",
dependencies: ["AdaptiveReplayCLI", "AdaptiveReplayKit"],
path: "Tests/AdaptiveReplayCLITests",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "AdaptiveReplayKitTests",
dependencies: ["AdaptiveRefreshCore", "AdaptiveReplayKit"],
path: "Tests/AdaptiveReplayKitTests",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "CodexBarLinuxTests",
dependencies: [
"CodexBarCore",
"CodexBarCLI",
.target(name: "CSQLite3", condition: .when(platforms: [.linux])),
],
path: "TestsLinux",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
]
#if os(macOS)
targets.append(contentsOf: [
.executableTarget(
name: "CodexBarClaudeWatchdog",
dependencies: [],
path: "Sources/CodexBarClaudeWatchdog",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "CodexBar",
dependencies: [
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"),
.product(name: "Vortex", package: "Vortex"),
"AdaptiveRefreshCore",
"CodexBarCore",
],
path: "Sources/CodexBar",
resources: [
.process("Resources"),
],
swiftSettings: [
// Opt into Swift 6 strict concurrency (approachable migration path).
.enableUpcomingFeature("StrictConcurrency"),
.define("ENABLE_SPARKLE"),
]),
.executableTarget(
name: "CodexBarWidget",
dependencies: ["CodexBarCore"],
path: "Sources/CodexBarWidget",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "CodexBarClaudeWebProbe",
dependencies: ["CodexBarCore"],
path: "Sources/CodexBarClaudeWebProbe",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
])
targets.append(.testTarget(
name: "CodexBarTests",
dependencies: ["CodexBar", "CodexBarCore", "CodexBarCLI", "CodexBarWidget"],
path: "Tests",
exclude: ["AdaptiveReplayCLITests", "AdaptiveReplayKitTests"],
resources: [
.copy("CodexBarTests/Fixtures"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]))
#endif
return targets
}())
+254
View File
@@ -0,0 +1,254 @@
# CodexBar 🎚️ — May your tokens never run out.
> Every AI coding limit, in your menu bar.
[![Latest release](https://img.shields.io/github/v/release/steipete/CodexBar?style=flat-square&color=0a0a0c)](https://github.com/steipete/CodexBar/releases/latest)
[![macOS 14+](https://img.shields.io/badge/macOS-14%2B-0a0a0c?style=flat-square)](https://github.com/steipete/CodexBar/releases/latest)
[![Homebrew](https://img.shields.io/badge/brew-steipete%2Ftap%2Fcodexbar-orange?style=flat-square)](https://github.com/steipete/homebrew-tap)
[![AUR](https://img.shields.io/aur/version/codexbar-cli?style=flat-square&color=1793d1)](https://aur.archlinux.org/packages/codexbar-cli)
[![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE)
[![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app)
<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 59 providers." width="100%" /></a>
Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons.
<img src="docs/codexbar.png" alt="CodexBar menu popover with provider tiles, usage bars, and reset countdowns" width="520" />
## Why
- **Plan around resets.** Per-provider session, weekly, and monthly windows with countdowns to the next reset — stop guessing whether to start that long task.
- **Credits, spend, and cost scans.** Credit balances, Admin API spend dashboards, provider billing summaries, and local cost scans where the source exposes enough detail.
- **Live status.** Provider status polling surfaces incident badges in the menu and an indicator overlay on the bar icon.
- **Privacy-first.** Reuses existing provider sessions — OAuth, device flow, API keys, browser cookies, local files — so no passwords are stored.
## Install
### Requirements
- macOS 14+ (Sonoma)
### GitHub Releases
Download: <https://github.com/steipete/CodexBar/releases>
### Homebrew
```bash
brew install --cask codexbar
```
### CLI Tarballs (macOS/Linux)
Homebrew formula (Linux today):
```bash
brew install steipete/tap/codexbar
```
Arch Linux AUR package:
```bash
yay -S codexbar-cli
```
Or download release tarballs from GitHub Releases:
- macOS: `CodexBarCLI-v<tag>-macos-arm64.tar.gz`, `CodexBarCLI-v<tag>-macos-x86_64.tar.gz`
- Linux (glibc): `CodexBarCLI-v<tag>-linux-aarch64.tar.gz`, `CodexBarCLI-v<tag>-linux-x86_64.tar.gz`
- Linux (static musl): `CodexBarCLI-v<tag>-linux-musl-aarch64.tar.gz`, `CodexBarCLI-v<tag>-linux-musl-x86_64.tar.gz`
### First run
- Open Settings → Providers and enable what you use.
- Install/sign in to the provider sources you rely on: CLIs, browser sessions, OAuth/device flow, API keys, local app files, or provider apps depending on the provider.
- Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras.
### Set API keys from the CLI
Provider toggles and API keys live in the resolved CodexBar config file. New installs use
`~/.config/codexbar/config.json`; existing `~/.codexbar/config.json` installs still load from the legacy path. You can
script the same provider list that Settings → Providers uses:
```bash
codexbar config providers
codexbar config enable --provider grok
codexbar config disable --provider cursor
```
For API-key providers, store a key without opening Settings:
```bash
printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin
```
`set-api-key` trims the piped value, stores it with restrictive config-file permissions, and enables the provider by default. Use `--no-enable` to only save the key, or `--api-key <key>` for one-off local scripts where shell history is not a concern.
See [CLI configuration](docs/cli-configuration.md) for the full flow.
## Providers
- [Codex](docs/codex.md) — OAuth API or local Codex CLI, plus optional OpenAI web dashboard extras.
- [OpenAI](docs/openai.md) — Admin API key usage/cost graphs with legacy credit-balance fallback.
- [Azure OpenAI](docs/azure-openai.md) — API key, endpoint, and deployment validation probe.
- [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available.
- [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets.
- [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage.
- [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows.
- [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas.
- [Alibaba Token Plan](docs/alibaba-token-plan.md) — Bailian browser/manual cookies for token-plan credits.
- [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies).
- [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth.
- [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing.
- [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API.
- [Devin](docs/devin.md) — Chrome localStorage session or manual Bearer token for daily and weekly quotas.
- [z.ai](docs/zai.md) — API token for personal/team quota, MCP, 5-hour, and hourly usage windows.
- [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking.
- [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage.
- [T3 Chat](docs/t3chat.md) — Browser cookies capture for Base and Overage usage buckets.
- [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5hour rate limit.
- [Kimi K2 (unofficial)](docs/kimi-k2.md) — Legacy API key flow for credit-based usage totals.
- [Kilo](docs/kilo.md) — API token with CLI-auth fallback for Kilo Pass usage.
- [Kiro](docs/kiro.md) — CLI-based usage; monthly credits + bonus credits.
- [Vertex AI](docs/vertexai.md) — Google Cloud gcloud OAuth with token cost tracking from local Claude logs.
- [Augment](docs/augment.md) — Augment CLI or browser cookies for credits tracking and usage monitoring.
- [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking.
- [Ollama](docs/ollama.md) — API key access plus browser cookies for Ollama Cloud usage windows.
- [Synthetic](docs/synthetic.md) — API key quota endpoint for rolling five-hour, weekly token, and search-hourly usage.
- [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking.
- [Warp](docs/warp.md) — API token for GraphQL request limits and monthly credits.
- [ElevenLabs](docs/elevenlabs.md) — API key for character credits and voice slot usage.
- [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers.
- [CrossModel](docs/crossmodel.md) — API key wallet balance with daily, weekly, and monthly spend.
- [Windsurf](docs/windsurf.md) — Browser localStorage session import or local SQLite cache for plan usage.
- [Zed](docs/zed.md) — Zed editor Keychain session for plan, edit-prediction quota, billing cycle, and overdue invoices.
- [Perplexity](docs/perplexity.md) — Account usage credits from Perplexity usage data.
- [Xiaomi MiMo](docs/mimo.md) — Browser cookies for balance and token-plan usage.
- [Doubao](docs/doubao.md) — API key for Volcengine Ark request-limit probes.
- [Sakana AI](docs/sakana.md) — Manual Cookie header for 5-hour and weekly quota windows.
- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
- [Mistral](docs/mistral.md) — Browser cookies for API spend, credit balance, and monthly-plan usage.
- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown).
- [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking.
- [Venice](docs/venice.md) — API key for DIEM or USD balance tracking.
- [Codebuff](docs/codebuff.md) — API token (or `~/.config/manicode/credentials.json`) for credit balance + weekly rate limit.
- [Crof](docs/crof.md) — API key for dollar credit balance and request quota tracking.
- [Command Code](docs/command-code.md) — Browser or manual cookies for monthly USD credits from Command Code billing.
- [Qoder](docs/qoder.md) — Browser or manual cookies for Qoder big model credit usage.
- [StepFun](docs/stepfun.md) — Username + password login for Step Plan rate limits (5hour + weekly windows) and subscription plan name.
- [AWS Bedrock](docs/bedrock.md) — AWS access keys or a named AWS profile (SSO/assume-role via the AWS CLI) for Cost Explorer spend, monthly budgets, and optional CloudWatch Claude activity.
- [Grok](docs/grok.md) — Grok CLI billing RPC plus grok.com browser-session fallback.
- [GroqCloud](docs/groqcloud.md) — API key for Enterprise Prometheus request/token/cache-hit metrics.
- [LLM Proxy](docs/llm-proxy.md) — API key + base URL for aggregate proxy quota stats and provider breakdowns.
- [ClawRouter](docs/clawrouter.md) — API key for monthly budget, spend, requests, tokens, and routed-provider usage.
- [sub2api](docs/sub2api.md) — Self-hosted gateway key quota, subscription limits, wallet balance, and per-key usage.
- [Wayfinder](docs/wayfinder.md) — Local router gateway polling for health, per-route breakdown, savings, and decision latency.
- [LiteLLM](docs/litellm.md) — Virtual key + proxy URL for personal and team budget/spend tracking.
- [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics.
- [Poe](docs/poe.md) — API key for current point balance and recent points history.
- [Chutes](docs/chutes.md) — API key for subscription usage, rolling and monthly quota windows, and pay-as-you-go quotas.
- Open to new providers: [provider authoring guide](docs/provider.md).
## Icon & Screenshot
The menu bar icon is a tiny usage meter. Bar meaning is provider-specific, and errors/stale data can dim the icon or
show an incident indicator.
## Features
- Multi-provider menu bar with per-provider toggles (Settings → Providers).
- Provider-specific usage meters with reset countdowns.
- Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history).
- Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock.
- Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories.
- Provider status polling with incident badges in the menu and icon overlay.
- Merge Icons mode to combine providers into one status item + switcher.
- Display controls for provider icons, labels, bars, reset-time style, and highest-usage auto-selection.
- Refresh cadence presets (manual, 1m, 2m, 5m, 15m).
- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex`, `claude`, or `both` for local cost usage); macOS and Linux CLI builds available.
- WidgetKit widgets for supported providers.
- Localized app and website with a shared 21-language catalog, automatic website detection, persistent pickers, and RTL support.
- Optional session quota notifications and weekly-reset confetti.
- Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored).
## Privacy note
Wondering if CodexBar scans your disk? It doesnt crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, provider config files, local JSONL logs) when the related features are enabled. Provider tokens and token-account settings live in the CodexBar config file with restrictive file permissions. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12).
## macOS permissions (why theyre needed)
- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers. If you dont grant it, use another supported browser, manual cookies/API keys, OAuth, or CLI/local sources where that provider supports them.
- **Keychain access (prompted by macOS)**:
- Chromium cookie import needs the browser “Safe Storage” key to decrypt cookies.
- Claude OAuth bootstrap may read the Claude CLI Keychain item when CodexBar has no usable cached credentials.
- CodexBar may use Keychain for browser cookie decryption, cached cookie headers, and OAuth/device-flow credentials where those sources require it.
- **How do I prevent those keychain alerts?**
- Open **Keychain Access.app** → login keychain → search the prompted item (for Claude OAuth, usually “Claude Code-credentials”).
- Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”.
- Prefer adding just CodexBar (avoid “Allow all applications” unless you want it wide open).
- Relaunch CodexBar after saving.
- Reference screenshot: ![Keychain access control](docs/keychain-allow.png)
- **How to do the same for the browser?**
- Find the browsers “Safe Storage” key (e.g., “Chrome Safe Storage”, “Brave Safe Storage”, “Microsoft Edge Safe Storage”).
- Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”.
- This removes the prompt when CodexBar decrypts cookies for that browser.
- **Last resort — stop all Keychain reads entirely**: if "Always Allow" doesn't stick (e.g., macOS resets the ACL after a Chromium update or a `partition_id` reset), open **CodexBar → Settings → Advanced → Keychain access** and enable **Disable Keychain access**. CodexBar will no longer touch the Keychain. Browser-cookie-based providers will be skipped, but Claude/Codex OAuth via the CLI still works (it reads `~/.codex` / `~/.claude` config files, not the Keychain).
- **Prompt after uninstall?** Deleting the app prevents a new launch from that bundle, but an already-running CodexBar process can keep requesting Keychain access until it quits. Check for that process, a Login Item, another installed copy, or a prompt that names a different requesting binary/path. See [Keychain prompt troubleshooting](docs/keychain-prompts.md) for safe checks and what to include in a support report without sharing secrets.
- **Files & Folders prompts (folder/volume access)**: CodexBar launches provider CLIs and local probes for some providers. If those helpers read a project directory or external drive, macOS may ask CodexBar for that folder/volume (e.g., Desktop or an external volume). This is driven by the helpers working directory, not background disk scanning.
- **What we do not request in the background**: no Screen Recording or Accessibility permissions; user-triggered helper actions may ask macOS for Automation permission to open Terminal. No passwords are stored (browser cookies are reused when you opt in).
## Docs
- Providers overview: [docs/providers.md](docs/providers.md)
- Provider authoring: [docs/provider.md](docs/provider.md)
- Issue labeling guide: [docs/ISSUE_LABELING.md](docs/ISSUE_LABELING.md)
- UI & icon notes: [docs/ui.md](docs/ui.md)
- CLI reference: [docs/cli.md](docs/cli.md)
- Configuration: [docs/configuration.md](docs/configuration.md)
- Keychain prompts: [docs/keychain-prompts.md](docs/keychain-prompts.md)
- CLI configuration: [docs/cli-configuration.md](docs/cli-configuration.md)
- Widgets: [docs/widgets.md](docs/widgets.md)
- Architecture: [docs/architecture.md](docs/architecture.md)
- Refresh loop: [docs/refresh-loop.md](docs/refresh-loop.md)
- Status polling: [docs/status.md](docs/status.md)
- Sparkle updates: [docs/sparkle.md](docs/sparkle.md)
- Packaging: [docs/packaging.md](docs/packaging.md)
- Development: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- Release checklist: [docs/RELEASING.md](docs/RELEASING.md)
- Changelog: [CHANGELOG.md](CHANGELOG.md)
## Getting started (dev)
- Clone the repo and open it in Xcode or run the scripts directly.
- Launch once, then toggle providers in Settings → Providers.
- Install/sign in to provider sources you rely on (CLIs, browser cookies, OAuth/device flow, API keys, or local app/config files).
- Optional: set OpenAI cookies (Automatic or Manual) for Codex dashboard extras.
## Build from source
Requires macOS 14+ and Swift 6.2+.
```bash
./Scripts/package_app.sh # builds CodexBar.app in-place with ad-hoc signing
open CodexBar.app
```
Dev loop:
```bash
./Scripts/compile_and_run.sh
./Scripts/compile_and_run.sh --test # also run the sharded test suite before packaging/relaunching
make check # SwiftFormat + SwiftLint
make docs-list # list docs with frontmatter summaries
```
CLI install:
```bash
# after installing CodexBar.app in /Applications
./bin/install-codexbar-cli.sh
```
## Related
- ✂️ [Trimmy](https://github.com/steipete/Trimmy) — “Paste once, run once.” Flatten multi-line shell snippets so they paste and run.
- 🧳 [MCPorter](https://mcporter.dev) — TypeScript toolkit + CLI for Model Context Protocol servers.
- 🧿 [oracle](https://askoracle.dev) — Ask the oracle when you're stuck. Invoke GPT-5 Pro with a custom context and files.
## Looking for a Windows version?
- [Win-CodexBar](https://github.com/Finesssee/Win-CodexBar)
## Linux desktop integration?
- [codexbar-waybar](https://github.com/Marouan-chak/codexbar-waybar) — Waybar custom module + GTK4 popover for Hyprland / Sway / other Wayland compositors, built on top of the bundled Linux CLI.
- [Codexbar GNOME](https://extensions.gnome.org/extension/9841/codexbar/) — GNOME Shell extension that brings CodexBar usage into the desktop panel.
- [codexbar-cinnamon-applet](https://github.com/jacobcalvert/codexbar-cinnamon-applet) — Linux Mint Cinnamon panel applet powered by CodexBar's JSON output.
- [noctalia-codex-usage](https://github.com/rayoplateado/noctalia-codex-usage) — Noctalia/Quickshell plugin that shows Codex 5-hour and weekly usage limits, built on top of the bundled Linux CLI.
- [KodexBar](https://github.com/tylxr59/KodexBar) — KDE Plasma widget that shows CodexBar usage in the Plasma panel, built on top of the bundled Linux CLI.
- [codexbar-plasmoid](https://github.com/psimaker/codexbar-plasmoid) — KDE Plasma 6 widget for CodexBar's meter icon, provider switcher, quota windows, pace, credits, local cost, and status, powered by the bundled Linux CLI.
## Status bar & terminal integration
- [showy-quota](https://github.com/enieuwy/showy-quota) — always-on AI plan quota strips for SketchyBar, tmux, and Zellij (standalone WASM plugin), built on `codexbar serve` / the bundled CLI.
## Credits
Inspired by [ccusage](https://github.com/ryoppippi/ccusage) (MIT), specifically the cost usage tracking.
## License
MIT • Peter Steinberger ([steipete](https://twitter.com/steipete))
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`steipete/CodexBar`
- 原始仓库:https://github.com/steipete/CodexBar
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Analyze quotio repository for interesting patterns and features
# Usage: ./Scripts/analyze_quotio.sh [feature-area]
set -euo pipefail
AREA=${1:-all}
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}==> Fetching latest quotio...${NC}"
git fetch quotio 2>/dev/null || {
echo -e "${YELLOW}Adding quotio remote...${NC}"
git remote add quotio https://github.com/nguyenphutrong/quotio.git
git fetch quotio
}
remote_default_branch() {
local remote=$1
local branch=""
local candidate
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
if [ -z "$branch" ]; then
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
fi
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
echo "$branch"
return 0
fi
for candidate in main master; do
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
echo "$candidate"
return 0
fi
done
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
exit 1
}
QUOTIO_BRANCH=$(remote_default_branch quotio)
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
echo ""
echo -e "${GREEN}==> Quotio Repository Analysis (${QUOTIO_REF})${NC}"
echo ""
# Show recent activity
echo -e "${BLUE}Recent Activity (last 30 days):${NC}"
git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true
echo ""
# Analyze file structure
echo -e "${BLUE}File Structure:${NC}"
git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -30 || true
echo ""
# Find interesting patterns based on area
case $AREA in
"providers"|"all")
echo -e "${BLUE}Provider Implementations:${NC}"
git ls-tree -r --name-only "$QUOTIO_REF" | grep -i provider | head -20 || true
echo ""
;;
esac
case $AREA in
"ui"|"all")
echo -e "${BLUE}UI Components:${NC}"
git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(view|ui|menu)' | head -20 || true
echo ""
;;
esac
case $AREA in
"auth"|"all")
echo -e "${BLUE}Authentication/Session:${NC}"
git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(auth|session|cookie|login)' | head -20 || true
echo ""
;;
esac
# Show commit messages for pattern analysis
echo -e "${BLUE}Recent Commit Messages (for pattern analysis):${NC}"
git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true
echo ""
# Create analysis report
REPORT_FILE="quotio-analysis-$(date +%Y%m%d).md"
cat > "$REPORT_FILE" << EOF
# Quotio Analysis Report
**Date:** $(date +%Y-%m-%d)
**Purpose:** Identify patterns and features for CodexBar fork inspiration
**Source ref:** \`$QUOTIO_REF\`
## Recent Activity
\`\`\`
$(git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true)
\`\`\`
## File Structure
\`\`\`
$(git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -50 || true)
\`\`\`
## Recent Commits
\`\`\`
$(git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true)
\`\`\`
## Areas of Interest
### Providers
- [ ] Review provider implementations
- [ ] Compare with CodexBar approach
- [ ] Identify improvements
### UI/UX
- [ ] Menu bar organization
- [ ] Settings layout
- [ ] Status indicators
### Authentication
- [ ] Session management
- [ ] Cookie handling
- [ ] OAuth flows
### Multi-Account
- [ ] Account switching
- [ ] Account storage
- [ ] UI patterns
## Action Items
- [ ] Review specific files of interest
- [ ] Document patterns (not code)
- [ ] Create implementation plan
- [ ] Implement independently
## Notes
Remember: We're looking for PATTERNS and IDEAS, not copying code.
All implementations must be original and follow CodexBar conventions.
EOF
echo -e "${GREEN}Analysis report saved to: $REPORT_FILE${NC}"
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo ""
echo "1. View specific files:"
echo " ${GREEN}git show $QUOTIO_REF:path/to/file${NC}"
echo ""
echo "2. Compare implementations:"
echo " ${GREEN}git diff main $QUOTIO_REF -- path/to/similar/file${NC}"
echo ""
echo "3. Review commit details:"
echo " ${GREEN}git log -p $QUOTIO_REF --since='30 days ago'${NC}"
echo ""
echo "4. Document patterns in:"
echo " ${GREEN}docs/QUOTIO_ANALYSIS.md${NC}"
echo ""
echo -e "${BLUE}Remember: Adapt patterns, don't copy code!${NC}"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
npx --yes tailwindcss@3.4.19 \
--config Scripts/tailwind.site.config.cjs \
--input Scripts/site-tailwind.input.css \
--output docs/site-utilities.css \
--minify
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ICON_FILE=${1:-Icon.icon}
BASENAME=${2:-Icon}
OUT_ROOT=${3:-build/icon}
XCODE_APP=${XCODE_APP:-/Applications/Xcode.app}
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool"
if [[ ! -x "$ICTOOL" ]]; then
ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool"
fi
if [[ ! -x "$ICTOOL" ]]; then
echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2
exit 1
fi
ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset"
TMP_DIR="$OUT_ROOT/tmp"
mkdir -p "$ICONSET_DIR" "$TMP_DIR"
MASTER_ART="$TMP_DIR/icon_art_824.png"
MASTER_1024="$TMP_DIR/icon_1024.png"
# Render inner art (no margin) with macOS Default appearance
"$ICTOOL" "$ICON_FILE" \
--export-preview macOS Default 824 824 1 -45 "$MASTER_ART"
# Pad to 1024x1024 with transparent border
sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null
# Generate required sizes
sizes=(16 32 64 128 256 512 1024)
for sz in "${sizes[@]}"; do
out="$ICONSET_DIR/icon_${sz}x${sz}.png"
sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null
if [[ "$sz" -ne 1024 ]]; then
dbl=$((sz*2))
out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png"
sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null
fi
done
# 512x512@2x already covered by 1024; ensure it exists
cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png"
iconutil -c icns "$ICONSET_DIR" -o Icon.icns
echo "Icon.icns generated at $(pwd)/Icon.icns"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
exec "$SCRIPT_DIR/mac-release" changelog-html "$@"
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const resources = path.join(repoRoot, "Sources/CodexBar/Resources");
const english = readCatalog("en");
const englishKeys = Object.keys(english).sort();
const strictLocales = ["ar", "ca", "fa", "th"];
const languageKeys = ["language_arabic", "language_persian", "language_thai"];
const isTest = process.argv.includes("--test");
function readCatalog(locale) {
const file = path.join(resources, `${locale}.lproj/Localizable.strings`);
if (!fs.existsSync(file)) return null;
const output = execFileSync("plutil", ["-convert", "json", "-o", "-", file], { encoding: "utf8" });
return JSON.parse(output);
}
function tokenSignature(value) {
// Exclude explicit `%%`, which does not consume an argument.
const withoutEscapedPercents = value.replace(/%%/g, "");
const printfRaw = withoutEscapedPercents.match(/%(?:\d+\$)?(?:\.\d+)?(?:@|d|f)/g) ?? [];
const printf = {};
let implicitIndex = 1;
for (const token of printfRaw) {
const match = token.match(/%(\d+)\$.*?([@df])/);
if (match) {
printf[Number.parseInt(match[1], 10)] = match[2];
} else {
printf[implicitIndex] = token.at(-1);
implicitIndex += 1;
}
}
return { printf, swift: swiftInterpolationTokens(value).sort() };
}
function formatKeyList(keys, limit = 12) {
const shown = keys.slice(0, limit).join(", ");
const remaining = keys.length - limit;
return remaining > 0 ? `${shown}, ... +${remaining} more` : shown;
}
function blankKeys(catalog, referenceKeys) {
return referenceKeys.filter((key) => Object.hasOwn(catalog, key) && !catalog[key]?.trim());
}
function swiftInterpolationTokens(value) {
const tokens = [];
for (let index = 0; index < value.length - 1; index += 1) {
if (value[index] !== "\\" || value[index + 1] !== "(") continue;
const start = index;
let depth = 1;
index += 2;
while (index < value.length && depth > 0) {
if (value[index] === "(") depth += 1;
if (value[index] === ")") depth -= 1;
index += 1;
}
tokens.push(value.slice(start, index));
index -= 1;
}
return tokens;
}
if (isTest) {
assertEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%2$d · %1$@"), "positional reorder");
assertNotEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%1$d · %2$@"), "positional type swap");
assertEqual(tokenSignature("%.0f%% used"), tokenSignature("%.0f%% verbraucht"), "escaped percent");
assertNotEqual(tokenSignature("\\(name): \\(usage)"), tokenSignature("\\(name): \\(value)"), "Swift tokens");
assertEqual(
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
tokenSignature("Fehler: \\(self.store.metadata(for: self.provider).displayName)"),
"nested Swift interpolation");
assertNotEqual(
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
tokenSignature("\\(self.store.metadata(for: self.provider) failed"),
"truncated Swift interpolation");
assertEqual(formatKeyList(["alpha", "beta"]), "alpha, beta", "short key list");
assertEqual(
formatKeyList(["alpha", "beta", "gamma", "delta"], 2),
"alpha, beta, ... +2 more",
"truncated key list");
assertEqual(
blankKeys({ alpha: "", beta: " ", gamma: "ok" }, ["alpha", "beta", "gamma", "delta"]),
["alpha", "beta"],
"blank keys");
console.log("app locale checker tests OK");
process.exit(0);
}
let hasErrors = false;
let checkedCount = 0;
for (const strictLocale of strictLocales) {
const dirPath = path.join(resources, `${strictLocale}.lproj`);
if (!fs.existsSync(dirPath)) {
console.error(`\x1b[31mError: Required strict locale catalog is completely missing: ${strictLocale}.lproj\x1b[0m`);
hasErrors = true;
}
}
for (const directory of fs.readdirSync(resources).filter((name) => name.endsWith(".lproj"))) {
const locale = directory.replace(/\.lproj$/, "");
if (locale === "en" || locale === "Base") continue;
const catalog = readCatalog(locale);
if (!catalog) continue;
checkedCount++;
const catalogKeys = Object.keys(catalog);
const emptyKeys = blankKeys(catalog, englishKeys);
// 1. Missing keys
const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key));
if (missingKeys.length > 0) {
const missingKeyList = formatKeyList(missingKeys);
if (strictLocales.includes(locale)) {
console.error(
`\x1b[31m[${locale}] Error: Missing ${missingKeys.length} keys in strict locale: ${missingKeyList}.\x1b[0m`);
hasErrors = true;
} else {
console.warn(`\x1b[33m[${locale}] Warning: Missing ${missingKeys.length} keys: ${missingKeyList}.\x1b[0m`);
}
}
const extraKeys = catalogKeys.filter((key) => !englishKeys.includes(key));
if (strictLocales.includes(locale) && extraKeys.length > 0) {
console.error(`\x1b[31m[${locale}] Error: Found ${extraKeys.length} extra keys in strict locale.\x1b[0m`);
hasErrors = true;
}
// Ensure critical language keys are present in ALL locales
for (const key of languageKeys) {
if (!catalog[key] || !catalog[key].trim()) {
console.error(`\x1b[31m[${locale}] Error: Missing critical language key "${key}".\x1b[0m`);
hasErrors = true;
}
}
if (emptyKeys.length > 0) {
console.error(
`\x1b[31m[${locale}] Error: Blank values for ${emptyKeys.length} keys: ${formatKeyList(emptyKeys)}.\x1b[0m`);
hasErrors = true;
}
// 2. Identical values count
let identicalCount = 0;
for (const key of englishKeys) {
if (!catalog[key]?.trim()) {
continue;
}
if (catalog[key] === english[key]) {
identicalCount++;
}
// 3. Format placeholder mismatch
const tEn = tokenSignature(english[key]);
const tLoc = tokenSignature(catalog[key]);
if (JSON.stringify(tEn) !== JSON.stringify(tLoc)) {
console.error(`\x1b[31m[${locale}] Error: Token mismatch for key "${key}"\x1b[0m`);
console.error(` en: ${english[key]} Tokens: ${JSON.stringify(tEn)}`);
console.error(` ${locale}: ${catalog[key]} Tokens: ${JSON.stringify(tLoc)}`);
hasErrors = true;
}
}
// Warn if identical translation count exceeds 15% of the total keys (approx > 150 out of 1050)
const identicalRatio = identicalCount / englishKeys.length;
if (identicalRatio > 0.15) {
console.warn(`\x1b[33m[${locale}] Warning: High number of identical translations: ${identicalCount}/${englishKeys.length} (${(identicalRatio * 100).toFixed(1)}%)\x1b[0m`);
}
}
if (hasErrors) {
console.error("\n\x1b[31mApp locale checks failed.\x1b[0m");
process.exit(1);
}
console.log(`\n\x1b[32mApp locales OK: Checked ${checkedCount} catalogs against ${englishKeys.length} English keys.\x1b[0m`);
function assertEqual(actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function assertNotEqual(actual, expected, label) {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
throw new Error(`${label}: signatures unexpectedly match`);
}
}
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const approvedRootDocumentation = new Set([
"README.md",
"CHANGELOG.md",
"LICENSE",
"VISION.md",
].map((relativePath) => path.join(repoRoot, relativePath)));
const readme = readText("README.md");
const readmeLinks = [
...markdownLinks(readme),
...markdownImageLinks(readme),
...htmlLinks(readme),
].filter(isRepositoryDocReference);
assert(readmeLinks.length > 0, "README.md has no local documentation links");
for (const link of readmeLinks) validateLocalDocLink(link, repoRoot, "README.md");
const providerLinks = inlineCodeDocLinks(readText("docs/providers.md"));
assert(providerLinks.length > 0, "docs/providers.md has no provider detail links");
for (const link of providerLinks) validateLocalDocLink(link, repoRoot, "docs/providers.md");
const docsLinks = markdownFiles("docs").flatMap((relativePath) => {
const markdown = readText(relativePath);
const links = [
...markdownLinks(markdown),
...markdownImageLinks(markdown),
...htmlLinks(markdown),
].filter(isLocalDocumentationReference);
return links.map((link) => ({ link, relativePath }));
});
for (const { link, relativePath } of docsLinks) {
validateLocalDocLink(link, path.join(repoRoot, path.dirname(relativePath)), relativePath);
}
console.log(
`documentation links OK: ${readmeLinks.length + providerLinks.length + docsLinks.length} local links`,
);
function readText(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function markdownLinks(markdown) {
const source = markdownTextOutsideCode(markdown);
const links = [];
const inlinePattern = /(?<!!)\[(?:\\.|[^\]\\])+\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g;
for (const match of source.matchAll(inlinePattern)) {
links.push(encodeSpaces(match[1] ?? match[2]));
}
const referencePattern = /^\s*\[[^\]\n]+]:\s*(?:<([^>\n]+)>|([^\s]+))/gm;
for (const match of source.matchAll(referencePattern)) {
links.push(encodeSpaces(match[1] ?? match[2]));
}
return links;
}
function markdownImageLinks(markdown) {
const source = markdownTextOutsideCode(markdown);
const pattern = /!\[(?:\\.|[^\]\\])*\]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/g;
return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2]);
}
function htmlLinks(markdown) {
const source = markdownTextOutsideCode(markdown);
const pattern = /<\s*(?:a|img)\b[^>]*?\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
return [...source.matchAll(pattern)].map((match) => match[1] ?? match[2] ?? match[3]);
}
function inlineCodeDocLinks(markdown) {
return markdown.split("\n").flatMap((line) => {
const trimmed = line.trim();
const prefix = "- Details: `";
if (!trimmed.startsWith(prefix)) return [];
const rest = trimmed.slice(prefix.length);
const end = rest.indexOf("`");
return end === -1 ? [] : [rest.slice(0, end)];
});
}
function validateLocalDocLink(rawLink, baseDirectory, sourceLabel) {
const sourcePath = path.join(repoRoot, sourceLabel);
const { absolutePath, fragment } = localDocPath(rawLink, baseDirectory, sourcePath);
assert(fs.existsSync(absolutePath), `${sourceLabel}: missing documentation target: ${rawLink}`);
if (path.extname(absolutePath).toLowerCase() !== ".md" || !fragment) return;
const anchors = markdownHeadingAnchors(readText(path.relative(repoRoot, absolutePath)));
assert(anchors.has(fragment), `${sourceLabel}: missing documentation anchor: ${rawLink}`);
}
function isRepositoryDocReference(rawLink) {
const parsed = parseRelativeURL(rawLink);
if (!parsed || parsed.protocol || parsed.host) return false;
let pathname = parsed.pathname;
while (pathname.startsWith("./")) pathname = pathname.slice(2);
return pathname === "docs" || pathname.startsWith("docs/");
}
function isLocalDocumentationReference(rawLink) {
const parsed = parseRelativeURL(rawLink);
if (!parsed || parsed.protocol || parsed.host) return false;
return Boolean(parsed.pathname || parsed.hash);
}
function localDocPath(rawLink, baseDirectory, sourcePath) {
const parsed = parseRelativeURL(rawLink);
assert(
parsed && !parsed.protocol && !parsed.host && (parsed.pathname || parsed.hash),
`invalid documentation URL: ${rawLink}`,
);
const rawPath = rawLink.split("#", 1)[0].split("?", 1)[0];
const decodedPath = decodeURIComponent(rawPath);
const absolutePath = decodedPath ? path.resolve(baseDirectory, decodedPath) : sourcePath;
const docsRoot = path.resolve(repoRoot, "docs");
const isInDocsTree = absolutePath === docsRoot || absolutePath.startsWith(`${docsRoot}${path.sep}`);
assert(
isInDocsTree || approvedRootDocumentation.has(absolutePath),
`documentation link escapes approved documentation roots: ${rawLink}`,
);
return { absolutePath, fragment: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "" };
}
function markdownFiles(relativeDir) {
const dir = path.join(repoRoot, relativeDir);
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
if (entry.name.startsWith(".") || entry.name === "node_modules") return [];
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) return markdownFiles(relativePath);
return entry.isFile() && entry.name.endsWith(".md") ? [relativePath] : [];
}).sort((a, b) => a.localeCompare(b));
}
function parseRelativeURL(rawLink) {
try {
const parsed = new URL(rawLink, "relative://repo/");
const isRelative = parsed.protocol === "relative:" && parsed.host === "repo";
return {
protocol: isRelative ? "" : parsed.protocol,
host: isRelative ? "" : parsed.host,
pathname: isRelative ? parsed.pathname.replace(/^\//, "") : parsed.pathname,
hash: parsed.hash,
};
} catch {
return null;
}
}
function markdownHeadingAnchors(markdown) {
const occurrences = new Map();
const anchors = new Set();
const source = markdownTextOutsideFencedCode(markdown);
for (const line of source.split("\n")) {
const trimmed = line.replace(/^[ \t]+/, "");
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(trimmed);
if (!match) continue;
const base = markdownHeadingSlug(match[2]);
if (!base) continue;
const occurrence = occurrences.get(base) ?? 0;
anchors.add(occurrence === 0 ? base : `${base}-${occurrence}`);
occurrences.set(base, occurrence + 1);
}
return anchors;
}
function markdownHeadingSlug(heading) {
const text = removeMarkdownFormatting(heading).toLowerCase();
let slug = "";
for (const char of text) {
if (/[\p{Letter}\p{Number}_-]/u.test(char)) {
slug += char;
} else if (/\s/u.test(char)) {
slug += "-";
}
}
return slug;
}
function removeMarkdownFormatting(text) {
return text
.replace(/`([^`]*)`/g, "$1")
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
.replace(/[*_~]/g, "");
}
function markdownTextOutsideCode(markdown) {
return markdownTextOutsideFencedCode(markdown)
.split("\n")
.map(removeInlineCode)
.join("\n");
}
function markdownTextOutsideFencedCode(markdown) {
let fence = null;
return markdown.split("\n").map((line) => {
if (fence) {
if (isClosingFence(line, fence.marker, fence.count)) fence = null;
return "";
}
const openingFence = parseOpeningFence(line);
if (openingFence) {
fence = openingFence;
return "";
}
return line;
}).join("\n");
}
function parseOpeningFence(line) {
const match = /^( {0,3})([`~]{3,})(.*)$/.exec(line);
if (!match) return null;
const marker = match[2][0];
if (marker === "`" && match[3].includes("`")) return null;
return { marker, count: match[2].length };
}
function isClosingFence(line, marker, minimumCount) {
const escaped = marker === "`" ? "`" : "~";
const pattern = new RegExp(`^ {0,3}${escaped}{${minimumCount},}\\s*$`);
return pattern.test(line);
}
function removeInlineCode(line) {
return line.replace(/(?<!`)(`+)(?!`)(.*?)(?<!`)\1(?!`)/g, "");
}
function encodeSpaces(value) {
return value.replaceAll(" ", "%20");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
exec "$SCRIPT_DIR/mac-release" check-assets "$@"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { localeCatalog, localeMessages } from "../docs/site-locales.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const indexHtml = fs.readFileSync(path.join(repoRoot, "docs/index.html"), "utf8");
const providerSource = fs.readFileSync(
path.join(repoRoot, "Sources/CodexBarCore/Providers/Providers.swift"),
"utf8",
);
const providerEnumBody = providerSource.match(
/public enum UsageProvider:[^{]+\{([\s\S]*?)\n\}/,
)?.[1];
assert(providerEnumBody, "could not locate UsageProvider cases");
const providerIDs = [...providerEnumBody.matchAll(/^\s*case\s+(\w+)\s*$/gm)].map((match) => match[1]);
assert(providerIDs.length > 0, "UsageProvider must define at least one provider");
assertEqual(new Set(providerIDs).size, providerIDs.length, "UsageProvider IDs");
const providerCount = providerIDs.length;
const publicCountFiles = [
["README.md", `alt="CodexBar — every AI coding limit in your menu bar. ${providerCount} providers."`],
["docs/providers.md", `CodexBar currently registers ${providerCount} provider IDs.`],
["docs/social.html", `<strong>${providerCount} providers</strong>`],
["docs/llms.txt", `across ${providerCount} providers`],
];
for (const [relativePath, expectedText] of publicCountFiles) {
const contents = fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
assert(contents.includes(expectedText), `${relativePath} must advertise ${providerCount} providers`);
}
assert(indexHtml.includes(`across ${providerCount} providers`), `index metadata must advertise ${providerCount} providers`);
assert(
indexHtml.includes(`across ${providerCount} AI coding providers`),
`index social metadata must advertise ${providerCount} providers`,
);
assert(
indexHtml.includes(`>${providerCount} providers,{mobileBreak}one menu bar</span>`),
`index provider heading must advertise ${providerCount} providers`,
);
assert(!indexHtml.includes("cdn.tailwindcss.com"), "site must not load Tailwind from a runtime CDN");
for (const match of indexHtml.matchAll(/<link rel="stylesheet" href="\.\/([^"?]+)(?:\?[^"']*)?"/g)) {
assert(fs.existsSync(path.join(repoRoot, "docs", match[1])), `missing local stylesheet ${match[1]}`);
}
const expectedCodes = [
"en", "zh-CN", "zh-TW", "ja-JP", "es", "pt-BR", "ko", "de", "fr", "ar", "it",
"vi", "nl", "tr", "uk", "ru", "id", "pl", "fa", "th", "gl", "ca", "sv",
];
const catalogCodes = localeCatalog.map((locale) => locale.code);
const appLanguageSource = fs.readFileSync(
path.join(repoRoot, "Sources/CodexBar/PreferencesGeneralPane.swift"),
"utf8",
);
assertEqual(catalogCodes, expectedCodes, "locale catalog");
assertEqual(
localeCatalog.filter((locale) => locale.direction === "rtl").map((locale) => locale.code),
["ar", "fa"],
"RTL locale catalog");
const appCatalogCodes = [...appLanguageSource.matchAll(/case \w+ = "([^"]+)"/g)]
.map((match) => match[1])
.filter(Boolean)
.map((code) => ({ "zh-Hans": "zh-CN", "zh-Hant": "zh-TW", ja: "ja-JP" })[code] ?? code);
assertEqual(appCatalogCodes, expectedCodes, "app language catalog");
const englishKeys = Object.keys(localeMessages.en).sort();
for (const locale of localeCatalog) {
const messages = localeMessages[locale.code];
assert(messages, `missing messages for ${locale.code}`);
assertEqual(Object.keys(messages).sort(), englishKeys, `${locale.code} message keys`);
for (const key of ["meta.description", "meta.ogDescription", "providers.title"]) {
const counts = [...messages[key].matchAll(/\d+/g)].map(Number);
assertEqual(counts[0], providerCount, `${locale.code}.${key} provider count`);
}
for (const key of englishKeys) {
assert(messages[key].trim(), `${locale.code}.${key} is blank`);
assertEqual(tokens(messages[key]), tokens(localeMessages.en[key]), `${locale.code}.${key} tokens`);
}
}
const referencedKeys = new Set();
for (const match of indexHtml.matchAll(/data-i18n(?:-rich|-aria-label|-title|-alt)?="([^"]+)"/g)) {
referencedKeys.add(match[1]);
}
for (const key of referencedKeys) {
assert(englishKeys.includes(key), `index.html references unknown locale key ${key}`);
}
const siteJs = fs.readFileSync(path.join(repoRoot, 'docs/site.js'), 'utf8');
const hasLanguagePicker = indexHtml.includes('id="language-picker-list"')
&& (indexHtml.includes('localeCatalog') || siteJs.includes('localeCatalog'));
assert(hasLanguagePicker, 'site must include the language picker backed by localeCatalog');
for (const code of catalogCodes) {
assert(indexHtml.includes(`href="https://codexbar.app/?lang=${code}"`), `missing hreflang URL for ${code}`);
}
const providerCards = [...indexHtml.matchAll(/<li class="provider-card"([^>]*)>([\s\S]*?)<\/li>/g)];
for (const [, attrs, body] of providerCards) {
if (!attrs.includes('hidden')) {
assert(body.includes('class="provider-card-link"'), 'provider cards must link to provider documentation');
assert(body.includes('class="provider-logo'), 'provider cards must use logo assets');
for (const match of body.matchAll(/src="\.\/([^"]+)"/g)) {
assert(fs.existsSync(path.join(repoRoot, 'docs', match[1])), `missing provider logo asset ${match[1]}`);
}
}
}
console.log(`app/site locales OK: ${catalogCodes.length} locales, ${englishKeys.length} site messages`);
function tokens(value) {
return [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]).sort();
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function assertEqual(actual, expected, label) {
const actualJSON = JSON.stringify(actual);
const expectedJSON = JSON.stringify(expected);
if (actualJSON !== expectedJSON) {
throw new Error(`${label}: expected ${expectedJSON}, got ${actualJSON}`);
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MAX_BYTES=$((2 * 1024 * 1024))
failures=0
tracked_files=0
declare -a blob_paths=()
declare -a blob_ids=()
cd "$ROOT_DIR"
while IFS= read -r -d '' entry; do
metadata=${entry%%$'\t'*}
path=${entry#*$'\t'}
read -r mode object stage <<<"$metadata"
[[ "$stage" == "0" ]] || continue
tracked_files=$((tracked_files + 1))
case "$path" in
*.app | *.app/* | *.dSYM | *.dSYM/* | *.xcarchive/* | *.xcresult/* | *.ipa | *.zip | *.delta | *.dmg | \
*.pkg | *.tar.gz | *.tgz)
printf 'ERROR: generated artifact is tracked: %s\n' "$path" >&2
failures=$((failures + 1))
;;
esac
# Submodule entries name commits rather than file blobs.
[[ "$mode" == "160000" ]] && continue
blob_paths+=("$path")
blob_ids+=("$object")
done < <(git ls-files --stage -z)
if ((${#blob_ids[@]} > 0)); then
index=0
while read -r object type size; do
path=${blob_paths[$index]}
if [[ "$type" != "blob" ]]; then
printf 'ERROR: tracked index entry is not a readable blob: %q (%s)\n' "$path" "$object" >&2
failures=$((failures + 1))
index=$((index + 1))
continue
fi
if ((size > MAX_BYTES)); then
printf 'ERROR: tracked file exceeds %d bytes: %q (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2
failures=$((failures + 1))
fi
index=$((index + 1))
done < <(printf '%s\n' "${blob_ids[@]}" | git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize)')
fi
if ((failures > 0)); then
printf 'Repository size check failed with %d violation(s).\n' "$failures" >&2
printf 'Publish build/release artifacts outside Git and optimize required source assets.\n' >&2
exit 1
fi
printf 'repository size OK: %d tracked files, maximum %d bytes each\n' "$tracked_files" "$MAX_BYTES"
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Check for new changes in upstream repositories
# Usage: ./Scripts/check_upstreams.sh [upstream|quotio|all]
set -euo pipefail
TARGET=${1:-all}
DAYS=${2:-7}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}==> Fetching upstream changes...${NC}"
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
git fetch upstream 2>/dev/null || {
echo -e "${YELLOW}Adding upstream remote...${NC}"
git remote add upstream https://github.com/steipete/CodexBar.git
git fetch upstream
}
fi
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
git fetch quotio 2>/dev/null || {
echo -e "${YELLOW}Adding quotio remote...${NC}"
git remote add quotio https://github.com/nguyenphutrong/quotio.git
git fetch quotio
}
fi
echo ""
remote_default_branch() {
local remote=$1
local branch=""
local candidate
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
if [ -z "$branch" ]; then
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
fi
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
echo "$branch"
return 0
fi
for candidate in main master; do
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
echo "$candidate"
return 0
fi
done
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
exit 1
}
# Check upstream (steipete)
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}"
UPSTREAM_BRANCH=$(remote_default_branch upstream)
UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}"
UPSTREAM_COUNT=$(git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ')
if [ "$UPSTREAM_COUNT" -gt 0 ]; then
echo -e "${GREEN}Found $UPSTREAM_COUNT new commits${NC}"
echo ""
git log --oneline --graph "main..${UPSTREAM_REF}" --no-merges | head -20 || true
echo ""
echo -e "${YELLOW}Files changed:${NC}"
git diff --stat "main..${UPSTREAM_REF}" | tail -20 || true
else
echo -e "${GREEN}No new commits (up to date)${NC}"
fi
echo ""
fi
# Check quotio
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
echo -e "${BLUE}==> Quotio changes (last $DAYS days):${NC}"
QUOTIO_BRANCH=$(remote_default_branch quotio)
QUOTIO_REF="quotio/${QUOTIO_BRANCH}"
QUOTIO_COUNT=$(git log --oneline "$QUOTIO_REF" --since="$DAYS days ago" 2>/dev/null | wc -l | tr -d ' ')
if [ "$QUOTIO_COUNT" -gt 0 ]; then
echo -e "${GREEN}Found $QUOTIO_COUNT commits in last $DAYS days${NC}"
echo ""
git log --oneline --graph "$QUOTIO_REF" --since="$DAYS days ago" | head -20 || true
echo ""
echo -e "${YELLOW}Recent file changes:${NC}"
# Show changes from last 10 commits
git diff --stat "${QUOTIO_REF}~10..${QUOTIO_REF}" 2>/dev/null | tail -20 || echo "Unable to show diff"
else
echo -e "${GREEN}No new commits in last $DAYS days${NC}"
fi
echo ""
fi
# Summary
echo -e "${BLUE}==> Summary${NC}"
if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then
echo "Upstream commits: $UPSTREAM_COUNT"
fi
if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then
echo "Quotio commits (${DAYS}d): $QUOTIO_COUNT"
fi
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo " Review upstream: ./Scripts/review_upstream.sh upstream"
echo " Review quotio: ./Scripts/review_upstream.sh quotio"
echo " Detailed diff: git diff main..<resolved-remote>/<default-branch>"
echo " View quotio: ./Scripts/analyze_quotio.sh"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
changed_paths_file="${1:-}"
if [[ -z "$changed_paths_file" || ! -f "$changed_paths_file" ]]; then
printf 'Usage: %s <changed-paths-file>\n' "$(basename "$0")" >&2
exit 2
fi
macos_tests=false
macos_tests_reason=""
path_count=0
require_macos_tests() {
local path="$1"
local reason="$2"
macos_tests=true
if [[ -z "$macos_tests_reason" ]]; then
macos_tests_reason="${path}: ${reason}"
fi
}
classify_path() {
local path="$1"
[[ -z "$path" ]] && return
path_count=$((path_count + 1))
case "$path" in
AGENTS.md|docs/configuration.md)
require_macos_tests "$path" "changes contributor or runtime configuration contracts"
;;
*.md)
;;
docs/.nojekyll|docs/CNAME|docs/index.html|docs/llms.txt|docs/site-locales.mjs|docs/site.css|docs/site.js|docs/social.html|docs/social.png)
;;
docs/*.png|docs/*.jpg|docs/*.jpeg|docs/*.webp|docs/*.ico|docs/*.svg)
;;
*)
require_macos_tests "$path" "not covered by portable docs/site checks"
;;
esac
}
invalid_row=false
while IFS=$'\t' read -r status first_path second_path extra_path \
|| [[ -n "${status:-}${first_path:-}${second_path:-}${extra_path:-}" ]]
do
[[ -z "${status}${first_path:-}${second_path:-}${extra_path:-}" ]] && continue
case "$status" in
R*|C*)
if ! [[ "$status" =~ ^[RC][0-9]{1,3}$ ]] \
|| ((10#${status:1} > 100)) \
|| [[ -z "${first_path:-}" || -z "${second_path:-}" || -n "${extra_path:-}" ]]
then
invalid_row=true
break
fi
classify_path "$first_path"
classify_path "$second_path"
;;
A|D|M|T|U|X|B)
if [[ -z "${first_path:-}" || -n "${second_path:-}" || -n "${extra_path:-}" ]]; then
invalid_row=true
break
fi
classify_path "$first_path"
;;
*)
invalid_row=true
break
;;
esac
done < "$changed_paths_file"
if [[ "$invalid_row" == true ]]; then
printf 'Invalid git name-status row; refusing to skip macOS tests.\n' >&2
exit 2
fi
if [[ "$path_count" -eq 0 ]]; then
require_macos_tests '<empty diff>' 'no changed paths were reported'
fi
if [[ "$macos_tests" == true ]]; then
summary_reason="$macos_tests_reason"
else
summary_reason="docs/site-only changes covered by portable checks"
fi
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
printf 'macos-tests=%s\n' "$macos_tests" >> "$GITHUB_OUTPUT"
printf 'macos-tests-reason=%s\n' "$summary_reason" >> "$GITHUB_OUTPUT"
printf 'changed-path-count=%s\n' "$path_count" >> "$GITHUB_OUTPUT"
fi
if [[ "$macos_tests" == true ]]; then
printf 'macOS Swift tests required for this change set: %s.\n' "$macos_tests_reason"
else
printf 'Skipping macOS Swift tests for docs/site-only changes covered by portable checks.\n'
fi
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Run SwiftPM tests in suite shards so CI cannot hang inside one aggregate run."""
from __future__ import annotations
import argparse
import os
import re
import signal
import subprocess
import sys
import time
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
class TestSelection:
name: str
filter_pattern: str
suite_name: str | None = None
@dataclass
class RunStats:
discovered_selections: int = 0
selected_selections: int = 0
selected_groups: int = 0
group_size: int = 0
shard_index: int | None = None
shard_count: int | None = None
discovery_seconds: float = 0
execution_seconds: float = 0
total_seconds: float = 0
first_pass_successful_groups: int = 0
first_pass_failed_groups: int = 0
full_group_retries: int = 0
timed_out_groups: int = 0
recovered_groups: int = 0
isolated_selection_retries: int = 0
def summary_rows(self) -> list[tuple[str, str]]:
shard = "none"
if self.shard_index is not None and self.shard_count is not None:
shard = f"{self.shard_index + 1}/{self.shard_count}"
return [
("Shard", shard),
("Group size", str(self.group_size)),
("Discovered selections", str(self.discovered_selections)),
("Selected selections", str(self.selected_selections)),
("Selected groups", str(self.selected_groups)),
("First-pass successful groups", str(self.first_pass_successful_groups)),
("First-pass failed groups", str(self.first_pass_failed_groups)),
("Full-group retries", str(self.full_group_retries)),
("Recovered groups", str(self.recovered_groups)),
("Timed out groups", str(self.timed_out_groups)),
("Isolated selection retries", str(self.isolated_selection_retries)),
("Discovery seconds", f"{self.discovery_seconds:.1f}"),
("Execution seconds", f"{self.execution_seconds:.1f}"),
("Total seconds", f"{self.total_seconds:.1f}"),
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--group-size", type=int, default=12)
parser.add_argument("--timeout", type=int, default=180)
parser.add_argument("--limit-groups", type=int)
parser.add_argument("--shard-index", type=int)
parser.add_argument("--shard-count", type=int)
parser.add_argument(
"--no-retry-non-timeout-failures",
action="store_false",
dest="retry_non_timeout_failures",
help="fail immediately when a group exits without timing out",
)
parser.add_argument("--list-only", action="store_true")
parser.add_argument("--swift-command", default="swift")
parser.add_argument("--swift-command-arg", action="append", default=[])
return parser.parse_args()
def run_command(command: list[str], timeout: int | None = None) -> int:
print(f"+ {' '.join(command)}", flush=True)
process = subprocess.Popen(command, start_new_session=True)
try:
return process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
print(f"::warning::Command timed out after {timeout}s: {' '.join(command)}", flush=True)
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
return 124
def swift_test_list(swift_command: list[str]) -> list[TestSelection]:
command = [*swift_command, "test", "list"]
try:
result = subprocess.run(command, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as error:
print(f"+ {swift_command[0]} test list", flush=True)
if error.stdout:
print(error.stdout, end="" if error.stdout.endswith("\n") else "\n", flush=True)
if error.stderr:
print(error.stderr, end="" if error.stderr.endswith("\n") else "\n", file=sys.stderr, flush=True)
raise
selections: set[TestSelection] = set()
unknown: list[str] = []
for line in result.stdout.splitlines():
top_level = re.fullmatch(r"(?P<module>[^.]+)\.(?:`(?P<display>.+)`|(?P<function>[^()/]+))\(\)", line)
if top_level is not None:
module = top_level.group("module")
test_name = top_level.group("display") or top_level.group("function")
selections.add(
TestSelection(
name=line,
# SwiftPM matches top-level Swift Testing functions by their display name,
# not the backtick-wrapped identifier printed by `swift test list`.
filter_pattern=rf"{re.escape(module)}\..*{re.escape(test_name)}",
)
)
continue
if "/" in line:
suite = line.split("/", 1)[0]
if "." in suite:
selections.add(
TestSelection(
name=suite,
filter_pattern=rf"^{re.escape(suite)}/",
suite_name=suite,
)
)
continue
unknown.append(line)
if unknown:
rendered = "\n".join(f"- {line}" for line in unknown)
raise RuntimeError(f"Unrecognized `swift test list` output:\n{rendered}")
return sorted(selections, key=lambda selection: selection.name)
def append_github_summary(stats: RunStats) -> None:
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_path:
return
with open(summary_path, "a", encoding="utf-8") as summary:
summary.write("### macOS Swift test timing\n\n")
summary.write("| Field | Value |\n")
summary.write("| --- | --- |\n")
for field, value in stats.summary_rows():
safe_value = value.replace("|", "\\|")
summary.write(f"| {field} | `{safe_value}` |\n")
summary.write("\n")
def print_timing_summary(stats: RunStats) -> None:
print("Swift test timing summary:", flush=True)
for field, value in stats.summary_rows():
print(f"- {field}: {value}", flush=True)
def chunks(items: list[TestSelection], size: int) -> Iterable[list[TestSelection]]:
for index in range(0, len(items), size):
yield items[index : index + size]
def shard_groups(groups: list[list[TestSelection]], shard_index: int | None, shard_count: int | None) -> list[list[TestSelection]]:
if shard_index is None and shard_count is None:
return groups
if shard_index is None or shard_count is None:
raise ValueError("--shard-index and --shard-count must be passed together")
if shard_count < 1:
raise ValueError("--shard-count must be positive")
if shard_index < 0 or shard_index >= shard_count:
raise ValueError("--shard-index must be in the range [0, --shard-count)")
return [group for index, group in enumerate(groups) if index % shard_count == shard_index]
def prioritized_suites(suites: list[TestSelection]) -> list[TestSelection]:
priority = ["CodexBarTests.CLIEntryTests"]
ordered = [suite for name in priority for suite in suites if suite.suite_name == name]
ordered.extend(suite for suite in suites if suite.suite_name not in priority)
return ordered
def filtered_suites_for_environment(suites: list[TestSelection]) -> list[TestSelection]:
if os.environ.get("GITHUB_ACTIONS") != "true" or sys.platform != "darwin":
return suites
# SwiftPM hangs before suite output for this executable-target suite on the Intel macOS runner.
# Linux CI still runs it in the full Swift test lane, and local macOS runs it directly.
skipped = {"CodexBarTests.CLIEntryTests"}
filtered = [suite for suite in suites if suite.suite_name not in skipped]
if len(filtered) != len(suites):
print(f"Skipping macOS CI-only suites: {', '.join(sorted(skipped))}", flush=True)
return filtered
def filter_for(suites: list[TestSelection]) -> str:
return rf"({'|'.join(suite.filter_pattern for suite in suites)})"
def run_group(suites: list[TestSelection], timeout: int, swift_command: list[str]) -> int:
return run_command(
[*swift_command, "test", "--skip-build", "--no-parallel", "--filter", filter_for(suites)],
timeout=timeout,
)
def retry_selections_individually(
suites: list[TestSelection],
timeout: int,
swift_command: list[str],
stats: RunStats,
) -> int:
for suite in suites:
stats.isolated_selection_retries += 1
print(f"::group::Swift test retry {suite.name}", flush=True)
retry_result = run_group([suite], timeout, swift_command)
print("::endgroup::", flush=True)
if retry_result != 0:
return retry_result
return 0
def main() -> int:
total_started = time.monotonic()
args = parse_args()
stats = RunStats(
group_size=args.group_size,
shard_index=args.shard_index,
shard_count=args.shard_count,
)
if args.group_size < 1:
print("--group-size must be positive", file=sys.stderr)
return 2
swift_command = [args.swift_command, *args.swift_command_arg]
result = 0
try:
discovery_started = time.monotonic()
try:
suites = prioritized_suites(filtered_suites_for_environment(swift_test_list(swift_command)))
finally:
stats.discovery_seconds = time.monotonic() - discovery_started
stats.discovered_selections = len(suites)
suite_groups = list(chunks(suites, args.group_size))
try:
suite_groups = shard_groups(suite_groups, args.shard_index, args.shard_count)
except ValueError as error:
print(str(error), file=sys.stderr)
result = 2
return result
if args.limit_groups is not None:
suite_groups = suite_groups[: args.limit_groups]
stats.selected_selections = sum(len(group) for group in suite_groups)
stats.selected_groups = len(suite_groups)
shard_suffix = ""
if args.shard_index is not None and args.shard_count is not None:
shard_suffix = f" in shard {args.shard_index + 1}/{args.shard_count}"
print(
f"Discovered {len(suites)} test selections; running {stats.selected_selections} selections "
f"in {len(suite_groups)} groups{shard_suffix}",
flush=True,
)
if args.list_only:
for group in suite_groups:
for suite in group:
print(suite.name)
return 0
if not suite_groups:
print("No test groups selected.", flush=True)
return 0
execution_started = time.monotonic()
for group_index, group in enumerate(suite_groups, start=1):
print(
f"::group::Swift test group {group_index}/{len(suite_groups)} "
f"({len(group)} selections)",
flush=True,
)
group_result = run_group(group, args.timeout, swift_command)
print("::endgroup::", flush=True)
if group_result == 0:
stats.first_pass_successful_groups += 1
continue
stats.first_pass_failed_groups += 1
group_timed_out = group_result == 124
if group_timed_out:
stats.timed_out_groups += 1
if len(group) == 1:
result = group_result
return result
if group_result != 124:
if not args.retry_non_timeout_failures:
result = group_result
return result
stats.full_group_retries += 1
print(f"Group {group_index} failed with exit code {group_result}; retrying group once", flush=True)
retry_result = run_group(group, args.timeout, swift_command)
if retry_result == 0:
stats.recovered_groups += 1
continue
if retry_result != 124:
result = retry_result
return result
group_timed_out = True
stats.timed_out_groups += 1
print(f"Group {group_index} timed out; retrying selections one at a time", flush=True)
retry_result = retry_selections_individually(group, args.timeout, swift_command, stats)
if retry_result != 0:
result = retry_result
return result
if group_timed_out:
stats.recovered_groups += 1
return result
finally:
stats.total_seconds = time.monotonic() - total_started
if "execution_started" in locals():
stats.execution_seconds = time.monotonic() - execution_started
if not args.list_only:
print_timing_summary(stats)
append_github_summary(stats)
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
lint_result="${1:-}"
changes_result="${2:-}"
macos_tests_required="${3:-}"
macos_test_result="${4:-}"
if [[ "$lint_result" != "success" ]]; then
printf 'lint job finished with %s\n' "${lint_result:-<empty>}" >&2
exit 1
fi
if [[ "$changes_result" != "success" ]]; then
printf 'changes job finished with %s\n' "${changes_result:-<empty>}" >&2
exit 1
fi
case "${macos_tests_required}:${macos_test_result}" in
true:success)
printf 'Lint and macOS Swift test shards passed.\n'
;;
false:skipped)
printf 'Lint passed; macOS Swift tests skipped for docs/site-only changes.\n'
;;
*)
printf 'macOS test gate/result mismatch: required=%s result=%s\n' \
"${macos_tests_required:-<empty>}" "${macos_test_result:-<empty>}" >&2
exit 1
;;
esac
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env bash
# Reset CodexBar: kill running instances, build, package, relaunch, verify.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_BUNDLE="${ROOT_DIR}/CodexBar.app"
APP_PROCESS_PATTERN="CodexBar.app/Contents/MacOS/CodexBar"
DEBUG_PROCESS_PATTERN="${ROOT_DIR}/.build/debug/CodexBar"
RELEASE_PROCESS_PATTERN="${ROOT_DIR}/.build/release/CodexBar"
LOCK_KEY="$(printf '%s' "${ROOT_DIR}" | shasum -a 256 | cut -c1-8)"
LOCK_DIR="${TMPDIR:-/tmp}/codexbar-compile-and-run-${LOCK_KEY}"
LOCK_PID_FILE="${LOCK_DIR}/pid"
WAIT_FOR_LOCK=0
RUN_TESTS=0
DEBUG_LLDB=0
RELEASE_ARCHES=""
SIGNING_MODE="${CODEXBAR_SIGNING:-}"
CLEAR_ADHOC_KEYCHAIN=0
log() { printf '%s\n' "$*"; }
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
delete_keychain_service_items() {
local service="$1"
security delete-generic-password -s "${service}" >/dev/null 2>&1 || true
while security delete-generic-password -s "${service}" >/dev/null 2>&1; do
:
done
}
# Ensure Swift >= 5.5 (required for --arch flag in swift build)
ensure_swift_version() {
local swift_output
local swift_ver
swift_output=$(swift --version 2>&1 || true)
if [[ "$swift_output" =~ (Apple[[:space:]]+)?Swift[[:space:]]+version[[:space:]]+([0-9]+)\.([0-9]+)(\.[0-9]+)? ]]; then
swift_ver="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}${BASH_REMATCH[4]}"
else
fail "Swift >= 5.5 required (found ${swift_output:-none}). Install Xcode or update swiftly."
fi
local major minor
major=$(echo "$swift_ver" | cut -d. -f1)
minor=$(echo "$swift_ver" | cut -d. -f2)
if [[ "${major:-0}" -ge 6 ]] || { [[ "${major:-0}" -eq 5 ]] && [[ "${minor:-0}" -ge 5 ]]; }; then
return 0
fi
# Try Xcode toolchain
local xcrun_swift
xcrun_swift=$(xcrun --find swift 2>/dev/null || true)
if [[ -n "$xcrun_swift" && -x "$xcrun_swift" ]]; then
log "WARN: PATH swift is v${swift_ver}; switching to Xcode toolchain at $(dirname "$xcrun_swift")"
export PATH="$(dirname "$xcrun_swift"):$PATH"
return 0
fi
fail "Swift >= 5.5 required (found ${swift_ver:-none}). Install Xcode or update swiftly."
}
has_signing_identity() {
local identity="${1:-}"
if [[ -z "${identity}" ]]; then
return 1
fi
security find-identity -p codesigning -v 2>/dev/null | grep -F "${identity}" >/dev/null 2>&1
}
detect_codesigning_identity() {
local preferred_prefixes=(
"Developer ID Application:"
"Apple Development:"
"Apple Distribution:"
)
local prefix
local identities
identities="$(security find-identity -p codesigning -v 2>/dev/null || true)"
for prefix in "${preferred_prefixes[@]}"; do
awk -v prefix="${prefix}" '
index($0, "\"" prefix) {
sub(/^[^\"]*\"/, "")
sub(/\".*$/, "")
print
exit
}
' <<<"${identities}"
done | sed -n '1p'
}
export_team_id_from_identity() {
local identity="${1:-}"
if [[ -n "${APP_TEAM_ID:-}" || -z "${identity}" ]]; then
return
fi
local subject
subject="$(security find-certificate -c "${identity}" -p 2>/dev/null \
| openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null || true)"
if [[ "${subject}" =~ (^|,)OU=([A-Z0-9]{10})(,|$) ]]; then
APP_TEAM_ID="${BASH_REMATCH[2]}"
export APP_TEAM_ID
return
fi
if [[ "${identity}" =~ \(([A-Z0-9]{10})\)$ ]]; then
APP_TEAM_ID="${BASH_REMATCH[1]}"
export APP_TEAM_ID
fi
}
resolve_signing_mode() {
if [[ -n "${SIGNING_MODE}" ]]; then
export_team_id_from_identity "${APP_IDENTITY:-}"
return
fi
if [[ -n "${APP_IDENTITY:-}" ]]; then
if has_signing_identity "${APP_IDENTITY}"; then
export_team_id_from_identity "${APP_IDENTITY}"
SIGNING_MODE="identity"
return
fi
log "WARN: APP_IDENTITY not found in Keychain; falling back to adhoc signing."
SIGNING_MODE="adhoc"
return
fi
local candidate=""
for candidate in \
"Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
"CodexBar Development"
do
if has_signing_identity "${candidate}"; then
APP_IDENTITY="${candidate}"
export APP_IDENTITY
export_team_id_from_identity "${APP_IDENTITY}"
SIGNING_MODE="identity"
return
fi
done
candidate="$(detect_codesigning_identity)"
if [[ -n "${candidate}" ]]; then
APP_IDENTITY="${candidate}"
export APP_IDENTITY
export_team_id_from_identity "${APP_IDENTITY}"
SIGNING_MODE="identity"
return
fi
SIGNING_MODE="adhoc"
}
run_step() {
local label="$1"; shift
log "==> ${label}"
if ! "$@"; then
fail "${label} failed"
fi
}
cleanup() {
if [[ -d "${LOCK_DIR}" ]]; then
rm -rf "${LOCK_DIR}"
fi
}
acquire_lock() {
while true; do
if mkdir "${LOCK_DIR}" 2>/dev/null; then
echo "$$" > "${LOCK_PID_FILE}"
return 0
fi
local existing_pid=""
if [[ -f "${LOCK_PID_FILE}" ]]; then
existing_pid="$(cat "${LOCK_PID_FILE}" 2>/dev/null || true)"
fi
if [[ -n "${existing_pid}" ]] && kill -0 "${existing_pid}" 2>/dev/null; then
if [[ "${WAIT_FOR_LOCK}" == "1" ]]; then
log "==> Another agent is compiling (pid ${existing_pid}); waiting..."
while kill -0 "${existing_pid}" 2>/dev/null; do
sleep 1
done
continue
fi
log "==> Another agent is compiling (pid ${existing_pid}); re-run with --wait."
exit 0
fi
rm -rf "${LOCK_DIR}"
done
}
trap cleanup EXIT INT TERM
kill_claude_probes() {
# CodexBar spawns `claude /usage` + `/status` in a PTY; if we kill the app mid-probe we can orphan them.
pkill -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true
sleep 0.2
pkill -9 -f "claude (/status|/usage) --allowed-tools" 2>/dev/null || true
}
kill_all_codexbar() {
is_running() {
pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1 \
|| pgrep -f "${DEBUG_PROCESS_PATTERN}" >/dev/null 2>&1 \
|| pgrep -f "${RELEASE_PROCESS_PATTERN}" >/dev/null 2>&1 \
|| pgrep -x "CodexBar" >/dev/null 2>&1
}
# Phase 1: request termination (give the app time to exit cleanly).
for _ in {1..25}; do
pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
pkill -x "CodexBar" 2>/dev/null || true
if ! is_running; then
return 0
fi
sleep 0.2
done
# Phase 2: force kill any stragglers (avoids `open -n` creating multiple instances).
pkill -9 -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
pkill -9 -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
pkill -9 -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
pkill -9 -x "CodexBar" 2>/dev/null || true
for _ in {1..25}; do
if ! is_running; then
return 0
fi
sleep 0.2
done
fail "Failed to kill all CodexBar instances."
}
# 1) Ensure a single runner instance.
for arg in "$@"; do
case "${arg}" in
--wait|-w) WAIT_FOR_LOCK=1 ;;
--test|-t) RUN_TESTS=1 ;;
--debug-lldb) DEBUG_LLDB=1 ;;
--clear-adhoc-keychain) CLEAR_ADHOC_KEYCHAIN=1 ;;
--release-universal) RELEASE_ARCHES="arm64 x86_64" ;;
--release-arches=*) RELEASE_ARCHES="${arg#*=}" ;;
--help|-h)
log "Usage: $(basename "$0") [--wait] [--test] [--debug-lldb] [--clear-adhoc-keychain] [--release-universal] [--release-arches=\"arm64 x86_64\"]"
exit 0
;;
*)
;;
esac
done
ensure_swift_version
resolve_signing_mode
if [[ "${CLEAR_ADHOC_KEYCHAIN}" == "1" && "${SIGNING_MODE}" != "adhoc" ]]; then
fail "--clear-adhoc-keychain is only supported when using adhoc signing."
fi
if [[ "${SIGNING_MODE}" == "adhoc" ]]; then
log "==> Signing: adhoc (set APP_IDENTITY or install a dev cert to avoid keychain prompts)"
else
log "==> Signing: ${APP_IDENTITY:-Developer ID Application}"
fi
acquire_lock
# 2) Kill all running CodexBar instances (debug, release, bundled).
log "==> Killing existing CodexBar instances"
kill_all_codexbar
kill_claude_probes
# 2.5) Optionally delete keychain entries to avoid permission prompts with adhoc signing
# (adhoc signature changes on every build, making old keychain entries inaccessible)
if [[ "${SIGNING_MODE:-adhoc}" == "adhoc" && "${CLEAR_ADHOC_KEYCHAIN}" == "1" ]]; then
log "==> Clearing CodexBar keychain entries (adhoc signing)"
# Clear both the legacy keychain store and the current cache service when developers explicitly want a clean reset
# of CodexBar-owned keychain state for ad-hoc builds.
delete_keychain_service_items "com.steipete.CodexBar"
delete_keychain_service_items "com.steipete.codexbar.cache"
elif [[ "${SIGNING_MODE:-adhoc}" == "adhoc" ]]; then
log "==> Preserving CodexBar keychain entries (pass --clear-adhoc-keychain to reset adhoc keychain state)"
fi
# 3) Package (release build happens inside package_app.sh).
if [[ "${RUN_TESTS}" == "1" ]]; then
run_step "sharded swift tests" "${ROOT_DIR}/Scripts/test.sh"
fi
if [[ "${DEBUG_LLDB}" == "1" && -n "${RELEASE_ARCHES}" ]]; then
fail "--release-arches is only supported for release packaging"
fi
HOST_ARCH="$(uname -m)"
ARCHES_VALUE="${HOST_ARCH}"
if [[ -n "${RELEASE_ARCHES}" ]]; then
ARCHES_VALUE="${RELEASE_ARCHES}"
fi
PACKAGE_ENV=(
ARCHES="${ARCHES_VALUE}"
)
if [[ "${DEBUG_LLDB}" == "1" ]]; then
run_step "package app" env CODEXBAR_ALLOW_LLDB=1 "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh" debug
else
if [[ -n "${SIGNING_MODE}" ]]; then
run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh"
else
run_step "package app" env "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh"
fi
fi
# 4) Launch the packaged app.
log "==> launch app"
if ! open "${APP_BUNDLE}"; then
log "WARN: launch app returned non-zero; falling back to direct binary launch."
"${APP_BUNDLE}/Contents/MacOS/CodexBar" >/dev/null 2>&1 &
disown
fi
# 5) Verify the app stays up for at least a moment (launch can be >1s on some systems).
for _ in {1..10}; do
if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then
log "OK: CodexBar is running."
exit 0
fi
sleep 0.4
done
fail "App exited immediately. Check crash logs in Console.app (User Reports)."
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env swift
import Foundation
struct SurveyOptions {
var root: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".codex", isDirectory: true)
.appendingPathComponent("sessions", isDirectory: true)
var days: Double = 30
}
struct Survey {
var files = 0
var totalBytes: Int64 = 0
var lines = 0
var relevantLines = 0
var lineLengths: [Int] = []
var linesOver32KiB = 0
var linesOver256KiB = 0
var turnContextLines = 0
var turnContextOver32KiB = 0
var turnContextOver256KiB = 0
var turnContextModelOffsets: [Int] = []
var turnContextModelOffsetUnder32KiB = 0
var turnContextModelOffsetUnder256KiB = 0
var tokenCountLines = 0
var tokenCountMissingExplicitModel = 0
mutating func recordFile(byteCount: Int64) {
self.files += 1
self.totalBytes += byteCount
}
mutating func recordLine(_ line: Data) {
guard !line.isEmpty else { return }
let length = line.count
self.lines += 1
self.lineLengths.append(length)
if length > 32 * 1024 {
self.linesOver32KiB += 1
}
if length > 256 * 1024 {
self.linesOver256KiB += 1
}
let isRelevant = line.contains(Marker.eventMessage)
|| line.contains(Marker.turnContext)
|| line.contains(Marker.sessionMetadata)
if isRelevant {
self.relevantLines += 1
}
if line.contains(Marker.turnContext) {
self.turnContextLines += 1
if length > 32 * 1024 {
self.turnContextOver32KiB += 1
}
if length > 256 * 1024 {
self.turnContextOver256KiB += 1
}
if let offset = line.firstOffset(of: Marker.modelField)
?? line.firstOffset(of: Marker.modelNameField)
{
self.turnContextModelOffsets.append(offset)
if offset < 32 * 1024 {
self.turnContextModelOffsetUnder32KiB += 1
}
if offset < 256 * 1024 {
self.turnContextModelOffsetUnder256KiB += 1
}
}
}
if line.contains(Marker.tokenCount) {
self.tokenCountLines += 1
if !line.contains(Marker.modelField), !line.contains(Marker.modelNameField) {
self.tokenCountMissingExplicitModel += 1
}
}
}
}
enum Marker {
static let eventMessage = Data(#""type":"event_msg""#.utf8)
static let turnContext = Data(#""type":"turn_context""#.utf8)
static let sessionMetadata = Data(#""type":"session_meta""#.utf8)
static let tokenCount = Data(#""token_count""#.utf8)
static let modelField = Data(#""model""#.utf8)
static let modelNameField = Data(#""model_name""#.utf8)
}
extension Data {
func contains(_ marker: Data) -> Bool {
self.range(of: marker) != nil
}
func firstOffset(of marker: Data) -> Int? {
guard let range = self.range(of: marker) else { return nil }
return self.distance(from: self.startIndex, to: range.lowerBound)
}
}
func parseOptions(arguments: [String]) throws -> SurveyOptions {
var options = SurveyOptions()
var index = 1
while index < arguments.count {
switch arguments[index] {
case "--root":
index += 1
guard index < arguments.count else {
throw UsageError.message("--root requires a path")
}
options.root = URL(fileURLWithPath: expandTilde(arguments[index]), isDirectory: true)
case "--days":
index += 1
guard index < arguments.count, let days = Double(arguments[index]) else {
throw UsageError.message("--days requires a number")
}
options.days = days
case "--help", "-h":
printUsage()
Foundation.exit(0)
default:
throw UsageError.message("unknown argument: \(arguments[index])")
}
index += 1
}
return options
}
func expandTilde(_ path: String) -> String {
guard path == "~" || path.hasPrefix("~/") else { return path }
let home = FileManager.default.homeDirectoryForCurrentUser.path
if path == "~" {
return home
}
return home + String(path.dropFirst())
}
enum UsageError: Error, CustomStringConvertible {
case message(String)
var description: String {
switch self {
case let .message(text):
text
}
}
}
func printUsage() {
print(
"""
Usage: Scripts/cost_jsonl_shape_survey.swift [--root PATH] [--days N]
Scans local Codex JSONL logs and prints aggregate shape only. It does not
print prompts, tool payloads, model values, file paths, or raw log lines.
""")
}
func jsonlFiles(root: URL, modifiedSince cutoff: Date) -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .fileSizeKey],
options: [.skipsHiddenFiles]) else { return [] }
var files: [URL] = []
for case let fileURL as URL in enumerator {
guard fileURL.pathExtension == "jsonl" else { continue }
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey])
guard values?.isRegularFile == true else { continue }
guard let modifiedAt = values?.contentModificationDate, modifiedAt >= cutoff else { continue }
files.append(fileURL)
}
return files.sorted { $0.path < $1.path }
}
func validateRoot(_ root: URL) throws {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: root.path, isDirectory: &isDirectory)
guard exists, isDirectory.boolValue else {
throw UsageError.message("root does not exist or is not a directory")
}
}
func scan(fileURL: URL, into survey: inout Survey) throws {
let values = try fileURL.resourceValues(forKeys: [.fileSizeKey])
survey.recordFile(byteCount: Int64(values.fileSize ?? 0))
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
var current = Data()
current.reserveCapacity(4 * 1024)
while true {
let chunk = try handle.read(upToCount: 256 * 1024) ?? Data()
if chunk.isEmpty {
survey.recordLine(current)
break
}
var segmentStart = chunk.startIndex
while let newline = chunk[segmentStart...].firstIndex(of: 0x0A) {
current.append(contentsOf: chunk[segmentStart..<newline])
survey.recordLine(current)
current.removeAll(keepingCapacity: true)
segmentStart = chunk.index(after: newline)
}
if segmentStart < chunk.endIndex {
current.append(contentsOf: chunk[segmentStart..<chunk.endIndex])
}
}
}
func percentile(_ values: [Int], _ percentile: Double) -> Int {
guard !values.isEmpty else { return 0 }
let sorted = values.sorted()
let index = Int((percentile * Double(sorted.count - 1)).rounded())
return sorted[max(0, min(index, sorted.count - 1))]
}
func printSummary(_ survey: Survey, options: SurveyOptions) {
print("root: \(redactedRootDescription(options.root))")
print("window days: \(Int(options.days))")
print("files: \(survey.files)")
print("total bytes: \(survey.totalBytes)")
print("lines: \(survey.lines)")
print("relevant Codex scanner lines: \(survey.relevantLines)")
print(
"line length p50/p90/p95/p99/max: " +
"\(percentile(survey.lineLengths, 0.50)) / " +
"\(percentile(survey.lineLengths, 0.90)) / " +
"\(percentile(survey.lineLengths, 0.95)) / " +
"\(percentile(survey.lineLengths, 0.99)) / " +
"\(percentile(survey.lineLengths, 1.00)) bytes")
print("lines > 32 KiB: \(survey.linesOver32KiB)")
print("lines > 256 KiB: \(survey.linesOver256KiB)")
print("turn_context lines: \(survey.turnContextLines)")
print("turn_context lines > 32 KiB: \(survey.turnContextOver32KiB)")
print("turn_context lines > 256 KiB: \(survey.turnContextOver256KiB)")
print(
"turn_context model offset p50/p95/max: " +
"\(percentile(survey.turnContextModelOffsets, 0.50)) / " +
"\(percentile(survey.turnContextModelOffsets, 0.95)) / " +
"\(percentile(survey.turnContextModelOffsets, 1.00)) bytes")
print(
"turn_context model offset < 32 KiB: " +
"\(survey.turnContextModelOffsetUnder32KiB) / \(survey.turnContextLines)")
print(
"turn_context model offset < 256 KiB: " +
"\(survey.turnContextModelOffsetUnder256KiB) / \(survey.turnContextLines)")
print(
"token_count rows missing an explicit model: " +
"\(survey.tokenCountMissingExplicitModel) / \(survey.tokenCountLines)")
}
func redactedRootDescription(_ root: URL) -> String {
let defaultRoot = SurveyOptions().root.standardizedFileURL.path
let currentRoot = root.standardizedFileURL.path
if currentRoot == defaultRoot {
return "default Codex sessions"
}
return "custom root (redacted)"
}
do {
let options = try parseOptions(arguments: CommandLine.arguments)
try validateRoot(options.root)
let cutoff = Date().addingTimeInterval(-options.days * 24 * 60 * 60)
let files = jsonlFiles(root: options.root, modifiedSince: cutoff)
guard !files.isEmpty else {
throw UsageError.message("no .jsonl files found in the selected time window")
}
var survey = Survey()
for fileURL in files {
try scan(fileURL: fileURL, into: &survey)
}
printSummary(survey, options: options)
} catch let error as UsageError {
fputs("error: \(error.description)\n\n", stderr)
printUsage()
Foundation.exit(2)
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
Foundation.exit(1)
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const DOCS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'docs');
const EXCLUDED_DIRS = new Set(['archive', 'research']);
function walkMarkdownFiles(dir, base = dir) {
const entries = readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) continue;
files.push(...walkMarkdownFiles(fullPath, base));
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(relative(base, fullPath));
}
}
return files.sort((a, b) => a.localeCompare(b));
}
function extractMetadata(fullPath) {
const content = readFileSync(fullPath, 'utf8');
if (!content.startsWith('---')) {
return { summary: null, readWhen: [], error: 'missing front matter' };
}
const endIndex = content.indexOf('\n---', 3);
if (endIndex === -1) {
return { summary: null, readWhen: [], error: 'unterminated front matter' };
}
const frontMatter = content.slice(3, endIndex).trim();
const lines = frontMatter.split('\n');
let summaryLine = null;
const readWhen = [];
let collectingReadWhen = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (line.startsWith('summary:')) {
summaryLine = line;
collectingReadWhen = false;
continue;
}
if (line.startsWith('read_when:')) {
collectingReadWhen = true;
const inline = line.slice('read_when:'.length).trim();
if (inline.startsWith('[') && inline.endsWith(']')) {
try {
const parsed = JSON.parse(inline.replace(/'/g, '"'));
if (Array.isArray(parsed)) {
parsed
.map((v) => String(v).trim())
.filter(Boolean)
.forEach((v) => readWhen.push(v));
}
} catch {
/* ignore malformed inline */
}
}
continue;
}
if (collectingReadWhen) {
if (line.startsWith('- ')) {
const hint = line.slice(2).trim();
if (hint) readWhen.push(hint);
} else if (line === '') {
// allow blank spacer lines inside list
} else {
collectingReadWhen = false;
}
}
}
if (!summaryLine) {
return { summary: null, readWhen, error: 'summary key missing' };
}
const summaryValue = summaryLine.slice('summary:'.length).trim();
const normalized = summaryValue.replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return { summary: null, readWhen, error: 'summary is empty' };
}
return { summary: normalized, readWhen };
}
console.log('Listing all markdown files in docs folder:');
const markdownFiles = walkMarkdownFiles(DOCS_DIR);
for (const relativePath of markdownFiles) {
const fullPath = join(DOCS_DIR, relativePath);
const { summary, readWhen, error } = extractMetadata(fullPath);
if (summary) {
console.log(`${relativePath} - ${summary}`);
if (readWhen.length > 0) {
console.log(` Read when: ${readWhen.join('; ')}`);
}
} else {
const reason = error ? ` - [${error}]` : '';
console.log(`${relativePath}${reason}`);
}
}
console.log('\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.');
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const docsDir = path.join(repoRoot, "docs");
const args = process.argv.slice(2);
const mode = parseMode(args);
const cname = fs.readFileSync(path.join(docsDir, "CNAME"), "utf8").trim();
const origin = "https://" + cname;
const productName = "CodexBar";
const source = "https://github.com/steipete/CodexBar";
const outputPath = path.join(docsDir, "llms.txt");
const pages = allHtml(docsDir)
.map((file) => {
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
if (rel === "404.html" || rel === "social.html") return null;
const html = fs.readFileSync(file, "utf8");
return {
rel,
title: textContent(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]) || titleize(path.basename(rel, ".html")),
description: attr(html.match(/<meta\s+name=["']description["']\s+content=["']([^"']*)["'][^>]*>/i)?.[1] || ""),
};
})
.filter(Boolean)
.sort((a, b) => (a.rel === "index.html" ? -1 : b.rel === "index.html" ? 1 : a.rel.localeCompare(b.rel)));
const productDescription =
pages.find((page) => page.rel === "index.html")?.description ||
"CodexBar shows AI coding-provider usage limits in the macOS menu bar.";
const lines = [
"# " + productName,
"",
productDescription,
"",
"Canonical documentation:",
...pages.map((page) => "- " + page.title + ": " + pageUrl(page.rel) + (page.description ? " - " + page.description : "")),
"",
"Source: " + source,
"",
"Guidance for agents:",
"- Prefer the canonical documentation URLs above over README excerpts or package metadata.",
"- Fetch only the pages needed for the current task; this is an index, not a full-site corpus.",
"",
];
const output = lines.join("\n");
if (mode === "check") {
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : null;
if (current !== output) {
console.error(`${path.relative(repoRoot, outputPath)} is out of date; run node Scripts/generate-llms.mjs`);
process.exit(1);
}
console.log("llms index OK: " + path.relative(repoRoot, outputPath));
} else {
fs.writeFileSync(outputPath, output, "utf8");
console.log("wrote " + path.relative(repoRoot, outputPath));
}
function parseMode(values) {
if (values.length === 0) return "write";
if (values.length === 1 && (values[0] === "write" || values[0] === "--write")) return "write";
if (values.length === 1 && (values[0] === "check" || values[0] === "--check")) return "check";
console.error("Usage: node Scripts/generate-llms.mjs [write|--write|check|--check]");
process.exit(2);
}
function allHtml(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.name === "node_modules" || entry.name.startsWith(".")) return [];
if (entry.isDirectory()) return allHtml(full);
return entry.name.endsWith(".html") ? [full] : [];
});
}
function pageUrl(rel) {
return rel === "index.html" ? origin + "/" : origin + "/" + rel;
}
function textContent(value) {
return attr(value || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
}
function attr(value) {
return String(value || "")
.replace(/&mdash;/g, "-")
.replace(/&amp;/g, "&")
.replace(/&nbsp;/g, " ")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.trim();
}
function titleize(input) {
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
}
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOOLS_DIR="${ROOT_DIR}/.build/lint-tools"
BIN_DIR="${TOOLS_DIR}/bin"
SWIFTFORMAT_VERSION="0.61.1"
SWIFTLINT_VERSION="0.65.0"
SWIFTFORMAT_SHA256_DARWIN="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
SWIFTLINT_SHA256_DARWIN="d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6"
SWIFTFORMAT_SHA256_LINUX_X86_64="7bc8706e3fd51963f1f29eb99098ebdf482f3497fa527c68e6cf75cbee29c77a"
SWIFTLINT_SHA256_LINUX_X86_64="79306a34e5c7cc55a220cd108cbb861dcad5f10138dcdf261e2624ae8b0a486b"
SWIFTFORMAT_SHA256_LINUX_ARM64="42a35b557a6d56975fba3a48e78d39ab5388c8faac65d4819f25d3e20c7504c0"
SWIFTLINT_SHA256_LINUX_ARM64="12d3b84bc5b69ae13a99a5a5c79904f9ce25867f099f6368d0037854f9ee6c26"
log() { printf '%s\n' "$*"; }
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
INSTALL_SWIFTFORMAT=false
INSTALL_SWIFTLINT=false
if [[ "$#" -eq 0 ]]; then
INSTALL_SWIFTFORMAT=true
INSTALL_SWIFTLINT=true
else
for tool in "$@"; do
case "$tool" in
all)
INSTALL_SWIFTFORMAT=true
INSTALL_SWIFTLINT=true
;;
swiftformat)
INSTALL_SWIFTFORMAT=true
;;
swiftlint)
INSTALL_SWIFTLINT=true
;;
*)
fail "Unknown lint tool '${tool}'. Usage: $(basename "$0") [all|swiftformat|swiftlint]..."
;;
esac
done
fi
sha256_value() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
return 0
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
return 0
fi
fail "Missing shasum/sha256sum."
}
download_file() {
local url="$1"
local out="$2"
curl -fL --retry 3 --retry-connrefused --retry-delay 2 -o "$out" "$url"
}
install_zip_binary() {
local label="$1"
local url="$2"
local expected_sha="$3"
local binary_name="$4"
local installed_name="${5:-$binary_name}"
local tmp_zip
tmp_zip="$(mktemp -t "${label}.XXXX")"
local tmp_dir
tmp_dir="$(mktemp -d -t "${label}.XXXX")"
log "==> Downloading ${label}"
download_file "$url" "$tmp_zip"
local actual_sha
actual_sha="$(sha256_value "$tmp_zip")"
if [[ -n "$expected_sha" && "$actual_sha" != "$expected_sha" ]]; then
rm -f "$tmp_zip"
rm -rf "$tmp_dir"
fail "${label} SHA256 mismatch (expected ${expected_sha}, got ${actual_sha})"
fi
unzip -q "$tmp_zip" -d "$tmp_dir"
local extracted_path=""
if [[ -f "${tmp_dir}/${binary_name}" ]]; then
extracted_path="${tmp_dir}/${binary_name}"
else
extracted_path="$(find "$tmp_dir" -type f -name "$binary_name" | head -n 1 || true)"
fi
if [[ -z "$extracted_path" || ! -f "$extracted_path" ]]; then
rm -f "$tmp_zip"
rm -rf "$tmp_dir"
fail "${label} binary '${binary_name}' not found in archive"
fi
install -m 0755 "$extracted_path" "${BIN_DIR}/${installed_name}"
rm -f "$tmp_zip"
rm -rf "$tmp_dir"
}
mkdir -p "$BIN_DIR"
swiftformat_installed() {
[[ -x "${BIN_DIR}/swiftformat" ]] \
&& [[ "$("${BIN_DIR}/swiftformat" --version 2>/dev/null || true)" == "${SWIFTFORMAT_VERSION}" ]]
}
swiftlint_installed() {
[[ -x "${BIN_DIR}/swiftlint" ]] \
&& [[ "$("${BIN_DIR}/swiftlint" version 2>/dev/null || true)" == "${SWIFTLINT_VERSION}" ]]
}
if { [[ "$INSTALL_SWIFTFORMAT" != true ]] || swiftformat_installed; } \
&& { [[ "$INSTALL_SWIFTLINT" != true ]] || swiftlint_installed; }
then
log "==> Requested lint tools already installed"
exit 0
fi
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Darwin)
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip"
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip"
if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then
install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256_DARWIN" "swiftformat"
fi
if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then
install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256_DARWIN" "swiftlint"
fi
;;
Linux)
case "$ARCH" in
x86_64)
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux.zip"
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_amd64.zip"
SWIFTFORMAT_BINARY="swiftformat_linux"
SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_X86_64"
SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_X86_64"
;;
aarch64|arm64)
SWIFTFORMAT_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat_linux_aarch64.zip"
SWIFTLINT_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux_arm64.zip"
SWIFTFORMAT_BINARY="swiftformat_linux_aarch64"
SWIFTFORMAT_SHA256="$SWIFTFORMAT_SHA256_LINUX_ARM64"
SWIFTLINT_SHA256="$SWIFTLINT_SHA256_LINUX_ARM64"
;;
*)
fail "Unsupported Linux arch: ${ARCH}"
;;
esac
if { [[ "$INSTALL_SWIFTFORMAT" == true ]] && [[ -z "$SWIFTFORMAT_SHA256" ]]; } \
|| { [[ "$INSTALL_SWIFTLINT" == true ]] && [[ -z "$SWIFTLINT_SHA256" ]]; }
then
log "WARN: Linux SHA256 verification not configured for ${ARCH}; installing anyway."
fi
if [[ "$INSTALL_SWIFTFORMAT" == true ]] && ! swiftformat_installed; then
install_zip_binary "SwiftFormat ${SWIFTFORMAT_VERSION}" "$SWIFTFORMAT_URL" "$SWIFTFORMAT_SHA256" "$SWIFTFORMAT_BINARY" "swiftformat"
fi
if [[ "$INSTALL_SWIFTLINT" == true ]] && ! swiftlint_installed; then
install_zip_binary "SwiftLint ${SWIFTLINT_VERSION}" "$SWIFTLINT_URL" "$SWIFTLINT_SHA256" "swiftlint"
fi
;;
*)
fail "Unsupported OS: ${OS}"
;;
esac
log "==> Installed lint tools to ${BIN_DIR}"
if [[ "$INSTALL_SWIFTFORMAT" == true ]]; then
"${BIN_DIR}/swiftformat" --version
fi
if [[ "$INSTALL_SWIFTLINT" == true ]]; then
"${BIN_DIR}/swiftlint" version
fi
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
# Simple script to launch CodexBar (kills existing instance first)
# Usage: ./Scripts/launch.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
APP_PATH="$PROJECT_ROOT/CodexBar.app"
echo "==> Killing existing CodexBar instances"
pkill -x CodexBar || pkill -f CodexBar.app || true
sleep 0.5
if [[ ! -d "$APP_PATH" ]]; then
echo "ERROR: CodexBar.app not found at $APP_PATH"
echo "Run ./Scripts/package_app.sh first to build the app"
exit 1
fi
echo "==> Launching CodexBar from $APP_PATH"
open -n "$APP_PATH"
# Wait a moment and check if it's running
sleep 1
if pgrep -x CodexBar > /dev/null; then
echo "OK: CodexBar is running."
else
echo "ERROR: App exited immediately. Check crash logs in Console.app (User Reports)."
exit 1
fi
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_DIR="${ROOT_DIR}/.build/lint-tools/bin"
ensure_swiftformat() {
"${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftformat
}
ensure_swiftlint() {
"${ROOT_DIR}/Scripts/install_lint_tools.sh" swiftlint
}
check_codex_parser_hash() {
"${ROOT_DIR}/Scripts/regenerate-codex-parser-hash.sh" --check
}
check_package_product_paths() {
"${ROOT_DIR}/Scripts/test_package_product_paths.sh"
}
check_package_strip() {
"${ROOT_DIR}/Scripts/test_package_strip.sh"
}
check_package_signing() {
"${ROOT_DIR}/Scripts/test_package_signing.sh"
}
check_release_dsym_paths() {
"${ROOT_DIR}/Scripts/test_release_dsym_paths.sh"
}
check_sparkle_signing_paths() {
"${ROOT_DIR}/Scripts/test_sparkle_signing_paths.sh"
}
check_swift_test_sharding() {
"${ROOT_DIR}/Scripts/test_swift_test_sharding.sh"
}
check_ci_path_gate() {
"${ROOT_DIR}/Scripts/test_ci_path_gate.sh"
}
check_repository_size() {
"${ROOT_DIR}/Scripts/check_repository_size.sh"
"${ROOT_DIR}/Scripts/test_repository_size.sh"
}
check_shell_scripts() {
local count=0
local script
for script in "${ROOT_DIR}"/Scripts/*.sh "${ROOT_DIR}"/Scripts/mac-release; do
[[ -f "$script" ]] || continue
bash -n "$script"
count=$((count + 1))
done
printf 'shell scripts OK: %d files\n' "$count"
}
check_app_locales() {
node "${ROOT_DIR}/Scripts/check-app-locales.mjs" --test
node "${ROOT_DIR}/Scripts/check-app-locales.mjs"
}
check_site_locales() {
node "${ROOT_DIR}/Scripts/check-site-locales.mjs"
node --check "${ROOT_DIR}/docs/site.js"
}
check_documentation_links() {
node "${ROOT_DIR}/Scripts/check-documentation-links.mjs"
}
check_llms_index() {
node "${ROOT_DIR}/Scripts/generate-llms.mjs" --check
}
run_portable_checks() {
check_codex_parser_hash
check_package_product_paths
check_package_strip
check_package_signing
check_release_dsym_paths
check_sparkle_signing_paths
check_swift_test_sharding
check_ci_path_gate
check_repository_size
check_shell_scripts
check_documentation_links
check_llms_index
check_site_locales
}
run_swiftformat_lint() {
ensure_swiftformat
"${BIN_DIR}/swiftformat" Sources Tests --lint
}
run_swiftlint() {
ensure_swiftlint
"${BIN_DIR}/swiftlint" --strict
}
cmd="${1:-lint}"
case "$cmd" in
lint)
check_app_locales
run_portable_checks
run_swiftformat_lint
run_swiftlint
;;
lint-linux)
run_portable_checks
run_swiftlint
;;
lint-macos)
check_app_locales
run_swiftformat_lint
;;
format)
ensure_swiftformat
"${BIN_DIR}/swiftformat" Sources Tests
;;
*)
printf 'Usage: %s [lint|lint-linux|lint-macos|format]\n' "$(basename "$0")" >&2
exit 2
;;
esac
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
cd "$ROOT"
if [[ -n "${MAC_RELEASE_TOOL:-}" ]]; then
exec "$MAC_RELEASE_TOOL" "$@"
fi
for candidate in \
"$ROOT/../agent-scripts/skills/release-mac-app/scripts/mac-release" \
"$HOME/Projects/agent-scripts/skills/release-mac-app/scripts/mac-release"; do
if [[ -x "$candidate" ]]; then
exec "$candidate" "$@"
fi
done
cat >&2 <<'EOF'
Missing mac-release helper.
Clone agent-scripts next to this repo or set MAC_RELEASE_TOOL=/path/to/mac-release.
EOF
exit 127
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
exec "$SCRIPT_DIR/mac-release" make-appcast "$@"
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
mimo-usage — local token usage tracker for cc-mimo
Scans ~/.claude-envs/mimo/.claude/projects/**/*.jsonl session files,
sums input/output/cache tokens per time window (today/week/all),
writes to ~/.codexbar/mimo-local-usage.json, and prints a human-readable
summary by default.
Usage:
mimo-usage # show summary (also refreshes cache)
mimo-usage --update # refresh cache only, no output (for LaunchAgent/wrapper)
mimo-usage --json # JSON output
mimo-usage --short # 1-line status (for status line / widget)
"""
import json
import os
import sys
from pathlib import Path
from datetime import datetime, timedelta, timezone
MIMO_HOME = Path(os.environ.get("MIMO_CLAUDE_HOME", Path.home() / ".claude-envs" / "mimo")).expanduser()
PROJECTS_DIR = MIMO_HOME / ".claude" / "projects"
CACHE_PATH = Path(
os.environ.get("MIMO_LOCAL_USAGE_PATH", Path.home() / ".codexbar" / "mimo-local-usage.json")
).expanduser()
def parse_session_usage(jsonl_path: Path):
"""Yield (identity, timestamp_iso, usage_dict) for each assistant message with usage."""
try:
with jsonl_path.open() as f:
for line in f:
try:
d = json.loads(line)
ts = d.get("timestamp")
msg = d.get("message")
if not isinstance(msg, dict):
continue
usage = msg.get("usage")
if not isinstance(usage, dict):
continue
if not ts:
continue
metadata = d.get("metadata")
message_metadata = msg.get("metadata")
session_id = d.get("sessionId") or d.get("session_id")
if not session_id and isinstance(metadata, dict):
session_id = metadata.get("sessionId")
if not session_id and isinstance(message_metadata, dict):
session_id = message_metadata.get("sessionId")
message_id = msg.get("id")
request_id = d.get("requestId") or d.get("request_id")
identity = None
if all(isinstance(value, str) and value for value in (message_id, request_id)):
identity = ("request", message_id, request_id)
elif (
request_id is None
and isinstance(session_id, str)
and session_id
and isinstance(message_id, str)
and message_id
):
identity = ("legacy", session_id, message_id)
yield identity, ts, usage
except (json.JSONDecodeError, ValueError):
continue
except (OSError, IOError):
return
def aggregate_usage():
"""Scan all mimo session jsonls and return windowed token sums."""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Week starts on Monday 00:00 UTC
week_start = today_start - timedelta(days=today_start.weekday())
windows = {
"today": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
"week": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
"all_time": {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "messages": 0},
}
sessions_scanned = 0
last_activity = None
keyed_rows = {}
unkeyed_rows = []
if not PROJECTS_DIR.exists():
return windows, sessions_scanned, last_activity
for jsonl in PROJECTS_DIR.rglob("*.jsonl"):
sessions_scanned += 1
for identity, ts_str, usage in parse_session_usage(jsonl):
try:
# Parse ISO timestamp (may end with Z)
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
continue
row = (ts, usage)
if identity is None:
unkeyed_rows.append(row)
else:
previous = keyed_rows.get(identity)
if previous is None or ts >= previous[0]:
keyed_rows[identity] = row
for ts, usage in [*keyed_rows.values(), *unkeyed_rows]:
input_t = int(usage.get("input_tokens", 0) or 0)
output_t = int(usage.get("output_tokens", 0) or 0)
cache_read_t = int(usage.get("cache_read_input_tokens", 0) or 0)
cache_create_t = int(usage.get("cache_creation_input_tokens", 0) or 0)
if last_activity is None or ts > last_activity:
last_activity = ts
# all_time
w = windows["all_time"]
w["input"] += input_t
w["output"] += output_t
w["cache_read"] += cache_read_t
w["cache_create"] += cache_create_t
w["messages"] += 1
if ts >= week_start:
w = windows["week"]
w["input"] += input_t
w["output"] += output_t
w["cache_read"] += cache_read_t
w["cache_create"] += cache_create_t
w["messages"] += 1
if ts >= today_start:
w = windows["today"]
w["input"] += input_t
w["output"] += output_t
w["cache_read"] += cache_read_t
w["cache_create"] += cache_create_t
w["messages"] += 1
return windows, sessions_scanned, last_activity
def write_cache(windows, sessions_scanned, last_activity):
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"last_activity": last_activity.isoformat() if last_activity else None,
"sessions_scanned": sessions_scanned,
"windows": windows,
"source": "local-jsonl-scan",
"note": "Local token accounting from cc-mimo session jsonl. Not a quota; mimo platform.xiaomimimo.com SSO cookie required for real quota.",
}
tmp = CACHE_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, indent=2))
tmp.replace(CACHE_PATH)
return payload
def fmt_tokens(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.1f}k"
return str(n)
def short_status(payload):
"""1-line status line."""
w = payload["windows"]["week"]
total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"]
return f"mimo: {fmt_tokens(total)} tok this week ({w['messages']} msg)"
def human_summary(payload):
"""Multi-line human-readable summary."""
last = payload.get("last_activity")
if last:
try:
last_dt = datetime.fromisoformat(last)
ago = datetime.now(timezone.utc) - last_dt
if ago.total_seconds() < 60:
ago_str = "just now"
elif ago.total_seconds() < 3600:
ago_str = f"{int(ago.total_seconds() / 60)}m ago"
elif ago.total_seconds() < 86400:
ago_str = f"{int(ago.total_seconds() / 3600)}h ago"
else:
ago_str = f"{ago.days}d ago"
except (ValueError, TypeError):
ago_str = last
else:
ago_str = "never"
lines = [
"== MiMo (local tracker) ==",
f"Sessions scanned: {payload['sessions_scanned']}",
f"Last activity: {ago_str}",
"",
]
for window_name, label in [("today", "Today"), ("week", "This week"), ("all_time", "All time")]:
w = payload["windows"][window_name]
in_t = fmt_tokens(w["input"])
out_t = fmt_tokens(w["output"])
cr_t = fmt_tokens(w["cache_read"])
cc_t = fmt_tokens(w["cache_create"])
total = w["input"] + w["output"] + w["cache_read"] + w["cache_create"]
lines.append(f"{label:>10}: {fmt_tokens(total):>8} total | in={in_t} out={out_t} cache_r={cr_t} cache_c={cc_t} | msg={w['messages']}")
lines.append("")
lines.append("Note: this is local accounting from cc-mimo session jsonl.")
lines.append("Real platform quota requires Chrome cookie (cookieSource=manual).")
return "\n".join(lines)
def main():
args = sys.argv[1:]
quiet = "--update" in args
json_out = "--json" in args
short = "--short" in args
windows, sessions_scanned, last_activity = aggregate_usage()
payload = write_cache(windows, sessions_scanned, last_activity)
if quiet:
return 0
if json_out:
print(json.dumps(payload, indent=2))
return 0
if short:
print(short_status(payload))
return 0
print(human_summary(payload))
return 0
if __name__ == "__main__":
sys.exit(main())
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env bash
set -euo pipefail
resolve_package_signing_mode() {
local requested="${CODEXBAR_SIGNING:-adhoc}"
case "$requested" in
adhoc|identity) ;;
*)
echo "ERROR: Unsupported CODEXBAR_SIGNING: $requested (expected adhoc or identity)" >&2
return 1
;;
esac
SIGNING_MODE="$requested"
}
CONF=${1:-release}
ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0}
SIGNING_MODE=
resolve_package_signing_mode
ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "$ROOT"
LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]')
case "$LOWER_CONF" in
debug|release) ;;
*)
echo "ERROR: Unsupported build configuration: $CONF (expected debug or release)" >&2
exit 1
;;
esac
# Load version info
source "$ROOT/version.env"
source "$ROOT/Scripts/package_product_paths.sh"
source "$ROOT/Scripts/sparkle_signing_paths.sh"
# Clean build only when explicitly requested (slower).
if [[ "${CODEXBAR_FORCE_CLEAN:-0}" == "1" ]]; then
if [[ -d "$ROOT/.build" ]]; then
if command -v trash >/dev/null 2>&1; then
if ! trash "$ROOT/.build"; then
echo "WARN: trash .build failed; continuing with swift package clean." >&2
fi
else
rm -rf "$ROOT/.build" || echo "WARN: rm -rf .build failed; continuing with swift package clean." >&2
fi
fi
swift package clean >/dev/null 2>&1 || true
fi
# Build for host architecture by default; allow overriding via ARCHES (e.g., "arm64 x86_64" for universal).
ARCH_LIST=( ${ARCHES:-} )
if [[ ${#ARCH_LIST[@]} -eq 0 ]]; then
HOST_ARCH=$(uname -m)
case "$HOST_ARCH" in
arm64) ARCH_LIST=(arm64) ;;
x86_64) ARCH_LIST=(x86_64) ;;
*) ARCH_LIST=("$HOST_ARCH") ;;
esac
fi
patch_keyboard_shortcuts() {
local util_path="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift"
if [[ ! -f "$util_path" ]]; then
return 0
fi
if grep -q "keyboardShortcutsSafeBundle" "$util_path"; then
return 0
fi
chmod +w "$util_path" || true
python3 - "$util_path" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
text = path.read_text()
if ".keyboardShortcutsSafeBundle" in text:
sys.exit(0)
text = text.replace(
'NSLocalizedString(self, bundle: .module, comment: self)',
'NSLocalizedString(self, bundle: .keyboardShortcutsSafeBundle, comment: self)',
)
inject = """
private extension Bundle {
/// Safe lookup that avoids the fatal trap in the autogenerated `Bundle.module`
/// when the resource bundle is not placed at the bundle root.
static let keyboardShortcutsSafeBundle: Bundle = {
#if os(macOS)
if let url = Bundle.main.url(forResource: "KeyboardShortcuts_KeyboardShortcuts", withExtension: "bundle"),
let bundle = Bundle(url: url) {
return bundle
}
let rootURL = Bundle.main.bundleURL.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle")
if let bundle = Bundle(url: rootURL) {
return bundle
}
#endif
let devURL = URL(fileURLWithPath: #file)
.deletingLastPathComponent() // Utilities.swift
.deletingLastPathComponent() // KeyboardShortcuts
.deletingLastPathComponent() // Sources
.appendingPathComponent("KeyboardShortcuts_KeyboardShortcuts.bundle")
if let bundle = Bundle(url: devURL) {
return bundle
}
return Bundle.main
}()
}
"""
marker = "}\n\n\nextension Data {"
if marker not in text:
raise SystemExit("Marker not found in Utilities.swift; patch failed.")
text = text.replace(marker, "}\n\n" + inject + "\n\nextension Data {")
path.write_text(text)
PY
}
KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift"
if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then
swift build -c "$CONF" --arch "${ARCH_LIST[0]}"
fi
patch_keyboard_shortcuts
# Resolve SwiftPM's current output path without relying on a fixed build-system layout.
# The output variable keeps the per-arch cache in this shell instead of losing it to
# command substitution.
swiftpm_bin_path() {
local arch="$1"
local output_var="$2"
local cache_var="SWIFTPM_BIN_PATH_${arch//[^A-Za-z0-9]/_}"
if [[ -z "${!cache_var+set}" ]]; then
local resolved
if ! resolved=$(codexbar_swiftpm_bin_path "$CONF" "$arch"); then
return 1
fi
printf -v "$cache_var" '%s' "$resolved"
fi
printf -v "$output_var" '%s' "${!cache_var}"
}
binary_has_arch() {
local binary="$1"
local arch="$2"
[[ -f "$binary" ]] && lipo -archs "$binary" 2>/dev/null | tr ' ' '\n' | grep -qx "$arch"
}
# SwiftBuild can reuse one output directory for sequential per-arch builds. Snapshot
# each fresh slice before the next build can replace it.
PRODUCT_STAGE_ROOT="$ROOT/.build/package-products/$LOWER_CONF"
rm -rf "$PRODUCT_STAGE_ROOT"
stage_build_products() {
local arch="$1"
local bin_dir stage_dir name product
swiftpm_bin_path "$arch" bin_dir
stage_dir="$PRODUCT_STAGE_ROOT/$arch"
mkdir -p "$stage_dir"
for name in CodexBar CodexBarCLI CodexBarClaudeWatchdog; do
if ! product=$(codexbar_require_product_file "$bin_dir" "$name" "$arch"); then
return 1
fi
if ! binary_has_arch "$product" "$arch"; then
echo "ERROR: ${product} does not contain required architecture: ${arch}" >&2
return 1
fi
cp "$product" "$stage_dir/$name"
done
if [[ -d "$bin_dir/CodexBar.dSYM" ]]; then
cp -R "$bin_dir/CodexBar.dSYM" "$stage_dir/"
fi
}
for ARCH in "${ARCH_LIST[@]}"; do
swift build -c "$CONF" --arch "$ARCH"
stage_build_products "$ARCH"
done
APP_FINAL="$ROOT/CodexBar.app"
APP_STAGE="$ROOT/.build/package/CodexBar.app"
rm -rf "$APP_STAGE"
APP="$APP_STAGE"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks"
mkdir -p "$APP/Contents/Helpers" "$APP/Contents/PlugIns"
# Convert new .icon bundle to .icns if present (macOS 14+/IconStudio export)
ICON_SOURCE="$ROOT/Icon.icon"
ICON_TARGET="$ROOT/Icon.icns"
if [[ -f "$ICON_SOURCE" ]]; then
iconutil --convert icns --output "$ICON_TARGET" "$ICON_SOURCE"
fi
BUNDLE_ID="com.steipete.codexbar"
FEED_URL="https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml"
AUTO_CHECKS=true
if [[ "$LOWER_CONF" == "debug" ]]; then
BUNDLE_ID="com.steipete.codexbar.debug"
FEED_URL=""
AUTO_CHECKS=false
fi
if [[ "$SIGNING_MODE" == "adhoc" ]]; then
FEED_URL=""
AUTO_CHECKS=false
fi
WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget"
APP_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}"
APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar"
if [[ "$BUNDLE_ID" == *".debug"* ]]; then
APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug"
fi
ENTITLEMENTS_DIR="$ROOT/.build/entitlements"
APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements"
WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements"
mkdir -p "$ENTITLEMENTS_DIR"
if [[ "$ALLOW_LLDB" == "1" && "$LOWER_CONF" != "debug" ]]; then
echo "ERROR: CODEXBAR_ALLOW_LLDB requires debug configuration" >&2
exit 1
fi
cat > "$APP_ENTITLEMENTS" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>${APP_GROUP_ID}</string>
</array>
$(if [[ "$ALLOW_LLDB" == "1" ]]; then echo " <key>com.apple.security.get-task-allow</key><true/>"; fi)
</dict>
</plist>
PLIST
cat > "$WIDGET_ENTITLEMENTS" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>${APP_GROUP_ID}</string>
</array>
</dict>
</plist>
PLIST
BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>CodexBar</string>
<key>CFBundleDisplayName</key><string>CodexBar</string>
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
<key>CFBundleExecutable</key><string>CodexBar</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${MARKETING_VERSION}</string>
<key>CFBundleVersion</key><string>${BUILD_NUMBER}</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>LSUIElement</key><true/>
<key>CFBundleIconFile</key><string>Icon</string>
<key>NSHumanReadableCopyright</key><string>© 2026 Peter Steinberger. MIT License.</string>
<key>SUFeedURL</key><string>${FEED_URL}</string>
<key>SUPublicEDKey</key><string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
<key>SUEnableAutomaticChecks</key><${AUTO_CHECKS}/>
<key>CodexBuildTimestamp</key><string>${BUILD_TIMESTAMP}</string>
<key>CodexGitCommit</key><string>${GIT_COMMIT}</string>
<key>CodexBarTeamID</key><string>${APP_TEAM_ID}</string>
</dict>
</plist>
PLIST
# Resolve a built binary from the fresh per-arch snapshot or SwiftPM's reported directory.
resolve_binary_path() {
local name="$1"
local arch="$2"
local bin_dir candidate
swiftpm_bin_path "$arch" bin_dir
if ! candidate=$(codexbar_resolve_staged_or_reported_file \
"$PRODUCT_STAGE_ROOT" "$bin_dir" "$name" "$arch"); then
return 1
fi
if ! binary_has_arch "$candidate" "$arch"; then
echo "ERROR: ${candidate} does not contain required architecture: ${arch}" >&2
return 1
fi
echo "$candidate"
}
verify_binary_arches() {
local binary="$1"; shift
local expected=("$@")
local actual
actual=$(lipo -archs "$binary")
local actual_count expected_count
actual_count=$(wc -w <<<"$actual" | tr -d ' ')
expected_count=${#expected[@]}
if [[ "$actual_count" -ne "$expected_count" ]]; then
echo "ERROR: $binary arch mismatch (expected: ${expected[*]}, actual: ${actual})" >&2
exit 1
fi
for arch in "${expected[@]}"; do
if [[ "$actual" != *"$arch"* ]]; then
echo "ERROR: $binary missing arch $arch (have: ${actual})" >&2
exit 1
fi
done
}
install_binary() {
local name="$1"
local dest="$2"
local binaries=()
for arch in "${ARCH_LIST[@]}"; do
local src
if ! src=$(resolve_binary_path "$name" "$arch"); then
exit 1
fi
binaries+=("$src")
done
if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then
lipo -create "${binaries[@]}" -output "$dest"
else
cp "${binaries[0]}" "$dest"
fi
chmod +x "$dest"
verify_binary_arches "$dest" "${ARCH_LIST[@]}"
}
strip_release_binary() {
local binary="$1"
if [[ "$LOWER_CONF" != "release" ]]; then
return 0
fi
if [[ ! -f "$binary" ]]; then
return 0
fi
xcrun strip -x "$binary"
}
ensure_widget_extension_project() {
local spec="$ROOT/WidgetExtension/project.yml"
local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj"
if [[ -f "$project_dir/project.pbxproj" ]]; then
return
fi
if ! command -v xcodegen >/dev/null 2>&1; then
echo "ERROR: Missing ${project_dir}; install xcodegen or restore the generated project." >&2
exit 1
fi
# The tracked project is authoritative. Regenerating it during packaging records the checkout
# directory's spelling in a package file reference and leaves release worktrees dirty.
xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet
}
build_widget_extension() {
local xcode_conf="Release"
if [[ "$LOWER_CONF" == "debug" ]]; then
xcode_conf="Debug"
fi
ensure_widget_extension_project
local derived_dir="$ROOT/.build/xcode-widget-extension-${LOWER_CONF}"
local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj"
local build_log="$derived_dir/xcodebuild.log"
local timeout_seconds="${CODEXBAR_WIDGET_EXTENSION_TIMEOUT_SECONDS:-900}"
local archs="${ARCH_LIST[*]}"
mkdir -p "$derived_dir"
echo "Building CodexBarWidget Xcode extension (${xcode_conf}, ${archs})." >&2
xcodebuild \
-project "$project_dir" \
-scheme CodexBarWidgetExtension \
-configuration "$xcode_conf" \
-destination "generic/platform=macOS" \
-derivedDataPath "$derived_dir" \
-skipPackageUpdates \
-disableAutomaticPackageResolution \
-skipMacroValidation \
-skipPackagePluginValidation \
CODEXBAR_WIDGET_BUNDLE_ID="$WIDGET_BUNDLE_ID" \
CODEXBAR_TEAM_ID="$APP_TEAM_ID" \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
CODE_SIGNING_ALLOWED=NO \
ARCHS="$archs" \
ONLY_ACTIVE_ARCH=NO \
build >"$build_log" 2>&1 &
local xcodebuild_pid=$!
local elapsed=0
while kill -0 "$xcodebuild_pid" 2>/dev/null; do
if [[ "$elapsed" -ge "$timeout_seconds" ]]; then
kill "$xcodebuild_pid" 2>/dev/null || true
wait "$xcodebuild_pid" 2>/dev/null || true
tail -80 "$build_log" >&2 || true
echo "ERROR: Timed out building CodexBarWidget extension after ${timeout_seconds}s" >&2
exit 1
fi
sleep 5
elapsed=$((elapsed + 5))
if (( elapsed > 0 && elapsed % 60 == 0 )); then
echo "Still building CodexBarWidget extension (${elapsed}s)..." >&2
fi
done
if ! wait "$xcodebuild_pid"; then
tail -120 "$build_log" >&2 || true
echo "ERROR: Failed to build CodexBarWidget extension" >&2
exit 1
fi
local appex="$derived_dir/Build/Products/${xcode_conf}/CodexBarWidget.appex"
if [[ ! -f "$appex/Contents/MacOS/CodexBarWidget" ]]; then
echo "ERROR: Missing Xcode-built CodexBarWidget.appex at ${appex}" >&2
exit 1
fi
echo "$appex"
}
install_widget_extension() {
local src_appex
src_appex="$(build_widget_extension)"
local widget_app="$APP/Contents/PlugIns/CodexBarWidget.appex"
rm -rf "$widget_app"
mkdir -p "$APP/Contents/PlugIns"
cp -R "$src_appex" "$widget_app"
verify_binary_arches "$widget_app/Contents/MacOS/CodexBarWidget" "${ARCH_LIST[@]}"
}
install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar"
strip_release_binary "$APP/Contents/MacOS/CodexBar"
# Ship CodexBarCLI alongside the app for easy symlinking.
install_binary "CodexBarCLI" "$APP/Contents/Helpers/CodexBarCLI"
strip_release_binary "$APP/Contents/Helpers/CodexBarCLI"
# Watchdog helper: ensures `claude` probes die when CodexBar crashes/gets killed.
install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
strip_release_binary "$APP/Contents/Helpers/CodexBarClaudeWatchdog"
install_widget_extension
strip_release_binary "$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
swiftpm_bin_path "${ARCH_LIST[0]}" PREFERRED_BUILD_DIR
# Embed Sparkle.framework
SPARKLE_SOURCE=$(codexbar_require_product_directory "$PREFERRED_BUILD_DIR" Sparkle.framework packaging)
cp -R "$SPARKLE_SOURCE" "$APP/Contents/Frameworks/"
chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar"
# Re-sign Sparkle and all nested components with the selected package identity.
SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
if [[ "$SIGNING_MODE" == "adhoc" ]]; then
CODESIGN_ID="-"
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
elif [[ "$ALLOW_LLDB" == "1" ]]; then
CODESIGN_ID="-"
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
else
CODESIGN_ID="${APP_IDENTITY:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}"
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID")
fi
function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }
# Validate Sparkle's nested layout before signing so framework layout drift fails clearly.
SPARKLE_SIGNING_TARGETS=$(codexbar_sparkle_signing_targets "$SPARKLE")
while IFS= read -r SPARKLE_TARGET; do
resign "$SPARKLE_TARGET"
done <<<"$SPARKLE_SIGNING_TARGETS"
if [[ -f "$ICON_TARGET" ]]; then
cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns"
fi
# Bundle app resources (provider icons, etc.).
APP_RESOURCES_DIR="$ROOT/Sources/CodexBar/Resources"
if [[ -d "$APP_RESOURCES_DIR" ]]; then
cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/"
fi
if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then
echo "ERROR: Missing Icon-classic.icns in app bundle resources." >&2
exit 1
fi
# SwiftPM resource bundles (e.g. KeyboardShortcuts) are emitted next to the built binary.
shopt -s nullglob
SWIFTPM_BUNDLES=("${PREFERRED_BUILD_DIR}/"*.bundle)
shopt -u nullglob
if [[ ${#SWIFTPM_BUNDLES[@]} -gt 0 ]]; then
for bundle in "${SWIFTPM_BUNDLES[@]}"; do
bundle_name="$(basename "$bundle")"
cp -R "$bundle" "$APP/Contents/Resources/"
done
fi
if [[ ! -d "$APP/Contents/Resources/KeyboardShortcuts_KeyboardShortcuts.bundle" ]]; then
echo "ERROR: Missing KeyboardShortcuts SwiftPM resource bundle (Settings → Keyboard shortcut will crash)." >&2
echo "Expected: ${PREFERRED_BUILD_DIR}/KeyboardShortcuts_KeyboardShortcuts.bundle" >&2
exit 1
fi
# Ensure contents are writable before stripping attributes and signing.
chmod -R u+w "$APP"
# Strip extended attributes to prevent AppleDouble (._*) files that break code sealing
xattr -cr "$APP"
find "$APP" -name '._*' -delete
# Sign helper binaries if present
if [[ -f "${APP}/Contents/Helpers/CodexBarCLI" ]]; then
codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarCLI"
fi
if [[ -f "${APP}/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then
codesign "${CODESIGN_ARGS[@]}" "${APP}/Contents/Helpers/CodexBarClaudeWatchdog"
fi
# Sign widget extension if present
if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then
codesign "${CODESIGN_ARGS[@]}" \
--entitlements "$WIDGET_ENTITLEMENTS" \
"$APP/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
codesign "${CODESIGN_ARGS[@]}" \
--entitlements "$WIDGET_ENTITLEMENTS" \
"$APP/Contents/PlugIns/CodexBarWidget.appex"
fi
# Finally sign the app bundle itself
codesign "${CODESIGN_ARGS[@]}" \
--entitlements "$APP_ENTITLEMENTS" \
"$APP"
rm -rf "$APP_FINAL"
mv "$APP" "$APP_FINAL"
APP="$APP_FINAL"
echo "Created $APP"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
codexbar_swiftpm_bin_path() {
local conf="$1"
shift
local command=(swift build --show-bin-path -c "$conf")
local arch
for arch in "$@"; do
command+=(--arch "$arch")
done
local path
if ! path=$("${command[@]}"); then
echo "ERROR: SwiftPM failed to report the ${conf} product directory for: $*" >&2
return 1
fi
if [[ -z "$path" ]]; then
echo "ERROR: SwiftPM reported an empty ${conf} product directory for: $*" >&2
return 1
fi
printf '%s\n' "$path"
}
codexbar_require_product_file() {
local bin_dir="$1"
local name="$2"
local arch_label="$3"
local product="$bin_dir/$name"
if [[ ! -f "$product" ]]; then
echo "ERROR: Missing ${name} for ${arch_label} at SwiftPM-reported path: ${product}" >&2
return 1
fi
printf '%s\n' "$product"
}
codexbar_require_product_directory() {
local bin_dir="$1"
local name="$2"
local context="$3"
local product="$bin_dir/$name"
if [[ ! -d "$product" ]]; then
echo "ERROR: Missing ${name} for ${context} at SwiftPM-reported path: ${product}" >&2
return 1
fi
printf '%s\n' "$product"
}
codexbar_resolve_staged_or_reported_file() {
local stage_root="$1"
local bin_dir="$2"
local name="$3"
local arch="$4"
local staged="$stage_root/$arch/$name"
if [[ -f "$staged" ]]; then
printf '%s\n' "$staged"
return
fi
codexbar_require_product_file "$bin_dir" "$name" "$arch"
}
codexbar_resolve_dsym_path() {
local stage_root="$1"
local bin_dir="$2"
local app_name="$3"
local arch="$4"
local staged="$stage_root/$arch/${app_name}.dSYM"
if [[ -d "$staged" ]]; then
printf '%s\n' "$staged"
return
fi
codexbar_require_product_directory "$bin_dir" "${app_name}.dSYM" "$arch"
}
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# Prepare a clean branch for upstream PR submission
# Usage: ./Scripts/prepare_upstream_pr.sh <feature-name>
set -e
FEATURE_NAME=$1
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
if [ -z "$FEATURE_NAME" ]; then
echo -e "${RED}Error: Feature name required${NC}"
echo "Usage: ./Scripts/prepare_upstream_pr.sh <feature-name>"
echo ""
echo "Examples:"
echo " ./Scripts/prepare_upstream_pr.sh fix-cursor-bonus"
echo " ./Scripts/prepare_upstream_pr.sh improve-cookie-handling"
exit 1
fi
BRANCH_NAME="upstream-pr/$FEATURE_NAME"
echo -e "${BLUE}==> Fetching latest upstream...${NC}"
git fetch upstream
echo -e "${BLUE}==> Creating upstream PR branch from upstream/main...${NC}"
git checkout upstream/main
git checkout -b "$BRANCH_NAME"
echo ""
echo -e "${GREEN}==> Branch created: $BRANCH_NAME${NC}"
echo ""
echo -e "${YELLOW}⚠️ IMPORTANT: This branch is for UPSTREAM submission${NC}"
echo ""
echo -e "${BLUE}Guidelines for upstream PRs:${NC}"
echo ""
echo "✅ DO include:"
echo " - Bug fixes that affect all users"
echo " - Performance improvements"
echo " - Provider enhancements (generic)"
echo " - Documentation improvements"
echo " - Test coverage"
echo ""
echo "❌ DO NOT include:"
echo " - Fork branding (About.swift, PreferencesAboutPane.swift)"
echo " - Fork-specific features (multi-account, etc.)"
echo " - References to topoffunnel.com"
echo " - Experimental features"
echo ""
echo -e "${BLUE}Next steps:${NC}"
echo ""
echo "1. Cherry-pick your commits (clean, no fork branding):"
echo " ${GREEN}git cherry-pick <commit-hash>${NC}"
echo ""
echo "2. Or manually apply changes:"
echo " ${GREEN}# Edit files${NC}"
echo " ${GREEN}git add <files>${NC}"
echo " ${GREEN}git commit -m 'fix: description'${NC}"
echo ""
echo "3. Ensure tests pass:"
echo " ${GREEN}make test${NC}"
echo ""
echo "4. Review changes:"
echo " ${GREEN}git diff upstream/main${NC}"
echo ""
echo "5. Push to your fork:"
echo " ${GREEN}git push origin $BRANCH_NAME${NC}"
echo ""
echo "6. Create PR on GitHub:"
echo " ${GREEN}https://github.com/steipete/CodexBar/compare/main...topoffunnel:$BRANCH_NAME${NC}"
echo ""
echo -e "${YELLOW}Remember: Keep PRs small and focused for better merge chances!${NC}"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SOURCE_DIR="${ROOT_DIR}/Sources/CodexBarCore/Vendored/CostUsage"
OUTPUT_FILE="${ROOT_DIR}/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift"
MODE="${1:-write}"
case "$MODE" in
write | --write)
CHECK_ONLY=0
;;
check | --check)
CHECK_ONLY=1
;;
*)
echo "Usage: $0 [write|--write|check|--check]" >&2
exit 2
;;
esac
if command -v shasum >/dev/null 2>&1; then
HASH_CMD=(shasum -a 256)
elif command -v sha256sum >/dev/null 2>&1; then
HASH_CMD=(sha256sum)
else
echo "error: shasum or sha256sum is required" >&2
exit 1
fi
FILE_LIST="$(mktemp)"
trap 'rm -f "$FILE_LIST"' EXIT
find "$SOURCE_DIR" \
-type f \
-name '*.swift' \
! -name '*Claude*' \
-print |
sed "s#^${ROOT_DIR}/##" |
LC_ALL=C sort >"$FILE_LIST"
HASH="$(
while IFS= read -r file; do
printf '== %s ==\n' "$file"
cat "${ROOT_DIR}/${file}"
printf '\n'
done <"$FILE_LIST" | "${HASH_CMD[@]}"
)"
HASH="${HASH%% *}"
SHORT_HASH="${HASH:0:16}"
render_generated() {
cat <<SWIFT
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.
enum CodexParserHash {
static let value = "$SHORT_HASH"
}
SWIFT
}
if [[ "$CHECK_ONLY" -eq 1 ]]; then
EXPECTED_FILE="$(mktemp)"
trap 'rm -f "$FILE_LIST" "$EXPECTED_FILE"' EXIT
render_generated >"$EXPECTED_FILE"
if ! cmp -s "$EXPECTED_FILE" "$OUTPUT_FILE"; then
echo "error: ${OUTPUT_FILE#${ROOT_DIR}/} is stale. Run Scripts/regenerate-codex-parser-hash.sh and commit the result." >&2
if [[ -f "$OUTPUT_FILE" ]]; then
diff -u "$OUTPUT_FILE" "$EXPECTED_FILE" >&2 || true
else
diff -u /dev/null "$EXPECTED_FILE" >&2 || true
fi
exit 1
fi
echo "Codex parser hash is current (${SHORT_HASH})"
exit 0
fi
mkdir -p "$(dirname "$OUTPUT_FILE")"
render_generated >"$OUTPUT_FILE"
echo "Updated ${OUTPUT_FILE#${ROOT_DIR}/} to ${SHORT_HASH}"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
exec "$SCRIPT_DIR/mac-release" release "$@"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
codexbar_release_arch_label() {
local raw="${1:-arm64 x86_64}"
local normalized
local has_arm64=0
local has_x86_64=0
local arch
normalized=$(printf "%s" "$raw" | tr ',' ' ')
for arch in $normalized; do
case "$arch" in
arm64) has_arm64=1 ;;
x86_64) has_x86_64=1 ;;
esac
done
if [[ "$has_arm64" == "1" && "$has_x86_64" == "1" ]]; then
printf "macos-universal"
return
fi
if [[ "$has_arm64" == "1" ]]; then
printf "macos-arm64"
return
fi
if [[ "$has_x86_64" == "1" ]]; then
printf "macos-x86_64"
return
fi
printf "macos-%s" "$(printf "%s" "$normalized" | tr ' ' '+')"
}
codexbar_app_zip_name() {
local version=$1
local arches="${2:-arm64 x86_64}"
printf "CodexBar-%s-%s.zip" "$(codexbar_release_arch_label "$arches")" "$version"
}
codexbar_dsym_zip_name() {
local version=$1
local arches="${2:-arm64 x86_64}"
printf "CodexBar-%s-%s.dSYM.zip" "$(codexbar_release_arch_label "$arches")" "$version"
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
codexbar_dsym_dwarf_path() {
local dsym_path="$1"
local app_name="$2"
local dwarf_path="${dsym_path}/Contents/Resources/DWARF/${app_name}"
if [[ ! -f "$dwarf_path" ]]; then
echo "Missing fresh dSYM for ${app_name} at: ${dwarf_path}" >&2
return 1
fi
printf '%s\n' "$dwarf_path"
}
codexbar_require_dsym_dwarf_for_arch() {
local dsym_path="$1"
local app_name="$2"
local arch="$3"
local dwarf_path
if ! dwarf_path=$(codexbar_dsym_dwarf_path "$dsym_path" "$app_name"); then
return 1
fi
if ! lipo -archs "$dwarf_path" | tr ' ' '\n' | grep -qx "$arch"; then
echo "dSYM at ${dwarf_path} does not contain required architecture: ${arch}" >&2
return 1
fi
printf '%s\n' "$dwarf_path"
}
codexbar_dwarf_uuid_for_arch() {
local path="$1"
local arch="$2"
local uuid
uuid=$(dwarfdump --uuid "$path" | awk -v arch="(${arch})" '$1 == "UUID:" && $3 == arch { print $2; exit }')
if [[ -z "$uuid" ]]; then
echo "Missing UUID for ${arch} in: ${path}" >&2
return 1
fi
printf '%s\n' "$uuid"
}
codexbar_verify_dsym_matches_binary() {
local app_binary="$1"
local dsym_dwarf="$2"
shift 2
local arch app_uuid dsym_uuid
if [[ ! -f "$app_binary" ]]; then
echo "Missing app binary for dSYM UUID verification: ${app_binary}" >&2
return 1
fi
if [[ ! -f "$dsym_dwarf" ]]; then
echo "Missing dSYM DWARF file for UUID verification: ${dsym_dwarf}" >&2
return 1
fi
for arch in "$@"; do
if ! app_uuid=$(codexbar_dwarf_uuid_for_arch "$app_binary" "$arch"); then
return 1
fi
if ! dsym_uuid=$(codexbar_dwarf_uuid_for_arch "$dsym_dwarf" "$arch"); then
return 1
fi
if [[ "$app_uuid" != "$dsym_uuid" ]]; then
echo "dSYM UUID mismatch for ${arch}: app=${app_uuid}, dSYM=${dsym_uuid}" >&2
return 1
fi
done
}
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Create a review branch for upstream changes
# Usage: ./Scripts/review_upstream.sh [upstream|quotio]
set -euo pipefail
UPSTREAM=${1:-upstream}
DATE=$(date +%Y%m%d)
BRANCH_NAME="upstream-sync/${UPSTREAM}-${DATE}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
if [ "$UPSTREAM" != "upstream" ] && [ "$UPSTREAM" != "quotio" ]; then
echo -e "${RED}Error: Must specify 'upstream' or 'quotio'${NC}"
echo "Usage: ./Scripts/review_upstream.sh [upstream|quotio]"
exit 1
fi
ensure_remote() {
local remote=$1
local url=$2
local origin_url
if git remote get-url "$remote" >/dev/null 2>&1; then
echo "$remote"
return 0
fi
if [ "$remote" = "upstream" ] && git remote get-url origin >/dev/null 2>&1; then
origin_url=$(git remote get-url origin)
case "$origin_url" in
https://github.com/steipete/CodexBar|https://github.com/steipete/CodexBar.git|git@github.com:steipete/CodexBar.git)
echo -e "${YELLOW}Remote 'upstream' missing; using origin for steipete/CodexBar.${NC}" >&2
echo "origin"
return 0
;;
*)
echo -e "${YELLOW}Remote 'upstream' missing; origin is not steipete/CodexBar, adding upstream.${NC}" >&2
;;
esac
fi
echo -e "${YELLOW}Adding $remote remote...${NC}" >&2
git remote add "$remote" "$url"
echo "$remote"
}
remote_default_branch() {
local remote=$1
local branch=""
local candidate
branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true)
if [ -z "$branch" ]; then
branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true)
fi
if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then
echo "$branch"
return 0
fi
for candidate in main master; do
if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then
echo "$candidate"
return 0
fi
done
echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2
exit 1
}
case "$UPSTREAM" in
upstream) REMOTE=$(ensure_remote upstream "https://github.com/steipete/CodexBar.git") ;;
quotio) REMOTE=$(ensure_remote quotio "https://github.com/nguyenphutrong/quotio.git") ;;
esac
echo -e "${BLUE}==> Fetching latest from $UPSTREAM...${NC}"
git fetch "$REMOTE" --prune
REMOTE_BRANCH=$(remote_default_branch "$REMOTE")
REMOTE_REF="${REMOTE}/${REMOTE_BRANCH}"
echo -e "${BLUE}==> Creating review branch for $UPSTREAM (${REMOTE_REF})...${NC}"
git switch main
git switch -c "$BRANCH_NAME"
echo ""
echo -e "${GREEN}==> Commits to review:${NC}"
git log --oneline --graph "main..${REMOTE_REF}" | head -30 || true
echo ""
echo -e "${GREEN}==> File changes summary:${NC}"
git diff --stat "main..${REMOTE_REF}"
echo ""
echo -e "${YELLOW}==> Review branch created: $BRANCH_NAME${NC}"
echo ""
echo -e "${BLUE}Next steps:${NC}"
echo ""
echo "1. Review commits in detail:"
echo " ${GREEN}git log -p main..$REMOTE_REF${NC}"
echo ""
echo "2. View specific files:"
echo " ${GREEN}git show $REMOTE_REF:path/to/file${NC}"
echo ""
echo "3. Cherry-pick specific commits:"
echo " ${GREEN}git cherry-pick <commit-hash>${NC}"
echo ""
echo "4. Or merge all changes:"
echo " ${GREEN}git merge $REMOTE_REF${NC}"
echo ""
echo "5. Test thoroughly:"
echo " ${GREEN}./Scripts/compile_and_run.sh${NC}"
echo ""
echo "6. If satisfied, merge to main:"
echo " ${GREEN}git checkout main && git merge $BRANCH_NAME${NC}"
echo ""
echo "7. Or discard review branch:"
echo " ${GREEN}git checkout main && git branch -D $BRANCH_NAME${NC}"
echo ""
# Create a review log file
LOG_FILE="upstream-review-${UPSTREAM}-${DATE}.txt"
echo "=== Upstream Review: $UPSTREAM @ $DATE ===" > "$LOG_FILE"
echo "" >> "$LOG_FILE"
echo "Commits:" >> "$LOG_FILE"
git log --oneline "main..${REMOTE_REF}" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"
echo "File changes:" >> "$LOG_FILE"
git diff --stat "main..${REMOTE_REF}" >> "$LOG_FILE"
echo -e "${GREEN}Review log saved to: $LOG_FILE${NC}"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Setup stable development code signing to reduce keychain prompts
set -euo pipefail
echo "🔐 Setting up stable development code signing..."
echo ""
echo "This will create a self-signed certificate that stays consistent across rebuilds,"
echo "reducing keychain permission prompts."
echo ""
# Check if we already have a CodexBar development certificate
CERT_NAME="CodexBar Development"
if security find-certificate -c "$CERT_NAME" >/dev/null 2>&1; then
echo "✅ Certificate '$CERT_NAME' already exists!"
echo ""
echo "To use it, add this to your shell profile (~/.zshrc or ~/.bashrc):"
echo ""
echo " export APP_IDENTITY='$CERT_NAME'"
echo ""
echo "Then restart your terminal and rebuild with ./Scripts/compile_and_run.sh"
exit 0
fi
echo "Creating self-signed certificate '$CERT_NAME'..."
echo ""
# Create a temporary config file for the certificate
TEMP_CONFIG=$(mktemp)
trap "rm -f $TEMP_CONFIG" EXIT
cat > "$TEMP_CONFIG" <<EOF
[ req ]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[ req_distinguished_name ]
CN = $CERT_NAME
O = CodexBar Development
C = US
[ v3_req ]
keyUsage = critical,digitalSignature
extendedKeyUsage = codeSigning
EOF
# Generate the certificate
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
-nodes -keyout /tmp/codexbar-dev.key -out /tmp/codexbar-dev.crt \
-config "$TEMP_CONFIG" 2>/dev/null
# Convert to PKCS12 format
openssl pkcs12 -export -out /tmp/codexbar-dev.p12 \
-inkey /tmp/codexbar-dev.key -in /tmp/codexbar-dev.crt \
-passout pass: 2>/dev/null
# Import into keychain
security import /tmp/codexbar-dev.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security
# Clean up temporary files
rm -f /tmp/codexbar-dev.{key,crt,p12}
echo ""
echo "✅ Certificate created successfully!"
echo ""
echo "⚠️ IMPORTANT: You need to trust this certificate for code signing:"
echo ""
echo "1. Open Keychain Access.app"
echo "2. Find '$CERT_NAME' in the 'login' keychain"
echo "3. Double-click it"
echo "4. Expand 'Trust' section"
echo "5. Set 'Code Signing' to 'Always Trust'"
echo "6. Close the window (enter your password when prompted)"
echo ""
echo "Then add this to your shell profile (~/.zshrc or ~/.bashrc):"
echo ""
echo " export APP_IDENTITY='$CERT_NAME'"
echo ""
echo "Restart your terminal and rebuild with ./Scripts/compile_and_run.sh"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="CodexBar"
APP_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)"
APP_BUNDLE="CodexBar.app"
ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/version.env"
source "$ROOT/Scripts/release_artifacts.sh"
source "$ROOT/Scripts/package_product_paths.sh"
source "$ROOT/Scripts/release_dsym_paths.sh"
verify_distribution_policy() {
local app=$1
if command -v syspolicy_check >/dev/null 2>&1; then
syspolicy_check distribution "$app"
else
spctl -a -t exec -vv "$app"
fi
}
# Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64).
ARCHES_VALUE=${ARCHES:-"arm64 x86_64"}
ZIP_NAME=$(codexbar_app_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE")
DSYM_ZIP=$(codexbar_dsym_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE")
if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then
echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2
exit 1
fi
NOTARIZATION_TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-notarize.XXXXXX")
chmod 700 "$NOTARIZATION_TEMP_DIR"
API_KEY_PATH="$NOTARIZATION_TEMP_DIR/codexbar-api-key.p8"
NOTARIZATION_ZIP="$NOTARIZATION_TEMP_DIR/${APP_NAME}Notarize.zip"
trap 'rm -rf "$NOTARIZATION_TEMP_DIR"' EXIT
(
umask 077
printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_PATH"
)
chmod 600 "$API_KEY_PATH"
ARCH_LIST=( ${ARCHES_VALUE} )
ARCHES="${ARCHES_VALUE}" CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release
ENTITLEMENTS_DIR="$ROOT/.build/entitlements"
APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements"
WIDGET_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBarWidget.entitlements"
echo "Signing with $APP_IDENTITY"
if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarCLI" ]]; then
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
"$APP_BUNDLE/Contents/Helpers/CodexBarCLI"
fi
if [[ -f "$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog" ]]; then
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
"$APP_BUNDLE/Contents/Helpers/CodexBarClaudeWatchdog"
fi
if [[ -d "$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex" ]]; then
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
--entitlements "$WIDGET_ENTITLEMENTS" \
"$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex/Contents/MacOS/CodexBarWidget"
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
--entitlements "$WIDGET_ENTITLEMENTS" \
"$APP_BUNDLE/Contents/PlugIns/CodexBarWidget.appex"
fi
codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \
--entitlements "$APP_ENTITLEMENTS" \
"$APP_BUNDLE"
DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto}
"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$NOTARIZATION_ZIP"
echo "Submitting for notarization"
xcrun notarytool submit "$NOTARIZATION_ZIP" \
--key "$API_KEY_PATH" \
--key-id "$APP_STORE_CONNECT_KEY_ID" \
--issuer "$APP_STORE_CONNECT_ISSUER_ID" \
--wait
echo "Stapling ticket"
xcrun stapler staple "$APP_BUNDLE"
# Strip any extended attributes that would create AppleDouble files when zipping
xattr -cr "$APP_BUNDLE"
find "$APP_BUNDLE" -name '._*' -delete
"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$ZIP_NAME"
verify_distribution_policy "$APP_BUNDLE"
stapler validate "$APP_BUNDLE"
echo "Packaging dSYM"
DSYM_STAGE_ROOT="$ROOT/.build/package-products/release"
DSYM_PATHS=()
for ARCH in "${ARCH_LIST[@]}"; do
STAGED_DSYM="$DSYM_STAGE_ROOT/$ARCH/${APP_NAME}.dSYM"
if [[ -d "$STAGED_DSYM" ]]; then
DSYM_PATHS+=("$STAGED_DSYM")
continue
fi
BIN_DIR=$(codexbar_swiftpm_bin_path release "$ARCH")
DSYM_PATHS+=("$(codexbar_resolve_dsym_path "$DSYM_STAGE_ROOT" "$BIN_DIR" "$APP_NAME" "$ARCH")")
done
DSYM_PATH="${DSYM_PATHS[0]}"
DSYM_DWARF_PATHS=()
for ((index = 0; index < ${#ARCH_LIST[@]}; index++)); do
ARCH="${ARCH_LIST[$index]}"
if ! ARCH_DSYM=$(codexbar_require_dsym_dwarf_for_arch "${DSYM_PATHS[$index]}" "$APP_NAME" "$ARCH"); then
exit 1
fi
DSYM_DWARF_PATHS+=("$ARCH_DSYM")
done
if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then
MERGED_DSYM_ROOT="${DSYM_STAGE_ROOT}/${APP_NAME}.dSYM-universal"
MERGED_DSYM="${MERGED_DSYM_ROOT}/${APP_NAME}.dSYM"
rm -rf "$MERGED_DSYM_ROOT"
mkdir -p "$MERGED_DSYM_ROOT"
cp -R "$DSYM_PATH" "$MERGED_DSYM"
DWARF_PATH="${MERGED_DSYM}/Contents/Resources/DWARF/${APP_NAME}"
lipo -create "${DSYM_DWARF_PATHS[@]}" -output "$DWARF_PATH"
DSYM_PATH="$MERGED_DSYM"
fi
if [[ ! -d "$DSYM_PATH" ]]; then
echo "Missing dSYM at SwiftPM-reported path: $DSYM_PATH" >&2
exit 1
fi
codexbar_verify_dsym_matches_binary \
"$APP_BUNDLE/Contents/MacOS/$APP_NAME" \
"$DSYM_PATH/Contents/Resources/DWARF/$APP_NAME" \
"${ARCH_LIST[@]}"
"$DITTO_BIN" --norsrc -c -k --keepParent "$DSYM_PATH" "$DSYM_ZIP"
echo "Done: $ZIP_NAME"
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
codexbar_resolve_sparkle_version_child() {
local versions_dir="$1"
local candidate="$2"
local label="$3"
local versions_root resolved
versions_root=$(cd "$versions_dir" && pwd -P)
if ! resolved=$(cd "$candidate" 2>/dev/null && pwd -P); then
echo "ERROR: Sparkle ${label} does not resolve: ${candidate}" >&2
return 1
fi
if [[ "$(dirname "$resolved")" != "$versions_root" ]]; then
echo "ERROR: Sparkle ${label} resolves outside the framework versions directory: ${candidate}" >&2
return 1
fi
printf '%s\n' "$resolved"
}
codexbar_sparkle_version_dir() {
local sparkle="$1"
local versions_dir="${sparkle}/Versions"
if [[ -L "$sparkle" ]]; then
echo "ERROR: Sparkle framework root must not be a symlink: ${sparkle}" >&2
return 1
fi
if [[ -L "$versions_dir" ]]; then
echo "ERROR: Sparkle versions directory must not be a symlink: ${versions_dir}" >&2
return 1
fi
if [[ ! -d "$versions_dir" ]]; then
echo "ERROR: Missing Sparkle versions directory: ${versions_dir}" >&2
return 1
fi
if [[ -e "$versions_dir/Current" || -L "$versions_dir/Current" ]]; then
local current
if ! current=$(codexbar_resolve_sparkle_version_child "$versions_dir" "$versions_dir/Current" "Versions/Current"); then
return 1
fi
printf '%s\n' "$current"
return
fi
local version_dirs=()
local candidate
shopt -s nullglob
for candidate in "$versions_dir"/*; do
if [[ -d "$candidate" ]]; then
version_dirs+=("$candidate")
fi
done
shopt -u nullglob
case "${#version_dirs[@]}" in
0)
echo "ERROR: Sparkle framework has no version directory under: ${versions_dir}" >&2
return 1
;;
1)
local resolved
if ! resolved=$(codexbar_resolve_sparkle_version_child \
"$versions_dir" "${version_dirs[0]}" "version directory"); then
return 1
fi
printf '%s\n' "$resolved"
;;
*)
echo "ERROR: Sparkle framework has multiple version directories and no Versions/Current symlink: ${versions_dir}" >&2
return 1
;;
esac
}
codexbar_require_sparkle_signing_target() {
local path="$1"
local label="$2"
local trusted_root="$3"
local resolved trusted_root_resolved
if [[ -L "$path" ]]; then
echo "ERROR: Sparkle signing target must not be a symlink (${label}): ${path}" >&2
return 1
fi
if [[ ! -e "$path" ]]; then
echo "ERROR: Missing Sparkle signing target (${label}): ${path}" >&2
return 1
fi
if ! trusted_root_resolved=$(cd "$trusted_root" 2>/dev/null && pwd -P); then
echo "ERROR: Sparkle signing root does not resolve (${label}): ${trusted_root}" >&2
return 1
fi
if [[ -d "$path" ]]; then
resolved=$(cd "$path" && pwd -P)
else
resolved="$(cd "$(dirname "$path")" && pwd -P)/$(basename "$path")"
fi
if [[ "$resolved" != "$trusted_root_resolved" &&
"${resolved#"$trusted_root_resolved"/}" == "$resolved" ]]; then
echo "ERROR: Sparkle signing target resolves outside its trusted root (${label}): ${path}" >&2
return 1
fi
printf '%s\n' "$resolved"
}
codexbar_sparkle_signing_targets() {
local sparkle="$1"
local version_dir
if ! version_dir=$(codexbar_sparkle_version_dir "$sparkle"); then
return 1
fi
codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1
codexbar_require_sparkle_signing_target "$version_dir/Sparkle" "framework binary" "$version_dir" || return 1
codexbar_require_sparkle_signing_target "$version_dir/Autoupdate" "autoupdate tool" "$version_dir" || return 1
codexbar_require_sparkle_signing_target "$version_dir/Updater.app" "updater app" "$version_dir" || return 1
codexbar_require_sparkle_signing_target \
"$version_dir/Updater.app/Contents/MacOS/Updater" "updater executable" "$version_dir" || return 1
codexbar_require_sparkle_signing_target \
"$version_dir/XPCServices/Downloader.xpc" "downloader xpc" "$version_dir" || return 1
codexbar_require_sparkle_signing_target \
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \
"downloader executable" "$version_dir" || return 1
codexbar_require_sparkle_signing_target \
"$version_dir/XPCServices/Installer.xpc" "installer xpc" "$version_dir" || return 1
codexbar_require_sparkle_signing_target \
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer" \
"installer executable" "$version_dir" || return 1
codexbar_require_sparkle_signing_target "$version_dir" "framework version" "$version_dir" || return 1
codexbar_require_sparkle_signing_target "$sparkle" "framework root" "$sparkle" || return 1
}
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
content: ["./docs/index.html", "./docs/site.js"],
theme: {
extend: {
screens: {
tablet: "769px",
},
fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["SFMono-Regular", "SF Mono", "Menlo", "monospace"],
},
},
},
};
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GROUP_SIZE="${CODEXBAR_TEST_GROUP_SIZE:-12}"
SUITE_TIMEOUT="${CODEXBAR_TEST_SUITE_TIMEOUT:-180}"
RETRY_NON_TIMEOUT_FAILURES="${CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES:-1}"
cd "${ROOT_DIR}"
# Defense in depth: test processes also self-detect, but keep this explicit so runner changes cannot
# expose the user's login Keychain. Deliberate isolated Keychain tests must opt in by setting the allow flag.
if [[ "${CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS:-}" != "1" ]]; then
export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1
fi
ARGS=(
--group-size "${GROUP_SIZE}"
--timeout "${SUITE_TIMEOUT}"
)
case "${RETRY_NON_TIMEOUT_FAILURES}" in
0) ARGS+=(--no-retry-non-timeout-failures) ;;
1) ;;
*)
echo "CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES must be 0 or 1" >&2
exit 2
;;
esac
if [[ -n "${CODEXBAR_TEST_SHARD_INDEX:-}" || -n "${CODEXBAR_TEST_SHARD_COUNT:-}" ]]; then
ARGS+=(
--shard-index "${CODEXBAR_TEST_SHARD_INDEX:?CODEXBAR_TEST_SHARD_COUNT requires CODEXBAR_TEST_SHARD_INDEX}"
--shard-count "${CODEXBAR_TEST_SHARD_COUNT:?CODEXBAR_TEST_SHARD_INDEX requires CODEXBAR_TEST_SHARD_COUNT}"
)
fi
exec python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" "${ARGS[@]}" "$@"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
assert_gate() {
local expected="$1"
local name="$2"
local paths_file="${tmp_dir}/${name}.paths"
local output_file="${tmp_dir}/${name}.output"
shift 2
printf '%s\n' "$@" > "$paths_file"
GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null
local actual
actual="$(sed -n 's/^macos-tests=//p' "$output_file")"
if [[ "$actual" != "$expected" ]]; then
printf '%s: expected macos-tests=%s, got %s\n' "$name" "$expected" "${actual:-<empty>}" >&2
exit 1
fi
local reason
reason="$(sed -n 's/^macos-tests-reason=//p' "$output_file")"
if [[ -z "$reason" ]]; then
printf '%s: expected macos-tests-reason output\n' "$name" >&2
exit 1
fi
local path_count
path_count="$(sed -n 's/^changed-path-count=//p' "$output_file")"
if ! [[ "$path_count" =~ ^[0-9]+$ ]]; then
printf '%s: expected numeric changed-path-count output, got %s\n' \
"$name" "${path_count:-<empty>}" >&2
exit 1
fi
if [[ "$expected" == false && "$reason" != "docs/site-only changes covered by portable checks" ]]; then
printf '%s: expected docs/site skip reason, got %s\n' "$name" "$reason" >&2
exit 1
fi
}
assert_gate false docs-only $'M\tdocs/providers.md' $'M\tREADME.md'
assert_gate true configuration-doc $'M\tdocs/configuration.md'
assert_gate true rename-to-configuration-doc $'R100\tdocs/old.md\tdocs/configuration.md'
assert_gate true rename-from-configuration-doc $'R100\tdocs/configuration.md\tdocs/new.md'
assert_gate true agents-contract $'M\tAGENTS.md'
assert_gate true rename-to-agents-contract $'R100\tdocs/old.md\tAGENTS.md'
assert_gate true rename-from-agents-contract $'R100\tAGENTS.md\tdocs/new.md'
assert_gate true source $'M\tSources/CodexBar/App.swift'
assert_gate false docs-site $'M\tdocs/index.html' $'M\tdocs/site.css' $'M\tdocs/site.js' \
$'M\tdocs/site-locales.mjs' $'M\tdocs/social.html' $'M\tdocs/social.png' \
$'M\tdocs/CNAME' $'M\tdocs/.nojekyll' $'M\tdocs/llms.txt'
assert_gate false docs-site-assets $'M\tdocs/icon.png' $'M\tdocs/logos/provider-logo.svg'
assert_gate true docs-unknown-code $'M\tdocs/custom-tool.js'
assert_gate true docs-site-with-config $'M\tdocs/site.css' $'M\tdocs/configuration.md'
assert_gate true empty
assert_gate true source-to-docs $'R100\tSources/CodexBar/App.swift\tdocs/App.md'
assert_gate true docs-to-source $'R100\tdocs/App.md\tSources/CodexBar/App.swift'
assert_gate false docs-to-site $'R100\tdocs/old.md\tdocs/site.css'
assert_gate_fails() {
local name="$1"
local paths_file="${tmp_dir}/${name}.paths"
local output_file="${tmp_dir}/${name}.output"
shift
printf '%s\n' "$@" > "$paths_file"
if GITHUB_OUTPUT="$output_file" "${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$paths_file" >/dev/null 2>&1; then
printf '%s: malformed gate input unexpectedly succeeded\n' "$name" >&2
exit 1
fi
if [[ -s "$output_file" ]]; then
printf '%s: malformed gate input emitted an output\n' "$name" >&2
exit 1
fi
}
assert_gate_fails missing-rename-target $'R100\tREADME.md'
assert_gate_fails extra-modified-path $'M\tREADME.md\tdocs/configuration.md'
assert_gate_fails missing-rename-score $'R\tREADME.md\tdocs/README.md'
assert_gate_fails invalid-rename-score $'Rfoo\tREADME.md\tdocs/README.md'
assert_gate_fails out-of-range-rename-score $'R101\tREADME.md\tdocs/README.md'
unterminated_paths="${tmp_dir}/unterminated.paths"
unterminated_output="${tmp_dir}/unterminated.output"
printf '%s' $'M\tREADME.md\tdocs/configuration.md' > "$unterminated_paths"
if GITHUB_OUTPUT="$unterminated_output" \
"${ROOT_DIR}/Scripts/ci_macos_test_gate.sh" "$unterminated_paths" >/dev/null 2>&1
then
printf 'unterminated malformed gate input unexpectedly succeeded\n' >&2
exit 1
fi
if [[ -s "$unterminated_output" ]]; then
printf 'unterminated malformed gate input emitted an output\n' >&2
exit 1
fi
verify="${ROOT_DIR}/Scripts/ci_verify_test_jobs.sh"
"$verify" success success true success >/dev/null
"$verify" success success false skipped >/dev/null
assert_verify_fails() {
if "$verify" "$@" >/dev/null 2>&1; then
printf 'unexpected aggregate success: %s\n' "$*" >&2
exit 1
fi
}
assert_verify_fails success success true skipped
assert_verify_fails success success false success
assert_verify_fails success success "" skipped
assert_verify_fails failure success true success
assert_verify_fails success failure true success
printf 'CI macOS path gate tests passed.\n'
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
PREV_TAG=${1:?"pass previous release tag (e.g. v0.1.0)"}
CUR_TAG=${2:?"pass current release tag (e.g. v0.1.1)"}
PREV_VER=${PREV_TAG#v}
CUR_VER=${CUR_TAG#v}
APP_NAME="CodexBar"
ZIP_URL="https://github.com/steipete/CodexBar/releases/download/${PREV_TAG}/${APP_NAME}-macos-universal-${PREV_VER}.zip"
TMP_DIR=$(mktemp -d /tmp/codexbar-live.XXXX)
trap 'rm -rf "$TMP_DIR"' EXIT
echo "Downloading previous release $PREV_TAG from $ZIP_URL"
curl --fail --location --output "$TMP_DIR/prev.zip" "$ZIP_URL"
echo "Installing previous release to /Applications/${APP_NAME}.app"
osascript -e 'tell application "CodexBar" to quit' >/dev/null 2>&1 || true
for _ in {1..20}; do
pgrep -x "$APP_NAME" >/dev/null || break
sleep 0.25
done
if pgrep -x "$APP_NAME" >/dev/null; then
echo "ERROR: ${APP_NAME} did not quit before replacement." >&2
exit 1
fi
rm -rf /Applications/${APP_NAME}.app
ditto -x -k "$TMP_DIR/prev.zip" "$TMP_DIR"
ditto "$TMP_DIR/${APP_NAME}.app" /Applications/${APP_NAME}.app
echo "Launching previous build…"
open -n /Applications/${APP_NAME}.app
sleep 4
cat <<'MSG'
Manual step: trigger "Check for Updates…" in the app and install the update.
Expect to land on the newly released version. When done, confirm below.
MSG
read -rp "Did the update succeed from ${PREV_TAG} to ${CUR_TAG}? (y/N) " answer
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
echo "Live update test NOT confirmed; failing per RUN_SPARKLE_UPDATE_TEST." >&2
exit 1
fi
installed_ver=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"/Applications/${APP_NAME}.app/Contents/Info.plist")
if [[ "$installed_ver" != "$CUR_VER" ]]; then
echo "Live update reported success but installed ${installed_ver}; expected ${CUR_VER}." >&2
exit 1
fi
echo "Live update test confirmed."
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/Scripts/package_product_paths.sh"
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-paths.XXXXXX")
trap 'rm -rf "$TEMP_DIR"' EXIT
NATIVE_DIR="$TEMP_DIR/.build/arm64-apple-macosx/release"
SWIFTBUILD_DIR="$TEMP_DIR/.build/out/Products/Release"
STAGE_ROOT="$TEMP_DIR/.build/package-products/release"
mkdir -p "$NATIVE_DIR/CodexBar.dSYM" "$SWIFTBUILD_DIR/Sparkle.framework" "$SWIFTBUILD_DIR/CodexBar.dSYM"
touch "$NATIVE_DIR/CodexBar" "$SWIFTBUILD_DIR/CodexBar"
native=$(codexbar_require_product_file "$NATIVE_DIR" CodexBar arm64)
[[ "$native" == "$NATIVE_DIR/CodexBar" ]]
swiftbuild=$(codexbar_require_product_file "$SWIFTBUILD_DIR" CodexBar arm64)
[[ "$swiftbuild" == "$SWIFTBUILD_DIR/CodexBar" ]]
framework=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging)
[[ "$framework" == "$SWIFTBUILD_DIR/Sparkle.framework" ]]
dsym=$(codexbar_require_product_directory "$SWIFTBUILD_DIR" CodexBar.dSYM release)
[[ "$dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]]
resolved=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
[[ "$resolved" == "$SWIFTBUILD_DIR/CodexBar" ]]
resolved_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
[[ "$resolved_dsym" == "$SWIFTBUILD_DIR/CodexBar.dSYM" ]]
mkdir -p "$STAGE_ROOT/arm64/CodexBar.dSYM"
touch "$STAGE_ROOT/arm64/CodexBar"
staged=$(codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
[[ "$staged" == "$STAGE_ROOT/arm64/CodexBar" ]]
staged_dsym=$(codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64)
[[ "$staged_dsym" == "$STAGE_ROOT/arm64/CodexBar.dSYM" ]]
rm -rf "$STAGE_ROOT"
rm "$SWIFTBUILD_DIR/CodexBar"
if codexbar_resolve_staged_or_reported_file "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \
2>"$TEMP_DIR/missing-file.log"; then
echo "ERROR: Missing reported product unexpectedly fell back to legacy output." >&2
exit 1
fi
grep -Fq "$SWIFTBUILD_DIR/CodexBar" "$TEMP_DIR/missing-file.log"
rm -rf "$SWIFTBUILD_DIR/Sparkle.framework"
if codexbar_require_product_directory "$SWIFTBUILD_DIR" Sparkle.framework packaging \
2>"$TEMP_DIR/missing-directory.log"; then
echo "ERROR: Missing reported framework was accepted." >&2
exit 1
fi
grep -Fq "$SWIFTBUILD_DIR/Sparkle.framework" "$TEMP_DIR/missing-directory.log"
rm -rf "$SWIFTBUILD_DIR/CodexBar.dSYM"
if codexbar_resolve_dsym_path "$STAGE_ROOT" "$SWIFTBUILD_DIR" CodexBar arm64 \
2>"$TEMP_DIR/missing-dsym.log"; then
echo "ERROR: Missing reported dSYM unexpectedly fell back to legacy output." >&2
exit 1
fi
grep -Fq "$SWIFTBUILD_DIR/CodexBar.dSYM" "$TEMP_DIR/missing-dsym.log"
swift() {
[[ "$*" == "build --show-bin-path -c release --arch arm64" ]]
printf '%s\n' "$SWIFTBUILD_DIR"
}
reported=$(codexbar_swiftpm_bin_path release arm64)
[[ "$reported" == "$SWIFTBUILD_DIR" ]]
swift() {
return 23
}
if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/query.log"; then
echo "ERROR: SwiftPM bin-path query failure was ignored." >&2
exit 1
fi
grep -Fq "SwiftPM failed to report" "$TEMP_DIR/query.log"
swift() {
return 0
}
if codexbar_swiftpm_bin_path release arm64 2>"$TEMP_DIR/empty.log"; then
echo "ERROR: Empty SwiftPM bin path was accepted." >&2
exit 1
fi
grep -Fq "SwiftPM reported an empty" "$TEMP_DIR/empty.log"
echo "Package product path tests passed."
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh"
RELEASE_SCRIPT="$ROOT/Scripts/sign-and-notarize.sh"
FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-signing-functions.XXXXXX")
trap 'rm -f "$FUNCTIONS_FILE"' EXIT
python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY'
import sys
from pathlib import Path
script = Path(sys.argv[1]).read_text()
start = script.index('resolve_package_signing_mode() {')
end = script.index('\n}\n', start) + 3
Path(sys.argv[2]).write_text(script[start:end])
PY
source "$FUNCTIONS_FILE"
unset CODEXBAR_SIGNING
SIGNING_MODE=
resolve_package_signing_mode
[[ "$SIGNING_MODE" == "adhoc" ]]
CODEXBAR_SIGNING=identity
resolve_package_signing_mode
[[ "$SIGNING_MODE" == "identity" ]]
CODEXBAR_SIGNING=invalid
if resolve_package_signing_mode 2>/dev/null; then
echo "Invalid package signing mode unexpectedly succeeded" >&2
exit 1
fi
grep -Fq 'CODEXBAR_SIGNING=identity ./Scripts/package_app.sh release' "$RELEASE_SCRIPT"
echo "Package signing tests passed."
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
PACKAGE_SCRIPT="$ROOT/Scripts/package_app.sh"
FUNCTIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/codexbar-package-strip-functions.XXXXXX")
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-strip.XXXXXX")
trap 'rm -rf "$FUNCTIONS_FILE" "$TEMP_DIR"' EXIT
python3 - "$PACKAGE_SCRIPT" "$FUNCTIONS_FILE" <<'PY'
import sys
from pathlib import Path
script = Path(sys.argv[1]).read_text()
start = script.index('strip_release_binary() {')
end = script.index('\n}\n', start) + 3
Path(sys.argv[2]).write_text(script[start:end])
PY
xcrun() {
[[ "$1" == "strip" && "$2" == "-x" ]]
printf '%s\n' "$3" >> "$STRIP_LOG"
}
source "$FUNCTIONS_FILE"
binary="$TEMP_DIR/CodexBar"
touch "$binary"
STRIP_LOG="$TEMP_DIR/release.log"
LOWER_CONF=release
strip_release_binary "$binary"
grep -Fqx "$binary" "$STRIP_LOG"
STRIP_LOG="$TEMP_DIR/debug.log"
LOWER_CONF=debug
strip_release_binary "$binary"
[[ ! -e "$STRIP_LOG" ]]
STRIP_LOG="$TEMP_DIR/missing.log"
LOWER_CONF=release
strip_release_binary "$TEMP_DIR/MissingBinary"
[[ ! -e "$STRIP_LOG" ]]
echo "Package strip tests passed."
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/Scripts/release_dsym_paths.sh"
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-release-dsym-paths.XXXXXX")
trap 'rm -rf "$TEMP_DIR"' EXIT
make_dsym() {
local dsym_path="$1"
mkdir -p "$dsym_path/Contents/Resources/DWARF"
touch "$dsym_path/Contents/Resources/DWARF/CodexBar"
}
ARM_DSYM="$TEMP_DIR/CodexBar arm64.dSYM"
UNIVERSAL_DSYM="$TEMP_DIR/CodexBar universal.dSYM"
WRONG_ARCH_DSYM="$TEMP_DIR/CodexBar stale.dSYM"
MISSING_DWARF_DSYM="$TEMP_DIR/CodexBar missing.dSYM"
APP_BINARY="$TEMP_DIR/CodexBar.app"
MATCHING_DWARF="$TEMP_DIR/CodexBar matching"
MISMATCHED_DWARF="$TEMP_DIR/CodexBar mismatched"
MISSING_UUID_DWARF="$TEMP_DIR/CodexBar missing UUID"
make_dsym "$ARM_DSYM"
make_dsym "$UNIVERSAL_DSYM"
make_dsym "$WRONG_ARCH_DSYM"
mkdir -p "$MISSING_DWARF_DSYM/Contents/Resources/DWARF"
touch "$APP_BINARY" "$MATCHING_DWARF" "$MISMATCHED_DWARF" "$MISSING_UUID_DWARF"
lipo() {
[[ "$1" == "-archs" ]]
case "$2" in
"$ARM_DSYM/Contents/Resources/DWARF/CodexBar")
printf '%s\n' "arm64"
;;
"$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar")
printf '%s\n' "arm64 x86_64"
;;
"$WRONG_ARCH_DSYM/Contents/Resources/DWARF/CodexBar")
printf '%s\n' "x86_64"
;;
*)
echo "unexpected lipo path: $2" >&2
return 2
;;
esac
}
dwarfdump() {
[[ "$1" == "--uuid" ]]
case "$2" in
"$APP_BINARY" | "$MATCHING_DWARF")
printf '%s\n' \
"UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \
"UUID: BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB (x86_64) $2"
;;
"$MISMATCHED_DWARF")
printf '%s\n' \
"UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2" \
"UUID: CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC (x86_64) $2"
;;
"$MISSING_UUID_DWARF")
printf '%s\n' "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (arm64) $2"
;;
*)
echo "unexpected dwarfdump path: $2" >&2
return 2
;;
esac
}
arm_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$ARM_DSYM" CodexBar arm64)
[[ "$arm_dwarf" == "$ARM_DSYM/Contents/Resources/DWARF/CodexBar" ]]
x86_dwarf=$(codexbar_require_dsym_dwarf_for_arch "$UNIVERSAL_DSYM" CodexBar x86_64)
[[ "$x86_dwarf" == "$UNIVERSAL_DSYM/Contents/Resources/DWARF/CodexBar" ]]
if codexbar_require_dsym_dwarf_for_arch "$MISSING_DWARF_DSYM" CodexBar arm64 \
2>"$TEMP_DIR/missing-dwarf.log"; then
echo "ERROR: Missing dSYM DWARF file was accepted." >&2
exit 1
fi
grep -Fq "$MISSING_DWARF_DSYM/Contents/Resources/DWARF/CodexBar" "$TEMP_DIR/missing-dwarf.log"
if codexbar_require_dsym_dwarf_for_arch "$WRONG_ARCH_DSYM" CodexBar arm64 \
2>"$TEMP_DIR/wrong-arch.log"; then
echo "ERROR: Wrong-architecture dSYM was accepted." >&2
exit 1
fi
grep -Fq "required architecture: arm64" "$TEMP_DIR/wrong-arch.log"
codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MATCHING_DWARF" arm64 x86_64
if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISMATCHED_DWARF" arm64 x86_64 \
2>"$TEMP_DIR/mismatched-uuid.log"; then
echo "ERROR: Mismatched dSYM UUID was accepted." >&2
exit 1
fi
grep -Fq "dSYM UUID mismatch for x86_64" "$TEMP_DIR/mismatched-uuid.log"
if codexbar_verify_dsym_matches_binary "$APP_BINARY" "$MISSING_UUID_DWARF" arm64 x86_64 \
2>"$TEMP_DIR/missing-uuid.log"; then
echo "ERROR: Missing dSYM UUID was accepted." >&2
exit 1
fi
grep -Fq "Missing UUID for x86_64" "$TEMP_DIR/missing-uuid.log"
echo "Release dSYM path tests passed."
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-repository-size.XXXXXX")
trap 'rm -rf "$TEMP_DIR"' EXIT
mkdir -p "$TEMP_DIR/Scripts"
cp "$ROOT_DIR/Scripts/check_repository_size.sh" "$TEMP_DIR/Scripts/"
git -C "$TEMP_DIR" init --quiet
empty_output=$("$TEMP_DIR/Scripts/check_repository_size.sh")
grep -Fq 'repository size OK: 0 tracked files' <<<"$empty_output"
printf 'small source file\n' > "$TEMP_DIR/source.txt"
git -C "$TEMP_DIR" add source.txt Scripts/check_repository_size.sh
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
dd if=/dev/zero of="$TEMP_DIR/untracked.bin" bs=1024 count=2049 2>/dev/null
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
dd if=/dev/zero of="$TEMP_DIR/boundary.bin" bs=1024 count=2048 2>/dev/null
git -C "$TEMP_DIR" add boundary.bin
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
printf 'x' >> "$TEMP_DIR/boundary.bin"
git -C "$TEMP_DIR" add boundary.bin
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/large.log" 2>&1; then
printf 'ERROR: staged blob one byte above the limit was accepted.\n' >&2
exit 1
fi
grep -Fq 'tracked file exceeds 2097152 bytes: boundary.bin (2097153 bytes)' "$TEMP_DIR/large.log"
git -C "$TEMP_DIR" rm --cached --force --quiet boundary.bin
git -C "$TEMP_DIR" add untracked.bin
printf 'working tree is now small\n' > "$TEMP_DIR/untracked.bin"
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/staged-large.log" 2>&1; then
printf 'ERROR: oversized staged blob was accepted after its working-tree file changed.\n' >&2
exit 1
fi
grep -Fq 'tracked file exceeds 2097152 bytes: untracked.bin (2098176 bytes)' "$TEMP_DIR/staged-large.log"
git -C "$TEMP_DIR" rm --cached --force --quiet untracked.bin
printf 'small staged blob\n' > "$TEMP_DIR/index-is-authoritative.bin"
git -C "$TEMP_DIR" add index-is-authoritative.bin
dd if=/dev/zero of="$TEMP_DIR/index-is-authoritative.bin" bs=1024 count=2049 2>/dev/null
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
odd_path=$'odd\nname.txt'
printf 'small source file\n' > "$TEMP_DIR/$odd_path"
git -C "$TEMP_DIR" add "$odd_path"
"$TEMP_DIR/Scripts/check_repository_size.sh" >/dev/null
artifacts=(
"CodexBar 2.app/Contents/MacOS/CodexBar"
"CodexBar.dSYM/Contents/Info.plist"
"CodexBar.xcarchive/Products/Applications/CodexBar.app/Contents/Info.plist"
"CodexBar.xcresult/Data/data"
"CodexBar.ipa"
"CodexBar.zip"
"CodexBar.delta"
"CodexBar.dmg"
"CodexBar.pkg"
"CodexBar.tar.gz"
"CodexBar.tgz"
)
for artifact in "${artifacts[@]}"; do
mkdir -p "$TEMP_DIR/$(dirname "$artifact")"
printf 'release artifact\n' > "$TEMP_DIR/$artifact"
git -C "$TEMP_DIR" add -f "$artifact"
done
ln -s source.txt "$TEMP_DIR/CodexBar-latest.dmg"
git -C "$TEMP_DIR" add -f CodexBar-latest.dmg
rm "$TEMP_DIR/CodexBar.zip"
if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/artifact.log" 2>&1; then
printf 'ERROR: tracked release artifacts were accepted.\n' >&2
exit 1
fi
for artifact in "${artifacts[@]}" CodexBar-latest.dmg; do
grep -Fq "generated artifact is tracked: $artifact" "$TEMP_DIR/artifact.log"
done
printf 'Repository size tests passed.\n'
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/Scripts/sparkle_signing_paths.sh"
TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-sparkle-signing.XXXXXX")
TEMP_DIR=$(cd "$TEMP_DIR" && pwd -P)
trap 'rm -rf "$TEMP_DIR"' EXIT
make_sparkle_version() {
local sparkle="$1"
local version="$2"
local version_dir="$sparkle/Versions/$version"
mkdir -p \
"$version_dir/Updater.app/Contents/MacOS" \
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS" \
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS"
touch \
"$version_dir/Sparkle" \
"$version_dir/Autoupdate" \
"$version_dir/Updater.app/Contents/MacOS/Updater" \
"$version_dir/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \
"$version_dir/XPCServices/Installer.xpc/Contents/MacOS/Installer"
}
SINGLE="$TEMP_DIR/Single Sparkle.framework"
make_sparkle_version "$SINGLE" B
single_version=$(codexbar_sparkle_version_dir "$SINGLE")
[[ "$single_version" == "$SINGLE/Versions/B" ]]
single_targets=$(codexbar_sparkle_signing_targets "$SINGLE")
grep -Fqx "$SINGLE" <<<"$single_targets"
grep -Fqx "$SINGLE/Versions/B/Sparkle" <<<"$single_targets"
grep -Fqx "$SINGLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" <<<"$single_targets"
CURRENT="$TEMP_DIR/Current Sparkle.framework"
make_sparkle_version "$CURRENT" A
make_sparkle_version "$CURRENT" C
ln -s C "$CURRENT/Versions/Current"
current_version=$(codexbar_sparkle_version_dir "$CURRENT")
[[ "$current_version" == "$CURRENT/Versions/C" ]]
rm "$CURRENT/Versions/C/Autoupdate"
if codexbar_sparkle_signing_targets "$CURRENT" >"$TEMP_DIR/missing-target.out" 2>"$TEMP_DIR/missing-target.log"; then
echo "ERROR: Missing Sparkle signing target was accepted." >&2
exit 1
fi
grep -Fq "Autoupdate" "$TEMP_DIR/missing-target.log"
AMBIGUOUS="$TEMP_DIR/Ambiguous Sparkle.framework"
make_sparkle_version "$AMBIGUOUS" A
make_sparkle_version "$AMBIGUOUS" B
if codexbar_sparkle_version_dir "$AMBIGUOUS" 2>"$TEMP_DIR/ambiguous.log"; then
echo "ERROR: Ambiguous Sparkle versions were accepted without Versions/Current." >&2
exit 1
fi
grep -Fq "multiple version directories" "$TEMP_DIR/ambiguous.log"
BROKEN_CURRENT="$TEMP_DIR/Broken Current Sparkle.framework"
make_sparkle_version "$BROKEN_CURRENT" B
ln -s Missing "$BROKEN_CURRENT/Versions/Current"
if codexbar_sparkle_version_dir "$BROKEN_CURRENT" 2>"$TEMP_DIR/broken-current.log"; then
echo "ERROR: Broken Sparkle Versions/Current was accepted." >&2
exit 1
fi
grep -Fq "Versions/Current does not resolve" "$TEMP_DIR/broken-current.log"
ESCAPING_CURRENT="$TEMP_DIR/Escaping Current Sparkle.framework"
OUTSIDE_SPARKLE="$TEMP_DIR/Outside Sparkle.framework"
make_sparkle_version "$ESCAPING_CURRENT" B
make_sparkle_version "$OUTSIDE_SPARKLE" C
ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_CURRENT/Versions/Current"
if codexbar_sparkle_version_dir "$ESCAPING_CURRENT" 2>"$TEMP_DIR/escaping-current.log"; then
echo "ERROR: Escaping Sparkle Versions/Current was accepted." >&2
exit 1
fi
grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-current.log"
SYMLINKED_VERSIONS="$TEMP_DIR/Symlinked Versions Sparkle.framework"
mkdir -p "$SYMLINKED_VERSIONS"
ln -s "$OUTSIDE_SPARKLE/Versions" "$SYMLINKED_VERSIONS/Versions"
if codexbar_sparkle_version_dir "$SYMLINKED_VERSIONS" 2>"$TEMP_DIR/symlinked-versions.log"; then
echo "ERROR: Symlinked Sparkle Versions directory was accepted." >&2
exit 1
fi
grep -Fq "versions directory must not be a symlink" "$TEMP_DIR/symlinked-versions.log"
SYMLINKED_FRAMEWORK="$TEMP_DIR/Symlinked Sparkle.framework"
ln -s "$OUTSIDE_SPARKLE" "$SYMLINKED_FRAMEWORK"
if codexbar_sparkle_version_dir "$SYMLINKED_FRAMEWORK" 2>"$TEMP_DIR/symlinked-framework.log"; then
echo "ERROR: Symlinked Sparkle framework root was accepted." >&2
exit 1
fi
grep -Fq "framework root must not be a symlink" "$TEMP_DIR/symlinked-framework.log"
SYMLINKED_TARGET="$TEMP_DIR/Symlinked Target Sparkle.framework"
make_sparkle_version "$SYMLINKED_TARGET" B
rm "$SYMLINKED_TARGET/Versions/B/Autoupdate"
ln -s "$OUTSIDE_SPARKLE/Versions/C/Autoupdate" "$SYMLINKED_TARGET/Versions/B/Autoupdate"
if codexbar_sparkle_signing_targets \
"$SYMLINKED_TARGET" >"$TEMP_DIR/symlinked-target.out" 2>"$TEMP_DIR/symlinked-target.log"; then
echo "ERROR: Symlinked Sparkle signing target was accepted." >&2
exit 1
fi
grep -Fq "signing target must not be a symlink" "$TEMP_DIR/symlinked-target.log"
ESCAPING_TARGET_PARENT="$TEMP_DIR/Escaping Target Parent Sparkle.framework"
make_sparkle_version "$ESCAPING_TARGET_PARENT" B
mv "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices" "$TEMP_DIR/displaced-xpc-services"
ln -s "$OUTSIDE_SPARKLE/Versions/C/XPCServices" "$ESCAPING_TARGET_PARENT/Versions/B/XPCServices"
if codexbar_sparkle_signing_targets \
"$ESCAPING_TARGET_PARENT" >"$TEMP_DIR/escaping-target-parent.out" 2>"$TEMP_DIR/escaping-target-parent.log"; then
echo "ERROR: Sparkle signing target with an escaping parent was accepted." >&2
exit 1
fi
grep -Fq "signing target resolves outside its trusted root" "$TEMP_DIR/escaping-target-parent.log"
ESCAPING_SINGLE="$TEMP_DIR/Escaping Single Sparkle.framework"
mkdir -p "$ESCAPING_SINGLE/Versions"
ln -s "$OUTSIDE_SPARKLE/Versions/C" "$ESCAPING_SINGLE/Versions/B"
if codexbar_sparkle_version_dir "$ESCAPING_SINGLE" 2>"$TEMP_DIR/escaping-single.log"; then
echo "ERROR: Escaping single Sparkle version directory was accepted." >&2
exit 1
fi
grep -Fq "outside the framework versions directory" "$TEMP_DIR/escaping-single.log"
echo "Sparkle signing path tests passed."
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-test-sharding.XXXXXX")"
trap 'rm -rf "${TEMP_DIR}"' EXIT
IFS= read -r -d '' FAKE_SWIFT_SCRIPT <<'EOF' || true
set -euo pipefail
printf '%s\n' "$*" >> "${FAKE_SWIFT_LOG}"
if [[ "$*" == "test list" ]]; then
if [[ "${FAKE_SWIFT_MODE:-success}" == "list_fail" ]]; then
sleep 0.25
printf 'test-list stdout marker\n'
printf 'test-list stderr marker\n' >&2
exit 42
fi
printf '%s\n' \
"CodexBarTests.Alpha/test_one()" \
"CodexBarTests.Alpha/test_two(argument:)" \
"CodexBarTests.Beta/test_two" \
"CodexBarTests.Gamma/test_three" \
"CodexBarTests.Delta/test_four" \
"CodexBarTests.Epsilon/test_five" \
"CodexBarTests.Zeta/test_six" \
"CodexBarTests.Eta/test_seven" \
"CodexBarTests.Theta/test_eight" \
'CodexBarTests.`top level works`()' \
'CodexBarTests.`top/level slash works`()'
exit 0
fi
is_group=0
if [[ "$*" == *"|"* ]]; then
is_group=1
fi
next_group_attempt() {
local attempt=0
if [[ -f "${FAKE_SWIFT_STATE}" ]]; then
read -r attempt < "${FAKE_SWIFT_STATE}"
fi
attempt=$((attempt + 1))
printf '%s\n' "${attempt}" > "${FAKE_SWIFT_STATE}"
printf '%s\n' "${attempt}"
}
case "${FAKE_SWIFT_MODE:-success}" in
group_fail_once)
if [[ "${is_group}" == "1" && "$(next_group_attempt)" == "1" ]]; then
exit 1
fi
;;
group_always_fail)
if [[ "${is_group}" == "1" ]]; then
exit 1
fi
;;
group_timeout)
if [[ "${is_group}" == "1" ]]; then
sleep 2
fi
;;
singleton_timeout)
if [[ "${is_group}" == "0" ]]; then
sleep 2
fi
;;
group_fail_then_timeout)
if [[ "${is_group}" == "1" ]]; then
attempt="$(next_group_attempt)"
if [[ "${attempt}" == "1" ]]; then
exit 1
fi
sleep 2
fi
;;
esac
EOF
reset_case() {
local name="$1"
export FAKE_SWIFT_LOG="${TEMP_DIR}/${name}-swift.log"
export FAKE_SWIFT_STATE="${TEMP_DIR}/${name}-state"
export GITHUB_STEP_SUMMARY="${TEMP_DIR}/${name}-summary.md"
rm -f "${FAKE_SWIFT_LOG}" "${FAKE_SWIFT_STATE}" "${GITHUB_STEP_SUMMARY}"
}
run_harness() {
python3 "${ROOT_DIR}/Scripts/ci_swift_test_by_suite.py" \
"$@" \
--swift-command /bin/bash \
--swift-command-arg=-c \
--swift-command-arg="${FAKE_SWIFT_SCRIPT}" \
--swift-command-arg=fake-swift
}
python3 - "${ROOT_DIR}/.github/workflows/ci.yml" <<'PY'
import pathlib
import re
import sys
workflow = pathlib.Path(sys.argv[1]).read_text()
job_match = re.search(r"(?ms)^ swift-test-macos:\n(?P<body>.*?)(?=^ [a-zA-Z0-9_-]+:|\Z)", workflow)
if not job_match:
raise SystemExit("swift-test-macos job not found in CI workflow")
job = job_match.group("body")
if not re.search(r"(?m)^\s+shard-index:\s+\[0,\s*1\]\s*$", job):
raise SystemExit("swift-test-macos must run exactly two shard indexes: [0, 1]")
if not re.search(r"(?m)^\s+shard-count:\s+\[2\]\s*$", job):
raise SystemExit("swift-test-macos shard-count must be [2]")
if "CODEXBAR_TEST_SHARD_INDEX=${{ matrix.shard-index }}" not in job:
raise SystemExit("swift-test-macos must pass matrix.shard-index to Scripts/test.sh")
if "CODEXBAR_TEST_SHARD_COUNT=${{ matrix.shard-count }}" not in job:
raise SystemExit("swift-test-macos must pass matrix.shard-count to Scripts/test.sh")
PY
reset_case retry
export FAKE_SWIFT_MODE=group_fail_once
run_harness --group-size 4 --timeout 10 > "${TEMP_DIR}/retry.log"
grep -Fq "failed with exit code 1; retrying group once" "${TEMP_DIR}/retry.log"
grep -Fq "Swift test timing summary:" "${TEMP_DIR}/retry.log"
grep -Fq '| Discovered selections | `10` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Selected selections | `10` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Selected groups | `3` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| First-pass successful groups | `2` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
[[ "$(grep -c '^test --skip-build --no-parallel' "${FAKE_SWIFT_LOG}")" -eq 4 ]]
grep -Fq "CodexBarTests\\.Alpha" "${FAKE_SWIFT_LOG}"
grep -Fq "CodexBarTests\\.Beta" "${FAKE_SWIFT_LOG}"
grep -Fq "CodexBarTests\\..*top\\ level\\ works" "${FAKE_SWIFT_LOG}"
grep -Fq "CodexBarTests\\..*top/level\\ slash\\ works" "${FAKE_SWIFT_LOG}"
[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 5 ]]
reset_case strict
export FAKE_SWIFT_MODE=group_fail_once
set +e
CODEXBAR_TEST_GROUP_SIZE=4 \
CODEXBAR_TEST_SUITE_TIMEOUT=10 \
CODEXBAR_TEST_RETRY_NON_TIMEOUT_FAILURES=0 \
"${ROOT_DIR}/Scripts/test.sh" \
--limit-groups 1 \
--swift-command /bin/bash \
--swift-command-arg=-c \
--swift-command-arg="${FAKE_SWIFT_SCRIPT}" \
--swift-command-arg=fake-swift \
> "${TEMP_DIR}/strict.log" 2>&1
strict_status=$?
set -e
[[ "${strict_status}" -eq 1 ]]
grep -Fq '| First-pass failed groups | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Full-group retries | `0` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}"
[[ "$(wc -l < "${FAKE_SWIFT_LOG}")" -eq 2 ]]
reset_case shard-0
export FAKE_SWIFT_MODE=success
run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 > "${TEMP_DIR}/shard-0.log"
grep -Fq '| Shard | `1/2` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Selected selections | `6` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Selected groups | `2` |' "${GITHUB_STEP_SUMMARY}"
reset_case shard-1
run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 > "${TEMP_DIR}/shard-1.log"
grep -Fq '| Shard | `2/2` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Selected selections | `4` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Selected groups | `1` |' "${GITHUB_STEP_SUMMARY}"
reset_case shard-list-0
run_harness --group-size 4 --timeout 10 --shard-index 0 --shard-count 2 --list-only \
> "${TEMP_DIR}/shard-list-0.log"
reset_case shard-list-1
run_harness --group-size 4 --timeout 10 --shard-index 1 --shard-count 2 --list-only \
> "${TEMP_DIR}/shard-list-1.log"
cat "${TEMP_DIR}/shard-list-0.log" "${TEMP_DIR}/shard-list-1.log" \
| grep -v '^Discovered ' \
| sort > "${TEMP_DIR}/shards-combined.log"
reset_case shard-list-all
run_harness --group-size 4 --timeout 10 --list-only \
| grep -v '^Discovered ' \
| sort > "${TEMP_DIR}/shards-expected.log"
diff -u "${TEMP_DIR}/shards-expected.log" "${TEMP_DIR}/shards-combined.log"
reset_case group-timeout
export FAKE_SWIFT_MODE=group_timeout
run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/group-timeout.log"
grep -Fq "timed out; retrying selections one at a time" "${TEMP_DIR}/group-timeout.log"
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}"
reset_case singleton-timeout
export FAKE_SWIFT_MODE=singleton_timeout
set +e
run_harness --group-size 1 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/singleton-timeout.log" 2>&1
singleton_timeout_status=$?
set -e
[[ "${singleton_timeout_status}" -eq 124 ]]
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Isolated selection retries | `0` |' "${GITHUB_STEP_SUMMARY}"
reset_case retry-timeout
export FAKE_SWIFT_MODE=group_fail_then_timeout
run_harness --group-size 4 --limit-groups 1 --timeout 1 > "${TEMP_DIR}/retry-timeout.log"
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Timed out groups | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Recovered groups | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Isolated selection retries | `4` |' "${GITHUB_STEP_SUMMARY}"
reset_case repeated-failure
export FAKE_SWIFT_MODE=group_always_fail
set +e
run_harness --group-size 4 --limit-groups 1 --timeout 10 > "${TEMP_DIR}/failure.log" 2>&1
failure_status=$?
set -e
[[ "${failure_status}" -eq 1 ]]
grep -Fq '| Full-group retries | `1` |' "${GITHUB_STEP_SUMMARY}"
grep -Fq '| Recovered groups | `0` |' "${GITHUB_STEP_SUMMARY}"
reset_case list-failure
export FAKE_SWIFT_MODE=list_fail
set +e
run_harness --group-size 1 --timeout 10 > "${TEMP_DIR}/list-failure.log" 2>&1
list_failure_status=$?
set -e
[[ "${list_failure_status}" -ne 0 ]]
grep -Fq "test-list stdout marker" "${TEMP_DIR}/list-failure.log"
grep -Fq "test-list stderr marker" "${TEMP_DIR}/list-failure.log"
grep -Eq -- '- Discovery seconds: 0\.[1-9]' "${TEMP_DIR}/list-failure.log"
grep -Fq '| Discovered selections | `0` |' "${GITHUB_STEP_SUMMARY}"
echo "Swift test sharding tests passed."
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION=${1:?"usage: $0 <version>"}
ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "$ROOT"
first_line=$(grep -m1 '^## ' CHANGELOG.md | sed 's/^## //')
if [[ "$first_line" != ${VERSION}* ]]; then
echo "ERROR: Top CHANGELOG section is '$first_line' but expected '${VERSION} — …'" >&2
exit 1
fi
grep -q "^## ${VERSION} " CHANGELOG.md || {
echo "ERROR: No section for version ${VERSION} in CHANGELOG.md" >&2
exit 1
}
grep -q '^## [0-9]\+\.[0-9]\+\.[0-9].*Unreleased' CHANGELOG.md && {
echo "ERROR: Top section still labeled Unreleased; finalize changelog first." >&2
exit 1
}
echo "Changelog OK for ${VERSION}"
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env bash
# Isolated live verification for CodexBar #1844 / PR #1848.
# Uses only synthetic credentials under a disposable HOME and keychain.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
log() { printf '[verify-1844] %s\n' "$*"; }
ARTIFACT="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-1844-verify.XXXXXX")"
chmod 700 "$ARTIFACT"
HOME_FIXTURE="$ARTIFACT/home"
KEYCHAIN="$ARTIFACT/claude-fixture.keychain-db"
KEYCHAIN_PASSWORD="codexbar-1844-synthetic-fixture"
CONFIG="$ARTIFACT/config.json"
CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}"
APP="${CODEXBAR_APP_BINARY:-$ROOT/CodexBar.app/Contents/MacOS/CodexBar}"
MCP_PAYLOAD='{"mcpOAuth":{"plugin:synthetic":{"accessToken":"synthetic-mcp-token"}}}'
EXPIRED_PAYLOAD='{"claudeAiOauth":{"accessToken":"synthetic-expired-token","expiresAt":1000,"scopes":["user:profile"],"refreshToken":"synthetic-refresh-token"}}'
if [[ ! -x "$CLI" ]]; then
log "Missing packaged CLI: $CLI"
log "Run ./Scripts/package_app.sh, then retry."
exit 2
fi
if [[ ! -x "$APP" ]]; then
log "Missing packaged app binary: $APP"
exit 2
fi
cleanup() {
/usr/bin/security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true
}
trap cleanup EXIT
log "Artifacts: $ARTIFACT"
log "Phase 1: focused integration tests"
{
swift test --filter ClaudeOAuthTests
swift test --filter ClaudeUsageTests
swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests
swift test --filter 'expired claude CLI owner blocks background'
swift test --filter ClaudeOAuthCredentialsStoreSecurityCLITests
swift test --filter ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests
swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests
} 2>&1 | tee "$ARTIFACT/integration-tests.log"
log "Phase 1 passed"
log "Phase 2: disposable HOME, keychain, credentials, config, and Claude CLI canary"
mkdir -p "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin"
chmod 700 "$HOME_FIXTURE" "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library" \
"$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin"
printf '%s\n' "$EXPIRED_PAYLOAD" >"$HOME_FIXTURE/.claude/.credentials.json"
chmod 600 "$HOME_FIXTURE/.claude/.credentials.json"
printf '%s\n' '{"version":1,"providers":[{"id":"claude","enabled":true,"source":"oauth"}]}' >"$CONFIG"
chmod 600 "$CONFIG"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf "args:" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
'printf " %q" "$@" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
'printf "\\n" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
'if [[ "$*" == "auth status --json" ]]; then printf "{\"loggedIn\":true}\\n"; exit 0; fi' \
'if [[ "$*" == "--version" ]]; then printf "2.1.0\\n"; exit 0; fi' \
'if IFS= read -r line; then' \
' printf "stdin:%s\\n" "$line" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \
' if [[ "$line" == *"/status"* ]]; then printf touched >"$CODEXBAR_CLAUDE_TOUCH_CANARY"; fi' \
'fi' \
'exit 99' \
>"$ARTIFACT/bin/claude"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf touched >"$CODEXBAR_OPEN_TOUCH_CANARY"' \
'exit 99' \
>"$ARTIFACT/bin/open"
chmod 700 "$ARTIFACT/bin/claude" "$ARTIFACT/bin/open"
/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-before.txt"
/usr/bin/security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
/usr/bin/security set-keychain-settings -t 3600 "$KEYCHAIN"
/usr/bin/security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
/usr/bin/security add-generic-password \
-a codexbar-verify-1844 \
-s 'Claude Code-credentials' \
-w "$MCP_PAYLOAD" \
-A \
"$KEYCHAIN"
/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-after.txt"
if ! cmp -s "$ARTIFACT/keychains-before.txt" "$ARTIFACT/keychains-after.txt"; then
log "Phase 2 failed: creating the disposable keychain changed the user search list"
exit 1
fi
/usr/bin/security find-generic-password \
-s 'Claude Code-credentials' \
-w \
"$KEYCHAIN" >"$ARTIFACT/keychain-fixture.json"
cmp -s "$ARTIFACT/keychain-fixture.json" <(printf '%s\n' "$MCP_PAYLOAD")
PROC_LOG="$ARTIFACT/e2e-processes.log"
STDOUT="$ARTIFACT/e2e-stdout.json"
STDERR="$ARTIFACT/e2e-stderr.jsonl"
CANARY="$ARTIFACT/claude-status-canary"
INVOCATIONS="$ARTIFACT/claude-invocations.log"
OPEN_CANARY="$ARTIFACT/open-touch-canary"
: >"$PROC_LOG"
: >"$INVOCATIONS"
set +e
(
env \
HOME="$HOME_FIXTURE" \
CFFIXED_USER_HOME="$HOME_FIXTURE" \
CODEXBAR_CONFIG="$CONFIG" \
CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \
CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \
CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \
CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \
CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \
CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \
CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \
PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
"$CLI" usage --provider claude --source oauth --format json --pretty --log-level debug \
>"$STDOUT" 2>"$STDERR"
) &
PID=$!
while kill -0 "$PID" 2>/dev/null; do
{
date -u +%H:%M:%S
pgrep -P "$PID" -l 2>/dev/null || true
} >>"$PROC_LOG"
sleep 0.02
done
wait "$PID"
CLI_STATUS=$?
set -e
{
echo "# CodexBar #1844 isolated E2E verification"
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "candidate: $(git rev-parse HEAD)"
echo "packaged-cli: $CLI"
echo "cli-exit: $CLI_STATUS"
echo "default-keychain-search-list-unchanged: yes"
echo "real-home-referenced: no"
echo "claude-status-canary: $([[ -e "$CANARY" ]] && echo touched || echo untouched)"
echo "open-touch-canary: $([[ -e "$OPEN_CANARY" ]] && echo touched || echo untouched)"
echo
echo "## stdout"
cat "$STDOUT"
echo
echo "## stderr (filtered)"
rg -i 'mcp|delegated|expired|oauth|touch|open|only prompt|user action' "$STDERR" || true
echo
echo "## Claude CLI invocations"
cat "$INVOCATIONS"
echo
echo "## child processes"
cat "$PROC_LOG"
} | tee "$ARTIFACT/E2E-REPORT.md"
if [[ "$CLI_STATUS" -eq 0 ]]; then
log "Phase 2 failed: the MCP-only fixture unexpectedly produced successful OAuth usage"
exit 1
fi
if [[ -e "$CANARY" ]]; then
log "Phase 2 failed: delegated Claude CLI /status touch ran"
exit 1
fi
if [[ -e "$OPEN_CANARY" ]]; then
log "Phase 2 failed: browser/open helper ran"
exit 1
fi
if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$PROC_LOG" 2>/dev/null; then
log "Phase 2 failed: an open helper or browser was a probe child"
exit 1
fi
if ! rg -qi 'MCP OAuth state only|mcpOAuthOnlyKeychain|MCP OAuth' "$STDERR" "$STDOUT"; then
log "Phase 2 failed: expected MCP-only fail-closed message not found"
exit 1
fi
log "Phase 2 passed: exact packaged CLI failed closed without delegated /status touch or browser child"
log "Phase 3: isolated packaged app runtime smoke"
APP_PROC_LOG="$ARTIFACT/app-processes.log"
APP_STDOUT="$ARTIFACT/app-stdout.log"
APP_STDERR="$ARTIFACT/app-stderr.log"
: >"$APP_PROC_LOG"
: >"$INVOCATIONS"
(
env \
HOME="$HOME_FIXTURE" \
CFFIXED_USER_HOME="$HOME_FIXTURE" \
CODEXBAR_CONFIG="$CONFIG" \
CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \
CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \
CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \
CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \
CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \
CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \
CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \
PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
"$APP" >"$APP_STDOUT" 2>"$APP_STDERR"
) &
APP_PID=$!
APP_OBSERVED_CLI=0
POST_DISCOVERY_TICKS=0
for _ in $(seq 1 1000); do
if ! kill -0 "$APP_PID" 2>/dev/null; then
log "Phase 3 failed: packaged app exited before the isolated startup smoke completed"
wait "$APP_PID" || true
exit 1
fi
{
date -u +%H:%M:%S
pgrep -P "$APP_PID" -l 2>/dev/null || true
} >>"$APP_PROC_LOG"
if rg -q '^args: --version$' "$INVOCATIONS"; then
APP_OBSERVED_CLI=1
POST_DISCOVERY_TICKS=$((POST_DISCOVERY_TICKS + 1))
if [[ "$POST_DISCOVERY_TICKS" -ge 250 ]]; then
break
fi
fi
sleep 0.02
done
kill "$APP_PID"
wait "$APP_PID" 2>/dev/null || true
if [[ "$APP_OBSERVED_CLI" -ne 1 ]]; then
log "Phase 3 failed: packaged app never exercised the isolated Claude CLI fixture"
exit 1
fi
if [[ -e "$CANARY" ]]; then
log "Phase 3 failed: packaged app invoked delegated Claude CLI /status touch"
exit 1
fi
if [[ -e "$OPEN_CANARY" ]]; then
log "Phase 3 failed: packaged app invoked browser/open helper"
exit 1
fi
if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$APP_PROC_LOG" 2>/dev/null; then
log "Phase 3 failed: an open helper or browser was an app child"
exit 1
fi
{
echo
echo "## packaged app runtime"
echo "app-binary: $APP"
echo "isolated-claude-cli-discovery-observed: yes"
echo "post-discovery-observation-seconds: 5"
echo "app-stayed-running: yes"
echo "claude-status-canary: untouched"
echo "open-touch-canary: untouched"
echo "browser-child: none"
echo
echo "## packaged app Claude CLI invocations"
cat "$INVOCATIONS"
} | tee -a "$ARTIFACT/E2E-REPORT.md"
log "Phase 3 passed: packaged app exercised CLI discovery without delegated /status touch or browser child"
log "Report: $ARTIFACT/E2E-REPORT.md"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
exec "$SCRIPT_DIR/mac-release" verify-appcast "$@"
@@ -0,0 +1,87 @@
import Foundation
/// Canonical adaptive-refresh decision table shared by the app and offline replay tooling.
/// Platform adapters normalize their thermal signals before calling this type; thresholds and
/// delays live here only.
package struct AdaptiveRefreshPolicyCore: Sendable {
package struct Input: Sendable, Equatable {
package let now: Date
package let lastMenuOpenAt: Date?
package let lowPowerModeEnabled: Bool
package let thermalPressure: ThermalPressure
package init(
now: Date,
lastMenuOpenAt: Date?,
lowPowerModeEnabled: Bool,
thermalPressure: ThermalPressure)
{
self.now = now
self.lastMenuOpenAt = lastMenuOpenAt
self.lowPowerModeEnabled = lowPowerModeEnabled
self.thermalPressure = thermalPressure
}
}
package enum ThermalPressure: Sendable, Equatable {
case nominal
case constrained
}
package enum Reason: String, Sendable, Equatable {
case recentInteraction
case warm
case idle
case longIdle
case constrained
}
package struct Decision: Sendable, Equatable {
package let delay: Duration
package let reason: Reason
fileprivate init(delay: Duration, reason: Reason) {
self.delay = delay
self.reason = reason
}
}
private static let recentInteractionThreshold: TimeInterval = 5 * 60
private static let warmThreshold: TimeInterval = 60 * 60
private static let idleThreshold: TimeInterval = 4 * 60 * 60
private static let recentInteractionDelay: Duration = .seconds(2 * 60)
private static let warmDelay: Duration = .seconds(5 * 60)
private static let idleDelay: Duration = .seconds(15 * 60)
private static let longIdleDelay: Duration = .seconds(30 * 60)
private static let constrainedDelay: Duration = .seconds(30 * 60)
/// Representative cadence for consumers that need one interval but cannot access live state.
package static let nominalIntervalForHeuristics: TimeInterval = 5 * 60
package init() {}
package func nextDelay(for input: Input) -> Decision {
if input.lowPowerModeEnabled || input.thermalPressure == .constrained {
return Decision(delay: Self.constrainedDelay, reason: .constrained)
}
guard let lastMenuOpenAt = input.lastMenuOpenAt else {
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
}
// A future or clock-adjusted timestamp yields a negative age, which reads as recent.
let age = input.now.timeIntervalSince(lastMenuOpenAt)
if age <= Self.recentInteractionThreshold {
return Decision(delay: Self.recentInteractionDelay, reason: .recentInteraction)
}
if age <= Self.warmThreshold {
return Decision(delay: Self.warmDelay, reason: .warm)
}
if age < Self.idleThreshold {
return Decision(delay: Self.idleDelay, reason: .idle)
}
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
}
}
@@ -0,0 +1,98 @@
import AdaptiveReplayKit
import Foundation
enum ReplayPolicyName: String, CaseIterable, Sendable {
case adaptive
case adaptiveActivity = "adaptive-activity"
case fixed2Minutes = "fixed-2m"
case fixed5Minutes = "fixed-5m"
case fixed15Minutes = "fixed-15m"
case fixed30Minutes = "fixed-30m"
case manual
var policy: any ReplayPolicy {
switch self {
case .adaptive:
AdaptiveReplayPolicy()
case .adaptiveActivity:
CodingActivityAdaptivePolicy()
case .fixed2Minutes:
FixedIntervalPolicy(minutes: 2)
case .fixed5Minutes:
FixedIntervalPolicy(minutes: 5)
case .fixed15Minutes:
FixedIntervalPolicy(minutes: 15)
case .fixed30Minutes:
FixedIntervalPolicy(minutes: 30)
case .manual:
ManualPolicy()
}
}
static var expectedValues: String {
allCases.map(\.rawValue).joined(separator: ", ")
}
}
enum CLIArguments {
case run(
tracePath: String,
policyNames: [ReplayPolicyName],
jsonOutput: Bool,
gapGraceSeconds: TimeInterval?)
case help(exitCode: Int32)
case invalid(message: String)
static func parse(_ arguments: [String]) -> Self {
if arguments.contains("-h") || arguments.contains("--help") {
return .help(exitCode: EXIT_SUCCESS)
}
var tracePath: String?
var policyNames: [ReplayPolicyName] = []
var jsonOutput = false
var gapGraceSeconds: TimeInterval? = ReplayTraceSegmenter.defaultGraceSeconds
var index = 0
while index < arguments.count {
let argument = arguments[index]
switch argument {
case "--json":
jsonOutput = true
case "--raw-wall-clock":
gapGraceSeconds = nil
case "--gap-grace":
index += 1
guard index < arguments.count,
let seconds = TimeInterval(arguments[index]),
seconds >= 0,
seconds.isFinite
else { return .invalid(message: "--gap-grace requires non-negative finite seconds") }
gapGraceSeconds = seconds
case "--policy":
index += 1
guard index < arguments.count else { return .invalid(message: "--policy requires a value") }
let rawPolicyName = arguments[index]
guard let policyName = ReplayPolicyName(rawValue: rawPolicyName) else {
return .invalid(
message: "unknown policy '\(rawPolicyName)' (expected: \(ReplayPolicyName.expectedValues))")
}
policyNames.append(policyName)
default:
guard tracePath == nil else {
return .invalid(message: "unexpected argument '\(argument)'")
}
tracePath = argument
}
index += 1
}
guard let tracePath else {
return .help(exitCode: EXIT_FAILURE)
}
return .run(
tracePath: tracePath,
policyNames: policyNames.isEmpty ? ReplayPolicyName.allCases : policyNames,
jsonOutput: jsonOutput,
gapGraceSeconds: gapGraceSeconds)
}
}
+217
View File
@@ -0,0 +1,217 @@
import AdaptiveReplayKit
import Foundation
/// Thin CLI shell over `AdaptiveReplayKit`: parses a trace path and a policy name, runs the
/// replay, and prints the resulting `ReplayMetrics`. All parsing/replay/metrics logic lives in
/// the library this file only routes arguments to it and formats the result.
enum AdaptiveReplayCLI {
static func main() {
let arguments = CLIArguments.parse(Array(CommandLine.arguments.dropFirst()))
switch arguments {
case let .help(exitCode):
print(Self.helpText)
exit(exitCode)
case let .invalid(message):
FileHandle.standardError.write(Data("error: \(message)\n\n\(Self.helpText)\n".utf8))
exit(EXIT_FAILURE)
case let .run(tracePath, policyNames, jsonOutput, gapGraceSeconds):
Self.run(
tracePath: tracePath,
policyNames: policyNames,
jsonOutput: jsonOutput,
gapGraceSeconds: gapGraceSeconds)
}
}
private static func run(
tracePath: String,
policyNames: [ReplayPolicyName],
jsonOutput: Bool,
gapGraceSeconds: TimeInterval?)
{
let records: [AdaptiveRefreshTraceRecord]
do {
records = try AdaptiveRefreshTraceParser.parse(contentsOf: URL(fileURLWithPath: tracePath))
} catch {
FileHandle.standardError.write(Data("error: failed to parse trace: \(error)\n".utf8))
exit(EXIT_FAILURE)
}
let policies = policyNames.map(\.policy)
let results = policies.map { policy in
gapGraceSeconds.map {
ReplayEngine.runSegmented(trace: records, policy: policy, graceSeconds: $0)
} ?? ReplayEngine.run(trace: records, policy: policy)
}
let activityCoverage = ActivityCoverageStats.compute(from: records)
let recordedScheduleAudit = RecordedScheduleAuditor.audit(records)
if jsonOutput {
print(Self.renderJSON(
results,
activityCoverage: activityCoverage,
recordedScheduleAudit: recordedScheduleAudit,
gapGraceSeconds: gapGraceSeconds))
} else {
print(Self.renderTable(results))
print(Self.renderActivityCoverage(activityCoverage))
print(Self.renderRecordedScheduleAudit(recordedScheduleAudit))
if let gapGraceSeconds, let first = results.first {
print(String(
format: "segmentation: %d segments, %.2fh excluded (legacy heuristic, %.0fs grace)",
first.segmentCount,
first.excludedGapSeconds / 3600,
gapGraceSeconds))
} else {
print("segmentation: disabled (raw wall clock)")
}
}
}
private static func renderRecordedScheduleAudit(_ audit: RecordedScheduleAudit) -> String {
"recorded schedule: \(audit.recordedAdvanceCount) advances, "
+ "\(audit.acceptedEvaluationCount)/\(audit.evaluatedCount) evaluations accepted, "
+ "payload=\(audit.payloadMismatchCount) decision=\(audit.decisionMismatchCount) "
+ "menu-link=\(audit.menuLinkMismatchCount) mismatches, "
+ "ambiguous=\(audit.ambiguousComparisonCount)"
}
/// Reports coverage of optional activity observations already present in the input trace.
private static func renderActivityCoverage(_ stats: ActivityCoverageStats) -> String {
guard stats.decisionCount > 0 else {
return "activity telemetry: no decision events in trace"
}
let sampledSummary = String(
format: "%d/%d decisions sampled (%.0f%%)",
stats.sampledCount,
stats.decisionCount,
stats.sampledFraction * 100)
let activeSummary = String(
format: "%d/%d active coding at decision time (%.0f%%)",
stats.activeCount,
stats.sampledCount,
stats.activeFraction * 100)
return "activity telemetry: \(sampledSummary), \(activeSummary)"
}
private static func renderTable(_ results: [ReplayMetrics]) -> String {
var lines: [String] = []
let header = [
"policy", "refreshes", "per24h", "sim advances", "active >5m", "staleness p50",
"staleness p95", "constrained ok",
]
lines.append(header.joined(separator: "\t"))
for metrics in results {
let staleness = metrics.stalenessAtMenuOpen
lines.append([
metrics.policyName,
String(metrics.totalRefreshCount),
String(format: "%.2f", metrics.refreshCountPer24h),
String(metrics.interactionAdvanceCount),
"\(metrics.codingActiveDelayViolationCount)/\(metrics.codingActiveDecisionCount)",
staleness.map { String(format: "%.0fs", $0.median) } ?? "n/a",
staleness.map { String(format: "%.0fs", $0.p95) } ?? "n/a",
metrics.constrainedCompliance
.isCompliant ? "yes" : "NO (\(metrics.constrainedCompliance.violationCount))",
].joined(separator: "\t"))
}
return lines.joined(separator: "\n")
}
private static func renderJSON(
_ results: [ReplayMetrics],
activityCoverage: ActivityCoverageStats,
recordedScheduleAudit: RecordedScheduleAudit,
gapGraceSeconds: TimeInterval?) -> String
{
let policies = results.map { metrics -> [String: Any] in
var dict: [String: Any] = [
"policy": metrics.policyName,
"simulatedSpanSeconds": metrics.simulatedSpanSeconds,
"totalRefreshCount": metrics.totalRefreshCount,
"refreshCountPer24h": metrics.refreshCountPer24h,
"interactionAdvanceCount": metrics.interactionAdvanceCount,
"codingActiveDecisionCount": metrics.codingActiveDecisionCount,
"codingActiveDelayViolationCount": metrics.codingActiveDelayViolationCount,
"segmentCount": metrics.segmentCount,
"excludedGapSeconds": metrics.excludedGapSeconds,
"boundaryCensoredMenuOpenCount": metrics.boundaryCensoredMenuOpenCount,
"constrainedDecisionCount": metrics.constrainedCompliance.constrainedDecisionCount,
"constrainedViolationCount": metrics.constrainedCompliance.violationCount,
"constrainedCompliant": metrics.constrainedCompliance.isCompliant,
]
if let staleness = metrics.stalenessAtMenuOpen {
dict["stalenessMeanSeconds"] = staleness.mean
dict["stalenessMedianSeconds"] = staleness.median
dict["stalenessP95Seconds"] = staleness.p95
dict["stalenessSampleCount"] = staleness.sampleCount
}
return dict
}
let segmentation: [String: Any] = [
"mode": gapGraceSeconds == nil ? "rawWallClock" : "legacyGapHeuristic",
"gapGraceSeconds": gapGraceSeconds.map { $0 as Any } ?? NSNull(),
]
let payload: [String: Any] = [
"policies": policies,
"activityCoverage": [
"decisionCount": activityCoverage.decisionCount,
"sampledCount": activityCoverage.sampledCount,
"activeCount": activityCoverage.activeCount,
"sampledFraction": activityCoverage.sampledFraction,
"activeFraction": activityCoverage.activeFraction,
],
"recordedScheduleAudit": [
"recordedAdvanceCount": recordedScheduleAudit.recordedAdvanceCount,
"evaluatedCount": recordedScheduleAudit.evaluatedCount,
"acceptedEvaluationCount": recordedScheduleAudit.acceptedEvaluationCount,
"rejectedEvaluationCount": recordedScheduleAudit.rejectedEvaluationCount,
"payloadMismatchCount": recordedScheduleAudit.payloadMismatchCount,
"decisionMismatchCount": recordedScheduleAudit.decisionMismatchCount,
"menuLinkMismatchCount": recordedScheduleAudit.menuLinkMismatchCount,
"ambiguousComparisonCount": recordedScheduleAudit.ambiguousComparisonCount,
"isValid": recordedScheduleAudit.isValid,
],
"segmentation": segmentation,
]
guard let data = try? JSONSerialization.data(
withJSONObject: payload,
options: [.prettyPrinted, .sortedKeys]),
let text = String(data: data, encoding: .utf8)
else {
return "{}"
}
return text
}
private static let helpText = """
Usage: AdaptiveReplayCLI <trace.jsonl> [--policy <name>]... [--gap-grace <seconds>] [--raw-wall-clock] [--json]
Replays a JSONL adaptive-refresh trace against one or more refresh-timing policies and prints
per-policy metrics over automatically segmented observed time. Simulated advances are
counterfactual policy events; recorded live schedule evaluations are audited separately.
Policies:
adaptive The shared production adaptive policy. Advances on menu-open interactions,
same as UsageStore.noteMenuOpened(at:).
adaptive-activity Experimental adaptive table capped at 5m after observed coding activity.
fixed-2m Fixed 2 minute cadence. Unaffected by menu-open interactions.
fixed-5m Fixed 5 minute cadence.
fixed-15m Fixed 15 minute cadence.
fixed-30m Fixed 30 minute cadence.
manual Never refreshes (degenerate floor).
Defaults to comparing all seven policies when --policy is omitted.
Options:
--policy <name> Restrict to one listed policy; repeat to compare a specific subset.
--gap-grace <s> Split legacy gaps this many seconds after the last timer deadline (default 300).
--raw-wall-clock Disable gap segmentation; useful only for auditing the old behavior.
--json Print a machine-readable report including replay, activity, and audit data.
-h, --help Print this help text.
"""
}
AdaptiveReplayCLI.main()
@@ -0,0 +1,50 @@
import Foundation
/// Informational summary of optional coding-activity observations in a trace's `decision` events.
/// It reports how many decisions carried activity data and how many sampled decisions were below
/// `activeThresholdSeconds` for either CLI.
public struct ActivityCoverageStats: Sendable, Equatable {
public let decisionCount: Int
public let sampledCount: Int
public let activeCount: Int
public init(decisionCount: Int, sampledCount: Int, activeCount: Int) {
self.decisionCount = decisionCount
self.sampledCount = sampledCount
self.activeCount = activeCount
}
/// Fraction of `decision` events that carried at least one non-nil activity field.
public var sampledFraction: Double {
self.decisionCount == 0 ? 0 : Double(self.sampledCount) / Double(self.decisionCount)
}
/// Fraction of the *sampled* decisions (not all decisions) that looked like active coding.
public var activeFraction: Double {
self.sampledCount == 0 ? 0 : Double(self.activeCount) / Double(self.sampledCount)
}
/// - Parameter activeThresholdSeconds: below this many seconds since the newest transcript
/// write, a CLI counts as "active coding at decision time". Defaults to 5 minutes.
public static func compute(
from records: [AdaptiveRefreshTraceRecord],
activeThresholdSeconds: TimeInterval = 300) -> Self
{
var sampledCount = 0
var activeCount = 0
var decisionCount = 0
for record in records where record.kind == .decision {
decisionCount += 1
let codexSeconds = record.codexActivitySeconds
let claudeSeconds = record.claudeActivitySeconds
guard codexSeconds != nil || claudeSeconds != nil else { continue }
sampledCount += 1
let isActive = (codexSeconds ?? .infinity) < activeThresholdSeconds
|| (claudeSeconds ?? .infinity) < activeThresholdSeconds
if isActive {
activeCount += 1
}
}
return Self(decisionCount: decisionCount, sampledCount: sampledCount, activeCount: activeCount)
}
}
@@ -0,0 +1,201 @@
import Foundation
/// The event kinds a trace records. `decision` events capture a full policy tick (the
/// signals it saw plus what it chose). `menuOpen` and `refreshCompleted` capture the two
/// ground-truth events the replay engine anchors a simulation to, independent of any candidate
/// policy. `timerAdvanced` captures the one place live behavior *isn't* a plain tick loop: when
/// opening the menu makes `UsageStore.noteMenuOpened(at:)` pull the next adaptive refresh forward
/// (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`). Recording it separately
/// from `decision` lets a trace answer "did an advance happen, and to when" without relying on
/// fragile inference from decision-timestamp gaps.
public enum AdaptiveRefreshTraceEventKind: String, Sendable, Codable {
case decision
case menuOpen
case refreshCompleted
case timerAdvanced
/// Every live advance comparison, including the cases correctly rejected because the current
/// timer was already earlier. This is distinct from counterfactual replay advances.
case timerAdvanceEvaluated
}
/// One line of a JSONL adaptive-refresh trace. Field presence depends on `kind`: `decision`
/// records populate `menuAgeSeconds`, `lowPowerModeEnabled`, `thermalState`, `reason`, and
/// `delaySeconds`, plus optional activity observations supplied by the input trace
/// (`codexActivitySeconds`/`claudeActivitySeconds`, the seconds-since-newest-transcript fields,
/// and the per-file intensity fields alongside them); timer advance records populate
/// `previousScheduledAt`, `candidateScheduledAt`, `reason`, and `delaySeconds`; `menuOpen` and
/// `refreshCompleted` carry only `kind` and `timestamp`.
public struct AdaptiveRefreshTraceRecord: Sendable, Codable, Equatable {
public let kind: AdaptiveRefreshTraceEventKind
public let timestamp: Date
public let menuAgeSeconds: TimeInterval?
public let lowPowerModeEnabled: Bool?
public let thermalState: ReplayThermalState?
public let reason: String?
public let delaySeconds: TimeInterval?
/// Timer advance records only: the adaptive timer's scheduled refresh time before the
/// comparison, or `nil` when no refresh had been scheduled yet (matches
/// `UsageStore.shouldAdvanceAdaptiveTimer`'s "always advance when nothing is scheduled" rule).
public let previousScheduledAt: Date?
/// Timer advance records only: the candidate refresh time, i.e. the menu-open timestamp plus
/// the freshly computed decision's delay.
public let candidateScheduledAt: Date?
/// `timerAdvanceEvaluated` only: whether the live schedule comparison accepted the candidate.
public let timerAdvanceAccepted: Bool?
/// `timerAdvanceEvaluated` only: `previousScheduledAt - candidateScheduledAt`, captured before
/// whole-second ISO-8601 serialization. Positive means the candidate was earlier. Optional for
/// compatibility with traces recorded before exact comparison deltas were added.
public let scheduleLeadSeconds: TimeInterval?
/// `timerAdvanceEvaluated` only: whether another refresh was in flight at comparison time.
public let refreshInFlight: Bool?
/// `decision` only: seconds since the newest observed Codex session transcript modification,
/// or `nil` when unavailable. Optional so old trace lines without this field keep decoding.
public let codexActivitySeconds: TimeInterval?
/// `decision` only: the Claude Code counterpart of `codexActivitySeconds`.
public let claudeActivitySeconds: TimeInterval?
/// `decision` only: how long the newest Codex transcript has been
/// growing (its mtime minus its creationDate), or `nil` when unavailable. Not a separate
/// session-age field age is `codexActivitySeconds` + `codexSessionDurationSeconds`.
public let codexSessionDurationSeconds: TimeInterval?
/// `decision` only: the Claude Code counterpart of `codexSessionDurationSeconds`.
public let claudeSessionDurationSeconds: TimeInterval?
/// `decision` only: size in bytes of the newest Codex transcript, as a stateless raw value.
public let codexTranscriptBytes: Int64?
/// `decision` only: the Claude Code counterpart of `codexTranscriptBytes`.
public let claudeTranscriptBytes: Int64?
/// `decision` only: count of Codex `.jsonl` transcripts modified in the observation window.
public let codexActiveTranscriptCount: Int?
/// `decision` only: the Claude Code counterpart of `codexActiveTranscriptCount`.
public let claudeActiveTranscriptCount: Int?
public init(
kind: AdaptiveRefreshTraceEventKind,
timestamp: Date,
menuAgeSeconds: TimeInterval? = nil,
lowPowerModeEnabled: Bool? = nil,
thermalState: ReplayThermalState? = nil,
reason: String? = nil,
delaySeconds: TimeInterval? = nil,
previousScheduledAt: Date? = nil,
candidateScheduledAt: Date? = nil,
timerAdvanceAccepted: Bool? = nil,
scheduleLeadSeconds: TimeInterval? = nil,
refreshInFlight: Bool? = nil,
codexActivitySeconds: TimeInterval? = nil,
claudeActivitySeconds: TimeInterval? = nil,
codexSessionDurationSeconds: TimeInterval? = nil,
claudeSessionDurationSeconds: TimeInterval? = nil,
codexTranscriptBytes: Int64? = nil,
claudeTranscriptBytes: Int64? = nil,
codexActiveTranscriptCount: Int? = nil,
claudeActiveTranscriptCount: Int? = nil)
{
self.kind = kind
self.timestamp = timestamp
self.menuAgeSeconds = menuAgeSeconds
self.lowPowerModeEnabled = lowPowerModeEnabled
self.thermalState = thermalState
self.reason = reason
self.delaySeconds = delaySeconds
self.previousScheduledAt = previousScheduledAt
self.candidateScheduledAt = candidateScheduledAt
self.timerAdvanceAccepted = timerAdvanceAccepted
self.scheduleLeadSeconds = scheduleLeadSeconds
self.refreshInFlight = refreshInFlight
self.codexActivitySeconds = codexActivitySeconds
self.claudeActivitySeconds = claudeActivitySeconds
self.codexSessionDurationSeconds = codexSessionDurationSeconds
self.claudeSessionDurationSeconds = claudeSessionDurationSeconds
self.codexTranscriptBytes = codexTranscriptBytes
self.claudeTranscriptBytes = claudeTranscriptBytes
self.codexActiveTranscriptCount = codexActiveTranscriptCount
self.claudeActiveTranscriptCount = claudeActiveTranscriptCount
}
// swiftlint:disable:next function_parameter_count
public static func decision(
timestamp: Date,
menuAgeSeconds: TimeInterval?,
lowPowerModeEnabled: Bool,
thermalState: ReplayThermalState,
reason: String,
delaySeconds: TimeInterval,
codexActivitySeconds: TimeInterval? = nil,
claudeActivitySeconds: TimeInterval? = nil,
codexSessionDurationSeconds: TimeInterval? = nil,
claudeSessionDurationSeconds: TimeInterval? = nil,
codexTranscriptBytes: Int64? = nil,
claudeTranscriptBytes: Int64? = nil,
codexActiveTranscriptCount: Int? = nil,
claudeActiveTranscriptCount: Int? = nil) -> Self
{
Self(
kind: .decision,
timestamp: timestamp,
menuAgeSeconds: menuAgeSeconds,
lowPowerModeEnabled: lowPowerModeEnabled,
thermalState: thermalState,
reason: reason,
delaySeconds: delaySeconds,
codexActivitySeconds: codexActivitySeconds,
claudeActivitySeconds: claudeActivitySeconds,
codexSessionDurationSeconds: codexSessionDurationSeconds,
claudeSessionDurationSeconds: claudeSessionDurationSeconds,
codexTranscriptBytes: codexTranscriptBytes,
claudeTranscriptBytes: claudeTranscriptBytes,
codexActiveTranscriptCount: codexActiveTranscriptCount,
claudeActiveTranscriptCount: claudeActiveTranscriptCount)
}
public static func menuOpen(timestamp: Date) -> Self {
Self(kind: .menuOpen, timestamp: timestamp)
}
public static func refreshCompleted(timestamp: Date) -> Self {
Self(kind: .refreshCompleted, timestamp: timestamp)
}
/// - Parameters:
/// - timestamp: When the menu open that triggered the advance occurred.
/// - previousScheduledAt: The timer's scheduled refresh time immediately before the advance.
/// - candidateScheduledAt: The refresh time the timer advanced to (`timestamp + delaySeconds`).
/// - reason: The freshly computed decision's reason (e.g. `"recentInteraction"`).
/// - delaySeconds: The freshly computed decision's delay.
public static func timerAdvanced(
timestamp: Date,
previousScheduledAt: Date?,
candidateScheduledAt: Date,
reason: String,
delaySeconds: TimeInterval) -> Self
{
Self(
kind: .timerAdvanced,
timestamp: timestamp,
reason: reason,
delaySeconds: delaySeconds,
previousScheduledAt: previousScheduledAt,
candidateScheduledAt: candidateScheduledAt)
}
// swiftlint:disable:next function_parameter_count
public static func timerAdvanceEvaluated(
timestamp: Date,
previousScheduledAt: Date?,
candidateScheduledAt: Date,
reason: String,
delaySeconds: TimeInterval,
accepted: Bool,
refreshInFlight: Bool) -> Self
{
Self(
kind: .timerAdvanceEvaluated,
timestamp: timestamp,
reason: reason,
delaySeconds: delaySeconds,
previousScheduledAt: previousScheduledAt,
candidateScheduledAt: candidateScheduledAt,
timerAdvanceAccepted: accepted,
scheduleLeadSeconds: previousScheduledAt.map { $0.timeIntervalSince(candidateScheduledAt) },
refreshInFlight: refreshInFlight)
}
}
@@ -0,0 +1,85 @@
import Foundation
/// A malformed trace line, with enough context to find and fix it.
public struct AdaptiveRefreshTraceParseError: Error, Sendable, Equatable, CustomStringConvertible {
public let lineNumber: Int
public let content: String
public let underlyingDescription: String
public init(lineNumber: Int, content: String, underlyingDescription: String) {
self.lineNumber = lineNumber
self.content = content
self.underlyingDescription = underlyingDescription
}
public var description: String {
"trace line \(self.lineNumber) is malformed: \(self.underlyingDescription) (content: \(self.content))"
}
}
/// Parses newline-delimited JSON adaptive-refresh traces.
///
/// Deliberate choice: a malformed line **fails the whole parse** rather than being silently
/// skipped. A trace is acceptance evidence if a line is corrupt (truncated write, disk-full
/// mid-append, hand-edited fixture with a typo), the honest answer is "this trace is untrustworthy
/// as a whole", not "here are metrics computed from however much of it happened to parse". A
/// silently-shortened trace would still produce a superficially plausible replay report, which is
/// worse than a loud failure: it hides exactly the kind of gap that would bias staleness/refresh
/// counts. Callers that genuinely want best-effort parsing can catch the error and fall back to
/// `AdaptiveRefreshTraceParser.parseTolerantly`, which skips bad lines and returns what parsed.
public enum AdaptiveRefreshTraceParser {
public static func parse(_ text: String) throws -> [AdaptiveRefreshTraceRecord] {
let decoder = Self.makeDecoder()
var records: [AdaptiveRefreshTraceRecord] = []
for (index, line) in text.split(
omittingEmptySubsequences: false,
whereSeparator: \.isNewline).enumerated()
{
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard let data = trimmed.data(using: .utf8) else {
throw AdaptiveRefreshTraceParseError(
lineNumber: index + 1,
content: trimmed,
underlyingDescription: "not valid UTF-8")
}
do {
try records.append(decoder.decode(AdaptiveRefreshTraceRecord.self, from: data))
} catch {
throw AdaptiveRefreshTraceParseError(
lineNumber: index + 1,
content: trimmed,
underlyingDescription: String(describing: error))
}
}
return records
}
public static func parse(contentsOf url: URL) throws -> [AdaptiveRefreshTraceRecord] {
let text = try String(contentsOf: url, encoding: .utf8)
return try self.parse(text)
}
/// Best-effort variant: skips lines that fail to parse instead of throwing. Not the default
/// see the type-level documentation for why silent skipping is the wrong default for
/// acceptance-evidence traces. Exists for callers (future exploratory tooling) that explicitly
/// want partial data over none.
public static func parseTolerantly(_ text: String) -> [AdaptiveRefreshTraceRecord] {
let decoder = Self.makeDecoder()
var records: [AdaptiveRefreshTraceRecord] = []
for line in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { continue }
if let record = try? decoder.decode(AdaptiveRefreshTraceRecord.self, from: data) {
records.append(record)
}
}
return records
}
private static func makeDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
@@ -0,0 +1,57 @@
import AdaptiveRefreshCore
import Foundation
/// Replay adapter for the same canonical policy core used by the CodexBar app.
public struct AdaptiveReplayPolicy: ReplayPolicy, Sendable {
public let name = "adaptive"
/// Matches `UsageStore.noteMenuOpened(at:)`'s adaptive-only advance guard: this is the one
/// baseline that actually models the interaction-advance path, so it is the only one that
/// overrides the protocol's `false` default.
public let advancesOnInteraction = true
public init() {}
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
now: input.now,
lastMenuOpenAt: input.lastMenuOpenAt,
lowPowerModeEnabled: input.lowPowerModeEnabled,
thermalPressure: input.thermalState.isConstrained ? .constrained : .nominal))
return ReplayPolicyDecision(
delaySeconds: TimeInterval(decision.delay.components.seconds),
reason: decision.reason.rawValue)
}
}
/// A fixed-cadence baseline: always waits the same interval, regardless of signals. Used to
/// compare the adaptive policy against the flat refresh frequencies CodexBar also offers
/// (2/5/15/30 minutes). Never advances on interaction (`advancesOnInteraction` stays the protocol
/// default of `false`), matching the real app: fixed-cadence refresh frequencies never wire up
/// `noteMenuOpened`'s advance check.
public struct FixedIntervalPolicy: ReplayPolicy, Sendable {
public let name: String
private let intervalSeconds: TimeInterval
public init(minutes: Int) {
self.name = "fixed-\(minutes)m"
self.intervalSeconds = TimeInterval(minutes) * 60
}
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
ReplayPolicyDecision(delaySeconds: self.intervalSeconds, reason: "fixed")
}
}
/// The degenerate floor: never schedules a refresh. A trace replayed against this policy always
/// reports zero refreshes, which is the point it establishes the worst-case staleness bound the
/// other policies are compared against.
public struct ManualPolicy: ReplayPolicy, Sendable {
public let name = "manual"
public init() {}
public func decide(_: ReplayPolicyInput) -> ReplayPolicyDecision {
ReplayPolicyDecision(delaySeconds: nil, reason: "manual")
}
}
@@ -0,0 +1,27 @@
import Foundation
/// Replay-only candidate used to test whether stat-only coding activity can close the accepted
/// "active work is never slower than five minutes" gap. It is not a production policy approval.
public struct CodingActivityAdaptivePolicy: ReplayPolicy, Sendable {
public let name = "adaptive-activity"
public let advancesOnInteraction = true
private let base = AdaptiveReplayPolicy()
private static let activeThreshold: TimeInterval = 5 * 60
private static let activeDelayCap: TimeInterval = 5 * 60
public init() {}
public func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision {
let baseDecision = self.base.decide(input)
guard !input.isConstrained,
let activityAge = input.codingActivityAgeSeconds,
activityAge < Self.activeThreshold,
let baseDelay = baseDecision.delaySeconds,
baseDelay > Self.activeDelayCap
else {
return baseDecision
}
return ReplayPolicyDecision(delaySeconds: Self.activeDelayCap, reason: "codingActivity")
}
}
+39
View File
@@ -0,0 +1,39 @@
# AdaptiveReplayKit
`AdaptiveReplayKit` is an offline harness for comparing refresh-timing policies against an
explicit JSONL trace. `AdaptiveReplayCLI` is the command-line wrapper around the library.
## Scope
The replay targets do not import `CodexBar` or `CodexBarCore`; they share only the package-internal,
Foundation-only `AdaptiveRefreshCore` target with the app. They do not record app behavior, scan
Codex or Claude transcript directories, write trace files, call providers, or change the production
refresh policy. Trace capture and lifecycle management are deliberately outside this PR; callers
provide an existing trace path to the CLI.
Optional activity fields in the trace schema are inputs only. The replay kit never discovers or
collects them. Old records without those fields continue to decode.
## Components
- `AdaptiveRefreshTrace.swift` defines the version-tolerant trace schema.
- `AdaptiveRefreshTraceParser.swift` parses JSONL strictly by default. The tolerant entry point is
available for exploratory work that explicitly accepts skipped malformed records.
- `AdaptiveRefreshCore` owns the production decision table. `ReplayPolicy.swift`,
`BaselinePolicies.swift`, and `CandidatePolicies.swift` provide replay adapters, fixed/manual
baselines, and the replay-only activity candidate.
- `ReplayEngine.swift` and `ReplayMetrics.swift` calculate simulated refresh cadence, menu-open
staleness, interaction advances, and constrained-state compliance.
- `ReplayTraceSegmentation.swift` excludes legacy deadline-overrun gaps with an explicit heuristic
and reports the excluded duration.
- `RecordedScheduleAudit.swift` audits recorded timer-advance events independently from the replay
clock.
- `Sources/AdaptiveReplayCLI` formats table or JSON reports.
`interactionAdvanceCount` is counterfactual. Replay assumes a zero-duration refresh, while the
live app waits for provider work and may already have a refresh in flight. Recorded schedule events
therefore have a separate audit instead of a direct count comparison.
The legacy gap heuristic cannot distinguish sleep or reboot from a long refresh or event-loop
stall. Reports expose the segment count, grace interval, and excluded time rather than assigning a
cause.
@@ -0,0 +1,153 @@
import Foundation
public struct RecordedScheduleAudit: Sendable, Equatable {
public let recordedAdvanceCount: Int
public let evaluatedCount: Int
public let acceptedEvaluationCount: Int
public let rejectedEvaluationCount: Int
public let payloadMismatchCount: Int
public let decisionMismatchCount: Int
public let menuLinkMismatchCount: Int
public let ambiguousComparisonCount: Int
public var isValid: Bool {
self.payloadMismatchCount == 0
&& self.decisionMismatchCount == 0
&& self.menuLinkMismatchCount == 0
&& self.ambiguousComparisonCount == 0
}
}
/// Audits the live schedule records without equating them to ReplayEngine's counterfactual clock.
public enum RecordedScheduleAuditor {
public static func audit(
_ records: [AdaptiveRefreshTraceRecord],
timestampTolerance: TimeInterval = 1) -> RecordedScheduleAudit
{
let sorted = records.sorted { $0.timestamp < $1.timestamp }
let menuTimestamps = sorted.filter { $0.kind == .menuOpen }.map(\.timestamp)
let advances = sorted.filter { $0.kind == .timerAdvanced }
let evaluations = sorted.filter { $0.kind == .timerAdvanceEvaluated }
var payloadMismatchCount = advances.count(where: { !Self.payloadIsValid($0) })
let evaluationOutcomes = evaluations.map(Self.evaluationOutcome)
let decisionMismatchCount = evaluationOutcomes.count(where: { $0 == .mismatch })
let ambiguousComparisonCount = evaluationOutcomes.count(where: { $0 == .ambiguous })
// Every evaluation is caused by one menu open. Before evaluation records existed, an
// accepted advance was the only causal record, so retain those legacy advances as linkage
// events. Modern accepted advances are reconciled against evaluations below instead of
// consuming the same menu open twice.
let legacyAdvances = evaluations.first.map { firstEvaluation in
advances.filter { $0.timestamp < firstEvaluation.timestamp }
} ?? advances
let menuLinkMismatchCount = Self.unmatchedEventCount(
evaluations + legacyAdvances,
menuTimestamps: menuTimestamps,
timestampTolerance: timestampTolerance)
if let firstEvaluationAt = evaluations.first?.timestamp {
let accepted = evaluations.filter { $0.timerAdvanceAccepted == true }
let auditableAdvances = advances.filter { $0.timestamp >= firstEvaluationAt }
payloadMismatchCount += Self.scheduleMultiplicityDifference(accepted, auditableAdvances)
}
return RecordedScheduleAudit(
recordedAdvanceCount: advances.count,
evaluatedCount: evaluations.count,
acceptedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == true }),
rejectedEvaluationCount: evaluations.count(where: { $0.timerAdvanceAccepted == false }),
payloadMismatchCount: payloadMismatchCount,
decisionMismatchCount: decisionMismatchCount,
menuLinkMismatchCount: menuLinkMismatchCount,
ambiguousComparisonCount: ambiguousComparisonCount)
}
private static func payloadIsValid(_ record: AdaptiveRefreshTraceRecord) -> Bool {
guard let candidate = record.candidateScheduledAt,
let delay = record.delaySeconds,
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
else { return false }
// Whole-second legacy timestamps can collapse a sub-second accepted lead to equality.
return record.previousScheduledAt.map { candidate <= $0 } ?? true
}
private enum EvaluationOutcome: Equatable {
case valid
case mismatch
case ambiguous
}
private static func evaluationOutcome(_ record: AdaptiveRefreshTraceRecord) -> EvaluationOutcome {
guard let accepted = record.timerAdvanceAccepted,
let candidate = record.candidateScheduledAt,
let delay = record.delaySeconds,
abs(candidate.timeIntervalSince(record.timestamp) - delay) < 0.001
else { return .mismatch }
guard let previous = record.previousScheduledAt else { return accepted ? .valid : .mismatch }
if candidate != previous {
return accepted == (candidate < previous) ? .valid : .mismatch
}
guard let lead = record.scheduleLeadSeconds else { return .ambiguous }
return accepted == (lead > 0) ? .valid : .mismatch
}
private struct ScheduleKey: Hashable {
let timestamp: Date
let previousScheduledAt: Date?
let candidateScheduledAt: Date?
let reason: String?
let delaySeconds: TimeInterval?
}
private static func scheduleMultiplicityDifference(
_ lhs: [AdaptiveRefreshTraceRecord],
_ rhs: [AdaptiveRefreshTraceRecord]) -> Int
{
func counts(_ records: [AdaptiveRefreshTraceRecord]) -> [ScheduleKey: Int] {
Dictionary(grouping: records, by: scheduleKey).mapValues(\.count)
}
let lhsCounts = counts(lhs)
let rhsCounts = counts(rhs)
return Set(lhsCounts.keys).union(rhsCounts.keys).reduce(0) { difference, key in
difference + abs(lhsCounts[key, default: 0] - rhsCounts[key, default: 0])
}
}
/// Maximum one-to-one matching for sorted points with a symmetric tolerance window. Extra
/// menu opens are valid because fixed/manual modes do not emit schedule evaluations; only an
/// event without its own causal menu open is a mismatch.
private static func unmatchedEventCount(
_ records: [AdaptiveRefreshTraceRecord],
menuTimestamps: [Date],
timestampTolerance: TimeInterval) -> Int
{
let eventTimestamps = records.map(\.timestamp).sorted()
var eventIndex = 0
var menuIndex = 0
var unmatched = 0
while eventIndex < eventTimestamps.count, menuIndex < menuTimestamps.count {
let eventTimestamp = eventTimestamps[eventIndex]
let menuTimestamp = menuTimestamps[menuIndex]
if menuTimestamp < eventTimestamp.addingTimeInterval(-timestampTolerance) {
menuIndex += 1
} else if menuTimestamp > eventTimestamp.addingTimeInterval(timestampTolerance) {
unmatched += 1
eventIndex += 1
} else {
eventIndex += 1
menuIndex += 1
}
}
return unmatched + eventTimestamps.count - eventIndex
}
private static func scheduleKey(_ record: AdaptiveRefreshTraceRecord) -> ScheduleKey {
ScheduleKey(
timestamp: record.timestamp,
previousScheduledAt: record.previousScheduledAt,
candidateScheduledAt: record.candidateScheduledAt,
reason: record.reason,
delaySeconds: record.delaySeconds)
}
}
@@ -0,0 +1,303 @@
import Foundation
/// Simulates the live timer loop (`decide` sleep refresh `decide` ...) over a trace's
/// observed span for a given `ReplayPolicy`, pure and deterministic: the same trace and policy
/// always produce the same `ReplayMetrics`, since every input the policy sees comes from the
/// trace, never from a live clock.
///
/// Ground truth vs. reconstructed signal: `menuOpen` events are ground truth a menu either
/// opened at a timestamp or it didn't, independent of any policy. `lowPowerModeEnabled` and
/// `thermalState`, by contrast, are only *sampled* at the timestamps the trace's original
/// `decision` events happened to occur at (whatever policy produced the trace). When a candidate
/// policy's own tick times fall between those samples, the engine holds the most recent known
/// value (step function). This is the phase-1 approximation: without a continuous power/thermal
/// signal in the trace, "most recent sample" is the best available reconstruction. Before the
/// first known sample, the earliest available sample is used (hold-first).
///
/// Interaction advances: this is a *counterfactual* replay, not a literal replay of whatever the
/// recording policy happened to do each candidate policy gets its own tick schedule computed
/// fresh from `policy.decide(_:)`. To reproduce `UsageStore.noteMenuOpened(at:)`'s "pull the timer
/// forward" behavior (see `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`) for
/// *any* candidate policy, every `menuOpen` event that falls inside a policy's current tick window
/// is independently re-evaluated: if `policy.advancesOnInteraction` and the decision computed as of
/// that menu open would land earlier than the already-scheduled next tick, the schedule advances to
/// that earlier time, exactly like `startTimer(preservingResetBoundaryRefresh: true)` replacing a
/// pending sleep with a shorter one. Recorded `timerAdvanced` events are audited separately: their
/// count is not expected to equal
/// this counterfactual schedule because live refresh work has non-zero duration and can coalesce.
public enum ReplayEngine {
/// Safety valve against a pathological policy (e.g. a zero-or-negative delay bug) turning a
/// long trace into an unbounded loop.
private static let maxIterations = 2_000_000
/// The trace-derived, replay-invariant inputs the simulation loop reads on every tick:
/// menu-open ground truth plus the sampled power/thermal signal, both precomputed and sorted
/// once per `run` so the per-tick lookups stay O(log n).
private struct TraceSignals {
let menuOpenTimestamps: [Date]
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)]
let signalTimestamps: [Date]
let activitySamples: [ActivityObservation]
let activityTimestamps: [Date]
}
private struct ActivityObservation {
let timestamp: Date
let lastCodingActivityAt: Date?
}
public static func run(trace: [AdaptiveRefreshTraceRecord], policy: some ReplayPolicy) -> ReplayMetrics {
self.runDetailed(trace: trace, policy: policy).metrics
}
static func runDetailed(
trace: [AdaptiveRefreshTraceRecord],
policy: some ReplayPolicy,
stalenessStartAt: Date? = nil) -> ReplayRun
{
guard let start = trace.map(\.timestamp).min(), let end = trace.map(\.timestamp).max() else {
return ReplayRun(
metrics: ReplayMetrics(
policyName: policy.name,
simulatedSpanSeconds: 0,
totalRefreshCount: 0,
refreshCountPer24h: 0,
stalenessAtMenuOpen: nil,
constrainedCompliance: ConstrainedCompliance(constrainedDecisionCount: 0, violationCount: 0)),
stalenessSamples: [])
}
let menuOpenTimestamps = trace
.filter { $0.kind == .menuOpen }
.map(\.timestamp)
.sorted()
let signalSamples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)] = trace
.filter { $0.kind == .decision }
.compactMap { record in
guard let lowPower = record.lowPowerModeEnabled, let thermal = record.thermalState else {
return nil
}
return (timestamp: record.timestamp, lowPower: lowPower, thermal: thermal)
}
.sorted { $0.timestamp < $1.timestamp }
let activitySamples = trace
.filter { $0.kind == .decision }
.map { record in
let activityDates = [record.codexActivitySeconds, record.claudeActivitySeconds]
.compactMap(\.self)
.map { record.timestamp.addingTimeInterval(-max(0, $0)) }
return ActivityObservation(
timestamp: record.timestamp,
lastCodingActivityAt: activityDates.max())
}
.sorted { $0.timestamp < $1.timestamp }
let signals = TraceSignals(
menuOpenTimestamps: menuOpenTimestamps,
signalSamples: signalSamples,
signalTimestamps: signalSamples.map(\.timestamp),
activitySamples: activitySamples,
activityTimestamps: activitySamples.map(\.timestamp))
var cursor = start
var refreshTimestamps: [Date] = []
var constrainedDecisionCount = 0
var violationCount = 0
var interactionAdvanceCount = 0
var codingActiveDecisionCount = 0
var codingActiveDelayViolationCount = 0
var iterations = 0
// Monotonic pointer into `menuOpenTimestamps`: the scan below considers each menu open for
// an advance at most once, in the single tick window (cursor, next] it falls into.
var menuOpenScanIndex = 0
while cursor <= end, iterations < self.maxIterations {
iterations += 1
let (lowPower, thermal) = self.signal(
signals.signalSamples,
timestamps: signals.signalTimestamps,
at: cursor)
let input = ReplayPolicyInput(
now: cursor,
lastMenuOpenAt: self.lastValue(menuOpenTimestamps, atOrBefore: cursor),
lastCodingActivityAt: self.lastActivity(
signals.activitySamples,
timestamps: signals.activityTimestamps,
at: cursor),
lowPowerModeEnabled: lowPower,
thermalState: thermal)
let decision = policy.decide(input)
if input.isConstrained {
constrainedDecisionCount += 1
if let delay = decision.delaySeconds, delay < 1800 {
violationCount += 1
}
}
if !input.isConstrained,
let activityAge = input.codingActivityAgeSeconds,
activityAge < 5 * 60
{
codingActiveDecisionCount += 1
if decision.delaySeconds.map({ $0 <= 0 || $0 > 5 * 60 }) ?? true {
codingActiveDelayViolationCount += 1
}
}
guard let delay = decision.delaySeconds, delay > 0 else { break }
var next = cursor.addingTimeInterval(delay)
if policy.advancesOnInteraction {
let advanced = self.applyInteractionAdvances(
policy: policy,
signals: signals,
scanIndex: &menuOpenScanIndex,
windowStart: cursor,
scheduledAt: next)
next = advanced.scheduledAt
interactionAdvanceCount += advanced.advanceCount
}
guard next <= end else { break }
refreshTimestamps.append(next)
cursor = next
}
let span = end.timeIntervalSince(start)
let refreshCountPer24h = span > 0 ? Double(refreshTimestamps.count) * 86400 / span : 0
let stalenessMenuTimestamps = stalenessStartAt.map { start in
menuOpenTimestamps.filter { $0 >= start }
} ?? menuOpenTimestamps
let stalenessSamples = stalenessMenuTimestamps.isEmpty ? [] : self.stalenessSamples(
menuOpenTimestamps: stalenessMenuTimestamps,
refreshTimestamps: refreshTimestamps,
initialFreshAt: stalenessStartAt ?? start)
return ReplayRun(
metrics: ReplayMetrics(
policyName: policy.name,
simulatedSpanSeconds: span,
totalRefreshCount: refreshTimestamps.count,
refreshCountPer24h: refreshCountPer24h,
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
constrainedCompliance: ConstrainedCompliance(
constrainedDecisionCount: constrainedDecisionCount,
violationCount: violationCount),
interactionAdvanceCount: interactionAdvanceCount,
codingActiveDecisionCount: codingActiveDecisionCount,
codingActiveDelayViolationCount: codingActiveDelayViolationCount),
stalenessSamples: stalenessSamples)
}
/// Re-evaluates every not-yet-scanned menu open that falls in `(windowStart, scheduledAt]`
/// against `policy`, mirroring `UsageStore.shouldAdvanceAdaptiveTimer(scheduledAt:candidate:)`:
/// a menu open at time `T` computes `policy.decide(now: T, lastMenuOpenAt: T, ...)` (age zero,
/// exactly as `noteMenuOpened(at:)` does with `self.lastMenuOpenAt = date` already applied), and
/// if the resulting candidate (`T + delay`) lands earlier than the currently scheduled refresh,
/// the schedule advances to that candidate. Later menu opens in the same window are then
/// compared against the *advanced* schedule, same as a real second interaction tightening an
/// already-shortened sleep. Returns the (possibly advanced) scheduled time plus how many
/// advances were taken in this window.
private static func applyInteractionAdvances(
policy: some ReplayPolicy,
signals: TraceSignals,
scanIndex: inout Int,
windowStart: Date,
scheduledAt: Date) -> (scheduledAt: Date, advanceCount: Int)
{
var next = scheduledAt
var advanceCount = 0
while scanIndex < signals.menuOpenTimestamps.count {
let menuOpenAt = signals.menuOpenTimestamps[scanIndex]
guard menuOpenAt > windowStart else {
scanIndex += 1
continue
}
guard menuOpenAt <= next else { break }
let (lowPower, thermal) = self.signal(
signals.signalSamples,
timestamps: signals.signalTimestamps,
at: menuOpenAt)
let advanceDecision = policy.decide(ReplayPolicyInput(
now: menuOpenAt,
lastMenuOpenAt: menuOpenAt,
lastCodingActivityAt: self.lastActivity(
signals.activitySamples,
timestamps: signals.activityTimestamps,
at: menuOpenAt),
lowPowerModeEnabled: lowPower,
thermalState: thermal))
scanIndex += 1
guard let advanceDelay = advanceDecision.delaySeconds, advanceDelay > 0 else { continue }
let candidate = menuOpenAt.addingTimeInterval(advanceDelay)
if candidate < next {
next = candidate
advanceCount += 1
}
}
return (next, advanceCount)
}
private static func stalenessSamples(
menuOpenTimestamps: [Date],
refreshTimestamps: [Date],
initialFreshAt: Date) -> [Double]
{
menuOpenTimestamps.map { menuOpenAt in
let simulatedRefresh = self.lastValue(refreshTimestamps, atOrBefore: menuOpenAt)
let freshestAt = simulatedRefresh.map { max($0, initialFreshAt) } ?? initialFreshAt
return menuOpenAt.timeIntervalSince(freshestAt)
}
}
private static func lastActivity(
_ samples: [ActivityObservation],
timestamps: [Date],
at time: Date) -> Date?
{
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
return samples[index].lastCodingActivityAt
}
/// Binds the most recent power/thermal sample at or before `time` (hold-last), falling back
/// to the earliest known sample when `time` precedes every sample (hold-first), and to
/// nominal/not-low-power when no samples exist at all.
private static func signal(
_ samples: [(timestamp: Date, lowPower: Bool, thermal: ReplayThermalState)],
timestamps: [Date],
at time: Date) -> (Bool, ReplayThermalState)
{
guard !samples.isEmpty else { return (false, .nominal) }
if let index = self.lastIndex(timestamps, atOrBefore: time) {
return (samples[index].lowPower, samples[index].thermal)
}
return (samples[0].lowPower, samples[0].thermal)
}
private static func lastValue(_ timestamps: [Date], atOrBefore time: Date) -> Date? {
guard let index = self.lastIndex(timestamps, atOrBefore: time) else { return nil }
return timestamps[index]
}
/// Binary search for the last index whose timestamp is `<= time`, assuming `timestamps` is
/// sorted ascending. O(log n) so a long trace (thousands of decisions) stays fast to replay.
private static func lastIndex(_ timestamps: [Date], atOrBefore time: Date) -> Int? {
var low = 0
var high = timestamps.count - 1
var result: Int?
while low <= high {
let mid = (low + high) / 2
if timestamps[mid] <= time {
result = mid
low = mid + 1
} else {
high = mid - 1
}
}
return result
}
}
@@ -0,0 +1,108 @@
import Foundation
/// Mean/median/p95 of staleness (seconds since the last simulated refresh) observed at each
/// historical menu-open event. `p95` uses nearest-rank: samples are sorted ascending and index
/// `ceil(0.95 * n) - 1` (clamped to the last index) is reported the same convention most
/// dashboards use for small-to-medium sample counts, and simple enough to hand-verify in tests.
public struct StalenessStats: Sendable, Equatable {
public let mean: Double
public let median: Double
public let p95: Double
public let sampleCount: Int
public init(mean: Double, median: Double, p95: Double, sampleCount: Int) {
self.mean = mean
self.median = median
self.p95 = p95
self.sampleCount = sampleCount
}
init?(samples: [Double]) {
guard !samples.isEmpty else { return nil }
let sorted = samples.sorted()
self.init(
mean: sorted.reduce(0, +) / Double(sorted.count),
median: Self.percentile(sorted, fraction: 0.5),
p95: Self.percentile(sorted, fraction: 0.95),
sampleCount: sorted.count)
}
private static func percentile(_ sorted: [Double], fraction: Double) -> Double {
let rank = Int((fraction * Double(sorted.count)).rounded(.up))
return sorted[max(0, min(sorted.count - 1, rank - 1))]
}
}
/// Whether a policy honored the "never refresh faster than 30 minutes while constrained (low
/// power or serious/critical thermal)" rule at every simulated decision point where the input was
/// constrained.
public struct ConstrainedCompliance: Sendable, Equatable {
public let constrainedDecisionCount: Int
public let violationCount: Int
public init(constrainedDecisionCount: Int, violationCount: Int) {
self.constrainedDecisionCount = constrainedDecisionCount
self.violationCount = violationCount
}
public var isCompliant: Bool {
self.violationCount == 0
}
}
public struct ReplayMetrics: Sendable, Equatable {
public let policyName: String
public let simulatedSpanSeconds: TimeInterval
public let totalRefreshCount: Int
public let refreshCountPer24h: Double
public let stalenessAtMenuOpen: StalenessStats?
public let constrainedCompliance: ConstrainedCompliance
/// How many of `totalRefreshCount` were pulled forward by a menu-open interaction rather than
/// firing on the policy's own previously scheduled cadence i.e. how many times
/// `ReplayEngine.run` took the `advancesOnInteraction` branch for this policy. Always `0` for
/// policies that report `advancesOnInteraction == false` (see `ReplayPolicy`).
public let interactionAdvanceCount: Int
/// Unconstrained replayed decisions with a known transcript-write observation under five minutes old.
public let codingActiveDecisionCount: Int
/// Unconstrained active decisions whose selected delay exceeded the five-minute acceptance cap.
public let codingActiveDelayViolationCount: Int
/// Number of independently simulated awake/run segments contributing to these metrics.
public let segmentCount: Int
/// Wall-clock time excluded after an expected timer deadline because the app was unobserved.
public let excludedGapSeconds: TimeInterval
/// Menu opens before a segment's first recorded refresh, excluded equally for every policy.
public let boundaryCensoredMenuOpenCount: Int
public init(
policyName: String,
simulatedSpanSeconds: TimeInterval,
totalRefreshCount: Int,
refreshCountPer24h: Double,
stalenessAtMenuOpen: StalenessStats?,
constrainedCompliance: ConstrainedCompliance,
interactionAdvanceCount: Int = 0,
codingActiveDecisionCount: Int = 0,
codingActiveDelayViolationCount: Int = 0,
segmentCount: Int = 1,
excludedGapSeconds: TimeInterval = 0,
boundaryCensoredMenuOpenCount: Int = 0)
{
self.policyName = policyName
self.simulatedSpanSeconds = simulatedSpanSeconds
self.totalRefreshCount = totalRefreshCount
self.refreshCountPer24h = refreshCountPer24h
self.stalenessAtMenuOpen = stalenessAtMenuOpen
self.constrainedCompliance = constrainedCompliance
self.interactionAdvanceCount = interactionAdvanceCount
self.codingActiveDecisionCount = codingActiveDecisionCount
self.codingActiveDelayViolationCount = codingActiveDelayViolationCount
self.segmentCount = segmentCount
self.excludedGapSeconds = excludedGapSeconds
self.boundaryCensoredMenuOpenCount = boundaryCensoredMenuOpenCount
}
}
struct ReplayRun: Sendable {
let metrics: ReplayMetrics
let stalenessSamples: [Double]
}
@@ -0,0 +1,90 @@
import Foundation
// Replay harness for the adaptive refresh policy shipped in the `CodexBar` app target. The app
// and replay adapter both call `AdaptiveRefreshPolicyCore`; these types only normalize replay
// inputs and report replay-friendly output.
/// Coarse thermal-pressure signal matching the two `ProcessInfo.ThermalState` cases the policy
/// distinguishes (`.serious`/`.critical` vs everything else), expressed independently so this
/// library never needs Darwin-only APIs and can build on any platform.
public enum ReplayThermalState: String, Sendable, Codable, CaseIterable {
case nominal
case fair
case serious
case critical
public var isConstrained: Bool {
self == .serious || self == .critical
}
}
/// The inputs a refresh-timing policy needs to decide how long to wait before the next refresh.
/// Replay-specific policy input. Platform-independent fields map into the shared policy core.
public struct ReplayPolicyInput: Sendable, Equatable {
public let now: Date
public let lastMenuOpenAt: Date?
/// Most recent transcript write reconstructed from the latest activity observation available
/// at or before `now`. This is nil when that observation could not see either CLI.
public let lastCodingActivityAt: Date?
public let lowPowerModeEnabled: Bool
public let thermalState: ReplayThermalState
public init(
now: Date,
lastMenuOpenAt: Date?,
lastCodingActivityAt: Date? = nil,
lowPowerModeEnabled: Bool,
thermalState: ReplayThermalState)
{
self.now = now
self.lastMenuOpenAt = lastMenuOpenAt
self.lastCodingActivityAt = lastCodingActivityAt
self.lowPowerModeEnabled = lowPowerModeEnabled
self.thermalState = thermalState
}
/// Whether this input represents a power/thermal-constrained moment, independent of which
/// policy is deciding. Used by the replay engine to score constrained-tier compliance without
/// depending on any single policy's own notion of "constrained".
public var isConstrained: Bool {
self.lowPowerModeEnabled || self.thermalState.isConstrained
}
public var codingActivityAgeSeconds: TimeInterval? {
self.lastCodingActivityAt.map { max(0, self.now.timeIntervalSince($0)) }
}
}
/// A policy's decision: how long to wait, and a short human-readable reason code for reporting.
/// `delaySeconds == nil` means "never schedule another refresh" the degenerate floor used by
/// `ManualPolicy`.
public struct ReplayPolicyDecision: Sendable, Equatable {
public let delaySeconds: TimeInterval?
public let reason: String
public init(delaySeconds: TimeInterval?, reason: String) {
self.delaySeconds = delaySeconds
self.reason = reason
}
}
/// A pure, deterministic function from `ReplayPolicyInput` to `ReplayPolicyDecision`.
public protocol ReplayPolicy: Sendable {
var name: String { get }
/// Whether opening the menu can pull this policy's next refresh forward, mirroring
/// `UsageStore.noteMenuOpened(at:)`'s guard on `settings.refreshFrequency == .adaptive`: in the
/// real app, only adaptive mode ever advances the timer from an interaction fixed-cadence and
/// manual modes just record `lastMenuOpenAt` and let the existing schedule run. Defaults to
/// `false` so baseline policies (`FixedIntervalPolicy`, `ManualPolicy`) need no override; only
/// policies that actually model the adaptive table set this to `true`.
var advancesOnInteraction: Bool { get }
func decide(_ input: ReplayPolicyInput) -> ReplayPolicyDecision
}
extension ReplayPolicy {
public var advancesOnInteraction: Bool {
false
}
}
@@ -0,0 +1,123 @@
import Foundation
public struct ReplayTraceSegment: Sendable, Equatable {
public let records: [AdaptiveRefreshTraceRecord]
public let start: Date
public let end: Date
var replayRecords: [AdaptiveRefreshTraceRecord] {
guard self.records.last?.timestamp != self.end else { return self.records }
return self.records + [.refreshCompleted(timestamp: self.end)]
}
}
public struct ReplaySegmentationReport: Sendable, Equatable {
public let segments: [ReplayTraceSegment]
public let excludedGapSeconds: TimeInterval
public let breakCount: Int
public let graceSeconds: TimeInterval
public var includedSpanSeconds: TimeInterval {
self.segments.reduce(0) { $0 + max(0, $1.end.timeIntervalSince($1.start)) }
}
}
/// Splits legacy traces only when observation resumes well after the last timer deadline. The
/// normal scheduled wait remains inside the preceding segment; only overdue wall time is excluded.
public enum ReplayTraceSegmenter {
public static let defaultGraceSeconds: TimeInterval = 5 * 60
public static func automatic(
_ records: [AdaptiveRefreshTraceRecord],
graceSeconds: TimeInterval = Self.defaultGraceSeconds) -> ReplaySegmentationReport
{
let sorted = records.sorted { $0.timestamp < $1.timestamp }
guard let first = sorted.first else {
return ReplaySegmentationReport(
segments: [], excludedGapSeconds: 0, breakCount: 0, graceSeconds: graceSeconds)
}
var segments: [ReplayTraceSegment] = []
var currentRecords: [AdaptiveRefreshTraceRecord] = []
var currentStart = first.timestamp
var expectedDeadline: Date?
var excludedGapSeconds: TimeInterval = 0
for record in sorted {
if let deadline = expectedDeadline,
record.timestamp.timeIntervalSince(deadline) > graceSeconds,
!currentRecords.isEmpty
{
let end = max(currentRecords.last!.timestamp, deadline)
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: end))
excludedGapSeconds += max(0, record.timestamp.timeIntervalSince(end))
currentRecords = []
currentStart = record.timestamp
expectedDeadline = nil
}
currentRecords.append(record)
if record.kind == .decision, let delay = record.delaySeconds, delay > 0 {
expectedDeadline = record.timestamp.addingTimeInterval(delay)
} else if record.kind == .timerAdvanced, let candidate = record.candidateScheduledAt {
expectedDeadline = candidate
}
}
if let last = currentRecords.last {
segments.append(ReplayTraceSegment(records: currentRecords, start: currentStart, end: last.timestamp))
}
return ReplaySegmentationReport(
segments: segments,
excludedGapSeconds: excludedGapSeconds,
breakCount: max(0, segments.count - 1),
graceSeconds: graceSeconds)
}
}
extension ReplayEngine {
public static func runSegmented(
trace: [AdaptiveRefreshTraceRecord],
policy: some ReplayPolicy,
graceSeconds: TimeInterval = ReplayTraceSegmenter.defaultGraceSeconds) -> ReplayMetrics
{
let report = ReplayTraceSegmenter.automatic(trace, graceSeconds: graceSeconds)
let stalenessStarts = report.segments.map { segment in
segment.records.first(where: { $0.kind == .refreshCompleted })?.timestamp
}
let runs = zip(report.segments, stalenessStarts).map { segment, stalenessStart in
self.runDetailed(
trace: segment.replayRecords,
policy: policy,
stalenessStartAt: stalenessStart ?? .distantFuture)
}
let boundaryCensoredMenuOpenCount = zip(report.segments, stalenessStarts).reduce(0) { partial, pair in
let (segment, stalenessStart) = pair
return partial + segment.records.count(where: { record in
record.kind == .menuOpen && (stalenessStart.map { record.timestamp < $0 } ?? true)
})
}
let span = report.includedSpanSeconds
let refreshCount = runs.reduce(0) { $0 + $1.metrics.totalRefreshCount }
let stalenessSamples = runs.flatMap(\.stalenessSamples)
return ReplayMetrics(
policyName: policy.name,
simulatedSpanSeconds: span,
totalRefreshCount: refreshCount,
refreshCountPer24h: span > 0 ? Double(refreshCount) * 86400 / span : 0,
stalenessAtMenuOpen: StalenessStats(samples: stalenessSamples),
constrainedCompliance: ConstrainedCompliance(
constrainedDecisionCount: runs.reduce(0) {
$0 + $1.metrics.constrainedCompliance.constrainedDecisionCount
},
violationCount: runs.reduce(0) { $0 + $1.metrics.constrainedCompliance.violationCount }),
interactionAdvanceCount: runs.reduce(0) { $0 + $1.metrics.interactionAdvanceCount },
codingActiveDecisionCount: runs.reduce(0) { $0 + $1.metrics.codingActiveDecisionCount },
codingActiveDelayViolationCount: runs.reduce(0) {
$0 + $1.metrics.codingActiveDelayViolationCount
},
segmentCount: report.segments.count,
excludedGapSeconds: report.excludedGapSeconds,
boundaryCensoredMenuOpenCount: boundaryCensoredMenuOpenCount)
}
}
+5
View File
@@ -0,0 +1,5 @@
module CSQLite3 [system] {
header "shim.h"
link "sqlite3"
export *
}
+1
View File
@@ -0,0 +1 @@
#include <sqlite3.h>
+84
View File
@@ -0,0 +1,84 @@
import AppKit
@MainActor
func showAbout() {
NSApp.activate(ignoringOtherApps: true)
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
let versionString = build.isEmpty ? version : "\(version) (\(build))"
let buildTimestamp = Bundle.main.object(forInfoDictionaryKey: "CodexBuildTimestamp") as? String
let gitCommit = Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String
let separator = NSAttributedString(string: " · ", attributes: [
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
])
func makeLink(_ title: String, urlString: String) -> NSAttributedString {
NSAttributedString(string: title, attributes: [
.link: URL(string: urlString) as Any,
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
])
}
let credits = NSMutableAttributedString(string: "Peter Steinberger — MIT License\n")
credits.append(makeLink("GitHub", urlString: "https://github.com/steipete/CodexBar"))
credits.append(separator)
credits.append(makeLink("Website", urlString: "https://codexbar.app"))
credits.append(separator)
credits.append(makeLink("Twitter", urlString: "https://twitter.com/steipete"))
credits.append(separator)
credits.append(makeLink("Email", urlString: "mailto:peter@steipete.me"))
if let buildTimestamp, let formatted = formattedBuildTimestamp(buildTimestamp) {
var builtLine = "Built \(formatted)"
if let gitCommit, !gitCommit.isEmpty, gitCommit != "unknown" {
builtLine += " (\(gitCommit)"
#if DEBUG
builtLine += " DEBUG BUILD"
#endif
builtLine += ")"
}
credits.append(NSAttributedString(string: "\n\(builtLine)", attributes: [
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
.foregroundColor: NSColor.secondaryLabelColor,
]))
}
let options: [NSApplication.AboutPanelOptionKey: Any] = [
.applicationName: "CodexBar",
.applicationVersion: versionString,
.version: versionString,
.credits: credits,
.applicationIcon: (NSApplication.shared.applicationIconImage ?? NSImage()) as Any,
]
NSApp.orderFrontStandardAboutPanel(options: options)
// Remove the focus ring around the app icon in the standard About panel for a cleaner look.
if let aboutPanel = NSApp.windows.first(where: { $0.className.contains("About") }) {
removeFocusRings(in: aboutPanel.contentView)
}
}
private func formattedBuildTimestamp(_ timestamp: String) -> String? {
let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime]
guard let date = parser.date(from: timestamp) else { return timestamp }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.locale = .current
return formatter.string(from: date)
}
@MainActor
private func removeFocusRings(in view: NSView?) {
guard let view else { return }
if let imageView = view as? NSImageView {
imageView.focusRingType = .none
}
for subview in view.subviews {
removeFocusRings(in: subview)
}
}
@@ -0,0 +1,35 @@
import AdaptiveRefreshCore
import Foundation
/// Decides how long to wait before the next automatic usage refresh.
/// Pure by construction: every signal arrives via `Input`, so the same
/// input always yields the same `Decision` with no clock or system reads.
struct AdaptiveRefreshPolicy: Sendable {
struct Input: Sendable, Equatable {
let now: Date
let lastMenuOpenAt: Date?
let lowPowerModeEnabled: Bool
let thermalState: ProcessInfo.ThermalState
}
typealias Reason = AdaptiveRefreshPolicyCore.Reason
typealias Decision = AdaptiveRefreshPolicyCore.Decision
/// Representative cadence for consumers that need a single interval but cannot reach live
/// signals (`ProviderRegistry` builds provider specs before a `UsageStore` exists). Matches
/// `warmDelay`: the steady-state cadence while the user is active, which is when
/// interval-derived heuristics such as the persistent-CLI-session idle window matter most.
static let nominalIntervalForHeuristics = AdaptiveRefreshPolicyCore.nominalIntervalForHeuristics
func nextDelay(for input: Input) -> Decision {
AdaptiveRefreshPolicyCore().nextDelay(for: AdaptiveRefreshPolicyCore.Input(
now: input.now,
lastMenuOpenAt: input.lastMenuOpenAt,
lowPowerModeEnabled: input.lowPowerModeEnabled,
thermalPressure: Self.isConstrained(input.thermalState) ? .constrained : .nominal))
}
private static func isConstrained(_ state: ProcessInfo.ThermalState) -> Bool {
state == .serious || state == .critical
}
}
+154
View File
@@ -0,0 +1,154 @@
import CodexBarCore
import Foundation
import Observation
struct AgentSessionRemoteRefreshGate {
private(set) var generation = 0
private(set) var isInFlight = false
private(set) var isPending = false
mutating func settingsDidChange() {
self.generation += 1
self.isPending = self.isInFlight
}
mutating func begin() -> Int? {
guard !self.isInFlight else {
return nil
}
self.isInFlight = true
self.isPending = false
return self.generation
}
mutating func finish(generation: Int) -> (shouldPublish: Bool, shouldRetry: Bool) {
self.isInFlight = false
let outcome = (generation == self.generation, self.isPending)
self.isPending = false
return outcome
}
}
@MainActor
@Observable
final class AgentSessionsStore {
private let settings: SettingsStore
private let localScanner: LocalAgentSessionScanner
private let remoteFetcher: RemoteSessionFetcher
@ObservationIgnored private var localRefreshTask: Task<Void, Never>?
@ObservationIgnored private var remoteRefreshTask: Task<Void, Never>?
@ObservationIgnored private var localRefreshInFlight = false
@ObservationIgnored private var remoteRefreshGate = AgentSessionRemoteRefreshGate()
@ObservationIgnored var onUpdate: (@MainActor () -> Void)?
private(set) var localSessions: [AgentSession] = []
private(set) var remoteHosts: [RemoteSessionHostResult] = []
private(set) var lastUpdatedAt: Date?
init(
settings: SettingsStore,
localScanner: LocalAgentSessionScanner = LocalAgentSessionScanner(),
remoteFetcher: RemoteSessionFetcher = RemoteSessionFetcher())
{
self.settings = settings
self.localScanner = localScanner
self.remoteFetcher = remoteFetcher
}
var totalCount: Int {
self.localSessions.count + self.remoteHosts.reduce(0) { $0 + $1.sessions.count }
}
func start() {
guard self.localRefreshTask == nil, self.remoteRefreshTask == nil else { return }
self.localRefreshTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshLocal()
try? await Task.sleep(for: .seconds(30))
}
}
self.remoteRefreshTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshRemote()
try? await Task.sleep(for: .seconds(60))
}
}
}
func stop() {
self.localRefreshTask?.cancel()
self.remoteRefreshTask?.cancel()
self.localRefreshTask = nil
self.remoteRefreshTask = nil
}
func settingsDidChange() {
self.remoteRefreshGate.settingsDidChange()
guard self.settings.agentSessionsEnabled else {
self.localSessions = []
self.remoteHosts = []
self.onUpdate?()
return
}
guard !SettingsStore.isRunningTests else { return }
Task { [weak self] in
await self?.refreshLocal()
await self?.refreshRemote()
}
}
func refreshOnMenuOpen() {
guard self.settings.agentSessionsEnabled, !SettingsStore.isRunningTests else { return }
Task { [weak self] in
await self?.refreshLocal()
await self?.refreshRemote()
}
}
func focus(_ session: AgentSession, remoteHost: String?) {
if let remoteHost {
Task {
await self.remoteFetcher.focus(sessionID: session.id, host: remoteHost)
}
} else {
_ = SessionWindowFocuser.focus(session)
}
}
private func refreshLocal() async {
guard self.settings.agentSessionsEnabled, !self.localRefreshInFlight else { return }
self.localRefreshInFlight = true
let sessions = await self.localScanner.scan()
self.localRefreshInFlight = false
guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return }
self.localSessions = sessions
self.lastUpdatedAt = Date()
self.onUpdate?()
}
private func refreshRemote() async {
guard self.settings.agentSessionsEnabled else { return }
guard var generation = self.remoteRefreshGate.begin() else { return }
while self.settings.agentSessionsEnabled {
var hosts = self.manualHosts
await hosts.append(contentsOf: self.remoteFetcher.discoveredHosts())
let results = await self.remoteFetcher.fetch(hosts: hosts)
let outcome = self.remoteRefreshGate.finish(generation: generation)
guard !Task.isCancelled, self.settings.agentSessionsEnabled else { return }
if outcome.shouldPublish {
self.remoteHosts = results
self.lastUpdatedAt = Date()
self.onUpdate?()
}
guard outcome.shouldRetry, let nextGeneration = self.remoteRefreshGate.begin() else { return }
generation = nextGeneration
}
}
private var manualHosts: [String] {
self.settings.agentSessionsManualHosts
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
+115
View File
@@ -0,0 +1,115 @@
import CodexBarCore
import Foundation
@preconcurrency import UserNotifications
@MainActor
final class AppNotifications {
static let shared = AppNotifications()
private let centerProvider: @Sendable () -> UNUserNotificationCenter
private let logger = CodexBarLog.logger(LogCategories.notifications)
private var authorizationTask: Task<Bool, Never>?
init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) {
self.centerProvider = centerProvider
}
func requestAuthorizationOnStartup() {
guard !Self.isRunningUnderTests else { return }
_ = self.ensureAuthorizationTask()
}
func post(
idPrefix: String,
title: String,
body: String,
badge: NSNumber? = nil,
soundEnabled: Bool = true)
{
guard !Self.isRunningUnderTests else { return }
let center = self.centerProvider()
let logger = self.logger
Task { @MainActor in
let granted = await self.ensureAuthorized()
guard granted else {
logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix])
return
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = soundEnabled ? .default : nil
content.badge = badge
let request = UNNotificationRequest(
identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)",
content: content,
trigger: nil)
logger.info("posting", metadata: ["prefix": idPrefix])
do {
try await center.add(request)
} catch {
let errorText = String(describing: error)
logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText])
}
}
}
// MARK: - Private
private func ensureAuthorizationTask() -> Task<Bool, Never> {
if let authorizationTask { return authorizationTask }
let task = Task { @MainActor in
await self.requestAuthorization()
}
self.authorizationTask = task
return task
}
private func ensureAuthorized() async -> Bool {
await self.ensureAuthorizationTask().value
}
private func requestAuthorization() async -> Bool {
if let existing = await self.notificationAuthorizationStatus() {
if existing == .authorized || existing == .provisional {
return true
}
if existing == .denied {
return false
}
}
let center = self.centerProvider()
return await withCheckedContinuation { continuation in
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
continuation.resume(returning: granted)
}
}
}
private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? {
let center = self.centerProvider()
return await withCheckedContinuation { continuation in
center.getNotificationSettings { settings in
continuation.resume(returning: settings.authorizationStatus)
}
}
}
private static var isRunningUnderTests: Bool {
// Swift Testing doesn't always set XCTest env vars, and removing XCTest imports from
// the test target can make NSClassFromString("XCTestCase") return nil. If we're not
// running inside an app bundle, treat it as "tests/headless" to avoid crashes when
// accessing UNUserNotificationCenter.
if Bundle.main.bundleURL.pathExtension != "app" { return true }
let env = ProcessInfo.processInfo.environment
if env["XCTestConfigurationFilePath"] != nil { return true }
if env["TESTING_LIBRARY_VERSION"] != nil { return true }
if env["SWIFT_TESTING"] != nil { return true }
return NSClassFromString("XCTestCase") != nil
}
}
@@ -0,0 +1,11 @@
import Foundation
enum ChartBarHoverSelection {
static func accepts(distanceFromBarCenter: CGFloat, barHalfWidth: CGFloat, selectableCount: Int) -> Bool {
selectableCount <= 1 || distanceFromBarCenter <= barHalfWidth
}
static func nextCalendarDay(after date: Date, calendar: Calendar = .current) -> Date {
calendar.date(byAdding: .day, value: 1, to: date) ?? date.addingTimeInterval(86400)
}
}
+123
View File
@@ -0,0 +1,123 @@
import CodexBarCore
import Darwin
import Foundation
struct ClaudeLoginRunner {
static let loginArguments = ["auth", "login", "--claudeai"]
private static let successMarkers = ["Successfully logged in", "Login successful", "Logged in successfully"]
enum Phase {
case requesting
case waitingBrowser
}
struct Result {
enum Outcome {
case success
case timedOut
case failed(status: Int32)
case missingBinary
case launchFailed(String)
}
let outcome: Outcome
let output: String
let authLink: String?
}
static func run(
timeout: TimeInterval = 120,
binary: String = "claude",
environment: [String: String]? = nil,
onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result
{
await Task(priority: .userInitiated) {
onPhaseChange(.requesting)
do {
let runResult = try self.runPTY(
timeout: timeout,
binary: binary,
environment: environment,
onPhaseChange: onPhaseChange)
let link = self.firstLink(in: runResult.output)
switch runResult.completion {
case .processExited(status: 0):
return Result(outcome: .success, output: runResult.output, authLink: link)
case let .processExited(status):
return Result(outcome: .failed(status: status), output: runResult.output, authLink: link)
case .outputCondition where self.successMarkers.contains(where: runResult.output.contains):
return Result(outcome: .success, output: runResult.output, authLink: link)
case .outputCondition, .idleTimeout, .deadlineExceeded:
return Result(outcome: .timedOut, output: runResult.output, authLink: link)
}
} catch LoginError.binaryNotFound {
return Result(outcome: .missingBinary, output: "", authLink: nil)
} catch let LoginError.timedOut(text) {
return Result(outcome: .timedOut, output: text, authLink: self.firstLink(in: text))
} catch {
return Result(outcome: .launchFailed(error.localizedDescription), output: "", authLink: nil)
}
}.value
}
// MARK: - PTY runner
private enum LoginError: Error {
case binaryNotFound
case timedOut(text: String)
case launchFailed(String)
}
private struct PTYRunResult {
let output: String
let completion: TTYCommandRunner.Result.Completion
}
private static func runPTY(
timeout: TimeInterval,
binary: String,
environment: [String: String]?,
onPhaseChange: @escaping @Sendable (Phase) -> Void) throws -> PTYRunResult
{
let runner = TTYCommandRunner()
var options = TTYCommandRunner.Options(rows: 50, cols: 160, timeout: timeout)
options.extraArgs = self.loginArguments
options.baseEnvironment = environment
options.stopOnURL = false // keep running until CLI confirms
options.stopOnSubstrings = self.successMarkers
options.sendOnSubstrings = ["press ENTER to open in browser": "\r"]
options.settleAfterStop = 0.35
options.returnOnEmptyProcessExit = true
do {
let result = try runner.run(
binary: binary,
send: "",
options: options,
onURLDetected: { onPhaseChange(.waitingBrowser) })
return PTYRunResult(output: result.text, completion: result.completion)
} catch TTYCommandRunner.Error.binaryNotFound {
throw LoginError.binaryNotFound
} catch TTYCommandRunner.Error.timedOut {
throw LoginError.timedOut(text: "")
} catch let TTYCommandRunner.Error.launchFailed(msg) {
throw LoginError.launchFailed(msg)
} catch {
throw LoginError.launchFailed(error.localizedDescription)
}
}
private static func firstLink(in text: String) -> String? {
let pattern = #"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: nsRange),
let range = Range(match.range, in: text) else { return nil }
var url = String(text[range])
while let last = url.unicodeScalars.last,
CharacterSet(charactersIn: ".,;:)]}>\"'").contains(last)
{
url.unicodeScalars.removeLast()
}
return url
}
}
+77
View File
@@ -0,0 +1,77 @@
import AppKit
import SwiftUI
@MainActor
enum MenuPasteboardCopy {
typealias DeferredAction = @MainActor @Sendable () -> Void
typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void
typealias Writer = @MainActor @Sendable (String) -> Void
static func perform(
_ text: String,
scheduler: Scheduler = Self.schedule,
writer: @escaping Writer = Self.write,
completion: @escaping DeferredAction = {})
{
scheduler {
writer(text)
completion()
}
}
private static func schedule(_ action: @escaping DeferredAction) {
DispatchQueue.main.async(execute: action)
}
private static func write(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
}
struct ClickToCopyOverlay: NSViewRepresentable {
let copyText: String
func makeNSView(context: Context) -> ClickToCopyView {
ClickToCopyView(copyText: self.copyText)
}
func updateNSView(_ nsView: ClickToCopyView, context: Context) {
// Guard against no-op writes to avoid AppKit view invalidation on every
// parent card SwiftUI diff (each MenuCardView body re-eval runs through
// .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even
// when copyText is unchanged).
guard nsView.copyText != self.copyText else { return }
nsView.copyText = self.copyText
}
}
final class ClickToCopyView: NSView {
var copyText: String
private let copyAction: (String) -> Void
init(
copyText: String,
copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) })
{
self.copyText = copyText
self.copyAction = copyAction
super.init(frame: .zero)
self.wantsLayer = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}
override func mouseDown(with event: NSEvent) {
_ = event
self.copyAction(self.copyText)
}
}
@@ -0,0 +1,180 @@
import CodexBarCore
import Foundation
enum CodexAccountHealth: Equatable {
case ok
case needsReauth
case workspaceDeactivated
case missingAuth
case unavailable
var label: String? {
switch self {
case .ok:
nil
case .needsReauth:
"Needs re-auth"
case .workspaceDeactivated:
"Workspace deactivated"
case .missingAuth:
"Missing auth"
case .unavailable:
"Unavailable"
}
}
static func status(for account: CodexVisibleAccount, error: String?) -> CodexAccountHealth {
if let error {
return self.status(forError: error)
}
if account.authenticationHealthLabel != nil {
return .missingAuth
}
return .ok
}
static func status(forError error: String) -> CodexAccountHealth {
let normalized = error.lowercased()
if normalized.contains("deactivated") {
return .workspaceDeactivated
}
if normalized.contains("expired") ||
normalized.contains("revoked") ||
normalized.contains("unauthorized") ||
normalized.contains("401")
{
return .needsReauth
}
if normalized.contains("missing"), normalized.contains("auth") {
return .missingAuth
}
return .unavailable
}
}
enum CodexAccountPresentationOrdering {
static func orderedAccounts(
_ accounts: [CodexVisibleAccount],
snapshots: [CodexAccountUsageSnapshot],
activeVisibleAccountID: String?)
-> [CodexVisibleAccount]
{
guard accounts.count > 1 else { return accounts }
let snapshotByID = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.id, $0) })
let rankedAccounts = accounts.enumerated().map { index, account in
RankedAccount(
account: account,
rank: Rank(
account: account,
snapshot: snapshotByID[account.id],
activeVisibleAccountID: activeVisibleAccountID,
originalIndex: index))
}
let grouped = Dictionary(grouping: rankedAccounts, by: { Self.workspaceSortKey(for: $0.account) })
return grouped.values.sorted { lhs, rhs in
(lhs.map(\.rank).min() ?? .last) < (rhs.map(\.rank).min() ?? .last)
}.flatMap { group in
group.sorted { lhs, rhs in lhs.rank < rhs.rank }.map(\.account)
}
}
private struct RankedAccount {
let account: CodexVisibleAccount
let rank: Rank
}
private struct Rank: Comparable {
static let last = Rank(bucket: Int.max, availabilityScore: -.greatestFiniteMagnitude, originalIndex: Int.max)
let bucket: Int
let availabilityScore: Double
let displaySort: String
let originalIndex: Int
private init(bucket: Int, availabilityScore: Double, originalIndex: Int) {
self.bucket = bucket
self.availabilityScore = availabilityScore
self.displaySort = ""
self.originalIndex = originalIndex
}
init(
account: CodexVisibleAccount,
snapshot: CodexAccountUsageSnapshot?,
activeVisibleAccountID: String?,
originalIndex: Int)
{
self.originalIndex = originalIndex
self.displaySort = account.menuDisplayName.lowercased()
if account.id == activeVisibleAccountID {
self.bucket = 0
} else {
let health = CodexAccountHealth.status(for: account, error: snapshot?.error)
if health != .ok {
self.bucket = health == .missingAuth ? 4 : 3
} else if let availability = Self.availability(snapshot?.snapshot), availability <= 0 {
self.bucket = 2
} else {
self.bucket = 1
}
}
self.availabilityScore = Self.availability(snapshot?.snapshot) ?? -1
}
static func < (lhs: Rank, rhs: Rank) -> Bool {
if lhs.bucket != rhs.bucket { return lhs.bucket < rhs.bucket }
if lhs.availabilityScore != rhs.availabilityScore {
return lhs.availabilityScore > rhs.availabilityScore
}
if lhs.displaySort != rhs.displaySort { return lhs.displaySort < rhs.displaySort }
return lhs.originalIndex < rhs.originalIndex
}
private static func availability(_ snapshot: UsageSnapshot?) -> Double? {
guard let snapshot else { return nil }
let session = snapshot.primary?.remainingPercent
let weekly = snapshot.secondary?.remainingPercent
return switch (session, weekly) {
case let (.some(session), .some(weekly)):
min(session, weekly)
case let (.some(session), .none):
session
case let (.none, .some(weekly)):
weekly
case (.none, .none):
nil
}
}
}
private static func workspaceSortKey(for account: CodexVisibleAccount) -> String {
if let workspaceAccountID = account.workspaceAccountID, !workspaceAccountID.isEmpty {
return workspaceAccountID.lowercased()
}
return account.menuWorkspaceLabel?.lowercased() ?? "personal"
}
}
struct CodexAccountWorkspaceSection: Equatable {
let title: String
let accounts: [CodexVisibleAccount]
}
extension [CodexVisibleAccount] {
func codexWorkspaceSections() -> [CodexAccountWorkspaceSection] {
guard !self.isEmpty else { return [] }
var sections: [CodexAccountWorkspaceSection] = []
for account in self {
let title = account.menuWorkspaceLabel ?? "Personal"
if let index = sections.firstIndex(where: { $0.title == title }) {
var accounts = sections[index].accounts
accounts.append(account)
sections[index] = CodexAccountWorkspaceSection(title: title, accounts: accounts)
} else {
sections.append(CodexAccountWorkspaceSection(title: title, accounts: [account]))
}
}
return sections
}
}
@@ -0,0 +1,114 @@
import Foundation
import Observation
struct CodexSystemAccountPromotionUserFacingError: Error, Equatable {
let title: String
let message: String
}
@MainActor
@Observable
final class CodexAccountPromotionCoordinator {
let service: CodexAccountPromotionService
weak var managedAccountCoordinator: ManagedCodexAccountCoordinator?
private(set) var isAuthenticatingLiveAccount = false
private(set) var isPromotingSystemAccount = false
private(set) var userFacingError: CodexSystemAccountPromotionUserFacingError?
init(
service: CodexAccountPromotionService,
managedAccountCoordinator: ManagedCodexAccountCoordinator? = nil)
{
self.service = service
self.managedAccountCoordinator = managedAccountCoordinator
}
convenience init(
settingsStore: SettingsStore,
usageStore: UsageStore,
managedAccountCoordinator: ManagedCodexAccountCoordinator? = nil)
{
self.init(
service: CodexAccountPromotionService(settingsStore: settingsStore, usageStore: usageStore),
managedAccountCoordinator: managedAccountCoordinator)
}
func promote(managedAccountID: UUID)
async -> Result<CodexAccountPromotionResult, CodexSystemAccountPromotionUserFacingError>
{
self.userFacingError = nil
guard !self.isInteractionBlocked() else {
let error = Self.interactionBlockedError()
self.userFacingError = error
return .failure(error)
}
self.isPromotingSystemAccount = true
defer { self.isPromotingSystemAccount = false }
do {
let result = try await self.service.promoteManagedAccount(id: managedAccountID)
return .success(result)
} catch {
let mapped = Self.mapUserFacingError(error)
self.userFacingError = mapped
return .failure(mapped)
}
}
func clearError() {
self.userFacingError = nil
}
func setLiveReauthenticationInProgress(_ isInProgress: Bool) {
self.isAuthenticatingLiveAccount = isInProgress
}
func isInteractionBlocked() -> Bool {
self.isPromotingSystemAccount ||
self.isAuthenticatingLiveAccount ||
self.managedAccountCoordinator?.hasConflictingManagedAccountOperationInFlight == true
}
private static func interactionBlockedError() -> CodexSystemAccountPromotionUserFacingError {
CodexSystemAccountPromotionUserFacingError(
title: L("Could not switch system account"),
message: L("Finish the current managed account change before switching the system account."))
}
static func mapUserFacingError(_ error: Error) -> CodexSystemAccountPromotionUserFacingError {
let title = L("Could not switch system account")
if let error = error as? CodexAccountPromotionError {
let message = switch error {
case .targetManagedAccountNotFound:
L("That account is no longer available in CodexBar. Refresh the account list and try again.")
case .targetManagedAccountAuthMissing:
L("CodexBar could not find saved auth for that account. Re-authenticate it and try again.")
case .targetManagedAccountAuthUnreadable:
L("CodexBar could not read saved auth for that account. Re-authenticate it and try again.")
case .liveAccountUnreadable:
L("CodexBar could not read the current system account on this Mac.")
case .liveAccountMissingIdentityForPreservation:
L("CodexBar could not safely preserve the current system account before switching.")
case .liveAccountAPIKeyOnlyUnsupported:
L("CodexBar can't replace a system account that is signed in with an API key only setup.")
case .displacedLiveManagedAccountConflict:
L(
"CodexBar found another managed account that already uses the current system account. " +
"Resolve the duplicate account before switching.")
case .displacedLiveImportFailed:
L("CodexBar could not save the current system account before switching.")
case .managedStoreCommitFailed:
L("CodexBar could not update managed account storage.")
case .liveAuthSwapFailed:
L("CodexBar could not replace the live Codex auth on this Mac.")
}
return CodexSystemAccountPromotionUserFacingError(title: title, message: message)
}
return CodexSystemAccountPromotionUserFacingError(title: title, message: error.localizedDescription)
}
}

Some files were not shown because too many files have changed in this diff Show More