diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..3b2900c --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "aaif", + "owner": { + "name": "AAIF", + "url": "https://github.com/aaif" + }, + "description": "Official marketplace for AAIF (Agentic AI Foundation) community meetup tooling.", + "plugins": [ + { + "name": "aaif-meetups", + "source": "./", + "description": "Skills for running AAIF in-person and online meetups: event content writing and chapter/series/community ops.", + "category": "community", + "tags": ["meetups", "events", "community", "content", "google-workspace", "online"] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..08399b6 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "aaif-meetups", + "displayName": "AAIF Meetups Toolkit", + "version": "0.2.0", + "description": "Skills for running AAIF (Agentic AI Foundation) in-person and online meetups — event content writing (announcements, Luma pages, LinkedIn carousels, recaps, speaker bios/invites, day-of slides, attendee reminders) and chapter ops (spin up a new city chapter or online event series, triage community intake, clean intake data).", + "author": { + "name": "AAIF", + "url": "https://github.com/aaif" + }, + "homepage": "https://github.com/aaif/meetups", + "repository": "https://github.com/aaif/meetups", + "license": "MIT", + "keywords": ["aaif", "meetups", "events", "community", "google-workspace", "luma", "linkedin"] +} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..2ec1b0c --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,32 @@ +name: validate + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - uses: pre-commit/action@v3.0.1 # runs `pre-commit run --all-files` + + plugin-validate: + # Anthropic's own validator. The repo root is both the marketplace and the + # single plugin (marketplace source "./"), so one call validates both the + # marketplace.json and the plugin.json schema. SKILL.md frontmatter is + # covered by the check-skill-frontmatter hook in the pre-commit job above. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code + - name: Validate marketplace and plugin manifests + run: claude plugin validate . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b42788 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.DS_Store +.ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..115a131 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,62 @@ +# Pre-commit hooks for the AAIF Meetups plugin marketplace. +# Setup: pipx install pre-commit && pre-commit install +# Run: pre-commit run --all-files +# Update: pre-commit autoupdate +# CI runs the same hooks (see .github/workflows/validate.yml). + +# Generated/cache artifacts pre-commit should never scan. +exclude: '^(.*/__pycache__/.*|.*\.pyc)$' + +repos: + # --- Standard hygiene ------------------------------------------------------- + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] # keep intentional Markdown hard breaks + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-case-conflict + - id: check-added-large-files + args: [--maxkb=1024] + - id: mixed-line-ending + args: [--fix=lf] + - id: check-json # marketplace.json / plugin.json + - id: check-yaml # *.yaml/*.yml only (not SKILL.md frontmatter) + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + + # --- Python: Ruff bug-focused lint (config in pyproject.toml) --------------- + # Lint only (pyflakes + syntax), no reformatting. `--fix` still auto-removes + # unused imports/vars but never restyles code; the helper scripts use a + # deliberately compact style. A formatting sweep, if wanted, is its own PR. + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.20 + hooks: + - id: ruff-check + args: [--fix] + + # --- Spelling (allowlist in pyproject.toml [tool.codespell]) ---------------- + - repo: https://github.com/codespell-project/codespell + rev: v2.4.2 + hooks: + - id: codespell + additional_dependencies: [tomli] + + # --- Secret scanning (these scripts shell out + use urllib) ----------------- + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks + + # --- SKILL.md frontmatter syntax (broken YAML => empty metadata at runtime) -- + # Runs locally on commit and in CI; this hook — not `claude plugin validate`, + # which only checks the manifest schemas — is what catches skill frontmatter. + - repo: local + hooks: + - id: check-skill-frontmatter + name: validate SKILL.md YAML frontmatter + entry: python3 scripts/check_frontmatter.py + language: python + additional_dependencies: [pyyaml] + files: 'SKILL\.md$' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af06caf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to the **AAIF Meetups Toolkit** plugin are documented here. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +plugin version is the `version` field in `.claude-plugin/plugin.json`. + +## [0.2.0] + +### Added +- `aaif-create-online-series` skill — clone the **TemplateSeries** folder under the + top-level **Online** Drive folder and rebrand it for a new online event series + (reading group, paper club, webinar). The online sibling of `aaif-create-chapter`. +- Repo hardening: `$schema` references on both manifests, `.pre-commit-config.yaml`, + Ruff config (`pyproject.toml`), and a `validate` CI workflow (pre-commit + + `claude plugin validate`). + +### Changed +- Manifest descriptions and tags now cover **online** meetups/series, not just + in-person chapters. + +## [0.1.0] + +### Added +- Initial release: 11 skills for running AAIF in-person meetup chapters — content + writing (announcement, carousel, Luma description, speaker invite/bio, day-of + slides, attendee reminder, recap) and chapter ops (`aaif-create-chapter`, + `aaif-triage-intake`, `aaif-clean-data`). +- One-plugin marketplace (`aaif`) packaging the toolkit for `/plugin install`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..985117f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing + +Thanks for helping improve the AAIF Meetups Toolkit. The repo root is both the +marketplace and a single plugin (`aaif-meetups`) — `marketplace.json` and +`plugin.json` sit side by side in `.claude-plugin/`, and the skills live under +`skills/` at the repo root. + +## Adding or editing a skill + +A skill is a folder with a `SKILL.md` (plus an optional `scripts/` dir): + +``` +skills// +├── SKILL.md +└── scripts/ # optional helper scripts +``` + +`SKILL.md` starts with YAML frontmatter: + +```yaml +--- +name: aaif-something +description: One line — what it does AND when to use it ("Use when asked to …"), + so Claude auto-activates it at the right moment. +argument-hint: " [args]" +--- + +# Title +Clear, step-by-step instructions… +``` + +Guidelines: +- **Reference bundled scripts with `${CLAUDE_SKILL_DIR}/scripts/...`**, never a + hardcoded `.claude/skills/...` path — the variable resolves wherever the skill + is installed. +- Keep the `description` action-oriented; it's what triggers auto-activation. +- **Quote `argument-hint` values fully.** A value like `"" [--slug ]` + (a quoted scalar followed by bare text) is *invalid YAML* — the whole + frontmatter then fails to parse and the skill loads with empty metadata + (description dropped, so it never auto-activates). Single-quote the entire + value instead: `argument-hint: ' [--slug ]'`. +- Read and write Google Sheets/Drive **by header name / resource lookup**, not by + fixed column letters, so skills survive layout changes. +- These skills ship with AAIF's own Google resource IDs. If you're adapting them + for another chapter, change the constants at the top of each `scripts/*.py` and + the IDs referenced in the `SKILL.md`. + +## Checks + +This repo ships a [pre-commit](https://pre-commit.com/) config and a `validate` +GitHub Actions workflow. Set up the hooks once: + +```bash +pipx install pre-commit # or: pip install --user pre-commit +pre-commit install # run the hooks on every commit +pre-commit run --all-files # run them now against the whole repo +``` + +The hooks cover JSON/YAML/whitespace hygiene, Ruff (bug-focused lint of the +helper scripts), codespell, gitleaks secret scanning, and a SKILL.md +frontmatter check. + +Then validate the manifests. The repo root is both the marketplace and the +single plugin (marketplace `source: "./"`), so one call validates the +`marketplace.json` **and** the `plugin.json` schema: + +```bash +claude plugin validate . +``` + +This does *not* parse SKILL.md frontmatter — that's covered by the +`check-skill-frontmatter` pre-commit hook above. CI runs both on each PR. +Finally, install your local copy to try it live: + +```bash +/plugin marketplace add ./ # from the repo root +/plugin install aaif-meetups@aaif +``` + +## Pull requests + +Fork, branch, commit, and open a PR against `main`. Keep changes focused and +explain what a reviewer should check. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b952dbc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AAIF + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5549d8d..99ead27 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,152 @@ # AAIF Meetups Toolkit -A Claude Code plugin marketplace of Agent Skills for running **AAIF (Agentic AI -Foundation)** in-person and online meetups. +Agent Skills for running **AAIF (Agentic AI Foundation)** in‑person and online +meetups — from writing the LinkedIn announcement to spinning up a brand‑new city +chapter or online event series. -Setup is landing via pull request. +Packaged as a **Claude Code plugin** (and a one‑plugin marketplace) so any +organizer can install the whole toolkit in two commands. The skills are plain +[Agent Skills](https://code.claude.com/docs/en/skills) (`SKILL.md` files), so they +also work in claude.ai and the Claude Agent SDK — see [Using in other tools](#using-in-other-tools). + +--- + +## Install (Claude Code) + +```bash +/plugin marketplace add aaif/meetups +/plugin install aaif-meetups@aaif +``` + +`marketplace add aaif/meetups` reads `.claude-plugin/marketplace.json` from this +repo; `@aaif` is the marketplace name. After installing, the skills auto‑activate +when you describe a matching task (e.g. “draft the announcement post for our July +meetup”), or invoke one explicitly with `/aaif-meetups:`. + +--- + +## What's inside + +### ✍️ Content skills — no setup required +Pure writing skills. They take the event details you give them and produce copy. + +| Skill | What it writes | +|---|---| +| `aaif-announcement-post` | LinkedIn launch post for when RSVPs open | +| `aaif-carousel-copy` | 6‑slide LinkedIn carousel announcing a meetup | +| `aaif-luma-description` | Luma event‑page description | +| `aaif-speaker-invite` | Warm speaker‑invite DM / email | +| `aaif-speaker-bio` | 60–80 word speaker bio + one‑liner | +| `aaif-dayof-slides` | Slide text for the “Day of Event” deck | +| `aaif-attendee-reminder` | Pre‑event reminder to people who RSVP'd | +| `aaif-recap-post` | Post‑event LinkedIn recap (within 48h) | + +### 🛠️ Ops skills — need Google Workspace access +These drive Google Drive / Sheets through the `gws` CLI (see below). + +| Skill | What it does | Touches | +|---|---|---| +| `aaif-create-chapter` | Clone the **TemplateCity** folder and rebrand every asset for a new city | Google Drive | +| `aaif-create-online-series` | Clone the **TemplateSeries** folder under **Online/** and rebrand it for a new online series | Google Drive | +| `aaif-triage-intake` | Summarize who's awaiting review in the Community Intake sheet + draft outreach | Google Sheets | +| `aaif-clean-data` | Normalize/flag data quality in the Intake sheet (LinkedIn, casing, City=Other…) | Google Sheets | + +> **Heads up — these ship with AAIF's own IDs.** The ops skills reference AAIF's +> Google resources (the Chapters Drive, the Intake Ops spreadsheet ID, Luma slug +> conventions). To run your own chapter, fork and edit the constants at the top of +> each skill's `scripts/*.py` and the IDs in its `SKILL.md`. + +--- + +## Google Workspace access (for the ops skills) + +The ops skills read and write Google Drive/Sheets. Pick **one** of these ways to +give your agent access. The bundled scripts call the **`gws` CLI** out of the box; +the connector and MCP options are alternatives if you'd rather have Claude do the +Drive/Sheets work through tools (you then follow the skill steps interactively +instead of running the script). + +### Option A — `gws` CLI *(what the scripts use)* +The **`gws` CLI** (a Google Workspace command‑line tool) is what the scripts call. +Install it, then authenticate with one of: + +- **Interactive OAuth:** `gws auth login` +- **Credentials file:** set `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/oauth_credentials.json` +- **Pre‑obtained token:** set `GOOGLE_WORKSPACE_CLI_TOKEN=` +- **Client app:** set `GOOGLE_WORKSPACE_CLI_CLIENT_ID` and `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET`, then `gws auth login` + +On your Google Cloud project, enable the **Sheets**, **Drive**, and **Docs** APIs. +Verify with: + +```bash +gws sheets spreadsheets get --params '{"spreadsheetId":""}' +``` + +### Option B — Claude's Google Drive connector +claude.ai and Claude Code can connect Google Drive/Sheets natively via +**Connectors** (Settings → Connectors → Google Drive). Best when you want Claude to +pull event details from Drive docs or read a sheet conversationally. To use it with +the ops skills, run the skill's steps and let Claude operate the connected tools +rather than invoking the `gws` script. + +### Option C — Google Workspace MCP server +Run a Google Workspace **MCP server** and register it with Claude Code: + +```bash +claude mcp add google-workspace -- +# or add it to .mcp.json in your project +``` + +Claude then reads/writes Sheets and Drive through MCP tools. As with the connector, +follow the skill instructions interactively (the scripts themselves assume `gws`). + +--- + +## Using in other tools + +These skills are portable `SKILL.md` files, but not every tool consumes a Claude +Code *plugin*: + +- **Claude Code** — install as the plugin above (native). +- **claude.ai / Claude Agent SDK** — zip a skill folder (the dir containing + `SKILL.md`) and upload it as a Skill. +- **Cursor** — Cursor uses its own `.cursor/rules/*.mdc` format and does **not** + consume Claude Code plugins. You can copy a `SKILL.md`'s instructions into a + Cursor rule, but it won't run the bundled scripts the same way. + +The portable unit is the `SKILL.md`; the *plugin/marketplace* packaging is +Claude‑Code‑specific. + +--- + +## Repo layout + +The repo root is **both** the marketplace and the single plugin — the marketplace +entry's `source` is `"./"`, so there's no extra `plugins//` nesting. + +``` +meetups/ +├── .claude-plugin/ +│ ├── marketplace.json # one-plugin marketplace ("aaif") +│ └── plugin.json # plugin manifest (aaif-meetups) +├── skills/ +│ ├── aaif-announcement-post/SKILL.md +│ ├── aaif-create-chapter/{SKILL.md, scripts/} +│ └── … (12 skills total) +└── README.md +``` + +Bundled scripts are referenced from `SKILL.md` via `${CLAUDE_SKILL_DIR}/scripts/…` +so they resolve correctly once installed. + +--- + +## Contributing + +Issues and PRs welcome — new chapter‑ops skills, content variants, and +genericizing the AAIF‑specific IDs into config are all fair game. Keep each skill's +`description` action‑oriented (“Use when asked to …”) so it auto‑activates well. + +## License + +[MIT](LICENSE) © AAIF diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e362f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +# Tooling config for the repo's helper scripts (skills/*/scripts/*.py). +# These are stdlib-only utilities invoked by skills; there is no package to build. + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +# Bug-focused only: pyflakes (undefined names, unused imports/vars) + syntax +# errors. The scripts are written in a deliberately compact style, so stylistic +# and auto-modernizing rules (E-series, pyupgrade, formatting) are intentionally +# left off — a formatting sweep, if wanted, belongs in its own PR. +select = ["F", "E9"] + +[tool.codespell] +ignore-words-list = "aaif,luma,lu,ba,tre" diff --git a/scripts/check_frontmatter.py b/scripts/check_frontmatter.py new file mode 100755 index 0000000..9200df5 --- /dev/null +++ b/scripts/check_frontmatter.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Validate the YAML frontmatter of SKILL.md files. + +A SKILL.md whose frontmatter fails to parse loads at runtime with *empty +metadata* — every field (including the `description` that drives auto-activation) +is silently dropped. `claude plugin validate` does NOT parse skill frontmatter, +so this hook is the guard — it runs both locally on commit and in CI (the +pre-commit job). Requires PyYAML (pulled in by pre-commit). +""" +import re +import sys + +import yaml + +FRONTMATTER = re.compile(r"^---\n(.*?)\n---", re.S) + + +def check(path: str) -> list[str]: + text = open(path, encoding="utf-8").read() + m = FRONTMATTER.match(text) + if not m: + return [f"{path}: no `---` YAML frontmatter block at the top of the file"] + try: + data = yaml.safe_load(m.group(1)) + except yaml.YAMLError as e: + first = str(e).splitlines()[0] + return [f"{path}: frontmatter is not valid YAML ({first})"] + if not isinstance(data, dict): + return [f"{path}: frontmatter must be a YAML mapping"] + errors = [] + for field in ("name", "description"): + if not isinstance(data.get(field), str) or not data[field].strip(): + errors.append(f"{path}: frontmatter is missing a non-empty `{field}`") + return errors + + +def main(argv: list[str]) -> int: + problems = [err for path in argv for err in check(path)] + for err in problems: + print(err, file=sys.stderr) + return 1 if problems else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/skills/aaif-announcement-post/SKILL.md b/skills/aaif-announcement-post/SKILL.md new file mode 100644 index 0000000..e530ae2 --- /dev/null +++ b/skills/aaif-announcement-post/SKILL.md @@ -0,0 +1,42 @@ +--- +name: aaif-announcement-post +description: Write the LinkedIn launch/announcement post for an AAIF meetup when RSVPs open. Use when asked to draft the announcement, launch post, or "RSVPs are open" post for an AAIF event. +argument-hint: [event title / paste tracker entry] +--- + +# AAIF Event Announcement (LinkedIn) + +The launch post for when RSVPs open. Structure: **one-line hook**, the +**what/when/where**, **speakers + topic with one takeaway**, **RSVP CTA**. +~120 words, scannable, **at most one emoji, max 3 hashtags**. + +**House voice:** share the practice, never sell the product. Specific over grand, +builder-to-builder. Signal, not numbers. The RSVP line MUST be a Luma link. Edit +the draft before it ships. + +## Input (from the event tracker) +- Chapter : `[CHAPTER]` +- Event : `[EVENT TITLE] ([SERIES])` +- Theme : `[THEME ONE-LINER]` +- When : `[DATE & TIME]` +- Venue : `[VENUE / CITY]` +- RSVP : `[LUMA URL]` +- Speakers : `[SPEAKER + TOPIC; DEMO NAMES]` + +## Example (tested — match this format and voice) +Agentic AI Night: + +> Agents in production: what's working at scale, and what the demos never showed. +> +> AAIF San Francisco is back with Agentic AI Night — our Launch Series — on Tue, +> June 24, 17:30 in SoMa. +> +> Maya Chen (payments) opens with tool calling at 10M requests a day: the retries, +> the idempotency, the things that only break in prod. Then three 5-minute +> community demos from Diego Alvarez, Priya Nair, and one open slot. +> +> Vendor-neutral, curated, builder-first. No pitches — just people who ship. Seats +> are limited. +> +> RSVP → lu.ma/aaif-sanfrancisco +> #AgenticAI #MCP diff --git a/skills/aaif-attendee-reminder/SKILL.md b/skills/aaif-attendee-reminder/SKILL.md new file mode 100644 index 0000000..595bdc2 --- /dev/null +++ b/skills/aaif-attendee-reminder/SKILL.md @@ -0,0 +1,26 @@ +--- +name: aaif-attendee-reminder +description: Write the pre-event reminder to people who RSVP'd to an AAIF meetup (sent ~1 week out and the morning of). Use when asked to draft the attendee reminder / "see you tomorrow" note for an AAIF event. +argument-hint: [event title / paste tracker entry] +--- + +# AAIF Attendee Reminder + +Sent to RSVPs ~1 week out and the morning of. Short, logistics-first (~70 words). +**Lead with date, time, and exact venue / entry.** One line on the speaker. **End +by asking them to update their RSVP if plans change** so the seat can be released. + +**House voice:** warm, concrete, builder-to-builder. Signal, not numbers. + +## Input (from the event tracker) +- Event : `[EVENT TITLE]` When: `[DATE], doors [TIME]` +- Venue : `[VENUE / ENTRY NOTES]` Speaker: `[SPEAKER + TOPIC]` + +## Example (tested — match this format and voice) +Agentic AI Night: + +> You're set for Agentic AI Night this Tuesday, June 24 — doors 17:30 in SoMa, San +> Francisco (exact address and door code land in your inbox the morning of). Maya +> Chen opens with tool calling at 10M requests a day, then three quick community +> demos. If your plans change, please update your RSVP so we can pass your seat to +> the waitlist. See you there. diff --git a/skills/aaif-carousel-copy/SKILL.md b/skills/aaif-carousel-copy/SKILL.md new file mode 100644 index 0000000..51df124 --- /dev/null +++ b/skills/aaif-carousel-copy/SKILL.md @@ -0,0 +1,34 @@ +--- +name: aaif-carousel-copy +description: Write copy for a 6-slide LinkedIn carousel announcing an AAIF meetup. Use when asked to draft carousel slides/copy for an AAIF event (built from the LinkedIn Carousel template). +argument-hint: [event title / paste tracker entry] +--- + +# AAIF LinkedIn Carousel Copy + +Caption text for a **6-slide** LinkedIn carousel built from the AAIF LinkedIn +Carousel template. Each slide: a **headline (max 7 words)** + one short supporting +line. **Slide 1 hooks, slide 6 is the CTA.** + +**House voice:** share the practice, never sell the product. Specific over grand, +builder-to-builder. Signal, not numbers. + +**Workflow:** update the LinkedIn Carousel deck (`Event Name/LinkedIn Carousel.pptx` +in the chapter's Drive folder) with this copy, export it as a PDF, then post the PDF. + +## Input (from the event tracker) +- Event : `[EVENT TITLE] ([SERIES]) — [THEME]` +- Speakers : `[SPEAKER + TOPIC; DEMO NAMES]` +- When : `[DATE & TIME]` Where: `[VENUE / CITY]` RSVP: `[LUMA URL]` + +## Example (tested — match this format and voice) +Agentic AI Night: + +| # | Headline | Supporting line | +|---|----------|-----------------| +| 1 | Agents in production. | What works at scale — and what doesn't. | +| 2 | Tool calling at 10M/day. | Maya Chen on what broke, and the fixes. | +| 3 | Three live demos. | AGENTS.md at monorepo scale. Sandboxing goose. | +| 4 | Builder-first, always. | Vendor-neutral. No pitches. People who ship. | +| 5 | Tue June 24 — 17:30. | SoMa, San Francisco. Doors at 5:30. | +| 6 | Grab a seat. | Curated + limited. RSVP at lu.ma/aaif-sanfrancisco | diff --git a/skills/aaif-clean-data/SKILL.md b/skills/aaif-clean-data/SKILL.md new file mode 100644 index 0000000..8548c05 --- /dev/null +++ b/skills/aaif-clean-data/SKILL.md @@ -0,0 +1,79 @@ +--- +name: aaif-clean-data +description: Normalize and fix data quality in the AAIF Community Intake Ops sheet — canonicalize LinkedIn URLs, fix name/city casing & whitespace, resolve City="Other", flag bad/missing emails and duplicates, and surface broken rows in bright red. Reports & proposes by default; only writes on explicit approval. Use when asked to clean up / normalize / fix the intake data. +argument-hint: "[scan|apply|flags]" +--- + +# Clean AAIF Intake Data + +Normalize the intake data **without silently changing it**: detect issues, propose +fixes with a before→after diff, and only write when the user approves. Fixes are +applied to the **source** tab `Form Responses` (id `1cWkjCI5AGK9RX_fs23P5jRA4I2nixgnHuapvwHseZ5o`) +so the cleaned values flow through to the computed role tabs. Every applied change +is noted per row in an **`Autofixes`** column on `Form Responses` (created on first +use) — provenance for what the cleanup touched. + +Prereq: the `gws` CLI must be installed and authenticated (`gws-cli-access` memory). +See `aaif-intake-ops-sheet` memory for the sheet's structure. All reads/writes go +by **header name**, never column letter. + +## The three modes (engine: `scripts/clean.py`) + +1. **Scan (default, read-only)** — detect & propose: + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/clean.py scan # human-readable + python3 ${CLAUDE_SKILL_DIR}/scripts/clean.py scan --json # structured + ``` + Mechanical fixes proposed automatically: trim/collapse whitespace, re-case + clearly all-upper/all-lower names & cities, canonicalize LinkedIn URLs + (`https://www.linkedin.com/in/...`, strip tracking params & trailing slash). + Flags raised (need judgment): `City="Other"`, missing/invalid email, duplicate + email, LinkedIn that isn't a profile URL, missing name. + +2. **Apply (writes, on approval only)** — feed an approved change list: + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/clean.py apply changes.json + ``` + `changes.json` is `[{"row": , "header": "", "value": ""}]`. + Writes those cells in `Form Responses` and notes what changed per row in the + `Autofixes` column. + +3. **Install-flags (maintenance)** — add/refresh the live error flag: + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/clean.py install-flags + ``` + Adds an `Issues` column (live `ARRAYFORMULA`) to each role tab plus a + top-priority conditional rule that turns the whole row **bright red** whenever + there's a genuine error — **missing/invalid email or a broken LinkedIn URL**. It + auto-clears once fixed, and is distinct from the light-red "Denied" status. + `City="Other"` is deliberately **NOT** an error (it's a normalization to resolve, + surfaced by `scan`), so it never turns a row red. Already installed — re-run only + to refresh after a column move. + +## Procedure + +1. **Scan** and show the user the proposed mechanical fixes and the flags, grouped + and skimmable. Lead with anything that blocks usability (missing/invalid email). +2. **Resolve judgment flags yourself before asking the user to.** For each + `City="Other"` row, read that person's free-text in `Form Responses` (their + "Why organize / ties", "Have you helped run events before?", LinkedIn, etc.) and + infer the real city — e.g. Bangalore, Frankfurt, Luxembourg. Write the inferred + value into the **`Resolved City`** column (a dedicated source column at `CE`), + **never overwrite the submitted `City` dropdown** — that's the non-destructive + rule. The role tabs show `Resolved City` right after `City`, and a row stops + being flagged once `Resolved City` is filled. Don't guess with no signal. +3. **Confirm with the user** which fixes to apply. Mechanical fixes are safe to + batch; city resolutions should be eyeballed since they're inferred. +4. **Build `changes.json`** (rows + header names + new values) and run `apply`. + Re-run `scan` to confirm the diff shrank and check the `Autofixes` column. +5. Mechanical fixes are idempotent — running scan again after apply should show + them gone. + +## Notes & guardrails + +- **Never** edit the role tabs' computed columns; fixes go to `Form Responses`. +- Name re-casing only triggers on clearly all-upper/all-lower input (won't mangle + "McDonald", "von Neumann"); when unsure it leaves the value alone — verify odd ones. +- Don't sort/insert rows in `Form Responses` (breaks row alignment everywhere). +- Duplicate-email flag surfaces repeat submissions; decide which row wins before + acting — the engine won't merge or delete rows. diff --git a/skills/aaif-clean-data/scripts/clean.py b/skills/aaif-clean-data/scripts/clean.py new file mode 100755 index 0000000..92f8938 --- /dev/null +++ b/skills/aaif-clean-data/scripts/clean.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Data cleanup engine for the AAIF Community Intake Ops sheet. + +Operates on the SOURCE tab (`Form Responses`) so cleaned values flow through the +computed role tabs. Reads/writes columns by HEADER NAME, never by letter. + +Subcommands: + scan Detect & propose mechanical normalizations (dry-run). --json for data. + apply FILE Apply an approved list of changes (JSON: [{row,header,value}]), + writing to Form Responses and noting what changed per row in the + "Autofixes" column (created if missing). + install-flags Add/refresh the live "Issues" column + bright-red row rule on the + role tabs (Organizers/Hosts/Speakers). + +Nothing is written unless you run `apply` or `install-flags`. `scan` only reports. +""" +import argparse, json, re, subprocess, sys + +SHEET_ID = "1cWkjCI5AGK9RX_fs23P5jRA4I2nixgnHuapvwHseZ5o" +SOURCE = "Form Responses" +ROLE_TABS = {"Organizers": 537599805, "Hosts": 1923799643, "Speakers": 1491913647} +BRIGHT_RED = {"red": 0.91, "green": 0.26, "blue": 0.21} + +# Common person fields live on these source headers. +H_NAME, H_EMAIL, H_LINKEDIN, H_CITY = "Full name", "Email", "LinkedIn URL", "City" +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + +# Per-row provenance for applied edits is recorded in this Form Responses column +# (not a separate log tab). Phrases keep cells short; falls back to " updated". +AUTOFIX_COL = "Autofixes" +AUTOFIX_PHRASE = {"LinkedIn URL": "LinkedIn normalized", "Email": "email normalized", + "Full name": "name normalized", "Resolved City": "city resolved"} + + +# ---------- gws helpers ---------- +def gws(args): + out = subprocess.run(["gws"] + args, capture_output=True, text=True) + if out.returncode != 0: + sys.exit(f"gws error: {' '.join(args[:4])}...\n{out.stderr.strip()}") + txt = out.stdout + i = min((txt.index(c) for c in "{[" if c in txt), default=-1) + return json.loads(txt[i:]) if i >= 0 else {} + + +def read_tab(tab, rng="A1:CJ"): + d = gws(["sheets", "spreadsheets", "values", "get", "--params", + json.dumps({"spreadsheetId": SHEET_ID, "range": f"{tab}!{rng}", + "majorDimension": "ROWS"}), "--format", "json"]) + vals = d.get("values", []) + if not vals: + return [], [] + hdr = [h.strip() for h in vals[0]] + rows = [r + [""] * (len(hdr) - len(r)) for r in vals[1:]] + return hdr, rows + + +def colletter(n): # 1-based -> A1 letter + s = "" + while n: + n, r = divmod(n - 1, 26) + s = chr(65 + r) + s + return s + + +# ---------- normalizers ---------- +def smart_title(s): + def fix(w): + if not w: + return w + low = w.lower() + if low in ("von", "van", "de", "da", "del", "der", "la", "di"): + return low + if low.startswith("mc") and len(low) > 2: + return "Mc" + low[2:].capitalize() + if "-" in w: + return "-".join(fix(p) for p in w.split("-")) + if "'" in w: + return "'".join(p[:1].upper() + p[1:] for p in low.split("'")) + return w[:1].upper() + w[1:].lower() + return " ".join(fix(w) for w in s.split()) + + +def norm_name(s): + t = " ".join(s.split()) + # only re-case when the whole string is clearly all-lower or all-upper + if t and (t == t.lower() or t == t.upper()) and any(c.isalpha() for c in t): + cand = smart_title(t) + if cand != t: + return cand + return t + + +def norm_email(s): + return " ".join(s.split()).lower() + + +def norm_linkedin(s): + t = s.strip() + if not t: + return t + t = re.sub(r"^https?://", "", t, flags=re.I).strip() + t = re.sub(r"^www\.", "", t, flags=re.I) + t = t.split("?")[0].split("#")[0].rstrip("/") + if t.lower().startswith("linkedin.com"): + t = "linkedin.com" + t[len("linkedin.com"):] + return "https://www." + t + return "https://" + t # leave non-linkedin hosts visible (will be flagged) + + +def norm_city(s): + t = " ".join(s.split()) + if t and (t == t.lower() or t == t.upper()): + return smart_title(t) + return t + + +# ---------- scan ---------- +def idx(hdr, name): + return hdr.index(name) if name in hdr else None + + +def scan(): + hdr, rows = read_tab(SOURCE) + ni, ei, li, ci = (idx(hdr, h) for h in (H_NAME, H_EMAIL, H_LINKEDIN, H_CITY)) + # Reading by header name survives a reorder, not a *rename*: if a required + # column is gone, fail loudly instead of reporting "nothing to fix". + missing = [h for h, i in ((H_NAME, ni), (H_EMAIL, ei)) if i is None] + if missing: + sys.exit("ABORT: required column(s) %s not found in %r tab. Headers present: %s" + % (", ".join(missing), SOURCE, hdr)) + ri = idx(hdr, "Resolved City") # if filled, City="Other" is already resolved + changes, flags = [], [] + seen_email = {} + for rn, row in enumerate(rows, start=2): + # ni/ei guaranteed non-None above, so row[ni]/row[ei] are safe. + if not (row[ni] or row[ei] or "").strip(): + continue + def prop(i, fn, header): + if i is None: + return + old = row[i] + new = fn(old) + if new != old and old.strip(): + changes.append({"row": rn, "header": header, "old": old, "new": new}) + prop(ni, norm_name, H_NAME) + prop(ei, norm_email, H_EMAIL) + prop(li, norm_linkedin, H_LINKEDIN) + prop(ci, norm_city, H_CITY) + # flags (not auto-fixable mechanically) + email = (row[ei] if ei is not None else "").strip().lower() + name = (row[ni] if ni is not None else "").strip() + link = (row[li] if li is not None else "").strip().lower() + city = (row[ci] if ci is not None else "").strip() + who = name or email or f"row {rn}" + if not email: + flags.append({"row": rn, "who": who, "issue": "missing email"}) + elif not EMAIL_RE.match(email): + flags.append({"row": rn, "who": who, "issue": f"invalid email: {email}"}) + if not name: + flags.append({"row": rn, "who": who, "issue": "missing name"}) + if link and "linkedin.com/" not in link: + flags.append({"row": rn, "who": who, "issue": f"LinkedIn not a profile URL: {row[li].strip()}"}) + resolved = (row[ri].strip() if ri is not None and ri < len(row) else "") + if city.lower() == "other" and not resolved: + flags.append({"row": rn, "who": who, "issue": "city=Other (resolve into 'Resolved City' from their text)"}) + if email: + seen_email.setdefault(email, []).append(rn) + for email, rns in seen_email.items(): + if len(rns) > 1: + flags.append({"row": rns[0], "who": email, "issue": f"duplicate email in rows {rns}"}) + return changes, flags + + +def print_scan(changes, flags): + print(f"Cleanup scan of '{SOURCE}' — {len(changes)} proposed fixes, {len(flags)} flags\n") + if changes: + print("PROPOSED NORMALIZATIONS (apply to clean):") + for c in changes: + print(f" row {c['row']:>3} {c['header']:<13} {c['old']!r} -> {c['new']!r}") + print() + if flags: + print("FLAGS (need a human / judgment call):") + for f in flags: + print(f" row {f['row']:>3} [{f['issue']}] {f['who']}") + if not changes and not flags: + print("Clean — nothing to fix.") + + +# ---------- apply ---------- +def apply(path): + with open(path) as fh: + wanted = json.load(fh) + hdr, rows = read_tab(SOURCE) + ai = idx(hdr, AUTOFIX_COL) + if ai is None: # create the Autofixes column at the end of the source headers + ai = len(hdr) + gws(["sheets", "spreadsheets", "values", "update", "--params", + json.dumps({"spreadsheetId": SHEET_ID, + "range": f"{SOURCE}!{colletter(ai + 1)}1", "valueInputOption": "RAW"}), + "--json", json.dumps({"values": [[AUTOFIX_COL]]}), "--format", "json"]) + data, notes = [], {} + for ch in wanted: + rn, header, new = ch["row"], ch["header"], ch["value"] + ci = idx(hdr, header) + if ci is None: + print(f" skip: no column named {header!r}", file=sys.stderr) + continue + data.append({"range": f"{SOURCE}!{colletter(ci + 1)}{rn}", "values": [[new]]}) + notes.setdefault(rn, []).append(AUTOFIX_PHRASE.get(header, f"{header} updated")) + if not data: + print("No changes to apply.") + return + gws(["sheets", "spreadsheets", "values", "batchUpdate", "--params", + json.dumps({"spreadsheetId": SHEET_ID}), "--json", + json.dumps({"valueInputOption": "USER_ENTERED", "data": data}), "--format", "json"]) + # annotate each touched row's Autofixes cell (append, preserving prior notes) + fix = [] + for rn, phrases in notes.items(): + prior = rows[rn - 2][ai] if (rn - 2 < len(rows) and ai < len(rows[rn - 2])) else "" + uniq = [] + for p in phrases: + if p not in uniq: + uniq.append(p) + note = "; ".join(uniq) + combined = f"{prior} | {note}" if prior.strip() else note + fix.append({"range": f"{SOURCE}!{colletter(ai + 1)}{rn}", "values": [[combined]]}) + gws(["sheets", "spreadsheets", "values", "batchUpdate", "--params", + json.dumps({"spreadsheetId": SHEET_ID}), "--json", + json.dumps({"valueInputOption": "RAW", "data": fix}), "--format", "json"]) + print(f"Applied {len(data)} change(s); annotated 'Autofixes' on {len(notes)} row(s).") + + +# ---------- install live Issues flag + bright-red rule ---------- +def install_flags(): + for tab, sid in ROLE_TABS.items(): + hdr, _ = read_tab(tab, "A1:BB") + def L(name): + return colletter(hdr.index(name) + 1) if name in hdr else None + ts = L("Timestamp") + email = L("Email") + link = L("LinkedIn") + if "Issues" in hdr: + icol = hdr.index("Issues") + 1 + else: + icol = len(hdr) + 1 + ilet = colletter(icol) + # ARRAYFORMULA building a "; "-joined list of *errors*, blank when clean. + # NOTE: City="Other" is a normalization opportunity, NOT an error -> not here + # (it must not turn the row bright red). It's surfaced by `scan` instead. + parts = [] + if email: + parts.append(f'IF(ISNUMBER(SEARCH("@",${email}2:${email})),"","missing/bad email; ")') + if link: + parts.append(f'IF(REGEXMATCH(LOWER(${link}2:${link}),"linkedin\\.com/"),"","bad LinkedIn; ")') + concat = "&".join(parts) if parts else '""' + formula = (f'=ARRAYFORMULA(IF(${ts}2:${ts}="","",' + f'REGEXREPLACE({concat},"; $","")))') + # write header + formula + gws(["sheets", "spreadsheets", "values", "batchUpdate", "--params", + json.dumps({"spreadsheetId": SHEET_ID}), "--json", + json.dumps({"valueInputOption": "USER_ENTERED", "data": [ + {"range": f"{tab}!{ilet}1", "values": [["Issues"]]}, + {"range": f"{tab}!{ilet}2", "values": [[formula]]}]}), "--format", "json"]) + # add the bright-red rule only if not already installed (Issues was absent) + if "Issues" not in hdr: + rng = {"sheetId": sid, "startRowIndex": 1, "endRowIndex": 1000, + "startColumnIndex": 0, "endColumnIndex": icol} + req = {"addConditionalFormatRule": {"index": 0, "rule": {"ranges": [rng], "booleanRule": { + "condition": {"type": "CUSTOM_FORMULA", + "values": [{"userEnteredValue": f'=${ilet}2<>""'}]}, + "format": {"backgroundColor": BRIGHT_RED, + "textFormat": {"foregroundColor": {"red": 1, "green": 1, "blue": 1}, "bold": True}}}}}} + # bold header for the new column too + hdrfmt = {"repeatCell": {"range": {"sheetId": sid, "startRowIndex": 0, "endRowIndex": 1, + "startColumnIndex": icol - 1, "endColumnIndex": icol}, + "cell": {"userEnteredFormat": {"textFormat": {"bold": True}, + "backgroundColor": {"red": 0.85, "green": 0.85, "blue": 0.85}}}, + "fields": "userEnteredFormat.textFormat.bold,userEnteredFormat.backgroundColor"}} + gws(["sheets", "spreadsheets", "batchUpdate", "--params", + json.dumps({"spreadsheetId": SHEET_ID}), "--json", + json.dumps({"requests": [req, hdrfmt]}), "--format", "json"]) + print(f"{tab}: Issues column at {ilet}, bright-red rule " + f"{'kept' if 'Issues' in hdr else 'added'}.") + + +def main(): + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + sp = sub.add_parser("scan"); sp.add_argument("--json", action="store_true") + ap_apply = sub.add_parser("apply"); ap_apply.add_argument("file") + sub.add_parser("install-flags") + a = ap.parse_args() + if a.cmd == "scan": + changes, flags = scan() + if a.json: + print(json.dumps({"changes": changes, "flags": flags}, indent=1)) + else: + print_scan(changes, flags) + elif a.cmd == "apply": + apply(a.file) + elif a.cmd == "install-flags": + install_flags() + + +if __name__ == "__main__": + main() diff --git a/skills/aaif-create-chapter/SKILL.md b/skills/aaif-create-chapter/SKILL.md new file mode 100644 index 0000000..860fc64 --- /dev/null +++ b/skills/aaif-create-chapter/SKILL.md @@ -0,0 +1,92 @@ +--- +name: aaif-create-chapter +description: Create a new AAIF city chapter in the "Chapters" Google Drive by cloning TemplateCity and rebranding all assets. Use when asked to add/launch/set up a new AAIF city, chapter, or location. +argument-hint: ' [--slug ]' +--- + +# Create AAIF Chapter + +Spin up a new AAIF city "chapter" by cloning the **TemplateCity** folder in the +**Chapters** Google Drive and rebranding every Office file from San Francisco to +the new city. Each chapter folder is the standard template: `Event Tracker.docx`, +`Attendee CRM.xlsx`, `SKILLS.md.docx`, and the `Event Name/` + `Banners (...)/` +subfolders of `.pptx` design assets. + +Prereq: the `gws` CLI must be installed and authenticated (see the user's +`gws-cli-access` memory). All Drive calls go through it. + +## What gets replaced (and what does NOT) + +The rebrand swaps two tokens and leaves everything else alone. Event-specific +content — dates ("JUNE 24"), speakers ("Maya Chen"), venue, agenda, the SoMa / +"SOUTH OF MARKET" neighbourhood placeholder — is **template content** that +organizers fill per-event later using the prompts inside `SKILLS.md.docx`. Do not +touch it. + +| Token in template | Becomes | Notes | +|---|---|---| +| `San Francisco` / `SAN FRANCISCO` | new city, case-matched | contiguous in the clean template | +| `SF` abbreviation (`AAIF · SF`, `SF CHAPTER`, `About the AAIF SF Chapter`, `AAIF SF — Attendee CRM`, doc metadata) | full city name | **UPPER** in all-caps contexts, **Title case** in prose | +| `aaif-sanfrancisco` / `AAIF-SANFRANCISCO` (Luma slug, incl. hyperlink targets) | `aaif-` / `AAIF-` | see slug rules below | + +## Luma slug rules + +- Default slug = city lowercased, spaces/accents removed: `New York → newyork`, + `Mexico City → mexicocity`, `Montréal → montreal`. +- **Exceptions exist** — e.g. **Denver's page lives at `aaif-colorado`**, not + `aaif-denver`. Always confirm the live page; pass `--slug` to override. +- Live pages resolve at both `https://luma.com/aaif-` and + `https://lu.ma/aaif-`. The design files display the brand form + `LU.MA / AAIF-`; keep that — only the slug changes. +- The script **cannot create the Luma page** (that's done manually at luma.com). + It checks whether the page is live and warns if not. + +## Procedure + +1. **Confirm the city name and slug with the user.** Ask for the exact display + name (with spaces, e.g. "New York") and whether the Luma page exists / what + its slug is. If they don't know, the default slug is fine — the script will + tell you if it's not live. + +2. **Dry run first** to surface the slug, Luma status, and any name collision: + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/create_chapter.py \ + --city "New York" --dry-run + ``` + - If it aborts with "already exists", stop — the chapter is already there. + - If Luma shows NOT LIVE, tell the user the page needs creating at + `luma.com/aaif-` (or that the slug differs — re-run with `--slug`). + +3. **Create the chapter:** + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/create_chapter.py \ + --city "New York" # add --slug if overriding + ``` + The script clones TemplateCity → a new `` folder under Chapters, then + downloads, rebrands, and re-uploads each `.pptx/.docx/.xlsx` in place. It + prints a tree and flags any file with `!! residual` tokens. + +4. **Verify.** Confirm the run printed no `!! residual` flags and report the new + folder URL to the user. If the Luma page wasn't live, remind them to create it. + +## How it works / maintenance + +`scripts/create_chapter.py` is the engine. It rebrands at the paragraph level +(concatenate the text runs, transform, write back into the first run) so it is +robust to OOXML run-splitting. The `SF`-abbreviation casing is decided by the +surrounding words. The Drive layer uses `gws` (`files.copy`, `create`, `get`, +`update`). + +To validate the engine after any edit, rebrand a throwaway copy of the template +and diff it against an existing chapter (the canonical end-state): +```bash +python3 ${CLAUDE_SKILL_DIR}/scripts/create_chapter.py \ + --city "Los Angeles" --rebrand-local /path/to/template-copy +# then compare paragraph text against the real Los Angeles chapter +``` + +Constants (Chapters parent id, TemplateCity id) live at the top of the script. +The template must stay "clean": `San Francisco` contiguous (no run/paragraph +splits) and the slug normalized to `aaif-sanfrancisco`. If a future template edit +re-introduces a split, the paragraph-level engine still handles it, but the big +stacked title on Carousel slide 2 is intentionally a single adaptive line. diff --git a/skills/aaif-create-chapter/scripts/create_chapter.py b/skills/aaif-create-chapter/scripts/create_chapter.py new file mode 100755 index 0000000..dfd8661 --- /dev/null +++ b/skills/aaif-create-chapter/scripts/create_chapter.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Create a new AAIF city chapter by cloning the TemplateCity folder and +rebranding every Office file from "San Francisco" to the new city. + +Two replacement tokens are swapped (event content like dates/speakers is left +untouched - organizers fill that per-event using the SKILLS.md prompts): + + 1. City name "San Francisco" / "SAN FRANCISCO" -> new city (case matched) + plus the "SF" abbreviation (AAIF SF, SF CHAPTER, ...) -> new + city, upper-cased in all-caps contexts and Title-cased in prose. + 2. Luma slug aaif-sanfrancisco / aaif-sf -> aaif- + +Usage: + # Dry run - show what would happen, create nothing: + python create_chapter.py --city "New York" --dry-run + + # Create the chapter in Drive: + python create_chapter.py --city "New York" + + # Override the Luma slug (e.g. Denver's page lives at aaif-colorado): + python create_chapter.py --city "Denver" --slug colorado + + # Test the text engine on a local folder of .pptx/.docx/.xlsx (no Drive): + python create_chapter.py --city "Los Angeles" --rebrand-local ./somedir +""" +import argparse, html, json, os, re, subprocess, sys, time, unicodedata, urllib.error, urllib.request, zipfile + +CHAPTERS_PARENT = "1IQ1K7aVOKUUkxAcfLuNjdETEnmavvtjx" # the "Chapters" Drive folder +TEMPLATE_FOLDER = "1PHvEgqnHo0RrsFyA47O9iRJGaKehC8Eg" # the "TemplateCity" folder +SOURCE_NAME, SOURCE_UPPER = "San Francisco", "SAN FRANCISCO" + +PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation" +DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +FOLDER = "application/vnd.google-apps.folder" +MIME_BY_EXT = {".pptx": PPTX, ".docx": DOCX, ".xlsx": XLSX} + +# ---------------------------------------------------------------------------- +# Text rebranding engine (pure, unit-testable) +# ---------------------------------------------------------------------------- +def slugify(city): + s = unicodedata.normalize("NFKD", city).encode("ascii", "ignore").decode() + return re.sub(r"[^a-z0-9]", "", s.lower()) + +def transform_text(text, name, upper, newslug): + """Apply all city/slug replacements to one piece of *plain* text.""" + # 1. Luma slug (before the bare-SF pass, since it owns the '-SF' form) + def luma(m): + return ("AAIF-" + newslug.upper()) if m.group(0).isupper() else ("aaif-" + newslug) + text = re.sub(r"(?i)aaif-(?:sanfrancisco|sf)\b", luma, text) + # 2. Full city name + text = text.replace(SOURCE_NAME, name).replace(SOURCE_UPPER, upper) + # 3. Bare "SF" abbreviation - case decided by surrounding words + def sf(m): + after = text[m.end(): m.end() + 30] + before = text[max(0, m.start() - 30): m.start()] + nxt = re.search(r"[A-Za-z]{2,}", after) + nxt = nxt.group(0) if nxt else "" + prevs = re.findall(r"[A-Za-z]{2,}", before) + prev = prevs[-1] if prevs else "" + up = nxt.isupper() if nxt else (prev.isupper() if prev else False) + return upper if up else name + text = re.sub(r"\bSF\b", sf, text) + return text + +def _process_paragraphs(xml, ptag, ttag, tx): + """Concatenate the text runs in each paragraph, transform, and write the + result back into the first run (emptying the rest) so formatting and any + run-splitting are preserved.""" + pre = re.compile(r"<%s\b[^>]*>.*?" % (re.escape(ptag), re.escape(ptag)), re.S) + tre = re.compile(r"(<%s\b[^>]*>)(.*?)()" % (re.escape(ttag), re.escape(ttag)), re.S) + + def do_para(pm): + block = pm.group(0) + runs = list(tre.finditer(block)) + if not runs: + return block + concat = "".join(html.unescape(r.group(2)) for r in runs) + new = tx(concat) + if new == concat: + return block + new_esc = html.escape(new, quote=False) + out, last, first = [], 0, True + for r in runs: + out.append(block[last:r.start()]) + open_, _txt, close = r.group(1), r.group(2), r.group(3) + if first: + if "xml:space" not in open_: + open_ = open_[:-1] + ' xml:space="preserve">' + out.append(open_ + new_esc + close) + first = False + else: + out.append(open_ + close) + last = r.end() + out.append(block[last:]) + return "".join(out) + + return pre.sub(do_para, xml) + +def rebrand_part(part_name, data, name, upper, newslug): + """Return rebranded bytes for one OOXML part (or the original if unchanged).""" + tx = lambda s: transform_text(s, name, upper, newslug) + try: + xml = data.decode("utf-8") + except UnicodeDecodeError: + return data + if re.match(r"ppt/slides/slide\d+\.xml$", part_name): + xml = _process_paragraphs(xml, "a:p", "a:t", tx) + elif part_name == "word/document.xml": + xml = _process_paragraphs(xml, "w:p", "w:t", tx) + elif part_name == "xl/sharedStrings.xml": + xml = _process_paragraphs(xml, "si", "t", tx) + elif part_name in ("docProps/core.xml", "docProps/app.xml"): + # metadata: chapter labelled "AAIF SF" -> "AAIF " + xml = xml.replace("AAIF SF", "AAIF " + upper) + xml = xml.replace(SOURCE_NAME, name).replace(SOURCE_UPPER, upper) + xml = re.sub(r"(?i)aaif-(?:sanfrancisco|sf)\b", + lambda m: ("AAIF-" + newslug.upper()) if m.group(0).isupper() else ("aaif-" + newslug), xml) + elif part_name.endswith(".rels"): + # hyperlink targets to the Luma page + xml = re.sub(r"(?i)aaif-(?:sanfrancisco|sf)\b", + lambda m: ("AAIF-" + newslug.upper()) if m.group(0).isupper() else ("aaif-" + newslug), xml) + else: + return data + return xml.encode("utf-8") + +def rebrand_file(path, name, upper, newslug): + """Rewrite an .pptx/.docx/.xlsx in place. Returns number of parts changed.""" + tmp = path + ".new" + zin = zipfile.ZipFile(path, "r") + zout = zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) + changed = 0 + for it in zin.infolist(): + data = zin.read(it.filename) + new = rebrand_part(it.filename, data, name, upper, newslug) + if new != data: + changed += 1 + zi = zipfile.ZipInfo(it.filename, date_time=it.date_time) + zi.compress_type = it.compress_type + zi.external_attr = it.external_attr + zout.writestr(zi, new) + zin.close(); zout.close() + if zipfile.ZipFile(tmp).testzip() is not None: + os.remove(tmp); raise RuntimeError("repackaged zip failed validation: " + path) + os.replace(tmp, path) + return changed + +def residual_tokens(path): + """List any stale San Francisco / SF / aaif-sf tokens still present.""" + z = zipfile.ZipFile(path); hits = [] + for n in z.namelist(): + if not n.endswith((".xml", ".rels")): + continue + d = z.read(n) + for pat in (rb"San Francisco", rb"SAN FRANCISCO", rb"aaif-sf(?![a-z])", rb"\bSF\b"): + if re.search(pat, d, re.I): + hits.append((n.split("/")[-1], pat.decode("latin1"))) + return hits + +# ---------------------------------------------------------------------------- +# Drive helpers (via the gws CLI) +# ---------------------------------------------------------------------------- +_TRANSIENT = ("timed out", "internalError", "HTTP request failed", + "Connection", "temporarily", "rateLimit", "userRateLimit", + "backendError", "503", "500", "502") + +def _gws(cmd, cwd=None, retries=5): + """Run a gws command, retrying transient network/server errors.""" + for i in range(retries): + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if r.returncode == 0: + return r.stdout + msg = (r.stderr or "") + (r.stdout or "") + if i < retries - 1 and any(k in msg for k in _TRANSIENT): + time.sleep(2 * (i + 1)) + continue + raise RuntimeError("gws failed (%s): %s" % (r.returncode, msg.strip()[:400])) + +def gws_json(*args, params=None, body=None): + cmd = ["gws", *args] + if params is not None: + cmd += ["--params", json.dumps(params)] + if body is not None: + cmd += ["--json", json.dumps(body)] + out = _gws(cmd) + s = "\n".join(l for l in out.splitlines() if "keyring backend" not in l).strip() + if not s: + # Empty-but-successful stdout would silently become {} -> an empty file + # list -> a subtree that fails to clone while the run still says "Done". + raise RuntimeError("gws produced no JSON output for: %s" % " ".join(args)) + try: + return json.loads(s) + except json.JSONDecodeError: + raise RuntimeError("gws returned non-JSON output for %s: %s" % (" ".join(args), s[:200])) + +def gws_download(file_id, out): + # gws rejects --output paths outside its cwd, so run it in the file's dir. + d = os.path.dirname(out) or "." + _gws(["gws", "drive", "files", "get", "--params", + json.dumps({"fileId": file_id, "supportsAllDrives": True, "alt": "media"}), + "--output", os.path.basename(out)], cwd=d) + +def gws_upload(file_id, path, mime): + d = os.path.dirname(path) or "." + _gws(["gws", "drive", "files", "update", "--params", + json.dumps({"fileId": file_id, "supportsAllDrives": True}), + "--upload", os.path.basename(path), "--upload-content-type", mime], cwd=d) + +def list_children(folder_id): + res = gws_json("drive", "files", "list", params={ + "q": "'%s' in parents and trashed=false" % folder_id, + "fields": "files(id,name,mimeType)", "pageSize": 1000, + "supportsAllDrives": True, "includeItemsFromAllDrives": True}) + return res.get("files", []) + +def create_folder(name, parent): + return gws_json("drive", "files", "create", + params={"supportsAllDrives": True}, + body={"name": name, "mimeType": FOLDER, "parents": [parent]})["id"] + +def copy_file(file_id, name, parent): + return gws_json("drive", "files", "copy", + params={"fileId": file_id, "supportsAllDrives": True}, + body={"name": name, "parents": [parent]})["id"] + +def luma_status(slug): + """Return 'live' (HTTP 200), 'absent' (HTTP 404), or 'unknown' (could not + verify: timeout, DNS/SSL, 403/429/5xx). Never report a hard 404 for a + failure we could not actually confirm.""" + # Luma rejects HEAD and bare urllib UAs with 403; use GET + a browser UA. + req = urllib.request.Request("https://luma.com/aaif-" + slug, method="GET", + headers={"User-Agent": "Mozilla/5.0"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return "live" if r.status == 200 else "unknown" + except urllib.error.HTTPError as e: + return "absent" if e.code == 404 else "unknown" + except Exception: + return "unknown" + +# ---------------------------------------------------------------------------- +def clone_and_rebrand(folder_id, parent, name, ctx, indent=""): + """Recursively copy `folder_id` into `parent` as `name`, rebranding files.""" + new_id = create_folder(name, parent) + print("%s+ %s/" % (indent, name)) + for child in list_children(folder_id): + cname, cid, mime = child["name"], child["id"], child["mimeType"] + if mime == FOLDER: + clone_and_rebrand(cid, new_id, cname, ctx, indent + " ") + else: + copy_id = copy_file(cid, cname, new_id) + ext = os.path.splitext(cname)[1].lower() + if ext in MIME_BY_EXT: + tmp = os.path.join(ctx["tmp"], "f" + copy_id + ext) + gws_download(copy_id, tmp) + n = rebrand_file(tmp, ctx["name"], ctx["upper"], ctx["slug"]) + if n: + gws_upload(copy_id, tmp, MIME_BY_EXT[ext]) + left = residual_tokens(tmp) + if left: + ctx["residuals"].append((cname, left)) + flag = " !! residual %s" % left if left else "" + print("%s - %s (%d parts)%s" % (indent, cname, n, flag)) + os.remove(tmp) + else: + print("%s - %s (copied)" % (indent, cname)) + return new_id + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--city", required=True, help='Full city name, e.g. "New York"') + ap.add_argument("--slug", help="Luma slug override (default: city, lowercased, no spaces)") + ap.add_argument("--dry-run", action="store_true", help="Plan only; create nothing") + ap.add_argument("--rebrand-local", metavar="DIR", + help="Rebrand .pptx/.docx/.xlsx in a local dir (no Drive); for testing") + a = ap.parse_args() + + name = a.city.strip() + upper = name.upper() + slug = a.slug.strip().lower() if a.slug else slugify(name) + print("City : %s" % name) + print("Upper: %s" % upper) + print("Slug : aaif-%s -> https://luma.com/aaif-%s" % (slug, slug)) + + if a.rebrand_local: + residual_any = False + for root, _d, files in os.walk(a.rebrand_local): + for f in files: + if os.path.splitext(f)[1].lower() in MIME_BY_EXT: + p = os.path.join(root, f) + n = rebrand_file(p, name, upper, slug) + left = residual_tokens(p) + if left: + residual_any = True + print(" %s: %d parts%s" % (f, n, (" !! " + str(left)) if left else "")) + if residual_any: + sys.exit("FAIL: residual source tokens remain after rebrand (see !! above).") + return + + status = luma_status(slug) + print("Luma : %s" % { + "live": "LIVE (200)", + "absent": "NOT LIVE (404) - create the page at luma.com, or pass --slug", + "unknown": "COULD NOT VERIFY - network error reaching luma.com; check aaif-%s manually" % slug, + }[status]) + + existing = [c for c in list_children(CHAPTERS_PARENT) + if c["name"].lower() == name.lower() and c["mimeType"] == FOLDER] + if existing: + sys.exit("ABORT: a chapter folder named %r already exists (%s)" % (name, existing[0]["id"])) + + if a.dry_run: + print("\n[dry-run] Would clone TemplateCity -> %r under Chapters and rebrand all files." % name) + if status == "absent": + print("[dry-run] WARNING: Luma page aaif-%s is not live yet." % slug) + elif status == "unknown": + print("[dry-run] NOTE: could not verify the Luma page aaif-%s; check it manually." % slug) + return + + ctx = {"name": name, "upper": upper, "slug": slug, "residuals": [], + "tmp": os.path.join(os.environ.get("TMPDIR", "/tmp"), "aaif_chapter")} + os.makedirs(ctx["tmp"], exist_ok=True) + print() + new_id = clone_and_rebrand(TEMPLATE_FOLDER, CHAPTERS_PARENT, name, ctx) + print("\nDone. New chapter folder id: %s" % new_id) + print("https://drive.google.com/drive/folders/%s" % new_id) + if status == "absent": + print("REMINDER: create the Luma page at https://luma.com/aaif-%s (it is not live yet)." % slug) + elif status == "unknown": + print("REMINDER: could not verify the Luma page aaif-%s; check it manually at luma.com." % slug) + if ctx["residuals"]: + print("\nWARNING: %d file(s) still contain source tokens after rebrand:" % len(ctx["residuals"])) + for fn, toks in ctx["residuals"]: + print(" - %s: %s" % (fn, toks)) + sys.exit("The new folder is NOT clean - fix the template or rebrand engine and re-run.") + +if __name__ == "__main__": + main() diff --git a/skills/aaif-create-online-series/SKILL.md b/skills/aaif-create-online-series/SKILL.md new file mode 100644 index 0000000..07824c9 --- /dev/null +++ b/skills/aaif-create-online-series/SKILL.md @@ -0,0 +1,95 @@ +--- +name: aaif-create-online-series +description: Create a new AAIF online event series in the "Online" Google Drive folder by cloning TemplateSeries and rebranding all assets. Use when asked to add/launch/set up a new AAIF online series (reading group, paper club, webinar, online discussion) — not a city chapter. +argument-hint: ' [--slug ]' +--- + +# Create AAIF Online Series + +Spin up a new AAIF **online event series** (e.g. a Reading Group, a Paper Club) by +cloning the **TemplateSeries** folder in the top-level **Online** Google Drive +folder and rebranding every Office file from San Francisco to the new series. This +is the online sibling of [aaif-create-chapter]: same folder shape — `Event +Tracker.docx`, `Attendee CRM.xlsx`, `SKILLS.md.docx`, and the `Event Name/` + +`Banners (...)/` subfolders of `.pptx` design assets — but the **Event Tracker is +the no-venue "online" runbook** (platform / join link / tech check / recording / +chat-Q&A moderation instead of venue / A-V / food / door). + +Online series live under **Online/**, NOT under Chapters/. Use a city chapter +(aaif-create-chapter) for an in-person, city-based community; use this for a +recurring online program with no venue. + +Prereq: the `gws` CLI must be installed and authenticated (see the user's +`gws-cli-access` memory). All Drive calls go through it. + +## What gets replaced (and what does NOT) + +The rebrand swaps two tokens and leaves everything else alone. Event-specific +content — the example-event block (dates, speakers, example title), the agenda — +is **template content** organizers fill per-event using the prompts in +`SKILLS.md.docx`. Do not touch it. The TemplateSeries master is already +series-shaped (no "Chapter" wording in identity; the About blurb is a `[bracketed]` +placeholder the organizer fills in). + +| Token in template | Becomes | Notes | +|---|---|---| +| `San Francisco` / `SAN FRANCISCO` | new series, case-matched | contiguous in the clean template | +| `SF` abbreviation (`AAIF SF …`, doc metadata) | full series name | **UPPER** in all-caps contexts, **Title case** in prose | +| `aaif-sanfrancisco` / `aaif-sf` (Luma slug, incl. hyperlink targets) | `aaif-` | see slug rules below | + +## Luma slug rules + +- Default slug = series lowercased, spaces/accents removed: `Reading Group → readinggroup`. +- A brand-new series usually has **no live Luma page yet** — the script warns; the + page is created manually at luma.com. Pass `--slug` to override. +- Pages resolve at both `https://luma.com/aaif-` and `https://lu.ma/aaif-`. + +## Procedure + +1. **Confirm the series display name and slug with the user.** Ask for the exact + name (e.g. "Reading Group", or "Online Reading Group" if they want the word + Online in the title) and the Luma slug if one exists. + +2. **Dry run first** to surface the slug, Luma status, and any name collision: + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/create_series.py \ + --series "Reading Group" --dry-run + ``` + - If it aborts with "already exists", stop — the series is already there. + - If Luma shows NOT LIVE, tell the user the page needs creating at + `luma.com/aaif-` (or that the slug differs — re-run with `--slug`). + +3. **Create the series:** + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/create_series.py \ + --series "Reading Group" # add --slug if overriding + ``` + The script clones TemplateSeries → a new `` folder under Online, then + downloads, rebrands, and re-uploads each `.pptx/.docx/.xlsx` in place. It prints + a tree and flags any file with `!! residual` tokens. + +4. **Verify & hand off.** Confirm the run printed no `!! residual` flags and report + the new folder URL. Remind the user to (a) fill the `[bracketed]` About-the- + series blurb in `Event Tracker.docx`, and (b) create the Luma page if it wasn't + live. + +## How it works / maintenance + +`scripts/create_series.py` shares the **same text engine** as aaif-create-chapter +(paragraph-level concatenate → transform → write-back, robust to OOXML +run-splitting). Constants at the top: `ONLINE_PARENT` (the Online folder) and +`TEMPLATE_FOLDER` (TemplateSeries). The master's design decks (`Event Name/`, +`Slides.pptx`) were authored from the chapter decks with the front-facing brand +taglines de-chaptered; their **body content may still carry chapter/in-person +phrasing** ("global network of chapters", "same venue") — that's the organizer- +customized starting point, same as the example-event block. + +To validate the engine after any edit, rebrand a throwaway copy of the template +and check for residuals + that identity reads right: +```bash +python3 ${CLAUDE_SKILL_DIR}/scripts/create_series.py \ + --series "Reading Group" --rebrand-local /path/to/templateseries-copy +``` + +The template must stay "clean": `San Francisco` contiguous and the slug normalized +to `aaif-sanfrancisco`, so the two-token swap stays exhaustive. diff --git a/skills/aaif-create-online-series/scripts/create_series.py b/skills/aaif-create-online-series/scripts/create_series.py new file mode 100755 index 0000000..5c13c9b --- /dev/null +++ b/skills/aaif-create-online-series/scripts/create_series.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Create a new AAIF online event series by cloning the TemplateSeries folder and +rebranding every Office file from "San Francisco" to the new series. + +Online series (e.g. a Reading Group, a Paper Club) live under the top-level +**Online/** folder, NOT under Chapters/. They are the online-event sibling of a +city chapter: same folder shape (Event Tracker, Attendee CRM, SKILLS, Event Name/ +design assets, Banners/), but the Event Tracker is the no-venue "online" runbook +(platform / join link / recording / chat-Q&A instead of venue / A-V / door). + +Two replacement tokens are swapped (event content like dates/speakers/the example +block is left untouched - organizers fill that per-event using the SKILLS prompts): + + 1. Series name "San Francisco" / "SAN FRANCISCO" -> new series (case matched) + plus the "SF" abbreviation (AAIF SF, ...) -> new series, upper in + all-caps contexts and Title-cased in prose. + 2. Luma slug aaif-sanfrancisco / aaif-sf -> aaif- + +The TemplateSeries master is already series-shaped (no "Chapter" wording; the +identity blurb is a [bracketed] placeholder for the organizer to fill). + +Usage: + # Dry run - show what would happen, create nothing: + python create_series.py --series "Reading Group" --dry-run + + # Create the series in Drive: + python create_series.py --series "Reading Group" + + # Override the Luma slug: + python create_series.py --series "Reading Group" --slug readinggroup + + # Test the text engine on a local folder of .pptx/.docx/.xlsx (no Drive): + python create_series.py --series "Reading Group" --rebrand-local ./somedir +""" +import argparse, html, json, os, re, subprocess, sys, time, unicodedata, urllib.error, urllib.request, zipfile + +ONLINE_PARENT = "1g2vHrqDHfh9wBkDJryJIl8wqXA4J-d4i" # the top-level "Online" Drive folder +TEMPLATE_FOLDER = "1M15wzKvQqd_jQz5cG16NO_YcbWU3EH1j" # the "TemplateSeries" folder +SOURCE_NAME, SOURCE_UPPER = "San Francisco", "SAN FRANCISCO" + +PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation" +DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +FOLDER = "application/vnd.google-apps.folder" +MIME_BY_EXT = {".pptx": PPTX, ".docx": DOCX, ".xlsx": XLSX} + +# ---------------------------------------------------------------------------- +# Text rebranding engine (pure, unit-testable) - identical to aaif-create-chapter +# ---------------------------------------------------------------------------- +def slugify(series): + s = unicodedata.normalize("NFKD", series).encode("ascii", "ignore").decode() + return re.sub(r"[^a-z0-9]", "", s.lower()) + +def transform_text(text, name, upper, newslug): + """Apply all series/slug replacements to one piece of *plain* text.""" + # 1. Luma slug (before the bare-SF pass, since it owns the '-SF' form) + def luma(m): + return ("AAIF-" + newslug.upper()) if m.group(0).isupper() else ("aaif-" + newslug) + text = re.sub(r"(?i)aaif-(?:sanfrancisco|sf)\b", luma, text) + # 2. Full series name + text = text.replace(SOURCE_NAME, name).replace(SOURCE_UPPER, upper) + # 3. Bare "SF" abbreviation - case decided by surrounding words + def sf(m): + after = text[m.end(): m.end() + 30] + before = text[max(0, m.start() - 30): m.start()] + nxt = re.search(r"[A-Za-z]{2,}", after) + nxt = nxt.group(0) if nxt else "" + prevs = re.findall(r"[A-Za-z]{2,}", before) + prev = prevs[-1] if prevs else "" + up = nxt.isupper() if nxt else (prev.isupper() if prev else False) + return upper if up else name + text = re.sub(r"\bSF\b", sf, text) + return text + +def _process_paragraphs(xml, ptag, ttag, tx): + """Concatenate the text runs in each paragraph, transform, and write the + result back into the first run (emptying the rest) so formatting and any + run-splitting are preserved.""" + pre = re.compile(r"<%s\b[^>]*>.*?" % (re.escape(ptag), re.escape(ptag)), re.S) + tre = re.compile(r"(<%s\b[^>]*>)(.*?)()" % (re.escape(ttag), re.escape(ttag)), re.S) + + def do_para(pm): + block = pm.group(0) + runs = list(tre.finditer(block)) + if not runs: + return block + concat = "".join(html.unescape(r.group(2)) for r in runs) + new = tx(concat) + if new == concat: + return block + new_esc = html.escape(new, quote=False) + out, last, first = [], 0, True + for r in runs: + out.append(block[last:r.start()]) + open_, _txt, close = r.group(1), r.group(2), r.group(3) + if first: + if "xml:space" not in open_: + open_ = open_[:-1] + ' xml:space="preserve">' + out.append(open_ + new_esc + close) + first = False + else: + out.append(open_ + close) + last = r.end() + out.append(block[last:]) + return "".join(out) + + return pre.sub(do_para, xml) + +def rebrand_part(part_name, data, name, upper, newslug): + """Return rebranded bytes for one OOXML part (or the original if unchanged).""" + tx = lambda s: transform_text(s, name, upper, newslug) + try: + xml = data.decode("utf-8") + except UnicodeDecodeError: + return data + if re.match(r"ppt/slides/slide\d+\.xml$", part_name): + xml = _process_paragraphs(xml, "a:p", "a:t", tx) + elif part_name == "word/document.xml": + xml = _process_paragraphs(xml, "w:p", "w:t", tx) + elif part_name == "xl/sharedStrings.xml": + xml = _process_paragraphs(xml, "si", "t", tx) + elif part_name in ("docProps/core.xml", "docProps/app.xml"): + # metadata: labelled "AAIF SF" -> "AAIF " + xml = xml.replace("AAIF SF", "AAIF " + upper) + xml = xml.replace(SOURCE_NAME, name).replace(SOURCE_UPPER, upper) + xml = re.sub(r"(?i)aaif-(?:sanfrancisco|sf)\b", + lambda m: ("AAIF-" + newslug.upper()) if m.group(0).isupper() else ("aaif-" + newslug), xml) + elif part_name.endswith(".rels"): + # hyperlink targets to the Luma page + xml = re.sub(r"(?i)aaif-(?:sanfrancisco|sf)\b", + lambda m: ("AAIF-" + newslug.upper()) if m.group(0).isupper() else ("aaif-" + newslug), xml) + else: + return data + return xml.encode("utf-8") + +def rebrand_file(path, name, upper, newslug): + """Rewrite an .pptx/.docx/.xlsx in place. Returns number of parts changed.""" + tmp = path + ".new" + zin = zipfile.ZipFile(path, "r") + zout = zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) + changed = 0 + for it in zin.infolist(): + data = zin.read(it.filename) + new = rebrand_part(it.filename, data, name, upper, newslug) + if new != data: + changed += 1 + zi = zipfile.ZipInfo(it.filename, date_time=it.date_time) + zi.compress_type = it.compress_type + zi.external_attr = it.external_attr + zout.writestr(zi, new) + zin.close(); zout.close() + if zipfile.ZipFile(tmp).testzip() is not None: + os.remove(tmp); raise RuntimeError("repackaged zip failed validation: " + path) + os.replace(tmp, path) + return changed + +def residual_tokens(path): + """List any stale San Francisco / SF / aaif-sf tokens still present.""" + z = zipfile.ZipFile(path); hits = [] + for n in z.namelist(): + if not n.endswith((".xml", ".rels")): + continue + d = z.read(n) + for pat in (rb"San Francisco", rb"SAN FRANCISCO", rb"aaif-sf(?![a-z])", rb"\bSF\b"): + if re.search(pat, d, re.I): + hits.append((n.split("/")[-1], pat.decode("latin1"))) + return hits + +# ---------------------------------------------------------------------------- +# Drive helpers (via the gws CLI) +# ---------------------------------------------------------------------------- +_TRANSIENT = ("timed out", "internalError", "HTTP request failed", + "Connection", "temporarily", "rateLimit", "userRateLimit", + "backendError", "503", "500", "502") + +def _gws(cmd, cwd=None, retries=5): + """Run a gws command, retrying transient network/server errors.""" + for i in range(retries): + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if r.returncode == 0: + return r.stdout + msg = (r.stderr or "") + (r.stdout or "") + if i < retries - 1 and any(k in msg for k in _TRANSIENT): + time.sleep(2 * (i + 1)) + continue + raise RuntimeError("gws failed (%s): %s" % (r.returncode, msg.strip()[:400])) + +def gws_json(*args, params=None, body=None): + cmd = ["gws", *args] + if params is not None: + cmd += ["--params", json.dumps(params)] + if body is not None: + cmd += ["--json", json.dumps(body)] + out = _gws(cmd) + s = "\n".join(l for l in out.splitlines() if "keyring backend" not in l).strip() + if not s: + # Empty-but-successful stdout would silently become {} -> an empty file + # list -> a subtree that fails to clone while the run still says "Done". + raise RuntimeError("gws produced no JSON output for: %s" % " ".join(args)) + try: + return json.loads(s) + except json.JSONDecodeError: + raise RuntimeError("gws returned non-JSON output for %s: %s" % (" ".join(args), s[:200])) + +def gws_download(file_id, out): + # gws rejects --output paths outside its cwd, so run it in the file's dir. + d = os.path.dirname(out) or "." + _gws(["gws", "drive", "files", "get", "--params", + json.dumps({"fileId": file_id, "supportsAllDrives": True, "alt": "media"}), + "--output", os.path.basename(out)], cwd=d) + +def gws_upload(file_id, path, mime): + d = os.path.dirname(path) or "." + _gws(["gws", "drive", "files", "update", "--params", + json.dumps({"fileId": file_id, "supportsAllDrives": True}), + "--upload", os.path.basename(path), "--upload-content-type", mime], cwd=d) + +def list_children(folder_id): + res = gws_json("drive", "files", "list", params={ + "q": "'%s' in parents and trashed=false" % folder_id, + "fields": "files(id,name,mimeType)", "pageSize": 1000, + "supportsAllDrives": True, "includeItemsFromAllDrives": True}) + return res.get("files", []) + +def create_folder(name, parent): + return gws_json("drive", "files", "create", + params={"supportsAllDrives": True}, + body={"name": name, "mimeType": FOLDER, "parents": [parent]})["id"] + +def copy_file(file_id, name, parent): + return gws_json("drive", "files", "copy", + params={"fileId": file_id, "supportsAllDrives": True}, + body={"name": name, "parents": [parent]})["id"] + +def luma_status(slug): + """Return 'live' (HTTP 200), 'absent' (HTTP 404), or 'unknown' (could not + verify: timeout, DNS/SSL, 403/429/5xx). Never report a hard 404 for a + failure we could not actually confirm.""" + # Luma rejects HEAD and bare urllib UAs with 403; use GET + a browser UA. + req = urllib.request.Request("https://luma.com/aaif-" + slug, method="GET", + headers={"User-Agent": "Mozilla/5.0"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return "live" if r.status == 200 else "unknown" + except urllib.error.HTTPError as e: + return "absent" if e.code == 404 else "unknown" + except Exception: + return "unknown" + +# ---------------------------------------------------------------------------- +def clone_and_rebrand(folder_id, parent, name, ctx, indent=""): + """Recursively copy `folder_id` into `parent` as `name`, rebranding files.""" + new_id = create_folder(name, parent) + print("%s+ %s/" % (indent, name)) + for child in list_children(folder_id): + cname, cid, mime = child["name"], child["id"], child["mimeType"] + if mime == FOLDER: + clone_and_rebrand(cid, new_id, cname, ctx, indent + " ") + else: + copy_id = copy_file(cid, cname, new_id) + ext = os.path.splitext(cname)[1].lower() + if ext in MIME_BY_EXT: + tmp = os.path.join(ctx["tmp"], "f" + copy_id + ext) + gws_download(copy_id, tmp) + n = rebrand_file(tmp, ctx["name"], ctx["upper"], ctx["slug"]) + if n: + gws_upload(copy_id, tmp, MIME_BY_EXT[ext]) + left = residual_tokens(tmp) + if left: + ctx["residuals"].append((cname, left)) + flag = " !! residual %s" % left if left else "" + print("%s - %s (%d parts)%s" % (indent, cname, n, flag)) + os.remove(tmp) + else: + print("%s - %s (copied)" % (indent, cname)) + return new_id + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--series", required=True, help='Series display name, e.g. "Reading Group" or "Online Reading Group"') + ap.add_argument("--slug", help="Luma slug override (default: series, lowercased, no spaces)") + ap.add_argument("--dry-run", action="store_true", help="Plan only; create nothing") + ap.add_argument("--rebrand-local", metavar="DIR", + help="Rebrand .pptx/.docx/.xlsx in a local dir (no Drive); for testing") + a = ap.parse_args() + + name = a.series.strip() + upper = name.upper() + slug = a.slug.strip().lower() if a.slug else slugify(name) + print("Series: %s" % name) + print("Upper : %s" % upper) + print("Slug : aaif-%s -> https://luma.com/aaif-%s" % (slug, slug)) + + if a.rebrand_local: + residual_any = False + for root, _d, files in os.walk(a.rebrand_local): + for f in files: + if os.path.splitext(f)[1].lower() in MIME_BY_EXT: + p = os.path.join(root, f) + n = rebrand_file(p, name, upper, slug) + left = residual_tokens(p) + if left: + residual_any = True + print(" %s: %d parts%s" % (f, n, (" !! " + str(left)) if left else "")) + if residual_any: + sys.exit("FAIL: residual source tokens remain after rebrand (see !! above).") + return + + status = luma_status(slug) + print("Luma : %s" % { + "live": "LIVE (200)", + "absent": "NOT LIVE (404) - create the page at luma.com, or pass --slug", + "unknown": "COULD NOT VERIFY - network error reaching luma.com; check aaif-%s manually" % slug, + }[status]) + + existing = [c for c in list_children(ONLINE_PARENT) + if c["name"].lower() == name.lower() and c["mimeType"] == FOLDER] + if existing: + sys.exit("ABORT: an Online series folder named %r already exists (%s)" % (name, existing[0]["id"])) + + if a.dry_run: + print("\n[dry-run] Would clone TemplateSeries -> %r under Online and rebrand all files." % name) + if status == "absent": + print("[dry-run] WARNING: Luma page aaif-%s is not live yet." % slug) + elif status == "unknown": + print("[dry-run] NOTE: could not verify the Luma page aaif-%s; check it manually." % slug) + return + + ctx = {"name": name, "upper": upper, "slug": slug, "residuals": [], + "tmp": os.path.join(os.environ.get("TMPDIR", "/tmp"), "aaif_series")} + os.makedirs(ctx["tmp"], exist_ok=True) + print() + new_id = clone_and_rebrand(TEMPLATE_FOLDER, ONLINE_PARENT, name, ctx) + print("\nDone. New series folder id: %s" % new_id) + print("https://drive.google.com/drive/folders/%s" % new_id) + print("REMINDER: fill the [bracketed] series blurb in Event Tracker.docx (the template ships a placeholder).") + if status == "absent": + print("REMINDER: create the Luma page at https://luma.com/aaif-%s (it is not live yet)." % slug) + elif status == "unknown": + print("REMINDER: could not verify the Luma page aaif-%s; check it manually at luma.com." % slug) + if ctx["residuals"]: + print("\nWARNING: %d file(s) still contain source tokens after rebrand:" % len(ctx["residuals"])) + for fn, toks in ctx["residuals"]: + print(" - %s: %s" % (fn, toks)) + sys.exit("The new folder is NOT clean - fix the template or rebrand engine and re-run.") + +if __name__ == "__main__": + main() diff --git a/skills/aaif-dayof-slides/SKILL.md b/skills/aaif-dayof-slides/SKILL.md new file mode 100644 index 0000000..b83338b --- /dev/null +++ b/skills/aaif-dayof-slides/SKILL.md @@ -0,0 +1,60 @@ +--- +name: aaif-dayof-slides +description: Turn an event's tracker entry into the slide text for the AAIF "Day of Event" deck. Use when asked to fill/write the day-of slides or event deck for an AAIF meetup. +argument-hint: [event title / paste tracker entry] +--- + +# AAIF Day-of Slides (from the tracker) + +Turn an event's tracker entry into the text for the chapter's **"Day of Event"** +deck (`Event Name/Slides.pptx`). Fill the per-event slides from the tracker and +**leave the fixed brand slides** (`[FIXED]`: About AAIF, the global-network stats) +**exactly as written** — they are brand-standard. + +Keep it **terse and label-driven** (the deck voice). Output as `Slide N — :` +then the fields, slide for slide, then paste into the template. + +## Input (from the event tracker) +- Event : `[EVENT TITLE]` Series: `[SERIES]` Theme: `[THEME + ONE-LINER]` +- When : `[DATE & TIME]` Venue/City: `[VENUE], [CITY]` +- Host : `[HOST VENUE]` Members: `[MEMBER LOGOS]` +- Speakers : `[FOR EACH: NAME | ROLE | TALK or DEMO | "QUOTE"]` +- Agenda : `[RUN-OF-SHOW: TIME | BLOCK | NOTE]` +- Next : `[NEXT EVENT + DATE]` Links: `[LUMA / DISCORD / NEWSLETTER]` + +## Slides (in order) +``` + 1 Cover 6 Tonight's theme 11 Demo lineup + 2 Welcome 7 Run-of-show 12 Thank you + 3 About AAIF[FIXED]8 On mic (speakers) 13 Join the chapter + 4 Local chapter 9 Talk one 14 Networking + 5 Network [FIXED] 10 Demos 15 Next up +``` + +## Example (tested — match this format and voice; abbreviated) +Agentic AI Night: + +> **Slide 1 — Cover:** +> Kicker: THE AAIF COMMUNITY · Title: Agentic AI Night. · Sub: Launch Series — +> San Francisco · Date: TUE — JUNE 24, 2026 — 17:30 — LATE · Hosted by: Host +> Venue Co. · With: [member logos] +> +> **Slide 6 — Tonight's theme:** +> Label: TONIGHT'S THEME 01 · Title: Agents in production. · Body: What's working +> at scale, what's breaking in ways the demos never showed, what nobody's figured +> out yet. · Footer: STANDARDS AT WORK → MCP · AGENTS.MD · GOOSE +> +> **Slide 7 — Run-of-show:** +> 17:30/30m Doors & networking | 18:00/5m Why we're here | 18:05/25m Talk one — +> Maya Chen | 18:30/25m Demos ×3 | 18:55/5m Wrap up | 19:00/60m Networking +> +> **Slide 8 — On mic:** +> 01/TALK Maya Chen — STAFF ENGINEER — PAYMENTS — "Tool calling at scale — what +> broke at 10M requests a day." · 02/DEMO Diego Alvarez — AGENT INFRA — +> "AGENTS.md in a 4,000-repo monorepo. Yes, really." · 03/DEMO Priya Nair — +> GOOSE CONTRIBUTOR — "Sandboxing goose: lessons from letting agents run wild." +> +> **Slide 15 — Next up:** +> Title: "AI in Finance" — July 22. · RSVP: lu.ma/aaif-sanfrancisco +> +> (Slides 2, 4, 9, 10, 11, 12, 13, 14 follow the same pattern.) diff --git a/skills/aaif-luma-description/SKILL.md b/skills/aaif-luma-description/SKILL.md new file mode 100644 index 0000000..68221d3 --- /dev/null +++ b/skills/aaif-luma-description/SKILL.md @@ -0,0 +1,39 @@ +--- +name: aaif-luma-description +description: Write the Luma event-page description for an AAIF in-person meetup. Use when asked to draft the Luma description / event page copy for an AAIF event. +argument-hint: [event title / paste tracker entry] +--- + +# AAIF Luma Event Description + +Goes on the Luma page — a little longer; include the agenda and who should come. +Sections: **short intro**, **"What we'll cover"**, **"Who should come"**, a simple +**agenda with times**. ~180 words. **End on a line about AAIF being vendor-neutral +and builder-first.** + +**House voice:** share the practice, never sell the product. Specific over grand, +builder-to-builder. Signal, not numbers. + +## Input (from the event tracker) +- Event : `[EVENT TITLE] ([SERIES]) — [THEME]` +- When : `[DATE & TIME]` Where: `[VENUE / CITY]` +- Speaker : `[SPEAKER + TALK]` Demos: `[DEMO COUNT]` +- Agenda : `[RUN-OF-SHOW TIMES]` For: `[WHO IT'S FOR]` + +## Example (tested — match this format and voice) +Agentic AI Night: + +> Agentic AI Night kicks off our Launch Series: one night on what it actually +> takes to run agents in production. +> +> **WHAT WE'LL COVER** — Maya Chen on tool calling at 10M requests a day, then +> three short community demos with Q&A. +> +> **WHO SHOULD COME** — engineers shipping agents, and anyone curious what breaks +> past the demo. (Mention prerequisites here.) +> +> **AGENDA** — 17:30 doors & networking | 18:00 why we're here | 18:05 talk one | +> 18:30 demos ×3 | 18:55 wrap | 19:00 social till late. +> +> AAIF events are vendor-neutral and builder-first: no paid slots, no pitches. +> Curated, RSVP-based — signal, not numbers. diff --git a/skills/aaif-recap-post/SKILL.md b/skills/aaif-recap-post/SKILL.md new file mode 100644 index 0000000..cbb8978 --- /dev/null +++ b/skills/aaif-recap-post/SKILL.md @@ -0,0 +1,36 @@ +--- +name: aaif-recap-post +description: Write the post-event LinkedIn recap for an AAIF meetup (posted within 48 hours, with photos). Use when asked to draft the recap, thank-you, or wrap-up post after an AAIF event. +argument-hint: [event title / paste tracker entry] +--- + +# AAIF Post-Event Recap (LinkedIn) + +Posted within 48 hours with photos, while the conversation is still warm (~110 +words). **Thank the speaker and venue by name**, share **1-2 concrete takeaways**, +**mention turnout**, and **tease the next event with a link**. Warm and genuine, +not promotional, **one emoji max**. + +**House voice:** share the practice, never sell the product. Signal, not numbers. + +## Input (from the event tracker) +- Event/speaker : `[EVENT TITLE] — [SPEAKER + TOPIC]` +- Thank : `[VENUE / HOST]` +- Takeaways : `[1-2 TAKEAWAYS]` Turnout: `[TURNOUT]` +- Next : `[NEXT EVENT + DATE]` RSVP: `[LUMA URL]` + +## Example (tested — match this format and voice) +Agentic AI Night: + +> Full room for Agentic AI Night last night — thank you to everyone who came to +> talk agents in production. +> +> Maya Chen opened with tool calling at 10M requests a day, and the takeaway +> stuck: past a certain scale, retries and idempotency matter more than which +> model you picked. Then Diego Alvarez and Priya Nair ran demos that were equal +> parts impressive and honest about the rough edges. +> +> Thanks to Host Venue Co. for the space, and our founding members for keeping +> this vendor-neutral. +> +> Next: "AI in Finance," July 22 → lu.ma/aaif-sanfrancisco diff --git a/skills/aaif-speaker-bio/SKILL.md b/skills/aaif-speaker-bio/SKILL.md new file mode 100644 index 0000000..a9c0c8c --- /dev/null +++ b/skills/aaif-speaker-bio/SKILL.md @@ -0,0 +1,35 @@ +--- +name: aaif-speaker-bio +description: Write a speaker bio (a 60-80 word bio + a one-liner) for an AAIF in-person meetup speaker. Use when asked to draft/write a speaker bio for an AAIF event or chapter. +argument-hint: [speaker name / paste their tracker row] +--- + +# AAIF Speaker Bio + +Produce TWO versions of a speaker bio for an AAIF in-person meetup: +1. a **60-80 word** third-person bio, and +2. a **one-line** version, **max 18 words**. + +**House voice:** share the practice, never sell the product. Warm, concrete, +builder-to-builder. No hype or superlatives. Signal, not numbers. The draft gets +you ~90% there — edit before it ships. + +## Input (from the event tracker) +- Name : `[SPEAKER NAME]` +- Role/team : `[ROLE] @ [COMPANY/TEAM]` OR `[CURRENTLY BUILDING X]` +- Works on : `[WHAT THEY WORK ON]` +- Shipped : `[NOTABLE / SHIPPED WORK]` +- Talk : `[TALK TITLE]` +- Links : `[@HANDLE / URL]` + +## Example (tested — match this format and voice) +Maya Chen, Agentic AI Night: + +> **Bio:** Maya Chen is a Staff Engineer on a payments platform, where she keeps +> tool-calling agents reliable once they leave the demo and hit real traffic. The +> past year she has lived in what changes past ten million tool calls a day — +> retries, idempotency, and the failure modes nobody warns you about. She brings +> those lessons back to the community. At @mayabuilds. +> +> **One-liner:** Maya Chen, Staff Engineer on payments — tool calling at scale, +> and what broke at 10M requests a day. diff --git a/skills/aaif-speaker-invite/SKILL.md b/skills/aaif-speaker-invite/SKILL.md new file mode 100644 index 0000000..69745f8 --- /dev/null +++ b/skills/aaif-speaker-invite/SKILL.md @@ -0,0 +1,29 @@ +--- +name: aaif-speaker-invite +description: Write a short, warm speaker-invite DM or email for an AAIF in-person meetup. Use when asked to draft a speaker invite, outreach DM, or ask-someone-to-speak message for an AAIF event. +argument-hint: [speaker name + event / paste tracker entry] +--- + +# AAIF Speaker Outreach / Invite + +A short, friendly DM or email to invite a speaker (~90 words). Make the ask +**specific and easy to accept**: talk length, topic, date, venue, audience size, +and an offer to flex on timing. **Builder-to-builder, no corporate tone.** + +**House voice:** share the practice, never sell the product. Specific over grand. +Signal, not numbers. + +## Input (from the event tracker) +- Speaker : `[SPEAKER NAME]` Chapter: `[CHAPTER]` +- Topic : `[TOPIC WE'D LOVE]` When/where: `[DATE], [VENUE]` +- Audience : `[CAPACITY] [WHO ATTENDS]` + +## Example (tested — match this format and voice) +Maya Chen, Agentic AI Night: + +> Hi Maya — I help run AAIF San Francisco, a curated, vendor-neutral meetup for +> people building agents (no pitches, just folks who ship). I'd love to have you +> open our June night with a 25-minute talk on tool calling at scale — Tue June +> 24, evening, in SoMa, ~120 builders. Slides optional; a live walkthrough works +> great too. Happy to flex on the date if that week's tight. Would June 24 work +> for you? diff --git a/skills/aaif-triage-intake/SKILL.md b/skills/aaif-triage-intake/SKILL.md new file mode 100644 index 0000000..75d1796 --- /dev/null +++ b/skills/aaif-triage-intake/SKILL.md @@ -0,0 +1,76 @@ +--- +name: aaif-triage-intake +description: Triage new AAIF community intake submissions (organizers, hosts/venues, speakers) from the Intake Ops sheet — summarize who's awaiting review, assess fit, and draft next-step outreach. Use when asked to review/triage new applicants, check the intake queue, or produce an intake digest. +argument-hint: "[organizers|hosts|speakers]" +--- + +# Triage AAIF Intake + +Review the people who applied through the **"AAIF Community — Get Involved"** form +and decide what happens next. The form feeds the **AAIF Community Intake Ops** +sheet (id `1cWkjCI5AGK9RX_fs23P5jRA4I2nixgnHuapvwHseZ5o`), which auto-routes each +submission to the **Organizers**, **Hosts**, or **Speakers** tab. Submissions +land automatically; this skill is the human review loop on top of them. + +Prereq: the `gws` CLI must be installed and authenticated (see the user's +`gws-cli-access` memory). See the user's `aaif-intake-ops-sheet` memory for the +sheet's structure. + +## Status model (drives the queue and the sheet's cell colors) + +The five Status values (dropdown on column A, matched exactly by the sheet's +whole-row colors): `New` (blue) → `In progress` (orange) → `Accepted` (green) / +`Denied` (maroon); `Inactive` (gray). A **blank** Status cell is treated as `New`. +Two overrides beat the status color: a **data error** (missing/invalid email or +broken LinkedIn) paints the row bright red, and an **SLA breach** — a `New`/blank +row older than 1 week (of a 2-week response SLA) — paints it pink. Acting on a row +(moving it off `New`) clears the pink. Each role tab also has `Reviewed by`, +`Reviewed at`, `Decision notes`, and a `Chapter` assignment. + +## Procedure + +1. **Pull the queue.** Rows needing attention = Status blank / `New` / `In progress`: + ```bash + python3 ${CLAUDE_SKILL_DIR}/scripts/intake.py + ``` + Add `--json` for structured data, `--all` for every row, or + `--status Accepted` to filter explicitly. If the user named one type + (`organizers` / `hosts` / `speakers`), focus there but pull all so counts are right. + +2. **Assess fit per applicant**, using these signals (don't over-weight any one): + - **Organizer** — real ties to a local AI community, has run events before, + a concrete programming idea, and a city. Watch for a `City` of "Other" with a + non-obvious location (it's in their text) → note the actual city. + - **Host** — capacity ≥ 30 (`Holds 30+?`), A/V + wifi, a real company/venue, + and ideally `Recurring support?`. Logistics gaps are follow-ups, not denials. + - **Speaker** — talk relevance to AAIF (agents/MCP/infra/applied AI), ships in + production, and evidence (`Past talks / portfolio`). A thin abstract is a + follow-up for specifics. + +3. **Produce the triage digest** — grouped by tab, and for each applicant give a + one-line recommendation: **Accept**, **Follow up** (what to ask), or **Pass** + (why). Lead with the strongest candidates. Keep it skimmable. + +4. **Draft outreach where it helps** — don't just judge, move it forward: + - Speakers worth pursuing → use the **`aaif-speaker-invite`** skill for the DM. + - An accepted organizer for a city that has **no chapter yet** → suggest running + **`aaif-create-chapter`** for that city. + +5. **Write back only if asked.** Default is read-only. If the user wants to record + decisions, set `Status` / `Reviewed by` / `Reviewed at` / `Decision notes` + (and `Chapter`) via `gws sheets spreadsheets values batchUpdate` + (`valueInputOption: USER_ENTERED`). Resolve the target cell by the row number + from step 1 and the column's header name — never assume a fixed column letter. + +## Digest mode (for automation) + +`intake.py --json` is the data source for a future scheduled digest routine +(delivery channel TBD with the user). The same selection logic powers both the +interactive triage and the unattended digest, so they never drift. + +## Notes + +- The sheet is read by **header name**, not column letter — robust to the form or + sheet gaining/reordering columns. Keep that property in any edits here. +- `Other:` responses to "What brings you here?" match no tab and won't appear in + any queue. If counts look short, check `Form Responses` for unrouted rows. diff --git a/skills/aaif-triage-intake/scripts/intake.py b/skills/aaif-triage-intake/scripts/intake.py new file mode 100755 index 0000000..a637adf --- /dev/null +++ b/skills/aaif-triage-intake/scripts/intake.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Pull the AAIF intake queue (Organizers / Hosts / Speakers) from the +"AAIF Community Intake Ops" sheet and print the rows that need review. + +Reads everything by *header name* (never column letter), matching the sheet's +name-based extraction design, so it survives column reordering. + +Usage: + intake.py # text digest of rows needing attention + intake.py --json # same selection as JSON (for the digest routine) + intake.py --all # every row, regardless of status + intake.py --status New "In progress" # custom status filter +""" +import argparse, json, subprocess, sys + +SHEET_ID = "1cWkjCI5AGK9RX_fs23P5jRA4I2nixgnHuapvwHseZ5o" + +# Per-tab: the header names to surface in the digest (resolved by name). +# Name / Email / LinkedIn / City are shown for every tab; these add the +# distinctive, decision-relevant fields per applicant type. +TABS = { + "Organizers": ["Full name", "Email", "LinkedIn", "City", + "Chapter / city wanted", "Technical expertise", + "Run events before?", "Why organize / ties"], + "Hosts": ["Name", "Email", "LinkedIn", "City", "Company", + "Venue name", "Capacity", "Holds 30+?", "A/V available?"], + "Speakers": ["Name", "Email", "LinkedIn", "City", "Headline", + "Talk title", "Ships in production?", "Past talks / portfolio"], +} + +# Rows in these Status states (or blank) are "awaiting review". +DEFAULT_NEEDS_REVIEW = {"", "New", "In progress"} + + +def fetch(tab): + """Return (headers, rows) for a tab; rows are padded to len(headers).""" + params = json.dumps({"spreadsheetId": SHEET_ID, + "range": f"{tab}!A1:BB", "majorDimension": "ROWS"}) + out = subprocess.run(["gws", "sheets", "spreadsheets", "values", "get", + "--params", params, "--format", "json"], + capture_output=True, text=True) + if out.returncode != 0: + sys.exit(f"gws error reading {tab}: {out.stderr.strip()}") + # gws prints a keyring banner line before the JSON; find the JSON start. + txt = out.stdout + data = json.loads(txt[txt.index("{"):]) + vals = data.get("values", []) + if not vals: + return [], [] + headers = [h.strip() for h in vals[0]] + rows = [r + [""] * (len(headers) - len(r)) for r in vals[1:]] + return headers, rows + + +def col(headers, name): + return headers.index(name) if name in headers else None + + +def collect(status_filter, show_all): + result = {} + for tab, fields in TABS.items(): + headers, rows = fetch(tab) + if not headers: + result[tab] = [] + continue + si = col(headers, "Status") + ti = col(headers, "Timestamp") # real-row marker (always present from the form) + # A missing marker/status column means a header rename, not an empty + # queue — fail loudly rather than silently reporting "0 awaiting review". + if ti is None: + sys.exit(f"ABORT: tab {tab!r} has no 'Timestamp' column; headers present: {headers}") + if si is None and not show_all: + sys.exit(f"ABORT: tab {tab!r} has no 'Status' column to filter on; " + f"pass --all or fix the header. Headers present: {headers}") + picked = [] + for rn, row in enumerate(rows, start=2): # row 2 = first data row + if not (row[ti] or "").strip(): + continue # skip empty trailing rows (no Timestamp) + status = (row[si].strip() if si is not None else "") + if not show_all and status not in status_filter: + continue + rec = {"row": rn, "status": status or "New"} + for f in fields: + ci = col(headers, f) + rec[f] = (row[ci].strip() if ci is not None else "") + picked.append(rec) + result[tab] = picked + return result + + +def truncate(s, n=70): + s = " ".join(s.split()) + return s if len(s) <= n else s[: n - 1] + "…" + + +def text_digest(data): + total = sum(len(v) for v in data.values()) + counts = " · ".join(f"{len(v)} {t.lower()}" for t, v in data.items()) + print(f"AAIF intake — {total} awaiting review ({counts})\n") + for tab, recs in data.items(): + if not recs: + continue + print(f"== {tab} ({len(recs)}) ==") + for r in recs: + name = r.get("Full name") or r.get("Name") or "(no name)" + print(f" • [{r['status']}] {name} — {r.get('Email','')}" + f" {r.get('City','')} (row {r['row']})") + for f, v in r.items(): + if f in ("row", "status", "Full name", "Name", "Email", "City"): + continue + if v: + print(f" {f}: {truncate(v)}") + print() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--json", action="store_true") + ap.add_argument("--all", action="store_true") + ap.add_argument("--status", nargs="*", default=None, + help="Status values to include (default: blank/New/In progress)") + args = ap.parse_args() + sf = set(args.status) if args.status is not None else DEFAULT_NEEDS_REVIEW + if args.status is not None: + sf.add("") if "New" in sf else None + data = collect(sf, args.all) + if args.json: + print(json.dumps(data, indent=1)) + else: + text_digest(data) + + +if __name__ == "__main__": + main()