Skip to content

Commit 415e8b7

Browse files
authored
Merge pull request #15 from luohaha/feat/pip-installable
feat: pip/uv installable + auto-publish to PyPI on tag
2 parents 5b11d8a + c230120 commit 415e8b7

8 files changed

Lines changed: 167 additions & 26 deletions

File tree

.github/workflows/release.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Release to PyPI
2+
3+
# Push a version tag (e.g. `v0.1.0`) to build and publish to PyPI.
4+
# Auth is PyPI Trusted Publishing (OIDC) — no token/secret in the repo.
5+
# One-time setup: on pypi.org add a publisher for project `goaloop`
6+
# owner: luohaha repo: GoaLoop workflow: release.yml environment: pypi
7+
8+
on:
9+
push:
10+
tags:
11+
- "v*"
12+
13+
jobs:
14+
release:
15+
runs-on: ubuntu-latest
16+
environment:
17+
name: pypi
18+
url: https://pypi.org/p/goaloop
19+
permissions:
20+
id-token: write # mint the OIDC token Trusted Publishing needs
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Install uv
25+
uses: astral-sh/setup-uv@v5
26+
27+
- name: Verify tag matches package version
28+
run: |
29+
tag="${GITHUB_REF_NAME#v}"
30+
ver=$(grep -m1 '^version' pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')
31+
echo "tag=$tag pyproject=$ver"
32+
if [ "$tag" != "$ver" ]; then
33+
echo "::error::tag v$tag does not match pyproject version $ver — bump pyproject.toml first"
34+
exit 1
35+
fi
36+
37+
- name: Build sdist + wheel
38+
run: uv build
39+
40+
- name: Publish to PyPI
41+
uses: pypa/gh-action-pypi-publish@release/v1

README.md

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -57,37 +57,37 @@ verification passes.
5757

5858
## Install
5959

60-
Two pieces: the `goaloop` CLI (the orchestrator), and the Claude Code
61-
skills (the Manager front-end).
62-
63-
**1. Install the `goaloop` CLI** (stdlib-only, Python ≥ 3.10):
60+
Two pieces, two commands: install the `goaloop` CLI (the orchestrator),
61+
then deploy the Claude Code skills (the Manager front-end). Both ship in
62+
the package — no source checkout needed.
6463

6564
```bash
66-
git clone <repo-url> ~/GoaLoop
67-
pip install -e ~/GoaLoop # provides the `goaloop` command
68-
# or run without installing: python3 -m goaloop ... (from ~/GoaLoop)
65+
uv tool install goaloop # provides the `goaloop` command (stdlib-only, Python ≥ 3.10)
66+
goaloop install # deploys /goal-init, /goal-run, /goal-flash + the goal-runner agent into ~/.claude
6967
```
7068

71-
The CLI reads the Runner's system prompt from `~/GoaLoop/agents/goal-runner.md`
72-
(set `GOALOOP_RUNNER_PROMPT` to override). It shells out to `claude`, so the
73-
Claude Code CLI must be on your `PATH` and authenticated.
69+
`uvx goaloop ...` works too if you prefer not to install persistently;
70+
`pip install goaloop` is equivalent if you don't use uv. The Runner's
71+
system prompt ships inside the package (override with
72+
`GOALOOP_RUNNER_PROMPT`). The CLI shells out to `claude`, so the Claude
73+
Code CLI must be on your `PATH` and authenticated.
7474

75-
**2. Install the skills** — either local to one project:
75+
`goaloop install` skips any skill/agent that already exists; pass
76+
`--force` to overwrite. Verify by opening Claude Code and typing
77+
`/goal-init` — it should be recognized. (You can also drive the
78+
orchestrator entirely from the shell with `goaloop run`, skipping the
79+
skills.)
7680

77-
```bash
78-
cd ~/your-working-project && mkdir -p .claude
79-
ln -s ~/GoaLoop/skills .claude/skills
80-
```
81-
82-
…or globally for all CC sessions:
81+
<details>
82+
<summary>From source (development)</summary>
8383

8484
```bash
85-
mkdir -p ~/.claude/skills && cp -r ~/GoaLoop/skills/* ~/.claude/skills/
85+
git clone https://github.com/luohaha/GoaLoop ~/GoaLoop
86+
uv pip install -e ~/GoaLoop # or: pip install -e ~/GoaLoop
87+
goaloop install # same skill/agent deploy as above
88+
# run without installing: python3 -m goaloop ... (from ~/GoaLoop)
8689
```
87-
88-
Verify by opening Claude Code and typing `/goal-init` — it should be
89-
recognized. (You can also drive the orchestrator entirely from the shell
90-
with `goaloop run`, skipping the skills.)
90+
</details>
9191

9292
## Quickstart
9393

goaloop/cli.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import argparse
1212
import os
13+
import shutil
1314
import signal
1415
import subprocess
1516
import sys
@@ -230,6 +231,58 @@ def cmd_continue(args: argparse.Namespace) -> int:
230231
return 0
231232

232233

234+
def cmd_install(args: argparse.Namespace) -> int:
235+
"""Deploy the bundled skills and Runner agent into ~/.claude.
236+
237+
Makes `/goal-init`, `/goal-run`, `/goal-flash` and the `goal-runner`
238+
subagent available to every Claude Code session. The assets ship inside
239+
the installed package (`resources/`), so this works from a `uv tool
240+
install` / `pip install` with no source checkout.
241+
"""
242+
resources = Path(__file__).resolve().parent / "resources"
243+
claude = Path.home() / ".claude"
244+
245+
copied: list[str] = []
246+
skipped: list[str] = []
247+
248+
def deploy(src: Path, dst: Path) -> None:
249+
if dst.exists() and not args.force:
250+
skipped.append(str(dst))
251+
return
252+
if dst.exists():
253+
if dst.is_dir():
254+
shutil.rmtree(dst)
255+
else:
256+
dst.unlink()
257+
dst.parent.mkdir(parents=True, exist_ok=True)
258+
if src.is_dir():
259+
shutil.copytree(src, dst)
260+
else:
261+
shutil.copy2(src, dst)
262+
copied.append(str(dst))
263+
264+
skills_src = resources / "skills"
265+
if skills_src.is_dir():
266+
for skill in sorted(p for p in skills_src.iterdir() if p.is_dir()):
267+
deploy(skill, claude / "skills" / skill.name)
268+
269+
agents_src = resources / "agents"
270+
if agents_src.is_dir():
271+
for agent in sorted(agents_src.glob("*.md")):
272+
deploy(agent, claude / "agents" / agent.name)
273+
274+
for path in copied:
275+
print(f"installed {path}")
276+
for path in skipped:
277+
print(f"exists {path} (use --force to overwrite)")
278+
if not copied and not skipped:
279+
print("Nothing to install — no bundled resources found.", file=sys.stderr)
280+
return 1
281+
if copied:
282+
print("\nDone. Open Claude Code and type /goal-init to verify.")
283+
return 0
284+
285+
233286
def main(argv: list[str] | None = None) -> int:
234287
parser = argparse.ArgumentParser(prog="goaloop", description=__doc__)
235288
sub = parser.add_subparsers(dest="command", required=True)
@@ -265,6 +318,12 @@ def main(argv: list[str] | None = None) -> int:
265318
p_continue.add_argument("workspace")
266319
p_continue.set_defaults(func=cmd_continue)
267320

321+
p_install = sub.add_parser(
322+
"install", help="deploy bundled skills + agent into ~/.claude")
323+
p_install.add_argument("--force", action="store_true",
324+
help="overwrite existing skills/agents")
325+
p_install.set_defaults(func=cmd_install)
326+
268327
args = parser.parse_args(argv)
269328
return args.func(args)
270329

goaloop/orchestrator.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -445,15 +445,17 @@ def _parse_terminator(text: str) -> dict | None:
445445
def _runner_system_prompt() -> str:
446446
"""The Runner instructions, used as --append-system-prompt.
447447
448-
Single source of truth is `agents/goal-runner.md` in the repo; its YAML
449-
frontmatter is stripped. Override with GOALOOP_RUNNER_PROMPT if the file
450-
is not co-located with the package.
448+
Single source of truth is `agents/goal-runner.md` in the repo, shipped
449+
inside the package at `resources/agents/goal-runner.md` (a symlink in the
450+
source tree, a real copy in the installed wheel) so an installed `goaloop`
451+
is self-contained. Its YAML frontmatter is stripped. Override with
452+
GOALOOP_RUNNER_PROMPT to point elsewhere.
451453
"""
452454
import os
453455

454456
override = os.environ.get("GOALOOP_RUNNER_PROMPT")
455457
path = Path(override) if override else (
456-
Path(__file__).resolve().parents[1] / "agents" / "goal-runner.md"
458+
Path(__file__).resolve().parent / "resources" / "agents" / "goal-runner.md"
457459
)
458460
text = path.read_text()
459461
if text.startswith("---"):

goaloop/resources/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Packaged resources
2+
3+
`agents/` and `skills/` here are symlinks to the repo's top-level `agents/`
4+
and `skills/` — the single source of truth. They live under the package so
5+
the build (`setuptools`) copies their real content into the wheel, making an
6+
installed `goaloop` self-contained: the orchestrator loads the Runner system
7+
prompt from `resources/agents/goal-runner.md`, and `goaloop install` deploys
8+
`resources/skills/*` and `resources/agents/*` into `~/.claude/`.
9+
10+
Editing the top-level files is all you need; do not edit through the symlinks.

goaloop/resources/agents

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../agents

goaloop/resources/skills

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../skills

pyproject.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,25 @@
22
name = "goaloop"
33
version = "0.1.0"
44
description = "Goal-driven multi-attempt iteration loop driven by `claude -p`"
5+
readme = "README.md"
56
requires-python = ">=3.10"
7+
license = { file = "LICENSE" }
8+
authors = [{ name = "luohaha" }]
9+
keywords = ["claude", "claude-code", "agent", "automation", "iteration", "goal-driven"]
610
dependencies = [] # stdlib only
11+
classifiers = [
12+
"Development Status :: 4 - Beta",
13+
"Environment :: Console",
14+
"Intended Audience :: Developers",
15+
"Programming Language :: Python :: 3",
16+
"Programming Language :: Python :: 3 :: Only",
17+
"Topic :: Software Development :: Build Tools",
18+
"Operating System :: POSIX",
19+
]
20+
21+
[project.urls]
22+
Homepage = "https://github.com/luohaha/GoaLoop"
23+
Repository = "https://github.com/luohaha/GoaLoop"
724

825
[project.scripts]
926
goaloop = "goaloop.cli:main"
@@ -14,3 +31,13 @@ build-backend = "setuptools.build_meta"
1431

1532
[tool.setuptools]
1633
packages = ["goaloop"]
34+
35+
# `resources/agents` and `resources/skills` are symlinks to the repo's
36+
# top-level dirs (the single source of truth); setuptools copies their real
37+
# content into the wheel so an installed `goaloop` is self-contained.
38+
[tool.setuptools.package-data]
39+
goaloop = [
40+
"resources/README.md",
41+
"resources/agents/*.md",
42+
"resources/skills/*/*.md",
43+
]

0 commit comments

Comments
 (0)