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
3 changes: 3 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ _Avoid_: **source**, **registry** — both are deprecated command aliases now fo
Where a single installed skill came from — the install **intent** (what the user asked for), recorded as provenance on the installed skill. Variants: `git` (url + ref + subdir), `local` (a filesystem path — directory *or* `.zip` — plus `editable`, dir-only), `zip-url` (a remote zip), and `repository` (a *reference into* a configured **Repository**: `{repo, skill, version?}`). The `repository` variant is the only one **Version constraint** / ADR-0004 governs; `git`/`local`/`zip-url` are ref-based and versionless. `Origin` is intent only: the **resolved** facts (exact commit, resolved version, checksum, timestamps) live in the **Lock**, not in `Origin`. It is the single canonical model — replacing the former `SkillSource` (two colliding types), `SourceType`, `SourceSpecificFields`, and the flat `source_*` fields on the manifest/lock/skill records.
_Avoid_: **source** (banned, see above); do not blur `Origin::repository` (a reference; always names a concrete Repository) with **Repository** (the configured place itself).

**Origin ref**:
The *textual* form of an **Origin** — the single string a user types to name where a skill comes from (a git URL, a `.zip` URL, a local path, or `scope/skill`). It is resolved into a typed `Origin` by one seam, `Origin::infer(&str)`, which is the **only** place ref→`Origin` inference lives: both the CLI (`add`) and the HTTP install route call it, so the browser never re-implements detection. An Origin ref is *unresolved intent as text*; the `Origin` is *typed intent*; the **Lock** holds *resolved facts*. (Do not call it a "source" — banned.)

### Serving surfaces

Two orthogonal, first-class ways to expose skills to a client — distinguished by *protocol/consumer*, not redundant:
Expand Down
63 changes: 59 additions & 4 deletions crates/fastskill-cli/src/commands/add/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,64 @@ fn validate_folder_has_skill(path: &Path) -> CliResult<()> {
}

/// Build the install-intent [`Origin`] for the common (single-skill,
/// project-level) add path from the classified `source` and parsed args
/// (ADR-0005: `detect_skill_source` → `Origin`, feeding `add_from_origin`).
fn build_origin(source: &SkillSource, args: &AddArgs) -> CliResult<Origin> {
/// project-level) add path (ADR-0005 / spec 003 Phase 3: `infer_origin` is the
/// one canonical string → `Origin` inference path — the CLI no longer keeps
/// its own private copy of that classification logic).
///
/// `--source-type` is an explicit CLI override of source classification (not
/// part of the Origin-ref inference contract itself, and not exercised by the
/// browser UI); it keeps its own narrow per-variant construction
/// (`build_origin_explicit_source_type`) rather than being routed through the
/// core seam. The natural (no `--source-type`) path below — the common case —
/// delegates the raw `args.source` string straight to
/// `FastSkillService::infer_origin`, then applies the CLI's explicit
/// `--branch`/`--tag`/`--editable` overrides on top of the inferred `Origin`.
async fn build_origin(
service: &FastSkillService,
source: &SkillSource,
args: &AddArgs,
) -> CliResult<Origin> {
if args.source_type.is_some() {
return build_origin_explicit_source_type(source, args);
}

let mut origin = service.infer_origin(&args.source).await?;

match &mut origin {
Origin::Local { path, editable } => {
// `infer_origin` deliberately does not canonicalize (the fetch
// step resolves relative paths against cwd) — the CLI still wants
// an upfront, actionable "path does not exist" error and an
// absolute path recorded in the Manifest, mirroring pre-seam
// behavior.
let canonical = path.canonicalize().map_err(|e| {
CliError::InvalidSource(format!(
"Failed to resolve absolute path for '{}': {}",
path.display(),
e
))
})?;
*path = canonical;
*editable = args.editable;
}
Origin::Git { r#ref, .. } => {
if let Some(b) = &args.branch {
*r#ref = GitRef::Branch(b.clone());
} else if matches!(r#ref, GitRef::Default) {
if let Some(t) = &args.tag {
*r#ref = GitRef::Tag(t.clone());
}
}
}
Origin::ZipUrl { .. } | Origin::Repository { .. } => {}
}

Ok(origin)
}

/// Pre-seam per-`SkillSource`-variant `Origin` construction, kept only for the
/// explicit `--source-type` override (see `build_origin`).
fn build_origin_explicit_source_type(source: &SkillSource, args: &AddArgs) -> CliResult<Origin> {
match source {
SkillSource::ZipFile(path) => {
let canonical = path.canonicalize().map_err(|e| {
Expand Down Expand Up @@ -528,7 +583,7 @@ pub async fn execute_add(service: &FastSkillService, args: AddArgs, global: bool
validate_skill_structure(path)?;
}

let origin = build_origin(&source, &args)?;
let origin = build_origin(service, &source, &args).await?;
let mode = if args.force {
AddMode::Update
} else {
Expand Down
108 changes: 22 additions & 86 deletions crates/fastskill-cli/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,44 +28,27 @@ pub fn service_error_to_cli(
CliError::Service(e)
}

/// Git repository information parsed from URL
#[derive(Debug, Clone)]
pub struct GitUrlInfo {
pub repo_url: String,
pub branch: Option<String>,
pub subdir: Option<PathBuf>,
}

/// Detect if input is a skill ID (format: skillid@version, skillid, or scope/skillid@version)
/// Git repository information parsed from URL.
///
/// Re-exported from the core Origin-ref inference seam (ADR-0005 / spec 003
/// Phase 3) rather than duplicated here — see `fastskill_core::core::origin_infer`.
pub use fastskill_core::core::origin_infer::GitUrlInfo;

/// Detect if input is a skill ID (format: skillid@version, skillid, or scope/skillid@version).
///
/// Thin wrapper over the core seam's classification rule (ADR-0005 / spec 003
/// Phase 3) — kept here only so the rest of the CLI's `SkillSource`
/// classification (which core has no notion of: Folder vs ZipFile, the
/// `--recursive`/`--global` paths, `--source-type` overrides) has a single
/// place to call it from.
pub fn is_skill_id(input: &str) -> bool {
// Skill ID format: alphanumeric with dashes/underscores, optionally followed by @version or @tag
// Can also be scoped: scope/skillid or scope/skillid@version
// Examples: pptx@1.2.3, web-scraper, my_skill@2.0.0, aroff@teste, dev-user/test-skill, org/my-skill@1.0.0
// Must not contain backslashes or colons (URL schemes)
if input.contains('\\') || input.contains(':') {
return false;
}

// Check if it looks like a skill ID (not a file path)
// Supports both unscoped (skillid) and scoped (scope/skillid) formats
// The @suffix can be a version (1.2.3) or any identifier (teste, label, etc.)
// This regex is a compile-time constant pattern, so it should never fail
#[allow(clippy::expect_used)]
let skill_id_pattern =
regex::Regex::new(r"^[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?(@[a-zA-Z0-9_.-]+)?$")
.expect("Invalid regex pattern");
skill_id_pattern.is_match(input)
fastskill_core::core::origin_infer::is_skill_id(input)
}

/// Parse skill ID and version from input (format: skillid@version)
/// Parse skill ID and version from input (format: skillid@version). Thin
/// wrapper over the core seam's ref splitting (see `is_skill_id`).
pub fn parse_skill_id(input: &str) -> (String, Option<String>) {
if let Some(at_pos) = input.find('@') {
let skill_id = input[..at_pos].to_string();
let version = input[at_pos + 1..].to_string();
(skill_id, Some(version))
} else {
(input.to_string(), None)
}
fastskill_core::core::origin_infer::parse_skill_id_ref(input)
}

/// Detect the type of skill source (zip, folder, git URL, remote zip URL, or skill ID)
Expand Down Expand Up @@ -116,59 +99,12 @@ pub enum SkillSource {
SkillId(String),
}

/// Parse git URL to extract repository information
/// Parse git URL to extract repository information. Thin wrapper over the
/// core seam's `parse_git_url` (ADR-0005 / spec 003 Phase 3) — the CLI keeps
/// only the `ServiceError` → `CliError` mapping.
pub fn parse_git_url(git_url: &str) -> CliResult<GitUrlInfo> {
let url = Url::parse(git_url)
.map_err(|e| CliError::InvalidSource(format!("Invalid git URL: {}", e)))?;

// Extract query parameters for branch
let query_branch = url
.query_pairs()
.find(|(key, _)| key == "branch")
.map(|(_, value)| value.to_string());

// For GitHub tree URLs, extract branch and subdir
let path_segments: Vec<&str> = url.path().split('/').filter(|s| !s.is_empty()).collect();
let mut branch = None;
let mut subdir = None;

if url.host_str() == Some("github.com")
&& path_segments.len() >= 3
&& path_segments.get(2) == Some(&"tree")
&& path_segments.len() >= 4
{
// GitHub tree URL: /org/repo/tree/branch[/subdir...]
branch = Some(path_segments[3].to_string());
if path_segments.len() > 4 {
subdir = Some(std::path::PathBuf::from(path_segments[4..].join("/")));
}
}

// Construct clean repo URL
let mut clean_url = url.clone();
clean_url.set_query(None);
let mut path = clean_url.path().to_string();

// Remove tree/branch/subdir from path for GitHub tree URLs
if url.host_str() == Some("github.com") && path.contains("/tree/") {
if let Some(tree_pos) = path.find("/tree/") {
path = path[..tree_pos].to_string();
}
}

// Ensure .git extension
if !path.ends_with(".git") {
path.push_str(".git");
}

clean_url.set_path(&path);
let repo_url = clean_url.to_string();

Ok(GitUrlInfo {
repo_url,
branch: branch.or(query_branch),
subdir,
})
fastskill_core::core::origin_infer::parse_git_url(git_url)
.map_err(|e| CliError::InvalidSource(e.to_string()))
}

/// Validate skill structure follows Claude Code standard.
Expand Down
4 changes: 4 additions & 0 deletions crates/fastskill-core/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod lock;
pub mod manifest;
pub mod metadata;
pub mod origin;
pub mod origin_infer;
pub mod project;
pub mod project_config;
pub mod reconciliation;
Expand Down Expand Up @@ -67,6 +68,9 @@ pub use metadata::{
// origin
pub use origin::{GitRef, Origin, Resolved};

// origin_infer (the Origin-ref inference seam, spec 003 Phase 3)
pub use origin_infer::{is_skill_id, parse_git_url, parse_skill_id_ref, GitUrlInfo};

// install seam
pub use install::{AddMode, AddOutcome, Fetched, UpdatePreflight};

Expand Down
Loading
Loading