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." 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;