feat: surface macros as first-class custom commands in the REPL
Implements the invocation and management surfaces from plans/custom-commands-design.md §4 and §6: - Top-level dispatch: an enabled macro <name> now runs as ".<name> [args]" from the command catch-all; runtime-disabled macros point at ".macro enable <name>", locked macros name the owning config, and unknown commands keep the existing error verbatim - .macro enable|disable <name>: runtime toggles over the in-memory global-level enabled_macros list (disable with no list materializes all-active-minus-name); toggles error when a role/agent/session allowlist owns the field - .set enabled_macros <csv|null> with workspace-then-global existence validation; .set key completion gains enabled_macros and the previously missing enabled_skills - Dynamic completion: enabled macros (with descriptions) join built-ins on ".<TAB>" without touching the static command registry; ".macro <TAB>" lists invocable macros (incl. built-in-shadowed ones) plus the enable/disable subcommands; second-arg completion offers toggle-eligible names - .list macros: enriched table (name, source, isolated, state, description) covering every resolver state incl. missing and shadowed rows; .help gains a custom-commands section - Session info/render and sysinfo display enabled_macros; Macro::load resolves workspace-then-global; enable/disable rejected as macro names in the creator
This commit is contained in:
@@ -75,7 +75,6 @@ pub enum MacroState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MacroState {
|
impl MacroState {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_invocable(&self) -> bool {
|
pub fn is_invocable(&self) -> bool {
|
||||||
matches!(self, MacroState::Enabled | MacroState::ShadowedBuiltin)
|
matches!(self, MacroState::Enabled | MacroState::ShadowedBuiltin)
|
||||||
}
|
}
|
||||||
@@ -95,7 +94,6 @@ pub struct DiscoveredMacro {
|
|||||||
/// One row of the resolved macro set. Missing allowlist entries produce rows
|
/// One row of the resolved macro set. Missing allowlist entries produce rows
|
||||||
/// with `source: None`.
|
/// with `source: None`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ResolvedMacro {
|
pub struct ResolvedMacro {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub source: Option<MacroSource>,
|
pub source: Option<MacroSource>,
|
||||||
@@ -116,7 +114,6 @@ pub struct MacroPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MacroPolicy {
|
impl MacroPolicy {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn effective(
|
pub fn effective(
|
||||||
global: &AppConfig,
|
global: &AppConfig,
|
||||||
role: Option<&Role>,
|
role: Option<&Role>,
|
||||||
@@ -199,7 +196,6 @@ impl MacroPolicy {
|
|||||||
/// The invocation target for `name`: the workspace entry when one shadows
|
/// The invocation target for `name`: the workspace entry when one shadows
|
||||||
/// a global entry, otherwise the single discovered entry. Missing rows
|
/// a global entry, otherwise the single discovered entry. Missing rows
|
||||||
/// are never returned.
|
/// are never returned.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn find(&self, name: &str) -> Option<&ResolvedMacro> {
|
pub fn find(&self, name: &str) -> Option<&ResolvedMacro> {
|
||||||
self.macros
|
self.macros
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ pub struct Macro {
|
|||||||
|
|
||||||
impl Macro {
|
impl Macro {
|
||||||
pub fn load(name: &str) -> Result<Macro> {
|
pub fn load(name: &str) -> Result<Macro> {
|
||||||
let path = paths::macro_file(name);
|
let workspace_path = paths::workspace_macros_dir().join(format!("{name}.yaml"));
|
||||||
|
let path = if workspace_path.exists() {
|
||||||
|
workspace_path
|
||||||
|
} else {
|
||||||
|
paths::macro_file(name)
|
||||||
|
};
|
||||||
let err = || format!("Failed to load macro '{name}' at '{}'", path.display());
|
let err = || format!("Failed to load macro '{name}' at '{}'", path.display());
|
||||||
let content = read_to_string(&path).with_context(err)?;
|
let content = read_to_string(&path).with_context(err)?;
|
||||||
let value: Macro = serde_yaml::from_str(&content).with_context(err)?;
|
let value: Macro = serde_yaml::from_str(&content).with_context(err)?;
|
||||||
|
|||||||
+1
-3
@@ -31,10 +31,8 @@ pub use self::app_config::AppConfig;
|
|||||||
pub use self::app_state::AppState;
|
pub use self::app_state::AppState;
|
||||||
pub use self::input::Input;
|
pub use self::input::Input;
|
||||||
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use self::macro_policy::{
|
pub use self::macro_policy::{
|
||||||
DiscoveredMacro, MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState,
|
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
||||||
RESERVED_MACRO_NAMES, ResolvedMacro, discover_macros,
|
|
||||||
};
|
};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_instructions};
|
pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_instructions};
|
||||||
|
|||||||
@@ -474,11 +474,6 @@ pub fn list_macros() -> Vec<String> {
|
|||||||
list_file_names(macros_dir(), ".yaml")
|
list_file_names(macros_dir(), ".yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_macro(name: &str) -> bool {
|
|
||||||
let names = list_macros();
|
|
||||||
names.contains(&name.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_skills() -> Vec<String> {
|
pub fn list_skills() -> Vec<String> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
|||||||
+416
-29
@@ -7,7 +7,8 @@ use super::todo::TodoList;
|
|||||||
use super::tool_scope::{McpRuntime, ToolScope};
|
use super::tool_scope::{McpRuntime, ToolScope};
|
||||||
use super::{
|
use super::{
|
||||||
AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE,
|
AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE,
|
||||||
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, RIGHT_PROMPT, Role,
|
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel,
|
||||||
|
MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, RIGHT_PROMPT, ResolvedMacro, Role,
|
||||||
RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags,
|
RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags,
|
||||||
TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
|
TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
|
||||||
list_agents_with_descriptions, memory, paths,
|
list_agents_with_descriptions, memory, paths,
|
||||||
@@ -123,6 +124,104 @@ fn complete_skills_with_descriptions(names: Vec<String>) -> Vec<(String, Option<
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keys offered by `.set <TAB>` completion. `reasoning_effort` is appended at
|
||||||
|
/// completion time only when the current model supports reasoning levels.
|
||||||
|
const SET_COMPLETION_KEYS: [&str; 26] = [
|
||||||
|
"auto_continue",
|
||||||
|
"continuation_prompt",
|
||||||
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"enabled_macros",
|
||||||
|
"enabled_skills",
|
||||||
|
"enabled_tools",
|
||||||
|
"enabled_mcp_servers",
|
||||||
|
"inject_todo_instructions",
|
||||||
|
"inject_skill_instructions",
|
||||||
|
"skill_instructions",
|
||||||
|
"max_auto_continues",
|
||||||
|
"memory",
|
||||||
|
"save_session",
|
||||||
|
"compression_threshold",
|
||||||
|
"rag_reranker_model",
|
||||||
|
"rag_top_k",
|
||||||
|
"max_output_tokens",
|
||||||
|
"dry_run",
|
||||||
|
"function_calling_support",
|
||||||
|
"mcp_server_support",
|
||||||
|
"skills_enabled",
|
||||||
|
"stream",
|
||||||
|
"save",
|
||||||
|
"highlight",
|
||||||
|
"raw_markdown",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The new global-level `enabled_macros` list after toggling `name`, or
|
||||||
|
/// `None` when the toggle is a no-op (already in the requested state).
|
||||||
|
/// Disabling with no current list (all macros visible) materializes the list
|
||||||
|
/// as every active macro name minus `name`.
|
||||||
|
fn toggled_enabled_macros(
|
||||||
|
current: Option<&[String]>,
|
||||||
|
all_active: &[String],
|
||||||
|
name: &str,
|
||||||
|
enable: bool,
|
||||||
|
) -> Option<Vec<String>> {
|
||||||
|
match (current, enable) {
|
||||||
|
(None, true) => None,
|
||||||
|
(Some(list), true) => {
|
||||||
|
if list.iter().any(|v| v == name) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut list = list.to_vec();
|
||||||
|
list.push(name.to_string());
|
||||||
|
Some(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, false) => Some(
|
||||||
|
all_active
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.as_str() != name)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
(Some(list), false) => {
|
||||||
|
if list.iter().any(|v| v == name) {
|
||||||
|
Some(
|
||||||
|
list.iter()
|
||||||
|
.filter(|v| v.as_str() != name)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `.list macros` state column for a resolved row.
|
||||||
|
fn macro_state_display(
|
||||||
|
row: &ResolvedMacro,
|
||||||
|
lock_owner: impl Fn(MacroAllowlistLevel) -> String,
|
||||||
|
) -> String {
|
||||||
|
match &row.state {
|
||||||
|
MacroState::Enabled => "enabled".to_string(),
|
||||||
|
MacroState::DisabledRuntime => "disabled (runtime)".to_string(),
|
||||||
|
MacroState::Locked { level } => format!("locked ({} enabled_macros)", lock_owner(*level)),
|
||||||
|
MacroState::Missing => "missing".to_string(),
|
||||||
|
MacroState::ShadowedBuiltin => "shadowed (built-in)".to_string(),
|
||||||
|
MacroState::Invalid { reason } => format!("invalid ({reason})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `.list macros` source column: where the definition file lives, or `-`
|
||||||
|
/// for allowlist entries with no installed file.
|
||||||
|
fn macro_source_display(source: Option<MacroSource>) -> String {
|
||||||
|
match source {
|
||||||
|
Some(source) => source.to_string(),
|
||||||
|
None => "-".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum RenderMode {
|
pub enum RenderMode {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -1577,6 +1676,10 @@ impl RequestContext {
|
|||||||
"enabled_skills",
|
"enabled_skills",
|
||||||
super::format_option_value(&role.enabled_skills().map(|v| v.join(","))),
|
super::format_option_value(&role.enabled_skills().map(|v| v.join(","))),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"enabled_macros",
|
||||||
|
super::format_option_value(&role.enabled_macros().map(|v| v.join(","))),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"max_output_tokens",
|
"max_output_tokens",
|
||||||
role.model()
|
role.model()
|
||||||
@@ -2357,6 +2460,9 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_macro(&self, app: &AppConfig, name: &str) -> Result<()> {
|
pub fn new_macro(&self, app: &AppConfig, name: &str) -> Result<()> {
|
||||||
|
if RESERVED_MACRO_NAMES.contains(&name) {
|
||||||
|
bail!("'{name}' is a reserved macro name");
|
||||||
|
}
|
||||||
if self.macro_flag {
|
if self.macro_flag {
|
||||||
bail!("No macro");
|
bail!("No macro");
|
||||||
}
|
}
|
||||||
@@ -2374,12 +2480,150 @@ impl RequestContext {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The resolved macro set for the active context (workspace + global
|
||||||
|
/// discovery, effective `enabled_macros` allowlist, built-in shadowing).
|
||||||
|
pub fn macro_policy(&self) -> MacroPolicy {
|
||||||
|
MacroPolicy::effective(
|
||||||
|
&self.app.config,
|
||||||
|
self.role.as_ref(),
|
||||||
|
self.agent.as_ref(),
|
||||||
|
self.session.as_ref(),
|
||||||
|
&crate::repl::builtin_command_names(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A human-readable name for the config level whose `enabled_macros`
|
||||||
|
/// allowlist restricts a macro, e.g. `agent:oracle` or `role:coder`.
|
||||||
|
pub fn macro_lock_owner(&self, level: MacroAllowlistLevel) -> String {
|
||||||
|
let name = match level {
|
||||||
|
MacroAllowlistLevel::Session => self.session.as_ref().map(|s| s.name()),
|
||||||
|
MacroAllowlistLevel::Agent => self.agent.as_ref().map(|a| a.name()),
|
||||||
|
MacroAllowlistLevel::Role => self.role.as_ref().map(|r| r.name()),
|
||||||
|
MacroAllowlistLevel::Global => return "global config".to_string(),
|
||||||
|
};
|
||||||
|
match name {
|
||||||
|
Some(name) => format!("{level}:{name}"),
|
||||||
|
None => level.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables or disables a macro by editing the in-memory global-level
|
||||||
|
/// `enabled_macros` list. Errors when a role/agent/session allowlist is
|
||||||
|
/// active, since a global-level write would be silently shadowed.
|
||||||
|
pub fn macro_toggle(&mut self, name: &str, enable: bool) -> Result<()> {
|
||||||
|
let restricting_level = if self
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.enabled_macros())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
Some(MacroAllowlistLevel::Session)
|
||||||
|
} else if self
|
||||||
|
.agent
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| a.enabled_macros())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
Some(MacroAllowlistLevel::Agent)
|
||||||
|
} else if self
|
||||||
|
.role
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.enabled_macros())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
Some(MacroAllowlistLevel::Role)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(level) = restricting_level {
|
||||||
|
bail!(
|
||||||
|
"Macro toggles are restricted by {} enabled_macros; edit enabled_macros there",
|
||||||
|
self.macro_lock_owner(level)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let policy = self.macro_policy();
|
||||||
|
match policy.find(name).map(|row| &row.state) {
|
||||||
|
None => bail!("Unknown macro '{name}'"),
|
||||||
|
Some(MacroState::Invalid { reason }) => bail!("Macro '{name}' is invalid: {reason}"),
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
let all_active: Vec<String> = policy
|
||||||
|
.macros
|
||||||
|
.iter()
|
||||||
|
.filter(|row| {
|
||||||
|
row.source.is_some()
|
||||||
|
&& !row.shadowed_by_workspace
|
||||||
|
&& !matches!(row.state, MacroState::Missing | MacroState::Invalid { .. })
|
||||||
|
})
|
||||||
|
.map(|row| row.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let action = if enable { "enabled" } else { "disabled" };
|
||||||
|
match toggled_enabled_macros(
|
||||||
|
self.app.config.enabled_macros.as_deref(),
|
||||||
|
&all_active,
|
||||||
|
name,
|
||||||
|
enable,
|
||||||
|
) {
|
||||||
|
Some(list) => {
|
||||||
|
self.update_app_config(|app| app.enabled_macros = Some(list));
|
||||||
|
println!("Macro '{name}' {action}");
|
||||||
|
}
|
||||||
|
None => println!("Macro '{name}' is already {action}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The macros offered by top-level `.<TAB>` completion: enabled rows
|
||||||
|
/// only. Macros shadowed by a built-in command never appear here; they
|
||||||
|
/// stay reachable via `.macro <name>`.
|
||||||
|
pub fn visible_macro_completions(&self) -> Vec<(String, Option<String>)> {
|
||||||
|
self.macro_policy()
|
||||||
|
.macros
|
||||||
|
.into_iter()
|
||||||
|
.filter(|row| row.state == MacroState::Enabled && !row.shadowed_by_workspace)
|
||||||
|
.map(|row| (row.name, row.description))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_assets(&self, kind: &str) -> Result<()> {
|
pub fn list_assets(&self, kind: &str) -> Result<()> {
|
||||||
match kind {
|
match kind {
|
||||||
"roles" => print_asset_names("roles", &paths::list_roles(true)),
|
"roles" => print_asset_names("roles", &paths::list_roles(true)),
|
||||||
"sessions" => print_asset_names("sessions", &self.list_sessions()),
|
"sessions" => print_asset_names("sessions", &self.list_sessions()),
|
||||||
"rags" => print_asset_names("RAGs", &paths::list_rags()),
|
"rags" => print_asset_names("RAGs", &paths::list_rags()),
|
||||||
"macros" => print_asset_names("macros", &paths::list_macros()),
|
"macros" => {
|
||||||
|
let policy = self.macro_policy();
|
||||||
|
if policy.macros.is_empty() {
|
||||||
|
println!("No macros found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Macros:");
|
||||||
|
let header = format!(
|
||||||
|
" {:<24} {:<10} {:<9} {:<40} {}",
|
||||||
|
"name", "source", "isolated", "state", "description"
|
||||||
|
);
|
||||||
|
println!("{header}");
|
||||||
|
for row in &policy.macros {
|
||||||
|
let source = macro_source_display(row.source);
|
||||||
|
let isolated = match row.isolated {
|
||||||
|
Some(true) => "yes",
|
||||||
|
Some(false) => "no",
|
||||||
|
None => "-",
|
||||||
|
};
|
||||||
|
let state = macro_state_display(row, |level| self.macro_lock_owner(level));
|
||||||
|
let description = row.description.as_deref().unwrap_or_default();
|
||||||
|
let line = format!(
|
||||||
|
" {:<24} {:<10} {:<9} {:<40} {}",
|
||||||
|
row.name, source, isolated, state, description
|
||||||
|
);
|
||||||
|
println!("{}", line.trim_end());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
"agents" => {
|
"agents" => {
|
||||||
let entries = list_agents_with_descriptions();
|
let entries = list_agents_with_descriptions();
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
@@ -2728,6 +2972,23 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
self.update_app_config(|app| app.enabled_skills = parsed.clone());
|
self.update_app_config(|app| app.enabled_skills = parsed.clone());
|
||||||
}
|
}
|
||||||
|
"enabled_macros" => {
|
||||||
|
let raw: Option<String> = super::parse_value(value)?;
|
||||||
|
let parsed: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
||||||
|
if let Some(names) = parsed.as_ref() {
|
||||||
|
let policy = self.macro_policy();
|
||||||
|
for name in names {
|
||||||
|
if !policy
|
||||||
|
.macros
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.source.is_some() && &m.name == name)
|
||||||
|
{
|
||||||
|
bail!("macro '{name}' is not installed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.update_app_config(|app| app.enabled_macros = parsed.clone());
|
||||||
|
}
|
||||||
"skills_enabled" => {
|
"skills_enabled" => {
|
||||||
let value: Option<bool> = super::parse_value(value)?;
|
let value: Option<bool> = super::parse_value(value)?;
|
||||||
if let Some(session) = self.session.as_mut() {
|
if let Some(session) = self.session.as_mut() {
|
||||||
@@ -3001,7 +3262,28 @@ impl RequestContext {
|
|||||||
values.push("remote".to_string());
|
values.push("remote".to_string());
|
||||||
super::map_completion_values(values)
|
super::map_completion_values(values)
|
||||||
}
|
}
|
||||||
".macro" => super::map_completion_values(paths::list_macros()),
|
".macro" => {
|
||||||
|
let policy = self.macro_policy();
|
||||||
|
let mut values: Vec<(String, Option<String>)> = policy
|
||||||
|
.macros
|
||||||
|
.iter()
|
||||||
|
.filter(|row| {
|
||||||
|
row.source.is_some()
|
||||||
|
&& !row.shadowed_by_workspace
|
||||||
|
&& row.state.is_invocable()
|
||||||
|
})
|
||||||
|
.map(|row| (row.name.clone(), row.description.clone()))
|
||||||
|
.collect();
|
||||||
|
values.push((
|
||||||
|
"enable ".to_string(),
|
||||||
|
Some("Re-enable a runtime-disabled macro".to_string()),
|
||||||
|
));
|
||||||
|
values.push((
|
||||||
|
"disable ".to_string(),
|
||||||
|
Some("Disable a macro for the rest of this process".to_string()),
|
||||||
|
));
|
||||||
|
values
|
||||||
|
}
|
||||||
".reasoning" => {
|
".reasoning" => {
|
||||||
let levels = self.current_model().reasoning_levels();
|
let levels = self.current_model().reasoning_levels();
|
||||||
levels.iter().map(|v| (v.clone(), None)).collect()
|
levels.iter().map(|v| (v.clone(), None)).collect()
|
||||||
@@ -3016,32 +3298,7 @@ impl RequestContext {
|
|||||||
None => vec![],
|
None => vec![],
|
||||||
},
|
},
|
||||||
".set" => {
|
".set" => {
|
||||||
let mut values = vec![
|
let mut values = SET_COMPLETION_KEYS.to_vec();
|
||||||
"auto_continue",
|
|
||||||
"continuation_prompt",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"enabled_tools",
|
|
||||||
"enabled_mcp_servers",
|
|
||||||
"inject_todo_instructions",
|
|
||||||
"inject_skill_instructions",
|
|
||||||
"skill_instructions",
|
|
||||||
"max_auto_continues",
|
|
||||||
"memory",
|
|
||||||
"save_session",
|
|
||||||
"compression_threshold",
|
|
||||||
"rag_reranker_model",
|
|
||||||
"rag_top_k",
|
|
||||||
"max_output_tokens",
|
|
||||||
"dry_run",
|
|
||||||
"function_calling_support",
|
|
||||||
"mcp_server_support",
|
|
||||||
"skills_enabled",
|
|
||||||
"stream",
|
|
||||||
"save",
|
|
||||||
"highlight",
|
|
||||||
"raw_markdown",
|
|
||||||
];
|
|
||||||
if !self.current_model().reasoning_levels().is_empty() {
|
if !self.current_model().reasoning_levels().is_empty() {
|
||||||
values.push("reasoning_effort");
|
values.push("reasoning_effort");
|
||||||
}
|
}
|
||||||
@@ -3185,6 +3442,25 @@ impl RequestContext {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
values = super::map_completion_values(candidates);
|
values = super::map_completion_values(candidates);
|
||||||
|
} else if cmd == ".macro"
|
||||||
|
&& (args.first() == Some(&"enable") || args.first() == Some(&"disable"))
|
||||||
|
&& args.len() == 2
|
||||||
|
{
|
||||||
|
let enable = args.first() == Some(&"enable");
|
||||||
|
values = self
|
||||||
|
.macro_policy()
|
||||||
|
.macros
|
||||||
|
.into_iter()
|
||||||
|
.filter(|row| row.source.is_some() && !row.shadowed_by_workspace)
|
||||||
|
.filter(|row| {
|
||||||
|
if enable {
|
||||||
|
row.state == MacroState::DisabledRuntime
|
||||||
|
} else {
|
||||||
|
row.state.is_invocable()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|row| (row.name, row.description))
|
||||||
|
.collect();
|
||||||
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|
||||||
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
|
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
|
||||||
{
|
{
|
||||||
@@ -6463,4 +6739,115 @@ mod tests {
|
|||||||
"install_mcp_config must add new bundled servers"
|
"install_mcp_config must add new bundled servers"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn strings(names: &[&str]) -> Vec<String> {
|
||||||
|
names.iter().map(|s| s.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggled_enabled_macros_covers_all_transitions() {
|
||||||
|
let all_active = strings(&["a", "b", "c"]);
|
||||||
|
type ToggleCase = (Option<Vec<String>>, &'static str, bool, Option<Vec<String>>);
|
||||||
|
let cases: Vec<ToggleCase> = vec![
|
||||||
|
(None, "a", true, None),
|
||||||
|
(Some(strings(&["a"])), "a", true, None),
|
||||||
|
(Some(strings(&["a"])), "b", true, Some(strings(&["a", "b"]))),
|
||||||
|
(None, "b", false, Some(strings(&["a", "c"]))),
|
||||||
|
(
|
||||||
|
Some(strings(&["a", "b"])),
|
||||||
|
"b",
|
||||||
|
false,
|
||||||
|
Some(strings(&["a"])),
|
||||||
|
),
|
||||||
|
(Some(strings(&["a"])), "b", false, None),
|
||||||
|
];
|
||||||
|
for (current, name, enable, expected) in cases {
|
||||||
|
let result = toggled_enabled_macros(current.as_deref(), &all_active, name, enable);
|
||||||
|
assert_eq!(
|
||||||
|
result, expected,
|
||||||
|
"current={current:?} name={name} enable={enable}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved(state: MacroState) -> ResolvedMacro {
|
||||||
|
ResolvedMacro {
|
||||||
|
name: "m".to_string(),
|
||||||
|
source: Some(MacroSource::Global),
|
||||||
|
description: None,
|
||||||
|
isolated: None,
|
||||||
|
shadowed_by_workspace: false,
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn macro_state_display_covers_all_states() {
|
||||||
|
let owner = |level: MacroAllowlistLevel| format!("{level}:test");
|
||||||
|
let cases = vec![
|
||||||
|
(MacroState::Enabled, "enabled"),
|
||||||
|
(MacroState::DisabledRuntime, "disabled (runtime)"),
|
||||||
|
(
|
||||||
|
MacroState::Locked {
|
||||||
|
level: MacroAllowlistLevel::Agent,
|
||||||
|
},
|
||||||
|
"locked (agent:test enabled_macros)",
|
||||||
|
),
|
||||||
|
(MacroState::Missing, "missing"),
|
||||||
|
(MacroState::ShadowedBuiltin, "shadowed (built-in)"),
|
||||||
|
(
|
||||||
|
MacroState::Invalid {
|
||||||
|
reason: "boom".to_string(),
|
||||||
|
},
|
||||||
|
"invalid (boom)",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (state, expected) in cases {
|
||||||
|
assert_eq!(macro_state_display(&resolved(state), owner), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn macro_source_display_names_source_or_dash() {
|
||||||
|
assert_eq!(
|
||||||
|
macro_source_display(Some(MacroSource::Workspace)),
|
||||||
|
"workspace"
|
||||||
|
);
|
||||||
|
assert_eq!(macro_source_display(Some(MacroSource::Global)), "global");
|
||||||
|
assert_eq!(macro_source_display(None), "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_completion_keys_include_enabled_skills_and_macros() {
|
||||||
|
assert!(SET_COMPLETION_KEYS.contains(&"enabled_skills"));
|
||||||
|
assert!(SET_COMPLETION_KEYS.contains(&"enabled_macros"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_macro_rejects_reserved_names() {
|
||||||
|
let ctx = create_test_ctx();
|
||||||
|
let app = ctx.app.config.clone();
|
||||||
|
for name in RESERVED_MACRO_NAMES {
|
||||||
|
let err = ctx.new_macro(&app, name).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
format!("'{name}' is a reserved macro name")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn macro_lock_owner_names_the_owning_config() {
|
||||||
|
let mut ctx = create_test_ctx();
|
||||||
|
assert_eq!(ctx.macro_lock_owner(MacroAllowlistLevel::Role), "role");
|
||||||
|
ctx.role = Some(Role::new("coder", "prompt"));
|
||||||
|
assert_eq!(
|
||||||
|
ctx.macro_lock_owner(MacroAllowlistLevel::Role),
|
||||||
|
"role:coder"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ctx.macro_lock_owner(MacroAllowlistLevel::Global),
|
||||||
|
"global config"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,9 @@ impl Session {
|
|||||||
if let Some(enabled_skills) = self.enabled_skills() {
|
if let Some(enabled_skills) = self.enabled_skills() {
|
||||||
data["enabled_skills"] = json!(enabled_skills);
|
data["enabled_skills"] = json!(enabled_skills);
|
||||||
}
|
}
|
||||||
|
if let Some(enabled_macros) = self.enabled_macros() {
|
||||||
|
data["enabled_macros"] = json!(enabled_macros);
|
||||||
|
}
|
||||||
if let Some(save_session) = self.save_session() {
|
if let Some(save_session) = self.save_session() {
|
||||||
data["save_session"] = save_session.into();
|
data["save_session"] = save_session.into();
|
||||||
}
|
}
|
||||||
@@ -325,6 +328,10 @@ impl Session {
|
|||||||
items.push(("enabled_skills", enabled_skills.join(",")));
|
items.push(("enabled_skills", enabled_skills.join(",")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(enabled_macros) = self.enabled_macros() {
|
||||||
|
items.push(("enabled_macros", enabled_macros.join(",")));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(save_session) = self.save_session() {
|
if let Some(save_session) = self.save_session() {
|
||||||
items.push(("save_session", save_session.to_string()));
|
items.push(("save_session", save_session.to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -74,7 +74,24 @@ impl Completer for ReplCompleter {
|
|||||||
format!("{name} ")
|
format!("{name} ")
|
||||||
};
|
};
|
||||||
create_suggestion(&name, description, span)
|
create_suggestion(&name, description, span)
|
||||||
}))
|
}));
|
||||||
|
|
||||||
|
let macros: Vec<(String, Option<String>)> = ctx
|
||||||
|
.visible_macro_completions()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, description)| (format!(".{name}"), description))
|
||||||
|
.filter(|(name, _)| {
|
||||||
|
command_filter.len() == 1 || name.starts_with(&command_filter[..2])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let macros = fuzzy_filter(macros, |(name, _)| name.as_str(), &command_filter);
|
||||||
|
suggestions.extend(macros.iter().map(|(name, description)| {
|
||||||
|
create_suggestion(
|
||||||
|
&format!("{name} "),
|
||||||
|
description.as_deref().unwrap_or_default(),
|
||||||
|
span,
|
||||||
|
)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
suggestions
|
suggestions
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-10
@@ -12,8 +12,8 @@ use crate::client::{
|
|||||||
oauth,
|
oauth,
|
||||||
};
|
};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
|
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
||||||
macro_execute,
|
StateFlags, macro_execute,
|
||||||
};
|
};
|
||||||
use crate::config::{AssetCategory, paths};
|
use crate::config::{AssetCategory, paths};
|
||||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||||
@@ -1073,15 +1073,44 @@ pub async fn run_repl_command(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
".macro" => match split_first_arg(args) {
|
".macro" => match split_first_arg(args) {
|
||||||
Some((name, extra)) => {
|
Some((sub @ ("enable" | "disable"), rest)) => {
|
||||||
let app = Arc::clone(&ctx.app.config);
|
match rest.and_then(|v| v.split_whitespace().next()) {
|
||||||
if !paths::has_macro(name) && extra.is_none() {
|
Some(name) => ctx.macro_toggle(name, sub == "enable")?,
|
||||||
ctx.new_macro(app.as_ref(), name)?;
|
None => println!("Usage: .macro {sub} <name>"),
|
||||||
} else {
|
|
||||||
macro_execute(ctx, name, extra, abort_signal.clone()).await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => println!("Usage: .macro <name> <text>..."),
|
Some((name, extra)) => {
|
||||||
|
let policy = ctx.macro_policy();
|
||||||
|
match policy.find(name).map(|row| &row.state) {
|
||||||
|
Some(state) if state.is_invocable() => {
|
||||||
|
macro_execute(ctx, name, extra, abort_signal.clone()).await?;
|
||||||
|
}
|
||||||
|
Some(MacroState::DisabledRuntime) => bail!(
|
||||||
|
r#"Macro '{name}' is disabled. Re-enable it with ".macro enable {name}""#
|
||||||
|
),
|
||||||
|
Some(MacroState::Locked { level }) => bail!(
|
||||||
|
"Macro '{name}' is restricted by {} enabled_macros",
|
||||||
|
ctx.macro_lock_owner(*level)
|
||||||
|
),
|
||||||
|
Some(MacroState::Invalid { reason }) => {
|
||||||
|
bail!("Macro '{name}' is invalid: {reason}")
|
||||||
|
}
|
||||||
|
Some(_) | None => {
|
||||||
|
if extra.is_none() {
|
||||||
|
let app = Arc::clone(&ctx.app.config);
|
||||||
|
ctx.new_macro(app.as_ref(), name)?;
|
||||||
|
} else {
|
||||||
|
macro_execute(ctx, name, extra, abort_signal.clone()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => println!(
|
||||||
|
r#"Usage:
|
||||||
|
.macro <name> [text]... # Execute a macro
|
||||||
|
.macro enable <name> # Re-enable a runtime-disabled macro
|
||||||
|
.macro disable <name> # Disable a macro for the rest of this process"#
|
||||||
|
),
|
||||||
},
|
},
|
||||||
".file" => match args {
|
".file" => match args {
|
||||||
Some(args) => {
|
Some(args) => {
|
||||||
@@ -1294,7 +1323,23 @@ pub async fn run_repl_command(
|
|||||||
println!("Usage: .vault <add|get|update|delete|list> [name]")
|
println!("Usage: .vault <add|get|update|delete|list> [name]")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ => unknown_command()?,
|
_ => {
|
||||||
|
let name = cmd.strip_prefix('.').unwrap_or(cmd);
|
||||||
|
let policy = ctx.macro_policy();
|
||||||
|
match policy.find(name).map(|row| &row.state) {
|
||||||
|
Some(MacroState::Enabled) => {
|
||||||
|
macro_execute(ctx, name, args, abort_signal.clone()).await?;
|
||||||
|
}
|
||||||
|
Some(MacroState::DisabledRuntime) => bail!(
|
||||||
|
r#"Macro '{name}' is disabled. Re-enable it with ".macro enable {name}""#
|
||||||
|
),
|
||||||
|
Some(MacroState::Locked { level }) => bail!(
|
||||||
|
"Macro '{name}' is restricted by {} enabled_macros",
|
||||||
|
ctx.macro_lock_owner(*level)
|
||||||
|
),
|
||||||
|
_ => unknown_command()?,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
if let Some(cmd) = try_extract_shell_command(line) {
|
if let Some(cmd) = try_extract_shell_command(line) {
|
||||||
@@ -1522,6 +1567,20 @@ fn unknown_command() -> Result<()> {
|
|||||||
bail!(r#"Unknown command. Type ".help" for additional help."#);
|
bail!(r#"Unknown command. Type ".help" for additional help."#);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name of every built-in REPL command (first word, without the leading
|
||||||
|
/// dot), sorted and deduplicated. Macros with one of these names are shadowed
|
||||||
|
/// by the built-in and stay reachable only via `.macro <name>`.
|
||||||
|
pub fn builtin_command_names() -> Vec<&'static str> {
|
||||||
|
let mut names: Vec<&'static str> = REPL_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.filter_map(|cmd| cmd.name.split_whitespace().next())
|
||||||
|
.filter_map(|name| name.strip_prefix('.'))
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
fn dump_repl_help() {
|
fn dump_repl_help() {
|
||||||
let head = REPL_COMMANDS
|
let head = REPL_COMMANDS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1532,6 +1591,10 @@ fn dump_repl_help() {
|
|||||||
r###"{head}
|
r###"{head}
|
||||||
{:<24} Run an arbitrary shell command (stdout/stderr stream to your terminal; Ctrl+C interrupts)
|
{:<24} Run an arbitrary shell command (stdout/stderr stream to your terminal; Ctrl+C interrupts)
|
||||||
|
|
||||||
|
Custom commands (macros): macros are coyote's custom commands. An enabled
|
||||||
|
macro <name> runs top-level as .<name> [args...], equivalent to ".macro <name>".
|
||||||
|
List them with ".list macros"; toggle them with ".macro enable|disable <name>".
|
||||||
|
|
||||||
Type ::: to start multi-line editing, type ::: to finish it.
|
Type ::: to start multi-line editing, type ::: to finish it.
|
||||||
Press Ctrl+O to open an editor for editing the input buffer.
|
Press Ctrl+O to open an editor for editing the input buffer.
|
||||||
Press Ctrl+C to cancel the response, Ctrl+D to exit the REPL."###,
|
Press Ctrl+C to cancel the response, Ctrl+D to exit the REPL."###,
|
||||||
@@ -1732,6 +1795,22 @@ mod tests {
|
|||||||
assert_eq!(REPL_COMMANDS.len(), 60);
|
assert_eq!(REPL_COMMANDS.len(), 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_command_names_are_sorted_deduped_first_words_without_dots() {
|
||||||
|
let names = builtin_command_names();
|
||||||
|
assert!(!names.is_empty());
|
||||||
|
for name in &names {
|
||||||
|
assert!(!name.starts_with('.'), "'{name}' should not keep the dot");
|
||||||
|
assert!(!name.contains(' '), "'{name}' should be a single word");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
names.windows(2).all(|w| w[0] < w[1]),
|
||||||
|
"names should be sorted and deduplicated: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(names.contains(&"help"));
|
||||||
|
assert!(names.contains(&"macro"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_all_start_with_dot() {
|
fn repl_commands_all_start_with_dot() {
|
||||||
for cmd in REPL_COMMANDS.iter() {
|
for cmd in REPL_COMMANDS.iter() {
|
||||||
|
|||||||
Reference in New Issue
Block a user