Skip to content

Commit 6e6ae0a

Browse files
committed
chore: release v0.7.6 — bump version, update CHANGELOG
1 parent 0c283d5 commit 6e6ae0a

10 files changed

Lines changed: 278 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,41 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
---
99

10+
## [v0.7.6] — 2026-03-26
11+
12+
### Added
13+
14+
- **`runner` accepts an array** — the `runner` field in `ai/config.toml` now accepts
15+
either a string or an array, letting you invoke `agent-skills-cli` via a package
16+
runner without a global install:
17+
```toml
18+
[skills]
19+
backend = "agent-skills"
20+
runner = ["bunx", "agent-skills-cli"] # or ["npx", "agent-skills-cli"]
21+
runner = "skills" # string shorthand still works
22+
```
23+
24+
- **`haven upgrade` sudo fallback** — when the upgrade binary write fails with a
25+
permission error (e.g. haven is installed in `/usr/local/bin`), the command now
26+
detects this, prints a clear message, and asks:
27+
```
28+
error: Permission denied writing to /usr/local/bin/haven.
29+
Retry with sudo? [y/N]
30+
```
31+
If you confirm, it runs `sudo mv` + `sudo chmod 755` to complete the install.
32+
The download and checksum steps are not repeated. The extracted binary is staged
33+
in `/tmp` rather than a sibling `.new` file, so the permission error is caught
34+
at the move step rather than the extract step.
35+
36+
### Fixed
37+
38+
- **`haven ai backends` no longer silently shows native** — previously, any error
39+
loading `ai/config.toml` (e.g. a parse error or missing file) would silently fall
40+
back to the native backend, making it appear as the active backend even when
41+
another was configured. The error is now propagated so you see what went wrong.
42+
43+
---
44+
1045
## [v0.7.5] — 2026-03-26
1146

1247
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "haven"
3-
version = "0.7.5"
3+
version = "0.7.6"
44
edition = "2021"
55
description = "AI-first dotfiles & environment manager"
66
license = "MIT"

docs/reference/commands.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,17 @@ haven upgrade [--check] [--force]
341341
| `--check` | Check for update without installing. Exits 0 when up to date, 1 when update is available. |
342342
| `--force` | Install even if already on latest. |
343343

344+
If haven is installed in a system directory (e.g. `/usr/local/bin`), the write
345+
will fail with a permission error. The command detects this and prompts:
346+
347+
```
348+
error: Permission denied writing to /usr/local/bin/haven.
349+
Retry with sudo? [y/N]
350+
```
351+
352+
Answering `y` runs `sudo mv` + `sudo chmod 755` to complete the install without
353+
repeating the download.
354+
344355
---
345356

346357
## `haven unmanaged`

docs/reference/configuration.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,46 @@ Run `haven ai discover` to auto-generate this file by scanning installed platfor
109109

110110
---
111111

112+
## `ai/config.toml`
113+
114+
Optional file that configures the AI skill backend. All fields have defaults;
115+
the file can be omitted entirely.
116+
117+
```toml
118+
# ai/config.toml
119+
120+
[skills]
121+
backend = "native" # "native" | "agent-skills" | "akm"
122+
runner = "skills" # binary name, path, or array (agent-skills backend only)
123+
timeout_secs = 120 # subprocess timeout in seconds (agent-skills backend only)
124+
```
125+
126+
| Field | Type | Default | Description |
127+
|-------|------|---------|-------------|
128+
| `backend` | string | `"native"` | Skill backend to use. |
129+
| `runner` | string or array | `"skills"` | Binary (or program + args array) used to invoke agent-skills-cli. |
130+
| `timeout_secs` | integer | `120` | Timeout for agent-skills-cli subprocesses. |
131+
132+
### Runner examples
133+
134+
```toml
135+
# Global install (default)
136+
runner = "skills"
137+
138+
# Custom path
139+
runner = "/usr/local/bin/skills"
140+
141+
# Via bunx — no global install required
142+
runner = ["bunx", "agent-skills-cli"]
143+
144+
# Via npx
145+
runner = ["npx", "agent-skills-cli"]
146+
```
147+
148+
Run `haven ai backends` to see which backends are available and which is active.
149+
150+
---
151+
112152
## `ai/skills/<name>/skill.toml`
113153

114154
Declares a single AI skill.

src/ai_config.rs

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
///
33
/// ```toml
44
/// [skills]
5-
/// backend = "native" # "native" | "agent-skills" | "akm"
6-
/// runner = "skills" # path to agent-skills-cli binary (agent-skills backend only)
7-
/// timeout_secs = 120 # subprocess timeout in seconds (agent-skills backend only)
5+
/// backend = "native" # "native" | "agent-skills" | "akm"
6+
/// runner = "skills" # binary name or path (agent-skills backend only)
7+
/// runner = ["bunx", "agent-skills-cli"] # or an array: program + args prefix
8+
/// timeout_secs = 120 # subprocess timeout in seconds (agent-skills backend only)
89
/// ```
910
///
1011
/// When `ai/config.toml` is absent, all defaults apply (native backend).
@@ -35,9 +36,19 @@ impl BackendKind {
3536
#[derive(Debug, Clone)]
3637
pub struct AiConfig {
3738
pub backend: BackendKind,
38-
/// Path or name of the agent-skills-cli runner binary (default: `"skills"`).
39+
/// Runner command for the agent-skills-cli subprocess (default: `["skills"]`).
40+
///
41+
/// The first element is the program, the rest are arguments prepended before the
42+
/// subcommand. This lets users invoke via a package runner without a global install:
43+
///
44+
/// ```toml
45+
/// runner = ["bunx", "agent-skills-cli"]
46+
/// runner = ["npx", "agent-skills-cli"]
47+
/// runner = "/usr/local/bin/skills" # string shorthand for single-element array
48+
/// ```
49+
///
3950
/// Only used by `AgentSkills` backend (and future `Akm`).
40-
pub runner: String,
51+
pub runner: Vec<String>,
4152
/// Subprocess timeout in seconds (default: 120).
4253
/// Only used by `AgentSkills` backend (and future `Akm`).
4354
pub timeout_secs: u64,
@@ -47,7 +58,7 @@ impl Default for AiConfig {
4758
fn default() -> Self {
4859
AiConfig {
4960
backend: BackendKind::Native,
50-
runner: "skills".to_string(),
61+
runner: vec!["skills".to_string()],
5162
timeout_secs: 120,
5263
}
5364
}
@@ -77,10 +88,27 @@ struct RawAiConfig {
7788
skills: RawSkillsSection,
7889
}
7990

91+
/// Accepts either `runner = "skills"` or `runner = ["bunx", "agent-skills-cli"]`.
92+
#[derive(Deserialize)]
93+
#[serde(untagged)]
94+
enum RunnerValue {
95+
Single(String),
96+
Multi(Vec<String>),
97+
}
98+
99+
impl From<RunnerValue> for Vec<String> {
100+
fn from(v: RunnerValue) -> Self {
101+
match v {
102+
RunnerValue::Single(s) => vec![s],
103+
RunnerValue::Multi(v) => v,
104+
}
105+
}
106+
}
107+
80108
#[derive(Deserialize, Default)]
81109
struct RawSkillsSection {
82110
backend: Option<String>,
83-
runner: Option<String>,
111+
runner: Option<RunnerValue>,
84112
timeout_secs: Option<u64>,
85113
}
86114

@@ -97,9 +125,16 @@ impl RawAiConfig {
97125
),
98126
};
99127

128+
let runner = self.skills.runner
129+
.map(Vec::from)
130+
.unwrap_or_else(|| vec!["skills".to_string()]);
131+
if runner.is_empty() {
132+
anyhow::bail!("{}: 'runner' must not be an empty array", path_display);
133+
}
134+
100135
Ok(AiConfig {
101136
backend,
102-
runner: self.skills.runner.unwrap_or_else(|| "skills".to_string()),
137+
runner,
103138
timeout_secs: self.skills.timeout_secs.unwrap_or(120),
104139
})
105140
}
@@ -138,22 +173,42 @@ mod tests {
138173
write_config(&dir, "[skills]\nbackend = \"agent-skills\"\n");
139174
let cfg = AiConfig::load(dir.path()).unwrap();
140175
assert_eq!(cfg.backend, BackendKind::AgentSkills);
141-
assert_eq!(cfg.runner, "skills");
176+
assert_eq!(cfg.runner, vec!["skills"]);
142177
assert_eq!(cfg.timeout_secs, 120);
143178
}
144179

145180
#[test]
146-
fn ai_config_reads_custom_runner_and_timeout() {
181+
fn ai_config_reads_string_runner() {
147182
let dir = TempDir::new().unwrap();
148183
write_config(
149184
&dir,
150185
"[skills]\nbackend = \"agent-skills\"\nrunner = \"/usr/local/bin/skills\"\ntimeout_secs = 60\n",
151186
);
152187
let cfg = AiConfig::load(dir.path()).unwrap();
153-
assert_eq!(cfg.runner, "/usr/local/bin/skills");
188+
assert_eq!(cfg.runner, vec!["/usr/local/bin/skills"]);
154189
assert_eq!(cfg.timeout_secs, 60);
155190
}
156191

192+
#[test]
193+
fn ai_config_reads_array_runner() {
194+
let dir = TempDir::new().unwrap();
195+
write_config(
196+
&dir,
197+
"[skills]\nbackend = \"agent-skills\"\nrunner = [\"bunx\", \"agent-skills-cli\"]\n",
198+
);
199+
let cfg = AiConfig::load(dir.path()).unwrap();
200+
assert_eq!(cfg.runner, vec!["bunx", "agent-skills-cli"]);
201+
}
202+
203+
#[test]
204+
fn ai_config_errors_on_empty_runner_array() {
205+
let dir = TempDir::new().unwrap();
206+
write_config(&dir, "[skills]\nbackend = \"agent-skills\"\nrunner = []\n");
207+
let err = AiConfig::load(dir.path()).unwrap_err();
208+
let msg = format!("{err:#}");
209+
assert!(msg.contains("empty"), "error should mention empty array: {msg}");
210+
}
211+
157212
#[test]
158213
fn ai_config_errors_on_unknown_backend() {
159214
let dir = TempDir::new().unwrap();

src/commands/ai.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,15 +1033,17 @@ struct AgentSkillsSearchEntry {
10331033
stars: Option<u64>,
10341034
}
10351035

1036-
fn agentskills_search(runner: &str, query: &str, limit: usize) -> Result<Vec<SearchEntry>> {
1037-
let output = std::process::Command::new(runner)
1036+
fn agentskills_search(runner: &[String], query: &str, limit: usize) -> Result<Vec<SearchEntry>> {
1037+
let runner_display = runner.join(" ");
1038+
let output = std::process::Command::new(&runner[0])
1039+
.args(&runner[1..])
10381040
.args(["search", query, "--json", "--limit", &limit.to_string()])
10391041
.output()
1040-
.with_context(|| format!("Failed to run '{}' — is agent-skills-cli installed?", runner))?;
1042+
.with_context(|| format!("Failed to run '{}' — is agent-skills-cli installed?", runner_display))?;
10411043

10421044
if !output.status.success() {
10431045
let stderr = String::from_utf8_lossy(&output.stderr);
1044-
anyhow::bail!("'{}' search failed: {}", runner, stderr.trim());
1046+
anyhow::bail!("'{}' search failed: {}", runner_display, stderr.trim());
10451047
}
10461048

10471049
let text = String::from_utf8(output.stdout).context("agent-skills-cli output was not UTF-8")?;
@@ -1161,8 +1163,7 @@ pub struct BackendsOptions<'a> {
11611163

11621164
/// List all known skill backends and their availability on this machine.
11631165
pub fn backends(opts: &BackendsOptions<'_>) -> Result<()> {
1164-
let config = crate::ai_config::AiConfig::load(opts.repo_root)
1165-
.unwrap_or_default();
1166+
let config = crate::ai_config::AiConfig::load(opts.repo_root)?;
11661167
let active = config.backend.as_str();
11671168
let infos = crate::skill_backend_factory::list_backends();
11681169

0 commit comments

Comments
 (0)