@codio-ai/opencode-authoring-agent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -72,10 +72,11 @@ var AGENT_DESCRIPTIONS = {
72
72
  - Role: Writes Codio guide page markdown. Two modes: draft (returns body+metadata for create_page) and revise (surgical edits to existing .md).
73
73
  - Permissions: Read + edit (no write, no bash)
74
74
  - Stats: Sonnet-class prose quality.
75
- - Capabilities: Instructional writing, surgical revisions.
75
+ - Capabilities: Instructional writing, surgical revisions, Codio-compliant freeze/try-it/adaptive-instruction generation.
76
76
  - **Delegate when:** Drafting a new page, or applying a focused edit to an existing one.
77
77
  - **Don't delegate when:** The edit is a single-line typo fix — do it yourself.
78
- - **Rule of thumb:** New prose or non-trivial edit → @page-author. Trivial fix → yourself.`,
78
+ - **Rule of thumb:** New prose or non-trivial edit → @page-author. Trivial fix → yourself.
79
+ - **layoutConfig:** When delegating, include \`layoutConfig: { openFiles: string[] }\` derived from the page's \`files\` list so @page-author can generate adaptive file instructions.`,
79
80
  "assessment-author": `@assessment-author
80
81
  - Role: Authors MCQ / code-output-compare / fill-in-the-blanks / parsons-puzzle / llm-based-auto-rubric payloads. Returns structured payloads only.
81
82
  - Permissions: Read only
@@ -130,7 +131,7 @@ ${enabledAgents}
130
131
 
131
132
  <Tools>
132
133
 
133
- - \`create_page({workspace, stem, title, type, layout, learningObjectives, files?, markdownBody, chapterFolder?})\` — stamp a page JSON with a fresh v4 UUID and write the JSON+MD pair. Use after \`@page-author\` returns a body+metadata in draft mode.
134
+ - \`create_page({workspace, stem, title, type, layout, learningObjectives, files?, markdownBody, chapterFolder?})\` — stamp a page JSON with a fresh v4 UUID and write the JSON+MD pair. Use after \`@page-author\` returns a body+metadata in draft mode. Validates freeze directive syntax before writing — will throw if FREEZE CODE BEGIN/END are unmatched or use wrong comment prefix for the language.
134
135
  - \`create_assessment({type, workspace, payload})\` — stamp an assessment JSON with a fresh taskId and write to \`.guides/assessments/\`. Returns \`{ taskId, jsonPath, embedLine }\`. Use after \`@assessment-author\` returns a payload. The returned \`embedLine\` is what you splice into the page markdown.
135
136
  - \`validate_guide({workspace})\` — deterministic structural validation (UUIDs, order arrays, embed references, taskId/filename consistency). Run automatically at the end of new-assignment, import-source, and reorder workflows. Run manually on user request (\`/validate-guide\`).
136
137
  </Tools>
@@ -142,7 +143,7 @@ Phases (which apply depends on the user's intent; the workflow-* skills give per
142
143
  1. **Intake** — confirm topic, audience, LOs, structural constraints, source materials. Do not skip; confirm even if some info was in the user's first message.
143
144
  2. **Outline Gate** — for new assignments or substantial restructures, \`@outline-architect\` returns a proposed outline. **Block until the user approves.** No file writes until then.
144
145
  3. **Scaffold** — once approved, generate stems (kebab-slug + 4-hex). Build the planning map. Write the root \`.guides/content/index.json\` (and the single chapter folder's index if applicable).
145
- 4. **Drafting** — fan out \`@page-author\` (draft mode) for each page. Use \`create_page\` to stamp each. Run in parallel where pages are independent.
146
+ 4. **Drafting** — fan out \`@page-author\` (draft mode) for each page. Pass \`layoutConfig: { openFiles: files }\` derived from that page's \`files\` list. Use \`create_page\` to stamp each. Run in parallel where pages are independent.
146
147
  5. **Assessments** — for each page needing one, fan out \`@assessment-author\`. Use \`create_assessment\` for each returned payload. Splice the returned \`embedLine\` into the page markdown using the edit tool. Group multiple embeds under a \`## Check your understanding\` heading.
147
148
  6. **Validation** — run \`validate_guide\` tool (always at end of new-assignment / import-source / reorder workflows). Optionally delegate to \`@validator\` for content review when scope warrants.
148
149
  7. **Done** — summarize what was built and any flagged issues.
@@ -295,6 +296,7 @@ The orchestrator gives you:
295
296
  - \`title\`, \`type\`, \`layout\`, \`learningObjectives\`.
296
297
  - \`body_brief\` — bullets describing what the page should cover.
297
298
  - \`assignment_context\` — short summary of the assignment and what came before this page.
299
+ - \`layoutConfig\` *(optional)* — \`{ openFiles: string[], panels: [] }\` — which files are open by default in Codio. Use this for adaptive file instructions (see Rule 3 below).
298
300
 
299
301
  What you return:
300
302
  - \`markdownBody\` — the full instructional prose.
@@ -302,9 +304,47 @@ What you return:
302
304
 
303
305
  You do **not** insert assessment embed strings. The orchestrator inserts those after \`assessment-author\` returns.
304
306
 
307
+ ## Codio Idioms (Cardinal Rules)
308
+
309
+ These rules apply to **every page** you generate in draft mode.
310
+
311
+ ### Rule 1 — Freeze code directives
312
+
313
+ When a code block is scaffold or starter code students must not modify:
314
+ - Wrap with language-correct freeze directives:
315
+ - Python / Bash / Ruby: \`# FREEZE CODE BEGIN\` … \`# FREEZE CODE END\`
316
+ - JavaScript / TypeScript / Java / C / C++: \`// FREEZE CODE BEGIN\` … \`// FREEZE CODE END\`
317
+ - SQL: \`-- FREEZE CODE BEGIN\` … \`-- FREEZE CODE END\`
318
+ - Signal: source comments like \`# DO NOT ALTER THIS CODE\` or context like "starter code" identify freeze-worthy regions.
319
+ - Frozen code shows a padlock icon in Codio; students cannot edit it.
320
+ - Do **not** add a Try It button to frozen blocks.
321
+
322
+ ### Rule 2 — Try It buttons
323
+
324
+ For every executable code block that is **not frozen**, append a Try It button immediately after the closing fence:
325
+ - Syntax: \`{Try it}(COMMAND)\` — no space between \`{Try it}\` and the parentheses.
326
+ - Language commands: \`python3 solution.py\` / \`node solution.js\` / \`javac Main.java && java Main\`
327
+ - Skip Try It for pseudocode, non-executable snippets, and frozen blocks.
328
+
329
+ ### Rule 3 — Adaptive file instructions
330
+
331
+ Before writing any instruction that references a file:
332
+ - If \`layoutConfig.openFiles\` contains that file → write **"Look at the X file on the left"**.
333
+ - If the file is absent from \`openFiles\`, or no \`layoutConfig\` was provided → write **"Open the X file on the left"**.
334
+
335
+ ### Rule 4 — No duplicate page title
336
+
337
+ - The page title (JSON \`title\` field) is the canonical H1.
338
+ - Do **not** start your \`markdownBody\` with a \`# Heading\` that repeats the title.
339
+ - Begin the body with prose or an H2, never a duplicate H1.
340
+
341
+ ### Rule 5 — Visualizer file references
342
+
343
+ - Reference visualizer HTML files by local path only: e.g. \`"url": "tree_visualizer.html"\`.
344
+ - Do **not** use full URLs (\`http://…\`) or \`preview:\` schemes.
345
+
305
346
  ## Style rules
306
347
 
307
- - Heading 1 = page title (matches the JSON \`title\`).
308
348
  - One concept per page. If you find yourself introducing a second major concept, stop — that's a separate page.
309
349
  - Worked examples in fenced code blocks with a language label.
310
350
  - "You" not "the student". Active voice. No filler.
@@ -1087,6 +1127,51 @@ async function createAssessmentHandler(raw) {
1087
1127
  import { unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
1088
1128
  import { join as join4 } from "node:path";
1089
1129
  import { z as z3 } from "zod";
1130
+ var LANG_COMMENT_PREFIX = {
1131
+ python: "#",
1132
+ py: "#",
1133
+ bash: "#",
1134
+ sh: "#",
1135
+ ruby: "#",
1136
+ rb: "#",
1137
+ javascript: "//",
1138
+ js: "//",
1139
+ typescript: "//",
1140
+ ts: "//",
1141
+ java: "//",
1142
+ c: "//",
1143
+ cpp: "//",
1144
+ "c++": "//",
1145
+ sql: "--"
1146
+ };
1147
+ function validateFreezeDirectives(markdownBody) {
1148
+ const errors = [];
1149
+ const fenceRe = /^```(\w*)\n([\s\S]*?)^```/gm;
1150
+ let match;
1151
+ while ((match = fenceRe.exec(markdownBody)) !== null) {
1152
+ const lang = (match[1] ?? "").toLowerCase();
1153
+ const code = match[2] ?? "";
1154
+ const beginCount = (code.match(/FREEZE CODE BEGIN/g) ?? []).length;
1155
+ const endCount = (code.match(/FREEZE CODE END/g) ?? []).length;
1156
+ if (beginCount !== endCount) {
1157
+ errors.push(`\`\`\`${lang || "code"} block has unmatched freeze directives: ${beginCount} FREEZE CODE BEGIN, ${endCount} FREEZE CODE END`);
1158
+ continue;
1159
+ }
1160
+ if (beginCount === 0)
1161
+ continue;
1162
+ const expectedPrefix = LANG_COMMENT_PREFIX[lang];
1163
+ if (!expectedPrefix)
1164
+ continue;
1165
+ for (const m of code.matchAll(/^(.+?)FREEZE CODE BEGIN/gm)) {
1166
+ const actualPrefix = (m[1] ?? "").trim();
1167
+ if (actualPrefix !== expectedPrefix) {
1168
+ errors.push(`Wrong comment prefix for FREEZE CODE in \`\`\`${lang} block: found "${actualPrefix}", expected "${expectedPrefix}"`);
1169
+ break;
1170
+ }
1171
+ }
1172
+ }
1173
+ return errors;
1174
+ }
1090
1175
  var PAGE_TYPES = [
1091
1176
  "intro",
1092
1177
  "objectives",
@@ -1151,6 +1236,12 @@ function writeCreatedPage(workspace, chapterFolder, input) {
1151
1236
  }
1152
1237
  async function createPageHandler(raw) {
1153
1238
  const input = createPageInputSchema.parse(raw);
1239
+ const freezeErrors = validateFreezeDirectives(input.markdownBody);
1240
+ if (freezeErrors.length > 0) {
1241
+ throw new Error(`Freeze directive validation failed:
1242
+ ${freezeErrors.join(`
1243
+ `)}`);
1244
+ }
1154
1245
  return writeCreatedPage(input.workspace, input.chapterFolder, input);
1155
1246
  }
1156
1247
 
@@ -1175,6 +1266,23 @@ function walkJsonFiles(root) {
1175
1266
  }
1176
1267
  return out;
1177
1268
  }
1269
+ var LANG_COMMENT_PREFIX2 = {
1270
+ python: "#",
1271
+ py: "#",
1272
+ bash: "#",
1273
+ sh: "#",
1274
+ ruby: "#",
1275
+ rb: "#",
1276
+ javascript: "//",
1277
+ js: "//",
1278
+ typescript: "//",
1279
+ ts: "//",
1280
+ java: "//",
1281
+ c: "//",
1282
+ cpp: "//",
1283
+ "c++": "//",
1284
+ sql: "--"
1285
+ };
1178
1286
  function walkMdFiles(root) {
1179
1287
  if (!existsSync3(root))
1180
1288
  return [];
@@ -1205,6 +1313,49 @@ function collectIdFields(value, out) {
1205
1313
  collectIdFields(v, out);
1206
1314
  }
1207
1315
  }
1316
+ function findFirstH1(markdown) {
1317
+ const match = markdown.match(/^# (.+)$/m);
1318
+ return match ? (match[1] ?? "").trim() : null;
1319
+ }
1320
+ function checkFreezeDirectives(markdown) {
1321
+ const errors = [];
1322
+ const fenceRe = /^```(\w*)\n([\s\S]*?)^```/gm;
1323
+ let match;
1324
+ while ((match = fenceRe.exec(markdown)) !== null) {
1325
+ const lang = (match[1] ?? "").toLowerCase();
1326
+ const code = match[2] ?? "";
1327
+ const beginCount = (code.match(/FREEZE CODE BEGIN/g) ?? []).length;
1328
+ const endCount = (code.match(/FREEZE CODE END/g) ?? []).length;
1329
+ if (beginCount !== endCount) {
1330
+ errors.push(`\`\`\`${lang || "code"} block: ${beginCount} FREEZE CODE BEGIN vs ${endCount} FREEZE CODE END`);
1331
+ continue;
1332
+ }
1333
+ if (beginCount === 0)
1334
+ continue;
1335
+ const expectedPrefix = LANG_COMMENT_PREFIX2[lang];
1336
+ if (!expectedPrefix)
1337
+ continue;
1338
+ for (const m of code.matchAll(/^(.+?)FREEZE CODE BEGIN/gm)) {
1339
+ const actualPrefix = (m[1] ?? "").trim();
1340
+ if (actualPrefix !== expectedPrefix) {
1341
+ errors.push(`\`\`\`${lang} block uses "${actualPrefix} FREEZE CODE BEGIN", expected "${expectedPrefix} FREEZE CODE BEGIN"`);
1342
+ break;
1343
+ }
1344
+ }
1345
+ }
1346
+ return errors;
1347
+ }
1348
+ function findMalformedTryIt(markdown) {
1349
+ const errors = [];
1350
+ const re = /\{Try it\}(?!\()/g;
1351
+ let m;
1352
+ while ((m = re.exec(markdown)) !== null) {
1353
+ const lineNum = markdown.slice(0, m.index).split(`
1354
+ `).length;
1355
+ errors.push(`Line ~${lineNum}: {Try it} missing command parentheses — expected {Try it}(command)`);
1356
+ }
1357
+ return errors;
1358
+ }
1208
1359
  async function validateGuideHandler(raw) {
1209
1360
  const input = validateGuideInputSchema.parse(raw);
1210
1361
  const { guides, content, assessments } = resolveCodioPaths({
@@ -1295,7 +1446,75 @@ async function validateGuideHandler(raw) {
1295
1446
  }
1296
1447
  }
1297
1448
  }
1298
- return { findings, passed: findings.length === 0 };
1449
+ for (const f of walkJsonFiles(content)) {
1450
+ if (f.endsWith("/index.json"))
1451
+ continue;
1452
+ let pageTitle;
1453
+ try {
1454
+ const parsed = JSON.parse(readFileSync2(f, "utf8"));
1455
+ pageTitle = parsed?.title;
1456
+ } catch {
1457
+ continue;
1458
+ }
1459
+ if (!pageTitle)
1460
+ continue;
1461
+ const mdPath = f.replace(/\.json$/, ".md");
1462
+ if (!existsSync3(mdPath))
1463
+ continue;
1464
+ const body = readFileSync2(mdPath, "utf8");
1465
+ const firstH1 = findFirstH1(body);
1466
+ if (firstH1 && firstH1 === pageTitle) {
1467
+ findings.push({
1468
+ category: "double_header",
1469
+ detail: `${relative(input.workspace, mdPath)}: first H1 "${firstH1}" duplicates page title — remove H1 from body`,
1470
+ severity: "warning"
1471
+ });
1472
+ }
1473
+ }
1474
+ for (const md of walkMdFiles(content)) {
1475
+ const body = readFileSync2(md, "utf8");
1476
+ for (const err of checkFreezeDirectives(body)) {
1477
+ findings.push({
1478
+ category: "freeze_syntax_error",
1479
+ detail: `${relative(input.workspace, md)}: ${err}`,
1480
+ severity: "error"
1481
+ });
1482
+ }
1483
+ }
1484
+ for (const md of walkMdFiles(content)) {
1485
+ const body = readFileSync2(md, "utf8");
1486
+ for (const err of findMalformedTryIt(body)) {
1487
+ findings.push({
1488
+ category: "try_it_syntax_error",
1489
+ detail: `${relative(input.workspace, md)}: ${err}`,
1490
+ severity: "error"
1491
+ });
1492
+ }
1493
+ }
1494
+ for (const f of walkJsonFiles(content)) {
1495
+ if (f.endsWith("/index.json"))
1496
+ continue;
1497
+ let parsed;
1498
+ try {
1499
+ parsed = JSON.parse(readFileSync2(f, "utf8"));
1500
+ } catch {
1501
+ continue;
1502
+ }
1503
+ for (const entry of parsed?.files ?? []) {
1504
+ const filePath = entry?.path;
1505
+ if (!filePath)
1506
+ continue;
1507
+ if (!existsSync3(join5(input.workspace, filePath))) {
1508
+ findings.push({
1509
+ category: "missing_referenced_file",
1510
+ detail: `${relative(input.workspace, f)}: references file "${filePath}" which does not exist in workspace`,
1511
+ severity: "warning"
1512
+ });
1513
+ }
1514
+ }
1515
+ }
1516
+ const errorCount = findings.filter((f) => f.severity === "error").length;
1517
+ return { findings, passed: errorCount === 0 };
1299
1518
  }
1300
1519
 
1301
1520
  // src/tools/index.ts
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ export declare function validateFreezeDirectives(markdownBody: string): string[];
2
3
  export declare const PAGE_TYPES: readonly ["intro", "objectives", "instructional", "assessment", "conclusion"];
3
4
  export declare const PAGE_LAYOUTS: readonly ["1-panel", "2-panels-tree"];
4
5
  export declare const createPageInputSchema: z.ZodObject<{
@@ -1 +1 @@
1
- {"version":3,"file":"create-page.d.ts","sourceRoot":"","sources":["../../src/tools/create-page.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,UAAU,+EAMb,CAAC;AAEX,eAAO,MAAM,YAAY,uCAAwC,CAAC;AAElE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;iBAuB/B,CAAC;AAEJ,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,CAAC;IACxB,MAAM,EAAE,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;IACtC,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,WAAW,EAAE,OAAO,CAAC;IACrB,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,QAAQ,CAgB9D;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,KAAK,EAAE,eAAe,GACrB,gBAAgB,CA6BlB;AAED,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,gBAAgB,CAAC,CAG3B"}
1
+ {"version":3,"file":"create-page.d.ts","sourceRoot":"","sources":["../../src/tools/create-page.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAWxB,wBAAgB,wBAAwB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,EAAE,CA6BvE;AAED,eAAO,MAAM,UAAU,+EAMb,CAAC;AAEX,eAAO,MAAM,YAAY,uCAAwC,CAAC;AAElE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;iBAuB/B,CAAC;AAEJ,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,CAAC;IACxB,MAAM,EAAE,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;IACtC,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,WAAW,EAAE,OAAO,CAAC;IACrB,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,QAAQ,CAgB9D;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,KAAK,EAAE,eAAe,GACrB,gBAAgB,CA6BlB;AAED,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,gBAAgB,CAAC,CAO3B"}
@@ -3,7 +3,7 @@ export declare const validateGuideInputSchema: z.ZodObject<{
3
3
  workspace: z.ZodString;
4
4
  }, z.core.$strip>;
5
5
  export type ValidateGuideInput = z.infer<typeof validateGuideInputSchema>;
6
- export type FindingCategory = 'missing_content_dir' | 'invalid_uuid' | 'orphan_order_stem' | 'broken_assessment_ref' | 'taskid_filename_mismatch';
6
+ export type FindingCategory = 'missing_content_dir' | 'invalid_uuid' | 'orphan_order_stem' | 'broken_assessment_ref' | 'taskid_filename_mismatch' | 'double_header' | 'freeze_syntax_error' | 'try_it_syntax_error' | 'missing_referenced_file';
7
7
  export interface Finding {
8
8
  category: FindingCategory;
9
9
  detail: string;
@@ -1 +1 @@
1
- {"version":3,"file":"validate-guide.d.ts","sourceRoot":"","sources":["../../src/tools/validate-guide.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,eAAO,MAAM,wBAAwB;;iBAEnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,MAAM,MAAM,eAAe,GACvB,qBAAqB,GACrB,cAAc,GACd,mBAAmB,GACnB,uBAAuB,GACvB,0BAA0B,CAAC;AAE/B,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,eAAe,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;CACjB;AAyCD,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,mBAAmB,CAAC,CAwG9B"}
1
+ {"version":3,"file":"validate-guide.d.ts","sourceRoot":"","sources":["../../src/tools/validate-guide.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,eAAO,MAAM,wBAAwB;;iBAEnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,MAAM,MAAM,eAAe,GACvB,qBAAqB,GACrB,cAAc,GACd,mBAAmB,GACnB,uBAAuB,GACvB,0BAA0B,GAC1B,eAAe,GACf,qBAAqB,GACrB,qBAAqB,GACrB,yBAAyB,CAAC;AAE9B,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,eAAe,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;CACjB;AAgGD,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,mBAAmB,CAAC,CA+K9B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codio-ai/opencode-authoring-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Opencode plugin for authoring Codio guide assignments — orchestrator, subagents, tools, and skills.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,19 +11,13 @@
11
11
  "types": "./dist/index.d.ts"
12
12
  }
13
13
  },
14
- "files": [
15
- "dist",
16
- "src/skills",
17
- "bin",
18
- "README.md",
19
- "LICENSE"
20
- ],
14
+ "files": ["dist", "src/skills", "bin", "README.md", "LICENSE"],
21
15
  "bin": {
22
- "codio-authoring-register": "bin/codio-authoring-register.js",
23
- "codio-authoring-unregister": "bin/codio-authoring-unregister.js"
16
+ "codio-authoring-register": "./bin/codio-authoring-register.js",
17
+ "codio-authoring-unregister": "./bin/codio-authoring-unregister.js"
24
18
  },
25
19
  "scripts": {
26
- "build": "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external zod && tsc --emitDeclarationOnly",
20
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external zod && bun build src/codio-scripts/codio-fetch.js src/codio-scripts/codio-publish.js --outdir dist/codio-scripts --target node --format esm && tsc --emitDeclarationOnly",
27
21
  "postinstall": "node ./bin/codio-authoring-register.js || true",
28
22
  "preuninstall": "node ./bin/codio-authoring-unregister.js || true",
29
23
  "test": "bun test",
@@ -33,17 +27,11 @@
33
27
  "check": "biome check --write .",
34
28
  "check:ci": "biome check ."
35
29
  },
36
- "keywords": [
37
- "opencode",
38
- "opencode-plugin",
39
- "codio",
40
- "assignment-authoring",
41
- "education"
42
- ],
30
+ "keywords": ["opencode", "opencode-plugin", "codio", "assignment-authoring", "education"],
43
31
  "license": "MIT",
44
32
  "repository": {
45
33
  "type": "git",
46
- "url": "git+https://github.com/codio-extensions/codio-authoring-opencode-plugin.git"
34
+ "url": "https://github.com/codio-extensions/codio-authoring-opencode-plugin.git"
47
35
  },
48
36
  "homepage": "https://github.com/codio-extensions/codio-authoring-opencode-plugin#readme",
49
37
  "bugs": "https://github.com/codio-extensions/codio-authoring-opencode-plugin/issues",
@@ -61,6 +49,8 @@
61
49
  "@biomejs/biome": "2.4.11",
62
50
  "@types/node": "^24.6.1",
63
51
  "bun-types": "1.3.12",
52
+ "codio-api-js": "^0.18.0",
53
+ "json-2-csv": "^5.5.10",
64
54
  "typescript": "^5.9.3",
65
55
  "zod": "~4.1.0"
66
56
  },
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: codio-fetch
3
+ description: Fetch and export an existing Codio course into an AI-ready local project. Use when the user asks to fetch, export, download, pull, or import a course from Codio, or to create a local AI-ready project from an existing Codio course.
4
+ argument-hint: [--course-id <id>] [--output-dir <path>] [--domain <domain>]
5
+ allowed-tools: [Bash, Read, Write, Edit]
6
+ ---
7
+
8
+ # Codio Fetch
9
+
10
+ Export an existing Codio course into a clean, AI-ready local project that can be edited and re-published with `codio-publish`.
11
+
12
+ ## What this skill does
13
+
14
+ 1. Loads credentials from `.env` at the workspace root (the Codio assignment directory).
15
+ 2. Authenticates with the Codio API using `CODIO_CLIENT_ID` and `CODIO_CLIENT_SECRET`.
16
+ 3. Fetches course metadata and builds a `course-manifest.json` with all module and assignment IDs.
17
+ 4. Downloads the course source export ZIP from Codio.
18
+ 5. Extracts the ZIP into `course-content/`, expanding nested `.tar.zst` assignment archives.
19
+ 6. Writes `course-assignments.csv` as a flat index for easy review.
20
+ 7. Cleans up temporary download files.
21
+
22
+ The output is a self-contained project folder ready for `codio-publish` to read back.
23
+
24
+ ## Output structure
25
+
26
+ ```
27
+ <output-dir>/ ← default: ./course-project
28
+ ├── course-manifest.json ← machine-readable index with all IDs and paths
29
+ ├── course-assignments.csv ← flat CSV listing all modules and assignments
30
+ └── course-content/
31
+ └── <module-folder>/
32
+ └── <assignment-folder>/
33
+ └── .guides/ ← guide pages, assessments, images
34
+ ```
35
+
36
+ The `<output-dir>` is always a named subfolder, never at the workspace root itself.
37
+
38
+ ## Prerequisites
39
+
40
+ Before running, verify:
41
+
42
+ 1. **`.env` file** — must exist at the workspace root:
43
+ ```
44
+ CODIO_CLIENT_ID=your-client-id
45
+ CODIO_CLIENT_SECRET=your-client-secret
46
+ CODIO_COURSE_ID=your-course-id # optional if passing --course-id
47
+ CODIO_DOMAIN=codio.com # optional, defaults to codio.com
48
+ ```
49
+ The script finds `.env` by walking up from the current directory, so it works from anywhere inside the workspace.
50
+
51
+ 2. **Check `.env` is in place:**
52
+ ```bash
53
+ [ -f .env ] && echo "found" || echo "missing — create .env before running"
54
+ ```
55
+
56
+ 3. **Required system tools:** `node`, `unzip`, `tar`, `find`
57
+
58
+ ## Running the export
59
+
60
+ Run from the workspace root (the Codio assignment directory):
61
+
62
+ ```bash
63
+ bash ~/.config/opencode/skills/codio-fetch/scripts/run-course-export.sh --course-id <id>
64
+ ```
65
+
66
+ ### Options
67
+
68
+ | Option | Description |
69
+ |---|---|
70
+ | `--course-id <id>` | Codio course ID (overrides `CODIO_COURSE_ID` from `.env`) |
71
+ | `--output-dir <path>` | Output project folder (default: `./course-project`) |
72
+ | `--domain <domain>` | Codio domain (default: `codio.com` or `CODIO_DOMAIN` from `.env`) |
73
+
74
+ ### Examples
75
+
76
+ ```bash
77
+ # Use course ID from .env, output to ./course-project
78
+ bash ~/.config/opencode/skills/codio-fetch/scripts/run-course-export.sh
79
+
80
+ # Specify course ID and custom output folder
81
+ bash ~/.config/opencode/skills/codio-fetch/scripts/run-course-export.sh \
82
+ --course-id c87b4540de108faad55c8cefe7847492 \
83
+ --output-dir ./my-java-course
84
+
85
+ # Use a staging Codio domain
86
+ bash ~/.config/opencode/skills/codio-fetch/scripts/run-course-export.sh \
87
+ --course-id c87b4540de108faad55c8cefe7847492 \
88
+ --domain codio.co.uk
89
+ ```
90
+
91
+ ## How credentials are loaded
92
+
93
+ The shell script walks up from the current directory looking for `.env`, then sources it with `set -a; . .env; set +a`. Values in `.env` **override** anything already in the environment.
94
+
95
+ The `.env` is shared across all Codio skills (`codio-fetch`, `codio-publish`). There is one `.env` at the workspace root, not one per skill.
96
+
97
+ ## Environment variables
98
+
99
+ | Variable | Required | Description |
100
+ |---|---|---|
101
+ | `CODIO_CLIENT_ID` | Yes | Codio API client ID |
102
+ | `CODIO_CLIENT_SECRET` | Yes | Codio API client secret |
103
+ | `CODIO_COURSE_ID` | No | Course ID (can be passed as `--course-id` instead) |
104
+ | `CODIO_DOMAIN` | No | Codio domain, default `codio.com` |
105
+
106
+ ## After the export
107
+
108
+ - Open `<output-dir>/course-manifest.json` to see the full module/assignment hierarchy with IDs.
109
+ - Open `<output-dir>/course-assignments.csv` for a flat spreadsheet view.
110
+ - Assignment content is in `<output-dir>/course-content/<module>/<assignment>/`.
111
+ - Use `codio-publish` to push edits back to Codio — the manifest with existing IDs is already in place.
112
+
113
+ ## Troubleshooting
114
+
115
+ | Error | Fix |
116
+ |---|---|
117
+ | `Missing required environment variable: CODIO_CLIENT_ID` | Create or fix `.env` at workspace root |
118
+ | `Missing course id` | Pass `--course-id <id>` or set `CODIO_COURSE_ID` in `.env` |
119
+ | `Required command not found: unzip` | Install `unzip` via your package manager |
120
+ | API 401 / auth error | Credentials in `.env` are invalid or expired |
121
+
122
+ ## Manifest format
123
+
124
+ `course-manifest.json` produced by this skill is directly compatible with `codio-publish`. All assignment IDs are pre-populated, so a subsequent publish run will update existing assignments rather than create new ones.
125
+
126
+ ```jsonc
127
+ {
128
+ "schemaVersion": 2,
129
+ "courseId": "...",
130
+ "exportedAt": "...",
131
+ "counts": { "modules": 2, "assignments": 6 },
132
+ "paths": {
133
+ "assignmentsCsv": "course-assignments.csv",
134
+ "courseContent": "course-content"
135
+ },
136
+ "modules": [
137
+ {
138
+ "name": "Module Name",
139
+ "folder": "module-name",
140
+ "path": "course-content/module-name",
141
+ "assignmentCount": 3,
142
+ "assignments": [
143
+ {
144
+ "id": "...",
145
+ "name": "Assignment Name",
146
+ "folder": "assignment-name",
147
+ "path": "course-content/module-name/assignment-name"
148
+ }
149
+ ]
150
+ }
151
+ ]
152
+ }
153
+ ```
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
+ shopt -s nullglob
4
+
5
+ log() {
6
+ printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"
7
+ }
8
+
9
+ fail() {
10
+ log "ERROR: $*"
11
+ exit 1
12
+ }
13
+
14
+ require_command() {
15
+ command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
16
+ }
17
+
18
+ extract_assignment_archives() {
19
+ local root_dir="$1"
20
+ local archive_count=0
21
+
22
+ while IFS= read -r -d '' archive_path; do
23
+ archive_count=$((archive_count + 1))
24
+
25
+ local archive_dir archive_name destination_dir
26
+ archive_dir="$(dirname "$archive_path")"
27
+ archive_name="$(basename "$archive_path")"
28
+ destination_dir="$archive_dir/${archive_name%.tar.zst}"
29
+
30
+ rm -rf "$destination_dir"
31
+ mkdir -p "$destination_dir"
32
+
33
+ log "Extracting nested archive ${archive_name}"
34
+ tar --zstd -xf "$archive_path" -C "$destination_dir"
35
+ rm -f "$archive_path"
36
+ done < <(find "$root_dir" -type f -name '*.tar.zst' -print0)
37
+
38
+ log "Expanded ${archive_count} nested assignment archive(s)"
39
+ }
40
+
41
+ main() {
42
+ require_command unzip
43
+ require_command tar
44
+ require_command find
45
+ require_command cp
46
+
47
+ local export_zip_path="${1:-}"
48
+ local staging_dir="${2:-}"
49
+ local visible_output_dir="${3:-}"
50
+
51
+ [[ -n "$export_zip_path" ]] || fail 'Usage: extract-course-export.sh <export-zip-path> <staging-dir> <visible-output-dir>'
52
+ [[ -n "$staging_dir" ]] || fail 'Missing staging directory'
53
+ [[ -n "$visible_output_dir" ]] || fail 'Missing visible output directory'
54
+ [[ -f "$export_zip_path" ]] || fail "Export ZIP not found: $export_zip_path"
55
+
56
+ rm -rf "$staging_dir" "$visible_output_dir"
57
+ mkdir -p "$staging_dir" "$visible_output_dir"
58
+
59
+ log "Unzipping $(basename "$export_zip_path") into hidden staging"
60
+ unzip -q "$export_zip_path" -d "$staging_dir"
61
+
62
+ extract_assignment_archives "$staging_dir"
63
+
64
+ cp -R "$staging_dir"/. "$visible_output_dir"
65
+
66
+ log "Published extracted course content to $visible_output_dir"
67
+ }
68
+
69
+ main "$@"