Skip to content

Commit 5c3adb3

Browse files
willwearingclaude
andauthored
fix: extract shared domain logic, fix MCP npm install failure (#100)
The published @graspful/mcp@0.2.3 shipped with workspace:* for the @graspful/shared dep, causing npm install to fail with EUNSUPPORTEDPROTOCOL. Root cause: the CI sed replacement ran but MCP version was never bumped after the prior fix (de7036f only bumped CLI). Fix: move duplicated domain logic (validate, describe, scaffold, fill-concept, quality gate) from MCP and CLI into @graspful/shared. Both packages now import from shared instead of maintaining copies. - @graspful/shared 0.2.2 -> 0.2.3 (new exports) - @graspful/mcp 0.2.3 -> 0.2.4 (fix burned version) - @graspful/cli 0.2.5 -> 0.2.6 (uses shared imports) - MCP index.ts: 1271 -> 541 lines - Net: -680 lines of duplicated code Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7f62f66 commit 5c3adb3

13 files changed

Lines changed: 510 additions & 1189 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@graspful/cli",
3-
"version": "0.2.5",
3+
"version": "0.2.6",
44
"description": "Create adaptive learning courses from YAML. CLI and MCP server for AI agents.",
55
"keywords": ["course", "learning", "adaptive", "mcp", "mcp-server", "ai", "ai-agent", "agent", "cli", "education", "edtech", "knowledge-graph", "spaced-repetition", "course-creation", "yaml", "adaptive-learning", "lms"],
66
"repository": {

packages/cli/src/commands/create-brand.ts

Lines changed: 3 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,10 @@
11
import { Command } from 'commander';
22
import * as fs from 'fs';
33
import * as yaml from 'js-yaml';
4+
import { scaffoldBrandObject } from '@graspful/shared';
45
import { output } from '../lib/output';
56
import { cliCapture } from '../lib/analytics';
67

7-
const NICHE_PRESETS: Record<string, { preset: string; tagline: string; headline: string }> = {
8-
education: { preset: 'blue', tagline: 'Learn smarter, not harder', headline: 'Master any subject with adaptive learning' },
9-
healthcare: { preset: 'emerald', tagline: 'Training that saves lives', headline: 'Adaptive healthcare education for professionals' },
10-
finance: { preset: 'slate', tagline: 'Build financial expertise', headline: 'Master finance with adaptive learning' },
11-
tech: { preset: 'indigo', tagline: 'Level up your skills', headline: 'Adaptive tech training that meets you where you are' },
12-
legal: { preset: 'amber', tagline: 'Know the law, pass the exam', headline: 'Adaptive legal education for exam success' },
13-
default: { preset: 'blue', tagline: 'Learn adaptively', headline: 'Personalized learning that works' },
14-
};
15-
16-
export function scaffoldBrand(niche: string, options: { name?: string; domain?: string; orgSlug?: string }): string {
17-
const config = NICHE_PRESETS[niche] || NICHE_PRESETS['default'];
18-
const slug = (options.name || niche).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
19-
const name = options.name || `${niche.charAt(0).toUpperCase() + niche.slice(1)} Academy`;
20-
const domain = options.domain || `${slug}.graspful.ai`;
21-
22-
return yaml.dump({
23-
brand: {
24-
id: slug,
25-
name,
26-
domain,
27-
tagline: config.tagline,
28-
logoUrl: '/icon.svg',
29-
orgSlug: options.orgSlug || 'TODO: your-org-slug',
30-
},
31-
theme: {
32-
preset: config.preset,
33-
radius: '0.5rem',
34-
},
35-
landing: {
36-
hero: {
37-
headline: config.headline,
38-
subheadline: `${name} uses adaptive learning to help you master concepts faster.`,
39-
ctaText: 'Start Learning',
40-
},
41-
features: {
42-
heading: 'Why choose us?',
43-
items: [
44-
{ title: 'Adaptive Learning', description: 'Content adapts to your knowledge level', icon: 'brain' },
45-
{ title: 'Spaced Repetition', description: 'Review at optimal intervals for lasting memory', icon: 'clock' },
46-
{ title: 'Progress Tracking', description: 'See exactly where you stand', icon: 'chart' },
47-
],
48-
},
49-
howItWorks: {
50-
heading: 'How it works',
51-
items: [
52-
{ title: 'Take a diagnostic', description: 'We assess what you already know' },
53-
{ title: 'Learn adaptively', description: 'Focus on gaps, skip what you know' },
54-
{ title: 'Master the material', description: 'Prove mastery through progressive challenges' },
55-
],
56-
},
57-
faq: [],
58-
},
59-
pricing: {
60-
monthly: 0,
61-
currency: 'usd',
62-
trialDays: 0,
63-
},
64-
seo: {
65-
title: `${name} — Adaptive Learning`,
66-
description: config.tagline,
67-
keywords: [niche, 'learning', 'adaptive', 'education'],
68-
},
69-
}, { lineWidth: 120, noRefs: true });
70-
}
71-
728
export function registerCreateBrandCommand(createCmd: Command) {
739
createCmd
7410
.command('brand')
@@ -79,11 +15,12 @@ export function registerCreateBrandCommand(createCmd: Command) {
7915
.option('--org <slug>', 'Organization slug')
8016
.option('-o, --output <file>', 'Output file path (defaults to stdout)')
8117
.action(async (opts: { niche: string; name?: string; domain?: string; org?: string; output?: string }) => {
82-
const yamlContent = scaffoldBrand(opts.niche, {
18+
const obj = scaffoldBrandObject(opts.niche, {
8319
name: opts.name,
8420
domain: opts.domain,
8521
orgSlug: opts.org,
8622
});
23+
const yamlContent = yaml.dump(obj, { lineWidth: 120, noRefs: true });
8724

8825
cliCapture('brand scaffolded', { niche: opts.niche });
8926
if (opts.output) {

packages/cli/src/commands/create-course.ts

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,10 @@
11
import { Command } from 'commander';
22
import * as fs from 'fs';
33
import * as yaml from 'js-yaml';
4-
import { output, outputError } from '../lib/output';
4+
import { scaffoldCourseObject } from '@graspful/shared';
5+
import { output } from '../lib/output';
56
import { cliCapture } from '../lib/analytics';
67

7-
export function scaffoldCourse(topic: string, options: { hours?: number; source?: string }): string {
8-
const slug = topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
9-
return yaml.dump({
10-
course: {
11-
id: slug,
12-
name: topic,
13-
description: `Adaptive course on ${topic}`,
14-
estimatedHours: options.hours || 10,
15-
version: '2026.1',
16-
sourceDocument: options.source || 'TODO: Add source document',
17-
},
18-
sections: [
19-
{ id: 'foundations', name: 'Foundations', description: 'Core concepts' },
20-
{ id: 'application', name: 'Application', description: 'Applied concepts' },
21-
],
22-
concepts: [
23-
{
24-
id: `${slug}-intro`,
25-
name: `Introduction to ${topic}`,
26-
section: 'foundations',
27-
difficulty: 2,
28-
estimatedMinutes: 15,
29-
tags: ['foundational'],
30-
prerequisites: [],
31-
knowledgePoints: [],
32-
},
33-
],
34-
}, { lineWidth: 120, noRefs: true });
35-
}
36-
378
export function registerCreateCourseCommand(program: Command) {
389
const create = program
3910
.command('create')
@@ -48,10 +19,11 @@ export function registerCreateCourseCommand(program: Command) {
4819
.option('-o, --output <file>', 'Output file path (defaults to stdout)')
4920
.option('--scaffold-only', 'Generate scaffold without AI enrichment', true)
5021
.action(async (opts: { topic: string; hours: string; source?: string; output?: string; scaffoldOnly: boolean }) => {
51-
const yamlContent = scaffoldCourse(opts.topic, {
22+
const obj = scaffoldCourseObject(opts.topic, {
5223
hours: parseInt(opts.hours, 10),
5324
source: opts.source,
5425
});
26+
const yamlContent = yaml.dump(obj, { lineWidth: 120, noRefs: true });
5527

5628
cliCapture('course scaffolded', { topic: opts.topic, estimated_hours: parseInt(opts.hours, 10) });
5729

@@ -62,7 +34,6 @@ export function registerCreateCourseCommand(program: Command) {
6234
`Scaffold written to ${opts.output}`,
6335
);
6436
} else {
65-
// Output raw YAML to stdout (not wrapped in JSON even in json mode for piping)
6637
console.log(yamlContent);
6738
}
6839
});
Lines changed: 10 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,10 @@
11
import { Command } from 'commander';
22
import * as fs from 'fs';
33
import * as yaml from 'js-yaml';
4-
import { CourseYamlSchema } from '@graspful/shared';
5-
import type { CourseYaml } from '@graspful/shared';
4+
import { CourseYamlSchema, describeCourse } from '@graspful/shared';
65
import { output, outputError } from '../lib/output';
76
import { cliCapture } from '../lib/analytics';
87

9-
function computeGraphDepth(concepts: CourseYaml['concepts']): number {
10-
const graph = new Map<string, string[]>();
11-
for (const c of concepts) {
12-
graph.set(c.id, c.prerequisites);
13-
}
14-
15-
const memo = new Map<string, number>();
16-
17-
function depth(id: string, visited: Set<string>): number {
18-
if (memo.has(id)) return memo.get(id)!;
19-
if (visited.has(id)) return 0; // cycle, avoid infinite loop
20-
visited.add(id);
21-
22-
const prereqs = graph.get(id) ?? [];
23-
let maxPrereqDepth = 0;
24-
for (const prereq of prereqs) {
25-
if (graph.has(prereq)) {
26-
maxPrereqDepth = Math.max(maxPrereqDepth, depth(prereq, visited));
27-
}
28-
}
29-
30-
const d = maxPrereqDepth + 1;
31-
memo.set(id, d);
32-
return d;
33-
}
34-
35-
let maxDepth = 0;
36-
for (const c of concepts) {
37-
maxDepth = Math.max(maxDepth, depth(c.id, new Set()));
38-
}
39-
return maxDepth;
40-
}
41-
428
export function registerDescribeCommand(program: Command) {
439
program
4410
.command('describe <file>')
@@ -64,89 +30,25 @@ export function registerDescribeCommand(program: Command) {
6430
process.exit(1);
6531
}
6632

67-
const data = result.data;
68-
const concepts = data.concepts;
69-
const sections = data.sections;
70-
71-
const authoredConcepts = concepts.filter((c) => c.knowledgePoints.length > 0);
72-
const stubConcepts = concepts.filter((c) => c.knowledgePoints.length === 0);
73-
74-
const kpCount = concepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
75-
const problemCount = concepts.reduce(
76-
(sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0),
77-
0,
78-
);
79-
80-
const graphDepth = computeGraphDepth(concepts);
81-
82-
const conceptsWithoutKps = stubConcepts.map((c) => c.id);
83-
const kpsWithoutProblems: string[] = [];
84-
for (const c of concepts) {
85-
for (const kp of c.knowledgePoints) {
86-
if (kp.problems.length === 0) {
87-
kpsWithoutProblems.push(`${c.id}/${kp.id}`);
88-
}
89-
}
90-
}
91-
92-
// Section breakdown
93-
const sectionBreakdown: Array<{ section: string; concepts: number; kps: number; problems: number }> = [];
94-
if (sections.length > 0) {
95-
for (const section of sections) {
96-
const sectionConcepts = concepts.filter((c) => c.section === section.id);
97-
const sKps = sectionConcepts.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
98-
const sProblems = sectionConcepts.reduce(
99-
(sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0),
100-
0,
101-
);
102-
sectionBreakdown.push({ section: section.id, concepts: sectionConcepts.length, kps: sKps, problems: sProblems });
103-
}
104-
105-
// Concepts without a section
106-
const unsectioned = concepts.filter((c) => !c.section);
107-
if (unsectioned.length > 0) {
108-
const uKps = unsectioned.reduce((sum, c) => sum + c.knowledgePoints.length, 0);
109-
const uProblems = unsectioned.reduce(
110-
(sum, c) => sum + c.knowledgePoints.reduce((s, kp) => s + kp.problems.length, 0),
111-
0,
112-
);
113-
sectionBreakdown.push({ section: '(unsectioned)', concepts: unsectioned.length, kps: uKps, problems: uProblems });
114-
}
115-
}
116-
117-
const stats = {
118-
courseName: data.course.name,
119-
courseId: data.course.id,
120-
version: data.course.version,
121-
estimatedHours: data.course.estimatedHours,
122-
concepts: concepts.length,
123-
authoredConcepts: authoredConcepts.length,
124-
stubConcepts: stubConcepts.length,
125-
knowledgePoints: kpCount,
126-
problems: problemCount,
127-
graphDepth,
128-
conceptsWithoutKps: conceptsWithoutKps.length,
129-
kpsWithoutProblems: kpsWithoutProblems.length,
130-
sections: sectionBreakdown,
131-
};
33+
const stats = describeCourse(result.data);
13234

13335
const humanLines = [
134-
`Course: "${data.course.name}" (v${data.course.version})`,
135-
`Concepts: ${concepts.length} (${authoredConcepts.length} authored, ${stubConcepts.length} stubs)`,
136-
`KPs: ${kpCount}, Problems: ${problemCount}`,
137-
`Graph depth: ${graphDepth}`,
138-
`Missing: ${conceptsWithoutKps.length} concepts need KPs, ${kpsWithoutProblems.length} KPs need problems`,
36+
`Course: "${stats.courseName}" (v${stats.version})`,
37+
`Concepts: ${stats.concepts} (${stats.authoredConcepts} authored, ${stats.stubConcepts} stubs)`,
38+
`KPs: ${stats.knowledgePoints}, Problems: ${stats.problems}`,
39+
`Graph depth: ${stats.graphDepth}`,
40+
`Missing: ${stats.conceptsWithoutKps} concepts need KPs, ${stats.kpsWithoutProblems} KPs need problems`,
13941
];
14042

141-
if (sectionBreakdown.length > 0) {
43+
if (stats.sections.length > 0) {
14244
humanLines.push('');
14345
humanLines.push('Sections:');
144-
for (const s of sectionBreakdown) {
46+
for (const s of stats.sections) {
14547
humanLines.push(` ${s.section}: ${s.concepts} concepts, ${s.kps} KPs, ${s.problems} problems`);
14648
}
14749
}
14850

149-
cliCapture('course described', { concept_count: concepts.length, kp_count: kpCount, problem_count: problemCount });
51+
cliCapture('course described', { concept_count: stats.concepts, kp_count: stats.knowledgePoints, problem_count: stats.problems });
15052
output(stats, humanLines.join('\n'));
15153
});
15254
}

packages/cli/src/commands/fill-concept.ts

Lines changed: 14 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Command } from 'commander';
22
import * as fs from 'fs';
33
import * as yaml from 'js-yaml';
4-
import { CourseYamlSchema } from '@graspful/shared';
4+
import { fillConceptInRaw } from '@graspful/shared';
55
import { output, outputError } from '../lib/output';
66
import { cliCapture } from '../lib/analytics';
77

@@ -31,69 +31,24 @@ export function registerFillConceptCommand(program: Command) {
3131
process.exit(1);
3232
}
3333

34-
const parsed = CourseYamlSchema.safeParse(raw);
35-
if (!parsed.success) {
36-
outputError(`Invalid course YAML: ${parsed.error.issues[0]?.message ?? 'unknown error'}`);
37-
process.exit(1);
38-
}
34+
try {
35+
const updated = fillConceptInRaw(raw, conceptId, {
36+
kps: parseInt(opts.kps, 10),
37+
problemsPerKp: parseInt(opts.problems, 10),
38+
});
3939

40-
const data = parsed.data;
41-
const concept = data.concepts.find((c) => c.id === conceptId);
42-
if (!concept) {
43-
outputError(`Concept "${conceptId}" not found. Available: ${data.concepts.map((c) => c.id).join(', ')}`);
44-
process.exit(1);
45-
}
40+
const updatedYaml = yaml.dump(updated, { lineWidth: 120, noRefs: true, schema: yaml.JSON_SCHEMA });
41+
fs.writeFileSync(file, updatedYaml);
4642

47-
if (concept.knowledgePoints.length > 0) {
43+
cliCapture('concept filled', { concept_id: conceptId });
4844
output(
49-
{ conceptId, existingKps: concept.knowledgePoints.length, action: 'skipped' },
50-
`Concept "${conceptId}" already has ${concept.knowledgePoints.length} KP(s). Use --force to overwrite (not yet implemented).`,
45+
{ conceptId, kpsAdded: parseInt(opts.kps, 10), problemsPerKp: parseInt(opts.problems, 10), file },
46+
`Added ${opts.kps} KP stub(s) with ${opts.problems} problem(s) each to "${conceptId}" in ${file}`,
5147
);
52-
return;
53-
}
54-
55-
const kpCount = parseInt(opts.kps, 10);
56-
const problemsPerKp = parseInt(opts.problems, 10);
57-
58-
const newKps = [];
59-
for (let i = 1; i <= kpCount; i++) {
60-
const problems = [];
61-
for (let j = 1; j <= problemsPerKp; j++) {
62-
problems.push({
63-
id: `${conceptId}-kp${i}-p${j}`,
64-
type: 'multiple_choice',
65-
question: `TODO: Write question ${j} for ${conceptId} KP${i}`,
66-
options: ['Option A', 'Option B', 'Option C', 'Option D'],
67-
correct: 0,
68-
explanation: 'TODO: Explain the correct answer',
69-
difficulty: Math.min(j + 1, 5),
70-
});
71-
}
72-
73-
newKps.push({
74-
id: `${conceptId}-kp${i}`,
75-
instruction: `TODO: Write instruction for ${concept.name} — knowledge point ${i}`,
76-
workedExample: `TODO: Write a worked example for ${concept.name} — knowledge point ${i}`,
77-
problems,
78-
});
79-
}
80-
81-
// Rebuild the raw object to preserve structure, then replace the concept's KPs
82-
const rawObj = raw as Record<string, unknown>;
83-
const concepts = (rawObj['concepts'] as Array<Record<string, unknown>>);
84-
const targetConcept = concepts.find((c) => c['id'] === conceptId);
85-
if (targetConcept) {
86-
targetConcept['knowledgePoints'] = newKps;
48+
} catch (e) {
49+
outputError(e instanceof Error ? e.message : String(e));
50+
process.exit(1);
8751
}
88-
89-
const updatedYaml = yaml.dump(rawObj, { lineWidth: 120, noRefs: true, schema: yaml.JSON_SCHEMA });
90-
fs.writeFileSync(file, updatedYaml);
91-
92-
cliCapture('concept filled', { concept_id: conceptId });
93-
output(
94-
{ conceptId, kpsAdded: kpCount, problemsPerKp, file },
95-
`Added ${kpCount} KP stub(s) with ${problemsPerKp} problem(s) each to "${conceptId}" in ${file}`,
96-
);
9752
});
9853

9954
return fill;

0 commit comments

Comments
 (0)