Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "nu-analytics"
version = "0.5.1"
version = "0.5.2"
edition = "2021"
authors = ["Albert Lionelle <a.lionelle@northeastern.edu>"]
description = "A CLI tool for computing Curricular Analytics metrics. Based off metrics from CurricularAnalytics.org. Currently, only basic metrics and additional reporting features."
Expand Down
26 changes: 15 additions & 11 deletions src/assets/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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.
9 changes: 9 additions & 0 deletions src/assets/init/mcp.json.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"mcpServers": {
"nuanalytics": {
"type": "stdio",
"command": {{MCP_COMMAND}},
"args": {{MCP_ARGS}}
}
}
}
2 changes: 1 addition & 1 deletion src/assets/init/skills/degree-author/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<institution>-<program>.yaml`.
6. Write to `degrees/<institution>-<program>.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
Expand Down
31 changes: 31 additions & 0 deletions src/assets/init/skills/degree-fetch/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.<ext>` and `degrees/_fetch_b.<ext>`).
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/<institution>-<program>.<ext>`.
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.
2 changes: 1 addition & 1 deletion src/assets/init/skills/degree-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions src/assets/init/skills/degree-update/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src/assets/init/skills/plan-analyze/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` from the shell. Use `--no-report` for CSV-only metrics or `--no-csv` for HTML/MD/PDF reports only.

## Workflow
Expand Down
71 changes: 58 additions & 13 deletions src/cli/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand All @@ -74,8 +82,10 @@ pub fn run(dir: &Path, force: bool) -> Result<(), Box<dyn std::error::Error>> {
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<PathBuf> = STATIC_FILES
Expand All @@ -86,6 +96,9 @@ pub fn run(dir: &Path, force: bool) -> Result<(), Box<dyn std::error::Error>> {
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");
Expand All @@ -100,6 +113,7 @@ pub fn run(dir: &Path, force: bool) -> Result<(), Box<dyn std::error::Error>> {
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 {}",
Expand All @@ -121,13 +135,12 @@ fn write_file(path: &Path, content: &[u8]) -> Result<(), Box<dyn std::error::Err
Ok(())
}

/// Render the `.claude/settings.json` body by substituting the MCP command
/// and args into the embedded template. Values are JSON-encoded so quoting
/// and escaping are handled correctly.
fn render_settings(command: &str, args: &[String]) -> Result<String, serde_json::Error> {
/// 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<String, serde_json::Error> {
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))
}
Expand Down Expand Up @@ -170,13 +183,16 @@ 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",
".claude/skills/degree-author/generation-guide.md",
".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",
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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");
Expand All @@ -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;
Expand Down
Loading