use anyhow::{Context, Result};
use std::path::PathBuf;
use super::limits::*;
use super::*;
use crate::db::intent::{Intent, Strength};
use crate::db::pattern::Pattern;
pub struct ClaudeCodeAdapter {
pub global_claude_md: PathBuf,
pub gates_path: PathBuf,
pub global_skills_dir: PathBuf,
}
impl ClaudeCodeAdapter {
pub fn default() -> Result<Self> {
let home = dirs::home_dir().context("home directory not found")?;
Ok(Self {
global_claude_md: home.join(".claude/CLAUDE.md"),
gates_path: home.join(".asurada/gates.json"),
global_skills_dir: home.join(".claude/skills"),
})
}
}
#[derive(Debug)]
enum Route {
ClaudeMdLine {
block_id: &'static str,
line: String,
},
SkillBullet {
slug: &'static str,
title: &'static str,
description: &'static str,
bullet: String,
},
Gate(GateRule),
}
fn route_intent(intent: &Intent) -> Vec<Route> {
let line_text = intent.intent_text.trim();
let scope_suffix = intent
.project
.as_deref()
.map(|p| format!(" *(scope: {})*", p))
.unwrap_or_default();
let one_liner = format!("- {}{}", line_text, scope_suffix);
let is_short =
line_text.chars().count() <= SHORT_INTENT_CHAR_LIMIT && !line_text.contains('\n');
match intent.strength {
Strength::Preference if is_short => vec![Route::ClaudeMdLine {
block_id: "preferences",
line: one_liner,
}],
Strength::Preference => vec![Route::SkillBullet {
slug: "asurada-preferences",
title: "사용자 선호 (Asurada)",
description: "사용자가 Asurada 에 등록한 작업 선호. 코드 작성/응답 스타일/도구 사용 \
규약을 다룰 때 이 skill 의 항목들을 적극 반영하라.",
bullet: format!("- {}{}", line_text, scope_suffix),
}],
Strength::Principle => {
let mut out = vec![];
if let Some(rule) = principle_to_gate_rule(intent) {
out.push(Route::Gate(rule));
}
out.push(Route::ClaudeMdLine {
block_id: "principles",
line: format!("- {} *(자동 차단/확인 적용)*", line_text),
});
out
}
Strength::Context if is_short => vec![Route::ClaudeMdLine {
block_id: "context",
line: one_liner,
}],
Strength::Context => vec![Route::SkillBullet {
slug: "asurada-context",
title: "프로젝트 맥락 (Asurada)",
description: "Asurada 가 누적한 프로젝트별 맥락. 사용자 작업이 이 항목들과 \
관련될 때 이 skill 을 사용해 기존 결정/제약을 회상하라.",
bullet: format!("- {}{}", line_text, scope_suffix),
}],
}
}
impl RuntimeAdapter for ClaudeCodeAdapter {
fn name(&self) -> &'static str {
"claude-code"
}
fn compile(&self, intents: &[Intent], patterns: &[Pattern]) -> Result<CompiledArtifacts> {
let mut artifacts = CompiledArtifacts::default();
let mut claude_md_lines: std::collections::BTreeMap<&'static str, Vec<String>> =
Default::default();
let mut skill_buckets: std::collections::BTreeMap<&'static str, SkillBucket> =
Default::default();
for it in intents {
for r in route_intent(it) {
match r {
Route::ClaudeMdLine { block_id, line } => {
claude_md_lines.entry(block_id).or_default().push(line);
}
Route::SkillBullet {
slug,
title,
description,
bullet,
} => {
skill_buckets
.entry(slug)
.or_insert_with(|| SkillBucket {
slug,
title,
description,
bullets: vec![],
})
.bullets
.push(bullet);
}
Route::Gate(rule) => {
artifacts.gate_rules.push(rule);
}
}
}
}
let block_titles: &[(&str, &str)] = &[
("preferences", "사용자 선호 (Asurada)"),
("context", "프로젝트 맥락 (Asurada)"),
("principles", "원칙 (Asurada — 자동 차단/확인 적용)"),
];
for (block_id, title) in block_titles {
let lines = claude_md_lines.remove(block_id).unwrap_or_default();
if lines.is_empty() {
continue;
}
let head = ASURADA_BLOCK_MAX_LINES.saturating_sub(3); let (kept, overflow): (Vec<_>, Vec<_>) =
lines.into_iter().enumerate().partition(|(i, _)| *i < head);
let kept: Vec<String> = kept.into_iter().map(|(_, s)| s).collect();
let overflow: Vec<String> = overflow.into_iter().map(|(_, s)| s).collect();
artifacts.text_blocks.push(TextBlock {
id: (*block_id).to_string(),
target: BlockTarget::GlobalClaudeMd,
body: format!("## {}\n\n{}\n", title, kept.join("\n")),
});
if !overflow.is_empty() {
let (demote_slug, demote_title, demote_desc) = match *block_id {
"preferences" => (
"asurada-preferences",
"사용자 선호 (Asurada)",
"사용자가 등록한 선호 — CLAUDE.md 에서 demote 된 항목.",
),
"context" => (
"asurada-context",
"프로젝트 맥락 (Asurada)",
"Asurada 가 누적한 맥락 — CLAUDE.md 에서 demote 된 항목.",
),
_ => continue,
};
skill_buckets
.entry(demote_slug)
.or_insert_with(|| SkillBucket {
slug: demote_slug,
title: demote_title,
description: demote_desc,
bullets: vec![],
})
.bullets
.extend(overflow);
}
}
let mut sorted = patterns
.iter()
.filter(|h| h.status == "active")
.collect::<Vec<_>>();
sorted.sort_by_key(|h| -h.usage_count);
let top: Vec<&Pattern> = sorted
.into_iter()
.take(HARNESS_REGISTRY_MAX_ENTRIES)
.collect();
if !top.is_empty() {
let mut body = String::from("## 등록된 패턴 (Asurada — 자동 생성)\n\n");
body.push_str(
"자세한 내용은 각 skill 파일 참조. 비슷한 작업 요청 시 자동 트리거됩니다.\n\n",
);
for h in &top {
body.push_str(&format!(
"- **{}** *(used {}x)* — {}\n",
h.title, h.usage_count, h.description
));
}
artifacts.text_blocks.push(TextBlock {
id: "patterns".into(),
target: BlockTarget::GlobalClaudeMd,
body,
});
}
for (_, bucket) in skill_buckets {
if bucket.bullets.is_empty() {
continue;
}
let path = self.global_skills_dir.join(bucket.slug).join("SKILL.md");
let body = render_aux_skill(&bucket);
artifacts.files.push(GeneratedFile { path, body });
}
Ok(artifacts)
}
fn apply(&self, artifacts: &CompiledArtifacts) -> Result<ApplyReport> {
let mut report = ApplyReport::default();
for block in &artifacts.text_blocks {
let target = match &block.target {
BlockTarget::GlobalClaudeMd => self.global_claude_md.clone(),
BlockTarget::ProjectClaudeMd(p) => p.clone(),
};
let changed = upsert_managed_block(&target, block)?;
if changed {
report.files_written.push(target);
report.blocks_updated.push(block.id.clone());
}
}
let present_ids: std::collections::HashSet<&str> = artifacts
.text_blocks
.iter()
.map(|b| b.id.as_str())
.collect();
let all_known_ids: &[&str] = &[
"preferences",
"context",
"principles",
"patterns",
"harnesses",
];
for &id in all_known_ids {
if !present_ids.contains(id) {
let _ = remove_managed_block(&self.global_claude_md, id);
}
}
for gf in &artifacts.files {
if let Some(parent) = gf.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = gf.path.with_extension(format!(
"{}.asurada.tmp",
gf.path.extension().and_then(|e| e.to_str()).unwrap_or("md")
));
std::fs::write(&tmp, &gf.body)?;
std::fs::rename(&tmp, &gf.path)?;
report.files_written.push(gf.path.clone());
}
if let Some(parent) = self.gates_path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(&artifacts.gate_rules)?;
let tmp = self.gates_path.with_extension("json.asurada.tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, &self.gates_path)?;
report.gate_rules_count = artifacts.gate_rules.len();
report.files_written.push(self.gates_path.clone());
Ok(report)
}
}
struct SkillBucket {
slug: &'static str,
title: &'static str,
description: &'static str,
bullets: Vec<String>,
}
fn render_aux_skill(bucket: &SkillBucket) -> String {
format!(
"---\n\
name: {slug}\n\
description: {description}\n\
asurada-managed: true\n\
---\n\
\n\
# {title}\n\
\n\
이 skill 은 Asurada 가 자동으로 관리합니다 (`asurada intent compile`).\n\
사용자 직접 편집은 다음 컴파일 시 덮여 쓰일 수 있으니, 영구 변경은\n\
`asurada intent add/archive` 로 진행하세요.\n\
\n\
## 항목\n\
\n\
{bullets}\n",
slug = bucket.slug,
description = bucket.description,
title = bucket.title,
bullets = bucket.bullets.join("\n"),
)
}
fn principle_to_gate_rule(intent: &Intent) -> Option<GateRule> {
let trigger = intent.metadata.get("trigger")?;
let tool = trigger
.get("tool")
.and_then(|v| v.as_str())
.map(String::from);
let contains = trigger
.get("contains")
.and_then(|v| v.as_str())
.map(String::from);
if tool.is_none() && contains.is_none() {
return None;
}
let decision = intent
.metadata
.get("decision")
.and_then(|v| v.as_str())
.unwrap_or("ask")
.to_string();
Some(GateRule {
intent_id: intent.id.clone(),
tool,
contains,
decision,
reason: intent.intent_text.clone(),
})
}
pub fn load_gate_rules(path: &std::path::Path) -> Result<Vec<GateRule>> {
if !path.exists() {
return Ok(vec![]);
}
let body = std::fs::read_to_string(path)?;
if body.trim().is_empty() {
return Ok(vec![]);
}
Ok(serde_json::from_str(&body).unwrap_or_default())
}
pub fn match_gate(
rules: &[GateRule],
tool_name: Option<&str>,
tool_input: &serde_json::Value,
) -> Option<GateRule> {
let tool_input_str = tool_input.to_string();
for rule in rules {
let tool_ok = match (&rule.tool, tool_name) {
(None, _) => true,
(Some(want), Some(got)) => want == got,
(Some(_), None) => false,
};
if !tool_ok {
continue;
}
let contains_ok = match &rule.contains {
None => true,
Some(needle) => tool_input_str.contains(needle),
};
if !contains_ok {
continue;
}
return Some(rule.clone());
}
None
}