mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-06-06 01:35:42 +08:00
feat: import project instruction rules
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ archive/
|
|||||||
# Claw Code local artifacts
|
# Claw Code local artifacts
|
||||||
.claw/settings.local.json
|
.claw/settings.local.json
|
||||||
.claw/sessions/
|
.claw/sessions/
|
||||||
|
.claw/rules.local/
|
||||||
.clawhip/
|
.clawhip/
|
||||||
status-help.txt
|
status-help.txt
|
||||||
# Legacy Python port session scratch artifacts
|
# Legacy Python port session scratch artifacts
|
||||||
|
|||||||
17
USAGE.md
17
USAGE.md
@ -519,6 +519,23 @@ Runtime config is loaded in this order, with later entries overriding earlier on
|
|||||||
4. `<repo>/.claw/settings.json`
|
4. `<repo>/.claw/settings.json`
|
||||||
5. `<repo>/.claw/settings.local.json`
|
5. `<repo>/.claw/settings.local.json`
|
||||||
|
|
||||||
|
## Project instruction rules
|
||||||
|
|
||||||
|
In addition to root instruction files such as `CLAUDE.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
|
||||||
|
|
||||||
|
- `<repo>/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules.
|
||||||
|
- `<repo>/.claw/rules.local/` for personal local rules; this path is gitignored.
|
||||||
|
|
||||||
|
By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rulesImport": "none"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `"auto"` (the default) to import every supported framework, `"none"` to load only Claw instruction/rules files, or an array such as `["cursor", "copilot"]` to import selected frameworks.
|
||||||
|
|
||||||
## Mock parity harness
|
## Mock parity harness
|
||||||
|
|
||||||
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
|
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
|
||||||
|
|||||||
@ -95,6 +95,32 @@ pub struct RuntimeFeatureConfig {
|
|||||||
sandbox: SandboxConfig,
|
sandbox: SandboxConfig,
|
||||||
provider_fallbacks: ProviderFallbackConfig,
|
provider_fallbacks: ProviderFallbackConfig,
|
||||||
trusted_roots: Vec<String>,
|
trusted_roots: Vec<String>,
|
||||||
|
rules_import: RulesImportConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controls which external AI coding framework rules are imported into the system prompt.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum RulesImportConfig {
|
||||||
|
/// Import from all supported frameworks when files are detected.
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Do not import external framework rules; keep Claw instruction files only.
|
||||||
|
None,
|
||||||
|
/// Import only the named frameworks.
|
||||||
|
List(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RulesImportConfig {
|
||||||
|
#[must_use]
|
||||||
|
pub fn should_import(&self, framework: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Auto => true,
|
||||||
|
Self::None => false,
|
||||||
|
Self::List(frameworks) => frameworks
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.eq_ignore_ascii_case(framework)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ordered chain of fallback model identifiers used when the primary
|
/// Ordered chain of fallback model identifiers used when the primary
|
||||||
@ -353,6 +379,7 @@ impl ConfigLoader {
|
|||||||
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
||||||
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
||||||
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
||||||
|
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(RuntimeConfig {
|
Ok(RuntimeConfig {
|
||||||
@ -410,6 +437,7 @@ impl ConfigLoader {
|
|||||||
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
sandbox: parse_optional_sandbox_config(&merged_value)?,
|
||||||
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
|
||||||
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
|
||||||
|
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
let config = RuntimeConfig {
|
let config = RuntimeConfig {
|
||||||
@ -511,6 +539,11 @@ impl RuntimeConfig {
|
|||||||
&self.feature_config.trusted_roots
|
&self.feature_config.trusted_roots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn rules_import(&self) -> &RulesImportConfig {
|
||||||
|
&self.feature_config.rules_import
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge config-level default trusted roots with per-call roots.
|
/// Merge config-level default trusted roots with per-call roots.
|
||||||
///
|
///
|
||||||
/// Config roots are defaults and are kept first; per-call roots extend the
|
/// Config roots are defaults and are kept first; per-call roots extend the
|
||||||
@ -591,6 +624,11 @@ impl RuntimeFeatureConfig {
|
|||||||
&self.trusted_roots
|
&self.trusted_roots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn rules_import(&self) -> &RulesImportConfig {
|
||||||
|
&self.rules_import
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge this config's default trusted roots with per-call roots.
|
/// Merge this config's default trusted roots with per-call roots.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
pub fn trusted_roots_with_overrides(&self, per_call_roots: &[String]) -> Vec<String> {
|
||||||
@ -1162,6 +1200,37 @@ fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigE
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, ConfigError> {
|
||||||
|
let Some(object) = root.as_object() else {
|
||||||
|
return Ok(RulesImportConfig::default());
|
||||||
|
};
|
||||||
|
let Some(value) = object.get("rulesImport") else {
|
||||||
|
return Ok(RulesImportConfig::default());
|
||||||
|
};
|
||||||
|
|
||||||
|
match value {
|
||||||
|
JsonValue::String(value) if value.eq_ignore_ascii_case("auto") => Ok(RulesImportConfig::Auto),
|
||||||
|
JsonValue::String(value) if value.eq_ignore_ascii_case("none") => Ok(RulesImportConfig::None),
|
||||||
|
JsonValue::String(value) => Err(ConfigError::Parse(format!(
|
||||||
|
"merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names, got \"{value}\""
|
||||||
|
))),
|
||||||
|
JsonValue::Array(values) => values
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
item.as_str().map(str::to_string).ok_or_else(|| {
|
||||||
|
ConfigError::Parse(
|
||||||
|
"merged settings.rulesImport: array entries must be strings".to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map(RulesImportConfig::List),
|
||||||
|
_ => Err(ConfigError::Parse(
|
||||||
|
"merged settings.rulesImport: expected \"auto\", \"none\", or an array of framework names".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
|
||||||
match value {
|
match value {
|
||||||
"off" => Ok(FilesystemIsolationMode::Off),
|
"off" => Ok(FilesystemIsolationMode::Off),
|
||||||
@ -1724,6 +1793,72 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_rules_import_config() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let cwd = root.join("project");
|
||||||
|
let home = root.join("home").join(".claw");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
fs::create_dir_all(&cwd).expect("project dir");
|
||||||
|
fs::write(
|
||||||
|
home.join("settings.json"),
|
||||||
|
r#"{"rulesImport": ["cursor", "copilot"]}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
|
||||||
|
let loaded = ConfigLoader::new(&cwd, &home)
|
||||||
|
.load()
|
||||||
|
.expect("config should load");
|
||||||
|
|
||||||
|
assert!(loaded.rules_import().should_import("cursor"));
|
||||||
|
assert!(loaded.rules_import().should_import("copilot"));
|
||||||
|
assert!(!loaded.rules_import().should_import("windsurf"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rules_import_none_disables_external_frameworks() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let cwd = root.join("project");
|
||||||
|
let home = root.join("home").join(".claw");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
fs::create_dir_all(&cwd).expect("project dir");
|
||||||
|
fs::write(home.join("settings.json"), r#"{"rulesImport": "none"}"#)
|
||||||
|
.expect("write settings");
|
||||||
|
|
||||||
|
let loaded = ConfigLoader::new(&cwd, &home)
|
||||||
|
.load()
|
||||||
|
.expect("config should load");
|
||||||
|
|
||||||
|
assert!(!loaded.rules_import().should_import("cursor"));
|
||||||
|
assert!(!loaded.rules_import().should_import("copilot"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_rules_import_array_with_non_string_entries() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let cwd = root.join("project");
|
||||||
|
let home = root.join("home").join(".claw");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
fs::create_dir_all(&cwd).expect("project dir");
|
||||||
|
fs::write(
|
||||||
|
home.join("settings.json"),
|
||||||
|
r#"{"rulesImport": ["cursor", 42]}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
|
||||||
|
let error = ConfigLoader::new(&cwd, &home)
|
||||||
|
.load()
|
||||||
|
.expect_err("config should fail");
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("rulesImport"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_trusted_roots_from_settings() {
|
fn parses_trusted_roots_from_settings() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@ -92,6 +92,7 @@ enum FieldType {
|
|||||||
Bool,
|
Bool,
|
||||||
Object,
|
Object,
|
||||||
StringArray,
|
StringArray,
|
||||||
|
RulesImport,
|
||||||
Number,
|
Number,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +103,7 @@ impl FieldType {
|
|||||||
Self::Bool => "a boolean",
|
Self::Bool => "a boolean",
|
||||||
Self::Object => "an object",
|
Self::Object => "an object",
|
||||||
Self::StringArray => "an array of strings",
|
Self::StringArray => "an array of strings",
|
||||||
|
Self::RulesImport => "a string or an array of strings",
|
||||||
Self::Number => "a number",
|
Self::Number => "a number",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -114,6 +116,12 @@ impl FieldType {
|
|||||||
Self::StringArray => value
|
Self::StringArray => value
|
||||||
.as_array()
|
.as_array()
|
||||||
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
|
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
|
||||||
|
Self::RulesImport => {
|
||||||
|
value.as_str().is_some()
|
||||||
|
|| value
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
|
||||||
|
}
|
||||||
Self::Number => value.as_i64().is_some(),
|
Self::Number => value.as_i64().is_some(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -201,6 +209,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
|||||||
name: "provider",
|
name: "provider",
|
||||||
expected: FieldType::Object,
|
expected: FieldType::Object,
|
||||||
},
|
},
|
||||||
|
FieldSpec {
|
||||||
|
name: "rulesImport",
|
||||||
|
expected: FieldType::RulesImport,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||||
@ -705,6 +717,34 @@ mod tests {
|
|||||||
assert_eq!(result.errors[0].field, "hooks.BadHook");
|
assert_eq!(result.errors[0].field, "hooks.BadHook");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_rules_import_string_and_array_forms() {
|
||||||
|
for source in [
|
||||||
|
r#"{"rulesImport":"auto"}"#,
|
||||||
|
r#"{"rulesImport":"none"}"#,
|
||||||
|
r#"{"rulesImport":["cursor","copilot"]}"#,
|
||||||
|
] {
|
||||||
|
let parsed = JsonValue::parse(source).expect("valid json");
|
||||||
|
let object = parsed.as_object().expect("object");
|
||||||
|
|
||||||
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
|
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_rules_import_wrong_type() {
|
||||||
|
let source = r#"{"rulesImport":42}"#;
|
||||||
|
let parsed = JsonValue::parse(source).expect("valid json");
|
||||||
|
let object = parsed.as_object().expect("object");
|
||||||
|
|
||||||
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
|
assert_eq!(result.errors.len(), 1);
|
||||||
|
assert_eq!(result.errors[0].field, "rulesImport");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_nested_permissions_keys() {
|
fn validates_nested_permissions_keys() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@ -69,8 +69,9 @@ pub use config::{
|
|||||||
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
||||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||||
RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig,
|
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
|
||||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||||
|
CLAW_SETTINGS_SCHEMA_NAME,
|
||||||
};
|
};
|
||||||
pub use config_validate::{
|
pub use config_validate::{
|
||||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig};
|
||||||
use crate::git_context::GitContext;
|
use crate::git_context::GitContext;
|
||||||
|
|
||||||
/// Errors raised while assembling the final system prompt.
|
/// Errors raised while assembling the final system prompt.
|
||||||
@ -86,7 +86,24 @@ impl ProjectContext {
|
|||||||
current_date: impl Into<String>,
|
current_date: impl Into<String>,
|
||||||
) -> std::io::Result<Self> {
|
) -> std::io::Result<Self> {
|
||||||
let cwd = cwd.into();
|
let cwd = cwd.into();
|
||||||
let instruction_files = discover_instruction_files(&cwd)?;
|
let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?;
|
||||||
|
Ok(Self {
|
||||||
|
cwd,
|
||||||
|
current_date: current_date.into(),
|
||||||
|
git_status: None,
|
||||||
|
git_diff: None,
|
||||||
|
git_context: None,
|
||||||
|
instruction_files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_with_rules_import(
|
||||||
|
cwd: impl Into<PathBuf>,
|
||||||
|
current_date: impl Into<String>,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
|
let cwd = cwd.into();
|
||||||
|
let instruction_files = discover_instruction_files(&cwd, rules_import)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
cwd,
|
cwd,
|
||||||
current_date: current_date.into(),
|
current_date: current_date.into(),
|
||||||
@ -109,6 +126,18 @@ impl ProjectContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn discover_with_git_and_rules_import(
|
||||||
|
cwd: impl Into<PathBuf>,
|
||||||
|
current_date: impl Into<String>,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<ProjectContext> {
|
||||||
|
let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?;
|
||||||
|
context.git_status = read_git_status(&context.cwd);
|
||||||
|
context.git_diff = read_git_diff(&context.cwd);
|
||||||
|
context.git_context = GitContext::detect(&context.cwd);
|
||||||
|
Ok(context)
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder for the runtime system prompt and dynamic environment sections.
|
/// Builder for the runtime system prompt and dynamic environment sections.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct SystemPromptBuilder {
|
pub struct SystemPromptBuilder {
|
||||||
@ -227,7 +256,10 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
|
|||||||
items.into_iter().map(|item| format!(" - {item}")).collect()
|
items.into_iter().map(|item| format!(" - {item}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
fn discover_instruction_files(
|
||||||
|
cwd: &Path,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<Vec<ContextFile>> {
|
||||||
let mut directories = Vec::new();
|
let mut directories = Vec::new();
|
||||||
let mut cursor = Some(cwd);
|
let mut cursor = Some(cwd);
|
||||||
while let Some(dir) = cursor {
|
while let Some(dir) = cursor {
|
||||||
@ -248,11 +280,17 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
|||||||
] {
|
] {
|
||||||
push_context_file(&mut files, candidate)?;
|
push_context_file(&mut files, candidate)?;
|
||||||
}
|
}
|
||||||
|
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
|
||||||
|
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
|
||||||
|
push_framework_imports(&mut files, &dir, rules_import)?
|
||||||
}
|
}
|
||||||
Ok(dedupe_instruction_files(files))
|
Ok(dedupe_instruction_files(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||||
|
if path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
match fs::read_to_string(&path) {
|
match fs::read_to_string(&path) {
|
||||||
Ok(content) if !content.trim().is_empty() => {
|
Ok(content) if !content.trim().is_empty() => {
|
||||||
files.push(ContextFile { path, content });
|
files.push(ContextFile { path, content });
|
||||||
@ -264,6 +302,64 @@ fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
|
||||||
|
if dir.is_file() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let entries = match fs::read_dir(&dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let mut paths = entries
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.is_file() && is_supported_rule_file(path))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
paths.sort();
|
||||||
|
for path in paths {
|
||||||
|
push_context_file(files, path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_rule_file(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| {
|
||||||
|
matches!(
|
||||||
|
extension.to_ascii_lowercase().as_str(),
|
||||||
|
"md" | "txt" | "mdc"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_framework_imports(
|
||||||
|
files: &mut Vec<ContextFile>,
|
||||||
|
dir: &Path,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if rules_import.should_import("cursor") {
|
||||||
|
push_context_file(files, dir.join(".cursorrules"))?;
|
||||||
|
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("copilot") {
|
||||||
|
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("windsurf") {
|
||||||
|
push_context_file(files, dir.join(".windsurfrules"))?;
|
||||||
|
push_rules_dir(files, dir.join(".windsurfrules"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("plandex") {
|
||||||
|
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("crush") {
|
||||||
|
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
|
||||||
|
push_rules_dir(files, dir.join(".crush").join("rules"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_git_status(cwd: &Path) -> Option<String> {
|
fn read_git_status(cwd: &Path) -> Option<String> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(["--no-optional-locks", "status", "--short", "--branch"])
|
.args(["--no-optional-locks", "status", "--short", "--branch"])
|
||||||
@ -478,8 +574,9 @@ pub fn load_system_prompt(
|
|||||||
model_family: ModelFamilyIdentity,
|
model_family: ModelFamilyIdentity,
|
||||||
) -> Result<Vec<String>, PromptBuildError> {
|
) -> Result<Vec<String>, PromptBuildError> {
|
||||||
let cwd = cwd.into();
|
let cwd = cwd.into();
|
||||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
|
||||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||||
|
let project_context =
|
||||||
|
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
|
||||||
Ok(SystemPromptBuilder::new()
|
Ok(SystemPromptBuilder::new()
|
||||||
.with_os(os_name, os_version)
|
.with_os(os_name, os_version)
|
||||||
.with_model_family(model_family)
|
.with_model_family(model_family)
|
||||||
@ -592,6 +689,78 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_claw_rules_files_in_sorted_order() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let rules = root.join(".claw").join("rules");
|
||||||
|
let local_rules = root.join(".claw").join("rules.local");
|
||||||
|
fs::create_dir_all(&rules).expect("rules dir");
|
||||||
|
fs::create_dir_all(&local_rules).expect("local rules dir");
|
||||||
|
fs::write(rules.join("b.txt"), "b rule").expect("write b rule");
|
||||||
|
fs::write(rules.join("a.md"), "a rule").expect("write a rule");
|
||||||
|
fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored");
|
||||||
|
fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||||
|
let contents = context
|
||||||
|
.instruction_files
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.content.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]);
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rules_import_none_suppresses_external_framework_rules() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw").join("rules").join("project.md"),
|
||||||
|
"claw rule",
|
||||||
|
)
|
||||||
|
.expect("write claw rule");
|
||||||
|
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover_with_rules_import(
|
||||||
|
&root,
|
||||||
|
"2026-03-31",
|
||||||
|
&crate::config::RulesImportConfig::None,
|
||||||
|
)
|
||||||
|
.expect("context should load");
|
||||||
|
let rendered = render_instruction_files(&context.instruction_files);
|
||||||
|
|
||||||
|
assert!(rendered.contains("claw rule"));
|
||||||
|
assert!(!rendered.contains("cursor rule"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rules_import_list_loads_only_selected_framework_rules() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(&root).expect("root dir");
|
||||||
|
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||||
|
fs::create_dir_all(root.join(".github")).expect("github dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".github").join("copilot-instructions.md"),
|
||||||
|
"copilot rule",
|
||||||
|
)
|
||||||
|
.expect("write copilot rule");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover_with_rules_import(
|
||||||
|
&root,
|
||||||
|
"2026-03-31",
|
||||||
|
&crate::config::RulesImportConfig::List(vec!["copilot".to_string()]),
|
||||||
|
)
|
||||||
|
.expect("context should load");
|
||||||
|
let rendered = render_instruction_files(&context.instruction_files);
|
||||||
|
|
||||||
|
assert!(rendered.contains("copilot rule"));
|
||||||
|
assert!(!rendered.contains("cursor rule"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discovers_instruction_files_from_ancestor_chain() {
|
fn discovers_instruction_files_from_ancestor_chain() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
@ -935,6 +1104,51 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_system_prompt_respects_rules_import_config() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(root.join(".claw")).expect("claw dir");
|
||||||
|
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw").join("settings.json"),
|
||||||
|
r#"{"rulesImport":"none"}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
|
||||||
|
let _guard = env_lock();
|
||||||
|
ensure_valid_cwd();
|
||||||
|
let previous = std::env::current_dir().expect("cwd");
|
||||||
|
let original_home = std::env::var("HOME").ok();
|
||||||
|
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||||
|
std::env::set_var("HOME", &root);
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
||||||
|
std::env::set_current_dir(&root).expect("change cwd");
|
||||||
|
let prompt = super::load_system_prompt(
|
||||||
|
&root,
|
||||||
|
"2026-03-31",
|
||||||
|
"linux",
|
||||||
|
"6.8",
|
||||||
|
ModelFamilyIdentity::Claude,
|
||||||
|
)
|
||||||
|
.expect("system prompt should load")
|
||||||
|
.join("\n\n");
|
||||||
|
std::env::set_current_dir(previous).expect("restore cwd");
|
||||||
|
if let Some(value) = original_home {
|
||||||
|
std::env::set_var("HOME", value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var("HOME");
|
||||||
|
}
|
||||||
|
if let Some(value) = original_claw_home {
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!prompt.contains("cursor rule"));
|
||||||
|
assert!(prompt.contains("rulesImport"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_default_claude_model_family_identity() {
|
fn renders_default_claude_model_family_identity() {
|
||||||
// given: a prompt builder without an explicit model family override
|
// given: a prompt builder without an explicit model family override
|
||||||
|
|||||||
Reference in New Issue
Block a user