diff --git a/runtime/resources/context.go b/runtime/resources/context.go
new file mode 100644
index 0000000..d4d84b2
--- /dev/null
+++ b/runtime/resources/context.go
@@ -0,0 +1,37 @@
+package resources
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// ContextFiles walks from dir toward the filesystem root and concatenates
+// AGENTS.md and CLAUDE.md (nearest first). SYSTEM.md and APPEND_SYSTEM.md
+// are read from dir only.
+func ContextFiles(dir string) (string, error) {
+ var parts []string
+ cur := dir
+ for {
+ for _, name := range []string{"AGENTS.md", "CLAUDE.md"} {
+ raw, err := os.ReadFile(filepath.Join(cur, name))
+ if err != nil {
+ continue
+ }
+ parts = append(parts, "# "+name+" ("+cur+")\n"+string(raw))
+ }
+ parent := filepath.Dir(cur)
+ if parent == cur {
+ break
+ }
+ cur = parent
+ }
+ for _, name := range []string{"SYSTEM.md", "APPEND_SYSTEM.md"} {
+ raw, err := os.ReadFile(filepath.Join(dir, name))
+ if err != nil {
+ continue
+ }
+ parts = append(parts, "# "+name+"\n"+string(raw))
+ }
+ return strings.Join(parts, "\n\n"), nil
+}
diff --git a/runtime/resources/prompt.go b/runtime/resources/prompt.go
new file mode 100644
index 0000000..44d3518
--- /dev/null
+++ b/runtime/resources/prompt.go
@@ -0,0 +1,44 @@
+package resources
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Expand replaces {{var}} placeholders and /name includes.
+// An include reads
/.md. Unknown variables are left intact.
+func Expand(text, dir string, vars map[string]string) (string, error) {
+ out := text
+ for key, value := range vars {
+ out = strings.ReplaceAll(out, "{{"+key+"}}", value)
+ }
+ for {
+ i := strings.Index(out, "/")
+ if i < 0 {
+ break
+ }
+ rest := out[i+1:]
+ j := 0
+ for j < len(rest) && isName(rest[j]) {
+ j++
+ }
+ if j == 0 || (i > 0 && out[i-1] != ' ' && out[i-1] != '\n') {
+ break
+ }
+ name := rest[:j]
+ raw, err := os.ReadFile(filepath.Join(dir, name+".md"))
+ if err != nil {
+ break
+ }
+ out = out[:i] + string(raw) + out[i+1+j:]
+ }
+ for key, value := range vars {
+ out = strings.ReplaceAll(out, "{{"+key+"}}", value)
+ }
+ return out, nil
+}
+
+func isName(b byte) bool {
+ return b == '-' || b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
+}
diff --git a/runtime/resources/resources_test.go b/runtime/resources/resources_test.go
new file mode 100644
index 0000000..7080749
--- /dev/null
+++ b/runtime/resources/resources_test.go
@@ -0,0 +1,72 @@
+package resources
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "agent-native/runtime/skills"
+ "agent-native/runtime/tools"
+)
+
+func TestContextAndPromptAndTrust(t *testing.T) {
+ root := t.TempDir()
+ proj := filepath.Join(root, "proj")
+ if err := os.MkdirAll(proj, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("root-agents"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(proj, "SYSTEM.md"), []byte("sys"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ text, err := ContextFiles(proj)
+ if err != nil || !strings.Contains(text, "root-agents") || !strings.Contains(text, "sys") {
+ t.Fatalf("context: %s %v", text, err)
+ }
+ if err := os.WriteFile(filepath.Join(proj, "greet.md"), []byte("hello {{name}}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Expand("say /greet", proj, map[string]string{"name": "Ada"})
+ if err != nil || got != "say hello Ada" {
+ t.Fatalf("expand: %q %v", got, err)
+ }
+ if ProjectTrusted(TrustAsk, false, false) {
+ t.Fatal("ask is not trusted without approve")
+ }
+ if !ProjectTrusted(TrustNever, true, false) {
+ t.Fatal("approve overrides never")
+ }
+ if ProjectTrusted(TrustAlways, false, true) {
+ t.Fatal("no-approve wins")
+ }
+}
+
+func TestPiAliasesFilterAndSkillCommand(t *testing.T) {
+ reg := tools.WorkspaceTools(t.TempDir())
+ reg.RegisterPiAliases()
+ if !reg.Has("read") || !reg.Has("ls") || !reg.Has("read_file") {
+ t.Fatal("aliases missing")
+ }
+ if err := reg.Filter(tools.FilterOptions{Exclude: []string{"read"}, NoBuiltin: false}); err != nil {
+ t.Fatal(err)
+ }
+ if reg.Has("read") || !reg.Has("read_file") {
+ t.Fatal("exclude should drop only the alias")
+ }
+ dir := t.TempDir()
+ skillDir := filepath.Join(dir, "review")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ store := skills.NewStore(dir)
+ out, err := store.ExpandCommands("use /skill:review now")
+ if err != nil || !strings.Contains(out, "be thorough") {
+ t.Fatalf("skill expand: %q %v", out, err)
+ }
+}
diff --git a/runtime/resources/settings.go b/runtime/resources/settings.go
new file mode 100644
index 0000000..fda7296
--- /dev/null
+++ b/runtime/resources/settings.go
@@ -0,0 +1,102 @@
+package resources
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+)
+
+// Trust is how a project directory is treated in non-interactive runs.
+type Trust string
+
+const (
+ TrustAsk Trust = "ask"
+ TrustAlways Trust = "always"
+ TrustNever Trust = "never"
+)
+
+// Settings is the global plus project configuration.
+type Settings struct {
+ Trust Trust `json:"trust,omitempty"`
+ Model string `json:"model,omitempty"`
+ Thinking string `json:"thinking,omitempty"`
+ Tools []string `json:"tools,omitempty"`
+ Exclude []string `json:"exclude,omitempty"`
+ Vars map[string]string `json:"vars,omitempty"`
+}
+
+// LoadSettings reads global (optional) then project settings.
+// Project keys override global keys. A missing file is an empty settings.
+func LoadSettings(globalPath, projectPath string) (Settings, error) {
+ global, err := readSettings(globalPath)
+ if err != nil {
+ return Settings{}, err
+ }
+ project, err := readSettings(projectPath)
+ if err != nil {
+ return Settings{}, err
+ }
+ return mergeSettings(global, project), nil
+}
+
+// ProjectTrusted reports whether project resources may be loaded.
+// ask and never refuse in non-interactive mode. approve forces trust.
+func ProjectTrusted(trust Trust, approve, noApprove bool) bool {
+ if noApprove {
+ return false
+ }
+ if approve {
+ return true
+ }
+ return trust == TrustAlways
+}
+
+func readSettings(path string) (Settings, error) {
+ if path == "" {
+ return Settings{}, nil
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return Settings{}, nil
+ }
+ return Settings{}, err
+ }
+ var s Settings
+ if err := json.Unmarshal(raw, &s); err != nil {
+ return Settings{}, err
+ }
+ return s, nil
+}
+
+func mergeSettings(base, over Settings) Settings {
+ if over.Trust != "" {
+ base.Trust = over.Trust
+ }
+ if over.Model != "" {
+ base.Model = over.Model
+ }
+ if over.Thinking != "" {
+ base.Thinking = over.Thinking
+ }
+ if over.Tools != nil {
+ base.Tools = over.Tools
+ }
+ if over.Exclude != nil {
+ base.Exclude = over.Exclude
+ }
+ if len(over.Vars) > 0 {
+ if base.Vars == nil {
+ base.Vars = map[string]string{}
+ }
+ for k, v := range over.Vars {
+ base.Vars[k] = v
+ }
+ }
+ return base
+}
+
+// DefaultPaths returns the global and project settings paths for a cwd.
+func DefaultPaths(home, cwd string) (string, string) {
+ return filepath.Join(home, ".agent-native", "settings.json"), filepath.Join(cwd, ".agent-native", "settings.json")
+}
diff --git a/runtime/skills/skills.go b/runtime/skills/skills.go
index 387661c..d2158e9 100644
--- a/runtime/skills/skills.go
+++ b/runtime/skills/skills.go
@@ -107,6 +107,35 @@ func (s *Store) Load(name string) (Skill, error) {
return Skill{}, fmt.Errorf("unknown skill %q (not installed in any configured root)", name)
}
+// ExpandCommands replaces /skill:name with that skill's body.
+// An unknown name returns an error and leaves the text unchanged.
+func (s *Store) ExpandCommands(text string) (string, error) {
+ const marker = "/skill:"
+ var b strings.Builder
+ rest := text
+ for {
+ i := strings.Index(rest, marker)
+ if i < 0 {
+ b.WriteString(rest)
+ break
+ }
+ b.WriteString(rest[:i])
+ name := rest[i+len(marker):]
+ end := 0
+ for end < len(name) && name[end] != ' ' && name[end] != '\n' && name[end] != '\t' {
+ end++
+ }
+ skillName := name[:end]
+ skill, err := s.Load(skillName)
+ if err != nil {
+ return text, err
+ }
+ b.WriteString(skill.Body)
+ rest = name[end:]
+ }
+ return b.String(), nil
+}
+
// parse 解析一份 SKILL.md:目录名权威,frontmatter 可选。
func parse(dirName, path string, data []byte) (Skill, error) {
if !validSkillName(dirName) {
diff --git a/runtime/tools/alias.go b/runtime/tools/alias.go
new file mode 100644
index 0000000..bd3ad5f
--- /dev/null
+++ b/runtime/tools/alias.go
@@ -0,0 +1,83 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+)
+
+// aliasTool exposes an existing tool under a pi-style name.
+type aliasTool struct {
+ name string
+ inner Tool
+}
+
+func (a aliasTool) Name() string { return a.name }
+func (a aliasTool) Description() string { return a.inner.Description() }
+func (a aliasTool) Parameters() json.RawMessage { return a.inner.Parameters() }
+func (a aliasTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
+ return a.inner.Execute(ctx, args)
+}
+
+// piAliases maps the headless pi tool name onto the name already registered.
+var piAliases = map[string]string{
+ "read": "read_file",
+ "write": "write_file",
+ "edit": "edit_file",
+ "bash": "run_command",
+ "grep": "grep",
+ "find": "glob",
+ "ls": "list_dir",
+}
+
+// RegisterPiAliases adds pi tool names beside the existing ones.
+// A name is skipped when the underlying tool is not registered.
+func (r *Registry) RegisterPiAliases() {
+ for alias, inner := range piAliases {
+ if alias == inner || r.Has(alias) || !r.Has(inner) {
+ continue
+ }
+ r.Register(aliasTool{name: alias, inner: r.tools[inner]})
+ }
+}
+
+// FilterOptions selects which tools stay visible.
+type FilterOptions struct {
+ Tools []string
+ Exclude []string
+ NoBuiltin bool
+}
+
+// Filter applies an allow list, then exclusions. NoBuiltin drops the
+// workspace tool names and their pi aliases.
+func (r *Registry) Filter(opts FilterOptions) error {
+ if len(opts.Tools) > 0 {
+ if err := r.Restrict(opts.Tools); err != nil {
+ return err
+ }
+ }
+ drop := map[string]bool{}
+ for _, name := range opts.Exclude {
+ drop[name] = true
+ }
+ if opts.NoBuiltin {
+ for _, name := range []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "run_command", "run_python"} {
+ drop[name] = true
+ }
+ for alias := range piAliases {
+ drop[alias] = true
+ }
+ }
+ if len(drop) == 0 {
+ return nil
+ }
+ var order []string
+ for _, name := range r.order {
+ if drop[name] {
+ delete(r.tools, name)
+ continue
+ }
+ order = append(order, name)
+ }
+ r.order = order
+ return nil
+}