grad-agent 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
grad_agent/__init__.py ADDED
File without changes
@@ -0,0 +1,174 @@
1
+ """Extend the project catalog from GitHub + HuggingFace + local NINTE dirs.
2
+
3
+ Writes/updates `catalog.json` in the grad-agent directory. Each entry has the
4
+ same shape as the seed `PROJECTS` list in projects.py:
5
+ {name, pitch, link, tags}
6
+ Claude Haiku distils each source (repo, model, local README) into the pitch
7
+ and tags. Existing entries are updated in place; new ones appended.
8
+ """
9
+ from __future__ import annotations
10
+ import os, json, re, time
11
+ from pathlib import Path
12
+
13
+ from . import config as _cfg
14
+ _cfg.load_env_file()
15
+
16
+ from .sources import github as gh
17
+ from .sources import hf
18
+
19
+ try:
20
+ import anthropic
21
+ except Exception:
22
+ anthropic = None
23
+
24
+
25
+ def _catalog_path() -> Path:
26
+ return _cfg.data_dir() / "catalog.json"
27
+
28
+
29
+ def _projects_dir() -> Path:
30
+ prof = _cfg.load_profile()
31
+ if prof.projects_dir:
32
+ return Path(prof.projects_dir).expanduser()
33
+ return Path(os.environ.get("NINTE_ROOT", str(Path.home())))
34
+
35
+ # Skip these local dirs when scanning _projects_dir()
36
+ SKIP_LOCAL = {"GRAD", "portfolio", "SNAPSHOT", "Kwabena.zip"}
37
+
38
+ SYSTEM = (
39
+ "You extract a compact JSON record from a project description. Return a "
40
+ "strict JSON object with keys: pitch (one sentence, 25 to 45 words, in "
41
+ "Kwabena's voice, direct and grounded in the actual work described, no "
42
+ "cliches, no dashes), tags (a list of 5 to 12 short lowercase phrases "
43
+ "describing the technical area, methods, and domain, e.g. 'nlp', 'llm', "
44
+ "'african languages', 'yolo', 'time series'). Nothing else. No prose "
45
+ "outside the JSON."
46
+ )
47
+
48
+
49
+ def _distil(name: str, kind: str, description: str, extra: str = "",
50
+ model: str = "claude-haiku-4-5-20251001") -> dict:
51
+ if anthropic is None or not os.environ.get("ANTHROPIC_API_KEY"):
52
+ # Heuristic: pitch = first 40 words of description, tags = topics/tags
53
+ pitch = " ".join((description or f"{kind} named {name}").split()[:40])
54
+ return {"pitch": pitch, "tags": []}
55
+ client = anthropic.Anthropic()
56
+ user = (
57
+ f"KIND: {kind}\nNAME: {name}\nDESCRIPTION: {description}\n\n"
58
+ f"EXTRA CONTEXT (README or metadata):\n{extra[:3000]}\n\n"
59
+ f"Return the JSON now."
60
+ )
61
+ m = client.messages.create(
62
+ model=model, max_tokens=400, system=SYSTEM,
63
+ messages=[{"role": "user", "content": user}],
64
+ )
65
+ raw = m.content[0].text.strip()
66
+ # tolerate ```json fences
67
+ raw = re.sub(r"^```(?:json)?", "", raw).rstrip("`").strip()
68
+ try:
69
+ obj = json.loads(raw)
70
+ obj["pitch"] = obj.get("pitch", "").replace("—", ", ").replace("–", ", ")
71
+ return {"pitch": obj.get("pitch", ""), "tags": obj.get("tags", []) or []}
72
+ except Exception:
73
+ return {"pitch": (description or "")[:200], "tags": []}
74
+
75
+
76
+ def _load() -> dict:
77
+ if _catalog_path().exists():
78
+ return json.loads(_catalog_path().read_text())
79
+ return {"projects": {}, "last_synced": None}
80
+
81
+
82
+ def _save(cat: dict) -> None:
83
+ cat["last_synced"] = time.strftime("%Y-%m-%d %H:%M")
84
+ _catalog_path().write_text(json.dumps(cat, indent=2))
85
+
86
+
87
+ def _upsert(cat: dict, key: str, entry: dict) -> None:
88
+ cat["projects"][key] = entry
89
+
90
+
91
+ def sync_github() -> int:
92
+ cat = _load(); added = 0
93
+ for r in gh.list_repos():
94
+ if r["fork"] or r["archived"]: continue
95
+ readme = gh.fetch_readme(r["full_name"])
96
+ d = _distil(
97
+ name=r["name"],
98
+ kind="github repo",
99
+ description=r["description"] or "",
100
+ extra=(readme or "") + "\nTopics: " + ", ".join(r["topics"]),
101
+ )
102
+ entry = {
103
+ "name": r["name"],
104
+ "pitch": d["pitch"] or (r["description"] or r["name"]),
105
+ "link": r["html_url"],
106
+ "tags": list(dict.fromkeys(d["tags"] + r["topics"])),
107
+ "source": "github",
108
+ "language": r["language"],
109
+ "stars": r["stars"],
110
+ }
111
+ _upsert(cat, f"gh:{r['full_name']}", entry); added += 1
112
+ _save(cat); return added
113
+
114
+
115
+ def sync_hf() -> int:
116
+ cat = _load(); added = 0
117
+ for m in hf.list_models() + hf.list_datasets():
118
+ readme = hf.fetch_readme(m["id"], kind=m["kind"] + "s")
119
+ d = _distil(
120
+ name=m["id"],
121
+ kind=f"huggingface {m['kind']}",
122
+ description=(m.get("pipeline_tag") or "") + " " + ", ".join(m["tags"][:10]),
123
+ extra=readme,
124
+ )
125
+ entry = {
126
+ "name": m["id"],
127
+ "pitch": d["pitch"] or f"{m['kind']} at {m['id']}",
128
+ "link": m["url"],
129
+ "tags": list(dict.fromkeys(d["tags"] + m["tags"])),
130
+ "source": "huggingface",
131
+ "downloads": m["downloads"],
132
+ "likes": m["likes"],
133
+ }
134
+ _upsert(cat, f"hf:{m['kind']}:{m['id']}", entry); added += 1
135
+ _save(cat); return added
136
+
137
+
138
+ def sync_local() -> int:
139
+ cat = _load(); added = 0
140
+ for child in _projects_dir().iterdir():
141
+ if not child.is_dir() or child.name in SKIP_LOCAL: continue
142
+ readme_text = ""
143
+ for cand in ("README.md", "Readme.md", "readme.md", "README.txt"):
144
+ p = child / cand
145
+ if p.exists():
146
+ readme_text = p.read_text(errors="ignore")[:4000]; break
147
+ if not readme_text and not any((child / c).exists() for c in ("package.json", "requirements.txt")):
148
+ continue
149
+ d = _distil(
150
+ name=child.name, kind="local project directory",
151
+ description="", extra=readme_text,
152
+ )
153
+ entry = {
154
+ "name": child.name,
155
+ "pitch": d["pitch"] or child.name,
156
+ "link": str(child),
157
+ "tags": d["tags"],
158
+ "source": "local",
159
+ }
160
+ _upsert(cat, f"local:{child.name}", entry); added += 1
161
+ _save(cat); return added
162
+
163
+
164
+ def sync_all() -> dict:
165
+ return {
166
+ "github": sync_github(),
167
+ "huggingface": sync_hf(),
168
+ "local": sync_local(),
169
+ "catalog_path": str(_catalog_path()),
170
+ }
171
+
172
+
173
+ if __name__ == "__main__":
174
+ print(sync_all())
grad_agent/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ """grad-agent CLI.
2
+
3
+ Subcommands:
4
+ init Interactive setup: writes ~/.grad-agent/profile.yaml + .env
5
+ server Start the MCP stdio server (for `claude mcp add`).
6
+ run Run today's outreach batch and email the review inbox.
7
+ sync Sync catalog from GitHub + HuggingFace + local projects_dir.
8
+ register-claude Print the `claude mcp add` command tailored to this install.
9
+ path Print resolved GRAD_AGENT_HOME.
10
+ """
11
+ from __future__ import annotations
12
+ import argparse
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from . import config
18
+
19
+
20
+ def _prompt(label: str, default: str = "") -> str:
21
+ suffix = f" [{default}]" if default else ""
22
+ v = input(f"{label}{suffix}: ").strip()
23
+ return v or default
24
+
25
+
26
+ def cmd_init(args: argparse.Namespace) -> int:
27
+ home = config.ensure_home()
28
+ print(f"Setting up grad-agent at {home}\n")
29
+
30
+ tmpl_dir = config.TEMPLATES
31
+ profile_dst = config.profile_path()
32
+ env_dst = config.env_path()
33
+ programs_dst = config.programs_path()
34
+
35
+ if profile_dst.exists() and not args.force:
36
+ print(f"profile.yaml already exists at {profile_dst}. Use --force to overwrite.")
37
+ else:
38
+ shutil.copy(tmpl_dir / "profile.example.yaml", profile_dst)
39
+ print(f"wrote {profile_dst}")
40
+
41
+ if not programs_dst.exists() or args.force:
42
+ shutil.copy(tmpl_dir / "programs.example.yaml", programs_dst)
43
+ print(f"wrote {programs_dst}")
44
+
45
+ if env_dst.exists() and not args.force:
46
+ print(f".env already exists at {env_dst}. Use --force to overwrite.")
47
+ else:
48
+ shutil.copy(tmpl_dir / "env.example", env_dst)
49
+ print(f"wrote {env_dst}")
50
+
51
+ if args.interactive:
52
+ print("\nQuick setup, press Enter to keep defaults or fill later:\n")
53
+ import yaml
54
+ prof = yaml.safe_load(profile_dst.read_text()) or {}
55
+ prof["name"] = _prompt("Full name", prof.get("name", ""))
56
+ prof["identity_line"] = _prompt(
57
+ 'One-line identity (e.g. "a Computer Science graduate from KNUST")',
58
+ prof.get("identity_line", ""),
59
+ )
60
+ prof["portfolio"] = _prompt("Portfolio URL", prof.get("portfolio", ""))
61
+ prof["cv_path"] = _prompt("Absolute path to CV pdf", prof.get("cv_path", ""))
62
+ prof["transcript_path"] = _prompt("Absolute path to transcript pdf", prof.get("transcript_path", ""))
63
+ prof["degree_status"] = _prompt("Highest degree (bachelors/masters)", prof.get("degree_status", "bachelors"))
64
+ prof["target_term"] = _prompt("Target term", prof.get("target_term", "Fall 2027"))
65
+ prof["target_degree"] = _prompt("Target degree (PhD/MSc)", prof.get("target_degree", "PhD"))
66
+ prof["review_email"] = _prompt("Review inbox (where drafts land)", prof.get("review_email", ""))
67
+ profile_dst.write_text(yaml.safe_dump(prof, sort_keys=False))
68
+ print(f"\nupdated {profile_dst}")
69
+
70
+ print("\nNext steps:")
71
+ print(f" 1. Fill in secrets in {env_dst} (ANTHROPIC_API_KEY, SMTP_*)")
72
+ print(f" 2. Edit {profile_dst} to add your seed_projects (3 to 10 flagships)")
73
+ print(f" 3. Optional: edit {programs_dst} to add target programs")
74
+ print(f" 4. Run: grad-agent sync # scan projects")
75
+ print(f" 5. Run: grad-agent run --dry-run # preview one batch")
76
+ print(f" 6. Run: grad-agent register-claude # get MCP install command")
77
+ return 0
78
+
79
+
80
+ def cmd_server(_: argparse.Namespace) -> int:
81
+ from . import server as _server
82
+ _server.mcp.run()
83
+ return 0
84
+
85
+
86
+ def cmd_run(args: argparse.Namespace) -> int:
87
+ from . import daily_run
88
+ res = daily_run.run_batch(n=args.n, area=args.area or None, dry_run=args.dry_run)
89
+ print(res)
90
+ return 0
91
+
92
+
93
+ def cmd_sync(args: argparse.Namespace) -> int:
94
+ from . import catalog_sync
95
+ src = args.source or "all"
96
+ print(getattr(catalog_sync, f"sync_{src}", catalog_sync.sync_all)() if src != "all"
97
+ else catalog_sync.sync_all())
98
+ return 0
99
+
100
+
101
+ def _resolve_grad_agent_exe() -> str:
102
+ """Return the command Claude Code should run to launch this install.
103
+ Order of preference:
104
+ 1. `grad-agent` on PATH (clean form, works after `pipx ensurepath`)
105
+ 2. sys.argv[0] if it looks like a grad-agent script (matches how the
106
+ user just invoked us)
107
+ 3. A `grad-agent` binary next to the current Python (pipx layout)
108
+ 4. `<sys.executable> -m grad_agent.cli` (universal fallback)
109
+ """
110
+ from pathlib import Path
111
+
112
+ on_path = shutil.which("grad-agent")
113
+ if on_path:
114
+ return on_path
115
+
116
+ argv0 = Path(sys.argv[0]) if sys.argv and sys.argv[0] else None
117
+ if argv0 and argv0.is_absolute() and argv0.name.startswith("grad-agent"):
118
+ return str(argv0)
119
+
120
+ adj = Path(sys.executable).parent / "grad-agent"
121
+ if adj.exists():
122
+ return str(adj)
123
+
124
+ return f"{sys.executable} -m grad_agent.cli"
125
+
126
+
127
+ def cmd_register_claude(_: argparse.Namespace) -> int:
128
+ exe = _resolve_grad_agent_exe()
129
+ on_path = shutil.which("grad-agent")
130
+ cmd = f"claude mcp add grad-agent {exe} server"
131
+ print("Run this once to register with Claude Code:\n")
132
+ print(f" {cmd}\n")
133
+ if not on_path:
134
+ print("Note: `grad-agent` is not on your PATH yet.")
135
+ print("Run `pipx ensurepath` and open a new terminal to get the shorter form")
136
+ print("`claude mcp add grad-agent grad-agent server`.\n")
137
+ print("Then in a Claude Code session type /mcp to confirm it's connected.")
138
+ return 0
139
+
140
+
141
+ def cmd_path(_: argparse.Namespace) -> int:
142
+ print(config.home())
143
+ return 0
144
+
145
+
146
+ def main(argv: list[str] | None = None) -> int:
147
+ parser = argparse.ArgumentParser(prog="grad-agent", description=__doc__)
148
+ sub = parser.add_subparsers(dest="cmd")
149
+
150
+ p_init = sub.add_parser("init", help="Interactive setup")
151
+ p_init.add_argument("--force", action="store_true")
152
+ p_init.add_argument("--interactive", action="store_true", default=True)
153
+ p_init.add_argument("--no-interactive", dest="interactive", action="store_false")
154
+ p_init.set_defaults(func=cmd_init)
155
+
156
+ p_server = sub.add_parser("server", help="Start MCP stdio server")
157
+ p_server.set_defaults(func=cmd_server)
158
+
159
+ p_run = sub.add_parser("run", help="Run today's outreach batch")
160
+ p_run.add_argument("-n", type=int, default=3)
161
+ p_run.add_argument("--area", default="")
162
+ p_run.add_argument("--dry-run", action="store_true")
163
+ p_run.set_defaults(func=cmd_run)
164
+
165
+ p_sync = sub.add_parser("sync", help="Sync project catalog")
166
+ p_sync.add_argument("--source", choices=["all", "github", "hf", "local"], default="all")
167
+ p_sync.set_defaults(func=cmd_sync)
168
+
169
+ p_reg = sub.add_parser("register-claude", help="Print the claude mcp add command")
170
+ p_reg.set_defaults(func=cmd_register_claude)
171
+
172
+ p_path = sub.add_parser("path", help="Print GRAD_AGENT_HOME")
173
+ p_path.set_defaults(func=cmd_path)
174
+
175
+ args = parser.parse_args(argv)
176
+ if not getattr(args, "func", None):
177
+ parser.print_help(); return 1
178
+
179
+ config.load_env_file()
180
+ return args.func(args)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ raise SystemExit(main())
grad_agent/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """Centralised config + paths + profile loader for grad-agent.
2
+
3
+ User data location (in priority order):
4
+ 1. $GRAD_AGENT_HOME
5
+ 2. ~/.grad-agent/
6
+ 3. Current working directory (for legacy/dev)
7
+
8
+ Layout under HOME:
9
+ profile.yaml user identity + preferences
10
+ programs.yaml user's target programs (seeded from template on init)
11
+ .env secrets only (SMTP, ANTHROPIC_API_KEY, GITHUB_TOKEN, ...)
12
+ data/ outreach_log.xlsx, lor_log.xlsx, catalog.json, db.sqlite
13
+ drafts/ per-school drafts
14
+ """
15
+ from __future__ import annotations
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from functools import lru_cache
19
+ from pathlib import Path
20
+
21
+ try:
22
+ import yaml
23
+ except Exception:
24
+ yaml = None
25
+
26
+
27
+ PACKAGE_ROOT = Path(__file__).parent
28
+ TEMPLATES = PACKAGE_ROOT / "templates"
29
+
30
+
31
+ def home() -> Path:
32
+ env = os.environ.get("GRAD_AGENT_HOME")
33
+ if env:
34
+ return Path(env).expanduser().resolve()
35
+ default = Path.home() / ".grad-agent"
36
+ if default.exists():
37
+ return default
38
+ # Legacy fallback: the folder containing the package
39
+ return PACKAGE_ROOT.parent
40
+
41
+
42
+ def ensure_home() -> Path:
43
+ h = home()
44
+ h.mkdir(parents=True, exist_ok=True)
45
+ (h / "data").mkdir(exist_ok=True)
46
+ (h / "drafts").mkdir(exist_ok=True)
47
+ return h
48
+
49
+
50
+ def profile_path() -> Path: return home() / "profile.yaml"
51
+ def programs_path() -> Path: return home() / "programs.yaml"
52
+ def env_path() -> Path: return home() / ".env"
53
+
54
+ def data_dir() -> Path: ensure_home(); return home() / "data"
55
+ def drafts_dir() -> Path: ensure_home(); return home() / "drafts"
56
+
57
+
58
+ @dataclass
59
+ class Profile:
60
+ name: str = ""
61
+ identity_line: str = ""
62
+ portfolio: str = ""
63
+ cv_path: str = ""
64
+ transcript_path: str = ""
65
+ degree_status: str = "bachelors" # bachelors | masters
66
+ target_term: str = "Fall 2027"
67
+ target_degree: str = "PhD" # PhD | MSc
68
+ research_areas: list[str] = field(default_factory=list)
69
+ projects_dir: str = ""
70
+ seed_projects: list[dict] = field(default_factory=list)
71
+ blog: dict = field(default_factory=dict) # optional plugin config
72
+ review_email: str = "" # where drafts are sent
73
+
74
+ @property
75
+ def has_masters(self) -> bool:
76
+ return (self.degree_status or "").lower() == "masters"
77
+
78
+
79
+ @lru_cache(maxsize=1)
80
+ def load_profile() -> Profile:
81
+ p = profile_path()
82
+ if not p.exists() or yaml is None:
83
+ return Profile()
84
+ data = yaml.safe_load(p.read_text()) or {}
85
+ return Profile(
86
+ name=data.get("name", ""),
87
+ identity_line=data.get("identity_line", ""),
88
+ portfolio=data.get("portfolio", ""),
89
+ cv_path=str(Path(data.get("cv_path", "")).expanduser()) if data.get("cv_path") else "",
90
+ transcript_path=str(Path(data.get("transcript_path", "")).expanduser()) if data.get("transcript_path") else "",
91
+ degree_status=data.get("degree_status", "bachelors"),
92
+ target_term=data.get("target_term", "Fall 2027"),
93
+ target_degree=data.get("target_degree", "PhD"),
94
+ research_areas=data.get("research_areas", []) or [],
95
+ projects_dir=str(Path(data.get("projects_dir", "")).expanduser()) if data.get("projects_dir") else "",
96
+ seed_projects=data.get("seed_projects", []) or [],
97
+ blog=data.get("blog", {}) or {},
98
+ review_email=data.get("review_email", ""),
99
+ )
100
+
101
+
102
+ def load_env_file() -> None:
103
+ """Load .env from GRAD_AGENT_HOME. Silent if missing."""
104
+ try:
105
+ from dotenv import load_dotenv
106
+ except Exception:
107
+ return
108
+ p = env_path()
109
+ if p.exists():
110
+ load_dotenv(p, override=False)
111
+ # legacy: also try package-local .env
112
+ legacy = PACKAGE_ROOT.parent / ".env"
113
+ if legacy.exists():
114
+ load_dotenv(legacy, override=False)