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
19 changes: 17 additions & 2 deletions docs/architecture/cli-product-line-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,8 @@ Hook C0:脱敏发现 -> 精确命令预览 | 指纹确认 -> 原子发布本
仍等于导入值的字段;用户后续修改、来源变化或部分重新导入造成冲突时,逐字段选择“保留 BitFun / 重新导入
外部 / 手工处理”,不得整批覆盖。

下表描述目标覆盖范围;当前能力仅限上文列出的 MCP C0a 与 Hook C0,不能由本表推导出其他资产已经实现。
下表描述目标覆盖范围;显式配置导入能力仍仅限上文列出的 MCP C0a 与 Hook C0。Skill 的原地发现与调用使用下文所述
的既有 Skill Registry 路径,不属于显式配置导入,也不能由本表推导出其他资产已经实现。

| 来源 | 目标可导入 | 目标不导入 |
|---|---|---|
Expand All @@ -536,7 +537,21 @@ Hook C0:脱敏发现 -> 精确命令预览 | 指纹确认 -> 原子发布本
展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。
Skill Registry 还保留来源资产声明的隐式调用意图:Claude `SKILL.md` 的 `disable-model-invocation: true` 与 Codex
`agents/openai.yaml` 的 `policy.allow_implicit_invocation: false` 都会让 Skill 不进入模型自动目录,但不影响 `/skills`、
模式配置和显式加载。BitFun 不继承来源产品的全局启停策略,也未实现 URL/额外根和自动变化监听。
模式配置和显式加载。Claude `user-invocable: false` 与上述模型调用策略相互独立:它只让 Skill 不进入 Web/CLI 的用户
调用选择器,不从管理目录删除,也不改变模型目录或现有启停状态。缺省时 Skill 可由用户调用;`argument-hint` 只作为
选择器提示显示,不自动写入输入框。Web 与 CLI/TUI 选择 Skill 后统一生成 `[$skill-name]` 引用,用户可以直接在后面继续
输入参数,不需要先导入、复制或学习第二种启用流程。

显式调用仍由现有 `SkillTool` 和 Skill Registry 加载实际优先级赢家,本地与 Remote 分支沿用同一加载语义。工具的可选
`arguments` 字段使用共享的静态模板展开:支持原始 `$ARGUMENTS`、从零开始的 `$ARGUMENTS[N]` 和 `$N`、单/双引号
分组以及 `\$` 转义;缺失的位置参数保留原占位符,模板没有未转义占位符时才追加 `ARGUMENTS:` 段。该展开器只处理
字符串,不执行命令、脚本或动态变量。未携带 `arguments` 的旧工具调用保持原 Skill 正文不变。

这项能力不新增导入记录、来源图、后台 watcher 或第二套刷新生命周期。工作区查询继续按现有 Registry 路径扫描,用户
缓存继续使用已有刷新入口,CLI 的 `/reload-skills` 仍是明确的手动刷新方式;运行期不承诺对所有来源做文件监听或热重载。
本切片也不实现 `allowed-tools`、`context`、`fork`、`agent`、`model`、命名参数、动态 shell/runtime 变量、URL、祖先目录
级联、插件 Runtime 或 OpenCode 复杂 Hook。后续只有在存在稳定消费方和独立安全边界时才扩展这些语义。

Skill 说明和索引可按 L1 处理,脚本、URL 和外部依赖按 L2 确认;显式导入仍不得复制凭据值。MCP 启用状态按
OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示。

Expand Down
11 changes: 8 additions & 3 deletions src/apps/cli/src/modes/chat/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,17 @@ impl ChatMode {
rt_handle.block_on(async {
let registry = SkillRegistry::global();
registry
.get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type))
.get_user_invocable_skills_for_workspace(
Some(workspace.as_path()),
Some(&agent_type),
)
.await
})
});

if skills.is_empty() {
chat_state.add_system_message(format!(
"No enabled skills found for agent mode '{}'. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/, or enable built-in skills for this mode.",
"No user-invocable skills found for agent mode '{}'. Add or enable a skill, then check its user-invocable metadata.",
self.agent_type
));
return;
Expand Down Expand Up @@ -133,7 +136,7 @@ impl ChatMode {

/// Apply skill selection: fill input box with execution command
fn apply_skill_selection(&self, selected: &SkillItem, chat_view: &mut ChatView) {
chat_view.set_input(&format!("Execute the {} skill.", selected.name));
chat_view.set_input(&selected.invocation_text());
}

fn set_skill_enabled(
Expand Down Expand Up @@ -211,6 +214,7 @@ impl ChatMode {
default_enabled: true,
is_shadowed: info.is_shadowed,
shadowed_by_key: info.shadowed_by_key,
argument_hint: info.argument_hint,
}
}

Expand All @@ -227,6 +231,7 @@ impl ChatMode {
default_enabled: info.default_enabled,
is_shadowed: info.skill.is_shadowed,
shadowed_by_key: info.skill.shadowed_by_key,
argument_hint: info.skill.argument_hint,
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/apps/cli/src/ui/skill_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ pub(crate) struct SkillItem {
pub default_enabled: bool,
pub is_shadowed: bool,
pub shadowed_by_key: Option<String>,
pub argument_hint: Option<String>,
}

impl SkillItem {
pub(crate) fn invocation_text(&self) -> String {
format!("[${}] ", self.name)
}

fn display_source_label(&self) -> &str {
let label = self.source_label.trim();
if !label.is_empty() {
Expand Down Expand Up @@ -476,6 +481,15 @@ impl SkillSelectorState {
));
}
spans.push(Span::styled(skill.name.clone(), name_style));
if let Some(argument_hint) = skill
.argument_hint
.as_deref()
.map(str::trim)
.filter(|hint| !hint.is_empty())
{
spans.push(Span::raw(" "));
spans.push(Span::styled(argument_hint.to_string(), desc_style));
}
if !status.is_empty() {
spans.push(Span::styled(status, theme.style(StyleKind::Muted)));
}
Expand Down Expand Up @@ -532,9 +546,18 @@ mod tests {
default_enabled: true,
is_shadowed: false,
shadowed_by_key: None,
argument_hint: None,
}
}

#[test]
fn skill_invocation_text_uses_the_shared_inline_token_without_inserting_the_hint() {
let mut skill = skill_item("project::bitfun::pdf", "BitFun");
skill.argument_hint = Some("[file] [focus]".to_string());

assert_eq!(skill.invocation_text(), "[$pdf] ");
}

#[test]
fn skill_coverage_uses_winner_source_label() {
let winner = skill_item("project::bitfun::pdf", "BitFun");
Expand Down
11 changes: 8 additions & 3 deletions src/apps/cli/src/ui/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1923,14 +1923,17 @@ impl StartupPage {
tokio::runtime::Handle::current().block_on(async {
let registry = SkillRegistry::global();
registry
.get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type))
.get_user_invocable_skills_for_workspace(
Some(workspace.as_path()),
Some(&agent_type),
)
.await
})
});

if skills.is_empty() {
self.status = Some(format!(
"No enabled skills found for agent mode '{}'.",
"No user-invocable skills found for agent mode '{}'.",
self.agent_type
));
return;
Expand Down Expand Up @@ -1978,7 +1981,7 @@ impl StartupPage {
SkillSelectorAction::ConfigureSkills => self.show_skill_config_selector(),
SkillSelectorAction::Execute(selected) => {
self.skill_selector.hide();
self.set_input(&format!("Execute the {} skill.", selected.name));
self.set_input(&selected.invocation_text());
}
SkillSelectorAction::Toggle(selected) => {
self.set_skill_enabled(&selected, !selected.enabled);
Expand Down Expand Up @@ -2053,6 +2056,7 @@ impl StartupPage {
default_enabled: true,
is_shadowed: info.is_shadowed,
shadowed_by_key: info.shadowed_by_key,
argument_hint: info.argument_hint,
}
}

Expand All @@ -2069,6 +2073,7 @@ impl StartupPage {
default_enabled: info.default_enabled,
is_shadowed: info.skill.is_shadowed,
shadowed_by_key: info.skill.shadowed_by_key,
argument_hint: info.skill.argument_hint,
}
}

Expand Down
53 changes: 2 additions & 51 deletions src/crates/adapters/claude-code-adapter/src/command_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use bitfun_product_domains::external_sources::{
PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot,
PromptCommandSourceProvider, SourceKey, SourceQualifiedCommandId,
};
use bitfun_services_core::markdown::FrontMatterMarkdown;
use bitfun_services_core::markdown::{expand_prompt_template_arguments, FrontMatterMarkdown};
use bitfun_static_hook_support::{
collect_bounded_regular_files, read_bounded_text, BoundedDirectoryWalkError,
BoundedDirectoryWalkLimits, BoundedTextRead,
Expand Down Expand Up @@ -230,7 +230,7 @@ impl PromptCommandSourceProvider for ClaudeCodeCommandProvider {
}
match &command.availability {
PromptCommandAvailability::Available => Ok(ExpandedPromptCommand {
content: expand_template(&command.template, arguments),
content: expand_prompt_template_arguments(&command.template, arguments),
}),
PromptCommandAvailability::Restricted { reason, .. }
| PromptCommandAvailability::Invalid { reason } => {
Expand Down Expand Up @@ -686,55 +686,6 @@ fn command_definition(
Ok(definition)
}

fn expand_template(template: &str, arguments: &str) -> String {
let args = argument_regex()
.find_iter(arguments)
.map(|item| {
let value = item.as_str();
if value.len() >= 2
&& ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
{
value[1..value.len() - 1].to_string()
} else {
value.to_string()
}
})
.collect::<Vec<_>>();
let with_positions =
placeholder_regex().replace_all(template, |capture: &regex::Captures<'_>| {
let position = capture
.get(1)
.or_else(|| capture.get(2))
.and_then(|value| value.as_str().parse::<usize>().ok())
.unwrap_or(usize::MAX);
args.get(position).cloned().unwrap_or_default()
});
let uses_arguments = template.contains("$ARGUMENTS");
let uses_positions = placeholder_regex().is_match(template);
let mut expanded = with_positions.replace("$ARGUMENTS", arguments);
if !uses_arguments && !uses_positions && !arguments.trim().is_empty() {
expanded.push_str("\n\nARGUMENTS: ");
expanded.push_str(arguments);
}
expanded.trim().to_string()
}

fn argument_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r#"(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)"#)
.expect("static Claude Code argument regex compiles")
})
}

fn placeholder_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"\$(?:ARGUMENTS\[(\d+)\]|(\d+))").expect("static placeholder regex compiles")
})
}

fn shell_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"!`[^`]+`").expect("static shell regex compiles"))
Expand Down
20 changes: 20 additions & 0 deletions src/crates/adapters/claude-code-adapter/tests/command_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,26 @@ fn arguments_without_a_placeholder_use_claude_codes_arguments_section() {
);
}

#[test]
fn missing_and_escaped_argument_placeholders_remain_literal() {
let fixture = Fixture::new();
write(
fixture.user_claude.join("commands/literal.md"),
r"Use $0, keep $ARGUMENTS[3], and show \$ARGUMENTS plus \$1",
);

let provider = fixture.provider();
let snapshot = provider.discover(&fixture.context()).unwrap();

assert_eq!(
provider
.expand(&snapshot.commands[0], "alpha beta")
.unwrap()
.content,
"Use alpha, keep $ARGUMENTS[3], and show $ARGUMENTS plus $1"
);
}

#[test]
fn case_insensitive_duplicate_in_one_layer_is_invalid_and_deterministic() {
let fixture = Fixture::new();
Expand Down
Loading