Skip to content
Closed
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
136 changes: 126 additions & 10 deletions crates/konnect-core/src/tools/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//!
//! KiCAD interface:
//! - create_project → file system (template)
//! - open_project → IPC ping (check if project is open)
//! - open_project → IPC open-document query
//! - save_project → IPC board.save()
//! - get_project_info → file system read
//! - snapshot_project → kicad-cli export PDF
Expand Down Expand Up @@ -41,14 +41,14 @@ pub fn tools() -> Vec<ToolDef> {
),
tool!(
"open_project",
"Check whether a KiCAD project is currently open in the running KiCAD UI. \
Returns the active project path and whether KiCAD IPC is available.",
"List PCB documents open in the running KiCad UI and optionally check whether \
one specific KiCad project or board is open over IPC.",
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Optional: path to .kicad_pro file to check"
"description": "Optional path to a .kicad_pro project or .kicad_pcb board to check"
}
},
"required": []
Expand Down Expand Up @@ -250,21 +250,113 @@ async fn handle_create_project(
})))
}

fn requested_board_path(
args: &serde_json::Value,
) -> Result<Option<(String, PathBuf)>, CallToolResult> {
let Some(value) = args.get("path").filter(|value| !value.is_null()) else {
return Ok(None);
};
let Some(raw) = value.as_str().filter(|path| !path.trim().is_empty()) else {
return Err(CallToolResult::error_kind(
ToolErrorKind::InvalidArgument {
field: "path".to_string(),
reason: "must be a non-empty .kicad_pro or .kicad_pcb path".to_string(),
},
"Argument 'path' must be a non-empty .kicad_pro or .kicad_pcb path",
));
};
let requested = PathBuf::from(raw);
let extension = requested
.extension()
.and_then(|extension| extension.to_str());
let board = match extension {
Some(extension) if extension.eq_ignore_ascii_case("kicad_pro") => {
requested.with_extension("kicad_pcb")
}
Some(extension) if extension.eq_ignore_ascii_case("kicad_pcb") => requested.clone(),
_ => {
return Err(CallToolResult::error_kind(
ToolErrorKind::InvalidArgument {
field: "path".to_string(),
reason: "must end in .kicad_pro or .kicad_pcb".to_string(),
},
"Argument 'path' must end in .kicad_pro or .kicad_pcb",
));
}
};
Ok(Some((raw.to_string(), board)))
}

async fn handle_open_project(
_args: &serde_json::Value,
args: &serde_json::Value,
ctx: &ToolContext,
) -> anyhow::Result<CallToolResult> {
let requested = match requested_board_path(args) {
Ok(requested) => requested,
Err(error) => return Ok(error),
};
let ipc = konnect_ipc::KiCadIpcClient::new(&ctx.config.ipc_address);
let connected = ipc.ping().unwrap_or(false);
let (open_boards, open_boards_error) = if connected {
match ipc.get_open_board_paths() {
Ok(paths) => (
paths
.into_iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
None,
),
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
}
} else {
(Vec::new(), None)
};
let (requested_path, requested_board, requested_open, requested_check_error) = match requested {
Some((project_or_board, board)) if connected => match ipc.find_open_board(&board) {
Ok(_) => (
Some(project_or_board),
Some(board.display().to_string()),
Some(true),
None,
),
Err(error) => (
Some(project_or_board),
Some(board.display().to_string()),
Some(false),
Some(format!("{error:#}")),
),
},
Some((project_or_board, board)) => (
Some(project_or_board),
Some(board.display().to_string()),
None,
None,
),
None => (None, None, None, None),
};

let message = if !connected {
"KiCad IPC is not reachable. Start KiCad and enable the IPC API, or work in file-only mode."
} else if requested_open == Some(true) {
"The requested board is open in KiCad."
} else if requested_open == Some(false) {
"KiCad IPC is available, but the requested board is not open."
} else {
"KiCad IPC is available; open PCB documents are listed in open_boards."
};

Ok(CallToolResult::json(&json!({
"kicad_ui_running": connected,
"ipc_available": connected,
"ipc_address": ctx.config.ipc_address,
"message": if connected {
"KiCAD is running and IPC is available."
} else {
"KiCAD IPC is not reachable. Start KiCAD and enable the IPC API, or work in file-only mode."
}
"open_board_count": open_boards.len(),
"open_boards": open_boards,
"open_boards_error": open_boards_error,
"requested_path": requested_path,
"requested_board": requested_board,
"requested_open": requested_open,
"requested_check_error": requested_check_error,
"message": message
})))
}

Expand Down Expand Up @@ -541,6 +633,30 @@ mod tests {
assert!(content.contains("\"Edge.Cuts\""));
}

#[test]
fn open_project_maps_a_project_file_to_its_board() {
let requested = requested_board_path(&json!({ "path": "/work/voice.kicad_pro" }))
.unwrap()
.unwrap();
assert_eq!(requested.0, "/work/voice.kicad_pro");
assert_eq!(requested.1, PathBuf::from("/work/voice.kicad_pcb"));

let board = requested_board_path(&json!({ "path": "/work/voice.kicad_pcb" }))
.unwrap()
.unwrap();
assert_eq!(board.1, PathBuf::from("/work/voice.kicad_pcb"));
}

#[test]
fn open_project_rejects_a_path_it_cannot_check_over_pcb_ipc() {
let error = requested_board_path(&json!({ "path": "/work/voice.kicad_sch" }))
.expect_err("schematic documents are not exposed by this IPC query");
assert_eq!(
extract_error_kind(&error).as_deref(),
Some("invalid_argument")
);
}

// ─── handle_create_project ─────────────────────────────────────────────

#[tokio::test]
Expand Down
10 changes: 10 additions & 0 deletions crates/konnect-ipc/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,16 @@ impl KiCadIpcClient {
}
}

/// Resolve the filenames of every open PCB document, including relative
/// document identifiers whose project specifier supplies the directory.
pub fn get_open_board_paths(&self) -> Result<Vec<PathBuf>> {
Ok(self
.get_open_documents()?
.iter()
.filter_map(board_document_path)
.collect())
}

/// Get the first open PCB's DocumentSpecifier (needed for most commands).
fn get_board_document(&self) -> Result<kiapi::common::types::DocumentSpecifier> {
let docs = self.get_open_documents()?;
Expand Down
2 changes: 1 addition & 1 deletion tool-directory.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Six tools, grouped into *discovery/routing* and *observability*.
| Tool | Description |
|------|-------------|
| `create_project` | Create a new KiCAD project at the given path. Creates the directory, a blank `.kicad_pro`, empty `.kicad_sch`, and blank `.kicad_pcb`; refuses to replace any existing project file. |
| `open_project` | Check whether a KiCAD project is currently open in the running KiCAD UI. Returns the active project path and whether KiCAD IPC is available. |
| `open_project` | List PCB documents open in the running KiCad UI and optionally check a specific `.kicad_pro` or `.kicad_pcb` path over IPC. |
| `save_project` | Save the currently open PCB board file via KiCAD IPC. Requires KiCAD to be running with IPC enabled. |
| `get_project_info` | Read project metadata from a `.kicad_pro` file. Returns name, schematic/PCB paths, last-modified times. |
| `rename_project` | Rename the `.kicad_pro`/`.kicad_sch`/`.kicad_pcb`/`.kicad_prl` files *and* the internal references that carry the old name. Renaming the files alone makes KiCad treat the design as unannotated, losing every reference designator, because each symbol instance stores `(project "name")`. Supports `dry_run`. |
Expand Down
Loading