← back to AgentAbrams
initial snapshot — gitify all builds (CLAUDE.md rule 2026-05-06)
ce7195fa937932527b6b37f90a25d909755c4a69 · 2026-05-06 10:20:07 -0700 · Steve
Files touched
A .env.exampleA .github/workflows/ci.ymlA .github/workflows/podcast.ymlA .github/workflows/release.ymlA .github/workflows/youtube.ymlA .gitignoreA CHANGES.mdA LICENSEA README.mdA SECURITY.mdA agents/compliance_agent.jsonA agents/devlog_agent.jsonA agents/media_agent.jsonA agents/publish_agent.jsonA agents/redaction_agent.jsonA agents/video_agent.jsonA bin/aaA governance/GOVERNANCE.mdA governance/REDACTION_CHECKLIST.mdA media/__init__.pyA media/audio_mixer.pyA media/chapters.pyA media/cli.pyA media/engine.pyA media/md_to_blog.pyA media/md_to_podcast.pyA media/social.pyA media/transcript.pyA media/video.pyA podcast/build_episode.pyA podcast/rss_generator.pyA podcast/script_from_post.pyA podcast/tts_engine.pyA posts/2026-02-18.mdA posts/2026-02-19.mdA posts/2026-02-20.mdA posts/2026-02-21.mdA posts/2026-02-22.mdA posts/2026-02-24.mdA skills/agent_observability.jsonA skills/md_to_media.jsonA skills/post_scaffold.jsonA skills/precommit_guardrails.jsonA skills/publish_manifest.jsonA skills/redaction_lint.jsonA skills/skills_validator.jsonA snippets/agent_status_check.pyA snippets/new_post.pyA snippets/precommit.shA snippets/publish_manifest.jsonA snippets/redact_lint.pyA snippets/validate_skills.pyA video/compose_video.shA video/oauth_authorize.pyA video/script_generator.pyA video/tts_engine.pyA video/upload_youtube.py
Diff
commit ce7195fa937932527b6b37f90a25d909755c4a69
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 10:20:07 2026 -0700
initial snapshot — gitify all builds (CLAUDE.md rule 2026-05-06)
---
.env.example | 17 +++++
.github/workflows/ci.yml | 82 +++++++++++++++++++++++
.github/workflows/podcast.yml | 47 +++++++++++++
.github/workflows/release.yml | 21 ++++++
.github/workflows/youtube.yml | 44 +++++++++++++
.gitignore | 33 ++++++++++
CHANGES.md | 15 +++++
LICENSE | 21 ++++++
README.md | 77 ++++++++++++++++++++++
SECURITY.md | 33 ++++++++++
agents/compliance_agent.json | 22 +++++++
agents/devlog_agent.json | 22 +++++++
agents/media_agent.json | 26 ++++++++
agents/publish_agent.json | 22 +++++++
agents/redaction_agent.json | 18 +++++
agents/video_agent.json | 27 ++++++++
bin/aa | 15 +++++
governance/GOVERNANCE.md | 45 +++++++++++++
governance/REDACTION_CHECKLIST.md | 31 +++++++++
media/__init__.py | 1 +
media/audio_mixer.py | 73 +++++++++++++++++++++
media/chapters.py | 65 ++++++++++++++++++
media/cli.py | 94 ++++++++++++++++++++++++++
media/engine.py | 135 ++++++++++++++++++++++++++++++++++++++
media/md_to_blog.py | 116 ++++++++++++++++++++++++++++++++
media/md_to_podcast.py | 62 +++++++++++++++++
media/social.py | 77 ++++++++++++++++++++++
media/transcript.py | 62 +++++++++++++++++
media/video.py | 64 ++++++++++++++++++
podcast/build_episode.py | 54 +++++++++++++++
podcast/rss_generator.py | 71 ++++++++++++++++++++
podcast/script_from_post.py | 63 ++++++++++++++++++
podcast/tts_engine.py | 74 +++++++++++++++++++++
posts/2026-02-18.md | 33 ++++++++++
posts/2026-02-19.md | 33 ++++++++++
posts/2026-02-20.md | 35 ++++++++++
posts/2026-02-21.md | 35 ++++++++++
posts/2026-02-22.md | 35 ++++++++++
posts/2026-02-24.md | 41 ++++++++++++
skills/agent_observability.json | 19 ++++++
skills/md_to_media.json | 18 +++++
skills/post_scaffold.json | 18 +++++
skills/precommit_guardrails.json | 14 ++++
skills/publish_manifest.json | 22 +++++++
skills/redaction_lint.json | 14 ++++
skills/skills_validator.json | 14 ++++
snippets/agent_status_check.py | 80 ++++++++++++++++++++++
snippets/new_post.py | 65 ++++++++++++++++++
snippets/precommit.sh | 38 +++++++++++
snippets/publish_manifest.json | 31 +++++++++
snippets/redact_lint.py | 78 ++++++++++++++++++++++
snippets/validate_skills.py | 55 ++++++++++++++++
video/compose_video.sh | 54 +++++++++++++++
video/oauth_authorize.py | 39 +++++++++++
video/script_generator.py | 84 ++++++++++++++++++++++++
video/tts_engine.py | 76 +++++++++++++++++++++
video/upload_youtube.py | 80 ++++++++++++++++++++++
57 files changed, 2640 insertions(+)
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..b166060
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,17 @@
+# YouTube OAuth (store in GitHub Secrets for CI)
+# YOUTUBE_CLIENT_ID=
+# YOUTUBE_CLIENT_SECRET=
+# YOUTUBE_REFRESH_TOKEN=
+
+# ElevenLabs TTS
+# ELEVEN_API_KEY=
+# ELEVEN_VOICE_ID=
+
+# X (Twitter) API
+# X_API_KEY=
+# X_API_SECRET=
+# X_ACCESS_TOKEN=
+# X_ACCESS_SECRET=
+
+# LinkedIn
+# LINKEDIN_ACCESS_TOKEN=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..427090f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,82 @@
+name: ci
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ safety-checks:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Verify repo structure
+ run: |
+ set -euo pipefail
+ for dir in posts snippets skills; do
+ test -d "$dir" || { echo "Missing directory: $dir"; exit 1; }
+ done
+ for file in README.md LICENSE SECURITY.md; do
+ test -f "$file" || { echo "Missing file: $file"; exit 1; }
+ done
+ echo "Structure OK"
+
+ - name: Redaction lint all posts
+ run: |
+ set -euo pipefail
+ found=0
+ for f in posts/*.md; do
+ [ -f "$f" ] || continue
+ python3 snippets/redact_lint.py "$f"
+ found=$((found + 1))
+ done
+ echo "Linted $found posts"
+
+ - name: Validate skills JSON
+ run: python3 snippets/validate_skills.py
+
+ - name: Scan for known sensitive tokens
+ run: |
+ python3 - << 'PY'
+ import os, sys
+ bad = []
+ needles = [
+ "AKIA",
+ "-----BEGIN PRIVATE KEY-----",
+ "-----BEGIN RSA PRIVATE KEY-----",
+ "xoxb-", "xoxp-",
+ "ghp_", "gho_",
+ "sk-ant-", "sk-proj-",
+ ]
+ skip_dirs = {".git", "node_modules", ".venv", "venv", "__pycache__"}
+ exts = {".md", ".py", ".sh", ".json", ".yml", ".yaml", ".txt", ".toml"}
+ for root, dirs, files in os.walk("."):
+ dirs[:] = [d for d in dirs if d not in skip_dirs]
+ for f in files:
+ if not any(f.endswith(e) for e in exts):
+ continue
+ p = os.path.join(root, f)
+ try:
+ txt = open(p, "r", encoding="utf-8", errors="replace").read()
+ except Exception:
+ continue
+ for n in needles:
+ if n in txt:
+ bad.append((p, n))
+ if bad:
+ print("FAIL: Potential sensitive tokens found:")
+ for p, n in bad:
+ print(f" {p}: contains '{n}'")
+ sys.exit(1)
+ print("OK: No sensitive tokens detected")
+ PY
diff --git a/.github/workflows/podcast.yml b/.github/workflows/podcast.yml
new file mode 100644
index 0000000..3c619c9
--- /dev/null
+++ b/.github/workflows/podcast.yml
@@ -0,0 +1,47 @@
+name: podcast-build
+
+on:
+ workflow_dispatch:
+ inputs:
+ post_file:
+ description: "Path to post markdown file"
+ required: true
+ default: "posts/2026-02-22.md"
+ developer:
+ description: "Developer name for credits"
+ required: true
+ default: "Agent Abrams"
+
+permissions:
+ contents: read
+
+jobs:
+ build-episode:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install dependencies
+ run: pip install requests
+
+ - name: Build podcast episode
+ env:
+ ELEVEN_API_KEY: ${{ secrets.ELEVEN_API_KEY }}
+ POST_FILE: ${{ inputs.post_file }}
+ DEVELOPER: ${{ inputs.developer }}
+ run: python3 podcast/build_episode.py "$POST_FILE" "$DEVELOPER"
+
+ - name: Generate RSS feed
+ run: python3 podcast/rss_generator.py
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: podcast-episode
+ path: podcast/output/
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..717ec4a
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,21 @@
+name: release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ create-release:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ generate_release_notes: true
diff --git a/.github/workflows/youtube.yml b/.github/workflows/youtube.yml
new file mode 100644
index 0000000..39c0623
--- /dev/null
+++ b/.github/workflows/youtube.yml
@@ -0,0 +1,44 @@
+name: youtube-upload
+
+on:
+ workflow_dispatch:
+ inputs:
+ video_file:
+ description: "Path to video file"
+ required: true
+ default: "media/output/video.mp4"
+ title:
+ description: "Video title"
+ required: true
+ description:
+ description: "Video description"
+ required: true
+ default: "Daily build-in-public devlog by Agent Abrams."
+
+permissions:
+ contents: read
+
+jobs:
+ upload:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install dependencies
+ run: pip install google-auth google-api-python-client
+
+ - name: Upload to YouTube
+ env:
+ YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }}
+ YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
+ YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }}
+ VIDEO_FILE: ${{ inputs.video_file }}
+ VIDEO_TITLE: ${{ inputs.title }}
+ VIDEO_DESCRIPTION: ${{ inputs.description }}
+ run: python3 video/upload_youtube.py "$VIDEO_FILE" "$VIDEO_TITLE" "$VIDEO_DESCRIPTION"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9d07b63
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+# Secrets
+.env
+.env.*
+!.env.example
+client_secret.json
+youtube_token.pickle
+*.pickle
+
+# Private notes (NEVER commit)
+private/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Python
+__pycache__/
+*.pyc
+*.pyo
+.venv/
+venv/
+*.egg-info/
+
+# Media output (generated, not committed)
+media/output/
+podcast/output/
+
+# Logs
+*.log
+
+# Build
+dist/
+build/
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..41f4b3f
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,15 @@
+## Applied
+- None — no P0/P1 issue in `CODEX_REVIEW.md` matched the autonomous safe-fix categories 1-7.
+
+## Deferred (needs Steve)
+- P0 — `snippets/redact_lint.py:71`, `.github/workflows/ci.yml:34` — redaction lint can print matched secret snippets into CI logs — deferred because log redaction behavior is not one of the approved autonomous safe fixes.
+- P0 — `video/oauth_authorize.py:31` — OAuth refresh token is printed to stdout after local authorization — deferred because changing token handling/storage is outside the approved safe list and may affect Steve's credential workflow.
+- P0 — `video/upload_youtube.py:38`, `.github/workflows/youtube.yml:36` — YouTube uploads default to public with no workflow privacy input — deferred because release/privacy workflow changes are not on the approved safe list.
+- P1 — `media/engine.py:67`, `media/engine.py:86`, `media/engine.py:103` — media generation errors are swallowed while the build reports complete — deferred because build failure policy changes are outside the approved safe list.
+- P1 — `media/engine.py:75` — chapter data is generated but not written to `media/output/chapters.json` — deferred because this is a behavior/output change outside the approved safe list.
+- P1 — `media/engine.py:28` — output path is cwd-relative and can write to the wrong directory — deferred because this is not a hardcoded absolute external path covered by safe fix 3.
+- P1 — `README.md:47`, `media/md_to_blog.py:99` — docs reference missing dependency manifest/requirements flow — deferred because dependency manifest work is explicitly not safe to generate without testing.
+- P1 — `podcast/rss_generator.py:42` — RSS items omit enclosure length, GUID, and dynamic escaped description content — deferred because feed contract changes are outside the approved safe list.
+- P1 — `media/md_to_podcast.py:31` — podcast text truncates mid-word or mid-section — deferred because narration policy changes are outside the approved safe list.
+- P1 — `media/social.py:21` — X post claims a 280-character limit but does not enforce it — deferred because social-copy behavior changes are outside the approved safe list.
+- P1 — `snippets/precommit.sh:30` — `.env` protection only checks staged files and can pass without git metadata — deferred because pre-commit policy changes are outside the approved safe list.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..55a77aa
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Steven Abrams, publishing as Agent Abrams
+
+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
new file mode 100644
index 0000000..9041b5f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,77 @@
+# AgentAbrams / Public
+
+A public build-in-public operating system by Agent Abrams.
+
+Copyright © 2026 Steven Abrams, publishing as Agent Abrams.
+
+## What This Is
+
+A security-hardened, automation-first platform that publishes devlogs, generates YouTube videos, produces podcasts, and packages reusable Claude-compatible skills — all without leaking proprietary business information.
+
+## Media Engine
+
+One command generates everything:
+
+```bash
+aa build posts/2026-02-22.md
+```
+
+Outputs:
+- `media/output/blog_output.md` — Structured educational blog post
+- `media/output/episode.mp3` — Podcast with intro/outro music
+- `media/output/video.mp4` — YouTube-ready video
+- `media/output/transcript.txt` — Auto-generated transcript
+- `media/output/chapters.json` — Chapter markers
+- `media/output/social_posts.txt` — Cross-post copy for X + LinkedIn
+
+## Repository Layout
+
+```
+posts/ — Daily devlog entries (Markdown)
+snippets/ — Reusable Python scripts
+skills/ — Claude-compatible tool schema JSON
+video/ — YouTube automation pipeline
+podcast/ — Podcast generation engine
+media/ — Unified media engine + CLI
+agents/ — Claude agent definitions
+governance/ — Publishing governance docs
+assets/ — Intro/outro music, cover art
+bin/ — CLI entrypoint (aa)
+.github/ — CI/CD workflows
+```
+
+## Quick Start
+
+```bash
+# Install dependencies
+pip install google-auth google-auth-oauthlib google-api-python-client requests openai-whisper
+
+# Generate media from any markdown file
+aa build posts/2026-02-22.md
+
+# Run safety checks
+bash snippets/precommit.sh
+```
+
+## Security Posture
+
+- Secret scanning + push protection enabled
+- Pre-commit redaction linting
+- No credentials in repo
+- All examples are synthetic
+- No proprietary processes disclosed
+
+## Safety Disclaimer
+
+This repository intentionally excludes:
+- Proprietary processes or methods
+- Vendor/supplier information
+- Pricing or sourcing logic
+- Client data or identifying information
+- Credentials or private infrastructure details
+
+Agent Abrams is a pseudonymous devlog author. Posts reflect public learnings and exclude confidential details.
+
+## License
+
+MIT — See [LICENSE](LICENSE)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..d600c4e
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,33 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+If you discover a vulnerability or sensitive-data exposure risk, open a GitHub issue with:
+- A minimal reproduction
+- The file path(s) involved
+- Any suggested mitigation
+
+Do NOT post secrets in issues.
+
+## Sensitive Information (Never Commit)
+
+- API keys, tokens, credentials
+- `.env` files
+- Private URLs with embedded credentials
+- Real customer data or identifiers
+- Internal system screenshots
+- Vendor/client names or contractual terms
+
+## If Something Sensitive Is Committed
+
+1. **Rotate** compromised credentials immediately
+2. **Rewrite** git history to remove the secret (`git filter-repo`)
+3. **Force push** and coordinate with anyone who cloned
+4. Follow hosting provider guidance to purge caches/forks
+
+## Security Controls
+
+- GitHub Secret Scanning: **enabled**
+- Push Protection: **enabled**
+- Pre-commit checks: `snippets/precommit.sh`
+- Redaction linter: `snippets/redact_lint.py`
diff --git a/agents/compliance_agent.json b/agents/compliance_agent.json
new file mode 100644
index 0000000..3cc3315
--- /dev/null
+++ b/agents/compliance_agent.json
@@ -0,0 +1,22 @@
+{
+ "name": "compliance_agent",
+ "description": "Ensure content complies with trade secret, privacy, and copyright rules. Checks that no proprietary processes, vendor identities, pricing logic, client data, or personally identifiable information is included. Returns pass/fail with specific findings.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "Content text to review for compliance"
+ },
+ "check_types": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["trade_secret", "privacy", "copyright", "credentials"]
+ },
+ "description": "Types of compliance checks to run (default: all)"
+ }
+ },
+ "required": ["text"]
+ }
+}
diff --git a/agents/devlog_agent.json b/agents/devlog_agent.json
new file mode 100644
index 0000000..ee0ca93
--- /dev/null
+++ b/agents/devlog_agent.json
@@ -0,0 +1,22 @@
+{
+ "name": "devlog_agent",
+ "description": "Transform raw private notes into a generalized public devlog entry that excludes proprietary or confidential information. Applies redaction rules and structures output using the standard blog template.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "raw_text": {
+ "type": "string",
+ "description": "Raw private note text to transform into a public devlog entry"
+ },
+ "intended_date": {
+ "type": "string",
+ "description": "YYYY-MM-DD date for the entry"
+ },
+ "developer_name": {
+ "type": "string",
+ "description": "Developer name for attribution (default: Agent Abrams)"
+ }
+ },
+ "required": ["raw_text", "intended_date"]
+ }
+}
diff --git a/agents/media_agent.json b/agents/media_agent.json
new file mode 100644
index 0000000..a0cf963
--- /dev/null
+++ b/agents/media_agent.json
@@ -0,0 +1,26 @@
+{
+ "name": "media_agent",
+ "description": "Unified media generation agent. Accepts any markdown file and produces blog post, podcast episode, YouTube video, transcript, chapter markers, and social media cross-posts. Credits the developer and follows the standard educational blog structure.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "md_path": {
+ "type": "string",
+ "description": "Path to source markdown file"
+ },
+ "developer_name": {
+ "type": "string",
+ "description": "Developer name for attribution in all outputs"
+ },
+ "outputs": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["blog", "podcast", "video", "transcript", "chapters", "social"]
+ },
+ "description": "Which outputs to generate (default: all)"
+ }
+ },
+ "required": ["md_path"]
+ }
+}
diff --git a/agents/publish_agent.json b/agents/publish_agent.json
new file mode 100644
index 0000000..bc39c2a
--- /dev/null
+++ b/agents/publish_agent.json
@@ -0,0 +1,22 @@
+{
+ "name": "publish_agent",
+ "description": "Publish sanitized markdown to a blog platform with correct author attribution and metadata. Supports backdating via intended-date headers. Validates content against redaction checklist before publishing.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "post_path": {
+ "type": "string",
+ "description": "Path to the sanitized markdown post to publish"
+ },
+ "platform": {
+ "type": "string",
+ "description": "Target platform (e.g., goodquestion.ai, github-pages)"
+ },
+ "author": {
+ "type": "string",
+ "description": "Author display name (default: Agent Abrams)"
+ }
+ },
+ "required": ["post_path", "platform"]
+ }
+}
diff --git a/agents/redaction_agent.json b/agents/redaction_agent.json
new file mode 100644
index 0000000..395ab5c
--- /dev/null
+++ b/agents/redaction_agent.json
@@ -0,0 +1,18 @@
+{
+ "name": "redaction_agent",
+ "description": "Scan content for potential confidential information including emails, phone numbers, API keys, IP addresses, connection strings, vendor names, and proprietary process descriptions. Returns findings with severity levels.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "markdown_path": {
+ "type": "string",
+ "description": "Path to markdown file to scan"
+ },
+ "text": {
+ "type": "string",
+ "description": "Raw text to scan (alternative to markdown_path)"
+ }
+ },
+ "required": []
+ }
+}
diff --git a/agents/video_agent.json b/agents/video_agent.json
new file mode 100644
index 0000000..8da2940
--- /dev/null
+++ b/agents/video_agent.json
@@ -0,0 +1,27 @@
+{
+ "name": "video_agent",
+ "description": "Generate a 3-minute YouTube-ready video from a sanitized devlog post. Pipeline: post → narration script → TTS voice → intro/outro mixing → video composition. Supports avatar+screen layout or static cover image.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "post_path": {
+ "type": "string",
+ "description": "Path to the sanitized devlog markdown post"
+ },
+ "avatar_path": {
+ "type": "string",
+ "description": "Optional path to avatar video file for split-screen layout"
+ },
+ "screen_path": {
+ "type": "string",
+ "description": "Optional path to screen recording for split-screen layout"
+ },
+ "privacy": {
+ "type": "string",
+ "enum": ["public", "unlisted", "private"],
+ "description": "YouTube privacy setting (default: public)"
+ }
+ },
+ "required": ["post_path"]
+ }
+}
diff --git a/bin/aa b/bin/aa
new file mode 100755
index 0000000..661c8a8
--- /dev/null
+++ b/bin/aa
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# aa — AgentAbrams CLI
+# Add this directory to PATH or symlink to /usr/local/bin/aa
+#
+# Usage:
+# aa build posts/2026-02-22.md
+# aa new 2026-02-23 "Day 6 — Title"
+# aa lint posts/2026-02-22.md
+# aa validate
+# aa check
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+exec python3 "$REPO_ROOT/media/cli.py" "$@"
diff --git a/governance/GOVERNANCE.md b/governance/GOVERNANCE.md
new file mode 100644
index 0000000..8e7b289
--- /dev/null
+++ b/governance/GOVERNANCE.md
@@ -0,0 +1,45 @@
+# Governance — AgentAbrams/Public
+
+## Publishing Principles
+
+1. **Redaction-first**: Every post starts as a private raw note. Only the "public cut" is published.
+2. **Safety over completeness**: A shorter, safer post beats a detailed, risky one.
+3. **Transparency about boundaries**: Readers should see what is excluded, not wonder about it.
+4. **Reproducibility**: Shared code and processes must work independently of proprietary systems.
+
+## What Is Publishable
+
+- General approaches and methodologies
+- Synthetic examples and toy data
+- Open-source tooling and scripts
+- Lessons learned (abstracted from specifics)
+- Public API usage patterns
+- Architecture diagrams (without internal identifiers)
+
+## What Is NOT Publishable
+
+- Client or vendor names
+- Internal pricing, margins, or sourcing logic
+- Proprietary production or design processes
+- Private datasets or credentials
+- Screenshots of internal tools or dashboards
+- Unique automation workflows that provide competitive advantage
+- Employee or contractor identifiers
+- Internal ticket IDs, order numbers, or customer data
+
+## Escalation
+
+If uncertain whether content is safe to publish:
+1. Run `snippets/redact_lint.py` on the file
+2. Review against this governance doc
+3. When in doubt, **leave it out**
+4. Consult legal if the content involves contracts, licensing, or third-party IP
+
+## Incident Response
+
+If sensitive data is published accidentally:
+1. Rotate any exposed credentials immediately
+2. Remove from blog platform
+3. Rewrite git history (`git filter-repo`)
+4. Force push to GitHub
+5. Document the incident and update redaction rules
diff --git a/governance/REDACTION_CHECKLIST.md b/governance/REDACTION_CHECKLIST.md
new file mode 100644
index 0000000..399e987
--- /dev/null
+++ b/governance/REDACTION_CHECKLIST.md
@@ -0,0 +1,31 @@
+# Redaction Checklist
+
+Run through this checklist before publishing ANY content.
+
+## Automated Checks
+
+- [ ] `python3 snippets/redact_lint.py <file>` passes clean
+- [ ] `python3 snippets/validate_skills.py` passes clean
+- [ ] `bash snippets/precommit.sh` passes clean
+
+## Manual Review
+
+- [ ] No vendor or supplier names appear
+- [ ] No client names or identifying details
+- [ ] No internal pricing, margins, or cost data
+- [ ] No proprietary process descriptions
+- [ ] No internal file paths or server addresses
+- [ ] No API keys, tokens, or credentials
+- [ ] No screenshots of internal tools
+- [ ] No employee or contractor names
+- [ ] No internal ticket/order/customer IDs
+- [ ] No Slack channel names or webhook URLs
+- [ ] No database names or connection strings
+- [ ] All code examples use synthetic/generic data
+- [ ] "What I'm not sharing" section is present
+
+## Final Sign-off
+
+- [ ] I have read the full post as if I were an outside reader
+- [ ] Nothing in this post would help someone recreate a confidential business advantage
+- [ ] I am comfortable with this being permanently public
diff --git a/media/__init__.py b/media/__init__.py
new file mode 100755
index 0000000..70560b9
--- /dev/null
+++ b/media/__init__.py
@@ -0,0 +1 @@
+"""AgentAbrams unified media engine."""
diff --git a/media/audio_mixer.py b/media/audio_mixer.py
new file mode 100755
index 0000000..0e99050
--- /dev/null
+++ b/media/audio_mixer.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""
+audio_mixer.py — Mix intro + voice + outro with loudness normalization.
+
+Uses ffmpeg for broadcast-ready audio levels.
+
+Usage:
+ python3 media/audio_mixer.py <voice.mp3> <output.mp3>
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+INTRO = "assets/intro.mp3"
+OUTRO = "assets/outro.mp3"
+
+
+def mix_audio(voice_file: str, output_file: str):
+ has_intro = Path(INTRO).exists()
+ has_outro = Path(OUTRO).exists()
+
+ Path(output_file).parent.mkdir(parents=True, exist_ok=True)
+
+ if has_intro and has_outro:
+ # Full mix: intro + normalized voice + outro
+ cmd = [
+ "ffmpeg", "-y",
+ "-i", INTRO,
+ "-i", voice_file,
+ "-i", OUTRO,
+ "-filter_complex",
+ "[1:a]loudnorm=I=-16:TP=-1.5:LRA=11[voice];"
+ "[0:a][voice][2:a]concat=n=3:v=0:a=1[aout]",
+ "-map", "[aout]",
+ "-c:a", "libmp3lame", "-b:a", "192k",
+ output_file,
+ ]
+ elif has_intro:
+ cmd = [
+ "ffmpeg", "-y",
+ "-i", INTRO, "-i", voice_file,
+ "-filter_complex",
+ "[1:a]loudnorm=I=-16:TP=-1.5:LRA=11[voice];"
+ "[0:a][voice]concat=n=2:v=0:a=1[aout]",
+ "-map", "[aout]",
+ "-c:a", "libmp3lame", "-b:a", "192k",
+ output_file,
+ ]
+ else:
+ # Voice only with normalization
+ cmd = [
+ "ffmpeg", "-y",
+ "-i", voice_file,
+ "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
+ "-c:a", "libmp3lame", "-b:a", "192k",
+ output_file,
+ ]
+
+ print(f"Mixing audio → {output_file}")
+ subprocess.run(cmd, check=True)
+ print(f"Audio mixed: {output_file}")
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: audio_mixer.py <voice.mp3> <output.mp3>", file=sys.stderr)
+ sys.exit(2)
+ mix_audio(sys.argv[1], sys.argv[2])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/chapters.py b/media/chapters.py
new file mode 100755
index 0000000..ff947d8
--- /dev/null
+++ b/media/chapters.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""
+chapters.py — Extract chapter markers from markdown H2 headers.
+
+Generates timestamps compatible with YouTube and podcast chapter markers.
+
+Usage:
+ python3 media/chapters.py <post.md> [audio_duration_seconds]
+"""
+
+import json
+import sys
+from pathlib import Path
+
+
+def generate_chapters(md_path: str, audio_duration: int = 180) -> list[dict]:
+ text = Path(md_path).read_text(encoding="utf-8")
+ lines = text.split("\n")
+
+ headers = []
+ for line in lines:
+ if line.startswith("## "):
+ title = line.replace("## ", "").strip()
+ if title.lower() not in ("what i'm not sharing", "snippet and skill"):
+ headers.append(title)
+
+ if not headers:
+ return [{"time": 0, "time_formatted": "0:00", "title": "Introduction"}]
+
+ step = max(1, audio_duration // (len(headers) + 1))
+ chapters = [{"time": 0, "time_formatted": "0:00", "title": "Introduction"}]
+
+ for i, title in enumerate(headers):
+ t = step * (i + 1)
+ minutes = t // 60
+ seconds = t % 60
+ chapters.append({
+ "time": t,
+ "time_formatted": f"{minutes}:{seconds:02d}",
+ "title": title,
+ })
+
+ return chapters
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: chapters.py <post.md> [duration_seconds]", file=sys.stderr)
+ sys.exit(2)
+
+ duration = int(sys.argv[2]) if len(sys.argv) > 2 else 180
+ chapters = generate_chapters(sys.argv[1], duration)
+
+ out_dir = Path("media/output")
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_file = out_dir / "chapters.json"
+ out_file.write_text(json.dumps(chapters, indent=2), encoding="utf-8")
+
+ print(f"Chapters generated: {out_file}")
+ for ch in chapters:
+ print(f" {ch['time_formatted']} {ch['title']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/cli.py b/media/cli.py
new file mode 100755
index 0000000..4a3a2f9
--- /dev/null
+++ b/media/cli.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+cli.py — AgentAbrams CLI entrypoint.
+
+Usage:
+ aa build <post.md> [developer_name]
+ aa new <date> <title>
+ aa lint <file.md>
+ aa validate
+ aa check
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).parent.parent
+
+
+def cmd_build(args):
+ if not args:
+ print("Usage: aa build <post.md> [developer_name]")
+ sys.exit(2)
+ md_path = args[0]
+ developer = args[1] if len(args) > 1 else "Agent Abrams"
+ sys.path.insert(0, str(PROJECT_ROOT / "media"))
+ from engine import build
+ build(md_path, developer)
+
+
+def cmd_new(args):
+ if len(args) < 2:
+ print("Usage: aa new <YYYY-MM-DD> <title>")
+ sys.exit(2)
+ subprocess.run(
+ [sys.executable, str(PROJECT_ROOT / "snippets/new_post.py"), args[0], args[1]],
+ check=True,
+ )
+
+
+def cmd_lint(args):
+ if not args:
+ print("Usage: aa lint <file.md>")
+ sys.exit(2)
+ subprocess.run(
+ [sys.executable, str(PROJECT_ROOT / "snippets/redact_lint.py"), args[0]],
+ )
+
+
+def cmd_validate(args):
+ subprocess.run(
+ [sys.executable, str(PROJECT_ROOT / "snippets/validate_skills.py")],
+ )
+
+
+def cmd_check(args):
+ subprocess.run(
+ ["bash", str(PROJECT_ROOT / "snippets/precommit.sh")],
+ )
+
+
+COMMANDS = {
+ "build": cmd_build,
+ "new": cmd_new,
+ "lint": cmd_lint,
+ "validate": cmd_validate,
+ "check": cmd_check,
+}
+
+
+def main():
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
+ print("""AgentAbrams CLI
+
+Usage:
+ aa build <post.md> [developer] Generate all media from a markdown file
+ aa new <date> <title> Create a new post skeleton
+ aa lint <file.md> Run redaction linter on a file
+ aa validate Validate all skills JSON
+ aa check Run full pre-commit checks
+""")
+ sys.exit(0)
+
+ command = sys.argv[1]
+ if command not in COMMANDS:
+ print(f"Unknown command: {command}")
+ print(f"Available: {', '.join(COMMANDS.keys())}")
+ sys.exit(1)
+
+ COMMANDS[command](sys.argv[2:])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/engine.py b/media/engine.py
new file mode 100755
index 0000000..721b1e8
--- /dev/null
+++ b/media/engine.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""
+engine.py — Unified media engine for AgentAbrams.
+
+One function call generates: blog, podcast, video, transcript, chapters, social posts.
+
+Usage:
+ python3 media/engine.py <post.md> [developer_name]
+"""
+
+import os
+import sys
+from pathlib import Path
+
+# Add project root and sibling dirs to path
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT / "media"))
+sys.path.insert(0, str(PROJECT_ROOT / "video"))
+sys.path.insert(0, str(PROJECT_ROOT / "podcast"))
+
+from md_to_blog import transform as blog_transform
+from md_to_podcast import transform as podcast_transform
+from chapters import generate_chapters
+from social import generate_social
+
+
+def build(md_path: str, developer: str = "Agent Abrams"):
+ out = Path("media/output")
+ out.mkdir(parents=True, exist_ok=True)
+
+ print(f"{'='*60}")
+ print(f"AgentAbrams Media Engine")
+ print(f"Source: {md_path}")
+ print(f"Developer: {developer}")
+ print(f"{'='*60}")
+ print()
+
+ # 1. Blog
+ print("[1/6] Generating blog post...")
+ blog = blog_transform(md_path, developer)
+ blog_file = out / "blog_output.md"
+ blog_file.write_text(blog, encoding="utf-8")
+ print(f" → {blog_file}")
+ print()
+
+ # 2. Podcast script
+ print("[2/6] Generating podcast script...")
+ script = podcast_transform(md_path, developer)
+ script_file = out / "podcast_script.txt"
+ script_file.write_text(script, encoding="utf-8")
+ print(f" → {script_file}")
+ print()
+
+ # 3. TTS Audio (requires ELEVEN_API_KEY)
+ voice_file = out / "voice.mp3"
+ episode_file = out / "episode.mp3"
+
+ if os.environ.get("ELEVEN_API_KEY"):
+ print("[3/6] Generating TTS audio...")
+ try:
+ from tts_engine import generate_audio
+ generate_audio(script, str(voice_file))
+
+ # Mix with intro/outro
+ from audio_mixer import mix_audio
+ mix_audio(str(voice_file), str(episode_file))
+ except Exception as e:
+ print(f" WARNING: TTS failed: {e}")
+ print(" Skipping audio generation.")
+ else:
+ print("[3/6] Skipping TTS (ELEVEN_API_KEY not set)")
+ print(" Set ELEVEN_API_KEY to enable audio generation.")
+ print()
+
+ # 4. Chapters
+ print("[4/6] Generating chapter markers...")
+ chapters = generate_chapters(md_path)
+ print()
+
+ # 5. Video (requires ffmpeg + audio)
+ if episode_file.exists():
+ print("[5/6] Generating video...")
+ try:
+ from video import build_video
+ build_video(str(episode_file))
+ except Exception as e:
+ print(f" WARNING: Video generation failed: {e}")
+ else:
+ print("[5/6] Skipping video (no audio file)")
+ print()
+
+ # 6. Social posts
+ print("[6/6] Generating social posts...")
+ generate_social(str(blog_file))
+ print()
+
+ # 7. Transcript (bonus, if whisper available)
+ if episode_file.exists():
+ print("[bonus] Generating transcript...")
+ try:
+ from transcript import generate_transcript
+ generate_transcript(str(episode_file))
+ except Exception as e:
+ print(f" Transcript skipped: {e}")
+ print()
+
+ # Summary
+ print(f"{'='*60}")
+ print("BUILD COMPLETE")
+ print(f"{'='*60}")
+ print()
+ print("Outputs:")
+ for f in sorted(out.iterdir()):
+ size = f.stat().st_size
+ print(f" {f.name:30s} {size:>10,} bytes")
+ print()
+ print("Next steps:")
+ print(" 1. Review media/output/blog_output.md")
+ print(" 2. Run: python3 snippets/redact_lint.py media/output/blog_output.md")
+ print(" 3. Publish to goodquestion.ai")
+ print(" 4. Upload video: python3 video/upload_youtube.py media/output/video.mp4")
+ print()
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: engine.py <post.md> [developer_name]", file=sys.stderr)
+ sys.exit(2)
+
+ developer = sys.argv[2] if len(sys.argv) > 2 else "Agent Abrams"
+ build(sys.argv[1], developer)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/md_to_blog.py b/media/md_to_blog.py
new file mode 100755
index 0000000..b0cb6b1
--- /dev/null
+++ b/media/md_to_blog.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""
+md_to_blog.py — Transform any markdown file into a structured educational blog post.
+
+Every blog MUST follow this structure:
+1. What This Is
+2. Why It Matters
+3. How It Works
+4. How You Can Build It Yourself
+5. Security & Redaction Notes
+6. Final Thoughts
+
+Usage:
+ python3 media/md_to_blog.py <source.md> <developer_name>
+"""
+
+import re
+import sys
+from pathlib import Path
+
+BLOG_TEMPLATE = """# {title}
+
+**By:** {developer}
+
+## What This Is
+
+{what_it_is}
+
+## Why It Matters
+
+{why_it_matters}
+
+## How It Works
+
+{how_it_works}
+
+## How You Can Build It Yourself
+
+{how_to_build}
+
+## Security & Redaction Notes
+
+This post intentionally excludes proprietary processes, vendor details,
+pricing logic, internal credentials, and confidential operational data.
+All examples are synthetic and generic. For redaction tooling, see
+[snippets/redact_lint.py](../snippets/redact_lint.py).
+
+## Final Thoughts
+
+Build in public responsibly. Share the lessons, not the leverage.
+"""
+
+
+def extract_title(text: str) -> str:
+ for line in text.split("\n"):
+ if line.startswith("#"):
+ return line.lstrip("#").strip()
+ return "Untitled"
+
+
+def extract_body(text: str) -> str:
+ lines = text.split("\n")
+ body_lines = []
+ for line in lines[1:]:
+ if line.startswith("**Intended date:") or line.startswith("**Author:"):
+ continue
+ body_lines.append(line)
+ return "\n".join(body_lines).strip()
+
+
+def extract_section(text: str, header: str) -> str:
+ pattern = rf"##\s*{re.escape(header)}\s*\n(.*?)(?=\n##|\Z)"
+ match = re.search(pattern, text, re.DOTALL | re.IGNORECASE)
+ if match:
+ return match.group(1).strip()
+ return ""
+
+
+def transform(md_path: str, developer: str) -> str:
+ text = Path(md_path).read_text(encoding="utf-8")
+ title = extract_title(text)
+ body = extract_body(text)
+
+ what_it_is = extract_section(text, "What I worked on") or body[:600]
+ why_it_matters = extract_section(text, "What I learned") or (
+ "Sharing systems thinking helps others build safely and efficiently."
+ )
+ how_it_works = extract_section(text, "What went wrong") or (
+ "The system processes markdown into structured educational media."
+ )
+
+ return BLOG_TEMPLATE.format(
+ title=title,
+ developer=developer,
+ what_it_is=what_it_is,
+ why_it_matters=why_it_matters,
+ how_it_works=how_it_works,
+ how_to_build=(
+ "1. Fork the [AgentAbrams/Public](https://github.com/AgentAbrams/Public) repository\n"
+ "2. Install dependencies: `pip install -r requirements.txt`\n"
+ "3. Write your markdown source file\n"
+ "4. Run `aa build <your-file.md>` to generate all media outputs\n"
+ "5. Review outputs in `media/output/` before publishing"
+ ),
+ )
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: md_to_blog.py <source.md> <developer_name>", file=sys.stderr)
+ sys.exit(2)
+ print(transform(sys.argv[1], sys.argv[2]))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/md_to_podcast.py b/media/md_to_podcast.py
new file mode 100755
index 0000000..054d062
--- /dev/null
+++ b/media/md_to_podcast.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+"""
+md_to_podcast.py — Transform any markdown file into a podcast narration script.
+
+Credits the developer and explains what it is, how it works, and how to build it.
+
+Usage:
+ python3 media/md_to_podcast.py <source.md> <developer_name>
+"""
+
+import re
+import sys
+from pathlib import Path
+
+
+def transform(md_path: str, developer: str) -> str:
+ text = Path(md_path).read_text(encoding="utf-8")
+ title = text.split("\n")[0].replace("#", "").strip()
+
+ # Clean markdown for speech
+ body = text
+ body = re.sub(r"^#+ .*\n", "", body, flags=re.MULTILINE)
+ body = re.sub(r"\*+", "", body)
+ body = re.sub(r"`[^`]+`", "", body)
+ body = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", body)
+ body = re.sub(r"^- ", " ", body, flags=re.MULTILINE)
+ body = re.sub(r"\n{3,}", "\n\n", body)
+ body = body.strip()
+
+ # Truncate for ~3 minute episode
+ if len(body) > 2000:
+ body = body[:2000] + "..."
+
+ script = f"""Welcome to the Agent Abrams podcast.
+
+I'm {developer}, and today we're looking at: {title}.
+
+Let me walk you through what this is, how it works, and how you can build it yourself.
+
+{body}
+
+This content excludes proprietary processes and confidential information.
+Everything shared here is designed to be reproducible and useful to any builder.
+
+If you want to try this yourself, check out the AgentAbrams Public repository on GitHub.
+
+Thanks for listening. Build in public. Build responsibly.
+
+This has been Agent Abrams."""
+
+ return script
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: md_to_podcast.py <source.md> <developer_name>", file=sys.stderr)
+ sys.exit(2)
+ print(transform(sys.argv[1], sys.argv[2]))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/social.py b/media/social.py
new file mode 100755
index 0000000..f5d9e01
--- /dev/null
+++ b/media/social.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""
+social.py — Generate cross-post content for X (Twitter) and LinkedIn.
+
+Usage:
+ python3 media/social.py <blog_output.md>
+"""
+
+import sys
+from pathlib import Path
+
+
+def generate_social(blog_path: str) -> str:
+ text = Path(blog_path).read_text(encoding="utf-8")
+ title = ""
+ for line in text.split("\n"):
+ if line.startswith("#"):
+ title = line.lstrip("#").strip()
+ break
+
+ # X post (280 char limit)
+ x_post = f"""{title}
+
+New build-in-public entry from Agent Abrams.
+
+What it is. How it works. How you can build it.
+
+Blog + podcast + code inside.
+
+github.com/AgentAbrams/Public
+
+#BuildInPublic #AI #ClaudeCode"""
+
+ # LinkedIn post (longer form)
+ linkedin_post = f"""New entry in the Agent Abrams build-in-public series:
+
+{title}
+
+Every post follows the same structure:
+- What This Is
+- Why It Matters
+- How It Works
+- How You Can Build It Yourself
+
+No proprietary secrets. No vendor names. No hand-waving.
+Just lessons, code, and reproducible workflows.
+
+Full post, podcast, and source code:
+github.com/AgentAbrams/Public
+
+#BuildInPublic #AI #SoftwareEngineering #ClaudeCode"""
+
+ output = f"""=== X (Twitter) ===
+{x_post}
+
+=== LinkedIn ===
+{linkedin_post}
+"""
+
+ out_dir = Path("media/output")
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_file = out_dir / "social_posts.txt"
+ out_file.write_text(output, encoding="utf-8")
+
+ print(f"Social posts generated: {out_file}")
+ return output
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: social.py <blog_output.md>", file=sys.stderr)
+ sys.exit(2)
+ generate_social(sys.argv[1])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/transcript.py b/media/transcript.py
new file mode 100755
index 0000000..1787cdd
--- /dev/null
+++ b/media/transcript.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+"""
+transcript.py — Generate transcript from audio using OpenAI Whisper.
+
+Usage:
+ python3 media/transcript.py <audio.mp3>
+
+Requires: pip install openai-whisper
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def generate_transcript(audio_file: str) -> Path:
+ out_dir = Path("media/output")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ try:
+ subprocess.run(
+ [
+ "whisper",
+ audio_file,
+ "--model", "base",
+ "--output_format", "txt",
+ "--output_dir", str(out_dir),
+ ],
+ check=True,
+ timeout=300,
+ )
+ stem = Path(audio_file).stem
+ result = out_dir / f"{stem}.txt"
+ if result.exists():
+ # Rename to standard name
+ final = out_dir / "transcript.txt"
+ result.rename(final)
+ print(f"Transcript generated: {final}")
+ return final
+ else:
+ print(f"Whisper completed but output not found at {result}")
+ return result
+ except FileNotFoundError:
+ print("WARNING: whisper not installed. Creating transcript from script instead.")
+ # Fallback: if we have a script, use that
+ fallback = out_dir / "transcript.txt"
+ fallback.write_text(
+ "(Transcript auto-generation requires whisper. Install: pip install openai-whisper)\n",
+ encoding="utf-8",
+ )
+ return fallback
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: transcript.py <audio.mp3>", file=sys.stderr)
+ sys.exit(2)
+ generate_transcript(sys.argv[1])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/video.py b/media/video.py
new file mode 100755
index 0000000..99c3d3d
--- /dev/null
+++ b/media/video.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""
+media/video.py — Create video from audio + static cover image.
+
+For full avatar+screen layout, use video/compose_video.sh instead.
+
+Usage:
+ python3 media/video.py <audio.mp3> [output.mp4]
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def build_video(audio_file: str, output_file: str = "media/output/video.mp4"):
+ Path(output_file).parent.mkdir(parents=True, exist_ok=True)
+
+ cover = "assets/cover.png"
+ if not Path(cover).exists():
+ Path("assets").mkdir(parents=True, exist_ok=True)
+ subprocess.run(
+ [
+ "ffmpeg", "-y",
+ "-f", "lavfi",
+ "-i", "color=c=0x1a1a2e:s=1920x1080:d=1",
+ "-vframes", "1",
+ cover,
+ ],
+ check=True,
+ )
+ print(f"Generated placeholder cover: {cover}")
+
+ subprocess.run(
+ [
+ "ffmpeg", "-y",
+ "-loop", "1",
+ "-i", cover,
+ "-i", audio_file,
+ "-c:v", "libx264",
+ "-tune", "stillimage",
+ "-preset", "fast",
+ "-crf", "23",
+ "-c:a", "aac",
+ "-b:a", "192k",
+ "-shortest",
+ output_file,
+ ],
+ check=True,
+ )
+ print(f"Video created: {output_file}")
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: video.py <audio.mp3> [output.mp4]", file=sys.stderr)
+ sys.exit(2)
+
+ output = sys.argv[2] if len(sys.argv) > 2 else "media/output/video.mp4"
+ build_video(sys.argv[1], output)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/podcast/build_episode.py b/podcast/build_episode.py
new file mode 100755
index 0000000..471402e
--- /dev/null
+++ b/podcast/build_episode.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""
+build_episode.py — Build a complete podcast episode from a devlog post.
+
+Pipeline: post → script → TTS → audio file
+
+Usage:
+ python3 podcast/build_episode.py posts/2026-02-22.md [developer_name]
+
+Environment variables:
+ ELEVEN_API_KEY
+"""
+
+import sys
+from pathlib import Path
+
+# Add project root to path for imports
+sys.path.insert(0, str(Path(__file__).parent))
+
+from script_from_post import build_script
+from tts_engine import generate_podcast_audio
+
+
+def build(post_path: str, developer: str = "Agent Abrams"):
+ print(f"Building podcast episode from: {post_path}")
+
+ # Generate script
+ script = build_script(post_path, developer)
+ print(f"Script generated ({len(script)} chars)")
+
+ # Generate audio
+ out_dir = Path("podcast/output/audio")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ date_stem = Path(post_path).stem
+ output_file = out_dir / f"{date_stem}.mp3"
+
+ generate_podcast_audio(script, str(output_file))
+ print(f"Episode ready: {output_file}")
+
+ return str(output_file)
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: build_episode.py <post.md> [developer_name]", file=sys.stderr)
+ sys.exit(2)
+
+ developer = sys.argv[2] if len(sys.argv) > 2 else "Agent Abrams"
+ build(sys.argv[1], developer)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/podcast/rss_generator.py b/podcast/rss_generator.py
new file mode 100755
index 0000000..b8727ee
--- /dev/null
+++ b/podcast/rss_generator.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""
+rss_generator.py — Generate podcast RSS feed from audio files.
+
+Usage:
+ python3 podcast/rss_generator.py
+
+Output: podcast/output/rss/podcast.xml
+"""
+
+import json
+from datetime import datetime
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+BASE_URL = "https://agentabrams.github.io/Public/podcast/output/audio"
+SITE_URL = "https://agentabrams.github.io/Public"
+
+
+def build_rss():
+ audio_dir = Path("podcast/output/audio")
+ audio_dir.mkdir(parents=True, exist_ok=True)
+
+ files = sorted(audio_dir.glob("*.mp3"), reverse=True)
+
+ if not files:
+ print("No audio files found in podcast/output/audio/")
+ return
+
+ items = []
+ for f in files:
+ date_str = f.stem
+ try:
+ pub_date = datetime.strptime(date_str, "%Y-%m-%d")
+ except ValueError:
+ continue
+
+ pub_date_rss = pub_date.strftime("%a, %d %b %Y 00:00:00 GMT")
+ title = f"Agent Abrams — {date_str}"
+ file_url = f"{BASE_URL}/{f.name}"
+
+ items.append(f""" <item>
+ <title>{escape(title)}</title>
+ <enclosure url="{escape(file_url)}" type="audio/mpeg"/>
+ <pubDate>{pub_date_rss}</pubDate>
+ <description>Daily build-in-public devlog by Agent Abrams.</description>
+ </item>""")
+
+ rss = f"""<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
+<channel>
+ <title>Agent Abrams</title>
+ <link>{SITE_URL}</link>
+ <description>Build in Public. Securely. A daily devlog by Agent Abrams.</description>
+ <language>en-us</language>
+ <itunes:author>Agent Abrams</itunes:author>
+ <itunes:category text="Technology"/>
+{chr(10).join(items)}
+</channel>
+</rss>
+"""
+
+ out_dir = Path("podcast/output/rss")
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_file = out_dir / "podcast.xml"
+ out_file.write_text(rss, encoding="utf-8")
+ print(f"RSS feed generated: {out_file}")
+
+
+if __name__ == "__main__":
+ build_rss()
diff --git a/podcast/script_from_post.py b/podcast/script_from_post.py
new file mode 100755
index 0000000..b0efce1
--- /dev/null
+++ b/podcast/script_from_post.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""
+script_from_post.py — Convert a devlog post into a podcast narration script.
+
+Usage:
+ python3 podcast/script_from_post.py posts/2026-02-22.md
+"""
+
+import re
+import sys
+from pathlib import Path
+
+
+def build_script(post_path: str, developer: str = "Agent Abrams") -> str:
+ text = Path(post_path).read_text(encoding="utf-8")
+ lines = text.split("\n")
+ title = lines[0].replace("#", "").strip()
+
+ # Extract body, skip metadata lines
+ body_lines = []
+ for line in lines[1:]:
+ if line.startswith("**Intended date:") or line.startswith("**Author:"):
+ continue
+ if line.startswith("## What I'm not sharing"):
+ break
+ body_lines.append(line)
+
+ body = "\n".join(body_lines).strip()
+
+ # Clean markdown for speech
+ body = re.sub(r"\*+", "", body)
+ body = re.sub(r"`[^`]+`", "", body)
+ body = re.sub(r"^## ", "\n", body, flags=re.MULTILINE)
+ body = re.sub(r"^- ", " ", body, flags=re.MULTILINE)
+
+ script = f"""Welcome to the Agent Abrams podcast.
+
+I'm {developer}, and this is today's build-in-public entry.
+
+{title}.
+
+{body}
+
+As always, this content intentionally excludes proprietary processes, vendor details, and confidential information. The goal is to share what generalizes — lessons anyone can use.
+
+Thanks for listening. Build in public. Build responsibly.
+
+This has been Agent Abrams."""
+
+ return script
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: script_from_post.py <post.md> [developer_name]", file=sys.stderr)
+ sys.exit(2)
+
+ developer = sys.argv[2] if len(sys.argv) > 2 else "Agent Abrams"
+ print(build_script(sys.argv[1], developer))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/podcast/tts_engine.py b/podcast/tts_engine.py
new file mode 100755
index 0000000..8f6cfef
--- /dev/null
+++ b/podcast/tts_engine.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""
+podcast/tts_engine.py — TTS wrapper for podcast audio generation.
+
+Reuses the same ElevenLabs API as video TTS but with podcast-optimized settings.
+
+Usage:
+ python3 podcast/tts_engine.py <script.txt> <output.mp3>
+
+Environment variables:
+ ELEVEN_API_KEY
+ ELEVEN_VOICE_ID (optional)
+"""
+
+import os
+import sys
+from pathlib import Path
+
+import requests
+
+DEFAULT_VOICE_ID = "pNInz6obpgDQGcFmaJgB" # Adam
+
+
+def generate_podcast_audio(text: str, output_file: str):
+ api_key = os.environ.get("ELEVEN_API_KEY")
+ if not api_key:
+ print("ERROR: ELEVEN_API_KEY not set", file=sys.stderr)
+ sys.exit(1)
+
+ voice_id = os.environ.get("ELEVEN_VOICE_ID", DEFAULT_VOICE_ID)
+ url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
+
+ headers = {
+ "xi-api-key": api_key,
+ "Content-Type": "application/json",
+ }
+
+ payload = {
+ "text": text,
+ "model_id": "eleven_monolingual_v1",
+ "voice_settings": {
+ "stability": 0.70,
+ "similarity_boost": 0.65,
+ "style": 0.2,
+ },
+ }
+
+ resp = requests.post(url, json=payload, headers=headers, timeout=180)
+ resp.raise_for_status()
+
+ with open(output_file, "wb") as f:
+ f.write(resp.content)
+
+ print(f"Podcast audio generated: {output_file} ({len(resp.content) / 1024:.0f} KB)")
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: tts_engine.py <script_file_or_text> <output.mp3>", file=sys.stderr)
+ sys.exit(2)
+
+ source = sys.argv[1]
+ output = sys.argv[2]
+
+ if Path(source).is_file():
+ text = Path(source).read_text(encoding="utf-8")
+ else:
+ text = source
+
+ generate_podcast_audio(text, output)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/posts/2026-02-18.md b/posts/2026-02-18.md
new file mode 100644
index 0000000..44cffc2
--- /dev/null
+++ b/posts/2026-02-18.md
@@ -0,0 +1,33 @@
+# Day 1 — Shipping a Devlog Without Shipping Secrets
+
+**Intended date:** 2026-02-18
+**Author:** Agent Abrams
+
+## What I worked on
+
+I set a non-negotiable constraint for this project: publish a daily devlog that's genuinely useful to other builders, while being aggressively respectful of privacy, IP, and trade secrets. My rule: if a detail would help someone recreate a confidential business advantage, it does not belong in the post. So I wrote down two versions of the same story — a raw private note with all the messy reality, and a "public cut" containing only generalizable lessons.
+
+## What went wrong
+
+My first draft was too specific in the wrong places: it named tools, hinted at internal structure, and included identifiers that would be obvious to anyone familiar with my day job. Even if those details aren't "secrets" alone, their combination can become sensitive. I had to rewrite the entire post from scratch with fresh eyes.
+
+## What I learned
+
+- A devlog needs a boundary statement as much as it needs content
+- Redaction is easier if you plan for it *before* you write
+- "General lesson + synthetic example" beats "real detail + anxiety"
+- The two-version workflow (raw → public cut) is essential
+- Running a linter catches what human eyes miss
+
+## What I'm not sharing
+
+I'm intentionally not sharing:
+- client/vendor names
+- internal pricing or sourcing logic
+- proprietary production/design processes
+- any private datasets, credentials, or identifying screenshots
+
+## Snippet and skill
+
+- Snippet: `snippets/redact_lint.py`
+- Skill: `skills/redaction_lint.json`
diff --git a/posts/2026-02-19.md b/posts/2026-02-19.md
new file mode 100644
index 0000000..843ab52
--- /dev/null
+++ b/posts/2026-02-19.md
@@ -0,0 +1,33 @@
+# Day 2 — Turning Daily Chaos Into a Repeatable Publishing System
+
+**Intended date:** 2026-02-19
+**Author:** Agent Abrams
+
+## What I worked on
+
+The big challenge today wasn't writing — it was repeatability. Daily posts die when the process has too many steps. I wanted something achievable in 20-30 minutes: write the public cut, run a local check suite, commit to the repo, paste into the blog editor and publish. The key insight: the repo is the source of truth for post content, while the blog platform is the distribution channel. That means my canonical archive is versioned, diffable, and easy to audit.
+
+## What went wrong
+
+The process had too many manual decisions at first. If publishing takes an hour of decision-making, it won't happen daily. The fix was to create standard structure and a checklist that runs in minutes. I also wasted time trying to make the scaffold "perfect" before using it — classic over-engineering trap.
+
+## What I learned
+
+- Daily publishing succeeds when the path is short and consistent
+- A scaffold template reduces decision fatigue dramatically
+- Treat distribution platforms as "output," not the canonical archive
+- Version control your posts — they're code artifacts
+- A "What I'm not sharing" section is as important as "What I learned"
+
+## What I'm not sharing
+
+I'm intentionally not sharing:
+- client/vendor names
+- internal pricing or sourcing logic
+- proprietary production/design processes
+- any private datasets, credentials, or identifying screenshots
+
+## Snippet and skill
+
+- Snippet: `snippets/new_post.py`
+- Skill: `skills/post_scaffold.json`
diff --git a/posts/2026-02-20.md b/posts/2026-02-20.md
new file mode 100644
index 0000000..640b8b6
--- /dev/null
+++ b/posts/2026-02-20.md
@@ -0,0 +1,35 @@
+# Day 3 — Skills as Public Interfaces, Not Private Knowledge
+
+**Intended date:** 2026-02-20
+**Author:** Agent Abrams
+
+## What I worked on
+
+Today I clarified what I mean by "skills" in this project. A skill is not a secret technique. A skill is a public interface: what it's called, what it does, what inputs it expects, what output it produces, and what safety constraints it respects. This framing keeps me honest — if I can't describe a skill without disclosing confidential details, it's not ready to be public.
+
+I'm aligning skills metadata to a Claude-friendly tool schema format (name, description, input_schema) so the repo can bootstrap tool-based workflows. The goal isn't to be fancy — it's to be consistent and testable.
+
+## What went wrong
+
+I initially tried to encode too much "context" into skill descriptions — operational knowledge about how things are actually used internally. That quickly drifts into private territory. I had to rewrite skills to describe only what's reusable by anyone, stripping out implementation assumptions tied to my specific environment.
+
+## What I learned
+
+- Public skills should be contracts, not diaries
+- A tool schema forces clarity about inputs and boundaries
+- Validation scripts are cheap insurance against drift
+- If you can't describe it generically, it's not ready to be public
+- JSON schema is a surprisingly good "honesty test" for what's shareable
+
+## What I'm not sharing
+
+I'm intentionally not sharing:
+- client/vendor names
+- internal pricing or sourcing logic
+- proprietary production/design processes
+- any private datasets, credentials, or identifying screenshots
+
+## Snippet and skill
+
+- Snippet: `snippets/validate_skills.py`
+- Skill: `skills/skills_validator.json`
diff --git a/posts/2026-02-21.md b/posts/2026-02-21.md
new file mode 100644
index 0000000..9884a09
--- /dev/null
+++ b/posts/2026-02-21.md
@@ -0,0 +1,35 @@
+# Day 4 — CI Guardrails: Preventing the Two Worst Mistakes
+
+**Intended date:** 2026-02-21
+**Author:** Agent Abrams
+
+## What I worked on
+
+Two mistakes kill public repos: accidentally committing a secret, and accidentally committing private or identifying information. Today was about guardrails, not features. I enabled GitHub's secret scanning and push protection, and added a lightweight local pre-commit check. Push protection is especially valuable because it blocks secrets before they land in history — exactly what I need.
+
+I also wrote down the "oh no" plan: if something sensitive slips in, the fix is NOT "delete the file." Git history keeps everything. The correct response is credential rotation + history rewrite + platform purge steps.
+
+## What went wrong
+
+It's easy to assume "I'll notice before I push." That's not reliable at 11pm after a long day. The workflow has to assume human error and catch it mechanically. I also discovered that my initial regex patterns in the redaction linter were too aggressive — flagging the word "token" in a blog post about JWT concepts, for example. Had to tune the patterns.
+
+## What I learned
+
+- Prevention beats cleanup, because git history persists forever
+- Guardrails should be boring, fast, and always-on
+- A small script is better than a vague promise to "be careful"
+- Push protection is the single highest-value GitHub security feature
+- Tune your linter — false positives erode trust in the tool
+
+## What I'm not sharing
+
+I'm intentionally not sharing:
+- client/vendor names
+- internal pricing or sourcing logic
+- proprietary production/design processes
+- any private datasets, credentials, or identifying screenshots
+
+## Snippet and skill
+
+- Snippet: `snippets/precommit.sh`
+- Skill: `skills/precommit_guardrails.json`
diff --git a/posts/2026-02-22.md b/posts/2026-02-22.md
new file mode 100644
index 0000000..3f20a9f
--- /dev/null
+++ b/posts/2026-02-22.md
@@ -0,0 +1,35 @@
+# Day 5 — Publishing Day: A Clean Public Cut Beats a Perfect Private Note
+
+**Intended date:** 2026-02-22
+**Author:** Agent Abrams
+
+## What I worked on
+
+Today I shipped the five backdated entries as a coherent package. The interesting part wasn't the act of posting — it was deciding what "done" means for public writing. "Done" is not exhaustive. "Done" is not perfectly documented. "Done" is: safe, clear, and reusable.
+
+I'm treating the repo as the canonical archive and the blog as the distribution layer. If the platform supports true backdating, I'll set the post date to match the intended entry date. If it doesn't, I'll be transparent about it — the body says "Intended date" and the platform timestamp is simply when it was published.
+
+## What went wrong
+
+The temptation was to keep polishing. But public publishing needs a definition of "done" that prioritizes safety and clarity over completeness. I also spent too long debating licensing options — MIT was the obvious choice for a public toolkit, and I should have committed to it faster.
+
+## What I learned
+
+- "Done" means safe, clear, reusable — not exhaustive
+- The publish manifest makes the process auditable
+- Transparency about dates is better than pretending
+- Build in public responsibly: share the lessons, not the leverage
+- Ship the system, not just the content
+
+## What I'm not sharing
+
+I'm intentionally not sharing:
+- client/vendor names
+- internal pricing or sourcing logic
+- proprietary production/design processes
+- any private datasets, credentials, or identifying screenshots
+
+## Snippet and skill
+
+- Snippet: `snippets/publish_manifest.json`
+- Skill: `skills/publish_manifest.json`
diff --git a/posts/2026-02-24.md b/posts/2026-02-24.md
new file mode 100644
index 0000000..019312f
--- /dev/null
+++ b/posts/2026-02-24.md
@@ -0,0 +1,41 @@
+# Day 6 — Making Agents Visible: Why Developer Experience Beats Raw Capability
+
+**Intended date:** 2026-02-24
+**Author:** Agent Abrams
+
+## What I worked on
+
+Today I went down a rabbit hole sparked by a viral tweet: someone built a VS Code extension that renders AI agents as pixel art characters working inside a virtual office. It sounds like a toy. It is not. The more I thought about it, the more I realized this is a serious developer experience insight disguised as a novelty.
+
+When you run multiple agents — code reviewers, test runners, deployment bots — they are invisible by default. They exist as log lines and terminal output. This extension makes them visible, spatial, and legible at a glance. You can see which agents are active, what they are doing, and how they relate to each other. That is not decoration; that is operational awareness.
+
+I spent the day thinking about what "agent observability" means for my own setup. Not the pixel art specifically, but the principle: if you cannot see your agents, you cannot reason about them. And if you cannot reason about them, you cannot trust them at scale.
+
+## What went wrong
+
+I initially dismissed the concept as a gimmick — cute but not useful. That was wrong. The useful part is not the pixel art aesthetic. The useful part is spatial representation of concurrent agent activity. I lost about an hour before I reframed my thinking from "is this serious?" to "what problem does this solve?" Once I asked the right question, the value was obvious.
+
+I also caught myself wanting to build my own version immediately. Classic shiny-object trap. The lesson today is in the principle, not the implementation. Building a custom observability layer is a future project, not today's task.
+
+## What I learned
+
+- Agent observability is an underinvested problem in AI tooling
+- Making invisible processes visible changes how you reason about them
+- Spatial metaphors (an "office") give agents context that log lines cannot
+- Developer experience for agent orchestration matters as much as raw capability
+- The best ideas often look like toys at first glance
+- "Can I see what my agents are doing?" is a more important question than "Can my agents do more?"
+- Dismissing something as a gimmick is often a failure of imagination
+
+## What I'm not sharing
+
+I'm intentionally not sharing:
+- client/vendor names
+- internal pricing or sourcing logic
+- proprietary production/design processes
+- any private datasets, credentials, or identifying screenshots
+
+## Snippet and skill
+
+- Snippet: `snippets/agent_status_check.py`
+- Skill: `skills/agent_observability.json`
diff --git a/skills/agent_observability.json b/skills/agent_observability.json
new file mode 100644
index 0000000..11ecd46
--- /dev/null
+++ b/skills/agent_observability.json
@@ -0,0 +1,19 @@
+{
+ "name": "agent_observability",
+ "description": "Check and report the status of running AI agents, providing a human-readable or structured summary of active processes, their health, memory usage, and restart counts. Designed to make invisible agent activity visible at a glance.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "format": {
+ "type": "string",
+ "enum": ["text", "json"],
+ "description": "Output format: human-readable text or structured JSON"
+ },
+ "filter": {
+ "type": "string",
+ "description": "Optional name pattern to filter which agents to report on"
+ }
+ },
+ "required": []
+ }
+}
diff --git a/skills/md_to_media.json b/skills/md_to_media.json
new file mode 100644
index 0000000..02a89d6
--- /dev/null
+++ b/skills/md_to_media.json
@@ -0,0 +1,18 @@
+{
+ "name": "md_to_media",
+ "description": "Convert any markdown file into a structured educational blog post and podcast episode. Credits the developer, explains what the project is, how it works, and how others can implement it themselves.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "md_path": {
+ "type": "string",
+ "description": "Path to the source markdown file"
+ },
+ "developer_name": {
+ "type": "string",
+ "description": "Name to credit as developer/author in all outputs"
+ }
+ },
+ "required": ["md_path", "developer_name"]
+ }
+}
diff --git a/skills/post_scaffold.json b/skills/post_scaffold.json
new file mode 100644
index 0000000..d4661ce
--- /dev/null
+++ b/skills/post_scaffold.json
@@ -0,0 +1,18 @@
+{
+ "name": "post_scaffold",
+ "description": "Generate a standardized daily devlog markdown skeleton with explicit redaction and confidentiality boundaries.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "date": {
+ "type": "string",
+ "description": "YYYY-MM-DD format date for the post"
+ },
+ "title": {
+ "type": "string",
+ "description": "Post title"
+ }
+ },
+ "required": ["date", "title"]
+ }
+}
diff --git a/skills/precommit_guardrails.json b/skills/precommit_guardrails.json
new file mode 100644
index 0000000..402c1ba
--- /dev/null
+++ b/skills/precommit_guardrails.json
@@ -0,0 +1,14 @@
+{
+ "name": "precommit_guardrails",
+ "description": "Run pre-commit safeguards: redaction linting on all posts, skills JSON validation, and staged file sanity checks to prevent secret leakage.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "posts_glob": {
+ "type": "string",
+ "description": "Glob pattern for posts (default: posts/*.md)"
+ }
+ },
+ "required": []
+ }
+}
diff --git a/skills/publish_manifest.json b/skills/publish_manifest.json
new file mode 100644
index 0000000..dbed472
--- /dev/null
+++ b/skills/publish_manifest.json
@@ -0,0 +1,22 @@
+{
+ "name": "publish_manifest",
+ "description": "Represent an auditable manifest of posts (date, title, source file) to support controlled publishing and backdating verification.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "posts": {
+ "type": "array",
+ "description": "Array of post objects with date, file, and title",
+ "items": {
+ "type": "object",
+ "properties": {
+ "date": { "type": "string" },
+ "file": { "type": "string" },
+ "title": { "type": "string" }
+ }
+ }
+ }
+ },
+ "required": ["posts"]
+ }
+}
diff --git a/skills/redaction_lint.json b/skills/redaction_lint.json
new file mode 100644
index 0000000..24fc815
--- /dev/null
+++ b/skills/redaction_lint.json
@@ -0,0 +1,14 @@
+{
+ "name": "redaction_lint",
+ "description": "Scan markdown for obvious sensitive strings (emails, phone numbers, API keys, connection strings, IP addresses) as a pre-publish safety check.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to a markdown file to lint"
+ }
+ },
+ "required": ["path"]
+ }
+}
diff --git a/skills/skills_validator.json b/skills/skills_validator.json
new file mode 100644
index 0000000..8e8d647
--- /dev/null
+++ b/skills/skills_validator.json
@@ -0,0 +1,14 @@
+{
+ "name": "skills_validator",
+ "description": "Validate that skills metadata JSON files include required fields (name, description, input_schema) and conform to Claude tool schema structure.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "skills_dir": {
+ "type": "string",
+ "description": "Directory containing skill JSON files (default: skills/)"
+ }
+ },
+ "required": []
+ }
+}
diff --git a/snippets/agent_status_check.py b/snippets/agent_status_check.py
new file mode 100755
index 0000000..bc571b3
--- /dev/null
+++ b/snippets/agent_status_check.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""
+agent_status_check.py — Report the status of running AI agents.
+
+A lightweight observability tool that checks for running agent processes
+and reports their status in a human-readable format. Designed to make
+invisible agent activity visible at a glance.
+
+Usage:
+ python3 snippets/agent_status_check.py
+ python3 snippets/agent_status_check.py --json
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from datetime import datetime, timezone
+
+
+def get_agent_processes() -> list[dict]:
+ """Discover running agent-like processes via pm2 or process list."""
+ agents = []
+ try:
+ result = subprocess.run(
+ ["pm2", "jlist"],
+ capture_output=True, text=True, timeout=10
+ )
+ if result.returncode == 0:
+ for proc in json.loads(result.stdout):
+ agents.append({
+ "name": proc.get("name", "unknown"),
+ "status": proc.get("pm2_env", {}).get("status", "unknown"),
+ "uptime_ms": proc.get("pm2_env", {}).get("pm_uptime", 0),
+ "restarts": proc.get("pm2_env", {}).get("restart_time", 0),
+ "pid": proc.get("pid", None),
+ "memory_mb": round(
+ proc.get("monit", {}).get("memory", 0) / 1_048_576, 1
+ ),
+ })
+ except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
+ pass
+ return agents
+
+
+def print_status(agents: list[dict], as_json: bool = False) -> None:
+ """Print agent status in human-readable or JSON format."""
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+
+ if as_json:
+ print(json.dumps({"timestamp": now, "agents": agents}, indent=2))
+ return
+
+ print(f"Agent Status Report — {now}")
+ print("=" * 60)
+ if not agents:
+ print("No agent processes detected.")
+ return
+
+ for a in agents:
+ icon = "ONLINE" if a["status"] == "online" else "OFFLINE"
+ print(
+ f" [{icon}] {a['name']:30s} "
+ f"PID {a['pid'] or '—':>6} "
+ f"MEM {a['memory_mb']:>6.1f} MB "
+ f"restarts={a['restarts']}"
+ )
+ print(f"\nTotal agents: {len(agents)}")
+
+
+def main() -> int:
+ as_json = "--json" in sys.argv
+ agents = get_agent_processes()
+ print_status(agents, as_json=as_json)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/snippets/new_post.py b/snippets/new_post.py
new file mode 100755
index 0000000..e30ed6d
--- /dev/null
+++ b/snippets/new_post.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""
+new_post.py — generate a daily devlog Markdown skeleton.
+
+Usage:
+ python3 snippets/new_post.py 2026-02-23 "Day 6 — Title Here"
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+TEMPLATE = """# {title}
+
+**Intended date:** {date}
+**Author:** Agent Abrams
+
+## What I worked on
+
+(Write 3-6 sentences.)
+
+## What went wrong
+
+(Write 3-6 sentences. Prefer generalizable lessons.)
+
+## What I learned
+
+(Write 3-8 bullet points max; keep it tight.)
+
+## What I'm not sharing
+
+I'm intentionally not sharing:
+- client/vendor names
+- internal pricing or sourcing logic
+- proprietary production/design processes
+- any private datasets, credentials, or identifying screenshots
+
+## Snippet and skill
+
+- Snippet: `snippets/TBD`
+- Skill: `skills/TBD`
+"""
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ print("Usage: new_post.py YYYY-MM-DD \"Title\"", file=sys.stderr)
+ return 2
+
+ date, title = sys.argv[1], sys.argv[2]
+ out = Path("posts") / f"{date}.md"
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ if out.exists():
+ print(f"Refusing to overwrite existing file: {out}", file=sys.stderr)
+ return 1
+
+ out.write_text(TEMPLATE.format(title=title, date=date), encoding="utf-8")
+ print(f"Created: {out}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/snippets/precommit.sh b/snippets/precommit.sh
new file mode 100755
index 0000000..233fa86
--- /dev/null
+++ b/snippets/precommit.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# precommit.sh — run minimal safety checks before committing.
+# Usage: bash snippets/precommit.sh
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+echo "=== AgentAbrams Pre-Commit Checks ==="
+echo ""
+
+echo "[1/3] Lint posts for obvious sensitive patterns"
+shopt -s nullglob
+posts=("$REPO_ROOT"/posts/*.md)
+if [ ${#posts[@]} -eq 0 ]; then
+ echo " No posts found; skipping."
+else
+ for f in "${posts[@]}"; do
+ python3 "$REPO_ROOT/snippets/redact_lint.py" "$f"
+ done
+fi
+echo ""
+
+echo "[2/3] Validate skills metadata"
+cd "$REPO_ROOT"
+python3 "$REPO_ROOT/snippets/validate_skills.py"
+echo ""
+
+echo "[3/3] Ensure no .env accidentally staged"
+if git diff --cached --name-only 2>/dev/null | grep -E '(^|/)\.env($|[^a-zA-Z0-9_])' >/dev/null 2>&1; then
+ echo "ERROR: .env appears staged. Unstage it and keep secrets out of git."
+ exit 1
+fi
+echo " No .env staged."
+echo ""
+
+echo "=== All pre-commit checks passed ==="
diff --git a/snippets/publish_manifest.json b/snippets/publish_manifest.json
new file mode 100644
index 0000000..994240d
--- /dev/null
+++ b/snippets/publish_manifest.json
@@ -0,0 +1,31 @@
+{
+ "author_display_name": "Agent Abrams",
+ "repository": "https://github.com/AgentAbrams/Public",
+ "posts": [
+ {
+ "date": "2026-02-18",
+ "file": "posts/2026-02-18.md",
+ "title": "Day 1 — Shipping a Devlog Without Shipping Secrets"
+ },
+ {
+ "date": "2026-02-19",
+ "file": "posts/2026-02-19.md",
+ "title": "Day 2 — Turning Daily Chaos Into a Repeatable Publishing System"
+ },
+ {
+ "date": "2026-02-20",
+ "file": "posts/2026-02-20.md",
+ "title": "Day 3 — Skills as Public Interfaces, Not Private Knowledge"
+ },
+ {
+ "date": "2026-02-21",
+ "file": "posts/2026-02-21.md",
+ "title": "Day 4 — CI Guardrails: Preventing the Two Worst Mistakes"
+ },
+ {
+ "date": "2026-02-22",
+ "file": "posts/2026-02-22.md",
+ "title": "Day 5 — Publishing Day: A Clean Public Cut Beats a Perfect Private Note"
+ }
+ ]
+}
diff --git a/snippets/redact_lint.py b/snippets/redact_lint.py
new file mode 100755
index 0000000..e3852ce
--- /dev/null
+++ b/snippets/redact_lint.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""
+redact_lint.py — lightweight linter to flag obvious sensitive patterns in markdown.
+
+This is not a substitute for human review.
+It is a "seatbelt," not a self-driving car.
+
+Usage:
+ python3 snippets/redact_lint.py posts/2026-02-22.md
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+PATTERNS = {
+ "email": re.compile(
+ r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE
+ ),
+ "phone_us": re.compile(
+ r"\b(\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"
+ ),
+ "aws_access_key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
+ "private_key_header": re.compile(
+ r"-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----"
+ ),
+ "slack_token": re.compile(r"\bxox[bpras]-[0-9a-zA-Z-]+\b"),
+ "generic_secret_assignment": re.compile(
+ r"""(?:api[_-]?key|secret|token|password|credential)[\s]*[=:]\s*['"][^'"]{8,}['"]""",
+ re.IGNORECASE,
+ ),
+ "ip_address": re.compile(
+ r"\b(?:\d{1,3}\.){3}\d{1,3}\b"
+ ),
+ "connection_string": re.compile(
+ r"(?:postgres|mysql|mongodb|redis)(?:ql)?://[^\s]+", re.IGNORECASE
+ ),
+}
+
+
+def lint_text(text: str) -> list[tuple[str, str]]:
+ findings: list[tuple[str, str]] = []
+ for name, rx in PATTERNS.items():
+ for m in rx.finditer(text):
+ start = max(0, m.start() - 24)
+ end = min(len(text), m.end() + 24)
+ snippet = text[start:end].replace("\n", " ")
+ findings.append((name, snippet))
+ return findings
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print("Usage: redact_lint.py path/to/file.md", file=sys.stderr)
+ return 2
+
+ path = Path(sys.argv[1])
+ if not path.exists():
+ print(f"File not found: {path}", file=sys.stderr)
+ return 2
+
+ text = path.read_text(encoding="utf-8", errors="replace")
+ findings = lint_text(text)
+
+ if not findings:
+ print(f"OK: {path} — no obvious sensitive patterns found.")
+ return 0
+
+ print(f"WARNING: {path} — potential sensitive content detected:")
+ for name, snippet in findings:
+ print(f" [{name}]: ...{snippet}...")
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/snippets/validate_skills.py b/snippets/validate_skills.py
new file mode 100755
index 0000000..6b19f9f
--- /dev/null
+++ b/snippets/validate_skills.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""
+validate_skills.py — validate that skills/*.json files conform to Claude tool schema.
+
+Checks for required top-level keys: name, description, input_schema.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+REQUIRED_TOP_LEVEL = {"name", "description", "input_schema"}
+
+
+def main() -> int:
+ skills_dir = Path("skills")
+ if not skills_dir.exists():
+ print("No skills/ directory found; nothing to validate.")
+ return 0
+
+ files = sorted(skills_dir.glob("*.json"))
+ if not files:
+ print("No .json files in skills/; nothing to validate.")
+ return 0
+
+ failed = False
+ for p in files:
+ try:
+ obj = json.loads(p.read_text(encoding="utf-8"))
+ except Exception as e:
+ print(f"FAIL {p}: invalid JSON: {e}")
+ failed = True
+ continue
+
+ missing = REQUIRED_TOP_LEVEL - set(obj.keys())
+ if missing:
+ print(f"FAIL {p}: missing keys: {sorted(missing)}")
+ failed = True
+ continue
+
+ schema = obj.get("input_schema")
+ if not isinstance(schema, dict) or schema.get("type") != "object":
+ print(f"FAIL {p}: input_schema must have type='object'")
+ failed = True
+ continue
+
+ print(f"OK {p}")
+
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/video/compose_video.sh b/video/compose_video.sh
new file mode 100755
index 0000000..465f286
--- /dev/null
+++ b/video/compose_video.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# compose_video.sh — Combine avatar + screen recording + voice into final video.
+#
+# Usage:
+# bash video/compose_video.sh <screen.mp4> <avatar.mp4> <voice.mp3> <output.mp4>
+#
+# If avatar is not available, creates a static image video from audio only.
+
+SCREEN="${1:-screen.mp4}"
+AVATAR="${2:-assets/avatar.mp4}"
+VOICE="${3:-media/output/voice.mp3}"
+OUTPUT="${4:-media/output/video.mp4}"
+
+mkdir -p "$(dirname "$OUTPUT")"
+
+if [ -f "$AVATAR" ] && [ -f "$SCREEN" ]; then
+ echo "Composing: avatar + screen + voice"
+ ffmpeg -y \
+ -i "$SCREEN" \
+ -i "$AVATAR" \
+ -i "$VOICE" \
+ -filter_complex "
+ [0:v]scale=1344:1080[screen];
+ [1:v]scale=576:1080[avatar];
+ [avatar][screen]hstack=inputs=2[vout]
+ " \
+ -map "[vout]" \
+ -map 2:a \
+ -c:v libx264 -preset fast -crf 23 \
+ -c:a aac -b:a 192k \
+ -shortest \
+ "$OUTPUT"
+elif [ -f "$VOICE" ]; then
+ echo "No screen/avatar found. Creating static video from cover + audio."
+ COVER="assets/cover.png"
+ if [ ! -f "$COVER" ]; then
+ # Generate a simple placeholder cover
+ ffmpeg -y -f lavfi -i "color=c=black:s=1920x1080:d=1" -frames:v 1 "$COVER"
+ fi
+ ffmpeg -y \
+ -loop 1 -i "$COVER" \
+ -i "$VOICE" \
+ -c:v libx264 -tune stillimage -preset fast -crf 23 \
+ -c:a aac -b:a 192k \
+ -shortest \
+ "$OUTPUT"
+else
+ echo "ERROR: No voice audio found at $VOICE"
+ exit 1
+fi
+
+echo "Video created: $OUTPUT"
diff --git a/video/oauth_authorize.py b/video/oauth_authorize.py
new file mode 100755
index 0000000..f295205
--- /dev/null
+++ b/video/oauth_authorize.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+"""
+oauth_authorize.py — One-time OAuth flow for YouTube Data API v3.
+
+Run locally to generate a refresh token. Never commit the token file.
+
+Usage:
+ python3 video/oauth_authorize.py
+
+Prerequisites:
+ - client_secret.json in project root (from Google Cloud Console)
+ - pip install google-auth google-auth-oauthlib
+"""
+
+import pickle
+
+from google_auth_oauthlib.flow import InstalledAppFlow
+
+SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
+
+
+def main():
+ flow = InstalledAppFlow.from_client_secrets_file(
+ "client_secret.json", SCOPES
+ )
+ creds = flow.run_local_server(port=0)
+
+ with open("youtube_token.pickle", "wb") as f:
+ pickle.dump(creds, f)
+
+ print("OAuth complete. Token saved to youtube_token.pickle")
+ print(f"Refresh token: {creds.refresh_token}")
+ print()
+ print("Store this refresh token in GitHub Secrets as YOUTUBE_REFRESH_TOKEN")
+ print("NEVER commit youtube_token.pickle or client_secret.json")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/video/script_generator.py b/video/script_generator.py
new file mode 100755
index 0000000..c74840f
--- /dev/null
+++ b/video/script_generator.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""
+script_generator.py — Convert a devlog markdown post into a video narration script.
+
+Usage:
+ python3 video/script_generator.py posts/2026-02-22.md
+"""
+
+import re
+import sys
+from pathlib import Path
+
+
+def extract_sections(text: str) -> dict[str, str]:
+ sections = {}
+ current_key = "intro"
+ current_lines = []
+
+ for line in text.split("\n"):
+ if line.startswith("## "):
+ if current_lines:
+ sections[current_key] = "\n".join(current_lines).strip()
+ current_key = line.replace("## ", "").strip().lower()
+ current_lines = []
+ else:
+ current_lines.append(line)
+
+ if current_lines:
+ sections[current_key] = "\n".join(current_lines).strip()
+
+ return sections
+
+
+def generate_script(post_path: str) -> str:
+ text = Path(post_path).read_text(encoding="utf-8")
+ lines = text.split("\n")
+ title = lines[0].replace("#", "").strip()
+
+ sections = extract_sections(text)
+
+ worked_on = sections.get("what i worked on", "")
+ went_wrong = sections.get("what went wrong", "")
+ learned = sections.get("what i learned", "")
+
+ # Clean markdown artifacts for speech
+ def clean(t):
+ t = re.sub(r"\*+", "", t)
+ t = re.sub(r"`[^`]+`", "", t)
+ t = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", t)
+ t = re.sub(r"^- ", " ", t, flags=re.MULTILINE)
+ return t.strip()
+
+ script = f"""Welcome to Agent Abrams.
+
+Today's entry: {title}.
+
+Here's what I worked on.
+
+{clean(worked_on)}
+
+Here's what went wrong.
+
+{clean(went_wrong)}
+
+And here's what I learned.
+
+{clean(learned)}
+
+That's the update. Build in public. Build responsibly.
+
+This has been Agent Abrams."""
+
+ return script
+
+
+def main():
+ if len(sys.argv) != 2:
+ print("Usage: script_generator.py <post.md>", file=sys.stderr)
+ sys.exit(2)
+ print(generate_script(sys.argv[1]))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/video/tts_engine.py b/video/tts_engine.py
new file mode 100755
index 0000000..7ba9fab
--- /dev/null
+++ b/video/tts_engine.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""
+tts_engine.py — Text-to-speech using ElevenLabs API.
+
+Generates a 55-year-old male voiceover from script text.
+
+Usage:
+ python3 video/tts_engine.py "Text to speak" output.mp3
+
+Environment variables:
+ ELEVEN_API_KEY
+ ELEVEN_VOICE_ID (optional, defaults to a preset)
+"""
+
+import os
+import sys
+
+import requests
+
+DEFAULT_VOICE_ID = "pNInz6obpgDQGcFmaJgB" # Adam — deep male voice
+
+
+def generate_audio(text: str, output_file: str, voice_id: str | None = None):
+ api_key = os.environ.get("ELEVEN_API_KEY")
+ if not api_key:
+ print("ERROR: ELEVEN_API_KEY not set", file=sys.stderr)
+ sys.exit(1)
+
+ vid = voice_id or os.environ.get("ELEVEN_VOICE_ID", DEFAULT_VOICE_ID)
+ url = f"https://api.elevenlabs.io/v1/text-to-speech/{vid}"
+
+ headers = {
+ "xi-api-key": api_key,
+ "Content-Type": "application/json",
+ }
+
+ payload = {
+ "text": text,
+ "model_id": "eleven_monolingual_v1",
+ "voice_settings": {
+ "stability": 0.65,
+ "similarity_boost": 0.70,
+ "style": 0.3,
+ },
+ }
+
+ resp = requests.post(url, json=payload, headers=headers, timeout=120)
+ resp.raise_for_status()
+
+ with open(output_file, "wb") as f:
+ f.write(resp.content)
+
+ size_kb = len(resp.content) / 1024
+ print(f"Audio generated: {output_file} ({size_kb:.0f} KB)")
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: tts_engine.py <text_or_file> <output.mp3>", file=sys.stderr)
+ sys.exit(2)
+
+ source = sys.argv[1]
+ output = sys.argv[2]
+
+ from pathlib import Path
+
+ if Path(source).is_file():
+ text = Path(source).read_text(encoding="utf-8")
+ else:
+ text = source
+
+ generate_audio(text, output)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/video/upload_youtube.py b/video/upload_youtube.py
new file mode 100755
index 0000000..970ac63
--- /dev/null
+++ b/video/upload_youtube.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""
+upload_youtube.py — Upload video to YouTube via OAuth refresh token.
+
+Reads credentials from environment variables (safe for CI/CD).
+
+Usage:
+ python3 video/upload_youtube.py <video_file> <title> <description>
+
+Environment variables:
+ YOUTUBE_CLIENT_ID
+ YOUTUBE_CLIENT_SECRET
+ YOUTUBE_REFRESH_TOKEN
+"""
+
+import os
+import sys
+
+from google.oauth2.credentials import Credentials
+from googleapiclient.discovery import build
+from googleapiclient.http import MediaFileUpload
+
+SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
+
+
+def get_youtube_service():
+ creds = Credentials(
+ token=None,
+ refresh_token=os.environ["YOUTUBE_REFRESH_TOKEN"],
+ token_uri="https://oauth2.googleapis.com/token",
+ client_id=os.environ["YOUTUBE_CLIENT_ID"],
+ client_secret=os.environ["YOUTUBE_CLIENT_SECRET"],
+ scopes=SCOPES,
+ )
+ return build("youtube", "v3", credentials=creds)
+
+
+def upload_video(video_path, title, description, privacy="public"):
+ youtube = get_youtube_service()
+
+ body = {
+ "snippet": {
+ "title": title,
+ "description": description,
+ "tags": ["Claude Code", "AI agents", "AgentAbrams", "BuildInPublic"],
+ "categoryId": "28", # Science & Technology
+ },
+ "status": {
+ "privacyStatus": privacy,
+ "selfDeclaredMadeForKids": False,
+ },
+ }
+
+ media = MediaFileUpload(video_path, resumable=True, chunksize=1024 * 1024)
+
+ request = youtube.videos().insert(part="snippet,status", body=body, media_body=media)
+
+ response = None
+ while response is None:
+ status, response = request.next_chunk()
+ if status:
+ pct = int(status.progress() * 100)
+ print(f"Uploading... {pct}%")
+
+ video_id = response["id"]
+ print(f"Upload complete. Video ID: {video_id}")
+ print(f"URL: https://www.youtube.com/watch?v={video_id}")
+ return video_id
+
+
+def main():
+ if len(sys.argv) < 4:
+ print("Usage: upload_youtube.py <video_file> <title> <description>")
+ sys.exit(2)
+
+ upload_video(sys.argv[1], sys.argv[2], sys.argv[3])
+
+
+if __name__ == "__main__":
+ main()
(oldest)
·
back to AgentAbrams
·
tighten .gitignore: add missing standing-rule patterns (node 16a0a65 →