From 519380be0834b638dfad77d1dc44cb9a3446b2ca Mon Sep 17 00:00:00 2001 From: Hasnain Sikora Date: Tue, 16 Jun 2026 14:23:46 -0400 Subject: [PATCH 1/2] fix(init): handle combined JSON format, add .mcp.json, and harden degree-fetch (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps in the init command scaffolding addressed: 1. Unified JSON format support — all degree skill templates now reference both `*.unified.json` and `*.yaml`. Added two new skills: `degree-update` (revise existing degree files in either format) and `degree-fetch` (pull from the database with dual-build verification and majority-rules reconciliation before writing the final file). 2. Project-root .mcp.json — init now generates a `.mcp.json` alongside `.claude/settings.json`, using the same dynamic command detection so both configs stay in sync. Conflict detection and --force handling cover the new file. 3. degree-fetch hardening — the new skill prompts for output format, runs two independent parallel scaffold builds, diffs them, surfaces any discrepancies to the user, and cleans up scratch files regardless of outcome. README updated to reflect the new layout entry, updated CLI examples, and the expanded five-skill list. Co-Authored-By: Claude Sonnet 4.6 --- src/assets/init/README.md | 26 ++++--- src/assets/init/mcp.json.tmpl | 9 +++ src/assets/init/skills/degree-author/SKILL.md | 2 +- src/assets/init/skills/degree-fetch/SKILL.md | 31 ++++++++ src/assets/init/skills/degree-review/SKILL.md | 2 +- src/assets/init/skills/degree-update/SKILL.md | 29 ++++++++ src/assets/init/skills/plan-analyze/SKILL.md | 2 +- src/cli/commands/init.rs | 71 +++++++++++++++---- 8 files changed, 145 insertions(+), 27 deletions(-) create mode 100644 src/assets/init/mcp.json.tmpl create mode 100644 src/assets/init/skills/degree-fetch/SKILL.md create mode 100644 src/assets/init/skills/degree-update/SKILL.md diff --git a/src/assets/init/README.md b/src/assets/init/README.md index 2f4d73f..884f8e3 100644 --- a/src/assets/init/README.md +++ b/src/assets/init/README.md @@ -6,24 +6,26 @@ curriculum-plan analyses, driven either from the CLI or from Claude via MCP. ## Layout -- `degrees/` — degree program YAML files (schema v5.2). Drop new programs here. +- `degrees/` — degree program files (`*.yaml` or `*.unified.json`). Drop new programs here. - `plans/` — curriculum-plan CSVs in CurricularAnalytics.org format. - `metrics/` — generated CSV metrics (created on first run). - `reports/` — generated HTML/Markdown/PDF reports (created on first run). - `nuanalytics.toml` — local config; overrides the user/global config. -- `.claude/` — MCP wiring and SKILL.md skills for Claude Code. +- `.mcp.json` — project-root MCP server registration (read by Claude Code and compatible clients). +- `.claude/` — Claude Code MCP wiring and SKILL.md skills. ## Common commands ```sh -# Validate a single degree YAML +# Validate a single degree file (YAML or unified JSON) nuanalytics degree validate degrees/my-program.yaml +nuanalytics degree validate degrees/my-program.unified.json # Full plan-enumeration analysis (writes CSV + HTML) nuanalytics degree analyze degrees/my-program.yaml -# Batch-analyze every degree YAML in this project -nuanalytics degree analyze degrees/*.yaml +# Batch-analyze every degree file in this project +nuanalytics degree analyze degrees/*.yaml degrees/*.unified.json # Trim alternatives down to a single shared shortest path nuanalytics degree trim degrees/my-program.yaml -o trimmed/ @@ -37,14 +39,16 @@ nuanalytics config ## Using Claude in this directory -Running `claude` from this directory picks up `.claude/settings.json` and the -skills under `.claude/skills/`. The NuAnalytics MCP server is wired in, so +Running `claude` from this directory picks up `.mcp.json` (or `.claude/settings.json`) +and the skills under `.claude/skills/`. The NuAnalytics MCP server is wired in, so Claude can call the degree tools — `validate_degree`, `audit_degree`, `analyze_degree`, `trim_degree`, `get_degree_schema`, and friends — directly. -Three skills auto-trigger based on what you ask: +Five skills auto-trigger based on what you ask: -- **degree-author** — generate a new degree YAML from a catalog source. -- **degree-review** — validate and critique an existing degree YAML. -- **plan-analyze** — run analyses on a degree YAML or curriculum CSV. +- **degree-author** — generate a new degree YAML or unified JSON from a catalog source. +- **degree-review** — validate and critique an existing degree YAML or unified JSON. +- **degree-update** — revise an existing degree file (`*.yaml` or `*.unified.json`). +- **degree-fetch** — pull a degree from the database with dual-build verification. +- **plan-analyze** — run analyses on a degree file or curriculum CSV. diff --git a/src/assets/init/mcp.json.tmpl b/src/assets/init/mcp.json.tmpl new file mode 100644 index 0000000..c3ef4ea --- /dev/null +++ b/src/assets/init/mcp.json.tmpl @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "nuanalytics": { + "type": "stdio", + "command": {{MCP_COMMAND}}, + "args": {{MCP_ARGS}} + } + } +} diff --git a/src/assets/init/skills/degree-author/SKILL.md b/src/assets/init/skills/degree-author/SKILL.md index d469781..fbd7228 100644 --- a/src/assets/init/skills/degree-author/SKILL.md +++ b/src/assets/init/skills/degree-author/SKILL.md @@ -21,7 +21,7 @@ You are an expert academic catalog analyst building NuAnalytics-compatible degre 3. Extract metadata (institution, program, total credits, effective catalog year). 4. Map each requirement to the schema's requirement types — verify against `quick-reference.md`. 5. Build the catalog (course list) with prerequisites; trace chains the user mentioned. -6. Write the YAML to `degrees/-.yaml`. +6. Write to `degrees/-.yaml` (or `.unified.json` if the user requests the combined format). 7. Validate via the `validate_degree` MCP tool. Fix every error before declaring done. 8. Audit via `audit_degree`. Address warnings the user cares about. 9. *(Optional)* If the user wants a simplified single-path view of the YAML diff --git a/src/assets/init/skills/degree-fetch/SKILL.md b/src/assets/init/skills/degree-fetch/SKILL.md new file mode 100644 index 0000000..3ff99a3 --- /dev/null +++ b/src/assets/init/skills/degree-fetch/SKILL.md @@ -0,0 +1,31 @@ +--- +name: degree-fetch +description: Fetch and build a NuAnalytics degree file from the database with dual-build verification. Use when the user wants to pull a degree from the database and save it as a *.unified.json or *.yaml file. +--- + +# Degree Fetch + +Fetch a degree from the database with dual-build verification to catch transcription errors. + +## Workflow + +1. Ask the user for the desired output format: `*.unified.json` or `*.yaml`. Default to `*.yaml` if unspecified. +2. Identify the degree via `search_degrees`. Confirm the match with the user before proceeding. +3. **Dual build** — execute two independent `scaffold_degree_yaml` calls in parallel, writing each result to a scratch file (`degrees/_fetch_a.` and `degrees/_fetch_b.`). +4. **Compare** — diff the two outputs: + - If identical: proceed to step 5. + - If discrepancies exist: apply majority-rules reconciliation — values that agree between both builds are accepted automatically; any field where the two builds differ must be surfaced to the user for confirmation before the final file is written. +5. Write the reconciled result to `degrees/-.`. +6. Delete both scratch files. +7. Validate the final file via `validate_degree`. Fix any errors before declaring done. + +## Format notes + +- **`*.unified.json`** — use when downstream tools expect the combined JSON format. +- **`*.yaml`** — use for human-readable, hand-editable output. + +## Critical rules + +- Never skip dual-build verification, even for small degree files. +- Do not auto-resolve discrepancies silently — always surface them to the user. +- Always delete scratch files after reconciliation, whether it succeeded or was aborted. diff --git a/src/assets/init/skills/degree-review/SKILL.md b/src/assets/init/skills/degree-review/SKILL.md index ca4014c..caabb4a 100644 --- a/src/assets/init/skills/degree-review/SKILL.md +++ b/src/assets/init/skills/degree-review/SKILL.md @@ -9,7 +9,7 @@ Validate and critique an existing degree YAML. ## Workflow -1. Read the target YAML file. +1. Read the target file (`degrees/*.unified.json` or `degrees/*.yaml`). 2. Call the `validate_degree` MCP tool. Report every error. 3. Call the `audit_degree` MCP tool. Group warnings by category (prerequisite chains, cross-listings, credit totals, structural). 4. Cross-check against the source catalog if the user provided one. diff --git a/src/assets/init/skills/degree-update/SKILL.md b/src/assets/init/skills/degree-update/SKILL.md new file mode 100644 index 0000000..9d384fa --- /dev/null +++ b/src/assets/init/skills/degree-update/SKILL.md @@ -0,0 +1,29 @@ +--- +name: degree-update +description: Update or revise an existing NuAnalytics degree YAML or unified JSON file. Use when the user wants to modify requirements, add or remove courses, fix errors, or refresh a degree file against a new catalog version. +--- + +# Degree Update + +Revise an existing NuAnalytics degree file — supports both `*.unified.json` and `*.yaml` formats. + +## Workflow + +1. Identify the target file (`degrees/*.unified.json` or `degrees/*.yaml`). If ambiguous, list candidates. +2. Read the current file content. +3. Validate the current state via `validate_degree`. Note all existing errors before editing. +4. Apply the user's requested changes (requirement edits, course additions/removals, metadata corrections). +5. Re-validate via `validate_degree`. All errors must be resolved before declaring done. +6. Run `audit_degree` and surface any new warnings introduced by the edits. + +## Format notes + +- **`*.yaml`** — human-authored YAML; prefer for hand-edited work. +- **`*.unified.json`** — machine-generated combined format; update by editing the source YAML and regenerating, or edit the JSON directly if that is the canonical source. +- Never silently convert between formats — preserve whichever format the file already uses unless the user explicitly requests a conversion. + +## Critical rules + +- Verify every changed course number against the source catalog. +- Show credit arithmetic for any section whose totals change. +- Do not remove requirements unless the user explicitly confirms the deletion. diff --git a/src/assets/init/skills/plan-analyze/SKILL.md b/src/assets/init/skills/plan-analyze/SKILL.md index a0d8493..2014027 100644 --- a/src/assets/init/skills/plan-analyze/SKILL.md +++ b/src/assets/init/skills/plan-analyze/SKILL.md @@ -9,7 +9,7 @@ Run NuAnalytics on a curriculum file and surface the results. ## Tool selection -- **Degree YAML** (`degrees/*.yaml`) → use the `analyze_degree` MCP tool. If the user wants only validation, use `validate_degree`; for structural issues only, `audit_degree`. +- **Degree file** (`degrees/*.unified.json` or `degrees/*.yaml`) → use the `analyze_degree` MCP tool. If the user wants only validation, use `validate_degree`; for structural issues only, `audit_degree`. - **Curriculum CSV plan** (`plans/*.csv`) → run `nuanalytics planner ` from the shell. Use `--no-report` for CSV-only metrics or `--no-csv` for HTML/MD/PDF reports only. ## Workflow diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index 3885de3..f4086d7 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -22,11 +22,14 @@ const BIN_NAME: &str = "nuanalytics"; const MCP_SUBCOMMAND: &str = "mcp"; const SETTINGS_TEMPLATE: &str = include_str!("../../assets/init/settings.json.tmpl"); +const MCP_JSON_TEMPLATE: &str = include_str!("../../assets/init/mcp.json.tmpl"); const NUANALYTICS_TOML: &str = include_str!("../../assets/init/nuanalytics.toml"); const PROJECT_README: &str = include_str!("../../assets/init/README.md"); const SKILL_DEGREE_AUTHOR: &str = include_str!("../../assets/init/skills/degree-author/SKILL.md"); const SKILL_DEGREE_REVIEW: &str = include_str!("../../assets/init/skills/degree-review/SKILL.md"); +const SKILL_DEGREE_UPDATE: &str = include_str!("../../assets/init/skills/degree-update/SKILL.md"); +const SKILL_DEGREE_FETCH: &str = include_str!("../../assets/init/skills/degree-fetch/SKILL.md"); const SKILL_PLAN_ANALYZE: &str = include_str!("../../assets/init/skills/plan-analyze/SKILL.md"); // Schema reference is shared with the MCP server's `get_degree_schema` tool — @@ -54,14 +57,19 @@ const STATIC_FILES: &[(&str, &str)] = &[ REF_EXAMPLE, ), (".claude/skills/degree-review/SKILL.md", SKILL_DEGREE_REVIEW), + (".claude/skills/degree-update/SKILL.md", SKILL_DEGREE_UPDATE), + (".claude/skills/degree-fetch/SKILL.md", SKILL_DEGREE_FETCH), (".claude/skills/plan-analyze/SKILL.md", SKILL_PLAN_ANALYZE), ("degrees/.gitkeep", ""), ("plans/.gitkeep", ""), ]; -/// Path (relative to the target directory) of the templated MCP config. +/// Path (relative to the target directory) of the templated Claude Code MCP config. const SETTINGS_REL: &str = ".claude/settings.json"; +/// Path (relative to the target directory) of the project-root MCP config. +const MCP_JSON_REL: &str = ".mcp.json"; + /// Scaffold a `NuAnalytics` research project at `dir`. /// /// # Errors @@ -74,8 +82,10 @@ pub fn run(dir: &Path, force: bool) -> Result<(), Box> { fs::create_dir_all(dir)?; let (mcp_command, mcp_args) = detect_mcp_command(); - let settings_json = render_settings(&mcp_command, &mcp_args)?; + let settings_json = render_mcp_config(SETTINGS_TEMPLATE, &mcp_command, &mcp_args)?; + let mcp_json = render_mcp_config(MCP_JSON_TEMPLATE, &mcp_command, &mcp_args)?; let settings_path = dir.join(SETTINGS_REL); + let mcp_json_path = dir.join(MCP_JSON_REL); if !force { let mut conflicts: Vec = STATIC_FILES @@ -86,6 +96,9 @@ pub fn run(dir: &Path, force: bool) -> Result<(), Box> { if settings_path.exists() { conflicts.push(settings_path.clone()); } + if mcp_json_path.exists() { + conflicts.push(mcp_json_path.clone()); + } if !conflicts.is_empty() { let mut msg = String::from("the following files already exist (use --force to overwrite):\n"); @@ -100,6 +113,7 @@ pub fn run(dir: &Path, force: bool) -> Result<(), Box> { write_file(&dir.join(rel), content.as_bytes())?; } write_file(&settings_path, settings_json.as_bytes())?; + write_file(&mcp_json_path, mcp_json.as_bytes())?; println!( "\n✓ scaffolded NuAnalytics research project at {}", @@ -121,13 +135,12 @@ fn write_file(path: &Path, content: &[u8]) -> Result<(), Box Result { +/// Render an MCP config template by substituting the command and args. +/// Values are JSON-encoded so quoting and escaping are handled correctly. +fn render_mcp_config(template: &str, command: &str, args: &[String]) -> Result { let command_json = serde_json::to_string(command)?; let args_json = serde_json::to_string(args)?; - Ok(SETTINGS_TEMPLATE + Ok(template .replace("{{MCP_COMMAND}}", &command_json) .replace("{{MCP_ARGS}}", &args_json)) } @@ -170,6 +183,7 @@ mod tests { const EXPECTED_FILES: &[&str] = &[ "nuanalytics.toml", "README.md", + ".mcp.json", ".claude/settings.json", ".claude/skills/degree-author/SKILL.md", ".claude/skills/degree-author/schema-v5.2.yaml", @@ -177,6 +191,8 @@ mod tests { ".claude/skills/degree-author/quick-reference.md", ".claude/skills/degree-author/example-bscs-general.yaml", ".claude/skills/degree-review/SKILL.md", + ".claude/skills/degree-update/SKILL.md", + ".claude/skills/degree-fetch/SKILL.md", ".claude/skills/plan-analyze/SKILL.md", "degrees/.gitkeep", "plans/.gitkeep", @@ -223,17 +239,17 @@ mod tests { let tmp = TempDir::new().expect("tempdir"); let target = tmp.path().join("proj"); fs::create_dir_all(target.join(".claude")).expect("mkdir"); - fs::write(target.join(".claude/settings.json"), "pre-existing\n").expect("seed"); + fs::write(target.join(".mcp.json"), "pre-existing\n").expect("seed"); let err = run(&target, false).expect_err("must refuse to overwrite without --force"); let msg = err.to_string(); assert!(msg.contains("already exist"), "unexpected error: {msg}"); assert!( - msg.contains("settings.json"), + msg.contains(".mcp.json"), "should name the conflict: {msg}" ); - let body = fs::read_to_string(target.join(".claude/settings.json")).expect("read"); + let body = fs::read_to_string(target.join(".mcp.json")).expect("read"); assert_eq!(body, "pre-existing\n"); } @@ -265,13 +281,14 @@ mod tests { } #[test] - fn render_settings_escapes_quotes_and_backslashes_in_command() { + fn render_mcp_config_escapes_quotes_and_backslashes_in_command() { // A Windows-style path with backslashes plus a literal double quote - // would corrupt the JSON if `render_settings` used naive interpolation + // would corrupt the JSON if `render_mcp_config` used naive interpolation // instead of `serde_json::to_string`. let weird = r#"C:\Program Files\Nu"Analytics\nuanalytics.exe"#; let rendered = - render_settings(weird, &[MCP_SUBCOMMAND.to_string()]).expect("render_settings"); + render_mcp_config(SETTINGS_TEMPLATE, weird, &[MCP_SUBCOMMAND.to_string()]) + .expect("render_mcp_config"); let v: Value = serde_json::from_str(&rendered) .expect("rendered settings must be valid JSON even for odd command paths"); @@ -282,6 +299,34 @@ mod tests { ); } + #[test] + fn mcp_json_is_valid_with_stdio_type() { + let tmp = TempDir::new().expect("tempdir"); + let target = tmp.path().join("proj"); + + run(&target, false).expect("init succeeds"); + + let body = fs::read_to_string(target.join(".mcp.json")).expect("read .mcp.json"); + let v: Value = serde_json::from_str(&body).expect(".mcp.json parses as JSON"); + + let server = &v["mcpServers"]["nuanalytics"]; + assert_eq!( + server.get("type").and_then(Value::as_str), + Some("stdio"), + "mcpServers.nuanalytics.type must be \"stdio\"; got {server}" + ); + assert!( + server.get("command").and_then(Value::as_str).is_some(), + "mcpServers.nuanalytics.command must be a string; got {server}" + ); + let args = server + .get("args") + .and_then(Value::as_array) + .expect("args array"); + assert_eq!(args.len(), 1); + assert_eq!(args[0].as_str(), Some("mcp")); + } + #[test] fn generated_nuanalytics_toml_parses_as_config() { use nu_analytics::config::Config; From b4613b625730d760bedc3be30c78f5a959bec8a2 Mon Sep 17 00:00:00 2001 From: Hasnain Sikora Date: Tue, 16 Jun 2026 15:13:07 -0400 Subject: [PATCH 2/2] chore: bump version to 0.5.2 Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8172d4..41abdc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,7 +1369,7 @@ dependencies = [ [[package]] name = "nu-analytics" -version = "0.5.1" +version = "0.5.2" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index e63a244..30c0949 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nu-analytics" -version = "0.5.1" +version = "0.5.2" edition = "2021" authors = ["Albert Lionelle "] description = "A CLI tool for computing Curricular Analytics metrics. Based off metrics from CurricularAnalytics.org. Currently, only basic metrics and additional reporting features."