Skip to content

Commit e97e403

Browse files
homanpcursoragent
andauthored
feat: add PyPI registry support (#19)
* feat: add PyPI registry support - Add PypiClient for fetching metadata and downloading packages from PyPI - Support both sdist (.tar.gz) and wheel (.whl) formats - Add Python-specific capability detection (requests, subprocess, os.environ, etc.) - Update CVE scanner to support PyPI ecosystem via OSV API - Add Python dependency file parsing (requirements.txt, pyproject.toml, Pipfile) - Update worker to dispatch scans based on registry type - Add PyPI metadata structs (PypiPackageMetadata, PypiReleaseInfo, PypiMaintainer) - Calculate trust score based on PyPI-specific signals (author, classifiers, etc.) Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add PyPI seed support and switch to Claude Sonnet - Add --registry flag to seed script (npm/pypi) - Add fetch_top_pypi_packages() for PyPI stats - Add PYPI_AI_PACKAGES list for Python AI ecosystem - Update fetch_cve_packages() to accept ecosystem param - Switch agentic scan model from kimi-k2.5 to claude-sonnet-4-5 - Bump version to v0.1.5 Co-authored-by: Cursor <cursoragent@cursor.com> * chore: update Rust version to 1.88 in Dockerfiles Required by time@0.3.46 which needs rustc 1.88.0 Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add PyPI auto-detection to CLI - Add project.rs with shared ProjectType/PackageManager detection - Auto-detect Python projects (requirements.txt, pyproject.toml, etc.) - Auto-detect package manager (pip, poetry, pipenv, uv) - Update add.rs to use project detection and pass registry to API - Handle Python version syntax (==, >=, etc.) - Refactor scan.rs to use shared project module Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e5c98ef commit e97e403

File tree

20 files changed

+2511
-131
lines changed

20 files changed

+2511
-131
lines changed

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ members = [
1111
]
1212

1313
[workspace.package]
14-
version = "0.1.4"
14+
version = "0.1.5"
1515
edition = "2021"
1616
authors = ["sus contributors"]
1717
license = "MIT"
@@ -56,6 +56,7 @@ url = { version = "2.5", features = ["serde"] }
5656
semver = { version = "1.0", features = ["serde"] }
5757
flate2 = "1.0"
5858
tar = "0.4"
59+
zip = "2.2"
5960
tempfile = "3.14"
6061
dotenvy = "0.15"
6162
async-trait = "0.1"

Dockerfile.api

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Stage 1: Chef setup
2-
FROM rust:1.85-alpine AS chef
2+
FROM rust:1.88-alpine AS chef
33
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
44
RUN cargo install cargo-chef
55
WORKDIR /app

Dockerfile.cve

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Stage 1: Chef setup
2-
FROM rust:1.85-alpine AS chef
2+
FROM rust:1.88-alpine AS chef
33
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
44
RUN cargo install cargo-chef
55
WORKDIR /app

Dockerfile.watcher

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Stage 1: Chef setup
2-
FROM rust:1.85-alpine AS chef
2+
FROM rust:1.88-alpine AS chef
33
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
44
RUN cargo install cargo-chef
55
WORKDIR /app

Dockerfile.worker

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Stage 1: Chef setup
2-
FROM rust:1.85-alpine AS chef
2+
FROM rust:1.88-alpine AS chef
33
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
44
RUN cargo install cargo-chef
55
WORKDIR /app

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ if you're building an agent that installs packages, sus is for you.
232232
## roadmap
233233

234234
- [x] npm support
235-
- [ ] pypi support
235+
- [x] pypi support
236236
- [ ] crates.io support
237237
- [ ] go modules support
238238
- [ ] private registry support

crates/cli/src/api_client.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
33
use anyhow::{Context, Result};
44
use common::{
5-
BulkLookupRequest, PackageResponse, PackageVersionPair, ScanRequest, ScanRequestResponse,
5+
BulkLookupRequest, PackageResponse, PackageVersionPair, Registry, ScanRequest,
6+
ScanRequestResponse,
67
};
78
use reqwest::Client;
89

@@ -70,18 +71,28 @@ impl SusClient {
7071
.context("Failed to parse API response")
7172
}
7273

73-
/// Request a scan for a package
74+
/// Request a scan for a package (defaults to npm registry)
7475
pub async fn request_scan(
7576
&self,
7677
name: &str,
7778
version: Option<&str>,
79+
) -> Result<ScanRequestResponse> {
80+
self.request_scan_with_registry(name, version, None).await
81+
}
82+
83+
/// Request a scan for a package with a specific registry
84+
pub async fn request_scan_with_registry(
85+
&self,
86+
name: &str,
87+
version: Option<&str>,
88+
registry: Option<Registry>,
7889
) -> Result<ScanRequestResponse> {
7990
let url = format!("{}/v1/scan", self.base_url);
8091

8192
let request = ScanRequest {
8293
name: name.to_string(),
8394
version: version.map(String::from),
84-
registry: None, // Default to npm
95+
registry,
8596
};
8697

8798
let response = self

crates/cli/src/commands/add.rs

Lines changed: 84 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,52 @@
33
use crate::agents_md;
44
use crate::api_client::SusClient;
55
use crate::config;
6+
use crate::project::{self, NpmPackageManager, ProjectType, PypiPackageManager};
67
use crate::ui::{self, print_capabilities, print_risk};
78
use anyhow::Result;
89
use colored::Colorize;
910
use common::RiskLevel;
1011
use dialoguer::Confirm;
1112
use std::process::Command;
1213

13-
/// Parse a package string into name and optional version
14-
fn parse_package_spec(spec: &str) -> (&str, Option<&str>) {
15-
// Handle scoped packages like @types/node@1.0.0
16-
if let Some(rest) = spec.strip_prefix('@') {
17-
// Find the second @ for version
18-
if let Some(idx) = rest.find('@') {
19-
let idx = idx + 1; // Adjust for the @ prefix
20-
return (&spec[..idx], Some(&spec[idx + 1..]));
21-
}
22-
return (spec, None);
23-
}
24-
25-
// Regular package like lodash@4.17.0
26-
if let Some(idx) = spec.find('@') {
27-
return (&spec[..idx], Some(&spec[idx + 1..]));
28-
}
29-
30-
(spec, None)
31-
}
32-
3314
/// Run the add command
3415
pub async fn run(
3516
client: &SusClient,
3617
packages: Vec<String>,
3718
yolo: bool,
3819
strict: bool,
3920
) -> Result<()> {
21+
// Detect project type
22+
let project_type = match project::detect_project_type() {
23+
Some(pt) => pt,
24+
None => {
25+
anyhow::bail!(
26+
"No supported project files found.\n\
27+
Supported files:\n\
28+
- npm: package.json, pnpm-lock.yaml, yarn.lock, bun.lockb\n\
29+
- python: requirements.txt, pyproject.toml, Pipfile, setup.py"
30+
);
31+
}
32+
};
33+
4034
// Check if AGENTS.md docs feature is enabled
4135
let agents_md_enabled = config::is_agents_md_enabled();
4236

4337
for package_spec in &packages {
44-
let (name, version) = parse_package_spec(package_spec);
45-
let display_name = if let Some(v) = version {
46-
format!("{}@{}", name, v)
38+
let (name, version) = project::parse_package_spec(package_spec, &project_type);
39+
let display_name = if let Some(ref v) = version {
40+
format_display_name(&name, v, &project_type)
4741
} else {
48-
name.to_string()
42+
name.clone()
4943
};
5044

5145
let pb = ui::spinner(&format!("checking {}...", display_name));
5246

5347
// Fetch assessment from API
54-
let assessment = match if let Some(v) = version {
55-
client.get_package_version(name, v).await
48+
let assessment = match if let Some(ref v) = version {
49+
client.get_package_version(&name, v).await
5650
} else {
57-
client.get_package(name).await
51+
client.get_package(&name).await
5852
} {
5953
Ok(a) => {
6054
ui::finish_spinner(&pb, a.risk_level.emoji(), &display_name);
@@ -68,7 +62,11 @@ pub async fn run(
6862
display_name.yellow()
6963
);
7064

71-
match client.request_scan(name, version).await {
65+
let registry = project_type.registry();
66+
match client
67+
.request_scan_with_registry(&name, version.as_deref(), Some(registry))
68+
.await
69+
{
7270
Ok(resp) => {
7371
println!(
7472
" scan queued (job {}), try again in ~{}s",
@@ -92,7 +90,7 @@ pub async fn run(
9290
}
9391

9492
// If yolo, proceed without assessment
95-
install_package(package_spec).await?;
93+
install_package(package_spec, &project_type)?;
9694
continue;
9795
} else {
9896
ui::finish_spinner(&pb, "❌", &display_name);
@@ -151,52 +149,78 @@ pub async fn run(
151149
}
152150

153151
// Install the package
154-
install_package(package_spec).await?;
152+
install_package(package_spec, &project_type)?;
155153
println!("{}", "📦 installed".green());
156154

157155
// Save docs and update AGENTS.md index if enabled
158156
if agents_md_enabled {
159157
if let Some(skill_md) = &assessment.skill_md {
160-
save_package_docs(name, skill_md);
158+
save_package_docs(&name, skill_md);
161159
}
162160
}
163161
}
164162

165163
Ok(())
166164
}
167165

168-
/// Install a package using npm/pnpm/yarn
169-
async fn install_package(package: &str) -> Result<()> {
170-
// Detect package manager
171-
let pm = detect_package_manager();
166+
/// Format display name based on project type
167+
fn format_display_name(name: &str, version: &str, project_type: &ProjectType) -> String {
168+
match project_type {
169+
ProjectType::Npm(_) => format!("{}@{}", name, version),
170+
ProjectType::Pypi(_) => format!("{}=={}", name, version),
171+
}
172+
}
172173

173-
let status = Command::new(&pm)
174-
.args(["add", package])
174+
/// Install a package using the appropriate package manager
175+
fn install_package(package: &str, project_type: &ProjectType) -> Result<()> {
176+
match project_type {
177+
ProjectType::Npm(pm) => install_npm_package(package, *pm),
178+
ProjectType::Pypi(pm) => install_pypi_package(package, *pm),
179+
}
180+
}
181+
182+
/// Install an npm package
183+
fn install_npm_package(package: &str, pm: NpmPackageManager) -> Result<()> {
184+
let cmd = pm.command();
185+
let install_cmd = pm.install_cmd();
186+
187+
let status = Command::new(cmd)
188+
.args([install_cmd, package])
175189
.status()
176-
.map_err(|e| anyhow::anyhow!("Failed to run {}: {}", pm, e))?;
190+
.map_err(|e| anyhow::anyhow!("Failed to run {}: {}", cmd, e))?;
177191

178192
if !status.success() {
179-
anyhow::bail!("{} add failed with exit code {:?}", pm, status.code());
193+
anyhow::bail!(
194+
"{} {} failed with exit code {:?}",
195+
cmd,
196+
install_cmd,
197+
status.code()
198+
);
180199
}
181200

182201
Ok(())
183202
}
184203

185-
/// Detect which package manager to use
186-
fn detect_package_manager() -> String {
187-
// Check for lockfiles in order of preference
188-
if std::path::Path::new("pnpm-lock.yaml").exists() {
189-
return "pnpm".to_string();
190-
}
191-
if std::path::Path::new("yarn.lock").exists() {
192-
return "yarn".to_string();
193-
}
194-
if std::path::Path::new("bun.lockb").exists() {
195-
return "bun".to_string();
204+
/// Install a PyPI package
205+
fn install_pypi_package(package: &str, pm: PypiPackageManager) -> Result<()> {
206+
let cmd = pm.command();
207+
let install_cmd = pm.install_cmd();
208+
209+
let status = Command::new(cmd)
210+
.args([install_cmd, package])
211+
.status()
212+
.map_err(|e| anyhow::anyhow!("Failed to run {}: {}", cmd, e))?;
213+
214+
if !status.success() {
215+
anyhow::bail!(
216+
"{} {} failed with exit code {:?}",
217+
cmd,
218+
install_cmd,
219+
status.code()
220+
);
196221
}
197222

198-
// Default to npm
199-
"npm".to_string()
223+
Ok(())
200224
}
201225

202226
/// Save package documentation to .sus-docs/ and update AGENTS.md index
@@ -226,16 +250,17 @@ mod tests {
226250
use super::*;
227251

228252
#[test]
229-
fn test_parse_package_spec() {
230-
assert_eq!(parse_package_spec("lodash"), ("lodash", None));
253+
fn test_format_display_name() {
254+
let npm = ProjectType::Npm(NpmPackageManager::Npm);
255+
let pypi = ProjectType::Pypi(PypiPackageManager::Pip);
256+
231257
assert_eq!(
232-
parse_package_spec("lodash@4.17.0"),
233-
("lodash", Some("4.17.0"))
258+
format_display_name("lodash", "4.17.0", &npm),
259+
"lodash@4.17.0"
234260
);
235-
assert_eq!(parse_package_spec("@types/node"), ("@types/node", None));
236261
assert_eq!(
237-
parse_package_spec("@types/node@18.0.0"),
238-
("@types/node", Some("18.0.0"))
262+
format_display_name("requests", "2.31.0", &pypi),
263+
"requests==2.31.0"
239264
);
240265
}
241266
}

0 commit comments

Comments
 (0)