Skip to content

Commit 4ebdcd8

Browse files
Panopticon Agentclaude
andcommitted
feat(setup-repo): repo-aware summaries + GH_TOKEN setup, DRY token storage
Improve the setup-repo shell flow's operator UX: * Open with two bulleted lists — what we know about the repo (name + local-checkout vs GitHub-remote, classified from the repo's git URL) and what its setup entails (Claude credential always; GH_TOKEN for GitHub repos). * Add a GH_TOKEN step (GitHub repos only): interactive `gh auth login`, then capture via `gh auth token` and write it into the env-file — mirroring the Claude-token step, with a graceful fallback when `gh` isn't on the host. * DRY the env-file write: generalize `store_oauth_token` into `store_env_token <VAR> <token> <file>`, the one implementation both tokens share (comment out the active line, drop the placeholder stub, append). * Turn the closing summary into a bullet-per-step list. * Fold in PR #309's hint wording (detach reassurance + actionable drop). To classify local vs GitHub the script needs the repo's git URL, so the ShellRunner now exports PANOPTICON_REPO_NAME / PANOPTICON_REPO_GIT_URL (passed through by the spawner from the repo record). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 11701ae commit 4ebdcd8

7 files changed

Lines changed: 319 additions & 51 deletions

File tree

src/panopticon/sessionservice/shell_runner.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ def spawn(
6969
env_file: str | None = None,
7070
script: str = "",
7171
workdir: str | None = None,
72+
repo_name: str | None = None,
73+
git_url: str | None = None,
7274
progress: Callable[[LifecyclePhase], None] | None = None,
7375
) -> str:
7476
"""Run ``script`` for ``task_id`` in a fresh host tmux session; return the session name.
@@ -79,7 +81,9 @@ def spawn(
7981
workflow's own override. ``workdir`` falls back to the operator's home only when unset (direct
8082
use). The pane runs ``sh -c`` with ``PANOPTICON_SERVICE_URL`` and ``PANOPTICON_TASK_ID``
8183
exported — so the script can drive its own lifecycle over REST (e.g. advance to COMPLETE on
82-
success) — and the repo's ``env_file`` secrets sourced first when given. ``env_file`` is a
84+
success), ``PANOPTICON_REPO_NAME`` / ``PANOPTICON_REPO_GIT_URL`` exported when given (so a
85+
shell workflow can tailor its flow to the repo — e.g. classify local vs GitHub) — and the
86+
repo's ``env_file`` secrets sourced first when given. ``env_file`` is a
8387
**name relative to this runner's secrets dir** (ADR 0007), resolved host-locally (like
8488
``LocalRunner``) so a remote runner uses its own host's secrets. The panopticon shell lib
8589
(``panopticon_advance``/``_drop``/…) is loaded into the shell so the script can drive its task
@@ -115,6 +119,13 @@ def _report(phase: LifecyclePhase) -> None:
115119
# Load the panopticon shell lib so the script can drive its task (panopticon_advance, …).
116120
_TASK_LIB,
117121
]
122+
# Expose what we know about the repo so a shell workflow can tailor its flow — e.g. setup-repo
123+
# classifies local vs GitHub from the git URL to decide whether a GH_TOKEN is needed. Omitted
124+
# when unset (direct use), so the script sees the vars unset.
125+
if repo_name:
126+
lines.append(f"export PANOPTICON_REPO_NAME={shlex.quote(repo_name)}")
127+
if git_url:
128+
lines.append(f"export PANOPTICON_REPO_GIT_URL={shlex.quote(git_url)}")
118129
# Resolve the env_file *name* to an absolute path under this host's secrets dir, expose the
119130
# path (so a script can tell the operator where to add their own credential), then source it
120131
# if it exists (a not-yet-created secrets file is fine — the script sees the vars unset).

src/panopticon/sessionservice/spawner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ def _spawn_shell(self, task: JsonObj, repo: JsonObj) -> str:
260260
env_file=repo.get("env_file"), # per-repo secrets, sourced into the shell (ADR 0007)
261261
script=spec["script"],
262262
workdir=workdir,
263+
repo_name=repo.get(
264+
"name"
265+
), # so the script can name the repo + classify local vs GitHub
266+
git_url=repo.get("git_url"),
263267
progress=lambda phase: self._report(task_id, phase), # STARTING then AWAITING
264268
)
265269

src/panopticon/workflows/setup_repo.sh

Lines changed: 141 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,80 @@
1-
# Collect a Claude auth token (`claude setup-token`) for the repo's env-file. Run by the session
2-
# service in a host tmux session (no container); ShellRunner sources the repo's env-file first, so an
3-
# already-configured credential shows up as an env var, and exports PANOPTICON_ENV_FILE (its path).
1+
# Guide the operator through a repo's host-side setup: mint a Claude auth token (`claude
2+
# setup-token`) and — for a GitHub repo — a `GH_TOKEN` (`gh auth login`), writing each into the
3+
# repo's env-file. Run by the session service in a host tmux session (no container); ShellRunner
4+
# sources the repo's env-file first (so an already-configured credential shows up as an env var) and
5+
# exports PANOPTICON_ENV_FILE (its path), plus PANOPTICON_REPO_NAME / PANOPTICON_REPO_GIT_URL.
46
#
5-
# Whatever route the operator takes, the script converges on a summary + a prompt to press Enter,
6-
# which completes the task and returns them to the dashboard.
7+
# Whatever route the operator takes, the script converges on a bulleted summary + a prompt to press
8+
# Enter, which completes the task and returns them to the dashboard.
79

810
env_file="${PANOPTICON_ENV_FILE:-the repo's env-file}"
11+
repo_name="${PANOPTICON_REPO_NAME:-this repo}"
12+
repo_url="${PANOPTICON_REPO_GIT_URL:-}"
13+
repo_label=$(repo_source_label "$repo_url")
914
1015
# How to get back to the dashboard: detach from this tmux session. Detect the prefix + detach key
1116
# from the running server (the operator may have rebound them), falling back to the tmux defaults.
1217
prefix=$(tmux show-options -gv prefix 2>/dev/null)
1318
[ -n "$prefix" ] || prefix="C-b"
1419
detach=$(tmux list-keys -T prefix 2>/dev/null | awk '$NF == "detach-client" { print $(NF - 1); exit }')
1520
[ -n "$detach" ] || detach="d"
16-
dashboard_hint="To return to the dashboard without finishing, detach: press $prefix then $detach (the task stays running)."
21+
dashboard_hint="To return to the dashboard without finishing, detach: press $prefix then $detach (you can resume this task any time from the dashboard)."
1722
1823
# Show how to get back to the dashboard up front, before anything else.
1924
echo "$dashboard_hint"
2025
echo
2126
27+
# Work out what's already configured and what setting this repo up entails. The Claude credential is
28+
# always needed (the agent runs `claude` regardless); a GH_TOKEN is only needed for a GitHub remote
29+
# (a local checkout has nothing to push).
30+
claude_configured=0
31+
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; then
32+
claude_configured=1
33+
fi
34+
gh_needed=0
35+
gh_configured=0
36+
if repo_is_github "$repo_url"; then
37+
gh_needed=1
38+
[ -n "${GH_TOKEN:-}" ] && gh_configured=1
39+
fi
40+
41+
# What we know about the repo, and what its setup entails — two bulleted lists up front.
42+
echo "This repo:"
43+
echo " • Name: $repo_name"
44+
echo " • Source: $repo_label"
45+
echo
46+
echo "To set up:"
47+
if [ "$claude_configured" -eq 1 ]; then
48+
echo " • Claude credential — already configured"
49+
else
50+
echo " • Claude credential — needed"
51+
fi
52+
if [ "$gh_needed" -eq 1 ]; then
53+
if [ "$gh_configured" -eq 1 ]; then
54+
echo " • GH_TOKEN — already configured"
55+
else
56+
echo " • GH_TOKEN — needed (GitHub repo)"
57+
fi
58+
else
59+
echo " • GH_TOKEN — not needed (not a GitHub repo)"
60+
fi
61+
echo
62+
63+
# The closing summary is a bullet per step; each step appends its outcome here.
2264
summary=""
65+
add_summary() {
66+
if [ -z "$summary" ]; then
67+
summary="$1"
68+
else
69+
summary="$summary
70+
$1"
71+
fi
72+
}
2373
24-
# Mint a token and record the outcome in $summary. On success, capture the minted token and write it
25-
# straight into the repo's env-file (commenting out any previous one — see store_oauth_token); fall
74+
# Mint a Claude token and record the outcome. On success, capture the minted token and write it
75+
# straight into the repo's env-file (commenting out any previous one — see store_env_token); fall
2676
# back to on-screen copy instructions when it can't be captured or there's no env-file to write to.
27-
# extract_oauth_token / store_oauth_token come from setup_repo_lib.sh (prepended by shell_script()).
77+
# extract_oauth_token / store_env_token come from setup_repo_lib.sh (prepended by shell_script()).
2878
collect_token() {
2979
echo
3080
echo "Running 'claude setup-token' — follow the prompts to mint a token."
@@ -49,36 +99,73 @@ collect_token() {
4999
fi
50100
51101
if [ "$_ct_ok" -eq 0 ]; then
52-
summary="'claude setup-token' failed or was cancelled — no token was collected."
102+
add_summary "Claude credential: 'claude setup-token' failed or was cancelled — nothing collected."
53103
elif [ -n "$_ct_token" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \
54-
&& store_oauth_token "$_ct_token" "$PANOPTICON_ENV_FILE"; then
104+
&& store_env_token CLAUDE_CODE_OAUTH_TOKEN "$_ct_token" "$PANOPTICON_ENV_FILE"; then
55105
echo
56106
echo "Wrote the new token to $env_file as CLAUDE_CODE_OAUTH_TOKEN (any previous one was commented out)."
57-
summary="Minted a new token and wrote it to $env_file (any previous token was commented out)."
107+
add_summary "Claude credential: minted a new token and wrote it to $env_file (any previous one was commented out)."
58108
else
59109
# Minted, but we couldn't capture/extract it or there's no env-file configured — guide the copy.
60110
echo
61111
echo "Token minted. Copy the token shown above into $env_file as:"
62112
echo " CLAUDE_CODE_OAUTH_TOKEN=<token>"
63-
summary="Minted a new token — copy it into $env_file as CLAUDE_CODE_OAUTH_TOKEN."
113+
add_summary "Claude credential: minted a new token — copy it into $env_file as CLAUDE_CODE_OAUTH_TOKEN."
64114
fi
65115
}
66116
67-
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; then
117+
# Authenticate to GitHub and record the outcome — mirrors collect_token. Run the interactive
118+
# `gh auth login`, then capture the resulting token with `gh auth token` and write it into the repo's
119+
# env-file via the shared store_env_token. Falls back to on-screen guidance when `gh` isn't installed
120+
# or the token can't be captured/written.
121+
collect_gh_token() {
122+
if ! command -v gh >/dev/null 2>&1; then
123+
echo
124+
echo "The 'gh' CLI isn't installed on this host, so I can't run 'gh auth login'."
125+
echo "Add a token to $env_file yourself instead:"
126+
echo " GH_TOKEN=<a GitHub token, e.g. from 'gh auth token' or a personal access token>"
127+
add_summary "GH_TOKEN: 'gh' not installed — add GH_TOKEN to $env_file yourself."
128+
return
129+
fi
130+
echo
131+
echo "Running 'gh auth login' — follow the prompts to authenticate to GitHub."
132+
echo
133+
_gt_ok=1
134+
gh auth login || _gt_ok=0
135+
_gt_token=""
136+
[ "$_gt_ok" -eq 1 ] && _gt_token=$(gh auth token 2>/dev/null)
137+
138+
if [ "$_gt_ok" -eq 0 ]; then
139+
add_summary "GH_TOKEN: 'gh auth login' failed or was cancelled — nothing collected."
140+
elif [ -n "$_gt_token" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \
141+
&& store_env_token GH_TOKEN "$_gt_token" "$PANOPTICON_ENV_FILE"; then
142+
echo
143+
echo "Wrote the token to $env_file as GH_TOKEN (any previous one was commented out)."
144+
add_summary "GH_TOKEN: authenticated with gh and wrote the token to $env_file (any previous one was commented out)."
145+
else
146+
# Authenticated, but we couldn't read the token or there's no env-file — guide the copy.
147+
echo
148+
echo "Authenticated, but couldn't capture the token. Add it to $env_file yourself:"
149+
echo " GH_TOKEN=<token from 'gh auth token'>"
150+
add_summary "GH_TOKEN: authenticated — copy 'gh auth token' into $env_file as GH_TOKEN."
151+
fi
152+
}
153+
154+
# --- Claude credential -------------------------------------------------------------------------
155+
if [ "$claude_configured" -eq 1 ]; then
68156
echo "A Claude credential is already configured in $env_file."
69-
echo "To keep using it, drop this task instead (press 'x' in the dashboard)."
70157
echo
71158
printf 'Collect a new token anyway? [y/N] '
72159
read answer
73160
case "$answer" in
74161
[Yy]*) collect_token ;;
75-
*) summary="Kept the existing credential in $env_file — nothing collected." ;;
162+
*) add_summary "Claude credential: kept the existing one in $env_file — nothing collected." ;;
76163
esac
77164
else
78165
echo "No Claude credential found in $env_file."
79166
echo "About to collect one with 'claude setup-token'."
80167
echo
81-
echo "Prefer to use your own? Drop this task (press 'x' in the dashboard) and add one of"
168+
echo "Prefer to use your own? Press $prefix then $detach to go to the dashboard, drop this task using 'x' and add one of"
82169
echo "these to $env_file yourself:"
83170
echo " CLAUDE_CODE_OAUTH_TOKEN=<token from 'claude setup-token'>"
84171
echo " ANTHROPIC_API_KEY=<your Anthropic API key>"
@@ -88,11 +175,44 @@ else
88175
collect_token
89176
fi
90177
91-
# Every route converges here: summarize what happened, then complete the task on Enter (which ends
92-
# the session and returns the operator to the dashboard; detaching instead — see the hint above —
93-
# leaves it running).
178+
# --- GH_TOKEN (GitHub repos only) --------------------------------------------------------------
179+
if [ "$gh_needed" -eq 1 ]; then
180+
echo
181+
if [ "$gh_configured" -eq 1 ]; then
182+
echo "A GH_TOKEN is already configured in $env_file."
183+
echo
184+
printf 'Re-authenticate to GitHub anyway? [y/N] '
185+
read answer
186+
case "$answer" in
187+
[Yy]*) collect_gh_token ;;
188+
*) add_summary "GH_TOKEN: kept the existing one in $env_file — nothing collected." ;;
189+
esac
190+
else
191+
echo "No GH_TOKEN found in $env_file — a GitHub repo needs one to push and open PRs."
192+
echo
193+
echo "Prefer to use your own? Press $prefix then $detach to go to the dashboard, drop this task using 'x' and add"
194+
echo " GH_TOKEN=<a GitHub token>"
195+
echo "to $env_file yourself."
196+
echo
197+
printf 'Authenticate to GitHub with gh now? [Y/n] '
198+
read answer
199+
case "$answer" in
200+
[Nn]*) add_summary "GH_TOKEN: skipped — add GH_TOKEN to $env_file yourself." ;;
201+
*) collect_gh_token ;;
202+
esac
203+
fi
204+
fi
205+
206+
# Every route converges here: summarize what happened (a bullet per step), then complete the task on
207+
# Enter (which ends the session and returns the operator to the dashboard; detaching instead — see
208+
# the hint above — leaves it running).
94209
echo
95-
echo "Summary: $summary"
210+
echo "Summary:"
211+
if [ -n "$summary" ]; then
212+
echo "$summary"
213+
else
214+
echo " • Nothing to do — everything was already configured."
215+
fi
96216
echo
97217
printf 'Press Enter to complete this task and return to the dashboard. '
98218
read _

src/panopticon/workflows/setup_repo_lib.sh

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,65 @@ extract_oauth_token() {
1010
grep -oaE 'sk-ant-oat01-[A-Za-z0-9_-]+' "$1" 2>/dev/null | tail -n 1
1111
}
1212

13-
# Store a freshly minted token $1 into env-file $2, preserving history:
14-
# * comment out any existing *active* `CLAUDE_CODE_OAUTH_TOKEN=…` line (kept as a record, not lost),
15-
# * drop any placeholder *comment* stub (`# CLAUDE_CODE_OAUTH_TOKEN =`, or a `<…>` placeholder),
13+
# Store a freshly minted value $2 for env var $1 into env-file $3, preserving history:
14+
# * comment out any existing *active* `<VAR>=…` line (kept as a record, not lost),
15+
# * drop any placeholder *comment* stub (`# <VAR> =`, or a `<…>` placeholder),
1616
# * append the new active line.
17-
# Other lines (ANTHROPIC_API_KEY, GH_TOKEN, blanks, unrelated comments) are left untouched. Atomic
18-
# replace, private perms (the file holds a live credential). Returns nonzero if it can't be written.
19-
store_oauth_token() {
20-
_sot_token=$1
21-
_sot_file=$2
22-
[ -n "$_sot_file" ] || return 1
17+
# Other lines (a different var, blanks, unrelated comments) are left untouched. Atomic replace,
18+
# private perms (the file holds a live credential). Returns nonzero if it can't be written. Shared
19+
# by every token the setup flow writes (CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN, …). $1 must be a plain
20+
# env var name (`[A-Za-z_][A-Za-z0-9_]*`) — it's interpolated verbatim into the sed/grep patterns,
21+
# where it carries no regex metacharacters.
22+
store_env_token() {
23+
_set_var=$1
24+
_set_token=$2
25+
_set_file=$3
26+
[ -n "$_set_file" ] || return 1
2327
umask 077
24-
mkdir -p "$(dirname "$_sot_file")" || return 1
25-
_sot_tmp=$(mktemp "$_sot_file.XXXXXX") || return 1
26-
if [ -f "$_sot_file" ]; then
28+
mkdir -p "$(dirname "$_set_file")" || return 1
29+
_set_tmp=$(mktemp "$_set_file.XXXXXX") || return 1
30+
if [ -f "$_set_file" ]; then
2731
# 1) comment out an active assignment — a leading '#' means the line no longer starts with
28-
# the bare var name, so an already-commented real token (with an sk-ant-… value) is left
29-
# as-is; then 2) drop placeholder comment stubs (an empty or `<…>` value).
30-
sed -E 's/^([[:space:]]*)CLAUDE_CODE_OAUTH_TOKEN=/\1# CLAUDE_CODE_OAUTH_TOKEN=/' "$_sot_file" \
31-
| grep -vE '^[[:space:]]*#[[:space:]]*CLAUDE_CODE_OAUTH_TOKEN[[:space:]]*=[[:space:]]*(<[^>]*>)?[[:space:]]*$' \
32-
> "$_sot_tmp" || true # grep exits 1 when it filters every line — that's fine
32+
# the bare var name, so an already-commented real value is left as-is; then 2) drop
33+
# placeholder comment stubs (an empty or `<…>` value).
34+
sed -E "s/^([[:space:]]*)${_set_var}=/\\1# ${_set_var}=/" "$_set_file" \
35+
| grep -vE "^[[:space:]]*#[[:space:]]*${_set_var}[[:space:]]*=[[:space:]]*(<[^>]*>)?[[:space:]]*\$" \
36+
> "$_set_tmp" || true # grep exits 1 when it filters every line — that's fine
3337
fi
34-
printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "$_sot_token" >> "$_sot_tmp" || {
35-
rm -f "$_sot_tmp"
38+
printf '%s=%s\n' "$_set_var" "$_set_token" >> "$_set_tmp" || {
39+
rm -f "$_set_tmp"
3640
return 1
3741
}
38-
mv "$_sot_tmp" "$_sot_file" || {
39-
rm -f "$_sot_tmp"
42+
mv "$_set_tmp" "$_set_file" || {
43+
rm -f "$_set_tmp"
4044
return 1
4145
}
42-
chmod 600 "$_sot_file" 2>/dev/null || true
46+
chmod 600 "$_set_file" 2>/dev/null || true
47+
}
48+
49+
# Back-compat wrapper: store a Claude OAuth token. The shared implementation lives in
50+
# store_env_token; this keeps the Claude call site (and its tests) reading clearly.
51+
store_oauth_token() {
52+
store_env_token CLAUDE_CODE_OAUTH_TOKEN "$1" "$2"
53+
}
54+
55+
# Classify a repo from its git URL ($1), for the setup flow's "what do we know / what's needed"
56+
# summary. `repo_is_github` returns 0 for a github.com remote — the case that needs a GH_TOKEN (a
57+
# local checkout has nothing to push). `repo_source_label` prints a human label.
58+
repo_is_github() {
59+
printf '%s' "$1" | grep -qiE 'github\.com'
60+
}
61+
62+
repo_source_label() {
63+
if [ -z "$1" ]; then
64+
printf 'unknown'
65+
elif repo_is_github "$1"; then
66+
printf 'GitHub remote'
67+
elif printf '%s' "$1" | grep -qE '^(/|\./|\.\./|~|file://)' \
68+
|| ! printf '%s' "$1" | grep -qE '://|@'; then
69+
# A filesystem path / file:// URL, or a bare ref with neither a scheme nor an scp-style host.
70+
printf 'local checkout'
71+
else
72+
printf 'remote'
73+
fi
4374
}

tests/sessionservice/test_shell_runner.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,29 @@ def test_spawn_exports_service_env_and_runs_the_script() -> None:
7474
assert command.rstrip().endswith("claude setup-token") # the workflow script runs last
7575

7676

77+
def test_spawn_exports_repo_name_and_git_url_when_given() -> None:
78+
# So a shell workflow can tailor its flow to the repo (setup-repo classifies local vs GitHub from
79+
# the git URL to decide whether a GH_TOKEN is needed).
80+
rec = _Recorder()
81+
ShellRunner("http://svc:8000", run=rec).spawn(
82+
"t1",
83+
script="echo hi",
84+
repo_name="acme/widget",
85+
git_url="https://github.com/acme/widget.git",
86+
)
87+
command = rec.calls[-1][-1]
88+
assert "export PANOPTICON_REPO_NAME=acme/widget" in command
89+
assert "export PANOPTICON_REPO_GIT_URL=https://github.com/acme/widget.git" in command
90+
91+
92+
def test_spawn_omits_repo_vars_without_them() -> None:
93+
rec = _Recorder()
94+
ShellRunner("http://svc:8000", run=rec).spawn("t1", script="echo hi")
95+
command = rec.calls[-1][-1]
96+
assert "PANOPTICON_REPO_NAME" not in command
97+
assert "PANOPTICON_REPO_GIT_URL" not in command
98+
99+
77100
def test_spawn_loads_the_panopticon_shell_lib_before_the_script() -> None:
78101
# The shell lib (task_lib.sh) is injected so the workflow script can drive its task over REST
79102
# (panopticon_advance/_drop/…) instead of hand-rolling curl.

0 commit comments

Comments
 (0)