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
80 changes: 76 additions & 4 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,60 @@ fn main() {
codegen_registry();
}

/// Generate a raw string literal that safely contains the given content.
/// Dynamically determines the minimum number of '#' needed.
fn raw_string_literal(s: &str) -> String {
// Find the longest sequence of '#' characters following a '"' in the string
let mut max_hashes = 0;
let mut current_hashes = 0;
let mut after_quote = false;

for c in s.chars() {
if after_quote {
if c == '#' {
current_hashes += 1;
max_hashes = max_hashes.max(current_hashes);
} else {
after_quote = false;
current_hashes = 0;
}
}
if c == '"' {
after_quote = true;
current_hashes = 0;
}
}

// Use one more '#' than the longest sequence found
let hashes = "#".repeat(max_hashes + 1);
format!("r{hashes}\"{s}\"{hashes}")
}

/// Parse options from a TOML value into a Vec of (key, value) pairs
fn parse_options(opts: Option<&toml::Value>) -> Vec<(String, String)> {
opts.map(|opts| {
if let Some(table) = opts.as_table() {
table
.iter()
.map(|(k, v)| {
let value = match v {
toml::Value::String(s) => s.clone(),
toml::Value::Table(t) => {
// Serialize nested tables back to TOML string
toml::to_string(t).unwrap_or_default()
}
_ => v.to_string(),
};
(k.clone(), value)
})
.collect::<Vec<_>>()
} else {
vec![]
}
})
.unwrap_or_default()
}

fn codegen_registry() {
let out_dir = env::var_os("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("registry.rs");
Expand Down Expand Up @@ -53,6 +107,7 @@ fn codegen_registry() {
r##"RegistryBackend{{
full: r#"{backend}"#,
platforms: &[],
options: &[],
}}"##
));
}
Expand All @@ -68,15 +123,26 @@ fn codegen_registry() {
.collect::<Vec<_>>()
})
.unwrap_or_default();
let backend_options = parse_options(backend.get("options"));
backends.push(format!(
r##"RegistryBackend{{
full: r#"{full}"#,
platforms: &[{platforms}],
options: &[{options}],
}}"##,
platforms = platforms
.into_iter()
.map(|p| format!("\"{p}\""))
.collect::<Vec<_>>()
.join(", "),
options = backend_options
.iter()
.map(|(k, v)| format!(
"({}, {})",
raw_string_literal(k),
raw_string_literal(v)
))
.collect::<Vec<_>>()
.join(", ")
));
}
Expand Down Expand Up @@ -124,7 +190,7 @@ fn codegen_registry() {
let rt = format!(
r#"RegistryTool{{short: "{short}", description: {description}, backends: &[{backends}], aliases: &[{aliases}], test: &{test}, os: &[{os}], depends: &[{depends}], idiomatic_files: &[{idiomatic_files}]}}"#,
description = description
.map(|d| format!("Some(r###\"{d}\"###)"))
.map(|d| format!("Some({})", raw_string_literal(&d)))
.unwrap_or("None".to_string()),
backends = backends.into_iter().collect::<Vec<_>>().join(", "),
aliases = aliases
Expand All @@ -133,7 +199,11 @@ fn codegen_registry() {
.collect::<Vec<_>>()
.join(", "),
test = test
.map(|(t, v)| format!("Some((r###\"{t}\"###, r###\"{v}\"###))"))
.map(|(t, v)| format!(
"Some(({}, {}))",
raw_string_literal(&t),
raw_string_literal(&v)
))
.unwrap_or("None".to_string()),
os = os
.iter()
Expand Down Expand Up @@ -289,7 +359,8 @@ pub static SETTINGS_META: Lazy<IndexMap<&'static str, SettingsMeta>> = Lazy::new
if let Some(description) = props.get("description") {
let description = description.as_str().unwrap().to_string();
lines.push(format!(
r####" description: r###"{description}"###,"####
" description: {},",
raw_string_literal(&description)
));
}
lines.push(" },".to_string());
Expand All @@ -313,7 +384,8 @@ pub static SETTINGS_META: Lazy<IndexMap<&'static str, SettingsMeta>> = Lazy::new
if let Some(description) = props.get("description") {
let description = description.as_str().unwrap().to_string();
lines.push(format!(
r####" description: r###"{description}"###,"####
" description: {},",
raw_string_literal(&description)
));
}
lines.push(" },".to_string());
Expand Down
70 changes: 70 additions & 0 deletions docs/dev-tools/backends/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,76 @@ linux-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-linux-x64", for
windows-x64 = { url = "https://example.com/releases/my-tool-v1.0.0-windows-x64", format = "zip" }
```

### `version_list_url`

Fetch available versions from a remote URL. This enables `mise ls-remote` to list available versions for HTTP-based tools:

```toml
[tools."http:my-tool"]
version = "1.0.0"
url = "https://example.com/releases/my-tool-v{{version}}.tar.gz"
version_list_url = "https://example.com/releases/versions.txt"
```

The version list URL can return data in multiple formats:

- **Plain text**: A single version number (e.g., `2.0.53`)
- **Line-separated**: One version per line
- **JSON array of strings**: `["1.0.0", "1.1.0", "2.0.0"]`
- **JSON array of objects**: `[{"version": "1.0.0"}, {"tag_name": "v2.0.0"}]`
- **JSON object with versions array**: `{"versions": ["1.0.0", "2.0.0"]}`

Version prefixes like `v` are automatically stripped.

### `version_regex`

Extract versions from the version list URL response using a regular expression:

```toml
[tools."http:my-tool"]
version = "1.0.0"
url = "https://example.com/releases/my-tool-v{{version}}.tar.gz"
version_list_url = "https://example.com/releases/"
version_regex = 'my-tool-v(\d+\.\d+\.\d+)\.tar\.gz'
```

The first capturing group is used as the version. If no capturing group is present, the entire match is used.

### `version_json_path`

Extract versions from JSON responses using a jq-like path expression:

```toml
[tools."http:my-tool"]
version = "1.0.0"
url = "https://example.com/releases/my-tool-v{{version}}.tar.gz"
version_list_url = "https://api.example.com/releases"
version_json_path = ".[].tag_name"
```

Supported path expressions:

- `.` - root value
- `.[]` - iterate over array elements
- `.[].field` - extract field from each array element
- `.field` - extract field from object
- `.field[]` - iterate over array in field
- `.field.subfield` - nested field access
- `.data.versions[]` - complex nested paths

Examples:

```toml
# GitHub releases API format
version_json_path = ".[].tag_name"

# Nested versions array
version_json_path = ".data.versions[]"

# Release info objects
version_json_path = ".releases[].info.version"
```

### `bin_path`

Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{{version}}`:
Expand Down
10 changes: 10 additions & 0 deletions e2e/backend/test_http_version_list_slow
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash

# Test HTTP backend with version_list_url (using claude-code from registry)

# Test that ls-remote works with version_list_url
assert_contains "mise ls-remote claude" "2."

# Test installation of claude-code via http backend
mise install claude@latest
assert "mise x claude -- claude --version"
24 changes: 23 additions & 1 deletion registry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -795,10 +795,32 @@ test = ["clarinet --version", "clarinet {{version}}"]

[tools.claude]
aliases = ["claude-code"]
backends = ["npm:@anthropic-ai/claude-code"]
description = "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows -- all through natural language commands"
os = ["linux", "macos"]
test = ["claude --version", "{{version}} (Claude Code)"]

[[tools.claude.backends]]
full = "http:claude"

[tools.claude.backends.options]
bin = "claude"
version_list_url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/stable"

[tools.claude.backends.options.platforms.macos-arm64]
url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/darwin-arm64/claude"

[tools.claude.backends.options.platforms.macos-x64]
url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/darwin-x64/claude"

[tools.claude.backends.options.platforms.linux-arm64]
url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/linux-arm64/claude"

[tools.claude.backends.options.platforms.linux-x64]
url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/linux-x64/claude"

[[tools.claude.backends]]
full = "npm:@anthropic-ai/claude-code"

[tools.claude-squad]
backends = ["aqua:smtg-ai/claude-squad", "ubi:smtg-ai/claude-squad"]
description = "Manage multiple AI agents like Claude Code, Aider, Codex, and Amp. 10x your productivity"
Expand Down
20 changes: 20 additions & 0 deletions schema/mise-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@
"type": "string"
},
"description": "Specific platforms this backend supports"
},
"options": {
"type": "object",
"description": "Backend-specific options (e.g., version_list_url, bin, platforms)",
"properties": {
"platforms": {
"type": "object",
"description": "Platform-specific options (e.g., macos-arm64, linux-x64)",
"additionalProperties": {
"type": "object",
"description": "Platform-specific configuration",
"additionalProperties": {
"type": "string"
}
}
}
},
"additionalProperties": {
"type": "string"
}
}
},
"required": ["full"],
Expand Down
23 changes: 21 additions & 2 deletions src/backend/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::backend::static_helpers::{
clean_binary_name, get_filename_from_url, list_available_platforms_with_key,
lookup_platform_key, template_string, verify_artifact,
};
use crate::backend::version_list;
use crate::cli::args::BackendArg;
use crate::config::Config;
use crate::config::Settings;
Expand Down Expand Up @@ -378,6 +379,24 @@ impl HttpBackend {
}
Ok(())
}

/// Fetch and parse versions from a version list URL
async fn fetch_versions_from_url(&self) -> Result<Vec<String>> {
let opts = self.ba.opts();

// Get version_list_url from options
let version_list_url = match opts.get("version_list_url") {
Some(url) => url.clone(),
None => return Ok(vec![]),
};

// Get optional version regex pattern or JSON path
Comment on lines +387 to +393
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] These two lines extract options that are already being extracted from opts. Consider extracting all three options (version_list_url, version_regex, version_json_path) consistently at the same location for better code organization.

Suggested change
// Get version_list_url from options
let version_list_url = match opts.get("version_list_url") {
Some(url) => url.clone(),
None => return Ok(vec![]),
};
// Get optional version regex pattern or JSON path
// Get version_list_url and related options from options
let version_list_url = match opts.get("version_list_url") {
Some(url) => url.clone(),
None => return Ok(vec![]),
};

Copilot uses AI. Check for mistakes.
let version_regex = opts.get("version_regex").map(|s| s.as_str());
let version_json_path = opts.get("version_json_path").map(|s| s.as_str());

// Use the version_list module to fetch and parse versions
version_list::fetch_versions(&version_list_url, version_regex, version_json_path).await
Comment on lines +388 to +398
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clone() is unnecessary here. Since opts.get() returns Option<&String>, you can use as_str() to get &str and pass it directly to fetch_versions without cloning.

Suggested change
let version_list_url = match opts.get("version_list_url") {
Some(url) => url.clone(),
None => return Ok(vec![]),
};
// Get optional version regex pattern or JSON path
let version_regex = opts.get("version_regex").map(|s| s.as_str());
let version_json_path = opts.get("version_json_path").map(|s| s.as_str());
// Use the version_list module to fetch and parse versions
version_list::fetch_versions(&version_list_url, version_regex, version_json_path).await
let version_list_url = opts.get("version_list_url").map(|s| s.as_str());
if version_list_url.is_none() {
return Ok(vec![]);
}
// Get optional version regex pattern or JSON path
let version_regex = opts.get("version_regex").map(|s| s.as_str());
let version_json_path = opts.get("version_json_path").map(|s| s.as_str());
// Use the version_list module to fetch and parse versions
version_list::fetch_versions(version_list_url.unwrap(), version_regex, version_json_path).await

Copilot uses AI. Check for mistakes.
}
}

#[async_trait]
Expand All @@ -391,8 +410,8 @@ impl Backend for HttpBackend {
}

async fn _list_remote_versions(&self, _config: &Arc<Config>) -> Result<Vec<String>> {
// Http backend doesn't support remote version listing
Ok(vec![])
// Fetch versions from version_list_url if configured
self.fetch_versions_from_url().await
}

async fn install_version_(
Expand Down
Loading
Loading