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
2 changes: 1 addition & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' }}
type=raw,value=latest

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/ppa-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ on:
env:
DEBIAN_FRONTEND: noninteractive

permissions:
contents: read

jobs:
publish-ppa:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -102,7 +105,7 @@ jobs:
pkg-config

- name: Set up Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
toolchain: stable

Expand Down Expand Up @@ -236,7 +239,7 @@ jobs:

- name: Upload artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c1a64a875d1fc6a0a # v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ppa-source-packages
path: /tmp/packages/
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
tags:
- "v*"

# Least privilege: read-only token by default, elevated per job below.
permissions:
contents: read

jobs:
build:
strategy:
Expand Down Expand Up @@ -71,6 +75,8 @@ jobs:
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write # creates the GitHub release and uploads assets
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

Expand Down Expand Up @@ -243,6 +249,8 @@ jobs:
name: Trigger npm publish
needs: release
runs-on: ubuntu-latest
permissions:
actions: write # gh workflow run
steps:
- name: Dispatch npm-publish workflow
env:
Expand All @@ -256,6 +264,8 @@ jobs:
name: Trigger PPA publish
needs: release
runs-on: ubuntu-latest
permissions:
actions: write # gh workflow run
steps:
- name: Dispatch ppa-publish workflow
env:
Expand All @@ -269,6 +279,8 @@ jobs:
name: Trigger Docker publish
needs: release
runs-on: ubuntu-latest
permissions:
actions: write # gh workflow run
steps:
- name: Dispatch docker workflow
env:
Expand Down
9 changes: 3 additions & 6 deletions .github/workflows/security-check.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
name: security-check

# No paths filter: "cargo audit" is a required branch-protection check, and a
# path-filtered required check never reports on PRs that skip it — blocking
# the merge forever. Audit is cheap with a warm cache; run it everywhere.
on:
push:
branches: [master]
paths:
- "Cargo.lock"
- "Cargo.toml"
pull_request:
paths:
- "Cargo.lock"
- "Cargo.toml"

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/vendored-file-warning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions: {}

jobs:
warn:
if: github.event.pull_request.user.login != 'getappz'
if: github.event.pull_request.user.login != 'getappz' && github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
steps:
- name: Warn on vendored file changes
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/winget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
types: [published]
workflow_dispatch:

permissions:
contents: read

jobs:
publish:
runs-on: windows-latest
Expand All @@ -19,7 +22,7 @@ jobs:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
$id = "getappz.agentflare"
$urls = gh release view --json assets --jq '.assets[] | select(.name | test("\\.(exe|msi)$")) | .url'
$urls = gh release view --json assets --jq '.assets[] | select(.name | test("windows.*\\.(zip|exe|msi)$")) | .url'
if (-not $urls) { throw "No winget installer assets found" }
komac update $id --version "$env:RELEASE_TAG" --urls $urls
komac cleanup --only-merged
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/target
/docs/superpowers/
/.worktrees/

# Local onpush doc-generation tooling (machine-specific, not part of the project)
/.onpush/
/onpush_run.log
/install_onpush.ps1
/run_onpush.bat
/serve_docs.py
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ FROM debian:13-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
sudo curl ca-certificates \
curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*

RUN curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/getappz/agentflare/main/install.sh | sh
RUN useradd --create-home agentflare
USER agentflare
WORKDIR /home/agentflare

ENV PATH="/root/.local/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/getappz/agentflare/master/install.sh | sh

ENV PATH="/home/agentflare/.local/bin:${PATH}"

ENTRYPOINT ["agentflare"]
12 changes: 6 additions & 6 deletions crates/agent-registry/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// the approach used by `agents-cli`'s `findInPath` and `caam`'s
/// `findBinary`, minus their shims-dir exclusion (agentflare has no shims
/// directory yet).
#[must_use]
pub fn find_binary(names: &[&str]) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
Expand Down Expand Up @@ -110,6 +111,7 @@ mod find_binary_tests {
/// Find the first `\d+\.\d+\.\d+`-shaped substring in `text` (a `--version`
/// command's combined stdout+stderr). Hand-rolled instead of pulling in the
/// `regex` crate for one pattern.
#[must_use]
pub fn extract_version(text: &str) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
for start in 0..chars.len() {
Expand Down Expand Up @@ -196,7 +198,7 @@ impl VersionRunner for RealVersionRunner {
fn run_version_command(binary: &Path, args: &[&str]) -> Result<String, String> {
let binary = binary.to_path_buf();
let binary_for_thread = binary.clone();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let args: Vec<String> = args.iter().map(std::string::ToString::to_string).collect();
let (tx, rx) = mpsc::channel();

std::thread::spawn(move || {
Expand Down Expand Up @@ -237,15 +239,13 @@ pub fn resolve_version_with(
.and_then(|m| m.modified())
.map_err(|e| format!("could not stat {}: {e}", binary_path.display()))?
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
.map_or(0, |d| d.as_secs());
let binary_path_str = binary_path.to_string_lossy().into_owned();

if let Some(entry) = cache.get(agent_key) {
if entry.binary_path == binary_path_str && entry.mtime == mtime {
if let Some(entry) = cache.get(agent_key)
&& entry.binary_path == binary_path_str && entry.mtime == mtime {
return Ok(entry.version.clone());
}
}

let raw = runner.run(binary_path, version_args)?;
let version = extract_version(&raw)
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-registry/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub enum Agent {
}

impl Agent {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Agent::ClaudeCode => "claude-code",
Expand Down Expand Up @@ -277,6 +278,7 @@ pub static REGISTRY: &[AgentSpec] = &[
/// Not yet consumed outside tests — wired up by the upcoming agent detection
/// engine and CLI commands.
#[allow(dead_code)]
#[must_use]
pub fn spec(agent: Agent) -> &'static AgentSpec {
REGISTRY
.iter()
Expand Down
21 changes: 15 additions & 6 deletions crates/ponytail/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ pub fn normalize_persisted_mode(mode: &str) -> Option<&'static str> {
normalize_mode(mode).or_else(|| normalize_config_mode(mode))
}

/// Like `normalize_config_mode`, but also accepts user-defined custom skill
/// names (which are discovered at runtime, so they can't be `&'static str`).
pub fn normalize_extended_mode(mode: &str) -> Option<String> {
let m = mode.trim().to_lowercase();
normalize_config_mode(&m)
.map(str::to_string)
.or_else(|| crate::sub_skills::get_custom(&m).map(|_| m))
}
Comment on lines +24 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== config.rs matches ==\n'
rg -n "normalize_extended_mode|normalize_config_mode|get_custom|set_default_mode|set_active" crates/ponytail/src/config.rs src/cli/ponytail.rs || true

printf '\n== config.rs excerpt ==\n'
sed -n '1,120p' crates/ponytail/src/config.rs

printf '\n== cli excerpt ==\n'
sed -n '1,240p' src/cli/ponytail.rs

Repository: getappz/agentflare

Length of output: 13666


Wire PonytailAction::Set/Default through normalize_extended_modesrc/cli/ponytail.rs:104-125 still uses normalize_config_mode, so direct set/default commands reject custom skill names as invalid mode before they reach set_default_mode/set_active. PromptSubmit already uses the extended path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ponytail/src/config.rs` around lines 24 - 31, Update the mode parsing
used by PonytailAction::Set and PonytailAction::Default so it routes through
normalize_extended_mode instead of normalize_config_mode. The current path in
the cli/ponytail handling rejects runtime-discovered custom skill names as
invalid before they can reach set_default_mode or set_active, so make the
action-specific parsing consistent with PromptSubmit by accepting both built-in
and custom modes via the extended helper.


pub fn is_deactivation(text: &str) -> bool {
let t = text.trim().to_lowercase();
let t = t.trim_end_matches(|c: char| c == '.' || c == '!' || c == '?' || c.is_whitespace());
Expand All @@ -45,29 +54,29 @@ struct ConfigFile {

pub fn default_mode() -> String {
if let Ok(val) = std::env::var("PONYTAIL_DEFAULT_MODE")
&& let Some(m) = normalize_config_mode(&val)
&& let Some(m) = normalize_extended_mode(&val)
{
return m.to_string();
return m;
}
if let Ok(data) = std::fs::read_to_string(config_path())
&& let Ok(cfg) = serde_json::from_str::<ConfigFile>(&data)
&& let Some(mode) = cfg.default_mode
&& let Some(m) = normalize_config_mode(&mode)
&& let Some(m) = normalize_extended_mode(&mode)
{
return m.to_string();
return m;
}
DEFAULT_MODE.to_string()
}

pub fn set_default_mode(mode: &str) -> Result<(), String> {
let normalized = normalize_config_mode(mode).ok_or_else(|| format!("invalid mode: {mode}"))?;
let normalized = normalize_extended_mode(mode).ok_or_else(|| format!("invalid mode: {mode}"))?;
let dir = config_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let mut cfg: ConfigFile = std::fs::read_to_string(config_path())
.ok()
.and_then(|d| serde_json::from_str(&d).ok())
.unwrap_or_default();
cfg.default_mode = Some(normalized.to_string());
cfg.default_mode = Some(normalized);
let json = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
std::fs::write(config_path(), json).map_err(|e| e.to_string())?;
Ok(())
Expand Down
28 changes: 19 additions & 9 deletions crates/ponytail/src/instructions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,31 +53,41 @@ pub fn download_skill() -> Result<String, String> {
}

pub fn build(mode: &str, skill_path: Option<&Path>) -> Instructions {
let effective = config::normalize_persisted_mode(mode)
.unwrap_or(config::DEFAULT_MODE);

if crate::sub_skills::get(effective).is_some() || crate::sub_skills::get_custom(effective).is_some() {
// Custom skill names aren't in the static mode lists, so keep them as-is
// instead of collapsing them to the default mode.
let effective: String = config::normalize_persisted_mode(mode)
.map(str::to_string)
.or_else(|| crate::sub_skills::get_custom(mode).map(|_| mode.to_string()))
.unwrap_or_else(|| config::DEFAULT_MODE.to_string());

if crate::sub_skills::get(&effective).is_some() {
return Instructions {
mode: effective.to_string(),
mode: effective.clone(),
body: format!(
"PONYTAIL MODE ACTIVE — level: {effective}. Behavior defined by /ponytail-{effective} skill."
),
};
}

// Custom skills have no harness-installed /ponytail-<name> skill to point
// at, so their authored body is delivered inline.
if let Some(body) = crate::sub_skills::get_custom(&effective) {
return Instructions { mode: effective, body };
}

let skill_body = if let Some(path) = skill_path {
std::fs::read_to_string(path).unwrap_or_else(|_| EMBEDDED_SKILL.to_string())
} else {
crate::sub_skills::get_custom(effective)
.or_else(|| std::fs::read_to_string(skill_cache_path()).ok())
std::fs::read_to_string(skill_cache_path())
.ok()
.or_else(find_workspace_agents_md)
.unwrap_or_else(|| EMBEDDED_SKILL.to_string())
};

let filtered = filter_skill_body(&skill_body, effective);
let filtered = filter_skill_body(&skill_body, &effective);

Instructions {
mode: effective.to_string(),
mode: effective,
body: filtered,
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/ponytail/src/skill-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ Default mode = `full`, auto-active every session. Change it:
export PONYTAIL_DEFAULT_MODE=ultra
```

**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`):
**Config file** (`~/.config/agentflare/ponytail/config.json`, Windows: `%APPDATA%\agentflare\ponytail\config.json`):
```json
{ "defaultMode": "lite" }
{ "default_mode": "lite" }
```

Set `"off"` to disable auto-activation on session start, activate manually
Expand Down
5 changes: 5 additions & 0 deletions crates/ponytail/src/sub_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ static CUSTOM_SKILLS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
let path = entry.path();
if path.extension().map_or(false, |e| e == "md") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
// Reserved built-in mode/skill names can't be shadowed by
// custom files — a user's full.md must not hijack full mode.
if crate::config::VALID_MODES.contains(&name) {
continue;
}
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm VALID_MODES contents and whether reserved skill names overlap.
rg -n 'VALID_MODES' crates/ponytail/src/config.rs -A5 -B2
rg -n 'fn normalize_mode|fn all_mode_names|fn all_skill_names' crates/ponytail/src/config.rs crates/ponytail/src/switcher.rs -A10

Repository: getappz/agentflare

Length of output: 2825


🏁 Script executed:

#!/bin/bash
sed -n '1,220p' crates/ponytail/src/sub_skills.rs
sed -n '1,120p' crates/ponytail/src/config.rs
sed -n '1,120p' crates/ponytail/src/switcher.rs

Repository: getappz/agentflare

Length of output: 13335


Normalize the reserved-name check crates/ponytail/src/sub_skills.rs:39-43
path.file_stem() is compared raw against VALID_MODES, so Full.md/FULL.md slips past the guard and gets added to CUSTOM_SKILLS. Normalize the stem before checking so reserved names stay blocked case-insensitively.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ponytail/src/sub_skills.rs` around lines 39 - 43, The reserved-name
guard in sub_skills.rs currently compares path.file_stem() directly against
VALID_MODES, so mixed-case names can bypass the check and be added to
CUSTOM_SKILLS. Update the name handling in the custom-skill discovery path to
normalize the stem before the VALID_MODES.contains(...) check, using the
existing logic around path.file_stem() and the CUSTOM_SKILLS insertion flow, so
reserved built-in modes remain blocked case-insensitively.

if let Ok(body) = std::fs::read_to_string(&path) {
if !body.is_empty() {
map.insert(name.to_string(), body);
Expand Down
20 changes: 10 additions & 10 deletions crates/ponytail/src/switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ pub fn detect(input: &str) -> Option<SwitchAction> {
let prefixed = format!("/ponytail-{name}");
let alt = format!("/ponytail:{name}");
if prompt == prefixed || prompt.starts_with(&format!("{prefixed} ")) {
let normalized = config::normalize_config_mode(&name)?;
return Some(SwitchAction::SetMode(normalized.to_string()));
let normalized = config::normalize_extended_mode(&name)?;
return Some(SwitchAction::SetMode(normalized));
}
if prompt == alt || prompt.starts_with(&format!("{alt} ")) {
let normalized = config::normalize_config_mode(&name)?;
return Some(SwitchAction::SetMode(normalized.to_string()));
let normalized = config::normalize_extended_mode(&name)?;
return Some(SwitchAction::SetMode(normalized));
}
}

Expand Down Expand Up @@ -73,20 +73,20 @@ pub fn detect(input: &str) -> Option<SwitchAction> {
if smode.is_empty() {
return None;
}
let normalized = config::normalize_config_mode(smode)?;
Some(SwitchAction::SetSession(normalized.to_string()))
let normalized = config::normalize_extended_mode(smode)?;
Some(SwitchAction::SetSession(normalized))
}
s if all_skill_names().iter().any(|n| n == s) => {
let normalized = config::normalize_config_mode(s)?;
Some(SwitchAction::SetMode(normalized.to_string()))
let normalized = config::normalize_extended_mode(s)?;
Some(SwitchAction::SetMode(normalized))
}
"default" => {
let dmode = arg;
if dmode.is_empty() {
return None;
}
let normalized = config::normalize_config_mode(dmode)?;
Some(SwitchAction::SetDefault(normalized.to_string()))
let normalized = config::normalize_extended_mode(dmode)?;
Some(SwitchAction::SetDefault(normalized))
}
_ => None,
}
Expand Down
Loading
Loading