Skip to content

Commit f913da1

Browse files
authored
feat(runtimed): project-aware dependency management — pixi add / uv add (#1650)
* feat(runtimed): project-aware dependency management — pixi add / uv add When a notebook lives inside a project (pixi.toml or pyproject.toml), dependency management now targets the project file directly instead of storing deps as inline notebook metadata. Key changes: - Auto-detection priority flipped: project file > inline deps > prewarmed. When pixi.toml exists, the daemon always uses pixi:toml (not pixi:inline). - Bootstrap: on notebook open, the daemon populates CRDT metadata from the project file so UI and MCP tools see what deps are available. - Promotion: when add_dependency writes to CRDT and the kernel is project-backed, the daemon promotes the dep to the project file via `pixi add` or `uv add`, then re-bootstraps CRDT from the updated file. Removals trigger `pixi remove` / `uv remove`. - handle_sync_environment: for project-backed envs, promotes deps and signals needs_restart (project envs can't hot-install). - LaunchKernel: promotes pending inline deps before launching project-backed kernels. - pixi:toml shell-hook: now sets current_dir on the command (previously only the pixi run fallback did this). - create_notebook: skips ensure_package_manager_metadata when a matching project file exists, avoiding needless restart. - get_dependencies: includes mode ("project" or "inline") in response. - LaunchedEnvConfig: added pyproject_path field (mirrors pixi_toml_path). Fixes #1644, #1645 * fix(runtimed): address code review — scope pyproject parser, partial failure handling - extract_pyproject_deps: track [project] table state so deps from [tool.*] tables are not captured - build_launched_config: store venv_path/python_path for uv:pyproject (was missing, unlike all other Python env sources) - promote_inline_deps_to_project: use best-effort pattern — collect errors and continue adding remaining deps. Always re-bootstrap CRDT from project file even on partial failure so CRDT reflects reality.
1 parent 44934fe commit f913da1

File tree

23 files changed

+697
-76
lines changed

23 files changed

+697
-76
lines changed

crates/notebook-doc/src/metadata.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ impl NotebookMetadataSnapshot {
498498

499499
// ── Pixi dependency operations ──────────────────────────────────
500500

501-
fn pixi_section_or_default(&mut self) -> &mut PixiInlineMetadata {
501+
pub fn pixi_section_or_default(&mut self) -> &mut PixiInlineMetadata {
502502
self.runt.pixi.get_or_insert_with(|| PixiInlineMetadata {
503503
dependencies: Vec::new(),
504504
pypi_dependencies: Vec::new(),

crates/notebook-protocol/src/protocol.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ pub struct LaunchedEnvConfig {
3838
#[serde(skip_serializing_if = "Option::is_none")]
3939
pub pixi_toml_path: Option<PathBuf>,
4040

41+
/// Path to pyproject.toml (uv:pyproject only).
42+
#[serde(skip_serializing_if = "Option::is_none")]
43+
pub pyproject_path: Option<PathBuf>,
44+
4145
/// Deno config (if kernel_type is "deno")
4246
#[serde(skip_serializing_if = "Option::is_none")]
4347
pub deno_config: Option<DenoLaunchedConfig>,

crates/runt-mcp/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub mod execution;
2020
pub mod formatting;
2121
pub mod health;
2222
pub mod presence;
23+
pub mod project_file;
2324
mod resources;
2425
mod session;
2526
mod structured;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//! Minimal project file detection for MCP tools.
2+
//!
3+
//! Walks up from a start path looking for `pyproject.toml` or `pixi.toml`.
4+
//! Used to determine whether dep management should target a project file
5+
//! (via `pixi add` / `uv add`) or notebook inline metadata.
6+
7+
use std::path::{Path, PathBuf};
8+
9+
/// The type of project file detected.
10+
#[derive(Debug, Clone, PartialEq, Eq)]
11+
pub enum ProjectFileKind {
12+
PyprojectToml,
13+
PixiToml,
14+
}
15+
16+
/// A detected project file with its path and kind.
17+
#[derive(Debug, Clone)]
18+
pub struct DetectedProjectFile {
19+
pub path: PathBuf,
20+
pub kind: ProjectFileKind,
21+
}
22+
23+
impl DetectedProjectFile {
24+
/// The package manager name (for `detect_package_manager` compatibility).
25+
pub fn manager(&self) -> &'static str {
26+
match self.kind {
27+
ProjectFileKind::PyprojectToml => "uv",
28+
ProjectFileKind::PixiToml => "pixi",
29+
}
30+
}
31+
32+
/// The daemon env_source string for this project type.
33+
pub fn env_source(&self) -> &'static str {
34+
match self.kind {
35+
ProjectFileKind::PyprojectToml => "uv:pyproject",
36+
ProjectFileKind::PixiToml => "pixi:toml",
37+
}
38+
}
39+
}
40+
41+
/// Detect the nearest project file by walking up from `start_path`.
42+
///
43+
/// Checks each directory for `pyproject.toml` and `pixi.toml`.
44+
/// A `pyproject.toml` with `[tool.pixi]` is treated as a pixi project.
45+
/// Stops at `.git` boundaries or the user's home directory.
46+
pub fn detect_project_file(start_path: &Path) -> Option<DetectedProjectFile> {
47+
let start_dir = if start_path.is_file() {
48+
start_path.parent()?
49+
} else {
50+
start_path
51+
};
52+
53+
let home_dir = dirs::home_dir();
54+
let mut current = start_dir.to_path_buf();
55+
56+
loop {
57+
// Check pyproject.toml first (higher priority in tiebreaker)
58+
let pyproject = current.join("pyproject.toml");
59+
if pyproject.exists() {
60+
// If it has [tool.pixi], treat as pixi project
61+
if has_pixi_section(&pyproject) {
62+
return Some(DetectedProjectFile {
63+
path: pyproject,
64+
kind: ProjectFileKind::PixiToml,
65+
});
66+
}
67+
return Some(DetectedProjectFile {
68+
path: pyproject,
69+
kind: ProjectFileKind::PyprojectToml,
70+
});
71+
}
72+
73+
// Check pixi.toml
74+
let pixi = current.join("pixi.toml");
75+
if pixi.exists() {
76+
return Some(DetectedProjectFile {
77+
path: pixi,
78+
kind: ProjectFileKind::PixiToml,
79+
});
80+
}
81+
82+
// Stop at home directory or git repo root
83+
if let Some(ref home) = home_dir {
84+
if current == *home {
85+
return None;
86+
}
87+
}
88+
if current.join(".git").exists() {
89+
return None;
90+
}
91+
92+
match current.parent() {
93+
Some(parent) if parent != current => {
94+
current = parent.to_path_buf();
95+
}
96+
_ => return None,
97+
}
98+
}
99+
}
100+
101+
/// Check if a pyproject.toml contains a `[tool.pixi]` section.
102+
fn has_pixi_section(path: &Path) -> bool {
103+
std::fs::read_to_string(path)
104+
.ok()
105+
.is_some_and(|content| content.contains("[tool.pixi]") || content.contains("[tool.pixi."))
106+
}

crates/runt-mcp/src/tools/deps.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,20 @@ pub async fn get_dependencies(
393393
.map(|s| s.env.prewarmed_packages)
394394
.unwrap_or_default();
395395

396+
// Indicate whether deps come from a project file or notebook metadata
397+
let env_source = handle
398+
.get_runtime_state()
399+
.ok()
400+
.map(|s| s.kernel.env_source.clone());
401+
let mode = match env_source.as_deref() {
402+
Some("pixi:toml") | Some("uv:pyproject") => "project",
403+
_ => "inline",
404+
};
405+
396406
let mut result = serde_json::json!({
397407
"dependencies": deps,
398408
"package_manager": manager,
409+
"mode": mode,
399410
});
400411
if !prewarmed.is_empty() {
401412
result["available_packages"] = serde_json::json!(prewarmed);

crates/runt-mcp/src/tools/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ pub fn all_tools() -> Vec<Tool> {
259259
// -- Dependencies --
260260
Tool::new(
261261
"add_dependency",
262-
"Add a package dependency (e.g. 'pandas>=2.0'). Auto-detects package manager (uv, conda, or pixi). Use after='sync' to hot-install (UV only) or after='restart' to restart kernel with new deps.",
262+
"Add a package dependency (e.g. 'pandas>=2.0'). When a project file (pixi.toml, pyproject.toml) exists, promotes the dependency to the project file via 'pixi add' or 'uv add'. Otherwise stores in notebook metadata. Use after='sync' or after='restart' to apply.",
263263
schema_for::<deps::AddDependencyParams>(),
264264
)
265265
.annotate(
@@ -270,7 +270,7 @@ pub fn all_tools() -> Vec<Tool> {
270270
),
271271
Tool::new(
272272
"remove_dependency",
273-
"Remove a package dependency. Auto-detects package manager. Requires restart_kernel() to take effect.",
273+
"Remove a package dependency. When a project file exists, runs 'pixi remove' or 'uv remove'. Otherwise removes from notebook metadata. Requires restart_kernel() to take effect.",
274274
schema_for::<deps::RemoveDependencyParams>(),
275275
)
276276
.annotate(

crates/runt-mcp/src/tools/session.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ pub async fn create_notebook(
372372
) -> Result<CallToolResult, McpError> {
373373
let runtime = arg_str(request, "runtime").unwrap_or("python");
374374
let working_dir = arg_str(request, "working_dir").map(|s| PathBuf::from(resolve_path(s)));
375+
let working_dir_for_detection = working_dir.clone();
375376

376377
let prev = previous_notebook_id(server).await;
377378

@@ -421,9 +422,17 @@ pub async fn create_notebook(
421422
// Only override metadata when the user explicitly requested a
422423
// package manager. When omitted, the daemon already set the
423424
// correct metadata from default_python_env.
425+
// Skip when a matching project file exists — the daemon already
426+
// detected it and will bootstrap deps into CRDT.
424427
if let Some(pm) = explicit_pkg_manager {
425-
metadata_changed =
426-
super::deps::ensure_package_manager_metadata(&result.handle, pm);
428+
let project_matches = working_dir_for_detection
429+
.as_ref()
430+
.and_then(|wd| crate::project_file::detect_project_file(wd))
431+
.is_some_and(|d| d.manager() == pm);
432+
if !project_matches {
433+
metadata_changed =
434+
super::deps::ensure_package_manager_metadata(&result.handle, pm);
435+
}
427436
}
428437
}
429438

crates/runtimed/src/jupyter_kernel.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ impl KernelConnection for JupyterKernel {
266266
);
267267
let mut cmd = tokio::process::Command::new(&python);
268268
cmd.envs(&env_vars);
269+
if let Some(parent) = manifest.parent() {
270+
cmd.current_dir(parent);
271+
}
269272
cmd.args([
270273
"-Xfrozen_modules=off",
271274
"-m",

0 commit comments

Comments
 (0)