diff --git a/apps/docs/content/docs/en/add.md b/apps/docs/content/docs/en/add.md index a63fc82..e86e0ef 100644 --- a/apps/docs/content/docs/en/add.md +++ b/apps/docs/content/docs/en/add.md @@ -3,7 +3,7 @@ title: one add description: Add a templated project to an existing workspace. --- -`one add` selects a template from the registry, writes it into the workspace, registers it in the manifest, and syncs template defaults such as Dockerfile, Kustomize, workflows, and AI guides. +`one add` selects a template from the registry, writes it into the workspace, registers it in the manifest, and syncs template defaults such as Dockerfile, Kustomize, workflows, and agent docs. There are two entry points: @@ -54,15 +54,21 @@ one add nestjs-api --name api --yes "status": "completed", "providers": ["codex", "claude-code"], "generated_files": [ - "/abs/path/my-app/AGENTS.md", - "/abs/path/my-app/CLAUDE.md" + "AGENTS.md", + "CLAUDE.md", + ".one/agents/conventions.md", + ".one/agents/projects/services-user-api.md", + ".one/agents/ops/dev.md", + ".one/agents/ops/secrets.md", + ".one/agents/ops/container.md", + ".one/agents/ops/deploy.md" ], - "file_count": 2 + "file_count": 8 } } ``` -`warnings[]` means a compatibility or post-sync step produced a non-blocking warning; the project was still added. `ai_guides.status` tells you whether root `AGENTS.md` / `CLAUDE.md` refreshed successfully. `ai_guides.generated_files` contains absolute paths under the workspace root. +`warnings[]` means a compatibility or post-sync step produced a non-blocking warning; the project was still added. `ai_guides.status` tells you whether root `AGENTS.md`, `CLAUDE.md`, and `.one/agents/**` refreshed successfully. `ai_guides.generated_files` contains workspace-relative paths. ## Examples @@ -112,9 +118,9 @@ one add nestjs-api --name user-api --yes -o json | jq - Adds `kustomize/base` and `kustomize/overlays/{dev,staging,prod}` for `deploy/kustomize` - S3-compatible deploy projects do not write local deploy artifacts; deploy uses the object-storage profile configured by `one configure add deploy/aws-s3 --profile ` or another split S3 backend (`deploy/aliyun-oss`, `deploy/r2`, etc.) - Adds GitHub Actions workflow entries -- Refreshes `AGENTS.md` / `CLAUDE.md` +- Refreshes `AGENTS.md`, `CLAUDE.md`, and `.one/agents/**` -If a non-critical step fails, such as AI guide refresh, the project still exists and the related status is marked `failed` or `skipped`. +If a non-critical step fails, such as agent-doc refresh, the project still exists and the related status is marked `failed` or `skipped`. ## Common Errors @@ -138,5 +144,5 @@ Not sure which one to use? Read the [template decision tree](/en/docs/templates/ ## After Adding - Check `one.manifest.json#projects[]` to confirm registration -- AI guides and container / deploy artifacts are synced by `one add` +- Agent docs and container / deploy artifacts are synced by `one add` - `one add` does not install dependencies: JS / TS workspaces install from the root with the package manager; Go projects run `go mod download` in the project directory, then `go mod tidy` only after changing imports or when module metadata needs repair diff --git a/apps/docs/content/docs/en/ai-native.md b/apps/docs/content/docs/en/ai-native.md index eda8ce0..af0550c 100644 --- a/apps/docs/content/docs/en/ai-native.md +++ b/apps/docs/content/docs/en/ai-native.md @@ -17,7 +17,7 @@ That layer covers four boundaries: 1. **Automation interface**: agents and CI read structured output instead of scraping terminal text. 2. **Error recovery**: agents recover from stable codes, context, and remediation hints. -3. **Engineering contracts**: agents read durable rules from `AGENTS.md` / `CLAUDE.md`. +3. **Engineering contracts**: agents read durable rules from `AGENTS.md` and `.one/agents/` (`CLAUDE.md` points to `AGENTS.md`). 4. **Permission boundaries**: local credentials, environment setup, and deployment configuration have explicit owners instead of being guessed by agents. ## Rule 1: Command Output Must Be Parseable @@ -97,13 +97,15 @@ The full catalogue is in [Error codes](/en/docs/error-codes/). ## Rule 3: Engineering Contracts Belong In The Repository -When One CLI creates a workspace or adds a template, it maintains repository-level agent guides: +When One CLI creates a workspace or adds a template, it maintains a repository-level agent harness: -- `AGENTS.md`: read by Codex and similar agents -- `CLAUDE.md`: read by Claude Code -- Each template's own `CLAUDE.md`: copied into the generated subproject with stack-specific rules +- `AGENTS.md`: the canonical, thin routing entry for Codex and similar agents +- `CLAUDE.md`: a generated pointer that tells Claude Code to follow `./AGENTS.md` +- `.one/agents/conventions.md`: workspace-level conventions and One CLI operating rules +- `.one/agents/projects/.md`: one stack-specific guide per manifest project, named from `relativeDir` with slashes flattened to hyphens +- `.one/agents/ops/*.md`: domain operation guides for `one dev`, `one env`, `one container`, and `one deploy` when the manifest enables those domains -These files are not temporary prompts. They are part of the repository. An agent entering the workspace should read them before deciding how to code, run commands, install dependencies, or change configuration. +These files are not temporary prompts. They are a projection of `one.manifest.json`: the manifest is the source of truth, `AGENTS.md` stays small, and detail files are opened on demand. One CLI does not copy agent stubs into subproject directories. Example rules: @@ -155,9 +157,9 @@ Inside any One workspace, trigger a missing template to see the `one-cli/error/v one add api-fastify --name api --yes -o json ``` -After adding a real template, check the workspace-level and subproject-level agent guides: +After adding a real template, check the workspace-level router and centralized detail guides: ```bash one add nestjs-api --name api --yes -o json -ls AGENTS.md CLAUDE.md services/api/CLAUDE.md +ls AGENTS.md CLAUDE.md .one/agents/conventions.md .one/agents/projects/services-api.md ``` diff --git a/apps/docs/content/docs/en/error-codes.md b/apps/docs/content/docs/en/error-codes.md index de04837..f07718f 100644 --- a/apps/docs/content/docs/en/error-codes.md +++ b/apps/docs/content/docs/en/error-codes.md @@ -223,9 +223,9 @@ Profile file schema does not match this binary. Upgrade CLI or recreate profiles Release-flow backend expected a toolchain or repo state that the workspace does not have. -## AI Guides / Skills +## Agent Docs / Skills -`AGENTS.md`, `CLAUDE.md`, and One CLI skill installation. +`AGENTS.md`, `CLAUDE.md`, `.one/agents/**`, and One CLI skill installation. ### `AI_CONFIG_INVALID` @@ -237,7 +237,7 @@ Legacy provider gate; the current CLI renders all supported providers and should ### `AI_GUIDES_FAILED` -AI guide refresh failed. Read the surfaced error. +Agent docs refresh failed. Read the surfaced error. ### `AI_GUIDE_EXISTS` diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md index a52af86..873c076 100644 --- a/apps/docs/content/docs/zh/add.md +++ b/apps/docs/content/docs/zh/add.md @@ -54,15 +54,21 @@ one add nestjs-api --name api --yes "status": "completed", "providers": ["codex", "claude-code"], "generated_files": [ - "/abs/path/my-app/AGENTS.md", - "/abs/path/my-app/CLAUDE.md" + "AGENTS.md", + "CLAUDE.md", + ".one/agents/conventions.md", + ".one/agents/projects/services-user-api.md", + ".one/agents/ops/dev.md", + ".one/agents/ops/secrets.md", + ".one/agents/ops/container.md", + ".one/agents/ops/deploy.md" ], - "file_count": 2 + "file_count": 8 } } ``` -`warnings[]` 存在时表示模板兼容性或后置同步有非阻断提示;项目仍然加成功。`ai_guides.status` 表示根目录 `AGENTS.md` / `CLAUDE.md` 是否刷新成功。`ai_guides.generated_files` 是工作区根目录下的绝对路径。 +`warnings[]` 存在时表示模板兼容性或后置同步有非阻断提示;项目仍然加成功。`ai_guides.status` 表示根目录 `AGENTS.md`、`CLAUDE.md` 和 `.one/agents/**` 是否刷新成功。`ai_guides.generated_files` 是工作区相对路径。 ## 示例 @@ -112,9 +118,9 @@ one add nestjs-api --name user-api --yes -o json | jq - `deploy/kustomize` 工作区根加 `kustomize/base` 和 `kustomize/overlays/{dev,staging,prod}` - S3 兼容 deploy 后端不写本地部署产物;部署时使用 `one configure add deploy/aws-s3 --profile ` 或其它拆分后的 S3 后端(`deploy/aliyun-oss`、`deploy/r2` 等)配置的对象存储 profile - 加 GitHub Actions workflow 条目 -- 刷 `AGENTS.md` / `CLAUDE.md` +- 刷 `AGENTS.md`、`CLAUDE.md` 和 `.one/agents/**` -如果有失败的 step(比如 AI 指南刷新失败),项目仍然加成功,只是相关字段会标 `failed` / `skipped`。 +如果有失败的 step(比如 agent 文档刷新失败),项目仍然加成功,只是相关字段会标 `failed` / `skipped`。 ## 错误恢复 @@ -138,5 +144,5 @@ one add nestjs-api --name user-api --yes -o json | jq ## 加完之后 - 检查 `one.manifest.json#projects[]` 确认项目登记 -- AI 指南、容器 / 部署 artefacts 都已由 `one add` 在执行过程中同步完成 +- Agent 文档、容器 / 部署 artefacts 都已由 `one add` 在执行过程中同步完成 - `one add` 不自动安装依赖:JS / TS 工作区在根目录跑 package manager install;Go 项目进项目目录跑 `go mod download`,修改 imports 或需要修复模块元数据时再跑 `go mod tidy` diff --git a/apps/docs/content/docs/zh/ai-native.md b/apps/docs/content/docs/zh/ai-native.md index aaed865..6bc44ba 100644 --- a/apps/docs/content/docs/zh/ai-native.md +++ b/apps/docs/content/docs/zh/ai-native.md @@ -17,7 +17,7 @@ One CLI 不只生成项目文件,也会在 monorepo 里留下可持续执行 1. **自动化接口**:agent 和 CI 读取结构化输出,不抓终端文本 2. **错误恢复**:agent 按稳定错误码、上下文和恢复建议处理失败 -3. **工程契约**:agent 从仓库里的 `AGENTS.md` / `CLAUDE.md` 读取长期规则 +3. **工程契约**:agent 从仓库里的 `AGENTS.md` 和 `.one/agents/` 读取长期规则(`CLAUDE.md` 指向 `AGENTS.md`) 4. **权限边界**:本机凭据、环境和部署配置有明确归属,不交给 agent 猜 ## 规则一:命令输出必须可解析 @@ -97,13 +97,15 @@ agent 的处理顺序应该是: ## 规则三:工程契约要留在仓库里 -One CLI 创建工作区和添加模板时,会维护仓库级 agent 指南: +One CLI 创建工作区和添加模板时,会维护仓库级 agent harness: -- `AGENTS.md`:给 Codex 等 agent 读取 -- `CLAUDE.md`:给 Claude Code 读取 -- 每个模板自己的 `CLAUDE.md`:放在生成出来的子项目里,描述该技术栈的约定 +- `AGENTS.md`:canonical 的瘦路由入口,给 Codex 等 agent 读取 +- `CLAUDE.md`:生成的一行指针,让 Claude Code 跟随 `./AGENTS.md` +- `.one/agents/conventions.md`:工作区约定和 One CLI 操作规则 +- `.one/agents/projects/.md`:每个 manifest 项目一份技术栈指南,文件名来自 `relativeDir`,斜杠会拍平成连字符 +- `.one/agents/ops/*.md`:当 manifest 启用相关 domain 时生成 `one dev`、`one env`、`one container`、`one deploy` 操作指南 -这些文件不是一次性提示词,而是仓库的一部分。agent 进来后先读它们,再决定怎么写代码、跑命令、补依赖和改配置。 +这些文件不是一次性提示词,而是 `one.manifest.json` 的投影:manifest 是事实源,`AGENTS.md` 保持小而稳定,细节文件按需打开。One CLI 不会再把 agent stub 复制进子项目目录。 示例约定: @@ -155,9 +157,9 @@ one templates -o json one add api-fastify --name api --yes -o json ``` -添加一个真实模板后,可以检查工作区级和子项目级 agent 指南: +添加一个真实模板后,可以检查工作区级路由和集中式细节指南: ```bash one add nestjs-api --name api --yes -o json -ls AGENTS.md CLAUDE.md services/api/CLAUDE.md +ls AGENTS.md CLAUDE.md .one/agents/conventions.md .one/agents/projects/services-api.md ``` diff --git a/apps/docs/content/docs/zh/error-codes.md b/apps/docs/content/docs/zh/error-codes.md index 7d546ac..0bddf7b 100644 --- a/apps/docs/content/docs/zh/error-codes.md +++ b/apps/docs/content/docs/zh/error-codes.md @@ -321,9 +321,9 @@ The release-flow backend's expected toolchain or repo state does not match the w > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 -## AI 指南 / Skills +## Agent 文档 / Skills -`AGENTS.md` / `CLAUDE.md` 生成与 bundled skill 安装。 +`AGENTS.md` / `CLAUDE.md` / `.one/agents/**` 生成与 bundled skill 安装。 ### `AI_CONFIG_INVALID` @@ -339,7 +339,7 @@ Reserved for legacy AI provider gates; current workspaces always render for ever ### `AI_GUIDES_FAILED` -AI guides refresh failed; see surfaced error message. +Agent docs refresh failed; see surfaced error message. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 diff --git a/packages/cli/internal/ai/agentsdir.go b/packages/cli/internal/ai/agentsdir.go new file mode 100644 index 0000000..6d9aad2 --- /dev/null +++ b/packages/cli/internal/ai/agentsdir.go @@ -0,0 +1,333 @@ +package ai + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/workspace" +) + +const ( + agentsRootDir = ".one/agents" + agentsProjectsDir = ".one/agents/projects" + agentsOpsDir = ".one/agents/ops" +) + +type route struct { + Label string + Path string +} + +func sortedManifestProjects(m *workspace.Manifest) []workspace.ManifestProject { + if m == nil { + return nil + } + out := append([]workspace.ManifestProject{}, m.Projects...) + sort.Slice(out, func(i, j int) bool { + return out[i].RelativeDir < out[j].RelativeDir + }) + return out +} + +func ensureRootAgentsFile(projectRoot string, m *workspace.Manifest) error { + path := filepath.Join(projectRoot, GuideFilename(ProviderCodex)) + if _, err := os.Stat(path); err == nil { + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + return os.WriteFile(path, []byte(RootAgentsContent(workspaceName(m))), 0o644) +} + +func writeClaudePointer(projectRoot string, force bool) error { + path := filepath.Join(projectRoot, GuideFilename(ProviderClaudeCode)) + content := ClaudePointerContent() + current, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return err + } + return os.WriteFile(path, []byte(content), 0o644) + } + if string(current) == content { + return nil + } + curStr := string(current) + managed := strings.Contains(curStr, generatedStart) || + strings.Contains(curStr, legacyGeneratedStart) || + strings.Contains(curStr, subprojectsStart) + if !managed && !force { + return cliErrors.New(cliErrors.AI_GUIDE_EXISTS, + "CLAUDE.md 已存在且不像 One CLI 生成文件,请手动合并或删除后重试。") + } + if managed { + backup := filepath.Join(projectRoot, filepath.FromSlash(agentsRootDir), "claude-legacy.md") + if _, err := os.Stat(backup); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return err + } + if err := os.MkdirAll(filepath.Dir(backup), 0o755); err != nil { + return err + } + if err := os.WriteFile(backup, current, 0o644); err != nil { + return err + } + } + } + return os.WriteFile(path, []byte(content), 0o644) +} + +func writeAgentsDir(projectRoot string, m *workspace.Manifest, projects []workspace.ManifestProject) ([]string, error) { + generated := []string{} + if err := writeGeneratedFile(projectRoot, filepath.ToSlash(filepath.Join(agentsRootDir, "conventions.md")), ConventionsContent()); err != nil { + return nil, err + } + generated = append(generated, filepath.ToSlash(filepath.Join(agentsRootDir, "conventions.md"))) + + projectFiles, err := writeProjectGuides(projectRoot, projects) + if err != nil { + return nil, err + } + generated = append(generated, projectFiles...) + + opsFiles, err := writeOpsGuides(projectRoot, m, projects) + if err != nil { + return nil, err + } + generated = append(generated, opsFiles...) + return generated, nil +} + +func writeProjectGuides(projectRoot string, projects []workspace.ManifestProject) ([]string, error) { + dir := filepath.Join(projectRoot, filepath.FromSlash(agentsProjectsDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + + wanted := map[string]bool{} + generated := make([]string, 0, len(projects)) + for _, p := range projects { + rel := projectGuideRelPath(p.RelativeDir) + wanted[filepath.Base(rel)] = true + if err := writeGeneratedFile(projectRoot, rel, renderProjectGuide(p)); err != nil { + return nil, err + } + generated = append(generated, rel) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".md") || wanted[name] { + continue + } + if err := os.Remove(filepath.Join(dir, name)); err != nil { + return nil, err + } + } + return generated, nil +} + +func writeOpsGuides(projectRoot string, m *workspace.Manifest, projects []workspace.ManifestProject) ([]string, error) { + wanted := map[string]string{ + "dev.md": DevOpsContent(), + } + if workspace.EnvBackend(m) != "" { + wanted["secrets.md"] = SecretsOpsContent() + } + containerKinds := projectContainerKinds(m, projects) + if len(containerKinds) > 0 { + wanted["container.md"] = ContainerOpsContent(containerKinds) + } + deployKinds := projectDeployKinds(projects) + if len(deployKinds) > 0 { + wanted["deploy.md"] = DeployOpsContent(deployKinds) + } + + dir := filepath.Join(projectRoot, filepath.FromSlash(agentsOpsDir)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + + order := []string{"dev.md", "secrets.md", "container.md", "deploy.md"} + generated := []string{} + for _, name := range order { + content, ok := wanted[name] + if !ok { + continue + } + rel := filepath.ToSlash(filepath.Join(agentsOpsDir, name)) + if err := writeGeneratedFile(projectRoot, rel, content); err != nil { + return nil, err + } + generated = append(generated, rel) + } + + for _, name := range order { + if _, ok := wanted[name]; ok { + continue + } + path := filepath.Join(dir, name) + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + } + return generated, nil +} + +func writeGeneratedFile(projectRoot, relPath, content string) error { + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + path := filepath.Join(projectRoot, filepath.FromSlash(relPath)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0o644) +} + +func renderAgentsIndex(m *workspace.Manifest, projects []workspace.ManifestProject) string { + projectRoutes := make([]route, 0, len(projects)) + for _, p := range projects { + label := p.RelativeDir + if p.TemplateID != "" { + label = fmt.Sprintf("%s (%s)", p.RelativeDir, p.TemplateID) + } + projectRoutes = append(projectRoutes, route{Label: label, Path: projectGuideRelPath(p.RelativeDir)}) + } + ops := opsRoutes(m, projects) + + var b strings.Builder + b.WriteString(generatedStart + "\n") + b.WriteString("Use this index to choose the smallest relevant detail file.\n\n") + b.WriteString("- Workspace conventions: [`.one/agents/conventions.md`](.one/agents/conventions.md)\n") + if len(projectRoutes) > 0 { + b.WriteString("\n### Projects\n\n") + for _, r := range projectRoutes { + b.WriteString("- `" + r.Label + "`: [`" + r.Path + "`](" + r.Path + ")\n") + } + } + if len(ops) > 0 { + b.WriteString("\n### Operations\n\n") + for _, r := range ops { + b.WriteString("- " + r.Label + ": [`" + r.Path + "`](" + r.Path + ")\n") + } + } + b.WriteString(generatedEnd + "\n") + return b.String() +} + +func renderProjectGuide(p workspace.ManifestProject) string { + content := loadTemplateContent(p.TemplateID) + if content == "" { + content = renderFallbackGuide(p.TemplateID) + } + name := strings.TrimSpace(p.Name) + if name == "" { + name = filepath.Base(filepath.FromSlash(p.RelativeDir)) + } + + var b strings.Builder + b.WriteString("# " + name + "\n\n") + b.WriteString("- Project path: `" + p.RelativeDir + "`\n") + if p.TemplateID != "" { + b.WriteString("- Template: `" + p.TemplateID + "`\n") + } + if p.Toolchain != "" { + b.WriteString("- Toolchain: `" + p.Toolchain + "`\n") + } + if p.PackageManager != "" { + b.WriteString("- Package manager: `" + p.PackageManager + "`\n") + } + b.WriteString("\n## Stack Rules\n\n") + b.WriteString(strings.TrimSpace(content)) + b.WriteString("\n") + return b.String() +} + +func opsRoutes(m *workspace.Manifest, projects []workspace.ManifestProject) []route { + out := []route{{ + Label: "Dev workflows", + Path: filepath.ToSlash(filepath.Join(agentsOpsDir, "dev.md")), + }} + if workspace.EnvBackend(m) != "" { + out = append(out, route{Label: "Environment variables", Path: filepath.ToSlash(filepath.Join(agentsOpsDir, "secrets.md"))}) + } + if len(projectContainerKinds(m, projects)) > 0 { + out = append(out, route{Label: "Container builds", Path: filepath.ToSlash(filepath.Join(agentsOpsDir, "container.md"))}) + } + if len(projectDeployKinds(projects)) > 0 { + out = append(out, route{Label: "Deployments", Path: filepath.ToSlash(filepath.Join(agentsOpsDir, "deploy.md"))}) + } + return out +} + +func projectContainerKinds(m *workspace.Manifest, projects []workspace.ManifestProject) []string { + seen := map[string]bool{} + for _, p := range projects { + if p.Domains == nil || p.Domains.Container == nil { + continue + } + kind := workspace.ContainerKindForProject(m, p.Name) + if strings.TrimSpace(kind) == "" { + kind = workspace.ContainerBackendDocker + } + seen[kind] = true + } + return mapKeys(seen) +} + +func projectDeployKinds(projects []workspace.ManifestProject) []string { + seen := map[string]bool{} + for _, p := range projects { + if p.Domains == nil || p.Domains.Deploy == nil || strings.TrimSpace(p.Domains.Deploy.Kind) == "" { + continue + } + seen[p.Domains.Deploy.Kind] = true + } + return mapKeys(seen) +} + +func mapKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func projectGuideRelPath(relativeDir string) string { + name := flattenRelativeDir(relativeDir) + return filepath.ToSlash(filepath.Join(agentsProjectsDir, name+".md")) +} + +func flattenRelativeDir(relativeDir string) string { + rel := filepath.ToSlash(strings.TrimSpace(relativeDir)) + rel = strings.Trim(rel, "/") + if rel == "" || rel == "." { + return "project" + } + rel = strings.NewReplacer("/", "-", "\\", "-").Replace(rel) + rel = strings.Trim(rel, "-") + if rel == "" { + return "project" + } + return rel +} + +func workspaceName(m *workspace.Manifest) string { + if m == nil || m.Workspace == nil { + return "" + } + return m.Workspace.Name +} diff --git a/packages/cli/internal/ai/ai.go b/packages/cli/internal/ai/ai.go index 6fc9b57..70326aa 100644 --- a/packages/cli/internal/ai/ai.go +++ b/packages/cli/internal/ai/ai.go @@ -1,17 +1,13 @@ -// Package ai materialises the workspace-level AI guide files (AGENTS.md -// for Codex, CLAUDE.md for Claude Code) by aggregating each subproject's -// per-template ai/ snippet into a single managed block. The on-disk -// AGENTS.md / CLAUDE.md may carry hand-written content outside the -// managed block — the renderer never touches those bytes. +// Package ai materialises the workspace-level agent harness from +// one.manifest.json. AGENTS.md is the canonical routing entry, CLAUDE.md +// points at it, and .one/agents/ holds the detailed project and ops docs. package ai import ( "errors" - "fmt" "io/fs" "path/filepath" "regexp" - "sort" "strings" "os" @@ -21,9 +17,9 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/workspace" ) -// Provider names the canonical AI provider this package can render guides -// for. Current workspaces always render for every provider in DefaultProviders — there is -// no per-workspace opt-in field anymore. +// Provider names the agent surface represented in RefreshResult. Current +// workspaces always report every provider in DefaultProviders; AGENTS.md is +// canonical and CLAUDE.md is a pointer to it. type Provider string const ( @@ -31,15 +27,15 @@ const ( ProviderClaudeCode Provider = "claude-code" ) -// DefaultProviders is the list of providers AGENTS.md / CLAUDE.md is -// rendered for in every workspace. Adding a new provider only requires -// extending this slice (and ensuring guideFilename / providerLabel -// recognise it). +// DefaultProviders is the list of agent surfaces materialised for every +// workspace. var DefaultProviders = []Provider{ProviderCodex, ProviderClaudeCode} const ( - generatedStart = "" - generatedEnd = "" + generatedStart = "" + generatedEnd = "" + legacyGeneratedStart = "" + legacyGeneratedEnd = "" ) // RefreshResult is the JSON envelope emitted by `add` under @@ -58,9 +54,9 @@ type errBody struct { Message string `json:"message"` } -// Refresh re-renders every AI guide for the workspace. Soft errors (no +// Refresh re-renders the agent harness for the workspace. Soft errors (no // subprojects) become status:"skipped"; everything else surfaces as -// status:"failed". Current workspaces always render for every provider in +// status:"failed". Current workspaces always report every provider in // DefaultProviders. func Refresh(projectRoot string, force bool) RefreshResult { res, err := tryRefresh(projectRoot, force) @@ -83,39 +79,43 @@ func Refresh(projectRoot string, force bool) RefreshResult { func tryRefresh(projectRoot string, force bool) (RefreshResult, error) { if !workspace.HasManifest(projectRoot) { return RefreshResult{}, cliErrors.New(cliErrors.NOT_ONE_PROJECT, - "refresh AI guides outside a One workspace") + "refresh agent docs outside a One workspace") } - rootDirs, err := workspace.ResolveRootDirs(projectRoot, nil) + manifest, err := workspace.ReadManifest(projectRoot) if err != nil { return RefreshResult{}, err } - subprojects, err := workspace.DiscoverProjects(projectRoot, rootDirs) - if err != nil { - return RefreshResult{}, err - } - // Always update the workspace CLAUDE.md sub-project index. Best-effort - // — don't fail Refresh if the workspace CLAUDE.md is missing or has - // been hand-customized past the markers; subprojects.go handles that - // gracefully. - _ = writeSubprojectsIndex(projectRoot, subprojects) - + projects := sortedManifestProjects(manifest) providers := append([]Provider{}, DefaultProviders...) - if len(subprojects) == 0 { + if len(projects) == 0 { return RefreshResult{}, cliErrors.New(cliErrors.AI_NO_SUBPROJECTS, "当前项目未发现可识别的项目。") } - generated := make([]string, 0, len(providers)) - for _, p := range providers { - filename := guideFilename(p) - sections := collectSections(subprojects, p) - content := renderGuide(p, sections) - path := filepath.Join(projectRoot, filename) - if _, err := writeManagedGuide(path, content, force, false); err != nil { - return RefreshResult{}, err - } - generated = append(generated, filename) + if err := ensureRootAgentsFile(projectRoot, manifest); err != nil { + return RefreshResult{}, err + } + + // Always update the canonical AGENTS.md sub-project index. Best-effort + // — don't fail Refresh if the user removed the block; subprojects.go + // leaves aggressively customised files alone. + _ = writeSubprojectsIndex(projectRoot, projects) + + agentsIndex := renderAgentsIndex(manifest, projects) + if _, err := writeManagedGuide(filepath.Join(projectRoot, GuideFilename(ProviderCodex)), agentsIndex, force, false); err != nil { + return RefreshResult{}, err + } + if err := writeClaudePointer(projectRoot, force); err != nil { + return RefreshResult{}, err } + agentFiles, err := writeAgentsDir(projectRoot, manifest, projects) + if err != nil { + return RefreshResult{}, err + } + + generated := make([]string, 0, 2+len(agentFiles)) + generated = append(generated, GuideFilename(ProviderCodex), GuideFilename(ProviderClaudeCode)) + generated = append(generated, agentFiles...) return RefreshResult{ Status: "completed", @@ -125,10 +125,6 @@ func tryRefresh(projectRoot string, force bool) (RefreshResult, error) { }, nil } -func guideFilename(p Provider) string { - return GuideFilename(p) -} - // GuideFilename returns the on-disk filename for a given provider's guide. // Exported so status checks can surface missing-guide issues without // re-implementing the mapping. @@ -140,7 +136,7 @@ func GuideFilename(p Provider) string { } // ExpectedProviders returns the providers AGENTS.md / CLAUDE.md is -// rendered for in this workspace. The current schema always returns DefaultProviders; +// materialised for in this workspace. The current schema always returns DefaultProviders; // the function is kept for callers (notably status checks) that want to // list expected guide files without referencing the package-level slice // directly. @@ -148,67 +144,19 @@ func ExpectedProviders(_ string) ([]Provider, error) { return append([]Provider{}, DefaultProviders...), nil } -func providerLabel(p Provider) string { - if p == ProviderCodex { - return "Codex" - } - return "Claude Code" -} - -// section is one rendered chunk of the managed block: every subproject of -// the same template grouped together with the merged ai/.md + -// common.md content. -type section struct { - TemplateID string - Subprojects []workspace.Project - Content string -} - -func collectSections(subs []workspace.Project, p Provider) []section { - groups := map[string][]workspace.Project{} - for _, s := range subs { - groups[s.TemplateID] = append(groups[s.TemplateID], s) - } - ids := make([]string, 0, len(groups)) - for id := range groups { - ids = append(ids, id) - } - sort.Strings(ids) - out := make([]section, 0, len(ids)) - for _, id := range ids { - content := loadTemplateContent(id, p) - if content == "" { - content = renderFallbackGuide(id) - } - out = append(out, section{ - TemplateID: id, - Subprojects: groups[id], - Content: content, - }) - } - return out -} - // loadTemplateContent reads