
Teach Course Builder
- 4 installs
- Updated January 23, 2026
- jwynia/teach
Helps with ai & agent building tasks.
About
teach-course-builder is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- teach-course-builder
- AI & Agent Building
- AI-coding skill
Teach Course Builder by the numbers
- 4 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #13,371 of 16,544 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/teach --skill teach-course-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| Last updated | January 23, 2026 |
| Repository | jwynia/teach ↗ |
What it does
Helps with ai & agent building tasks.
Files
Teach Course Builder
Transform source documents (markdown files, articles, documentation) into structured courses using the Teach authoring API. This skill analyzes source materials, proposes course structure, and creates courses via CLI scripts.
Core Principle
Source documents become structured learning paths, not just copied content.
The skill doesn't just copy files - it analyzes structure, infers units and lessons, identifies audience layers, and creates a coherent course with competencies.
Setup
Requirements
1. Deno - Install from https://deno.land 2. Teach Authoring API - Running at http://localhost:4100
Configuration
Set the API URL (optional if using default):
export TEACH_API_URL="http://localhost:4100/api"Start the authoring API:
cd /path/to/teach
pnpm dev:authoring-apiWorkflow Phases
Phase 1: Analyze
Analyze source documents to understand their structure.
deno run --allow-read --allow-env scripts/analyze-sources.ts /path/to/sourcesThis produces a course plan showing:
- Suggested course title
- Proposed units (based on directories)
- Proposed lessons (based on files)
- Detected audience layers
- Suggested competencies
Phase 2: Plan
Review the generated plan with the user. Modify as needed:
- Adjust unit titles and ordering
- Merge or split lessons
- Refine audience layer assignments
- Add or remove competencies
Phase 3: Research (Optional)
If content needs expansion, use the research skill:
- Identify gaps in the source material
- Research supplementary topics
- Find authoritative sources for claims
Phase 4: Build
Create the course structure via API:
# Create the course
deno run --allow-net --allow-env scripts/create-course.ts \
--title "Working with AI" \
--description "A practical guide"
# Add units
deno run --allow-net --allow-env scripts/add-unit.ts \
--course-id <course-id> \
--title "Foundations"
# Add lessons with content
deno run --allow-net --allow-env --allow-read scripts/add-lesson.ts \
--unit-id <unit-id> \
--title "What AI Is and Isn't" \
--content-file /path/to/what-ai-is-and-isnt.md \
--audience-layer general
# Add competencies
deno run --allow-net --allow-env scripts/add-competency.ts \
--course-id <course-id> \
--name "Understanding AI Limitations" \
--cluster-name "AI Literacy"Phase 5: Review
Verify the course in the authoring UI:
- Open http://localhost:4101/courses/<course-id>
- Check Content tab for unit/lesson hierarchy
- Check Competencies tab for learning objectives
- Edit and refine as needed
Available Scripts
analyze-sources.ts
Analyze a directory of markdown files and propose course structure.
# Human-readable output
deno run --allow-read --allow-env scripts/analyze-sources.ts /path/to/sources
# JSON output (for programmatic use)
deno run --allow-read --allow-env scripts/analyze-sources.ts /path/to/sources --jsonWhat it does:
- Recursively finds all
.mdfiles (excluding READMEs) - Extracts titles, descriptions, and headings
- Groups files by directory into units
- Infers audience layers from directory/file names
- Counts words for scope estimation
- Suggests competencies from heading keywords
create-course.ts
Create a new course.
deno run --allow-net --allow-env scripts/create-course.ts \
--title "Course Title" \
--description "Description" \
--jsonadd-unit.ts
Add a unit to a course.
deno run --allow-net --allow-env scripts/add-unit.ts \
--course-id <id> \
--title "Unit Title" \
--description "Description" \
--order 1add-lesson.ts
Add a lesson to a unit.
deno run --allow-net --allow-env --allow-read scripts/add-lesson.ts \
--unit-id <id> \
--title "Lesson Title" \
--description "Description" \
--content-file /path/to/content.md \
--audience-layer general \
--order 1Audience layers:
general- Introductory content for all learnerspractitioner- Intermediate content for regular usersspecialist- Advanced/technical content for experts
add-competency.ts
Add a competency to a course.
# Creates cluster if needed
deno run --allow-net --allow-env scripts/add-competency.ts \
--course-id <id> \
--name "Competency Name" \
--cluster-name "Cluster Name"
# Use existing cluster
deno run --allow-net --allow-env scripts/add-competency.ts \
--cluster-id <id> \
--name "Competency Name"list-courses.ts
List existing courses.
deno run --allow-net --allow-env scripts/list-courses.ts
deno run --allow-net --allow-env scripts/list-courses.ts --jsonExample Interaction
User: "Build a course from inbox/ai-education/"
Workflow:
1. Analyze the source directory:
deno run --allow-read --allow-env scripts/analyze-sources.ts inbox/ai-education/2. Review the proposed structure:
# Course Plan: AI Education
**Source:** inbox/ai-education/
**Total:** 12 lessons, ~15,000 words
## Proposed Structure
### 1. Layer 1: Foundations
- **What AI Is and Isn't** [general]
- **Basic Prompting** [general]
- **When to Use AI** [general]
- **Data and Privacy** [general]
### 2. Layer 2: Effective Use
- **Why AI Gives Mediocre Results** [practitioner]
- **Framework-First Prompting** [practitioner]
...3. Confirm structure with user, then build:
# Create course
deno run --allow-net --allow-env scripts/create-course.ts \
--title "Working with AI: A Practical Guide" \
--description "Standalone articles about working effectively with AI tools"
# Get course ID from output, then create units and lessons...4. Direct user to http://localhost:4101/courses/<id> to review
Integration Points
Research Skill
For content gaps:
- Use research skill's Tavily integration to find sources
- Expand thin lessons with researched content
- Verify claims in source documents
Competency Skill
For learning objectives:
- Use competency skill to define progression paths
- Map competencies to lessons
- Define rubrics for assessment
Gentle-Teaching Skill
For content design:
- Apply gentle-teaching principles to lesson content
- Ensure appropriate scaffolding
- Design for learner empowerment
Anti-Patterns
The Copy-Paste Course
Problem: Just copying files without structure analysis Fix: Always run analyze-sources.ts first; review and refine structure
The Flat Course
Problem: All lessons at same level without units Fix: Group related lessons into units; use directory structure as guide
The Gapless Course
Problem: Assuming source materials are complete Fix: Identify gaps; use research skill to expand; ask user about missing topics
The Competency-Free Course
Problem: Course without learning objectives Fix: Always add competencies; extract from headings or define explicitly
Output Persistence
Course data is persisted via the authoring API:
- Database:
apps/authoring-api/data/authoring.db - Accessible via: http://localhost:4101
What Gets Stored
| Data | Location | Access |
|---|---|---|
| Courses | API database | API or UI |
| Units | API database | API or UI |
| Lessons | API database | API or UI |
| Competencies | API database | API or UI |
| Source analysis | Console output | Re-run script |
Environment Configuration
# API endpoint (default: http://localhost:4100/api)
export TEACH_API_URL="http://localhost:4100/api"
# For research integration
export TAVILY_API_KEY="your-key-here"Troubleshooting
"Connection refused" errors
The authoring API isn't running:
cd /path/to/teach
pnpm dev:authoring-api"Course not found" errors
Use list-courses.ts to verify course ID:
deno run --allow-net --allow-env scripts/list-courses.tsEmpty course plan
Source directory may only contain READMEs (which are skipped):
- Check for
.mdfiles that aren't namedREADME.md - Verify the path is correct
What You Do NOT Do
- You do not bypass the authoring API (always use scripts)
- You do not create courses without user review of structure
- You do not copy content verbatim without structure analysis
- You do not skip competency definition
- You propose structure; the user decides final organization
#!/usr/bin/env -S deno run --allow-net --allow-env
/**
* Add Competency
*
* Adds a competency to an existing course via the authoring API.
* Creates a competency cluster if needed.
*
* Usage:
* deno run --allow-net --allow-env scripts/add-competency.ts --course-id <id> --name "Competency Name"
* deno run --allow-net --allow-env scripts/add-competency.ts --course-id <id> --name "Name" --cluster-name "Cluster"
* deno run --allow-net --allow-env scripts/add-competency.ts --cluster-id <id> --name "Name"
*/
import {
createCompetency,
createCompetencyCluster,
listCompetencyClusters,
parseArgs,
showHelp,
type Competency,
type CompetencyCluster,
} from "./api-client.ts";
// === OUTPUT FORMATTING ===
function formatCompetency(competency: Competency, cluster?: CompetencyCluster): string {
const lines: string[] = [];
lines.push(`Competency Created Successfully`);
lines.push(`===============================`);
lines.push(``);
lines.push(`ID: ${competency.id}`);
lines.push(`Cluster ID: ${competency.clusterId}`);
if (cluster) {
lines.push(`Cluster: ${cluster.name}`);
}
lines.push(`Name: ${competency.name}`);
lines.push(`Description: ${competency.description || "(none)"}`);
lines.push(`Order: ${competency.order}`);
lines.push(`Created: ${competency.createdAt}`);
return lines.join("\n");
}
// === MAIN ===
async function main(): Promise<void> {
const args = parseArgs(Deno.args);
if (args.has("help") || args.has("h") || Deno.args.includes("-h")) {
showHelp(
"Add Competency",
"Usage: deno run --allow-net --allow-env scripts/add-competency.ts [options]",
` --course-id <id> Course ID (required if no --cluster-id)
--cluster-id <id> Cluster ID (use existing cluster)
--cluster-name <name> Cluster name (creates new or finds existing)
--name <name> Competency name (required)
--description <desc> Competency description`
);
Deno.exit(0);
}
const name = args.get("name") as string;
if (!name) {
console.error("Error: --name is required");
Deno.exit(1);
}
const description = args.get("description") as string | undefined;
let clusterId = args.get("cluster-id") as string | undefined;
const courseId = args.get("course-id") as string | undefined;
const clusterName = args.get("cluster-name") as string | undefined;
// If no cluster-id, we need course-id and optionally cluster-name
if (!clusterId) {
if (!courseId) {
console.error("Error: Either --cluster-id or --course-id is required");
Deno.exit(1);
}
const targetClusterName = clusterName || "General";
try {
// Check if cluster exists
const clusters = await listCompetencyClusters(courseId);
const existing = clusters.find(
(c) => c.name.toLowerCase() === targetClusterName.toLowerCase()
);
if (existing) {
clusterId = existing.id;
console.error(`Using existing cluster: ${existing.name}`);
} else {
// Create new cluster
const newCluster = await createCompetencyCluster(courseId, {
name: targetClusterName,
description: `Competency cluster: ${targetClusterName}`,
});
clusterId = newCluster.id;
console.error(`Created new cluster: ${newCluster.name}`);
}
} catch (error) {
console.error(`Error with competency cluster: ${error}`);
Deno.exit(1);
}
}
try {
const competency = await createCompetency(clusterId, {
name,
description,
});
if (args.has("json")) {
console.log(JSON.stringify(competency, null, 2));
} else {
console.log(formatCompetency(competency));
}
} catch (error) {
console.error(`Error creating competency: ${error}`);
Deno.exit(1);
}
}
main();
#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read
/**
* Add Lesson
*
* Adds a lesson to an existing unit via the authoring API.
* Can optionally read content from a markdown file.
*
* Usage:
* deno run --allow-net --allow-env --allow-read scripts/add-lesson.ts --unit-id <id> --title "Lesson Title"
* deno run --allow-net --allow-env --allow-read scripts/add-lesson.ts --unit-id <id> --title "Title" --content-file /path/to/content.md
* deno run --allow-net --allow-env --allow-read scripts/add-lesson.ts --unit-id <id> --title "Title" --audience-layer general
*/
import { createLesson, parseArgs, showHelp, type Lesson } from "./api-client.ts";
// === OUTPUT FORMATTING ===
function formatLesson(lesson: Lesson): string {
const lines: string[] = [];
lines.push(`Lesson Created Successfully`);
lines.push(`===========================`);
lines.push(``);
lines.push(`ID: ${lesson.id}`);
lines.push(`Unit ID: ${lesson.unitId}`);
lines.push(`Title: ${lesson.title}`);
lines.push(`Description: ${lesson.description || "(none)"}`);
lines.push(`Order: ${lesson.order}`);
lines.push(`Audience Layer: ${lesson.audienceLayer || "(none)"}`);
lines.push(`Content Type: ${lesson.content.type}`);
lines.push(`Content Length: ${lesson.content.body.length} characters`);
lines.push(`Created: ${lesson.createdAt}`);
return lines.join("\n");
}
// === MAIN ===
async function main(): Promise<void> {
const args = parseArgs(Deno.args);
if (args.has("help") || args.has("h") || Deno.args.includes("-h")) {
showHelp(
"Add Lesson",
"Usage: deno run --allow-net --allow-env --allow-read scripts/add-lesson.ts --unit-id <id> --title <title> [options]",
` --unit-id <id> Unit ID (required)
--title <title> Lesson title (required)
--description <desc> Lesson description
--content-file <path> Path to markdown file for content
--audience-layer <layer> Audience layer: general, practitioner, specialist
--order <number> Order within unit`
);
Deno.exit(0);
}
const unitId = args.get("unit-id") as string;
if (!unitId) {
console.error("Error: --unit-id is required");
Deno.exit(1);
}
const title = args.get("title") as string;
if (!title) {
console.error("Error: --title is required");
Deno.exit(1);
}
const description = args.get("description") as string | undefined;
const orderStr = args.get("order") as string | undefined;
const order = orderStr ? parseInt(orderStr, 10) : undefined;
const contentFile = args.get("content-file") as string | undefined;
const audienceLayerRaw = args.get("audience-layer") as string | undefined;
// Validate audience layer
let audienceLayer: "general" | "practitioner" | "specialist" | undefined;
if (audienceLayerRaw) {
if (!["general", "practitioner", "specialist"].includes(audienceLayerRaw)) {
console.error("Error: --audience-layer must be one of: general, practitioner, specialist");
Deno.exit(1);
}
audienceLayer = audienceLayerRaw as "general" | "practitioner" | "specialist";
}
// Read content from file if specified
let content: { type: "markdown" | "html"; body: string } | undefined;
if (contentFile) {
try {
const body = await Deno.readTextFile(contentFile);
content = { type: "markdown", body };
console.error(`Read ${body.length} characters from ${contentFile}`);
} catch (error) {
console.error(`Error reading content file: ${error}`);
Deno.exit(1);
}
}
try {
const lesson = await createLesson(unitId, {
title,
description,
order,
content,
audienceLayer,
});
if (args.has("json")) {
console.log(JSON.stringify(lesson, null, 2));
} else {
console.log(formatLesson(lesson));
}
} catch (error) {
console.error(`Error creating lesson: ${error}`);
Deno.exit(1);
}
}
main();
#!/usr/bin/env -S deno run --allow-net --allow-env
/**
* Add Unit
*
* Adds a unit to an existing course via the authoring API.
*
* Usage:
* deno run --allow-net --allow-env scripts/add-unit.ts --course-id <id> --title "Unit Title"
* deno run --allow-net --allow-env scripts/add-unit.ts --course-id <id> --title "Title" --description "Description"
* deno run --allow-net --allow-env scripts/add-unit.ts --course-id <id> --title "Title" --order 2
*/
import { createUnit, parseArgs, showHelp, type Unit } from "./api-client.ts";
// === OUTPUT FORMATTING ===
function formatUnit(unit: Unit): string {
const lines: string[] = [];
lines.push(`Unit Created Successfully`);
lines.push(`========================`);
lines.push(``);
lines.push(`ID: ${unit.id}`);
lines.push(`Course ID: ${unit.courseId}`);
lines.push(`Title: ${unit.title}`);
lines.push(`Description: ${unit.description || "(none)"}`);
lines.push(`Order: ${unit.order}`);
lines.push(`Created: ${unit.createdAt}`);
lines.push(``);
lines.push(`Next steps:`);
lines.push(` - Add lessons: deno run --allow-net --allow-env scripts/add-lesson.ts --unit-id ${unit.id} --title "Lesson Title"`);
return lines.join("\n");
}
// === MAIN ===
async function main(): Promise<void> {
const args = parseArgs(Deno.args);
if (args.has("help") || args.has("h") || Deno.args.includes("-h")) {
showHelp(
"Add Unit",
"Usage: deno run --allow-net --allow-env scripts/add-unit.ts --course-id <id> --title <title> [options]",
` --course-id <id> Course ID (required)
--title <title> Unit title (required)
--description <desc> Unit description
--order <number> Order within course`
);
Deno.exit(0);
}
const courseId = args.get("course-id") as string;
if (!courseId) {
console.error("Error: --course-id is required");
Deno.exit(1);
}
const title = args.get("title") as string;
if (!title) {
console.error("Error: --title is required");
Deno.exit(1);
}
const description = args.get("description") as string | undefined;
const orderStr = args.get("order") as string | undefined;
const order = orderStr ? parseInt(orderStr, 10) : undefined;
try {
const unit = await createUnit(courseId, {
title,
description,
order,
});
if (args.has("json")) {
console.log(JSON.stringify(unit, null, 2));
} else {
console.log(formatUnit(unit));
}
} catch (error) {
console.error(`Error creating unit: ${error}`);
Deno.exit(1);
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-env
/**
* Analyze Source Documents
*
* Reads markdown files from a directory and proposes a course structure.
* Identifies units based on subdirectories, lessons based on files.
*
* Usage:
* deno run --allow-read --allow-env scripts/analyze-sources.ts /path/to/sources
* deno run --allow-read --allow-env scripts/analyze-sources.ts /path/to/sources --json
* deno run --allow-read --allow-env scripts/analyze-sources.ts /path/to/sources --plan
*/
import { parseArgs, showHelp } from "./api-client.ts";
// === TYPES ===
interface SourceFile {
path: string;
filename: string;
title: string;
description: string;
headings: string[];
wordCount: number;
directory: string;
}
interface ProposedLesson {
sourceFile: string;
title: string;
description: string;
order: number;
audienceLayer?: "general" | "practitioner" | "specialist";
}
interface ProposedUnit {
title: string;
description: string;
sourceDirectory: string;
lessons: ProposedLesson[];
order: number;
}
interface CoursePlan {
suggestedTitle: string;
description: string;
sourceDirectory: string;
units: ProposedUnit[];
suggestedCompetencies: string[];
totalLessons: number;
totalWordCount: number;
}
// === FILE ANALYSIS ===
async function readMarkdownFile(path: string): Promise<string> {
try {
return await Deno.readTextFile(path);
} catch (error) {
console.error(`Error reading ${path}: ${error}`);
return "";
}
}
function extractTitle(content: string, filename: string): string {
// Try to find H1 heading
const h1Match = content.match(/^#\s+(.+)$/m);
if (h1Match) {
return h1Match[1].trim();
}
// Fall back to filename
return filename
.replace(/\.md$/, "")
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function extractDescription(content: string): string {
// Find first paragraph after title
const lines = content.split("\n");
let foundHeading = false;
let paragraphLines: string[] = [];
for (const line of lines) {
if (line.startsWith("#")) {
if (foundHeading && paragraphLines.length > 0) break;
foundHeading = true;
continue;
}
if (foundHeading && line.trim()) {
if (line.startsWith("-") || line.startsWith("|") || line.startsWith("```")) {
break;
}
paragraphLines.push(line.trim());
} else if (foundHeading && paragraphLines.length > 0) {
break;
}
}
const description = paragraphLines.join(" ");
return description.length > 200 ? description.slice(0, 197) + "..." : description;
}
function extractHeadings(content: string): string[] {
const headings: string[] = [];
const lines = content.split("\n");
for (const line of lines) {
const match = line.match(/^(#{1,3})\s+(.+)$/);
if (match) {
headings.push(match[2].trim());
}
}
return headings;
}
function countWords(content: string): number {
// Remove markdown syntax and count words
const text = content
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]+`/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#*_~|]/g, "");
return text.split(/\s+/).filter((w) => w.length > 0).length;
}
async function analyzeFile(
path: string,
rootDir: string
): Promise<SourceFile | null> {
const content = await readMarkdownFile(path);
if (!content) return null;
const filename = path.split("/").pop() || "";
if (filename.toLowerCase() === "readme.md") {
return null; // Skip READMEs, they're usually navigation
}
// Get relative directory
const relativePath = path.replace(rootDir, "").replace(/^\//, "");
const parts = relativePath.split("/");
const directory = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
return {
path,
filename,
title: extractTitle(content, filename),
description: extractDescription(content),
headings: extractHeadings(content),
wordCount: countWords(content),
directory,
};
}
// === DIRECTORY SCANNING ===
async function findMarkdownFiles(dir: string): Promise<string[]> {
const files: string[] = [];
async function scan(currentDir: string): Promise<void> {
try {
for await (const entry of Deno.readDir(currentDir)) {
const path = `${currentDir}/${entry.name}`;
if (entry.isDirectory) {
await scan(path);
} else if (entry.name.endsWith(".md")) {
files.push(path);
}
}
} catch (error) {
console.error(`Error scanning ${currentDir}: ${error}`);
}
}
await scan(dir);
return files.sort();
}
// === COURSE STRUCTURE INFERENCE ===
function inferAudienceLayer(
directory: string,
title: string
): "general" | "practitioner" | "specialist" | undefined {
const lowerDir = directory.toLowerCase();
const lowerTitle = title.toLowerCase();
if (
lowerDir.includes("foundation") ||
lowerDir.includes("layer-1") ||
lowerDir.includes("basic") ||
lowerTitle.includes("introduction") ||
lowerTitle.includes("getting started")
) {
return "general";
}
if (
lowerDir.includes("effective") ||
lowerDir.includes("layer-2") ||
lowerDir.includes("intermediate") ||
lowerTitle.includes("advanced") ||
lowerTitle.includes("techniques")
) {
return "practitioner";
}
if (
lowerDir.includes("technical") ||
lowerDir.includes("layer-3") ||
lowerDir.includes("expert") ||
lowerTitle.includes("architecture") ||
lowerTitle.includes("implementation")
) {
return "specialist";
}
return undefined;
}
function formatUnitTitle(directory: string): string {
if (!directory) return "General";
// Handle layer-style directories
const layerMatch = directory.match(/layer-(\d+)-(\w+)/i);
if (layerMatch) {
const [, num, name] = layerMatch;
return `Layer ${num}: ${name.charAt(0).toUpperCase() + name.slice(1)}`;
}
// Otherwise, format the directory name
return directory
.split("/")
.pop()!
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function buildCoursePlan(
files: SourceFile[],
sourceDir: string
): CoursePlan {
// Group files by directory
const byDirectory = new Map<string, SourceFile[]>();
for (const file of files) {
const dir = file.directory || "";
if (!byDirectory.has(dir)) {
byDirectory.set(dir, []);
}
byDirectory.get(dir)!.push(file);
}
// Build units
const units: ProposedUnit[] = [];
let unitOrder = 1;
// Sort directories to maintain consistent ordering
const sortedDirs = [...byDirectory.keys()].sort();
for (const dir of sortedDirs) {
const dirFiles = byDirectory.get(dir)!;
// Sort files within directory
dirFiles.sort((a, b) => a.filename.localeCompare(b.filename));
const lessons: ProposedLesson[] = dirFiles.map((file, index) => ({
sourceFile: file.path,
title: file.title,
description: file.description,
order: index + 1,
audienceLayer: inferAudienceLayer(dir, file.title),
}));
units.push({
title: formatUnitTitle(dir),
description: `Content from ${dir || "root directory"}`,
sourceDirectory: dir,
lessons,
order: unitOrder++,
});
}
// Try to infer course title from README or directory name
let suggestedTitle = "New Course";
const dirName = sourceDir.split("/").pop();
if (dirName) {
suggestedTitle = dirName
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
// Extract potential competencies from all headings
const allHeadings = files.flatMap((f) => f.headings);
const competencyKeywords = [
"understand",
"learn",
"know",
"skill",
"ability",
"can",
"will be able",
];
const suggestedCompetencies = allHeadings
.filter((h) =>
competencyKeywords.some((k) => h.toLowerCase().includes(k))
)
.slice(0, 10);
const totalWordCount = files.reduce((sum, f) => sum + f.wordCount, 0);
return {
suggestedTitle,
description: `Course generated from ${files.length} source documents`,
sourceDirectory: sourceDir,
units,
suggestedCompetencies,
totalLessons: files.length,
totalWordCount,
};
}
// === OUTPUT FORMATTING ===
function formatPlan(plan: CoursePlan): string {
const lines: string[] = [];
lines.push(`# Course Plan: ${plan.suggestedTitle}`);
lines.push("");
lines.push(`**Source:** ${plan.sourceDirectory}`);
lines.push(
`**Total:** ${plan.totalLessons} lessons, ~${plan.totalWordCount.toLocaleString()} words`
);
lines.push("");
lines.push("## Proposed Structure");
lines.push("");
for (const unit of plan.units) {
lines.push(`### ${unit.order}. ${unit.title}`);
lines.push("");
for (const lesson of unit.lessons) {
const layer = lesson.audienceLayer
? ` [${lesson.audienceLayer}]`
: "";
lines.push(`- **${lesson.title}**${layer}`);
if (lesson.description) {
lines.push(` ${lesson.description}`);
}
}
lines.push("");
}
if (plan.suggestedCompetencies.length > 0) {
lines.push("## Suggested Competencies");
lines.push("");
for (const comp of plan.suggestedCompetencies) {
lines.push(`- ${comp}`);
}
lines.push("");
}
lines.push("---");
lines.push(
"*Generated by teach-course-builder. Review and modify as needed.*"
);
return lines.join("\n");
}
// === MAIN ===
async function main(): Promise<void> {
const args = parseArgs(Deno.args);
if (args.has("help") || args.has("h") || Deno.args.includes("-h")) {
showHelp(
"Analyze Source Documents",
"Usage: deno run --allow-read --allow-env scripts/analyze-sources.ts <directory>",
` <directory> Source directory containing markdown files`
);
Deno.exit(0);
}
const sourceDir = args.get("_positional") as string;
if (!sourceDir) {
console.error("Error: Please specify a source directory");
console.error(
"Usage: deno run --allow-read --allow-env scripts/analyze-sources.ts <directory>"
);
Deno.exit(1);
}
// Check if directory exists
try {
const stat = await Deno.stat(sourceDir);
if (!stat.isDirectory) {
console.error(`Error: ${sourceDir} is not a directory`);
Deno.exit(1);
}
} catch {
console.error(`Error: Directory ${sourceDir} does not exist`);
Deno.exit(1);
}
// Find and analyze files
const files = await findMarkdownFiles(sourceDir);
if (files.length === 0) {
console.error(`No markdown files found in ${sourceDir}`);
Deno.exit(1);
}
console.error(`Found ${files.length} markdown files`);
// Analyze each file
const analyzed: SourceFile[] = [];
for (const file of files) {
const result = await analyzeFile(file, sourceDir);
if (result) {
analyzed.push(result);
}
}
console.error(`Analyzed ${analyzed.length} content files`);
// Build course plan
const plan = buildCoursePlan(analyzed, sourceDir);
// Output
if (args.has("json")) {
console.log(JSON.stringify(plan, null, 2));
} else {
console.log(formatPlan(plan));
}
}
main();
/**
* API Client for Teach Authoring API
*
* Reusable HTTP client module for Deno scripts.
*
* Environment:
* TEACH_API_URL - Base URL for the API (default: http://localhost:4100/api)
*/
// === CONFIGURATION ===
export const API_BASE =
Deno.env.get("TEACH_API_URL") || "http://localhost:4100/api";
// === TYPES ===
export interface Course {
id: string;
title: string;
description: string;
version: string;
status: "draft" | "published" | "archived";
createdAt: string;
updatedAt: string;
}
export interface Unit {
id: string;
courseId: string;
title: string;
description: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface Lesson {
id: string;
unitId: string;
title: string;
description: string;
order: number;
content: {
type: "markdown" | "html";
body: string;
};
audienceLayer: "general" | "practitioner" | "specialist" | null;
createdAt: string;
updatedAt: string;
}
export interface CompetencyCluster {
id: string;
courseId: string;
name: string;
description: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface Competency {
id: string;
clusterId: string;
name: string;
description: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface ApiError {
error: string;
message?: string;
}
// === HTTP METHODS ===
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorBody = await response.text();
let errorMessage: string;
try {
const errorJson = JSON.parse(errorBody) as ApiError;
errorMessage = errorJson.message || errorJson.error || errorBody;
} catch {
errorMessage = errorBody;
}
throw new Error(`API Error ${response.status}: ${errorMessage}`);
}
return response.json() as Promise<T>;
}
export async function apiGet<T>(path: string): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
return handleResponse<T>(response);
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return handleResponse<T>(response);
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return handleResponse<T>(response);
}
export async function apiDelete(path: string): Promise<void> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`API Error ${response.status}: ${errorBody}`);
}
}
// === COURSE API ===
export async function listCourses(): Promise<Course[]> {
return apiGet<Course[]>("/courses");
}
export async function getCourse(id: string): Promise<Course> {
return apiGet<Course>(`/courses/${id}`);
}
export async function createCourse(data: {
title: string;
description?: string;
version?: string;
}): Promise<Course> {
return apiPost<Course>("/courses", {
title: data.title,
description: data.description || "",
version: data.version || "1.0.0",
});
}
export async function updateCourse(
id: string,
data: Partial<{
title: string;
description: string;
version: string;
status: "draft" | "published" | "archived";
}>
): Promise<Course> {
return apiPut<Course>(`/courses/${id}`, data);
}
export async function deleteCourse(id: string): Promise<void> {
return apiDelete(`/courses/${id}`);
}
// === UNIT API ===
export async function listUnits(courseId: string): Promise<Unit[]> {
return apiGet<Unit[]>(`/courses/${courseId}/units`);
}
export async function getUnit(id: string): Promise<Unit> {
return apiGet<Unit>(`/units/${id}`);
}
export async function createUnit(
courseId: string,
data: {
title: string;
description?: string;
order?: number;
}
): Promise<Unit> {
return apiPost<Unit>(`/courses/${courseId}/units`, {
title: data.title,
description: data.description || "",
order: data.order,
});
}
export async function updateUnit(
id: string,
data: Partial<{
title: string;
description: string;
order: number;
}>
): Promise<Unit> {
return apiPut<Unit>(`/units/${id}`, data);
}
export async function deleteUnit(id: string): Promise<void> {
return apiDelete(`/units/${id}`);
}
// === LESSON API ===
export async function listLessons(unitId: string): Promise<Lesson[]> {
return apiGet<Lesson[]>(`/units/${unitId}/lessons`);
}
export async function getLesson(id: string): Promise<Lesson> {
return apiGet<Lesson>(`/lessons/${id}`);
}
export async function createLesson(
unitId: string,
data: {
title: string;
description?: string;
order?: number;
content?: { type: "markdown" | "html"; body: string };
audienceLayer?: "general" | "practitioner" | "specialist" | null;
}
): Promise<Lesson> {
return apiPost<Lesson>(`/units/${unitId}/lessons`, {
title: data.title,
description: data.description || "",
order: data.order,
contentType: data.content?.type || "markdown",
contentBody: data.content?.body || "",
audienceLayer: data.audienceLayer || null,
});
}
export async function updateLesson(
id: string,
data: Partial<{
title: string;
description: string;
order: number;
content: { type: "markdown" | "html"; body: string };
audienceLayer: "general" | "practitioner" | "specialist" | null;
}>
): Promise<Lesson> {
// Transform content object to flat fields for API
const apiData: Record<string, unknown> = { ...data };
if (data.content) {
apiData.contentType = data.content.type;
apiData.contentBody = data.content.body;
delete apiData.content;
}
return apiPut<Lesson>(`/lessons/${id}`, apiData);
}
export async function deleteLesson(id: string): Promise<void> {
return apiDelete(`/lessons/${id}`);
}
// === COMPETENCY CLUSTER API ===
export async function listCompetencyClusters(
courseId: string
): Promise<CompetencyCluster[]> {
return apiGet<CompetencyCluster[]>(`/courses/${courseId}/competency-clusters`);
}
export async function getCompetencyCluster(
id: string
): Promise<CompetencyCluster> {
return apiGet<CompetencyCluster>(`/competency-clusters/${id}`);
}
export async function createCompetencyCluster(
courseId: string,
data: {
name: string;
description?: string;
order?: number;
}
): Promise<CompetencyCluster> {
return apiPost<CompetencyCluster>(`/courses/${courseId}/competency-clusters`, {
name: data.name,
description: data.description || "",
order: data.order,
});
}
// === COMPETENCY API ===
export async function listCompetencies(
clusterId: string
): Promise<Competency[]> {
return apiGet<Competency[]>(`/competency-clusters/${clusterId}/competencies`);
}
export async function getCompetency(id: string): Promise<Competency> {
return apiGet<Competency>(`/competencies/${id}`);
}
export async function createCompetency(
clusterId: string,
data: {
name: string;
description?: string;
order?: number;
}
): Promise<Competency> {
return apiPost<Competency>(`/competency-clusters/${clusterId}/competencies`, {
name: data.name,
description: data.description || "",
order: data.order,
});
}
// === UTILITIES ===
export function parseArgs(args: string[]): Map<string, string | boolean> {
const result = new Map<string, string | boolean>();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const nextArg = args[i + 1];
if (nextArg && !nextArg.startsWith("--")) {
result.set(key, nextArg);
i++;
} else {
result.set(key, true);
}
} else if (!args[i - 1]?.startsWith("--")) {
// Positional argument
if (!result.has("_positional")) {
result.set("_positional", arg);
}
}
}
return result;
}
export function showHelp(name: string, usage: string, options: string): void {
console.log(`${name}
${usage}
Options:
${options}
--json Output as JSON
--help, -h Show this help message
`);
}
#!/usr/bin/env -S deno run --allow-net --allow-env
/**
* Create Course
*
* Creates a new course via the authoring API.
*
* Usage:
* deno run --allow-net --allow-env scripts/create-course.ts --title "Course Title"
* deno run --allow-net --allow-env scripts/create-course.ts --title "Title" --description "Description"
* deno run --allow-net --allow-env scripts/create-course.ts --title "Title" --json
*/
import { createCourse, parseArgs, showHelp, type Course } from "./api-client.ts";
// === OUTPUT FORMATTING ===
function formatCourse(course: Course): string {
const lines: string[] = [];
lines.push(`Course Created Successfully`);
lines.push(`==========================`);
lines.push(``);
lines.push(`ID: ${course.id}`);
lines.push(`Title: ${course.title}`);
lines.push(`Description: ${course.description || "(none)"}`);
lines.push(`Version: ${course.version}`);
lines.push(`Status: ${course.status}`);
lines.push(`Created: ${course.createdAt}`);
lines.push(``);
lines.push(`Next steps:`);
lines.push(` - Add units: deno run --allow-net --allow-env scripts/add-unit.ts --course-id ${course.id} --title "Unit Title"`);
lines.push(` - View in UI: http://localhost:4101/courses/${course.id}`);
return lines.join("\n");
}
// === MAIN ===
async function main(): Promise<void> {
const args = parseArgs(Deno.args);
if (args.has("help") || args.has("h") || Deno.args.includes("-h")) {
showHelp(
"Create Course",
"Usage: deno run --allow-net --allow-env scripts/create-course.ts --title <title> [options]",
` --title <title> Course title (required)
--description <desc> Course description
--version <version> Version string (default: 1.0.0)`
);
Deno.exit(0);
}
const title = args.get("title") as string;
if (!title) {
console.error("Error: --title is required");
console.error("Usage: deno run --allow-net --allow-env scripts/create-course.ts --title <title>");
Deno.exit(1);
}
const description = args.get("description") as string | undefined;
const version = args.get("version") as string | undefined;
try {
const course = await createCourse({
title,
description,
version,
});
if (args.has("json")) {
console.log(JSON.stringify(course, null, 2));
} else {
console.log(formatCourse(course));
}
} catch (error) {
console.error(`Error creating course: ${error}`);
Deno.exit(1);
}
}
main();
#!/usr/bin/env -S deno run --allow-net --allow-env
/**
* List Courses
*
* Lists all courses in the authoring API.
*
* Usage:
* deno run --allow-net --allow-env scripts/list-courses.ts
* deno run --allow-net --allow-env scripts/list-courses.ts --json
*/
import { listCourses, parseArgs, showHelp, type Course } from "./api-client.ts";
// === OUTPUT FORMATTING ===
function formatCourses(courses: Course[]): string {
if (courses.length === 0) {
return "No courses found.\n\nCreate one with:\n deno run --allow-net --allow-env scripts/create-course.ts --title \"Course Title\"";
}
const lines: string[] = [];
lines.push(`Found ${courses.length} course(s):`);
lines.push(``);
for (const course of courses) {
lines.push(`${course.title}`);
lines.push(` ID: ${course.id}`);
lines.push(` Status: ${course.status}`);
if (course.description) {
const desc = course.description.length > 60
? course.description.slice(0, 57) + "..."
: course.description;
lines.push(` Desc: ${desc}`);
}
lines.push(``);
}
return lines.join("\n");
}
// === MAIN ===
async function main(): Promise<void> {
const args = parseArgs(Deno.args);
if (args.has("help") || args.has("h") || Deno.args.includes("-h")) {
showHelp(
"List Courses",
"Usage: deno run --allow-net --allow-env scripts/list-courses.ts [options]",
``
);
Deno.exit(0);
}
try {
const courses = await listCourses();
if (args.has("json")) {
console.log(JSON.stringify(courses, null, 2));
} else {
console.log(formatCourses(courses));
}
} catch (error) {
console.error(`Error listing courses: ${error}`);
Deno.exit(1);
}
}
main();
Course Plan: {{title}}
Source: {{sourceDirectory}} Total: {{totalLessons}} lessons, ~{{totalWordCount}} words Generated: {{date}}
Overview
{{description}}
Proposed Structure
{{#each units}}
{{order}}. {{title}}
{{description}}
{{#each lessons}}
- {{title}}{{#if audienceLayer}} [{{audienceLayer}}]{{/if}}
{{#if description}}{{description}}{{/if}}
- Source:
{{sourceFile}}
{{/each}}
{{/each}}
Suggested Competencies
{{#each suggestedCompetencies}}
- {{this}}
{{/each}}
Build Commands
# 1. Create the course
deno run --allow-net --allow-env scripts/create-course.ts \
--title "{{title}}" \
--description "{{description}}"
# 2. Note the course ID from the output, then create units:
{{#each units}}
deno run --allow-net --allow-env scripts/add-unit.ts \
--course-id <COURSE_ID> \
--title "{{title}}" \
--order {{order}}
# 3. Add lessons to unit {{order}} (note unit ID from output):
{{#each lessons}}
deno run --allow-net --allow-env --allow-read scripts/add-lesson.ts \
--unit-id <UNIT_{{../order}}_ID> \
--title "{{title}}" \
--content-file "{{sourceFile}}"{{#if audienceLayer}} \
--audience-layer {{audienceLayer}}{{/if}} \
--order {{order}}
{{/each}}
{{/each}}Review Checklist
- [ ] Course title and description are accurate
- [ ] Unit groupings make pedagogical sense
- [ ] Lesson ordering follows learning progression
- [ ] Audience layers are correctly assigned
- [ ] All source files are accounted for
- [ ] Competencies cover key learning objectives
- [ ] No content gaps require research
Next Steps
1. Review and approve this plan 2. Run the build commands above 3. Open http://localhost:4101/courses/<COURSE_ID> 4. Verify structure in the Content tab 5. Add competencies in the Competencies tab 6. Refine lesson content as needed
---
Generated by teach-course-builder skill. Review and modify as needed.