mgbam commited on
Commit
49687b5
Β·
verified Β·
1 Parent(s): a6b3514

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +11 -21
  2. app.py +131 -504
  3. requirements.txt +2 -7
README.md CHANGED
@@ -1,23 +1,13 @@
1
- ---
2
- license: mit
3
- sdk: gradio
4
- emoji: πŸ“š
5
- colorFrom: yellow
6
- colorTo: green
7
- sdk_version: 5.49.1
8
- ---
9
- # Skill.md Generator + Scaffolder (Hugging Face Space)
10
 
11
- ## πŸš€ Quickstart
12
- 1. Create a new **Gradio** Space.
13
- 2. Upload `app.py` and `requirements.txt` from this bundle.
14
- 3. In **Settings β†’ Repository secrets**, set:
15
- - `CLAUDE_API_KEY` β€” Anthropic key for the generated Python skill
16
- - `GH_TOKEN` β€” GitHub PAT if you want the **Publish to GitHub** button to work
17
- 4. Click **Create**. The UI lets you preview `Skill.md`, download a ZIP, or publish directly to GitHub.
18
 
19
- ## Notes
20
- - The Space writes files to a temp directory and returns a ZIP for download.
21
- - Mermaid code is embedded in `Skill.md` as a fenced block; GitHub renders it natively.
22
- - Upload a `.py` or `.ipynb` to extract functions into `snippets/` in the generated bundle.
23
- - For Node scaffolds, edit the generated `skill.js` to call Claude via `@anthropic-ai/sdk`.
 
 
 
 
 
1
+ # Agent Skills β€” Progressive Disclosure Demo (Hugging Face Space)
 
 
 
 
 
 
 
 
2
 
3
+ **Published Oct 16, 2025 (concept)**
 
 
 
 
 
 
4
 
5
+ This Space demonstrates *Agent Skills* built as folders that contain a `SKILL.md` plus
6
+ optional reference files and executable tools. It includes a sample **PDF Form Assistant** skill
7
+ that can extract form fields from a user-provided PDF via a Python tool.
8
+
9
+ ## What's in here
10
+ - `app.py`: Gradio UI that lists installed skills, shows metadata, loads `SKILL.md`, opens linked files, and runs the PDF tool.
11
+ - `skills/pdf/SKILL.md`: YAML-frontmatter metadata + core guidance.
12
+ - `skills/pdf/reference.md`, `skills/pdf/forms.md`: deeper context for progressive disclosure.
13
+ - `skills/pdf/tools/extract_form_fields.py`: a small tool callable from the UI.
app.py CHANGED
@@ -1,519 +1,146 @@
1
  import gradio as gr
2
- import json, os, re, zipfile, textwrap, tempfile, ast
3
- from datetime import datetime
4
 
5
- # Optional: notebook parsing for .ipynb extraction
6
- try:
7
- import nbformat
8
- except Exception:
9
- nbformat = None
10
 
11
- # GitHub publisher
12
- try:
13
- from github import Github, GithubException
14
- except Exception:
15
- Github = None
16
- class GithubException(Exception):
17
- pass
18
-
19
- # ---------- utils ----------
20
-
21
- def slugify(name: str) -> str:
22
- s = name.strip().lower()
23
- s = re.sub(r"[^a-z0-9\-\_]+", "-", s)
24
- s = re.sub(r"-+", "-", s).strip("-")
25
- return s or "skill"
26
-
27
- DEF_OUTPUT_SCHEMA = {
28
- "type": "object",
29
- "properties": {
30
- "ok": {"type": "boolean"},
31
- "message": {"type": "string"}
32
- },
33
- "required": ["ok", "message"]
34
- }
35
-
36
- DEF_EXAMPLES = [
37
- {"input": {"text": "hello"}, "expected": {"ok": True, "message": "hi"}}
38
- ]
39
-
40
- PY_REQUIREMENTS = """\
41
- fastapi
42
- uvicorn
43
- pydantic
44
- pytest
45
- anthropic
46
- """
47
-
48
- def NODE_PACKAGE_JSON(name: str):
49
- return json.dumps({
50
- "name": name,
51
- "version": "0.1.0",
52
- "type": "module",
53
- "scripts": {"dev": "node server.js"},
54
- "dependencies": {"express": "^4.19.2"},
55
- "devDependencies": {}
56
- }, indent=2)
57
-
58
- README_TMPL = """\
59
- # {skill_name}
60
-
61
- This bundle was generated by the **Skill.md Generator**.
62
-
63
- ## Run (Python / FastAPI)
64
- ```bash
65
- pip install -r requirements.txt
66
- uvicorn server:app --reload --port 7861
67
- ```
68
-
69
- ## Run (Node / Express)
70
- ```bash
71
- npm install
72
- npm run dev
73
- ```
74
-
75
- ## Endpoint
76
- - `POST /run` β†’ `{{"input": {{...}}}}` β†’ returns skill output
77
-
78
- ### Secrets (.env or host env)
79
- - `CLAUDE_API_KEY` β€” required to call Claude
80
- - `GH_TOKEN` (optional) β€” GitHub PAT, only needed for publishing from the Space
81
- - `GH_REPO` (optional) β€” e.g. `owner/name`
82
- - `GH_BRANCH` (optional) β€” defaults to `main`
83
- """
84
-
85
- # ---------- content builders ----------
86
-
87
- def inputs_table_md(inputs):
88
- lines = ["| name | type | required | description |", "|---|---|---|---|"]
89
- for row in inputs:
90
- lines.append(f"| {row.get('name','')} | {row.get('type','')} | {row.get('required', False)} | {row.get('description','')} |")
91
- return "\n".join(lines)
92
-
93
-
94
- def build_mermaid(name: str, inputs, outputs_schema):
95
- nodes = ["flowchart TD", f" A([Input β†’ {name}])-->B{{Skill}}", " B-->C([Output])"]
96
- if inputs:
97
- for i, row in enumerate(inputs):
98
- nm = row.get("name") or f"in{i}"
99
- nodes.append(f" A---I{i}([{nm}])")
100
- # show top-level output keys if object
101
- if isinstance(outputs_schema, dict) and outputs_schema.get("type") == "object":
102
- for i, key in enumerate(outputs_schema.get("properties", {}).keys()):
103
- nodes.append(f" C---O{i}([{key}])")
104
- return "\n".join(nodes)
105
-
106
-
107
- def build_skill_md(meta, inputs, output_schema, examples, tools, include_mermaid=True):
108
- mermaid_code = build_mermaid(meta["name"], inputs, output_schema) if include_mermaid else None
109
-
110
- md = [
111
- f"# {meta['name']} β€” Skill.md", "",
112
- f"**Author:** {meta.get('author','')} ",
113
- f"**Version:** {meta.get('version','0.1.0')} ",
114
- f"**Created:** {datetime.utcnow().isoformat()}Z", "",
115
- "## Overview", meta.get("description", ""), "",
116
- "## Inputs", inputs_table_md(inputs), "",
117
- "## Output Schema",
118
- "```json", json.dumps(output_schema, indent=2), "```", "",
119
- "## External Tools / APIs",
120
- ("\n".join([f"- {t}" for t in tools]) if tools else "- None"), "",
121
- "## Examples (goldens)",
122
- "```json", json.dumps(examples, indent=2), "```", "",
123
- "## Prompt Skeleton",
124
- "```markdown",
125
- textwrap.dedent(f"""
126
- You are the **{meta['name']}** skill. Follow the contract precisely.
127
- - Purpose: {meta.get('purpose','(fill me)')}
128
- - Style: {meta.get('style','concise, factual')}
129
- - Constraints: {meta.get('constraints','must output valid JSON matching schema')}
130
-
131
- Input: {{input}}
132
- Output: JSON matching the schema above.
133
- """),
134
- "```",
135
- ]
136
- if mermaid_code:
137
- md += ["", "## Workflow (Mermaid)", "```mermaid", mermaid_code, "```"]
138
- return "\n".join(md)
139
-
140
-
141
- # ---------- scaffolds ----------
142
-
143
- def scaffold_python(meta, output_schema):
144
- skill_fn = textwrap.dedent(f"""
145
- import os, json
146
- from typing import Dict, Any
147
- from anthropic import Anthropic
148
-
149
- CLAUDE_MODEL = os.getenv("CLAUDE_MODEL", "claude-3-5-sonnet-latest")
150
- CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY", "")
151
- client = Anthropic(api_key=CLAUDE_API_KEY) if CLAUDE_API_KEY else None
152
-
153
- def run_skill(input: Dict[str, Any]) -> Dict[str, Any]:
154
- \"\"\"Calls Claude to fulfill the {meta['name']} contract. Adjust the prompt and parsing as needed.\"\"\"
155
- if client is None:
156
- # Fallback: echo if no API key is present
157
- return {{"ok": True, "message": f"Echo: {{input}}"}}
158
-
159
- system = (
160
- "You are the {meta['name']} skill. Respond with JSON ONLY matching the schema "
161
- "(top-level keys and types) provided by the host application."
162
- )
163
- user_prompt = (
164
- "Given the input below, produce a JSON object that matches the schema, "
165
- "filling fields as best as possible. If unsure, set ok=false and explain in message.\\n\\n"
166
- f"Input: {{json.dumps(input)}}"
167
- )
168
-
169
- msg = client.messages.create(
170
- model=CLAUDE_MODEL,
171
- max_tokens=512,
172
- temperature=0,
173
- system=system,
174
- messages=[{{"role": "user", "content": user_prompt}}],
175
- )
176
- text_parts = []
177
- for part in msg.content:
178
- if getattr(part, "type", None) == "text":
179
- text_parts.append(part.text)
180
- text = "".join(text_parts).strip()
181
-
182
- try:
183
- return json.loads(text)
184
- except Exception:
185
- # If the model returned non-JSON, wrap it
186
- return {{"ok": True, "message": text}}
187
- """)
188
-
189
- server_py = textwrap.dedent("""
190
- from fastapi import FastAPI, HTTPException
191
- from pydantic import BaseModel
192
- from typing import Any, Dict
193
- from skill import run_skill
194
-
195
- app = FastAPI(title="Skill Server")
196
-
197
- class RunReq(BaseModel):
198
- input: Dict[str, Any]
199
-
200
- @app.post("/run")
201
- def run(req: RunReq):
202
- try:
203
- return run_skill(req.input)
204
- except Exception as e:
205
- raise HTTPException(status_code=500, detail=str(e))
206
- """)
207
-
208
- tests = textwrap.dedent("""
209
- from skill import run_skill
210
-
211
- def test_echo():
212
- out = run_skill({"text": "hi"})
213
- assert isinstance(out, dict)
214
- assert "ok" in out
215
- """)
216
-
217
- env = """# Add API keys here
218
- CLAUDE_API_KEY=
219
- CLAUDE_MODEL=claude-3-5-sonnet-latest
220
- GH_TOKEN=
221
- GH_REPO=owner/name
222
- GH_BRANCH=main
223
- """
224
-
225
- return {
226
- "skill.py": skill_fn,
227
- "server.py": server_py,
228
- "requirements.txt": PY_REQUIREMENTS,
229
- "test_skill.py": tests,
230
- ".env.example": env,
231
- }
232
-
233
-
234
- def scaffold_node(meta):
235
- skill_js = textwrap.dedent(f"""
236
- // Core skill logic for {meta['name']} (Node)
237
- // To use Claude in Node, install: npm i @anthropic-ai/sdk
238
- // import Anthropic from '@anthropic-ai/sdk';
239
- // const client = new Anthropic({{ apiKey: process.env.CLAUDE_API_KEY }});
240
- export async function runSkill(input) {{
241
- // TODO: implement real logic with Claude
242
- return {{ ok: true, message: `Echo: ${{JSON.stringify(input)}}` }};
243
- }}
244
- """)
245
-
246
- server_js = textwrap.dedent("""
247
- import express from "express";
248
- import { runSkill } from "./skill.js";
249
-
250
- const app = express();
251
- app.use(express.json());
252
-
253
- app.post("/run", async (req, res) => {
254
- try {
255
- const out = await runSkill(req.body.input || {});
256
- return res.json(out);
257
- } catch (e) {
258
- return res.status(500).json({ error: String(e) });
259
- }
260
- });
261
-
262
- const PORT = process.env.PORT || 7861;
263
- app.listen(PORT, () => console.log(`Skill server on :${PORT}`));
264
- """)
265
-
266
- env = """# Add API keys here
267
- CLAUDE_API_KEY=
268
- CLAUDE_MODEL=claude-3-5-sonnet-latest
269
- GH_TOKEN=
270
- GH_REPO=owner/name
271
- GH_BRANCH=main
272
- """
273
-
274
- return {
275
- "skill.js": skill_js,
276
- "server.js": server_js,
277
- "package.json": NODE_PACKAGE_JSON(slugify(meta["name"])),
278
- ".env.example": env,
279
- }
280
-
281
-
282
- # ---------- extraction (optional) ----------
283
-
284
- def extract_functions_from_py(py_path: str):
285
- with open(py_path, "r", encoding="utf-8") as f:
286
- source = f.read()
287
- tree = ast.parse(source)
288
- funcs = []
289
- for node in tree.body:
290
- if isinstance(node, ast.FunctionDef):
291
- start = node.lineno - 1
292
- end = max([getattr(node, 'end_lineno', start+1) - 1, start])
293
- lines = source.splitlines()
294
- code = "\n".join(lines[start:end+1])
295
- funcs.append({"name": node.name, "code": code})
296
- return funcs
297
-
298
-
299
- def extract_functions_from_ipynb(nb_path: str):
300
- if nbformat is None:
301
  return []
302
- nb = nbformat.read(nb_path, as_version=4)
303
- funcs = []
304
- pattern = re.compile(r"^def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", re.MULTILINE)
305
- for cell in nb.cells:
306
- if cell.get("cell_type") == "code":
307
- code = cell.get("source", "")
308
- for match in pattern.finditer(code):
309
- # crude: include whole cell
310
- funcs.append({"name": match.group(1), "code": code})
311
- return funcs
312
 
 
 
 
 
 
 
313
 
314
- # ---------- GitHub publish ----------
315
-
316
- def publish_folder_to_github(repo_full: str, branch: str, base_path: str, commit_msg: str, token: str, folder_root: str):
317
- if Github is None:
318
- return "PyGithub not installed. Add 'PyGithub' to requirements.txt in the Space.", None
319
- if not token:
320
- return "Missing GH token. Set GH_TOKEN in Space Secrets or provide a token.", None
321
- gh = Github(token)
322
  try:
323
- repo = gh.get_repo(repo_full)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  except Exception as e:
325
- return f"Failed to access repo {repo_full}: {e}", None
326
-
327
- branch = branch or "main"
328
- base_path = (base_path or "").strip("/")
329
-
330
- published = []
331
- for dirpath, _, filenames in os.walk(folder_root):
332
- for fn in filenames:
333
- fp = os.path.join(dirpath, fn)
334
- with open(fp, "r", encoding="utf-8") as f:
335
- content = f.read()
336
- rel = os.path.relpath(fp, folder_root).replace("\\", "/")
337
- dest = f"{base_path}/{rel}" if base_path else rel
338
- try:
339
- existing = repo.get_contents(dest, ref=branch)
340
- repo.update_file(dest, commit_msg, content, existing.sha, branch=branch)
341
- except GithubException:
342
- # create on 404
343
- repo.create_file(dest, commit_msg, content, branch=branch)
344
- published.append(dest)
345
- web_url = f"https://github.com/{repo_full}/tree/{branch}/{base_path}" if base_path else f"https://github.com/{repo_full}/tree/{branch}"
346
- return f"Published {len(published)} files to {repo_full}@{branch}/{base_path}", web_url
347
-
348
-
349
- # ---------- gradio app ----------
350
-
351
- def generate(skill_name, author, description, purpose, style, constraints, language, inputs_df, output_schema_str, examples_str, tools_str, include_mermaid, py_or_ipynb):
352
- # meta
353
- meta = {
354
- "name": skill_name or "MySkill",
355
- "author": author or "",
356
- "version": "0.1.0",
357
- "description": description or "",
358
- "purpose": purpose or "",
359
- "style": style or "",
360
- "constraints": constraints or "",
361
- }
362
-
363
- # inputs
364
- inputs = []
365
- if inputs_df is not None:
366
- # gr.Dataframe can return list of lists; normalize
367
- headers = ["name","type","description","required"]
368
- if isinstance(inputs_df, list):
369
- for row in inputs_df:
370
- if isinstance(row, dict):
371
- inputs.append({k: row.get(k) for k in headers})
372
- elif isinstance(row, (list, tuple)) and len(row) >= 4:
373
- inputs.append({"name": row[0], "type": row[1], "description": row[2], "required": bool(row[3])})
374
- else:
375
- try:
376
- import pandas as pd
377
- if isinstance(inputs_df, pd.DataFrame):
378
- for _, r in inputs_df.iterrows():
379
- inputs.append({"name": r.get("name"), "type": r.get("type"), "description": r.get("description"), "required": bool(r.get("required", False))})
380
- except Exception:
381
- pass
382
-
383
- # schemas/examples/tools
384
- try:
385
- output_schema = json.loads(output_schema_str) if output_schema_str else DEF_OUTPUT_SCHEMA
386
- except Exception:
387
- output_schema = DEF_OUTPUT_SCHEMA
388
-
389
- try:
390
- examples = json.loads(examples_str) if examples_str else DEF_EXAMPLES
391
- except Exception:
392
- examples = DEF_EXAMPLES
393
-
394
- tools = [t.strip() for t in (tools_str or "").splitlines() if t.strip()]
395
-
396
- # optional extraction
397
- snippets = []
398
- if py_or_ipynb is not None:
399
- path = py_or_ipynb.name if hasattr(py_or_ipynb, "name") else str(py_or_ipynb)
400
- if path.endswith(".py"):
401
- snippets = extract_functions_from_py(path)
402
- elif path.endswith(".ipynb"):
403
- snippets = extract_functions_from_ipynb(path)
404
-
405
- # build Skill.md
406
- skill_md = build_skill_md(meta, inputs, output_schema, examples, tools, include_mermaid)
407
-
408
- # scaffold
409
- bundle = {}
410
- if language.startswith("Python"):
411
- bundle.update(scaffold_python(meta, output_schema))
412
- else:
413
- bundle.update(scaffold_node(meta))
414
-
415
- # add skill + readme
416
- bundle["Skill.md"] = skill_md
417
- bundle["README.md"] = README_TMPL.format(skill_name=meta["name"])
418
-
419
- # add snippets
420
- for sn in snippets:
421
- bundle[f"snippets/{sn['name']}.py"] = sn['code']
422
-
423
- # zip
424
- skill_slug = slugify(meta["name"])
425
- tmpdir = tempfile.mkdtemp(prefix=f"skill_{skill_slug}_")
426
- root = os.path.join(tmpdir, skill_slug)
427
- os.makedirs(root, exist_ok=True)
428
-
429
- for rel, content in bundle.items():
430
- full = os.path.join(root, rel)
431
- os.makedirs(os.path.dirname(full), exist_ok=True)
432
- with open(full, "w", encoding="utf-8") as f:
433
- f.write(content)
434
-
435
- zpath = os.path.join(tmpdir, f"{skill_slug}.zip")
436
- with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as zf:
437
- for dirpath, _, filenames in os.walk(root):
438
- for fn in filenames:
439
- fp = os.path.join(dirpath, fn)
440
- arc = os.path.relpath(fp, root)
441
- zf.write(fp, arcname=os.path.join(skill_slug, arc))
442
-
443
- return skill_md, zpath, root
444
-
445
-
446
- def do_publish(repo_full, branch, base_path, commit_msg, token, root):
447
- token = token or os.getenv("GH_TOKEN", "")
448
- if not root:
449
- return "Nothing to publish yet. Click 'Generate' first.", ""
450
- msg, url = publish_folder_to_github(repo_full, branch, base_path, commit_msg, token, root)
451
- link = f"\n\nRepo view: {url}" if url else ""
452
- return msg + link, url or ""
453
-
454
-
455
- with gr.Blocks(title="Skill.md Generator + Scaffolder") as demo:
456
- gr.Markdown("""
457
- # Skill.md Generator + Scaffolder
458
- 1) Fill the form β†’ 2) Generate preview β†’ 3) Download ZIP or Publish to GitHub.
459
- Configure **Space Secrets** for `CLAUDE_API_KEY` (required for live Claude calls in generated Python skill) and `GH_TOKEN` (optional, for publishing).
460
- """)
461
-
462
- with gr.Row():
463
- with gr.Column():
464
- skill_name = gr.Textbox(label="Skill name", value="MySkill")
465
- author = gr.Textbox(label="Author", placeholder="Your name")
466
- description = gr.Textbox(label="Description", lines=3)
467
- with gr.Row():
468
- language = gr.Radio(["Python / FastAPI", "Node / Express"], value="Python / FastAPI", label="Scaffold language")
469
- include_mermaid = gr.Checkbox(value=True, label="Include Mermaid diagram in Skill.md")
470
- purpose = gr.Textbox(label="Purpose (prompt hint)")
471
- style = gr.Textbox(label="Style (prompt hint)", value="concise, factual")
472
- constraints = gr.Textbox(label="Constraints", value="must output valid JSON matching schema")
473
- with gr.Column():
474
- inputs_df = gr.Dataframe(headers=["name","type","description","required"], row_count=(2, "dynamic"), datatype=["str","str","str","bool"], label="Inputs")
475
- output_schema = gr.Code(label="Output JSON Schema", language="json", value=json.dumps(DEF_OUTPUT_SCHEMA, indent=2))
476
- examples = gr.Code(label="Examples (JSON)", language="json", value=json.dumps(DEF_EXAMPLES, indent=2))
477
- tools = gr.Textbox(label="External tools/APIs (one per line)")
478
- py_or_ipynb = gr.File(label="Optional: upload .py or .ipynb to extract functions", file_count="single", type="filepath")
479
-
480
- gen = gr.Button("Generate Preview + ZIP")
481
-
482
- preview = gr.Markdown(label="Skill.md Preview")
483
- zip_out = gr.File(label="Download bundle (ZIP)")
484
-
485
- # Internal states to reuse for GitHub publish
486
- bundle_root_state = gr.State("")
487
- repo_url_state = gr.State("")
488
-
489
- gen.click(
490
- fn=generate,
491
- inputs=[skill_name, author, description, purpose, style, constraints, language, inputs_df, output_schema, examples, tools, include_mermaid, py_or_ipynb],
492
- outputs=[preview, zip_out, bundle_root_state]
493
  )
494
 
495
- gr.Markdown("""
496
- ---
497
- ## πŸ“€ Publish to GitHub (optional)
498
- Provide a repo you own and a Personal Access Token (or set `GH_TOKEN` in Space Secrets). Files are committed to the branch and path you choose.
499
- """)
500
 
501
  with gr.Row():
502
- with gr.Column():
503
- repo_full = gr.Textbox(label="Repo (owner/name)")
504
- branch = gr.Textbox(label="Branch", value="main")
505
- base_path = gr.Textbox(label="Path in repo (e.g., skills/my-skill)", value="skills")
506
- commit_msg = gr.Textbox(label="Commit message", value="Add generated skill bundle")
507
- token_in = gr.Textbox(label="GitHub Token (optional; Space Secret GH_TOKEN is recommended)", type="password")
508
- publish_btn = gr.Button("Publish to GitHub")
509
- with gr.Column():
510
- publish_status = gr.Markdown()
511
-
512
- publish_btn.click(
513
- fn=do_publish,
514
- inputs=[repo_full, branch, base_path, commit_msg, token_in, bundle_root_state],
515
- outputs=[publish_status, repo_url_state]
516
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
 
518
  if __name__ == "__main__":
519
  demo.launch()
 
1
  import gradio as gr
2
+ import os, re, json, textwrap
3
+ from typing import Dict, Tuple
4
 
5
+ SKILLS_ROOT = os.path.join(os.path.dirname(__file__), "skills")
 
 
 
 
6
 
7
+ def parse_frontmatter(md_text: str) -> Tuple[dict, str]:
8
+ if not md_text.startswith("---"):
9
+ return {}, md_text
10
+ parts = md_text.split("\n")
11
+ if parts[0].strip() != "---":
12
+ return {}, md_text
13
+ try:
14
+ end_idx = parts[1:].index("---") + 1
15
+ except ValueError:
16
+ return {}, md_text
17
+ fm_lines = parts[1:end_idx]
18
+ body = "\n".join(parts[end_idx+1:])
19
+ meta = {}
20
+ for line in fm_lines:
21
+ if ":" in line:
22
+ k, v = line.split(":", 1)
23
+ meta[k.strip()] = v.strip().strip('"').strip("'")
24
+ return meta, body
25
+
26
+ def load_skill(skill_slug: str):
27
+ path = os.path.join(SKILLS_ROOT, skill_slug, "SKILL.md")
28
+ if not os.path.exists(path):
29
+ return {}, f"**SKILL.md not found for skill `{skill_slug}`.**"
30
+ with open(path, "r", encoding="utf-8") as f:
31
+ text = f.read()
32
+ meta, body = parse_frontmatter(text)
33
+ return meta, body
34
+
35
+ def list_skills():
36
+ out = []
37
+ if not os.path.isdir(SKILLS_ROOT):
38
+ return out
39
+ for name in sorted(os.listdir(SKILLS_ROOT)):
40
+ if os.path.isdir(os.path.join(SKILLS_ROOT, name)) and os.path.exists(os.path.join(SKILLS_ROOT, name, "SKILL.md")):
41
+ meta, _ = load_skill(name)
42
+ out.append((name, meta.get("name", name), meta.get("description", "")))
43
+ return out
44
+
45
+ def list_linked_files(skill_slug: str):
46
+ root = os.path.join(SKILLS_ROOT, skill_slug)
47
+ if not os.path.isdir(root):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  return []
49
+ return [fn for fn in sorted(os.listdir(root)) if fn.lower().endswith(".md") and fn != "SKILL.md"]
 
 
 
 
 
 
 
 
 
50
 
51
+ def read_linked_file(skill_slug: str, filename: str) -> str:
52
+ path = os.path.join(SKILLS_ROOT, skill_slug, filename)
53
+ if not os.path.exists(path):
54
+ return f"**{filename} not found.**"
55
+ with open(path, "r", encoding="utf-8") as f:
56
+ return f.read()
57
 
58
+ def run_pdf_tool(skill_slug: str, uploaded_pdf) -> str:
 
 
 
 
 
 
 
59
  try:
60
+ if uploaded_pdf is None:
61
+ return "Please upload a PDF."
62
+ import runpy, json as _json, os as _os
63
+ tool_path = os.path.join(SKILLS_ROOT, skill_slug, "tools", "extract_form_fields.py")
64
+ if not os.path.exists(tool_path):
65
+ return "Tool script not found. Expected tools/extract_form_fields.py"
66
+ ns = runpy.run_path(tool_path)
67
+ if "extract_fields" not in ns:
68
+ return "extract_form_fields.py does not define extract_fields(pdf_path)."
69
+ fn = ns["extract_fields"]
70
+ result = fn(uploaded_pdf.name if hasattr(uploaded_pdf, "name") else uploaded_pdf)
71
+ try:
72
+ return "```json\n" + _json.dumps(result, indent=2) + "\n```"
73
+ except Exception:
74
+ return str(result)
75
  except Exception as e:
76
+ return f"Error running tool: {e}"
77
+
78
+ def explain_progressive_disclosure() -> str:
79
+ return (
80
+ "### Progressive Disclosure\\n\\n"
81
+ "1. **Startup**: Only skill *metadata* (name, description) is shown.\\n"
82
+ "2. **Trigger**: Loading a skill reads **SKILL.md** into context.\\n"
83
+ "3. **Deep Dive**: Linked files (e.g., `reference.md`, `forms.md`) are opened only when needed.\\n"
84
+ "4. **Tools**: For the PDF skill, run the Python tool to extract form fields without adding the PDF to context."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  )
86
 
87
+ with gr.Blocks(title="Agent Skills β€” Progressive Disclosure Demo") as demo:
88
+ gr.Markdown("# Equipping agents for the real world with Agent Skills\\nPublished Oct 16, 2025")
89
+ gr.Markdown("This Space demonstrates **Agent Skills** as folders containing a `SKILL.md`, optional linked files, and tools.")
 
 
90
 
91
  with gr.Row():
92
+ with gr.Column(scale=1):
93
+ gr.Markdown("### Installed Skills")
94
+ skills = list_skills()
95
+ skill_map = {f"{title} β€” {desc}": slug for (slug, title, desc) in skills} if skills else {}
96
+ labels = list(skill_map.keys()) or ["(No skills found)"]
97
+ skill_dd = gr.Dropdown(choices=labels, value=labels[0], label="Pick a skill")
98
+ meta_out = gr.JSON(label="Skill metadata")
99
+
100
+ def on_pick(label):
101
+ if not skill_map:
102
+ return {}, "", [], None, None
103
+ slug = skill_map.get(label, None)
104
+ if not slug:
105
+ return {}, "", [], None, None
106
+ meta, body = load_skill(slug)
107
+ return meta, body, list_linked_files(slug), slug, None
108
+
109
+ body_out = gr.Markdown(label="SKILL.md body")
110
+ linked_files = gr.Dropdown(choices=[], label="Linked files")
111
+ selected_slug_state = gr.State(value=None)
112
+ _reset = gr.State(value=None)
113
+
114
+ skill_dd.change(fn=on_pick, inputs=[skill_dd], outputs=[meta_out, body_out, linked_files, selected_slug_state, _reset])
115
+
116
+ with gr.Column(scale=2):
117
+ gr.Markdown("### Progressive Disclosure")
118
+ gr.Markdown(explain_progressive_disclosure())
119
+
120
+ gr.Markdown("---")
121
+ gr.Markdown("### Linked File Viewer")
122
+ linked_view = gr.Markdown()
123
+
124
+ def on_view_linked(filename, slug):
125
+ if not slug or not filename:
126
+ return "Pick a skill and a linked file."
127
+ return read_linked_file(slug, filename)
128
+
129
+ view_btn = gr.Button("Open linked file")
130
+ view_btn.click(fn=on_view_linked, inputs=[linked_files, selected_slug_state], outputs=[linked_view])
131
+
132
+ gr.Markdown("---")
133
+ gr.Markdown("### Run Skill Tool (PDF form field extractor)")
134
+ pdf_in = gr.File(label="Upload a PDF", file_count="single", type="filepath")
135
+ tool_out = gr.Markdown()
136
+
137
+ def run_tool_clicked(slug, pdf):
138
+ if not slug:
139
+ return "Pick a skill first."
140
+ return run_pdf_tool(slug, pdf)
141
+
142
+ run_tool_btn = gr.Button("Extract form fields")
143
+ run_tool_btn.click(fn=run_tool_clicked, inputs=[selected_slug_state, pdf_in], outputs=[tool_out])
144
 
145
  if __name__ == "__main__":
146
  demo.launch()
requirements.txt CHANGED
@@ -1,8 +1,3 @@
1
  gradio>=4.0.0
2
- fastapi
3
- uvicorn
4
- pydantic
5
- pytest
6
- nbformat
7
- anthropic
8
- PyGithub
 
1
  gradio>=4.0.0
2
+ PyYAML
3
+ PyPDF2