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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-observer",
"version": "1.2.0",
"version": "1.2.1",
"description": "Measure agent work across Codex, Claude Code, OpenCode and Grok Build from native records, and compare behavior by model and AgentsMD version.",
"author": {
"name": "toolboxmd",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-observer",
"version": "1.2.0",
"version": "1.2.1",
"description": "Measure agent work across Codex, Claude Code, OpenCode and Grok Build from native records, and compare behavior by model and AgentsMD version.",
"author": {
"name": "toolboxmd",
Expand Down
2 changes: 1 addition & 1 deletion .grok-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-observer",
"version": "1.2.0",
"version": "1.2.1",
"description": "Measure agent work across Codex, Claude Code, OpenCode and Grok Build from native records, and compare behavior by model and AgentsMD version.",
"author": {
"name": "toolboxmd",
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [1.2.1] - 2026-10-14

### Changed

- publish reads plain JSON from gh when color is forced, and names the command and stderr when output is empty or not JSON (#88)

## [1.2.0] - 2026-10-14

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.0
1.2.1
26 changes: 22 additions & 4 deletions agent_observer/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
from __future__ import annotations

import json
import os
import re
import shlex
import subprocess
from datetime import datetime, timezone

Expand Down Expand Up @@ -568,12 +570,28 @@ def render(summary: dict, target: str | None = None) -> str:


def _gh(args: list, payload: dict | None = None) -> dict | list:
proc = subprocess.run(["gh", "api", *args] + (["--input", "-"] if payload else []),
input=json.dumps(payload) if payload else None,
capture_output=True, text=True, check=False)
"""Run `gh api` and parse one JSON document.

CLICOLOR_FORCE makes gh colorize JSON even on a pipe and even when
NO_COLOR is set. That stdout is not JSON. This call turns the force
off. Empty or non-JSON stdout names the command and its stderr.
"""
cmd = ["gh", "api", *args] + (["--input", "-"] if payload else [])
env = os.environ.copy()
env["CLICOLOR_FORCE"] = "0"
proc = subprocess.run(cmd, input=json.dumps(payload) if payload else None,
capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or f"gh api failed: {args}")
return json.loads(proc.stdout) if proc.stdout.strip() else {}
stdout = proc.stdout or ""
stderr = (proc.stderr or "").strip() or "(no stderr)"
command = shlex.join(cmd)
if not stdout.strip():
raise RuntimeError(f"{command} returned empty output: {stderr}")
try:
return json.loads(stdout)
except json.JSONDecodeError:
raise RuntimeError(f"{command} returned non-JSON output: {stderr}") from None


def _authenticated_login() -> str:
Expand Down
84 changes: 84 additions & 0 deletions tests/test_publish_gh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""publish's gh call must return JSON, or name the command and its stderr.

A delegated worker sets CLICOLOR_FORCE. gh then colorizes `gh api` JSON
even on a pipe, and json.loads reports Expecting value. These tests use a
fake gh on PATH and never touch GitHub or the ledger.
"""

import os
import stat
import tempfile
import unittest

from agent_observer import publish


_FAKE = """#!/usr/bin/env python3
import os, sys
mode = os.environ.get("AO_GH_MODE", "")
if mode == "empty":
sys.stderr.write("keyring locked\\n")
raise SystemExit(0)
if mode == "text":
sys.stdout.write("not-json\\n")
sys.stderr.write("color forced\\n")
raise SystemExit(0)
if mode == "color":
force = os.environ.get("CLICOLOR_FORCE", "")
if force and force != "0":
sys.stdout.write("\\x1b[1;37m{\\x1b[m\\n")
else:
sys.stdout.write('{"login":"octocat"}\\n')
raise SystemExit(0)
sys.stderr.write("unknown mode\\n")
raise SystemExit(2)
"""


class PublishGhTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
bin_dir = os.path.join(self.tmp.name, "bin")
os.makedirs(bin_dir)
path = os.path.join(bin_dir, "gh")
with open(path, "w", encoding="utf-8") as fh:
fh.write(_FAKE)
os.chmod(path, stat.S_IRWXU)
self._saved = {
key: os.environ.get(key)
for key in ("PATH", "CLICOLOR_FORCE", "AO_GH_MODE")}
os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "")
os.environ["CLICOLOR_FORCE"] = "1"

def tearDown(self):
for key, value in self._saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value

def _error(self, mode):
os.environ["AO_GH_MODE"] = mode
with self.assertRaises(RuntimeError) as caught:
publish._gh(["user"])
message = str(caught.exception)
self.assertIn("gh api user", message)
self.assertNotIn("Expecting value", message)
return message

def test_colored_json_parses_when_color_is_forced(self):
os.environ["AO_GH_MODE"] = "color"
self.assertEqual(publish._gh(["user"]), {"login": "octocat"})

def test_empty_stdout_names_the_command_and_stderr(self):
message = self._error("empty")
self.assertIn("keyring locked", message)

def test_non_json_stdout_names_the_command_and_stderr(self):
message = self._error("text")
self.assertIn("color forced", message)


if __name__ == "__main__":
unittest.main()
Loading