Skip to content

Commit cdcf9a2

Browse files
wanshuiyinclaude
andcommitted
feat(homepage-generator): v1.1 — --from-repos flag for GitHub repo snapshots
Closes/addresses GitHub issue #2 (Yafei-Liu99 2026-05-23) — requested ability to merge selected repository timelines into the generated homepage for a more complete and accurate result. Implementation per Codex round-3 design consultation: CLI surface (one new flag + one safety flag): - `--from-repos owner/repo,owner/repo2` — comma-separated repo specs - `--include-private` — allow private repos in snapshot (default: skip) Data extraction (minimum useful API set, 2 calls per repo): - 1× gh GraphQL: nameWithOwner / description / url / homepageUrl / isPrivate / isArchived / stargazerCount / forkCount / createdAt / pushedAt / primaryLanguage / repositoryTopics(10) / releases(8 most recent) / defaultBranchRef latest commit - 1× gh REST `/repos/.../readme` with Accept: vnd.github.raw, truncated 20KB Pre-flight checks: - gh CLI installed (die early with install hint) - gh auth status OK (die early with `gh auth login` hint) Output: `.aris-homepage/github_repos.json` — per-repo snapshots with {repo, snapshot_at, url, description, stars, forks, primary_language, topics, is_archived, is_private, created_at, pushed_at, releases[], latest_commit, readme_excerpt}. EXTRACTION_HANDOFF.md auto-augmented when github_repos.json is present: - Tells the calling LLM agent where the snapshot lives - Provides timeline-reconciliation rules (≤2 events per repo, ≤6 total github-derived news, dedup CV + repo events same day/month, stars→stats not news, README excerpt sanity-checks claims) - Extends the extraction prompt template with one extra rule line Schema: PROFILE_SCHEMA.md `featured_projects[]` extended with optional `github:` subobject (snapshot + provenance via snapshot_at timestamp). Renderer is deliberately NOT live-fetching — what lands in profile.yml is what gets rendered (avoids render-time rate limits + cache issues). Dogfood verification on real repos: - wanshuiyin/ARIS-in-AI-Offer — 91★ · 0 releases · Python - wanshuiyin/Auto-claude-code-research-in-sleep — 10,499★ · 8 releases · Python Both fetched cleanly via gh; github_repos.json populates correctly. Out of scope for v1.1 (defer to v1.2): - --manual-homepage / --assets-dir CLI flags (agent still handles via context) - --merge mode for init re-runs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: GPT-5.5 xhigh via Codex MCP (design consultation) Closes: #2
1 parent 17f0e6a commit cdcf9a2

3 files changed

Lines changed: 215 additions & 15 deletions

File tree

skills/homepage-generator/PROFILE_SCHEMA.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,23 @@ featured_projects:
131131
open_problems: # optional — renders as numbered list under "Open problems explored in this line of work"
132132
- "Problem 1 statement — what it is, why it matters, why it's open."
133133
- "Problem 2 statement."
134+
github: # (v1.1) snapshot populated by `init --from-repos`
135+
repo: "owner/repo" # provenance: which repo this snapshot is from
136+
snapshot_at: "2026-05-24T10:00:00Z"
137+
stars: 128
138+
forks: 9
139+
primary_language: "Python"
140+
topics: ["academic-homepage", "llm-agent"]
141+
created_at: "2026-05-01T00:00:00Z"
142+
pushed_at: "2026-05-23T00:00:00Z"
143+
latest_release:
144+
tag: "v1.0"
145+
date: "2026-05-23T00:00:00Z"
146+
url: "https://github.com/owner/repo/releases/tag/v1.0"
134147
```
135148
149+
> **Note on `github:` subobject** (v1.1): populated by `aris-homepage init --from-repos owner/repo,...`. Written to `.aris-homepage/github_repos.json` first; calling LLM agent then maps it into `featured_projects[*].github` during the extraction step. Renderer does NOT auto-fetch — what's in profile.yml is what gets rendered (provenance lives in `snapshot_at`).
150+
136151
**Visual treatment**: the logo image floats right; text content (stats / links / elevator / sub-projects / open_problems) wraps around it, then continues full-width below the image. Use for ONE flagship project; the renderer supports multiple but visual weight stacks.
137152

138153
## §7 `awards`

skills/homepage-generator/SKILL.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: homepage-generator
33
description: "Generate a fact-checked academic personal homepage from a CV, optionally augmented by an existing manual homepage and an assets directory. Produces editable structured source files (profile.yml + publications.bib + bio.md + news.md) and a single-file HTML page. Uses Codex MCP for independent factual review against DBLP / arXiv. Optionally uses Gemini multimodal for screenshot critique when available. Use when the user says '做个学术主页', '从CV生成主页', 'aris-homepage', 'generate academic homepage from CV', 'PhD homepage', 'GitHub Pages personal site', or wants a fact-checked academic site."
4-
argument-hint: init --from-cv <cv.docx|cv.pdf|cv.txt> [--manual-homepage <url>] [--assets-dir <path>] [--out <dir>] [--force|--merge] | finalize | render --persona theory-minimal [--out <html>] [--override-all] [--no-audit] [--offline] | check [--strict] | doctor
4+
argument-hint: init --from-cv <cv.docx|cv.pdf|cv.txt> [--from-repos owner/repo,...] [--include-private] [--manual-homepage <url>] [--assets-dir <path>] [--out <dir>] [--force|--merge] | finalize | render --persona theory-minimal [--out <html>] [--override-all] [--no-audit] [--offline] | check [--strict] | doctor
55
allowed-tools: Bash(*), Read, Write, Edit, WebFetch, mcp__codex__codex
66
---
77

@@ -51,6 +51,7 @@ The `init` CLI only handles the CV → text conversion. The other two inputs are
5151
| Input | How to supply | Purpose |
5252
|---|---|---|
5353
| **CV** | `--from-cv cv.docx/pdf/txt` on the CLI | The **factual** source — identity, education, jobs, publications, awards |
54+
| **GitHub repos** (v1.1) | `--from-repos owner/repo,owner/repo2` on the CLI | The **project-evidence** source — stars / releases / topics / README per repo; merged into News + featured projects (issue #2) |
5455
| **Manual homepage** | Provide URL in the prompt; the agent uses WebFetch | The **editorial** source — section ordering, topic groupings, tone, link priorities, photo URL |
5556
| **Assets directory** | Provide path in the prompt; the agent inspects + copies into `assets/` | The **visual** source — headshot, paper thumbnails, project logos |
5657

@@ -63,12 +64,17 @@ The `init` CLI only handles the CV → text conversion. The other two inputs are
6364
## Commands
6465

6566
```bash
66-
aris-homepage init --from-cv <file> [--out DIR] [--force|--merge]
67+
aris-homepage init --from-cv <file> [--from-repos owner/repo,...] [--include-private] [--out DIR] [--force|--merge]
6768
# Step 1. Extract CV to plain text (via textutil / python-docx / pdftotext).
68-
# Step 2. Emit .aris-homepage/EXTRACTION_HANDOFF.md describing what the calling
69-
# LLM agent should write to .aris-homepage/extraction.json.
70-
# --force: backup *.bak-TIMESTAMP and overwrite; --merge: fill-only (v1.1)
71-
# NOTE: --manual-homepage and --assets-dir flags coming in v1.1; for now,
69+
# Step 1b. (v1.1) If --from-repos given, snapshot each repo via `gh` CLI
70+
# (GraphQL metadata + REST README, truncated 20KB) →
71+
# .aris-homepage/github_repos.json. Private repos skipped unless
72+
# --include-private.
73+
# Step 2. Emit .aris-homepage/EXTRACTION_HANDOFF.md describing what the calling
74+
# LLM agent should write to .aris-homepage/extraction.json
75+
# (handoff doc auto-includes guidance on github_repos.json if present).
76+
# --force: backup *.bak-TIMESTAMP and overwrite; --merge: fill-only (v1.2)
77+
# NOTE: --manual-homepage and --assets-dir flags coming in v1.2; for now,
7278
# the calling agent handles those sources via prompt context.
7379

7480
aris-homepage finalize [--out DIR]

tools/aris_homepage.py

Lines changed: 188 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,152 @@ def cmd_init(args: argparse.Namespace) -> int:
342342
txt_path.parent.mkdir(parents=True, exist_ok=True)
343343
txt_path.write_text(cv_txt, encoding="utf-8")
344344

345-
print_extraction_handoff(txt_path, out_dir)
345+
# Optional: fetch GitHub repo snapshots for additional context (v1.1).
346+
# See issue #2: user wants to merge repo timelines into homepage news + projects.
347+
repos_path = None
348+
if getattr(args, "from_repos", None):
349+
repo_specs = [r.strip() for r in args.from_repos.split(",") if r.strip()]
350+
repos_data = fetch_github_repos(repo_specs,
351+
include_private=getattr(args, "include_private", False))
352+
if repos_data:
353+
repos_path = out_dir / ".aris-homepage" / "github_repos.json"
354+
repos_path.write_text(json.dumps(repos_data, indent=2, ensure_ascii=False),
355+
encoding="utf-8")
356+
print(f"✓ {len(repos_data)} GitHub repo snapshots → {repos_path}")
357+
358+
print_extraction_handoff(txt_path, out_dir, repos_path=repos_path)
346359
return 0
347360

348361

362+
def fetch_github_repos(repos: list[str], *, include_private: bool = False) -> list[dict]:
363+
"""Fetch repo snapshot via `gh` CLI for each `owner/repo` spec.
364+
365+
Returns a list of dicts (one per successfully fetched repo) with shape:
366+
{repo, snapshot_at, url, homepage_url, description, stars, forks,
367+
primary_language, topics, is_archived, created_at, pushed_at,
368+
releases: [{tag, name, date, url, description}], latest_commit,
369+
readme_excerpt}
370+
371+
Private repos are skipped by default — pass include_private=True to override.
372+
"""
373+
if not shutil.which("gh"):
374+
die("`gh` CLI not installed. Install via `brew install gh` or see https://cli.github.com/")
375+
auth = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
376+
if auth.returncode != 0:
377+
die("`gh` CLI not authenticated. Run: `gh auth login`")
378+
379+
gql = """query($owner:String!, $name:String!) {
380+
repository(owner:$owner, name:$name) {
381+
nameWithOwner description url homepageUrl
382+
isPrivate isArchived
383+
stargazerCount forkCount
384+
createdAt pushedAt
385+
primaryLanguage { name }
386+
repositoryTopics(first: 10) { nodes { topic { name } } }
387+
releases(first: 8, orderBy: {field: CREATED_AT, direction: DESC}) {
388+
nodes { name tagName publishedAt url description }
389+
}
390+
defaultBranchRef {
391+
name
392+
target {
393+
... on Commit {
394+
latest: history(first: 1) {
395+
nodes { committedDate messageHeadline url oid }
396+
}
397+
}
398+
}
399+
}
400+
}
401+
}"""
402+
results: list[dict] = []
403+
for spec in repos:
404+
if "/" not in spec:
405+
print(f" ⚠ invalid repo spec '{spec}' (need owner/repo); skipping", file=sys.stderr)
406+
continue
407+
owner, name = spec.split("/", 1)
408+
try:
409+
r = subprocess.run(
410+
["gh", "api", "graphql",
411+
"-f", f"owner={owner}", "-f", f"name={name}",
412+
"-f", f"query={gql}"],
413+
capture_output=True, text=True, check=True,
414+
)
415+
data = json.loads(r.stdout).get("data", {}).get("repository")
416+
except subprocess.CalledProcessError as e:
417+
print(f" ⚠ gh api failed for {spec}: {(e.stderr or '')[:200]}", file=sys.stderr)
418+
continue
419+
except json.JSONDecodeError:
420+
print(f" ⚠ malformed JSON from gh for {spec}", file=sys.stderr)
421+
continue
422+
if not data:
423+
print(f" ⚠ repo not found or no access: {spec}", file=sys.stderr)
424+
continue
425+
if data.get("isPrivate") and not include_private:
426+
print(f" ⚠ skipping PRIVATE repo {spec} (use --include-private to override)",
427+
file=sys.stderr)
428+
continue
429+
430+
# README — separate REST call, truncated to 20KB
431+
readme_text = ""
432+
try:
433+
rm = subprocess.run(
434+
["gh", "api", f"/repos/{owner}/{name}/readme",
435+
"-H", "Accept: application/vnd.github.raw"],
436+
capture_output=True, text=True, check=False,
437+
)
438+
if rm.returncode == 0:
439+
readme_text = rm.stdout[:20000]
440+
except Exception:
441+
pass
442+
443+
topics = [n["topic"]["name"]
444+
for n in (data.get("repositoryTopics") or {}).get("nodes", [])]
445+
commits = (((data.get("defaultBranchRef") or {})
446+
.get("target") or {})
447+
.get("latest", {}).get("nodes", []))
448+
latest_commit = commits[0] if commits else None
449+
450+
snap = {
451+
"repo": data["nameWithOwner"],
452+
"snapshot_at": datetime.now(timezone.utc).isoformat(),
453+
"url": data["url"],
454+
"homepage_url": data.get("homepageUrl"),
455+
"description": data.get("description"),
456+
"stars": data.get("stargazerCount", 0),
457+
"forks": data.get("forkCount", 0),
458+
"primary_language": (data.get("primaryLanguage") or {}).get("name"),
459+
"topics": topics,
460+
"is_archived": data.get("isArchived", False),
461+
"is_private": data.get("isPrivate", False),
462+
"created_at": data.get("createdAt"),
463+
"pushed_at": data.get("pushedAt"),
464+
"releases": [
465+
{
466+
"tag": rel["tagName"],
467+
"name": rel.get("name"),
468+
"date": rel["publishedAt"],
469+
"url": rel["url"],
470+
"description": (rel.get("description") or "")[:500],
471+
}
472+
for rel in (data.get("releases") or {}).get("nodes", [])[:8]
473+
],
474+
"latest_commit": (
475+
{
476+
"date": latest_commit["committedDate"],
477+
"message": latest_commit["messageHeadline"],
478+
"url": latest_commit["url"],
479+
"sha": latest_commit["oid"][:7],
480+
} if latest_commit else None
481+
),
482+
"readme_excerpt": readme_text,
483+
}
484+
results.append(snap)
485+
rel_count = len(snap["releases"])
486+
print(f" ✓ {spec}{snap['stars']} ★ · {rel_count} releases · "
487+
f"{snap['primary_language'] or '—'}")
488+
return results
489+
490+
349491
def extract_text_from_cv(path: Path) -> str:
350492
"""Convert CV → plain text. macOS textutil → python-docx → pdftotext fallback chain."""
351493
suffix = path.suffix.lower()
@@ -371,26 +513,58 @@ def extract_text_from_cv(path: Path) -> str:
371513
die(f"Unsupported CV format: {suffix}. Use .txt, .docx, or .pdf.")
372514

373515

374-
def print_extraction_handoff(txt_path: Path, out_dir: Path) -> None:
516+
def print_extraction_handoff(txt_path: Path, out_dir: Path,
517+
repos_path: Path | None = None) -> None:
375518
"""Emit instructions for the calling LLM agent to do extraction.
376519
377520
This script does NOT call an LLM — the calling agent (Claude/Gemini)
378-
reads cv.txt, fills the JSON-schema-constrained output, and writes
379-
.aris-homepage/extraction.json. A follow-up `aris-homepage finalize`
380-
command (or this script re-invoked with --consume) ingests that JSON
381-
and writes profile.yml + publications.bib + bio.md + news.md.
521+
reads cv.txt + optional github_repos.json, fills the JSON-schema-constrained
522+
output, and writes .aris-homepage/extraction.json. A follow-up
523+
`aris-homepage finalize` ingests that JSON and writes the editable sources.
382524
"""
383525
handoff_path = out_dir / ".aris-homepage" / "EXTRACTION_HANDOFF.md"
384526
schema_path = out_dir / ".aris-homepage" / "extraction.schema.json"
385527
schema_path.write_text(json.dumps(EXTRACTION_SCHEMA, indent=2), encoding="utf-8")
528+
529+
repos_block = ""
530+
if repos_path is not None and repos_path.exists():
531+
repos_block = f"""
532+
533+
## Optional source: GitHub repo snapshots (v1.1)
534+
535+
A snapshot of user-selected GitHub repos has been written to:
536+
{repos_path.relative_to(out_dir)}
537+
538+
It contains per-repo: description / stars / forks / topics / primary_language /
539+
created_at / pushed_at / up to 8 releases / latest commit / README excerpt
540+
(truncated 20KB).
541+
542+
### How to use github_repos.json in extraction
543+
544+
- Surface each repo as a `featured_projects[]` entry (or extend an existing one)
545+
with a new `github:` subobject carrying the snapshot data (stars, forks,
546+
primary_language, topics, created_at, pushed_at, releases summary, latest_commit).
547+
- Merge each repo's **timeline** into `news_md` with these rules:
548+
* Take at most 2 events per repo (releases highest priority, then created_at).
549+
* Cap total github-derived news at 6 across all repos.
550+
* If a CV news entry already mentions a release / project on the same
551+
date OR same YYYY-MM with overlapping title — DEDUP: keep the CV's
552+
human phrasing and append the release URL; do not list both.
553+
* stars/forks go to project stats, NOT into news.
554+
- Use README excerpt to verify the project description matches the CV's claim
555+
(catches misrepresentation).
556+
- Mark anything you cannot confirm from `github_repos.json` as `uncertain[]`.
557+
"""
558+
386559
handoff_path.write_text(f"""# ARIS Homepage — Extraction Handoff
387560
388561
The CV has been converted to plain text at:
389562
{txt_path.relative_to(out_dir)}
390-
563+
{repos_block}
391564
## Next step (LLM agent task)
392565
393-
Read the CV text and emit JSON conforming to the schema at:
566+
Read the CV text{' + the github_repos.json snapshot' if repos_block else ''} and
567+
emit JSON conforming to the schema at:
394568
{schema_path.relative_to(out_dir)}
395569
396570
Write the JSON output to:
@@ -417,7 +591,7 @@ def print_extraction_handoff(txt_path: Path, out_dir: Path) -> None:
417591
> with a note. These will become checklist items in EXTRACTION_REVIEW.md.
418592
> - Do NOT invent claims. If the CV doesn't say something, leave the field null/empty.
419593
> - Identify the CV owner. Their name goes in profile.identity.name + name_native (if bilingual).
420-
>
594+
{('> - If github_repos.json exists, merge its timeline into news_md following the rules in the section above.' + chr(10)) if repos_block else ''}>
421595
> After writing the JSON, instruct the user to run `aris-homepage finalize`.
422596
""", encoding="utf-8")
423597
print(f"✓ CV text extracted to: {txt_path}")
@@ -1596,6 +1770,11 @@ def main() -> int:
15961770
p_init = sub.add_parser("init", help="extract CV to text, emit extraction handoff")
15971771
p_init.add_argument("--from-cv", required=True, help=".docx / .pdf / .txt CV path")
15981772
p_init.add_argument("--out", default=".", help="output directory (default: cwd)")
1773+
p_init.add_argument("--from-repos",
1774+
help="optional: comma-separated owner/repo list to snapshot via gh CLI "
1775+
"(e.g. wanshuiyin/ARIS-in-AI-Offer,wanshuiyin/Auto-claude-code-research-in-sleep)")
1776+
p_init.add_argument("--include-private", action="store_true",
1777+
help="allow private repos in --from-repos snapshots (default: skip)")
15991778
g = p_init.add_mutually_exclusive_group()
16001779
g.add_argument("--force", action="store_true", help="overwrite existing profile.yml")
16011780
g.add_argument("--merge", action="store_true", help="fill only missing fields (v1.1)")

0 commit comments

Comments
 (0)