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
1 change: 1 addition & 0 deletions app/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
dist
dist-web
coverage
app
src-tauri
Expand Down
10 changes: 8 additions & 2 deletions app/scripts/e2e-web-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REPO_ROOT="$(cd "$APP_DIR/.." && pwd)"
cd "$APP_DIR"

RUST_HOST_TRIPLE="${RUST_HOST_TRIPLE:-$(rustc -vV | awk '/^host: / { print $2 }')}"
RUSTC_BIN="$(command -v rustc)"
CARGO_BIN="${CARGO_BIN:-$(dirname "$RUSTC_BIN")/cargo}"
if [ ! -x "$CARGO_BIN" ]; then
CARGO_BIN="$(command -v cargo)"
fi

RUST_HOST_TRIPLE="${RUST_HOST_TRIPLE:-$("$RUSTC_BIN" -vV | awk '/^host: / { print $2 }')}"
E2E_WEB_CORE_TARGET_DIR="${E2E_WEB_CORE_TARGET_DIR:-$REPO_ROOT/target/e2e-web-${RUST_HOST_TRIPLE}}"

export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}"
Expand All @@ -24,4 +30,4 @@ fi
echo "Building web E2E bundle with backend ${VITE_BACKEND_URL}"
pnpm run build:web
echo "Building standalone openhuman-core for web E2E into ${E2E_WEB_CORE_TARGET_DIR}..."
CARGO_TARGET_DIR="$E2E_WEB_CORE_TARGET_DIR" "$REPO_ROOT/scripts/ci-cancel-aware.sh" cargo build --manifest-path "$REPO_ROOT/Cargo.toml" --bin openhuman-core
CARGO_TARGET_DIR="$E2E_WEB_CORE_TARGET_DIR" bash "$REPO_ROOT/scripts/ci-cancel-aware.sh" "$CARGO_BIN" build --manifest-path "$REPO_ROOT/Cargo.toml" --bin openhuman-core
63 changes: 59 additions & 4 deletions app/src/components/skills/SkillsExplorerTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,58 @@ const log = debug('skills:explorer-tab');
const CATALOG_PAGE_SIZE = 60;
const SEARCH_DEBOUNCE_MS = 300;

function slugifyInstallKey(value: string | null | undefined): string | null {
const raw = value?.trim();
if (!raw) return null;

let out = '';
let lastDash = false;
for (const ch of raw) {
if (/[a-z0-9]/i.test(ch)) {
out += ch.toLowerCase();
lastDash = false;
} else if (!lastDash && out.length > 0) {
out += '-';
lastDash = true;
}
}
return out.replace(/-+$/, '') || null;
}

function lastPathSegment(value: string | null | undefined): string | null {
const raw = value?.trim();
if (!raw) return null;
const parts = raw.split(/[/:#?]+/).filter(Boolean);
return parts.at(-1) ?? null;
}

function parentPathSegment(value: string | null | undefined): string | null {
const raw = value?.trim();
if (!raw) return null;
const parts = raw.split(/[\\/]+/).filter(Boolean);
return parts.length >= 2 ? parts.at(-2) ?? null : null;
}

function catalogInstallKeys(entry: CatalogEntry): string[] {
return [
slugifyInstallKey(entry.id),
slugifyInstallKey(lastPathSegment(entry.id)),
slugifyInstallKey(parentPathSegment(entry.docs_path)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not use docsPath categories as install keys

For Hermes entries, docs_path is shaped like bundled/<category>/<category>-<skill> (the parser test for apple-notes uses bundled/apple/apple-apple-notes), so parentPathSegment(entry.docs_path) adds only the category (apple, devops, etc.) to the catalog's install keys. If a user has an installed/local workflow whose slug is one of those categories, every registry entry in that category is treated as installed and the Install action/detail button is hidden even though the specific skill is absent; derive the skill slug from the last docsPath segment (stripping the category prefix) or avoid this key.

Useful? React with 👍 / 👎.

slugifyInstallKey(parentPathSegment(entry.download_url)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid deriving installed state from download URL parents

For catalog entries whose download_url is not guaranteed to be <slug>/SKILL.md, this adds unrelated path components as install keys. For example, download_url_from_source_url can turn a GitHub root blob into https://raw.githubusercontent.com/org/repo/main/SKILL.md (src/openhuman/skill_registry/ops.rs:570-575), so parentPathSegment(entry.download_url) becomes main; any user with an unrelated installed skill slug main will see that catalog entry as Installed and lose both install buttons. Restrict this key to URL shapes known to encode the skill slug, or avoid matching on the download URL parent.

Useful? React with 👍 / 👎.

].filter((key): key is string => Boolean(key));
}

function workflowInstallKeys(skill: WorkflowSummary): string[] {
return [
slugifyInstallKey(skill.id),
slugifyInstallKey(parentPathSegment(skill.location)),
].filter((key): key is string => Boolean(key));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function isCatalogEntryInstalled(entry: CatalogEntry, installedKeys: Set<string>): boolean {
return catalogInstallKeys(entry).some(key => installedKeys.has(key));
}

function SourceBadge({ source }: { source: string }) {
const SOURCE_COLORS: Record<string, string> = {
'built-in':
Expand Down Expand Up @@ -577,7 +629,10 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
}
}, [view, debouncedQuery, activeSourceFilter, fetchCatalog]);

const installedIds = useMemo(() => new Set(skills.map(s => s.id)), [skills]);
const installedKeys = useMemo(
() => new Set(skills.flatMap(skill => workflowInstallKeys(skill))),
[skills]
);

const filteredSkills = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
Expand Down Expand Up @@ -902,7 +957,7 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
<CatalogTile
key={`${entry.source}-${entry.id}`}
entry={entry}
installed={installedIds.has(entry.id)}
installed={isCatalogEntryInstalled(entry, installedKeys)}
installing={installingId === entry.id}
onClick={() => setDetailEntry(entry)}
onInstall={() => void handleRegistryInstall(entry)}
Expand Down Expand Up @@ -941,13 +996,13 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
<SkillDetailDialog
entry={detailEntry}
skill={detailSkill}
installed={detailEntry ? installedIds.has(detailEntry.id) : true}
installed={detailEntry ? isCatalogEntryInstalled(detailEntry, installedKeys) : true}
onClose={() => {
setDetailEntry(null);
setDetailSkill(null);
}}
onInstall={
detailEntry && !installedIds.has(detailEntry.id)
detailEntry && !isCatalogEntryInstalled(detailEntry, installedKeys)
? () => {
void handleRegistryInstall(detailEntry);
setDetailEntry(null);
Expand Down
63 changes: 58 additions & 5 deletions app/src/components/skills/__tests__/SkillsExplorerTab.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { CatalogEntry } from '../../../services/api/skillRegistryApi';
Expand Down Expand Up @@ -307,16 +307,69 @@ describe('SkillsExplorerTab', () => {
const { skillRegistryApi } = await import(
'../../../services/api/skillRegistryApi'
);
const installedSkill = { ...MOCK_SKILL, id: 'registry-skill-1' };
const catalogEntry = {
...MOCK_CATALOG_ENTRY,
id: 'built-in/apple-notes',
name: 'Apple Notes',
docs_path: 'skills/apple-notes/SKILL.md',
};
const installedSkill = {
...MOCK_SKILL,
id: 'apple-notes',
name: 'Apple Notes',
location: '/Users/test/.openhuman/skills/apple-notes/SKILL.md',
};
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([installedSkill]);
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
vi.mocked(skillRegistryApi.browse).mockResolvedValue([catalogEntry]);

render(<SkillsExplorerTab />);

await waitFor(() => {
expect(screen.getByText('Registry Skill')).toBeInTheDocument();
expect(screen.getByText('Apple Notes')).toBeInTheDocument();
});
const tile = screen.getByTestId('registry-tile-built-in/apple-notes');
expect(within(tile).getByText('Installed')).toBeInTheDocument();
expect(
within(tile).queryByTestId('registry-install-built-in/apple-notes')
).not.toBeInTheDocument();

await act(async () => {
fireEvent.click(tile);
});

await waitFor(() => {
expect(screen.getAllByText('Apple Notes').length).toBeGreaterThan(1);
});
expect(screen.getByTestId('registry-tile-registry-skill-1')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument();
});

it('does not mark catalog entries installed by display name alone', async () => {
const { workflowsApi } = await import('../../../services/api/workflowsApi');
const { skillRegistryApi } = await import(
'../../../services/api/skillRegistryApi'
);
const catalogEntry = {
...MOCK_CATALOG_ENTRY,
id: 'built-in/apple-notes',
name: 'Apple Notes',
docs_path: 'skills/apple-notes/SKILL.md',
};
const unrelatedInstalledSkill = {
...MOCK_SKILL,
id: 'apple-notes-copy',
name: 'Apple Notes',
location: '/Users/test/.openhuman/skills/apple-notes-copy/SKILL.md',
};
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([unrelatedInstalledSkill]);
vi.mocked(skillRegistryApi.browse).mockResolvedValue([catalogEntry]);

render(<SkillsExplorerTab />);

const tile = await screen.findByTestId('registry-tile-built-in/apple-notes');
expect(within(tile).queryByText('Installed')).not.toBeInTheDocument();
expect(
within(tile).getByTestId('registry-install-built-in/apple-notes')
).toBeInTheDocument();
});

it('has an install from URL button', async () => {
Expand Down
13 changes: 9 additions & 4 deletions scripts/test-rust-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,19 @@ export VITE_BACKEND_URL="$MOCK_API_URL"

cd "$REPO_ROOT"
source "$HOME/.cargo/env" 2>/dev/null || true
RUSTC_BIN="$(command -v rustc)"
CARGO_BIN="${CARGO_BIN:-$(dirname "$RUSTC_BIN")/cargo}"
if [ ! -x "$CARGO_BIN" ]; then
CARGO_BIN="$(command -v cargo)"
fi

echo "[rust-e2e] Running ${#SUITES[@]} suite(s) serially."
for suite in "${SUITES[@]}"; do
if [ "${#EXTRA_ARGS[@]}" -gt 0 ]; then
echo "[rust-e2e] cargo test --manifest-path Cargo.toml --test $suite -- ${EXTRA_ARGS[*]}"
"$SCRIPT_DIR/ci-cancel-aware.sh" cargo test --manifest-path Cargo.toml --test "$suite" -- "${EXTRA_ARGS[@]}"
echo "[rust-e2e] $CARGO_BIN test --manifest-path Cargo.toml --test $suite -- ${EXTRA_ARGS[*]}"
bash "$SCRIPT_DIR/ci-cancel-aware.sh" "$CARGO_BIN" test --manifest-path Cargo.toml --test "$suite" -- "${EXTRA_ARGS[@]}"
else
echo "[rust-e2e] cargo test --manifest-path Cargo.toml --test $suite"
"$SCRIPT_DIR/ci-cancel-aware.sh" cargo test --manifest-path Cargo.toml --test "$suite"
echo "[rust-e2e] $CARGO_BIN test --manifest-path Cargo.toml --test $suite"
bash "$SCRIPT_DIR/ci-cancel-aware.sh" "$CARGO_BIN" test --manifest-path Cargo.toml --test "$suite"
Comment on lines +141 to +144

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify CARGO_BIN initialization and error handling in the E2E test script

# Show the CARGO_BIN resolution logic (lines 131-144)
echo "=== CARGO_BIN resolution logic ==="
sed -n '131,144p' scripts/test-rust-e2e.sh

echo ""
echo "=== Checking for SCRIPT_DIR definition ==="
rg -n 'SCRIPT_DIR=' scripts/test-rust-e2e.sh | head -5

echo ""
echo "=== Checking for error handling when cargo/rustc not found ==="
rg -n -A2 -B2 'CARGO_BIN.*command -v|rustc.*not found|cargo.*not found' scripts/test-rust-e2e.sh

Repository: tinyhumansai/openhuman

Length of output: 1122


Add error handling if cargo/rustc discovery fails.

CARGO_BIN is properly initialized with a multi-step fallback (lines 131-135): env var override → rustc directory → command -v cargo, and SCRIPT_DIR is correctly defined at line 26. However, if both rustc and cargo discovery fail, CARGO_BIN could be empty when passed to the cargo invocations (lines 141, 144), resulting in a confusing error message. Add an explicit check after line 135 to fail with a clear error message if CARGO_BIN is not set or executable.

🤖 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 `@scripts/test-rust-e2e.sh` around lines 141 - 144, The CARGO_BIN variable is
initialized with a fallback mechanism at lines 131-135, but there is no
validation to ensure it is actually set and executable if all discovery methods
fail. Add an explicit error check immediately after line 135 that verifies
CARGO_BIN is not empty and is executable, and if the check fails, exit with a
clear error message that helps users understand that cargo/rustc discovery
failed. This will prevent confusing errors when CARGO_BIN is later used in the
bash invocations at lines 141 and 144.

fi
done
4 changes: 3 additions & 1 deletion src/openhuman/workflows/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ pub(crate) use super::ops_create::{create_workflow_inner, slugify_workflow_name}
#[cfg(test)]
pub(crate) use super::ops_discover::discover_workflows_inner;
#[cfg(test)]
pub(crate) use super::ops_install::{derive_install_slug, normalize_install_url};
pub(crate) use super::ops_install::{
derive_install_slug, install_workflow_from_url_with_home, normalize_install_url,
};
#[cfg(test)]
pub(crate) use super::ops_types::{
MAX_NAME_LEN, RESOURCE_DIRS, SKILL_MD, TRUST_MARKER, WORKFLOW_MD, WORKFLOW_TOML,
Expand Down
50 changes: 40 additions & 10 deletions src/openhuman/workflows/ops_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ pub struct InstallWorkflowFromUrlOutcome {
/// * Frontmatter is validated — `name` and `description` are required per
/// the agentskills.io spec.
/// * The slug is derived from `metadata.id` when present, otherwise the
/// sanitized `name` field. Collision with an existing directory is fatal
/// (no silent overwrite).
/// sanitized `name` field. If the target directory already contains a
/// `SKILL.md`, the install is treated as an idempotent success and reports
/// that the skill is already installed. Other directory collisions remain
/// fatal, and existing files are never silently overwritten.
/// * Write is atomic: `SKILL.md.tmp` in the target dir, then `rename` on
/// success.
///
Expand All @@ -115,6 +117,15 @@ pub struct InstallWorkflowFromUrlOutcome {
pub async fn install_workflow_from_url(
workspace_dir: &Path,
params: InstallWorkflowFromUrlParams,
) -> Result<InstallWorkflowFromUrlOutcome, String> {
let home = dirs::home_dir();
install_workflow_from_url_with_home(workspace_dir, params, home.as_deref()).await
}

pub(crate) async fn install_workflow_from_url_with_home(
workspace_dir: &Path,
params: InstallWorkflowFromUrlParams,
home: Option<&Path>,
) -> Result<InstallWorkflowFromUrlOutcome, String> {
let raw_url = params.url.trim().to_string();
validate_install_url(&raw_url)?;
Expand Down Expand Up @@ -148,10 +159,9 @@ pub async fn install_workflow_from_url(
"[skills] install_workflow_from_url: entry"
);

let home = dirs::home_dir();
let trusted_before = is_workspace_trusted(workspace_dir);
let before: std::collections::HashSet<String> =
discover_workflows_inner(home.as_deref(), Some(workspace_dir), trusted_before)
discover_workflows_inner(home, Some(workspace_dir), trusted_before)
.into_iter()
.map(|s| s.name)
.collect();
Expand Down Expand Up @@ -256,16 +266,36 @@ pub async fn install_workflow_from_url(
// a `<ws>/.openhuman/trust` marker and would render the install invisible to the
// skills list until the user opts the workspace into trust.
let skills_root = home
.as_deref()
.ok_or_else(|| "write failed: unable to resolve home directory".to_string())?
.join(".openhuman")
.join("skills");
let target_dir = skills_root.join(&slug);
if target_dir.exists() {
return Err(format!(
"skill already installed as {slug:?} at {}",
target_dir.display()
));
let target_file = target_dir.join(SKILL_MD);
if !target_file.is_file() {
return Err(format!(
"skill install target already exists but has no {SKILL_MD}: {}",
target_dir.display()
));
}

tracing::info!(
raw_url = %redacted_raw_url,
fetch_url = %redacted_fetch_url,
slug = %slug,
target = %target_file.display(),
"[skills] install_workflow_from_url: already installed"
);

return Ok(InstallWorkflowFromUrlOutcome {
url: raw_url,
stdout: format!(
"Skill {slug:?} is already installed at {}",
target_file.display()
),
stderr: parse_warnings.join("\n"),
new_skills: Vec::new(),
});
}

std::fs::create_dir_all(&target_dir).map_err(|e| {
Expand Down Expand Up @@ -319,7 +349,7 @@ pub async fn install_workflow_from_url(
}

let trusted_after = is_workspace_trusted(workspace_dir);
let after = discover_workflows_inner(home.as_deref(), Some(workspace_dir), trusted_after);
let after = discover_workflows_inner(home, Some(workspace_dir), trusted_after);
let new_skills: Vec<String> = after
.into_iter()
.map(|s| s.name)
Expand Down
Loading
Loading