From 207a0fa9a34c9325a6c3aec9322e7368b2f91ce5 Mon Sep 17 00:00:00 2001 From: bytedance-zhangbinkai Date: Fri, 21 Aug 2026 15:29:33 +0800 Subject: [PATCH 01/33] docs(base): sync workflow guide to current branch --- .../references/lark-base-workflow.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 24d40f4707..4c055c79fd 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -55,6 +55,7 @@ | 场景 | 步骤组合 | 示例 | |------|---------|------| | 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) | +| 定时触发+AI 分析 | TimerTrigger → AIAnalysisAction | [下方](#示例-ai-分析-定时分析-base-数据并回读核验) | | 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) | | 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) | | 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) | @@ -65,6 +66,51 @@ ## 完整示例 +### 示例: AI 分析 - 定时分析 Base 数据并回读核验 + +**场景**: 每天早上 9 点分析「订单表」和「退款表」的昨日趋势。创建后先回读确认四项配置已落盘;修改时始终先 `+workflow-get` 再做全量更新。 + +```json +{ + "client_token": "1704067200-ai-analysis", + "title": "每日经营 AI 分析", + "steps": [ + { + "id": "step_timer", + "type": "TimerTrigger", + "title": "每天早上 9 点触发", + "next": "step_ai_analysis", + "data": { + "rule": "DAILY", + "start_time": "2025-01-01 09:00", + "is_never_end": true + } + }, + { + "id": "step_ai_analysis", + "type": "AIAnalysisAction", + "title": "分析昨日经营异常", + "next": null, + "data": { + "analysis_task": [ + { "value_type": "text", "value": "分析昨日订单趋势、退款异常和可能原因,并给出今日行动建议" } + ], + "analysis_table_names": ["订单表", "退款表"], + "identity_type": "maker", + "output_instruction": "先给结论,再列证据与行动建议" + } + } + ] +} +``` + +**推荐操作顺序**: +- 创建后立刻执行 `lark-cli base +workflow-get --base-token --workflow-id --as user`,确认 `steps[].type` 仍为 `AIAnalysisAction`,且四个字段语义与提交一致。 +- 更新已有流程时,先用 `+workflow-get` 读取完整 JSON,只改目标字段,再把完整 body 传给 `+workflow-update`;不要手写局部 patch。 +- `analysis_table_names: []` 表示当前 Base 的全部数据表;若要限制范围,请显式列出表名。 +- `identity_type: "maker"` 表示固定流程身份;`identity_type: "triggerPersonal"` 仅适用于能提供真实触发者身份的触发器。 +- AI 分析是异步执行链路:配置保存成功、工作流启用成功、单次节点执行成功是三件事,需分别观察。 + ### 示例 1: 新增记录触发 + 发送消息 **场景**: 当订单表新增记录时,发送飞书消息通知负责人。 From 9d40b65827a610c1021c45d07c1509a4986fddaa Mon Sep 17 00:00:00 2001 From: bytedance-zhangbinkai Date: Fri, 21 Aug 2026 15:29:36 +0800 Subject: [PATCH 02/33] docs(base): sync workflow schema to current branch --- .../references/lark-base-workflow-schema.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index b79acbc41d..e79ea000a3 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -125,6 +125,7 @@ | `Delay` | 延迟 | | `LarkMessageAction` | 发送飞书消息 | | `GenerateAiTextAction` | AI 生成文本 | +| `AIAnalysisAction` | AI 分析 | > 所有 Action 节点**请勿设置** `children` ,通过 `next` 串联后继。 @@ -473,6 +474,30 @@ |------|------|------| | `prompt` | 是 | TextRefItem[] 提示词,支持 `text` / `ref` | +### AIAnalysisAction + +```json +{ + "analysis_task": [ + { "value_type": "text", "value": "分析昨日订单趋势、异常原因,并给出行动建议" } + ], + "analysis_table_names": ["订单表", "退款表"], + "identity_type": "maker", + "output_instruction": "先给结论,再列证据与行动建议" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `analysis_task` | 是 | TextRefItem[] 分析任务,支持 `text` / `ref` 混排;至少包含一项有效内容 | +| `analysis_table_names` | 否 | string[] 分析数据范围;为空数组 `[]` 或省略时表示当前 Base 的全部数据表 | +| `identity_type` | 是 | 数据访问身份:`maker`(固定流程身份) / `triggerPersonal`(流程触发者) | +| `output_instruction` | 否 | string 输出要求;仅支持纯文本,不支持引用、附件或云文档模板 | + +> ⚠️ `AIAnalysisAction` 的公开 JSON 使用 snake_case;服务端内部会按既有规则映射到 `analysisTask` / `analysisTableNames` / `identityType` / `outputInstruction`。 +> +> ⚠️ `analysis_table_names` 中指定的表名必须能解析到当前 Base;无效表名、无权限或身份与触发器不兼容时,应在保存或启用前失败,不能静默降级。 + ## Branch data 详细结构 @@ -788,6 +813,12 @@ HTTPClientAction 的输出取决于 `response_type`: |--------|------|----------| | (整体出参) | AI 生成的文本内容(不支持下钻,只能引用 `$.{stepId}`) | `$.{stepId}` | +##### AIAnalysisAction(AI 分析) + +| pathId | 说明 | 引用示例 | +|--------|------|----------| +| `analysisResult` | AI 分析结果字符串 | `$.{stepId}.analysisResult` | + ##### 无输出的操作节点 以下节点不产生任何可引用的输出数据: @@ -887,6 +918,7 @@ $.{stepId}.{fieldId}.fileToken → 文件 Token 列表(array,仅 | SetRecordAction | 动作 | ✅ | 动态(用户配置的字段) | | HTTPClientAction | 动作 | ✅ | 动态(取决于用户配置的 HTTP 响应输出) | | GenerateAiTextAction | 动作 | ✅ | 静态(单 string) | +| AIAnalysisAction | 动作 | ✅ | 静态(`analysisResult`) | | Delay | 动作 | ❌ | 无输出 | | LarkMessageAction | 动作 | ❌ | 无输出 | | IfElseBranch | 分支 | ❌ | 无输出 | From b7b21b150042f887b9f160a07899c9ae85d582b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Fri, 21 Aug 2026 16:26:39 +0800 Subject: [PATCH 03/33] feat(base): support AI classification workflow validation Co-authored-by: TRAE CLI --- shortcuts/base/base_shortcuts_test.go | 2 + .../workflow_ai_classification_validate.go | 361 ++++++++++++++++++ shortcuts/base/workflow_create.go | 7 +- shortcuts/base/workflow_execute_test.go | 153 ++++++++ shortcuts/base/workflow_update.go | 7 +- skills/lark-base/SKILL.md | 2 +- .../references/lark-base-workflow-schema.md | 49 +++ .../references/lark-base-workflow.md | 140 +++++++ 8 files changed, 718 insertions(+), 3 deletions(-) create mode 100644 shortcuts/base/workflow_ai_classification_validate.go diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 5ff21275bd..1372020635 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -879,6 +879,7 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "lark-base-workflow.md as the module entry", "lark-base-workflow-schema.md as the steps JSON SSOT", "do not invent steps[].type/data/next/children from natural language", + "AIClassificationBranch", }, }, { @@ -892,6 +893,7 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "workflow-id must start with wkf", "Updating does not enable or disable", "do not invent steps[].type/data/next/children from natural language", + "AIClassificationBranch", }, }, { diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go new file mode 100644 index 0000000000..9305d5afd4 --- /dev/null +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -0,0 +1,361 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "fmt" + "strings" +) + +const workflowAIClassificationStepType = "AIClassificationBranch" + +func validateWorkflowAIClassificationBranches(body map[string]interface{}) error { + stepsRaw, ok := body["steps"] + if !ok || stepsRaw == nil { + return nil + } + steps, ok := stepsRaw.([]interface{}) + if !ok { + return nil + } + + stepIDs := make(map[string]int, len(steps)) + for i, raw := range steps { + step, ok := raw.(map[string]interface{}) + if !ok { + continue + } + id, _ := step["id"].(string) + if id != "" { + stepIDs[id] = i + } + } + + for i, raw := range steps { + step, ok := raw.(map[string]interface{}) + if !ok { + continue + } + stepType, _ := step["type"].(string) + if stepType != workflowAIClassificationStepType { + continue + } + if err := validateWorkflowAIClassificationStep(step, i, stepIDs); err != nil { + return err + } + } + return nil +} + +func validateWorkflowAIClassificationStep(step map[string]interface{}, index int, stepIDs map[string]int) error { + path := fmt.Sprintf("--json steps[%d]", index) + stepID, _ := step["id"].(string) + if strings.TrimSpace(stepID) == "" { + return baseValidationErrorf("%s.id must be a non-empty string for AIClassificationBranch", path) + } + currentIndex, ok := stepIDs[stepID] + if !ok { + currentIndex = index + } + + data, ok := step["data"].(map[string]interface{}) + if !ok || data == nil { + return baseValidationErrorf("%s.data must be an object for AIClassificationBranch", path) + } + if err := validateAIClassificationMode(data, path); err != nil { + return err + } + if err := validateAIClassificationPrompt(data, path, stepID, currentIndex, stepIDs); err != nil { + return err + } + if err := validateAIClassificationBranches(data, path, stepIDs); err != nil { + return err + } + if err := validateAIClassificationDefaultBranch(data, path, stepIDs); err != nil { + return err + } + if err := validateAIClassificationTextRefList(data, "classifyPrompt", "classify_prompt", path, false, false, stepID, currentIndex, stepIDs); err != nil { + return err + } + return validateAIClassificationChildLinks(step, path, stepIDs) +} + +func validateAIClassificationMode(data map[string]interface{}, path string) error { + raw, ok := data["mode"] + if !ok || raw == nil { + return nil + } + mode, ok := raw.(string) + if !ok { + return baseValidationErrorf("%s.data.mode must be a string when set", path) + } + switch strings.ToLower(strings.TrimSpace(mode)) { + case "exclusive", "parallel": + return nil + default: + return baseValidationErrorf("%s.data.mode must be Exclusive or Parallel", path) + } +} + +func validateAIClassificationPrompt(data map[string]interface{}, path string, stepID string, currentIndex int, stepIDs map[string]int) error { + if _, ok := data["prompt"]; !ok { + return baseValidationErrorf("%s.data.prompt is required for AIClassificationBranch", path) + } + return validateAIClassificationTextRefList(data, "prompt", "prompt", path, true, true, stepID, currentIndex, stepIDs) +} + +func validateAIClassificationBranches(data map[string]interface{}, path string, stepIDs map[string]int) error { + raw, branchKey, ok := firstExisting(data, "childBranchList", "child_branch_list") + if !ok { + return baseValidationErrorf("%s.data.childBranchList is required for AIClassificationBranch", path) + } + branches, ok := raw.([]interface{}) + if !ok { + return baseValidationErrorf("%s.data.%s must be an array", path, branchKey) + } + if len(branches) < 2 { + return baseValidationErrorf("%s.data.%s must contain at least 2 classifications", path, branchKey) + } + + seenNames := map[string]int{} + seenEntries := map[string]string{} + for i, rawBranch := range branches { + branchPath := fmt.Sprintf("%s.data.%s[%d]", path, branchKey, i) + branch, ok := rawBranch.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s must be an object", branchPath) + } + name, err := textRefPlainText(branch["name"]) + if err != nil { + return baseValidationErrorf("%s.name must be plain text", branchPath) + } + name = strings.TrimSpace(name) + switch { + case name == "": + return baseValidationErrorf("%s.name must not be blank", branchPath) + case strings.ContainsAny(name, "\r\n"): + return baseValidationErrorf("%s.name must not contain newlines", branchPath) + case name == "其他": + return baseValidationErrorf("%s.name must not use reserved name %q", branchPath, "其他") + } + if prev, exists := seenNames[name]; exists { + return baseValidationErrorf("%s.name duplicates %s.data.%s[%d].name", branchPath, path, branchKey, prev) + } + seenNames[name] = i + + if descRaw, ok := branch["description"]; ok && descRaw != nil { + if _, err := textRefPlainText(descRaw); err != nil { + return baseValidationErrorf("%s.description must be plain text", branchPath) + } + } + if entry, ok := firstString(branch, "entryChildStepId", "entry_child_step_id"); ok { + entry = strings.TrimSpace(entry) + if entry == "" { + return baseValidationErrorf("%s.entryChildStepId must not be blank when set", branchPath) + } + if _, exists := stepIDs[entry]; !exists { + return baseValidationErrorf("%s.entryChildStepId references unknown step id %q", branchPath, entry) + } + if prevName, exists := seenEntries[entry]; exists { + return baseValidationErrorf("%s.entryChildStepId %q is already used by classification %q", branchPath, entry, prevName) + } + seenEntries[entry] = name + } + } + return nil +} + +func validateAIClassificationDefaultBranch(data map[string]interface{}, path string, stepIDs map[string]int) error { + raw, branchKey, ok := firstExisting(data, "defaultBranchInfo", "default_branch_info") + if ok && raw != nil { + info, ok := raw.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s.data.%s must be an object when set", path, branchKey) + } + if mode, ok := firstString(info, "mode"); ok { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "execute", "fail": + default: + return baseValidationErrorf("%s.data.%s.mode must be Execute or Fail", path, branchKey) + } + } + if entry, ok := firstString(info, "entryStepId", "entry_step_id"); ok { + entry = strings.TrimSpace(entry) + if entry != "" { + if _, exists := stepIDs[entry]; !exists { + return baseValidationErrorf("%s.data.%s.entryStepId references unknown step id %q", path, branchKey, entry) + } + } + } + } + + if action, ok := firstString(data, "no_match_action", "noMatchAction"); ok { + switch strings.ToLower(strings.TrimSpace(action)) { + case "classifytoother", "classify_to_other", "fail": + default: + return baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail", path) + } + } + return nil +} + +func validateAIClassificationChildLinks(step map[string]interface{}, path string, stepIDs map[string]int) error { + children, ok := step["children"].(map[string]interface{}) + if !ok || children == nil { + return baseValidationErrorf("%s.children.links is required for AIClassificationBranch", path) + } + linksRaw, ok := children["links"] + if !ok { + return baseValidationErrorf("%s.children.links is required for AIClassificationBranch", path) + } + links, ok := linksRaw.([]interface{}) + if !ok { + return baseValidationErrorf("%s.children.links must be an array", path) + } + seenTargets := map[string]int{} + for i, raw := range links { + linkPath := fmt.Sprintf("%s.children.links[%d]", path, i) + link, ok := raw.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s must be an object", linkPath) + } + kind, _ := link["kind"].(string) + if strings.TrimSpace(kind) != "case" { + return baseValidationErrorf("%s.kind must be case for AIClassificationBranch", linkPath) + } + to, _ := link["to"].(string) + to = strings.TrimSpace(to) + if to == "" { + return baseValidationErrorf("%s.to must not be blank", linkPath) + } + if _, exists := stepIDs[to]; !exists { + return baseValidationErrorf("%s.to references unknown step id %q", linkPath, to) + } + if prev, exists := seenTargets[to]; exists { + return baseValidationErrorf("%s.to duplicates %s.children.links[%d].to", linkPath, path, prev) + } + seenTargets[to] = i + } + return nil +} + +func validateAIClassificationTextRefList(data map[string]interface{}, camelKey string, snakeKey string, path string, required bool, allowRef bool, stepID string, currentIndex int, stepIDs map[string]int) error { + raw, key, ok := firstExisting(data, camelKey, snakeKey) + if !ok || raw == nil { + if required { + return baseValidationErrorf("%s.data.%s is required for AIClassificationBranch", path, camelKey) + } + return nil + } + items, ok := raw.([]interface{}) + if !ok { + return baseValidationErrorf("%s.data.%s must be a TextRefItem array", path, key) + } + + hasContent := false + for i, rawItem := range items { + itemPath := fmt.Sprintf("%s.data.%s[%d]", path, key, i) + item, ok := rawItem.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s must be an object", itemPath) + } + valueType, _ := firstString(item, "value_type", "valueType", "type") + valueType = strings.ToLower(strings.TrimSpace(valueType)) + value, _ := item["value"].(string) + if valueType == "" { + return baseValidationErrorf("%s.value_type must be text or ref", itemPath) + } + switch valueType { + case "text": + if strings.TrimSpace(value) != "" { + hasContent = true + } + case "ref": + if !allowRef { + return baseValidationErrorf("%s.value_type must be text; ref is not supported here", itemPath) + } + if strings.TrimSpace(value) == "" { + return baseValidationErrorf("%s.value must not be blank for ref", itemPath) + } + refStep, ok := workflowRefStepID(value) + if !ok { + return baseValidationErrorf("%s.value must be a workflow ref path starting with $.step_id", itemPath) + } + refIndex, exists := stepIDs[refStep] + if !exists { + return baseValidationErrorf("%s.value references unknown step id %q", itemPath, refStep) + } + if refStep == stepID || refIndex >= currentIndex { + return baseValidationErrorf("%s.value must reference a previous step, got %q", itemPath, refStep) + } + hasContent = true + default: + return baseValidationErrorf("%s.value_type must be text or ref", itemPath) + } + } + if required && !hasContent { + return baseValidationErrorf("%s.data.%s must contain non-empty text or a valid ref", path, key) + } + return nil +} + +func workflowRefStepID(value string) (string, bool) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "$.") { + return "", false + } + value = strings.TrimPrefix(value, "$.") + if value == "" { + return "", false + } + if idx := strings.Index(value, "."); idx >= 0 { + value = value[:idx] + } + return value, value != "" +} + +func textRefPlainText(raw interface{}) (string, error) { + switch value := raw.(type) { + case string: + return value, nil + case []interface{}: + var b strings.Builder + for _, itemRaw := range value { + item, ok := itemRaw.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("item must be an object") + } + valueType, _ := firstString(item, "value_type", "valueType", "type") + if strings.ToLower(strings.TrimSpace(valueType)) != "text" { + return "", fmt.Errorf("item must be text") + } + text, ok := item["value"].(string) + if !ok { + return "", fmt.Errorf("value must be string") + } + b.WriteString(text) + } + return b.String(), nil + default: + return "", fmt.Errorf("value must be string or TextRefItem array") + } +} + +func firstExisting(data map[string]interface{}, keys ...string) (interface{}, string, bool) { + for _, key := range keys { + if value, ok := data[key]; ok { + return value, key, true + } + } + return nil, "", false +} + +func firstString(data map[string]interface{}, keys ...string) (string, bool) { + for _, key := range keys { + if value, ok := data[key].(string); ok { + return value, true + } + } + return "", false +} diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 9b6084a901..f748beacad 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -28,6 +28,7 @@ var BaseWorkflowCreate = common.Shortcut{ "Before constructing steps, use +table-list and +field-list to confirm real table and field names.", "Step ids must be unique, and every next/children link must reference an existing step id.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", + "For AIClassificationBranch, read the schema section first and run +workflow-get after create to confirm the saved prompt, branches, default branch, and children.links.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { @@ -38,7 +39,11 @@ var BaseWorkflowCreate = common.Shortcut{ if err != nil { return err } - if _, err := parseJSONObject(pc, raw, "json"); err != nil { + body, err := parseJSONObject(pc, raw, "json") + if err != nil { + return err + } + if err := validateWorkflowAIClassificationBranches(body); err != nil { return err } return nil diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index ad7b0866c4..913b43139b 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -107,6 +107,59 @@ func TestBaseWorkflowExecuteCreate(t *testing.T) { } } +func TestBaseWorkflowExecuteCreatePreservesAIClassificationBranch(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"}, + }, + } + reg.Register(stub) + + body := `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {"table_name": "Feedback"}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "next": null, + "children": {"links": [ + {"kind": "case", "to": "step_bug", "label": "branch_1", "desc": "Bug"}, + {"kind": "case", "to": "step_feature", "label": "branch_2", "desc": "Feature"}, + {"kind": "case", "to": "step_other", "label": "other", "desc": "Other"} + ]}, + "data": { + "mode": "Exclusive", + "prompt": [ + {"value_type": "text", "value": "Classify feedback: "}, + {"value_type": "ref", "value": "$.step_trigger.fldFeedback"} + ], + "childBranchList": [ + {"name": [{"value_type": "text", "value": "Bug"}], "description": [{"value_type": "text", "value": "Broken behavior"}], "entryChildStepId": "step_bug"}, + {"name": [{"value_type": "text", "value": "Feature"}], "description": [{"value_type": "text", "value": "New capability"}], "entryChildStepId": "step_feature"} + ], + "defaultBranchInfo": {"mode": "Execute", "entryStepId": "step_other"}, + "classifyPrompt": [{"value_type": "text", "value": "Use Other when unsure."}], + "future_server_field": {"keep": true} + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {"unknown": true}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", body}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := string(stub.CapturedBody); !strings.Contains(got, `"type":"AIClassificationBranch"`) || !strings.Contains(got, `"future_server_field":{"keep":true}`) { + t.Fatalf("AI classification payload was not forwarded verbatim enough: %s", got) + } +} + func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { t.Run("missing base-token", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) @@ -124,6 +177,106 @@ func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { }) } +func TestBaseWorkflowExecuteValidateAIClassificationBranch(t *testing.T) { + base := func(data string, children string) string { + return `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + {"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `}, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` + } + validChildren := `{"links":[{"kind":"case","to":"step_bug"},{"kind":"case","to":"step_feature"}]}` + validData := `{ + "mode": "Exclusive", + "prompt": [{"value_type": "text", "value": "Classify: "}, {"value_type": "ref", "value": "$.step_trigger.fldFeedback"}], + "child_branch_list": [ + {"name": "Bug", "description": "Broken behavior", "entry_child_step_id": "step_bug"}, + {"name": "Feature", "description": "New capability", "entry_child_step_id": "step_feature"} + ], + "no_match_action": "fail", + "classify_prompt": [{"value_type": "text", "value": "Use the closest category."}] + }` + + tests := []struct { + name string + body string + want string + }{ + { + name: "blank prompt", + body: base(strings.Replace(validData, `"prompt": [{"value_type": "text", "value": "Classify: "}, {"value_type": "ref", "value": "$.step_trigger.fldFeedback"}]`, `"prompt": [{"value_type": "text", "value": " "}]`, 1), validChildren), + want: "data.prompt must contain non-empty text or a valid ref", + }, + { + name: "duplicate classification name", + body: base(strings.Replace(validData, `"Feature"`, `"Bug"`, 1), validChildren), + want: "duplicates", + }, + { + name: "unknown child entry", + body: base(strings.Replace(validData, `"step_feature"`, `"step_missing"`, 1), validChildren), + want: "references unknown step id", + }, + { + name: "classify prompt ref", + body: base(strings.Replace(validData, `"classify_prompt": [{"value_type": "text", "value": "Use the closest category."}]`, `"classify_prompt": [{"value_type": "ref", "value": "$.step_trigger.fldFeedback"}]`, 1), validChildren), + want: "ref is not supported here", + }, + { + name: "non case link", + body: base(validData, `{"links":[{"kind":"if_true","to":"step_bug"}]}`), + want: "kind must be case", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", tt.body}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want substring %q", err, tt.want) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("err type=%T want *errs.ValidationError", err) + } + }) + } +} + +func TestBaseWorkflowExecuteUpdateValidatesAIClassificationBranch(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + body := `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":[{"kind":"case","to":"step_bug"},{"kind":"case","to":"step_feature"}]}, + "data": { + "mode": "NotAMode", + "prompt": [{"value_type": "text", "value": "Classify"}], + "childBranchList": [ + {"name": "Bug", "entryChildStepId": "step_bug"}, + {"name": "Feature", "entryChildStepId": "step_feature"} + ] + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` + err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "data.mode must be Exclusive or Parallel") { + t.Fatalf("err=%v", err) + } +} + func TestBaseWorkflowExecuteDisable(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index d0b2c1caeb..e725f6f303 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -30,6 +30,7 @@ var BaseWorkflowUpdate = common.Shortcut{ "Step ids must be unique, and every next/children link must reference an existing step id.", "Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", + "For AIClassificationBranch, preserve the full object returned by +workflow-get and verify prompt, branches, default branch, and children.links after update.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { @@ -39,7 +40,11 @@ var BaseWorkflowUpdate = common.Shortcut{ return baseFlagErrorf("--workflow-id must not be blank") } pc := newParseCtx(runtime) - if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil { + body, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return err + } + if err := validateWorkflowAIClassificationBranches(body); err != nil { return err } return nil diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 2fcb84af84..79f7c2c8a0 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -149,7 +149,7 @@ BaseApp、Workspace、Page 或组件任务开始前完整读取 [应用模式与 Workflow 本身是 Base Block,其内部是一张由 `next` / `children` 连接的 steps 执行图;触发器、动作、条件分支和循环都是 step 类型。它适合定时执行、Record 新增或变更联动、消息通知、记录读写和跨系统调用。Workflow 分为三条操作路径: 1. **读取配置:** `+workflow-list` 定位流程,`+workflow-get` 读取 `title`、`status` 和完整 `steps` 执行图。 -2. **写入配置:** `+workflow-create` 创建完整定义,`+workflow-update` 更新完整定义;构造或修改配置前读取 [Workflow](references/lark-base-workflow.md),由该入口继续路由 step 类型和 schema。 +2. **写入配置:** `+workflow-create` 创建完整定义,`+workflow-update` 更新完整定义;构造或修改配置前读取 [Workflow](references/lark-base-workflow.md),由该入口继续路由 step 类型和 schema。涉及 AI 分类时优先读取 schema 的 `AIClassificationBranch` 小节。 3. **运行状态控制:** `+workflow-enable` / `+workflow-disable` 启用或停用已有 Workflow,不修改 steps 执行图。 ## Advanced Permission(AdvPerm) diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index b79acbc41d..83e5548166 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -134,6 +134,7 @@ |------|------| | `IfElseBranch` | 条件分支,`children.links` 含 `if_true` 和 `if_false` | | `SwitchBranch` | 多路分支,`children.links` 含多个 `case` | +| `AIClassificationBranch` | AI 分类分支,`children.links` 含多个 `case` | ### System 类型 @@ -552,6 +553,54 @@ | `name` | string | 分支名称 | | `condition` | OrGroup | 分支条件 | +### AIClassificationBranch + +`AIClassificationBranch` 用 AI 对 `prompt` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`+workflow-create` / `+workflow-update` 只提交完整 workflow JSON;提交后用 `+workflow-get` 回读确认服务端保存的最终字段和枚举。 + +```json +{ + "mode": "Exclusive", + "prompt": [ + { "value_type": "text", "value": "请根据反馈内容判断类型:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedback" } + ], + "childBranchList": [ + { + "name": [{ "value_type": "text", "value": "Bug" }], + "description": [{ "value_type": "text", "value": "功能报错、异常、不可用或结果错误" }], + "entryChildStepId": "step_bug_action" + }, + { + "name": [{ "value_type": "text", "value": "功能建议" }], + "description": [{ "value_type": "text", "value": "希望新增能力或优化现有功能" }], + "entryChildStepId": "step_feature_action" + } + ], + "defaultBranchInfo": { + "mode": "Execute", + "entryStepId": "step_other_action" + }, + "classifyPrompt": [ + { "value_type": "text", "value": "信息不足时进入其他分类;有明确故障现象时优先归为 Bug。" } + ] +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `mode` | 否 | 执行逻辑。`Exclusive`:最为匹配,只执行一个分类分支;`Parallel`:所有匹配,执行全部命中分支。省略时以服务端默认值为准 | +| `prompt` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` 混排;`ref` 只能引用前序 step | +| `childBranchList` / `child_branch_list` | 是 | 分类列表,至少 2 个。分类名可用 string 或纯文本 TextRefItem[],同一节点内不可为空、重复、包含换行或使用保留名称 `其他` | +| `defaultBranchInfo` / `default_branch_info` | 否 | 无匹配策略。`mode: "Execute"` 时可通过 `entryStepId` / `entry_step_id` 指向其他分支入口;`mode: "Fail"` 时当前节点失败 | +| `no_match_action` / `noMatchAction` | 否 | 服务端回读可能使用的无匹配策略字段,常见值为 `classifyToOther` / `fail` | +| `classifyPrompt` / `classify_prompt` | 否 | 全局分类规则,TextRefItem[],只能使用 `text` 段,不支持变量引用 | + +`children.links` 规则: +- 每条边使用 `kind: "case"`,`to` 指向该分类或默认分支的入口 step。 +- `label` 建议使用稳定中性值,如 `branch_1`、`branch_2`、`other`;分类语义写在 `desc` 和 `childBranchList[].name`。 +- `entryChildStepId` / `entryStepId` 与 `children.links[].to` 都必须引用同一 workflow 内存在的 step。多个分类不要指向同一个入口 step,避免图结构冲突。 +- 并行模式和 AI 分类能力是否可用由服务端/租户能力裁决;CLI 不会把 `Parallel` 静默降级为 `Exclusive`。 + ## System data 详细结构 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 24d40f4707..5f2d0d45e7 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -59,6 +59,7 @@ | 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) | | 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) | | 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支-switchbranch) | +| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例-ai-分类用户反馈自动分流) | | 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例5-组合场景-定时查找循环分支消息) | --- @@ -470,6 +471,145 @@ --- +### 示例 AI 分类:用户反馈自动分流 + +**场景**: 当用户反馈表新增记录时,AI 根据反馈内容分类为 Bug、功能建议或体验问题;无法判断时进入其他分支并通知人工复核。 + +```json +{ + "client_token": "1704067206", + "title": "用户反馈自动分流", + "steps": [ + { + "id": "step_trigger", + "type": "AddRecordTrigger", + "title": "新增反馈时触发", + "next": "step_ai_classify", + "data": { + "table_name": "用户反馈表", + "watched_field_name": "反馈详情" + } + }, + { + "id": "step_ai_classify", + "type": "AIClassificationBranch", + "title": "AI 判断反馈类型", + "children": { + "links": [ + { "kind": "case", "to": "step_bug_action", "label": "branch_1", "desc": "Bug" }, + { "kind": "case", "to": "step_feature_action", "label": "branch_2", "desc": "功能建议" }, + { "kind": "case", "to": "step_experience_action", "label": "branch_3", "desc": "体验问题" }, + { "kind": "case", "to": "step_other_action", "label": "other", "desc": "其他" } + ] + }, + "next": null, + "data": { + "mode": "Exclusive", + "prompt": [ + { "value_type": "text", "value": "请根据反馈标题和反馈详情判断类型:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackTitle" }, + { "value_type": "text", "value": " " }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } + ], + "childBranchList": [ + { + "name": [{ "value_type": "text", "value": "Bug" }], + "description": [{ "value_type": "text", "value": "功能报错、异常、崩溃、无法使用或结果错误" }], + "entryChildStepId": "step_bug_action" + }, + { + "name": [{ "value_type": "text", "value": "功能建议" }], + "description": [{ "value_type": "text", "value": "希望新增能力或改变产品行为" }], + "entryChildStepId": "step_feature_action" + }, + { + "name": [{ "value_type": "text", "value": "体验问题" }], + "description": [{ "value_type": "text", "value": "流程繁琐、操作难懂、性能慢或界面体验不佳" }], + "entryChildStepId": "step_experience_action" + } + ], + "defaultBranchInfo": { + "mode": "Execute", + "entryStepId": "step_other_action" + }, + "classifyPrompt": [ + { "value_type": "text", "value": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入其他分类。" } + ] + } + }, + { + "id": "step_bug_action", + "type": "SetRecordAction", + "title": "标记为 Bug", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "Bug" }] } + ] + } + }, + { + "id": "step_feature_action", + "type": "SetRecordAction", + "title": "标记为功能建议", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "功能建议" }] } + ] + } + }, + { + "id": "step_experience_action", + "type": "SetRecordAction", + "title": "标记为体验问题", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "体验问题" }] } + ] + } + }, + { + "id": "step_other_action", + "type": "LarkMessageAction", + "title": "通知人工复核", + "next": null, + "data": { + "receiver": [{ "value_type": "user", "value": { "id": "ou_xxxx", "name": "负责人" } }], + "send_to_everyone": false, + "title": [{ "value_type": "text", "value": "反馈需要人工复核" }], + "content": [ + { "value_type": "text", "value": "AI 未能确定反馈分类,请人工确认:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } + ], + "btn_list": [] + } + } + ] +} +``` + +标准操作顺序: +1. 创建:`lark-cli base +workflow-create --base-token --json @workflow.json` +2. 回读:`lark-cli base +workflow-get --base-token --workflow-id ` +3. 更新:先保存回读结果,只修改目标字段,再执行 `+workflow-update --json @workflow.json` +4. 再次回读:确认 `AIClassificationBranch` 的 `prompt`、分类列表、默认分支和 `children.links` 均未丢失 + +关键点: +- `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类和其他分支都通过 `to` 指向后续 step。 +- `prompt` 支持 `text` / `ref` 混排,`classifyPrompt` 只写纯文本规则。 +- `mode: "Parallel"` 表示所有匹配;是否支持并行模式和默认分支由服务端能力决定,CLI 不会静默降级。 +- AI 分类可能处理业务敏感信息,默认错误提示只定位字段路径,不应输出完整待分类内容。 + +--- + ### 示例 5: 组合场景(定时+查找+循环+分支+消息) **场景**: 每天早上 9 点,查找昨天的订单,按金额分级,给不同级别的销售发送不同的通知。 From 35eb05960264cf58321e47e49437eee2998093f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Fri, 21 Aug 2026 17:47:09 +0800 Subject: [PATCH 04/33] docs(base): document AI classification workflow schema Co-authored-by: TRAE CLI --- shortcuts/base/base_shortcuts_test.go | 2 - .../workflow_ai_classification_validate.go | 361 ------------------ shortcuts/base/workflow_create.go | 7 +- shortcuts/base/workflow_execute_test.go | 153 -------- shortcuts/base/workflow_update.go | 7 +- skills/lark-base/SKILL.md | 2 +- .../references/lark-base-workflow-schema.md | 18 +- .../references/lark-base-workflow.md | 6 +- 8 files changed, 15 insertions(+), 541 deletions(-) delete mode 100644 shortcuts/base/workflow_ai_classification_validate.go diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 1372020635..5ff21275bd 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -879,7 +879,6 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "lark-base-workflow.md as the module entry", "lark-base-workflow-schema.md as the steps JSON SSOT", "do not invent steps[].type/data/next/children from natural language", - "AIClassificationBranch", }, }, { @@ -893,7 +892,6 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "workflow-id must start with wkf", "Updating does not enable or disable", "do not invent steps[].type/data/next/children from natural language", - "AIClassificationBranch", }, }, { diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go deleted file mode 100644 index 9305d5afd4..0000000000 --- a/shortcuts/base/workflow_ai_classification_validate.go +++ /dev/null @@ -1,361 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package base - -import ( - "fmt" - "strings" -) - -const workflowAIClassificationStepType = "AIClassificationBranch" - -func validateWorkflowAIClassificationBranches(body map[string]interface{}) error { - stepsRaw, ok := body["steps"] - if !ok || stepsRaw == nil { - return nil - } - steps, ok := stepsRaw.([]interface{}) - if !ok { - return nil - } - - stepIDs := make(map[string]int, len(steps)) - for i, raw := range steps { - step, ok := raw.(map[string]interface{}) - if !ok { - continue - } - id, _ := step["id"].(string) - if id != "" { - stepIDs[id] = i - } - } - - for i, raw := range steps { - step, ok := raw.(map[string]interface{}) - if !ok { - continue - } - stepType, _ := step["type"].(string) - if stepType != workflowAIClassificationStepType { - continue - } - if err := validateWorkflowAIClassificationStep(step, i, stepIDs); err != nil { - return err - } - } - return nil -} - -func validateWorkflowAIClassificationStep(step map[string]interface{}, index int, stepIDs map[string]int) error { - path := fmt.Sprintf("--json steps[%d]", index) - stepID, _ := step["id"].(string) - if strings.TrimSpace(stepID) == "" { - return baseValidationErrorf("%s.id must be a non-empty string for AIClassificationBranch", path) - } - currentIndex, ok := stepIDs[stepID] - if !ok { - currentIndex = index - } - - data, ok := step["data"].(map[string]interface{}) - if !ok || data == nil { - return baseValidationErrorf("%s.data must be an object for AIClassificationBranch", path) - } - if err := validateAIClassificationMode(data, path); err != nil { - return err - } - if err := validateAIClassificationPrompt(data, path, stepID, currentIndex, stepIDs); err != nil { - return err - } - if err := validateAIClassificationBranches(data, path, stepIDs); err != nil { - return err - } - if err := validateAIClassificationDefaultBranch(data, path, stepIDs); err != nil { - return err - } - if err := validateAIClassificationTextRefList(data, "classifyPrompt", "classify_prompt", path, false, false, stepID, currentIndex, stepIDs); err != nil { - return err - } - return validateAIClassificationChildLinks(step, path, stepIDs) -} - -func validateAIClassificationMode(data map[string]interface{}, path string) error { - raw, ok := data["mode"] - if !ok || raw == nil { - return nil - } - mode, ok := raw.(string) - if !ok { - return baseValidationErrorf("%s.data.mode must be a string when set", path) - } - switch strings.ToLower(strings.TrimSpace(mode)) { - case "exclusive", "parallel": - return nil - default: - return baseValidationErrorf("%s.data.mode must be Exclusive or Parallel", path) - } -} - -func validateAIClassificationPrompt(data map[string]interface{}, path string, stepID string, currentIndex int, stepIDs map[string]int) error { - if _, ok := data["prompt"]; !ok { - return baseValidationErrorf("%s.data.prompt is required for AIClassificationBranch", path) - } - return validateAIClassificationTextRefList(data, "prompt", "prompt", path, true, true, stepID, currentIndex, stepIDs) -} - -func validateAIClassificationBranches(data map[string]interface{}, path string, stepIDs map[string]int) error { - raw, branchKey, ok := firstExisting(data, "childBranchList", "child_branch_list") - if !ok { - return baseValidationErrorf("%s.data.childBranchList is required for AIClassificationBranch", path) - } - branches, ok := raw.([]interface{}) - if !ok { - return baseValidationErrorf("%s.data.%s must be an array", path, branchKey) - } - if len(branches) < 2 { - return baseValidationErrorf("%s.data.%s must contain at least 2 classifications", path, branchKey) - } - - seenNames := map[string]int{} - seenEntries := map[string]string{} - for i, rawBranch := range branches { - branchPath := fmt.Sprintf("%s.data.%s[%d]", path, branchKey, i) - branch, ok := rawBranch.(map[string]interface{}) - if !ok { - return baseValidationErrorf("%s must be an object", branchPath) - } - name, err := textRefPlainText(branch["name"]) - if err != nil { - return baseValidationErrorf("%s.name must be plain text", branchPath) - } - name = strings.TrimSpace(name) - switch { - case name == "": - return baseValidationErrorf("%s.name must not be blank", branchPath) - case strings.ContainsAny(name, "\r\n"): - return baseValidationErrorf("%s.name must not contain newlines", branchPath) - case name == "其他": - return baseValidationErrorf("%s.name must not use reserved name %q", branchPath, "其他") - } - if prev, exists := seenNames[name]; exists { - return baseValidationErrorf("%s.name duplicates %s.data.%s[%d].name", branchPath, path, branchKey, prev) - } - seenNames[name] = i - - if descRaw, ok := branch["description"]; ok && descRaw != nil { - if _, err := textRefPlainText(descRaw); err != nil { - return baseValidationErrorf("%s.description must be plain text", branchPath) - } - } - if entry, ok := firstString(branch, "entryChildStepId", "entry_child_step_id"); ok { - entry = strings.TrimSpace(entry) - if entry == "" { - return baseValidationErrorf("%s.entryChildStepId must not be blank when set", branchPath) - } - if _, exists := stepIDs[entry]; !exists { - return baseValidationErrorf("%s.entryChildStepId references unknown step id %q", branchPath, entry) - } - if prevName, exists := seenEntries[entry]; exists { - return baseValidationErrorf("%s.entryChildStepId %q is already used by classification %q", branchPath, entry, prevName) - } - seenEntries[entry] = name - } - } - return nil -} - -func validateAIClassificationDefaultBranch(data map[string]interface{}, path string, stepIDs map[string]int) error { - raw, branchKey, ok := firstExisting(data, "defaultBranchInfo", "default_branch_info") - if ok && raw != nil { - info, ok := raw.(map[string]interface{}) - if !ok { - return baseValidationErrorf("%s.data.%s must be an object when set", path, branchKey) - } - if mode, ok := firstString(info, "mode"); ok { - switch strings.ToLower(strings.TrimSpace(mode)) { - case "execute", "fail": - default: - return baseValidationErrorf("%s.data.%s.mode must be Execute or Fail", path, branchKey) - } - } - if entry, ok := firstString(info, "entryStepId", "entry_step_id"); ok { - entry = strings.TrimSpace(entry) - if entry != "" { - if _, exists := stepIDs[entry]; !exists { - return baseValidationErrorf("%s.data.%s.entryStepId references unknown step id %q", path, branchKey, entry) - } - } - } - } - - if action, ok := firstString(data, "no_match_action", "noMatchAction"); ok { - switch strings.ToLower(strings.TrimSpace(action)) { - case "classifytoother", "classify_to_other", "fail": - default: - return baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail", path) - } - } - return nil -} - -func validateAIClassificationChildLinks(step map[string]interface{}, path string, stepIDs map[string]int) error { - children, ok := step["children"].(map[string]interface{}) - if !ok || children == nil { - return baseValidationErrorf("%s.children.links is required for AIClassificationBranch", path) - } - linksRaw, ok := children["links"] - if !ok { - return baseValidationErrorf("%s.children.links is required for AIClassificationBranch", path) - } - links, ok := linksRaw.([]interface{}) - if !ok { - return baseValidationErrorf("%s.children.links must be an array", path) - } - seenTargets := map[string]int{} - for i, raw := range links { - linkPath := fmt.Sprintf("%s.children.links[%d]", path, i) - link, ok := raw.(map[string]interface{}) - if !ok { - return baseValidationErrorf("%s must be an object", linkPath) - } - kind, _ := link["kind"].(string) - if strings.TrimSpace(kind) != "case" { - return baseValidationErrorf("%s.kind must be case for AIClassificationBranch", linkPath) - } - to, _ := link["to"].(string) - to = strings.TrimSpace(to) - if to == "" { - return baseValidationErrorf("%s.to must not be blank", linkPath) - } - if _, exists := stepIDs[to]; !exists { - return baseValidationErrorf("%s.to references unknown step id %q", linkPath, to) - } - if prev, exists := seenTargets[to]; exists { - return baseValidationErrorf("%s.to duplicates %s.children.links[%d].to", linkPath, path, prev) - } - seenTargets[to] = i - } - return nil -} - -func validateAIClassificationTextRefList(data map[string]interface{}, camelKey string, snakeKey string, path string, required bool, allowRef bool, stepID string, currentIndex int, stepIDs map[string]int) error { - raw, key, ok := firstExisting(data, camelKey, snakeKey) - if !ok || raw == nil { - if required { - return baseValidationErrorf("%s.data.%s is required for AIClassificationBranch", path, camelKey) - } - return nil - } - items, ok := raw.([]interface{}) - if !ok { - return baseValidationErrorf("%s.data.%s must be a TextRefItem array", path, key) - } - - hasContent := false - for i, rawItem := range items { - itemPath := fmt.Sprintf("%s.data.%s[%d]", path, key, i) - item, ok := rawItem.(map[string]interface{}) - if !ok { - return baseValidationErrorf("%s must be an object", itemPath) - } - valueType, _ := firstString(item, "value_type", "valueType", "type") - valueType = strings.ToLower(strings.TrimSpace(valueType)) - value, _ := item["value"].(string) - if valueType == "" { - return baseValidationErrorf("%s.value_type must be text or ref", itemPath) - } - switch valueType { - case "text": - if strings.TrimSpace(value) != "" { - hasContent = true - } - case "ref": - if !allowRef { - return baseValidationErrorf("%s.value_type must be text; ref is not supported here", itemPath) - } - if strings.TrimSpace(value) == "" { - return baseValidationErrorf("%s.value must not be blank for ref", itemPath) - } - refStep, ok := workflowRefStepID(value) - if !ok { - return baseValidationErrorf("%s.value must be a workflow ref path starting with $.step_id", itemPath) - } - refIndex, exists := stepIDs[refStep] - if !exists { - return baseValidationErrorf("%s.value references unknown step id %q", itemPath, refStep) - } - if refStep == stepID || refIndex >= currentIndex { - return baseValidationErrorf("%s.value must reference a previous step, got %q", itemPath, refStep) - } - hasContent = true - default: - return baseValidationErrorf("%s.value_type must be text or ref", itemPath) - } - } - if required && !hasContent { - return baseValidationErrorf("%s.data.%s must contain non-empty text or a valid ref", path, key) - } - return nil -} - -func workflowRefStepID(value string) (string, bool) { - value = strings.TrimSpace(value) - if !strings.HasPrefix(value, "$.") { - return "", false - } - value = strings.TrimPrefix(value, "$.") - if value == "" { - return "", false - } - if idx := strings.Index(value, "."); idx >= 0 { - value = value[:idx] - } - return value, value != "" -} - -func textRefPlainText(raw interface{}) (string, error) { - switch value := raw.(type) { - case string: - return value, nil - case []interface{}: - var b strings.Builder - for _, itemRaw := range value { - item, ok := itemRaw.(map[string]interface{}) - if !ok { - return "", fmt.Errorf("item must be an object") - } - valueType, _ := firstString(item, "value_type", "valueType", "type") - if strings.ToLower(strings.TrimSpace(valueType)) != "text" { - return "", fmt.Errorf("item must be text") - } - text, ok := item["value"].(string) - if !ok { - return "", fmt.Errorf("value must be string") - } - b.WriteString(text) - } - return b.String(), nil - default: - return "", fmt.Errorf("value must be string or TextRefItem array") - } -} - -func firstExisting(data map[string]interface{}, keys ...string) (interface{}, string, bool) { - for _, key := range keys { - if value, ok := data[key]; ok { - return value, key, true - } - } - return nil, "", false -} - -func firstString(data map[string]interface{}, keys ...string) (string, bool) { - for _, key := range keys { - if value, ok := data[key].(string); ok { - return value, true - } - } - return "", false -} diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index f748beacad..9b6084a901 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -28,7 +28,6 @@ var BaseWorkflowCreate = common.Shortcut{ "Before constructing steps, use +table-list and +field-list to confirm real table and field names.", "Step ids must be unique, and every next/children link must reference an existing step id.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "For AIClassificationBranch, read the schema section first and run +workflow-get after create to confirm the saved prompt, branches, default branch, and children.links.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { @@ -39,11 +38,7 @@ var BaseWorkflowCreate = common.Shortcut{ if err != nil { return err } - body, err := parseJSONObject(pc, raw, "json") - if err != nil { - return err - } - if err := validateWorkflowAIClassificationBranches(body); err != nil { + if _, err := parseJSONObject(pc, raw, "json"); err != nil { return err } return nil diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index 913b43139b..ad7b0866c4 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -107,59 +107,6 @@ func TestBaseWorkflowExecuteCreate(t *testing.T) { } } -func TestBaseWorkflowExecuteCreatePreservesAIClassificationBranch(t *testing.T) { - factory, stdout, reg := newExecuteFactory(t) - stub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/base/v3/bases/app_x/workflows", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"}, - }, - } - reg.Register(stub) - - body := `{ - "title": "Feedback classify", - "steps": [ - {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {"table_name": "Feedback"}}, - { - "id": "step_classify", - "type": "AIClassificationBranch", - "next": null, - "children": {"links": [ - {"kind": "case", "to": "step_bug", "label": "branch_1", "desc": "Bug"}, - {"kind": "case", "to": "step_feature", "label": "branch_2", "desc": "Feature"}, - {"kind": "case", "to": "step_other", "label": "other", "desc": "Other"} - ]}, - "data": { - "mode": "Exclusive", - "prompt": [ - {"value_type": "text", "value": "Classify feedback: "}, - {"value_type": "ref", "value": "$.step_trigger.fldFeedback"} - ], - "childBranchList": [ - {"name": [{"value_type": "text", "value": "Bug"}], "description": [{"value_type": "text", "value": "Broken behavior"}], "entryChildStepId": "step_bug"}, - {"name": [{"value_type": "text", "value": "Feature"}], "description": [{"value_type": "text", "value": "New capability"}], "entryChildStepId": "step_feature"} - ], - "defaultBranchInfo": {"mode": "Execute", "entryStepId": "step_other"}, - "classifyPrompt": [{"value_type": "text", "value": "Use Other when unsure."}], - "future_server_field": {"keep": true} - } - }, - {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {"unknown": true}}, - {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, - {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} - ] - }` - if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", body}, factory, stdout); err != nil { - t.Fatalf("err=%v", err) - } - if got := string(stub.CapturedBody); !strings.Contains(got, `"type":"AIClassificationBranch"`) || !strings.Contains(got, `"future_server_field":{"keep":true}`) { - t.Fatalf("AI classification payload was not forwarded verbatim enough: %s", got) - } -} - func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { t.Run("missing base-token", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) @@ -177,106 +124,6 @@ func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { }) } -func TestBaseWorkflowExecuteValidateAIClassificationBranch(t *testing.T) { - base := func(data string, children string) string { - return `{ - "title": "Feedback classify", - "steps": [ - {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, - {"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `}, - {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, - {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} - ] - }` - } - validChildren := `{"links":[{"kind":"case","to":"step_bug"},{"kind":"case","to":"step_feature"}]}` - validData := `{ - "mode": "Exclusive", - "prompt": [{"value_type": "text", "value": "Classify: "}, {"value_type": "ref", "value": "$.step_trigger.fldFeedback"}], - "child_branch_list": [ - {"name": "Bug", "description": "Broken behavior", "entry_child_step_id": "step_bug"}, - {"name": "Feature", "description": "New capability", "entry_child_step_id": "step_feature"} - ], - "no_match_action": "fail", - "classify_prompt": [{"value_type": "text", "value": "Use the closest category."}] - }` - - tests := []struct { - name string - body string - want string - }{ - { - name: "blank prompt", - body: base(strings.Replace(validData, `"prompt": [{"value_type": "text", "value": "Classify: "}, {"value_type": "ref", "value": "$.step_trigger.fldFeedback"}]`, `"prompt": [{"value_type": "text", "value": " "}]`, 1), validChildren), - want: "data.prompt must contain non-empty text or a valid ref", - }, - { - name: "duplicate classification name", - body: base(strings.Replace(validData, `"Feature"`, `"Bug"`, 1), validChildren), - want: "duplicates", - }, - { - name: "unknown child entry", - body: base(strings.Replace(validData, `"step_feature"`, `"step_missing"`, 1), validChildren), - want: "references unknown step id", - }, - { - name: "classify prompt ref", - body: base(strings.Replace(validData, `"classify_prompt": [{"value_type": "text", "value": "Use the closest category."}]`, `"classify_prompt": [{"value_type": "ref", "value": "$.step_trigger.fldFeedback"}]`, 1), validChildren), - want: "ref is not supported here", - }, - { - name: "non case link", - body: base(validData, `{"links":[{"kind":"if_true","to":"step_bug"}]}`), - want: "kind must be case", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - factory, stdout, _ := newExecuteFactory(t) - err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", tt.body}, factory, stdout) - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("err=%v want substring %q", err, tt.want) - } - var validationErr *errs.ValidationError - if !errors.As(err, &validationErr) { - t.Fatalf("err type=%T want *errs.ValidationError", err) - } - }) - } -} - -func TestBaseWorkflowExecuteUpdateValidatesAIClassificationBranch(t *testing.T) { - factory, stdout, _ := newExecuteFactory(t) - body := `{ - "title": "Feedback classify", - "steps": [ - {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, - { - "id": "step_classify", - "type": "AIClassificationBranch", - "children": {"links":[{"kind":"case","to":"step_bug"},{"kind":"case","to":"step_feature"}]}, - "data": { - "mode": "NotAMode", - "prompt": [{"value_type": "text", "value": "Classify"}], - "childBranchList": [ - {"name": "Bug", "entryChildStepId": "step_bug"}, - {"name": "Feature", "entryChildStepId": "step_feature"} - ] - } - }, - {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, - {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} - ] - }` - err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout) - if err == nil || !strings.Contains(err.Error(), "data.mode must be Exclusive or Parallel") { - t.Fatalf("err=%v", err) - } -} - func TestBaseWorkflowExecuteDisable(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index e725f6f303..d0b2c1caeb 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -30,7 +30,6 @@ var BaseWorkflowUpdate = common.Shortcut{ "Step ids must be unique, and every next/children link must reference an existing step id.", "Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "For AIClassificationBranch, preserve the full object returned by +workflow-get and verify prompt, branches, default branch, and children.links after update.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { @@ -40,11 +39,7 @@ var BaseWorkflowUpdate = common.Shortcut{ return baseFlagErrorf("--workflow-id must not be blank") } pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") - if err != nil { - return err - } - if err := validateWorkflowAIClassificationBranches(body); err != nil { + if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil { return err } return nil diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 79f7c2c8a0..2fcb84af84 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -149,7 +149,7 @@ BaseApp、Workspace、Page 或组件任务开始前完整读取 [应用模式与 Workflow 本身是 Base Block,其内部是一张由 `next` / `children` 连接的 steps 执行图;触发器、动作、条件分支和循环都是 step 类型。它适合定时执行、Record 新增或变更联动、消息通知、记录读写和跨系统调用。Workflow 分为三条操作路径: 1. **读取配置:** `+workflow-list` 定位流程,`+workflow-get` 读取 `title`、`status` 和完整 `steps` 执行图。 -2. **写入配置:** `+workflow-create` 创建完整定义,`+workflow-update` 更新完整定义;构造或修改配置前读取 [Workflow](references/lark-base-workflow.md),由该入口继续路由 step 类型和 schema。涉及 AI 分类时优先读取 schema 的 `AIClassificationBranch` 小节。 +2. **写入配置:** `+workflow-create` 创建完整定义,`+workflow-update` 更新完整定义;构造或修改配置前读取 [Workflow](references/lark-base-workflow.md),由该入口继续路由 step 类型和 schema。 3. **运行状态控制:** `+workflow-enable` / `+workflow-disable` 启用或停用已有 Workflow,不修改 steps 执行图。 ## Advanced Permission(AdvPerm) diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 83e5548166..1e38a954ae 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -555,7 +555,7 @@ ### AIClassificationBranch -`AIClassificationBranch` 用 AI 对 `prompt` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`+workflow-create` / `+workflow-update` 只提交完整 workflow JSON;提交后用 `+workflow-get` 回读确认服务端保存的最终字段和枚举。 +`AIClassificationBranch` 用 AI 对 `prompt` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`+workflow-create` / `+workflow-update` 会把 workflow JSON 作为对象提交给 Base v3 API,不在 CLI 内复制服务端节点校验;提交后用 `+workflow-get` 回读确认服务端保存的最终字段、枚举和兼容格式。 ```json { @@ -588,18 +588,18 @@ | 字段 | 必填 | 说明 | |------|------|------| -| `mode` | 否 | 执行逻辑。`Exclusive`:最为匹配,只执行一个分类分支;`Parallel`:所有匹配,执行全部命中分支。省略时以服务端默认值为准 | -| `prompt` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` 混排;`ref` 只能引用前序 step | -| `childBranchList` / `child_branch_list` | 是 | 分类列表,至少 2 个。分类名可用 string 或纯文本 TextRefItem[],同一节点内不可为空、重复、包含换行或使用保留名称 `其他` | -| `defaultBranchInfo` / `default_branch_info` | 否 | 无匹配策略。`mode: "Execute"` 时可通过 `entryStepId` / `entry_step_id` 指向其他分支入口;`mode: "Fail"` 时当前节点失败 | -| `no_match_action` / `noMatchAction` | 否 | 服务端回读可能使用的无匹配策略字段,常见值为 `classifyToOther` / `fail` | -| `classifyPrompt` / `classify_prompt` | 否 | 全局分类规则,TextRefItem[],只能使用 `text` 段,不支持变量引用 | +| `mode` | 否 | 执行逻辑,常见语义为 `Exclusive`(最为匹配,只执行一个分类分支)或 `Parallel`(所有匹配,执行全部命中分支)。可用枚举和默认值以服务端返回为准 | +| `prompt` | 是 | TextRefItem[],用于分类的内容,通常支持 `text` / `ref` 混排;引用路径是否有效由服务端工作流上下文裁决 | +| `childBranchList` / `child_branch_list` | 是 | 分类列表。常见字段包含 `name`、`description`、`entryChildStepId`;分类数量、名称格式和兼容字段以服务端能力为准 | +| `defaultBranchInfo` / `default_branch_info` | 否 | 无匹配策略。常见结构包含 `mode` 和 `entryStepId` / `entry_step_id`,例如进入其他分支或当前节点失败 | +| `no_match_action` / `noMatchAction` | 否 | 服务端回读可能使用的无匹配策略字段;不要在 CLI 侧自行改写为另一套枚举 | +| `classifyPrompt` / `classify_prompt` | 否 | 全局分类规则,通常为 TextRefItem[];是否支持变量引用和长度限制以服务端契约为准 | `children.links` 规则: - 每条边使用 `kind: "case"`,`to` 指向该分类或默认分支的入口 step。 - `label` 建议使用稳定中性值,如 `branch_1`、`branch_2`、`other`;分类语义写在 `desc` 和 `childBranchList[].name`。 -- `entryChildStepId` / `entryStepId` 与 `children.links[].to` 都必须引用同一 workflow 内存在的 step。多个分类不要指向同一个入口 step,避免图结构冲突。 -- 并行模式和 AI 分类能力是否可用由服务端/租户能力裁决;CLI 不会把 `Parallel` 静默降级为 `Exclusive`。 +- `entryChildStepId` / `entryStepId` 与 `children.links[].to` 的落盘关系以 `+workflow-get` 回读为准;创建或更新后不要只依赖本地 JSON 判断成功。 +- AI 分类、并行模式、默认分支和具体枚举是否可用由服务端/租户能力裁决。CLI 只展示服务端返回的成功结果或错误,不提前拒绝服务端未来可能接受的字段、枚举或兼容格式。 ## System data 详细结构 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 5f2d0d45e7..8d3038cd23 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -604,9 +604,9 @@ 关键点: - `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类和其他分支都通过 `to` 指向后续 step。 -- `prompt` 支持 `text` / `ref` 混排,`classifyPrompt` 只写纯文本规则。 -- `mode: "Parallel"` 表示所有匹配;是否支持并行模式和默认分支由服务端能力决定,CLI 不会静默降级。 -- AI 分类可能处理业务敏感信息,默认错误提示只定位字段路径,不应输出完整待分类内容。 +- `prompt` 通常使用 `text` / `ref` 混排;`classifyPrompt`、`mode`、`defaultBranchInfo` 和字段别名以服务端保存和 `+workflow-get` 回读为准。 +- `mode: "Parallel"` 表示所有匹配;是否支持并行模式、默认分支和具体枚举由服务端能力决定。 +- AI 分类可能处理业务敏感信息;创建或更新后用 `+workflow-get` 确认最终保存结果,不要只依赖提交前 JSON。 --- From a73fdef23db57f5587ca164b4f9402a77fc39087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Fri, 21 Aug 2026 19:25:02 +0800 Subject: [PATCH 05/33] feat(base): validate AI classification agent data Co-authored-by: TRAE CLI --- shortcuts/base/base_shortcuts_test.go | 4 + .../workflow_ai_classification_validate.go | 237 ++++++++++++++++++ shortcuts/base/workflow_create.go | 8 +- shortcuts/base/workflow_execute_test.go | 176 +++++++++++++ shortcuts/base/workflow_update.go | 8 +- .../references/lark-base-workflow-schema.md | 55 ++-- .../references/lark-base-workflow.md | 48 ++-- 7 files changed, 477 insertions(+), 59 deletions(-) create mode 100644 shortcuts/base/workflow_ai_classification_validate.go diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 5ff21275bd..17960211e5 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -879,6 +879,8 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "lark-base-workflow.md as the module entry", "lark-base-workflow-schema.md as the steps JSON SSOT", "do not invent steps[].type/data/next/children from natural language", + "mode/classes/content/classification_rule/no_match_action", + "cannot be saved as an empty shell", }, }, { @@ -892,6 +894,8 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "workflow-id must start with wkf", "Updating does not enable or disable", "do not invent steps[].type/data/next/children from natural language", + "same public Agent Data shape returned by +workflow-get", + "label default", }, }, { diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go new file mode 100644 index 0000000000..46531a3119 --- /dev/null +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "fmt" + "strings" +) + +const workflowAIClassificationStepType = "AIClassificationBranch" + +func validateWorkflowAIClassificationBranches(body map[string]interface{}) error { + steps, ok := body["steps"].([]interface{}) + if !ok { + return nil + } + + stepIDs := make(map[string]int, len(steps)) + for i, raw := range steps { + step, _ := raw.(map[string]interface{}) + id, _ := step["id"].(string) + if strings.TrimSpace(id) != "" { + stepIDs[id] = i + } + } + + for i, raw := range steps { + step, _ := raw.(map[string]interface{}) + stepType, _ := step["type"].(string) + if stepType != workflowAIClassificationStepType { + continue + } + if err := validateWorkflowAIClassificationStep(i, step, stepIDs); err != nil { + return err + } + } + return nil +} + +func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int) error { + path := fmt.Sprintf("--json steps[%d]", index) + data, ok := step["data"].(map[string]interface{}) + if !ok || data == nil { + return baseValidationErrorf("%s.data must be an object for AIClassificationBranch", path) + } + classes, err := validateAIClassificationAgentData(path, data, index, stepIDs) + if err != nil { + return err + } + return validateAIClassificationLinks(path, step, stepIDs, classes, aiClassificationNoMatchAction(data)) +} + +func validateAIClassificationAgentData(path string, data map[string]interface{}, stepIndex int, stepIDs map[string]int) ([]string, error) { + mode, ok := data["mode"].(string) + if !ok || strings.TrimSpace(mode) == "" { + return nil, baseValidationErrorf("%s.data.mode is required and must be Exclusive or Parallel", path) + } + if mode != "Exclusive" && mode != "Parallel" { + return nil, baseValidationErrorf("%s.data.mode must be Exclusive or Parallel", path) + } + + rawClasses, ok := data["classes"].([]interface{}) + if !ok { + return nil, baseValidationErrorf("%s.data.classes must be an array", path) + } + if len(rawClasses) < 2 { + return nil, baseValidationErrorf("%s.data.classes must contain at least 2 items", path) + } + classes := make([]string, 0, len(rawClasses)) + seen := map[string]int{} + for i, raw := range rawClasses { + classPath := fmt.Sprintf("%s.data.classes[%d]", path, i) + item, ok := raw.(map[string]interface{}) + if !ok { + return nil, baseValidationErrorf("%s must be an object", classPath) + } + name, _ := item["name"].(string) + name = strings.TrimSpace(name) + if name == "" { + return nil, baseValidationErrorf("%s.name must be a non-empty string", classPath) + } + if strings.ContainsAny(name, "\r\n") { + return nil, baseValidationErrorf("%s.name must not contain newlines", classPath) + } + if prev, exists := seen[name]; exists { + return nil, baseValidationErrorf("%s.name duplicates %s.data.classes[%d].name", classPath, path, prev) + } + if _, ok := item["desc"].(string); !ok { + return nil, baseValidationErrorf("%s.desc must be a string", classPath) + } + seen[name] = i + classes = append(classes, name) + } + + if err := validateAIClassificationContent(path, data["content"], stepIndex, stepIDs); err != nil { + return nil, err + } + if rule, ok := data["classification_rule"]; ok { + if _, ok := rule.(string); !ok { + return nil, baseValidationErrorf("%s.data.classification_rule must be a string when set", path) + } + } + action, ok := data["no_match_action"].(string) + if !ok || strings.TrimSpace(action) == "" { + return nil, baseValidationErrorf("%s.data.no_match_action is required and must be classifyToOther or fail", path) + } + if action != "classifyToOther" && action != "fail" { + return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail", path) + } + return classes, nil +} + +func validateAIClassificationContent(path string, raw interface{}, stepIndex int, stepIDs map[string]int) error { + items, ok := raw.([]interface{}) + if !ok { + return baseValidationErrorf("%s.data.content must be an array", path) + } + hasContent := false + for i, rawItem := range items { + itemPath := fmt.Sprintf("%s.data.content[%d]", path, i) + item, ok := rawItem.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s must be an object", itemPath) + } + valueType, _ := item["value_type"].(string) + value, _ := item["value"].(string) + switch valueType { + case "text": + if strings.TrimSpace(value) != "" { + hasContent = true + } + case "ref": + refStep, ok := workflowRefStepID(value) + if !ok { + return baseValidationErrorf("%s.value must be a workflow ref path starting with $.", itemPath) + } + refIndex, exists := stepIDs[refStep] + if !exists { + return baseValidationErrorf("%s.value references unknown step id %q", itemPath, refStep) + } + if refIndex >= stepIndex { + return baseValidationErrorf("%s.value must reference a previous step, got %q", itemPath, refStep) + } + hasContent = true + default: + return baseValidationErrorf("%s.value_type must be text or ref", itemPath) + } + } + if !hasContent { + return baseValidationErrorf("%s.data.content must contain non-empty text or ref", path) + } + return nil +} + +func validateAIClassificationLinks(path string, step map[string]interface{}, stepIDs map[string]int, classes []string, noMatchAction string) error { + children, _ := step["children"].(map[string]interface{}) + links, _ := children["links"].([]interface{}) + if len(links) == 0 { + return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path) + } + + ordinaryLinks := 0 + defaultLinks := 0 + seenTargets := map[string]int{} + for i, raw := range links { + linkPath := fmt.Sprintf("%s.children.links[%d]", path, i) + link, ok := raw.(map[string]interface{}) + if !ok { + return baseValidationErrorf("%s must be an object", linkPath) + } + kind, _ := link["kind"].(string) + if kind != "case" { + return baseValidationErrorf("%s.kind must be case for AIClassificationBranch", linkPath) + } + label, _ := link["label"].(string) + if label == "other" { + return baseValidationErrorf("%s.label must be default for AIClassificationBranch default branch, not other", linkPath) + } + to, _ := link["to"].(string) + to = strings.TrimSpace(to) + if to == "" { + return baseValidationErrorf("%s.to must not be blank", linkPath) + } + if _, ok := stepIDs[to]; !ok { + return baseValidationErrorf("%s.to references unknown step id %q", linkPath, to) + } + if prev, exists := seenTargets[to]; exists { + return baseValidationErrorf("%s.to duplicates %s.children.links[%d].to", linkPath, path, prev) + } + seenTargets[to] = i + + if label == "default" { + defaultLinks++ + continue + } + wantLabel := fmt.Sprintf("branch_%d", ordinaryLinks+1) + if label != wantLabel { + return baseValidationErrorf("%s.label must be %s for AIClassificationBranch class link", linkPath, wantLabel) + } + desc, _ := link["desc"].(string) + if ordinaryLinks < len(classes) && desc != classes[ordinaryLinks] { + return baseValidationErrorf("%s.desc must equal --json steps data.classes[%d].name", linkPath, ordinaryLinks) + } + ordinaryLinks++ + } + if ordinaryLinks != len(classes) { + return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path) + } + if noMatchAction == "classifyToOther" && defaultLinks != 1 { + return baseValidationErrorf("%s.children.links must contain exactly one default link when no_match_action is classifyToOther", path) + } + if noMatchAction == "fail" && defaultLinks != 0 { + return baseValidationErrorf("%s.children.links must not contain a default link when no_match_action is fail", path) + } + return nil +} + +func aiClassificationNoMatchAction(data map[string]interface{}) string { + action, _ := data["no_match_action"].(string) + return action +} + +func workflowRefStepID(value string) (string, bool) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, "$.") { + return "", false + } + value = strings.TrimPrefix(value, "$.") + if value == "" { + return "", false + } + if idx := strings.Index(value, "."); idx >= 0 { + value = value[:idx] + } + return value, value != "" +} diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 9b6084a901..288f8862e1 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -28,6 +28,8 @@ var BaseWorkflowCreate = common.Shortcut{ "Before constructing steps, use +table-list and +field-list to confirm real table and field names.", "Step ids must be unique, and every next/children link must reference an existing step id.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", + "AIClassificationBranch data uses the public Agent Data shape: mode/classes/content/classification_rule/no_match_action.", + "AIClassificationBranch cannot be saved as an empty shell; create downstream actions first and connect branch_1/branch_2/default through children.links.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { @@ -38,7 +40,11 @@ var BaseWorkflowCreate = common.Shortcut{ if err != nil { return err } - if _, err := parseJSONObject(pc, raw, "json"); err != nil { + body, err := parseJSONObject(pc, raw, "json") + if err != nil { + return err + } + if err := validateWorkflowAIClassificationBranches(body); err != nil { return err } return nil diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index ad7b0866c4..ccf12f68a7 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -107,6 +107,58 @@ func TestBaseWorkflowExecuteCreate(t *testing.T) { } } +func TestBaseWorkflowExecuteCreatePreservesAIClassificationAgentData(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"}, + }, + } + reg.Register(stub) + + body := `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {"table_name": "Feedback"}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links": [ + {"kind": "case", "label": "branch_1", "desc": "Bug", "to": "step_bug"}, + {"kind": "case", "label": "branch_2", "desc": "Feature", "to": "step_feature"}, + {"kind": "case", "label": "default", "desc": "默认分支", "to": "step_other"} + ]}, + "data": { + "mode": "Parallel", + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [ + {"value_type": "text", "value": "Classify feedback: "}, + {"value_type": "ref", "value": "$.step_trigger.fldFeedback"} + ], + "classification_rule": "Use Other when unsure.", + "no_match_action": "classifyToOther", + "future_server_field": {"keep": true} + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", body}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := string(stub.CapturedBody); !strings.Contains(got, `"type":"AIClassificationBranch"`) || !strings.Contains(got, `"future_server_field":{"keep":true}`) { + t.Fatalf("AI classification payload was not forwarded verbatim enough: %s", got) + } +} + func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { t.Run("missing base-token", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) @@ -124,6 +176,130 @@ func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { }) } +func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) { + base := func(data string, children string) string { + return `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + {"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `}, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + } + validChildren := `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"}]}` + validData := `{ + "mode": "Exclusive", + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category.", + "no_match_action": "fail" + }` + + tests := []struct { + name string + body string + want string + }{ + { + name: "draft data is not public protocol", + body: base(`{"mode":"Exclusive","prompt":[{"value_type":"text","value":"Classify"}],"childBranchList":[{"name":"Bug"},{"name":"Feature"}],"no_match_action":"fail"}`, validChildren), + want: "data.classes must be an array", + }, + { + name: "missing mode", + body: base(strings.Replace(validData, `"mode": "Exclusive",`, ``, 1), validChildren), + want: "data.mode is required", + }, + { + name: "empty links", + body: base(validData, `{"links":[]}`), + want: "children.links must contain one non-empty case link for each class", + }, + { + name: "other default label", + body: base(strings.Replace(validData, `"no_match_action": "fail"`, `"no_match_action": "classifyToOther"`, 1), `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},{"kind":"case","label":"other","desc":"其他","to":"step_other"}]}`), + want: "label must be default", + }, + { + name: "class link count mismatch", + body: base(validData, `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}]}`), + want: "children.links must contain one non-empty case link for each class", + }, + { + name: "class link desc mismatch", + body: base(validData, `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Mismatch","to":"step_feature"}]}`), + want: "desc must equal --json steps data.classes[1].name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", tt.body}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want substring %q", err, tt.want) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("err type=%T want *errs.ValidationError", err) + } + }) + } +} + +func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Feedback classify"}, + }, + } + reg.Register(stub) + + body := `{ + "title": "Feedback classify", + "status": "disabled", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":[ + {"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}, + {"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"} + ]}, + "data": { + "mode": "Parallel", + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category.", + "no_match_action": "fail" + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` + if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := string(stub.CapturedBody); !strings.Contains(got, `"mode":"Parallel"`) || !strings.Contains(got, `"classes":[`) { + t.Fatalf("AI classification get shape was not forwarded: %s", got) + } +} + func TestBaseWorkflowExecuteDisable(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index d0b2c1caeb..1686254af1 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -30,6 +30,8 @@ var BaseWorkflowUpdate = common.Shortcut{ "Step ids must be unique, and every next/children link must reference an existing step id.", "Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", + "AIClassificationBranch update accepts the same public Agent Data shape returned by +workflow-get; keep mode/classes/content/classification_rule/no_match_action.", + "AIClassificationBranch default branch must use children.links label default; label other is treated as invalid in CLI input.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { @@ -39,7 +41,11 @@ var BaseWorkflowUpdate = common.Shortcut{ return baseFlagErrorf("--workflow-id must not be blank") } pc := newParseCtx(runtime) - if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil { + body, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return err + } + if err := validateWorkflowAIClassificationBranches(body); err != nil { return err } return nil diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 1e38a954ae..5435119212 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -555,51 +555,48 @@ ### AIClassificationBranch -`AIClassificationBranch` 用 AI 对 `prompt` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`+workflow-create` / `+workflow-update` 会把 workflow JSON 作为对象提交给 Base v3 API,不在 CLI 内复制服务端节点校验;提交后用 `+workflow-get` 回读确认服务端保存的最终字段、枚举和兼容格式。 +`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议,不提交内部 Draft Data 字段;提交后用 `+workflow-get` 回读确认服务端保存的最终字段和枚举。 ```json { "mode": "Exclusive", - "prompt": [ - { "value_type": "text", "value": "请根据反馈内容判断类型:" }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedback" } - ], - "childBranchList": [ + "classes": [ { - "name": [{ "value_type": "text", "value": "Bug" }], - "description": [{ "value_type": "text", "value": "功能报错、异常、不可用或结果错误" }], - "entryChildStepId": "step_bug_action" + "name": "Bug", + "desc": "功能报错、异常、不可用或结果错误" }, { - "name": [{ "value_type": "text", "value": "功能建议" }], - "description": [{ "value_type": "text", "value": "希望新增能力或优化现有功能" }], - "entryChildStepId": "step_feature_action" + "name": "功能建议", + "desc": "希望新增能力或优化现有功能" } ], - "defaultBranchInfo": { - "mode": "Execute", - "entryStepId": "step_other_action" - }, - "classifyPrompt": [ - { "value_type": "text", "value": "信息不足时进入其他分类;有明确故障现象时优先归为 Bug。" } - ] + "content": [ + { "value_type": "text", "value": "请根据反馈内容判断类型:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedback" } + ], + "classification_rule": "信息不足时判定为无法匹配。", + "no_match_action": "fail" } ``` | 字段 | 必填 | 说明 | |------|------|------| -| `mode` | 否 | 执行逻辑,常见语义为 `Exclusive`(最为匹配,只执行一个分类分支)或 `Parallel`(所有匹配,执行全部命中分支)。可用枚举和默认值以服务端返回为准 | -| `prompt` | 是 | TextRefItem[],用于分类的内容,通常支持 `text` / `ref` 混排;引用路径是否有效由服务端工作流上下文裁决 | -| `childBranchList` / `child_branch_list` | 是 | 分类列表。常见字段包含 `name`、`description`、`entryChildStepId`;分类数量、名称格式和兼容字段以服务端能力为准 | -| `defaultBranchInfo` / `default_branch_info` | 否 | 无匹配策略。常见结构包含 `mode` 和 `entryStepId` / `entry_step_id`,例如进入其他分支或当前节点失败 | -| `no_match_action` / `noMatchAction` | 否 | 服务端回读可能使用的无匹配策略字段;不要在 CLI 侧自行改写为另一套枚举 | -| `classifyPrompt` / `classify_prompt` | 否 | 全局分类规则,通常为 TextRefItem[];是否支持变量引用和长度限制以服务端契约为准 | +| `mode` | 是 | 执行逻辑。`Exclusive`:最为匹配,只执行一个分类分支;`Parallel`:所有匹配,执行全部命中分支 | +| `classes` | 是 | 分类列表,至少 2 项。每项包含 `name` 和 `desc` | +| `classes[].name` | 是 | 分类名称,需与对应普通 `children.links[].desc` 保持一致 | +| `classes[].desc` | 是 | 分类描述,可为空字符串,但字段必须存在 | +| `content` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` | +| `classification_rule` | 否 | 全局分类规则纯文本 | +| `no_match_action` | 是 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败 | + +不要在公开 JSON 中提交内部 Draft Data 字段:`prompt`、`childBranchList` / `child_branch_list`、`defaultBranchInfo` / `default_branch_info`、`classifyPrompt` / `classify_prompt`。 `children.links` 规则: -- 每条边使用 `kind: "case"`,`to` 指向该分类或默认分支的入口 step。 -- `label` 建议使用稳定中性值,如 `branch_1`、`branch_2`、`other`;分类语义写在 `desc` 和 `childBranchList[].name`。 -- `entryChildStepId` / `entryStepId` 与 `children.links[].to` 的落盘关系以 `+workflow-get` 回读为准;创建或更新后不要只依赖本地 JSON 判断成功。 -- AI 分类、并行模式、默认分支和具体枚举是否可用由服务端/租户能力裁决。CLI 只展示服务端返回的成功结果或错误,不提前拒绝服务端未来可能接受的字段、枚举或兼容格式。 +- 分类和默认分支的拓扑只由 `children.links` 表达。 +- 普通分类边使用 `kind: "case"` 和 `label: "branch_1"`、`branch_2` 等稳定标签;`desc` 与 `classes[i].name` 保持一致;`to` 指向该分类的入口 step。 +- `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 +- `label: "other"` 不表示默认分支,不要使用。 +- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data、`mode` 和分支拓扑均已保存。 ## System data 详细结构 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 8d3038cd23..88a31a6c3b 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -499,42 +499,34 @@ { "kind": "case", "to": "step_bug_action", "label": "branch_1", "desc": "Bug" }, { "kind": "case", "to": "step_feature_action", "label": "branch_2", "desc": "功能建议" }, { "kind": "case", "to": "step_experience_action", "label": "branch_3", "desc": "体验问题" }, - { "kind": "case", "to": "step_other_action", "label": "other", "desc": "其他" } + { "kind": "case", "to": "step_other_action", "label": "default", "desc": "默认分支" } ] }, "next": null, "data": { "mode": "Exclusive", - "prompt": [ - { "value_type": "text", "value": "请根据反馈标题和反馈详情判断类型:" }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedbackTitle" }, - { "value_type": "text", "value": " " }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } - ], - "childBranchList": [ + "classes": [ { - "name": [{ "value_type": "text", "value": "Bug" }], - "description": [{ "value_type": "text", "value": "功能报错、异常、崩溃、无法使用或结果错误" }], - "entryChildStepId": "step_bug_action" + "name": "Bug", + "desc": "功能报错、异常、崩溃、无法使用或结果错误" }, { - "name": [{ "value_type": "text", "value": "功能建议" }], - "description": [{ "value_type": "text", "value": "希望新增能力或改变产品行为" }], - "entryChildStepId": "step_feature_action" + "name": "功能建议", + "desc": "希望新增能力或改变产品行为" }, { - "name": [{ "value_type": "text", "value": "体验问题" }], - "description": [{ "value_type": "text", "value": "流程繁琐、操作难懂、性能慢或界面体验不佳" }], - "entryChildStepId": "step_experience_action" + "name": "体验问题", + "desc": "流程繁琐、操作难懂、性能慢或界面体验不佳" } ], - "defaultBranchInfo": { - "mode": "Execute", - "entryStepId": "step_other_action" - }, - "classifyPrompt": [ - { "value_type": "text", "value": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入其他分类。" } - ] + "content": [ + { "value_type": "text", "value": "请根据反馈标题和反馈详情判断类型:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackTitle" }, + { "value_type": "text", "value": " " }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } + ], + "classification_rule": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入默认分支。", + "no_match_action": "classifyToOther" } }, { @@ -600,12 +592,12 @@ 1. 创建:`lark-cli base +workflow-create --base-token --json @workflow.json` 2. 回读:`lark-cli base +workflow-get --base-token --workflow-id ` 3. 更新:先保存回读结果,只修改目标字段,再执行 `+workflow-update --json @workflow.json` -4. 再次回读:确认 `AIClassificationBranch` 的 `prompt`、分类列表、默认分支和 `children.links` 均未丢失 +4. 再次回读:确认 `AIClassificationBranch` 的 `mode`、`classes`、`content`、`classification_rule`、`no_match_action` 和 `children.links` 均未丢失 关键点: -- `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类和其他分支都通过 `to` 指向后续 step。 -- `prompt` 通常使用 `text` / `ref` 混排;`classifyPrompt`、`mode`、`defaultBranchInfo` 和字段别名以服务端保存和 `+workflow-get` 回读为准。 -- `mode: "Parallel"` 表示所有匹配;是否支持并行模式、默认分支和具体枚举由服务端能力决定。 +- `AIClassificationBranch.data` 使用公开 Agent Data:`mode`、`classes`、`content`、`classification_rule`、`no_match_action`。 +- `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类使用 `branch_1`、`branch_2` 等标签,默认分支使用 `label: "default"`。 +- `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;`no_match_action: "classifyToOther"` 时必须提供默认分支。 - AI 分类可能处理业务敏感信息;创建或更新后用 `+workflow-get` 确认最终保存结果,不要只依赖提交前 JSON。 --- From 75c1b7ae94c1371f274e3ecdc0d762bfdedce9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Tue, 25 Aug 2026 22:46:17 +0800 Subject: [PATCH 06/33] fix(base): relax ai classification optional fields Co-authored-by: TRAE CLI --- shortcuts/base/base_shortcuts_test.go | 3 +- .../workflow_ai_classification_validate.go | 30 +++++----- shortcuts/base/workflow_create.go | 2 +- shortcuts/base/workflow_execute_test.go | 55 ++++++++++++++++--- shortcuts/base/workflow_update.go | 2 +- .../references/lark-base-workflow-schema.md | 12 ++-- .../references/lark-base-workflow.md | 10 ++-- 7 files changed, 77 insertions(+), 37 deletions(-) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 17960211e5..bc5f31b713 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -879,7 +879,8 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "lark-base-workflow.md as the module entry", "lark-base-workflow-schema.md as the steps JSON SSOT", "do not invent steps[].type/data/next/children from natural language", - "mode/classes/content/classification_rule/no_match_action", + "classes/content/classification_rule/no_match_action", + "omit mode", "cannot be saved as an empty shell", }, }, diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go index 46531a3119..023dc3070a 100644 --- a/shortcuts/base/workflow_ai_classification_validate.go +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -52,12 +52,14 @@ func validateWorkflowAIClassificationStep(index int, step map[string]interface{} } func validateAIClassificationAgentData(path string, data map[string]interface{}, stepIndex int, stepIDs map[string]int) ([]string, error) { - mode, ok := data["mode"].(string) - if !ok || strings.TrimSpace(mode) == "" { - return nil, baseValidationErrorf("%s.data.mode is required and must be Exclusive or Parallel", path) - } - if mode != "Exclusive" && mode != "Parallel" { - return nil, baseValidationErrorf("%s.data.mode must be Exclusive or Parallel", path) + if modeRaw, ok := data["mode"]; ok { + mode, ok := modeRaw.(string) + if !ok || strings.TrimSpace(mode) == "" { + return nil, baseValidationErrorf("%s.data.mode must be Exclusive or Parallel when set", path) + } + if mode != "Exclusive" && mode != "Parallel" { + return nil, baseValidationErrorf("%s.data.mode must be Exclusive or Parallel when set", path) + } } rawClasses, ok := data["classes"].([]interface{}) @@ -101,12 +103,14 @@ func validateAIClassificationAgentData(path string, data map[string]interface{}, return nil, baseValidationErrorf("%s.data.classification_rule must be a string when set", path) } } - action, ok := data["no_match_action"].(string) - if !ok || strings.TrimSpace(action) == "" { - return nil, baseValidationErrorf("%s.data.no_match_action is required and must be classifyToOther or fail", path) - } - if action != "classifyToOther" && action != "fail" { - return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail", path) + if actionRaw, ok := data["no_match_action"]; ok { + action, ok := actionRaw.(string) + if !ok || strings.TrimSpace(action) == "" { + return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail when set", path) + } + if action != "classifyToOther" && action != "fail" { + return nil, baseValidationErrorf("%s.data.no_match_action must be classifyToOther or fail when set", path) + } } return classes, nil } @@ -218,7 +222,7 @@ func validateAIClassificationLinks(path string, step map[string]interface{}, ste func aiClassificationNoMatchAction(data map[string]interface{}) string { action, _ := data["no_match_action"].(string) - return action + return strings.TrimSpace(action) } func workflowRefStepID(value string) (string, bool) { diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 288f8862e1..353dc65290 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -28,7 +28,7 @@ var BaseWorkflowCreate = common.Shortcut{ "Before constructing steps, use +table-list and +field-list to confirm real table and field names.", "Step ids must be unique, and every next/children link must reference an existing step id.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "AIClassificationBranch data uses the public Agent Data shape: mode/classes/content/classification_rule/no_match_action.", + "AIClassificationBranch data uses the public Agent Data shape: classes/content/classification_rule/no_match_action; omit mode and let the service default it.", "AIClassificationBranch cannot be saved as an empty shell; create downstream actions first and connect branch_1/branch_2/default through children.links.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index ccf12f68a7..56e7c0c78a 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -132,7 +132,6 @@ func TestBaseWorkflowExecuteCreatePreservesAIClassificationAgentData(t *testing. {"kind": "case", "label": "default", "desc": "默认分支", "to": "step_other"} ]}, "data": { - "mode": "Parallel", "classes": [ {"name": "Bug", "desc": "Broken behavior"}, {"name": "Feature", "desc": "New capability"} @@ -191,7 +190,6 @@ func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) { } validChildren := `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"}]}` validData := `{ - "mode": "Exclusive", "classes": [ {"name": "Bug", "desc": "Broken behavior"}, {"name": "Feature", "desc": "New capability"} @@ -212,9 +210,9 @@ func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) { want: "data.classes must be an array", }, { - name: "missing mode", - body: base(strings.Replace(validData, `"mode": "Exclusive",`, ``, 1), validChildren), - want: "data.mode is required", + name: "invalid mode", + body: base(strings.Replace(validData, `"classes": [`, `"mode": "invalid", "classes": [`, 1), validChildren), + want: "data.mode must be Exclusive or Parallel when set", }, { name: "empty links", @@ -253,6 +251,48 @@ func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) { } } +func TestBaseWorkflowExecuteValidateAIClassificationOptionalModeAndNoMatchAction(t *testing.T) { + base := func(data string, children string) string { + return `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + {"id": "step_classify", "type": "AIClassificationBranch", "children": ` + children + `, "data": ` + data + `}, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} + ] + }` + } + data := `{ + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category." + }` + children := `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},{"kind":"case","label":"default","desc":"默认分支","to":"step_other"}]}` + + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_ai", "title": "Feedback classify"}, + }, + } + reg.Register(stub) + if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", base(data, children)}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := string(stub.CapturedBody) + if strings.Contains(got, `"mode"`) || strings.Contains(got, `"no_match_action"`) { + t.Fatalf("AI classification optional fields should not be injected by CLI: %s", got) + } +} + func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) stub := &httpmock.Stub{ @@ -284,8 +324,7 @@ func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) {"name": "Feature", "desc": "New capability"} ], "content": [{"value_type": "text", "value": "Classify"}], - "classification_rule": "Use the closest category.", - "no_match_action": "fail" + "classification_rule": "Use the closest category." } }, {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, @@ -295,7 +334,7 @@ func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := string(stub.CapturedBody); !strings.Contains(got, `"mode":"Parallel"`) || !strings.Contains(got, `"classes":[`) { + if got := string(stub.CapturedBody); !strings.Contains(got, `"mode":"Parallel"`) || strings.Contains(got, `"no_match_action"`) || !strings.Contains(got, `"classes":[`) { t.Fatalf("AI classification get shape was not forwarded: %s", got) } } diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index 1686254af1..7cb4bbdf17 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -30,7 +30,7 @@ var BaseWorkflowUpdate = common.Shortcut{ "Step ids must be unique, and every next/children link must reference an existing step id.", "Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "AIClassificationBranch update accepts the same public Agent Data shape returned by +workflow-get; keep mode/classes/content/classification_rule/no_match_action.", + "AIClassificationBranch update accepts the same public Agent Data shape returned by +workflow-get; keep classes/content/classification_rule/no_match_action and preserve service-returned fields.", "AIClassificationBranch default branch must use children.links label default; label other is treated as invalid in CLI input.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 5435119212..37a5d3d35b 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -559,7 +559,6 @@ ```json { - "mode": "Exclusive", "classes": [ { "name": "Bug", @@ -574,29 +573,28 @@ { "value_type": "text", "value": "请根据反馈内容判断类型:" }, { "value_type": "ref", "value": "$.step_trigger.fldFeedback" } ], - "classification_rule": "信息不足时判定为无法匹配。", - "no_match_action": "fail" + "classification_rule": "信息不足时判定为无法匹配。" } ``` | 字段 | 必填 | 说明 | |------|------|------| -| `mode` | 是 | 执行逻辑。`Exclusive`:最为匹配,只执行一个分类分支;`Parallel`:所有匹配,执行全部命中分支 | +| `mode` | 否 | 服务端回读字段;创建时可省略,默认由服务端/SDK 设置。CLI 不在本地拒绝 `Parallel` 或缺失值 | | `classes` | 是 | 分类列表,至少 2 项。每项包含 `name` 和 `desc` | | `classes[].name` | 是 | 分类名称,需与对应普通 `children.links[].desc` 保持一致 | | `classes[].desc` | 是 | 分类描述,可为空字符串,但字段必须存在 | | `content` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` | | `classification_rule` | 否 | 全局分类规则纯文本 | -| `no_match_action` | 是 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败 | +| `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。创建缺省时使用服务端/SDK 默认 `classifyToOther`,更新缺省时保留既有配置 | 不要在公开 JSON 中提交内部 Draft Data 字段:`prompt`、`childBranchList` / `child_branch_list`、`defaultBranchInfo` / `default_branch_info`、`classifyPrompt` / `classify_prompt`。 `children.links` 规则: - 分类和默认分支的拓扑只由 `children.links` 表达。 - 普通分类边使用 `kind: "case"` 和 `label: "branch_1"`、`branch_2` 等稳定标签;`desc` 与 `classes[i].name` 保持一致;`to` 指向该分类的入口 step。 -- `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 +- 缺省或 `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 - `label: "other"` 不表示默认分支,不要使用。 -- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data、`mode` 和分支拓扑均已保存。 +- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data、服务端返回的 `mode` 和分支拓扑均已保存;更新时保留回读中的未修改字段。 ## System data 详细结构 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 88a31a6c3b..15146b204d 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -504,7 +504,6 @@ }, "next": null, "data": { - "mode": "Exclusive", "classes": [ { "name": "Bug", @@ -525,8 +524,7 @@ { "value_type": "text", "value": " " }, { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } ], - "classification_rule": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入默认分支。", - "no_match_action": "classifyToOther" + "classification_rule": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入默认分支。" } }, { @@ -592,12 +590,12 @@ 1. 创建:`lark-cli base +workflow-create --base-token --json @workflow.json` 2. 回读:`lark-cli base +workflow-get --base-token --workflow-id ` 3. 更新:先保存回读结果,只修改目标字段,再执行 `+workflow-update --json @workflow.json` -4. 再次回读:确认 `AIClassificationBranch` 的 `mode`、`classes`、`content`、`classification_rule`、`no_match_action` 和 `children.links` 均未丢失 +4. 再次回读:确认 `AIClassificationBranch` 的 `classes`、`content`、`classification_rule`、`children.links` 以及服务端返回的 `mode` / `no_match_action` 均未丢失 关键点: -- `AIClassificationBranch.data` 使用公开 Agent Data:`mode`、`classes`、`content`、`classification_rule`、`no_match_action`。 +- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`;创建时不需要提交 `mode`。 - `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类使用 `branch_1`、`branch_2` 等标签,默认分支使用 `label: "default"`。 -- `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;`no_match_action: "classifyToOther"` 时必须提供默认分支。 +- `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;缺省或 `no_match_action: "classifyToOther"` 时必须提供默认分支。 - AI 分类可能处理业务敏感信息;创建或更新后用 `+workflow-get` 确认最终保存结果,不要只依赖提交前 JSON。 --- From ec6262413ba65543b04af459b3ac669cf33ff849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Wed, 26 Aug 2026 19:27:11 +0800 Subject: [PATCH 07/33] fix(base): validate workflow ai analysis json Co-authored-by: TRAE CLI --- shortcuts/base/workflow_create.go | 19 +---- shortcuts/base/workflow_execute_test.go | 87 ++++++++++++++++++++++ shortcuts/base/workflow_json_validation.go | 77 +++++++++++++++++++ shortcuts/base/workflow_list.go | 2 +- shortcuts/base/workflow_update.go | 9 +-- 5 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 shortcuts/base/workflow_json_validation.go diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 9b6084a901..cd9de3bcb8 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -33,21 +33,15 @@ var BaseWorkflowCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("base-token")) == "" { return baseFlagErrorf("--base-token must not be blank") } - pc := newParseCtx(runtime) - raw, err := loadJSONInput(pc, runtime.Str("json"), "json") - if err != nil { - return err - } - if _, err := parseJSONObject(pc, raw, "json"); err != nil { + if _, err := parseWorkflowBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - pc := newParseCtx(runtime) var body map[string]interface{} - if raw, err := loadJSONInput(pc, runtime.Str("json"), "json"); err == nil { - body, _ = parseJSONObject(pc, raw, "json") + if parsed, err := parseWorkflowBodyJSON(runtime); err == nil { + body = parsed } return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/workflows"). @@ -55,12 +49,7 @@ var BaseWorkflowCreate = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - raw, err := loadJSONInput(pc, runtime.Str("json"), "json") - if err != nil { - return err - } - body, err := parseJSONObject(pc, raw, "json") + body, err := parseWorkflowBodyJSON(runtime) if err != nil { return err } diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index ebb82f5da7..0f7d469aa8 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -96,6 +96,93 @@ func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { t.Fatalf("expected error for invalid json") } }) + t.Run("rejects ai analysis table names string", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":"订单表","identity_type":"maker"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names") + }) + t.Run("rejects ai analysis table names item type", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表",1],"identity_type":"maker"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names[1]") + }) + t.Run("rejects ai analysis identity type enum", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"unknownIdentity"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maker, triggerPersonal") + if !strings.Contains(err.Error(), "steps[0].data.identity_type") { + t.Fatalf("err=%v, want field path", err) + } + }) + t.Run("accepts ai analysis maker", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_new"}, + }, + }) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"maker"}}]}`}, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + }) + t.Run("accepts ai analysis triggerPersonal", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_new"}, + }, + }) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"triggerPersonal"}}]}`}, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseWorkflowExecuteUpdateValidateAIAnalysisData(t *testing.T) { + t.Run("rejects table names string", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":"订单表","identity_type":"maker"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "steps[0].data.analysis_table_names") + }) + t.Run("rejects identity type enum", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"steps":[{"id":"step_ai","type":"AIAnalysisAction","data":{"analysis_table_names":["订单表"],"identity_type":"unknownIdentity"}}]}`}, factory, stdout) + assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maker, triggerPersonal") + if !strings.Contains(err.Error(), "steps[0].data.identity_type") { + t.Fatalf("err=%v, want field path", err) + } + }) +} + +func TestBaseWorkflowExecuteListReturnsEmptyItemsArray(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows/list", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": nil, "total": 0}, + }, + }) + if err := runShortcut(t, BaseWorkflowList, []string{"+workflow-list", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + items, ok := data["items"].([]interface{}) + if !ok { + t.Fatalf("items=%#v, want []", data["items"]) + } + if len(items) != 0 || data["total"] != float64(0) { + t.Fatalf("data=%#v, want empty items and total=0", data) + } } func TestBaseWorkflowExecuteDisable(t *testing.T) { diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go new file mode 100644 index 0000000000..482418b7b4 --- /dev/null +++ b/shortcuts/base/workflow_json_validation.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "fmt" + + "github.com/larksuite/cli/shortcuts/common" +) + +const aiAnalysisActionType = "AIAnalysisAction" + +var aiAnalysisIdentityTypes = map[string]bool{ + "maker": true, + "triggerPersonal": true, +} + +func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { + pc := newParseCtx(runtime) + body, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return nil, err + } + if err := validateWorkflowBodyForCLI(body); err != nil { + return nil, err + } + return body, nil +} + +func validateWorkflowBodyForCLI(body map[string]interface{}) error { + steps, ok := body["steps"].([]interface{}) + if !ok { + return nil + } + + for stepIndex, rawStep := range steps { + step, ok := rawStep.(map[string]interface{}) + if !ok { + continue + } + stepType, _ := step["type"].(string) + if stepType != aiAnalysisActionType { + continue + } + data, ok := step["data"].(map[string]interface{}) + if !ok { + continue + } + if value, exists := data["analysis_table_names"]; exists { + items, ok := value.([]interface{}) + if !ok { + return baseFlagErrorf("%s must be a string array", workflowJSONPath(stepIndex, "analysis_table_names")) + } + for itemIndex, item := range items { + if _, ok := item.(string); !ok { + return baseFlagErrorf("%s[%d] must be a string", workflowJSONPath(stepIndex, "analysis_table_names"), itemIndex) + } + } + } + if value, exists := data["identity_type"]; exists { + identityType, ok := value.(string) + if !ok { + return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) + } + if !aiAnalysisIdentityTypes[identityType] { + return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) + } + } + } + + return nil +} + +func workflowJSONPath(stepIndex int, suffix string) string { + return fmt.Sprintf("--json.steps[%d].data.%s", stepIndex, suffix) +} diff --git a/shortcuts/base/workflow_list.go b/shortcuts/base/workflow_list.go index d78661d9a8..a1149c8de3 100644 --- a/shortcuts/base/workflow_list.go +++ b/shortcuts/base/workflow_list.go @@ -46,7 +46,7 @@ var BaseWorkflowList = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - var allItems []interface{} + allItems := make([]interface{}, 0) pageToken := "" for { body := map[string]interface{}{ diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index d0b2c1caeb..f367f0720f 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -38,16 +38,14 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return baseFlagErrorf("--workflow-id must not be blank") } - pc := newParseCtx(runtime) - if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil { + if _, err := parseWorkflowBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - pc := newParseCtx(runtime) var body map[string]interface{} - body, _ = parseJSONObject(pc, runtime.Str("json"), "json") + body, _ = parseWorkflowBodyJSON(runtime) return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). @@ -55,8 +53,7 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + body, err := parseWorkflowBodyJSON(runtime) if err != nil { return err } From c8ed1fc3087ebe0811af2f0bb27533c33092ade3 Mon Sep 17 00:00:00 2001 From: bytedance-zhangbinkai Date: Wed, 26 Aug 2026 19:40:27 +0800 Subject: [PATCH 08/33] fix(base): validate workflow ai analysis json Co-authored-by: TRAE CLI --- shortcuts/base/workflow_execute_test.go | 25 ++++++++++++++++++++++ shortcuts/base/workflow_json_validation.go | 11 ++++++++++ shortcuts/base/workflow_update.go | 6 +++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index 0f7d469aa8..961ba433e6 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -4,6 +4,7 @@ package base import ( + "encoding/json" "strings" "testing" @@ -162,6 +163,30 @@ func TestBaseWorkflowExecuteUpdateValidateAIAnalysisData(t *testing.T) { }) } +func TestBaseWorkflowExecuteUpdateOmittedStepsClearsWorkflow(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Only Title", "steps": []interface{}{}}, + }, + } + reg.Register(stub) + if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"title":"Only Title"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body invalid JSON: %v", err) + } + steps, ok := body["steps"].([]interface{}) + if !ok || len(steps) != 0 { + t.Fatalf("request steps=%#v, want []", body["steps"]) + } +} + func TestBaseWorkflowExecuteListReturnsEmptyItemsArray(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go index 482418b7b4..b9bc5b1c51 100644 --- a/shortcuts/base/workflow_json_validation.go +++ b/shortcuts/base/workflow_json_validation.go @@ -28,6 +28,17 @@ func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface return body, nil } +func parseWorkflowUpdateBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body, err := parseWorkflowBodyJSON(runtime) + if err != nil { + return nil, err + } + if _, exists := body["steps"]; !exists { + body["steps"] = []interface{}{} + } + return body, nil +} + func validateWorkflowBodyForCLI(body map[string]interface{}) error { steps, ok := body["steps"].([]interface{}) if !ok { diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index f367f0720f..7fffa85e2f 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -38,14 +38,14 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return baseFlagErrorf("--workflow-id must not be blank") } - if _, err := parseWorkflowBodyJSON(runtime); err != nil { + if _, err := parseWorkflowUpdateBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - body, _ = parseWorkflowBodyJSON(runtime) + body, _ = parseWorkflowUpdateBodyJSON(runtime) return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). @@ -53,7 +53,7 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := parseWorkflowBodyJSON(runtime) + body, err := parseWorkflowUpdateBodyJSON(runtime) if err != nil { return err } From 5bedd45cf70e13a53ba4f41ec4655dea21aa1467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Wed, 26 Aug 2026 20:14:46 +0800 Subject: [PATCH 09/33] fix(base): default ai classification no match action Co-authored-by: TRAE CLI --- .../base/workflow_ai_classification_validate.go | 11 +++++++++-- shortcuts/base/workflow_execute_test.go | 12 ++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go index 023dc3070a..6e5652fb43 100644 --- a/shortcuts/base/workflow_ai_classification_validate.go +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -8,7 +8,10 @@ import ( "strings" ) -const workflowAIClassificationStepType = "AIClassificationBranch" +const ( + workflowAIClassificationStepType = "AIClassificationBranch" + workflowAIClassificationDefaultNoMatchAction = "classifyToOther" +) func validateWorkflowAIClassificationBranches(body map[string]interface{}) error { steps, ok := body["steps"].([]interface{}) @@ -222,7 +225,11 @@ func validateAIClassificationLinks(path string, step map[string]interface{}, ste func aiClassificationNoMatchAction(data map[string]interface{}) string { action, _ := data["no_match_action"].(string) - return strings.TrimSpace(action) + action = strings.TrimSpace(action) + if action == "" { + return workflowAIClassificationDefaultNoMatchAction + } + return action } func workflowRefStepID(value string) (string, bool) { diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index 56e7c0c78a..4ccf83e8b7 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -224,6 +224,12 @@ func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) { body: base(strings.Replace(validData, `"no_match_action": "fail"`, `"no_match_action": "classifyToOther"`, 1), `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"},{"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"},{"kind":"case","label":"other","desc":"其他","to":"step_other"}]}`), want: "label must be default", }, + { + name: "missing no match action still requires default link", + body: base(strings.Replace(validData, `, + "no_match_action": "fail"`, "", 1), validChildren), + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, { name: "class link count mismatch", body: base(validData, `{"links":[{"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}]}`), @@ -315,7 +321,8 @@ func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) "type": "AIClassificationBranch", "children": {"links":[ {"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}, - {"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"} + {"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"}, + {"kind":"case","label":"default","desc":"默认分支","to":"step_other"} ]}, "data": { "mode": "Parallel", @@ -328,7 +335,8 @@ func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) } }, {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, - {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "LarkMessageAction", "next": null, "data": {}} ] }` if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil { From 5904183594b8a1043bcc0e345807638826c8eb87 Mon Sep 17 00:00:00 2001 From: bytedance-zhangbinkai Date: Wed, 26 Aug 2026 22:03:40 +0800 Subject: [PATCH 10/33] fix(base): keep ai analysis validation scoped Co-authored-by: TRAE CLI From 8f3b4fded7056793e9ed93d9eeda45099ad509f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Thu, 27 Aug 2026 14:27:34 +0800 Subject: [PATCH 11/33] fix(base): normalize workflow empty steps Co-authored-by: TRAE CLI --- shortcuts/base/workflow_execute_test.go | 24 ++++++++++++++++++++++ shortcuts/base/workflow_json_validation.go | 11 ++++++++++ shortcuts/base/workflow_list.go | 2 +- shortcuts/base/workflow_update.go | 6 +++--- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index 10c76c0260..d2a4c51b7f 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -191,6 +191,30 @@ func TestBaseWorkflowExecuteUpdateValidateAIAnalysisData(t *testing.T) { }) } +func TestBaseWorkflowExecuteUpdateOmittedStepsClearsWorkflow(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Only Title", "steps": []interface{}{}}, + }, + } + reg.Register(stub) + if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", `{"title":"Only Title"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body invalid JSON: %v", err) + } + steps, ok := body["steps"].([]interface{}) + if !ok || len(steps) != 0 { + t.Fatalf("request steps=%#v, want []", body["steps"]) + } +} + func TestBaseWorkflowExecuteDisable(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go index 482418b7b4..3175e67039 100644 --- a/shortcuts/base/workflow_json_validation.go +++ b/shortcuts/base/workflow_json_validation.go @@ -28,6 +28,17 @@ func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface return body, nil } +func parseWorkflowUpdateBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body, err := parseWorkflowBodyJSON(runtime) + if err != nil { + return nil, err + } + if _, ok := body["steps"]; !ok { + body["steps"] = []interface{}{} + } + return body, nil +} + func validateWorkflowBodyForCLI(body map[string]interface{}) error { steps, ok := body["steps"].([]interface{}) if !ok { diff --git a/shortcuts/base/workflow_list.go b/shortcuts/base/workflow_list.go index d78661d9a8..a1149c8de3 100644 --- a/shortcuts/base/workflow_list.go +++ b/shortcuts/base/workflow_list.go @@ -46,7 +46,7 @@ var BaseWorkflowList = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - var allItems []interface{} + allItems := make([]interface{}, 0) pageToken := "" for { body := map[string]interface{}{ diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index f367f0720f..7fffa85e2f 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -38,14 +38,14 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return baseFlagErrorf("--workflow-id must not be blank") } - if _, err := parseWorkflowBodyJSON(runtime); err != nil { + if _, err := parseWorkflowUpdateBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - body, _ = parseWorkflowBodyJSON(runtime) + body, _ = parseWorkflowUpdateBodyJSON(runtime) return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). @@ -53,7 +53,7 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := parseWorkflowBodyJSON(runtime) + body, err := parseWorkflowUpdateBodyJSON(runtime) if err != nil { return err } From b137b9f102f851cdb33ef339357ada85d6c623c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=BB=A8=E5=87=AF?= Date: Fri, 28 Aug 2026 12:56:42 +0800 Subject: [PATCH 12/33] fix(base): reject ai classification mode input Co-authored-by: TRAE CLI --- shortcuts/base/base_shortcuts_test.go | 5 +- .../workflow_ai_classification_validate.go | 10 +-- shortcuts/base/workflow_create.go | 2 +- shortcuts/base/workflow_execute_test.go | 75 +++++++++++++++++-- shortcuts/base/workflow_update.go | 2 +- .../references/lark-base-workflow-schema.md | 7 +- .../references/lark-base-workflow.md | 4 +- 7 files changed, 80 insertions(+), 25 deletions(-) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index bc5f31b713..c6c6f76075 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -881,6 +881,7 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "do not invent steps[].type/data/next/children from natural language", "classes/content/classification_rule/no_match_action", "omit mode", + "only supports service-side Exclusive semantics", "cannot be saved as an empty shell", }, }, @@ -895,7 +896,9 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "workflow-id must start with wkf", "Updating does not enable or disable", "do not invent steps[].type/data/next/children from natural language", - "same public Agent Data shape returned by +workflow-get", + "classes/content/classification_rule/no_match_action", + "omit mode", + "only supports service-side Exclusive semantics", "label default", }, }, diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go index 6e5652fb43..ce920963f1 100644 --- a/shortcuts/base/workflow_ai_classification_validate.go +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -55,14 +55,8 @@ func validateWorkflowAIClassificationStep(index int, step map[string]interface{} } func validateAIClassificationAgentData(path string, data map[string]interface{}, stepIndex int, stepIDs map[string]int) ([]string, error) { - if modeRaw, ok := data["mode"]; ok { - mode, ok := modeRaw.(string) - if !ok || strings.TrimSpace(mode) == "" { - return nil, baseValidationErrorf("%s.data.mode must be Exclusive or Parallel when set", path) - } - if mode != "Exclusive" && mode != "Parallel" { - return nil, baseValidationErrorf("%s.data.mode must be Exclusive or Parallel when set", path) - } + if _, ok := data["mode"]; ok { + return nil, baseValidationErrorf("%s.data.mode is not supported; omit it because AI classification only supports Exclusive mode", path) } rawClasses, ok := data["classes"].([]interface{}) diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 353dc65290..b0f004e406 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -28,7 +28,7 @@ var BaseWorkflowCreate = common.Shortcut{ "Before constructing steps, use +table-list and +field-list to confirm real table and field names.", "Step ids must be unique, and every next/children link must reference an existing step id.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "AIClassificationBranch data uses the public Agent Data shape: classes/content/classification_rule/no_match_action; omit mode and let the service default it.", + "AIClassificationBranch data uses the public Agent Data shape: classes/content/classification_rule/no_match_action; omit mode because AI classification only supports service-side Exclusive semantics.", "AIClassificationBranch cannot be saved as an empty shell; create downstream actions first and connect branch_1/branch_2/default through children.links.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index 4ccf83e8b7..044f524cf5 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -206,13 +206,28 @@ func TestBaseWorkflowExecuteValidateAIClassificationAgentData(t *testing.T) { }{ { name: "draft data is not public protocol", - body: base(`{"mode":"Exclusive","prompt":[{"value_type":"text","value":"Classify"}],"childBranchList":[{"name":"Bug"},{"name":"Feature"}],"no_match_action":"fail"}`, validChildren), + body: base(`{"prompt":[{"value_type":"text","value":"Classify"}],"childBranchList":[{"name":"Bug"},{"name":"Feature"}],"no_match_action":"fail"}`, validChildren), want: "data.classes must be an array", }, { - name: "invalid mode", - body: base(strings.Replace(validData, `"classes": [`, `"mode": "invalid", "classes": [`, 1), validChildren), - want: "data.mode must be Exclusive or Parallel when set", + name: "exclusive mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": "Exclusive", "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", + }, + { + name: "parallel mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": "Parallel", "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", + }, + { + name: "empty mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": "", "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", + }, + { + name: "non string mode is not public input", + body: base(strings.Replace(validData, `"classes": [`, `"mode": true, "classes": [`, 1), validChildren), + want: "data.mode is not supported; omit it because AI classification only supports Exclusive mode", }, { name: "empty links", @@ -299,7 +314,52 @@ func TestBaseWorkflowExecuteValidateAIClassificationOptionalModeAndNoMatchAction } } -func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) { +func TestBaseWorkflowExecuteUpdateRejectsAIClassificationMode(t *testing.T) { + base := func(mode string) string { + return `{ + "title": "Feedback classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":[ + {"kind":"case","label":"branch_1","desc":"Bug","to":"step_bug"}, + {"kind":"case","label":"branch_2","desc":"Feature","to":"step_feature"} + ]}, + "data": { + "mode": "` + mode + `", + "classes": [ + {"name": "Bug", "desc": "Broken behavior"}, + {"name": "Feature", "desc": "New capability"} + ], + "content": [{"value_type": "text", "value": "Classify"}], + "classification_rule": "Use the closest category.", + "no_match_action": "fail" + } + }, + {"id": "step_bug", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_feature", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` + } + + for _, mode := range []string{"Exclusive", "Parallel"} { + t.Run(mode, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", base(mode)}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "data.mode is not supported; omit it because AI classification only supports Exclusive mode") { + t.Fatalf("err=%v", err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("err type=%T want *errs.ValidationError", err) + } + }) + } +} + +func TestBaseWorkflowExecuteUpdatePreservesAIClassificationWithoutMode(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) stub := &httpmock.Stub{ Method: "PUT", @@ -325,7 +385,6 @@ func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) {"kind":"case","label":"default","desc":"默认分支","to":"step_other"} ]}, "data": { - "mode": "Parallel", "classes": [ {"name": "Bug", "desc": "Broken behavior"}, {"name": "Feature", "desc": "New capability"} @@ -342,8 +401,8 @@ func TestBaseWorkflowExecuteUpdateAcceptsAIClassificationGetShape(t *testing.T) if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := string(stub.CapturedBody); !strings.Contains(got, `"mode":"Parallel"`) || strings.Contains(got, `"no_match_action"`) || !strings.Contains(got, `"classes":[`) { - t.Fatalf("AI classification get shape was not forwarded: %s", got) + if got := string(stub.CapturedBody); strings.Contains(got, `"mode"`) || strings.Contains(got, `"no_match_action"`) || !strings.Contains(got, `"classes":[`) { + t.Fatalf("AI classification payload should be forwarded without injected optional fields: %s", got) } } diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index 7cb4bbdf17..9895c39546 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -30,7 +30,7 @@ var BaseWorkflowUpdate = common.Shortcut{ "Step ids must be unique, and every next/children link must reference an existing step id.", "Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "AIClassificationBranch update accepts the same public Agent Data shape returned by +workflow-get; keep classes/content/classification_rule/no_match_action and preserve service-returned fields.", + "AIClassificationBranch update accepts the public Agent Data shape classes/content/classification_rule/no_match_action; omit mode because AI classification only supports service-side Exclusive semantics.", "AIClassificationBranch default branch must use children.links label default; label other is treated as invalid in CLI input.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 37a5d3d35b..60d63106ea 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -555,7 +555,7 @@ ### AIClassificationBranch -`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议,不提交内部 Draft Data 字段;提交后用 `+workflow-get` 回读确认服务端保存的最终字段和枚举。 +`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议,不提交内部 Draft Data 字段;不要提交 `mode`,AI 分类仅支持服务端 Exclusive 语义。 ```json { @@ -579,7 +579,6 @@ | 字段 | 必填 | 说明 | |------|------|------| -| `mode` | 否 | 服务端回读字段;创建时可省略,默认由服务端/SDK 设置。CLI 不在本地拒绝 `Parallel` 或缺失值 | | `classes` | 是 | 分类列表,至少 2 项。每项包含 `name` 和 `desc` | | `classes[].name` | 是 | 分类名称,需与对应普通 `children.links[].desc` 保持一致 | | `classes[].desc` | 是 | 分类描述,可为空字符串,但字段必须存在 | @@ -587,14 +586,14 @@ | `classification_rule` | 否 | 全局分类规则纯文本 | | `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。创建缺省时使用服务端/SDK 默认 `classifyToOther`,更新缺省时保留既有配置 | -不要在公开 JSON 中提交内部 Draft Data 字段:`prompt`、`childBranchList` / `child_branch_list`、`defaultBranchInfo` / `default_branch_info`、`classifyPrompt` / `classify_prompt`。 +不要在公开 JSON 中提交 `mode` 或内部 Draft Data 字段:`prompt`、`childBranchList` / `child_branch_list`、`defaultBranchInfo` / `default_branch_info`、`classifyPrompt` / `classify_prompt`。`data.mode` 不支持作为 CLI 输入,即使值为 `Exclusive` 也必须省略;服务端按 AI 分类仅支持的 Exclusive 语义处理。 `children.links` 规则: - 分类和默认分支的拓扑只由 `children.links` 表达。 - 普通分类边使用 `kind: "case"` 和 `label: "branch_1"`、`branch_2` 等稳定标签;`desc` 与 `classes[i].name` 保持一致;`to` 指向该分类的入口 step。 - 缺省或 `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 - `label: "other"` 不表示默认分支,不要使用。 -- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data、服务端返回的 `mode` 和分支拓扑均已保存;更新时保留回读中的未修改字段。 +- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data 和分支拓扑均已保存;更新时保留回读中的未修改字段,但不要向 `AIClassificationBranch.data` 写入 `mode`。 ## System data 详细结构 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 15146b204d..00344729b0 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -590,10 +590,10 @@ 1. 创建:`lark-cli base +workflow-create --base-token --json @workflow.json` 2. 回读:`lark-cli base +workflow-get --base-token --workflow-id ` 3. 更新:先保存回读结果,只修改目标字段,再执行 `+workflow-update --json @workflow.json` -4. 再次回读:确认 `AIClassificationBranch` 的 `classes`、`content`、`classification_rule`、`children.links` 以及服务端返回的 `mode` / `no_match_action` 均未丢失 +4. 再次回读:确认 `AIClassificationBranch` 的 `classes`、`content`、`classification_rule`、`no_match_action` 和 `children.links` 均未丢失 关键点: -- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`;创建时不需要提交 `mode`。 +- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`;创建和更新都必须省略 `mode`,服务端按 AI 分类仅支持的 Exclusive 语义处理。 - `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类使用 `branch_1`、`branch_2` 等标签,默认分支使用 `label: "default"`。 - `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;缺省或 `no_match_action: "classifyToOther"` 时必须提供默认分支。 - AI 分类可能处理业务敏感信息;创建或更新后用 `+workflow-get` 确认最终保存结果,不要只依赖提交前 JSON。 From 09064227b13f3c1017ce742e6e4ec4f14ff4e43c Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Fri, 28 Aug 2026 14:13:22 +0800 Subject: [PATCH 13/33] fix: polish skill --- skills/lark-base/references/lark-base-workflow-schema.md | 6 +++--- skills/lark-base/references/lark-base-workflow.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 60d63106ea..179c25e37d 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -555,7 +555,7 @@ ### AIClassificationBranch -`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议,不提交内部 Draft Data 字段;不要提交 `mode`,AI 分类仅支持服务端 Exclusive 语义。 +`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议,不提交内部 Draft Data 字段。 ```json { @@ -586,14 +586,14 @@ | `classification_rule` | 否 | 全局分类规则纯文本 | | `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。创建缺省时使用服务端/SDK 默认 `classifyToOther`,更新缺省时保留既有配置 | -不要在公开 JSON 中提交 `mode` 或内部 Draft Data 字段:`prompt`、`childBranchList` / `child_branch_list`、`defaultBranchInfo` / `default_branch_info`、`classifyPrompt` / `classify_prompt`。`data.mode` 不支持作为 CLI 输入,即使值为 `Exclusive` 也必须省略;服务端按 AI 分类仅支持的 Exclusive 语义处理。 +不要在公开 JSON 中提交内部 Draft Data 字段:`prompt`、`childBranchList` / `child_branch_list`、`defaultBranchInfo` / `default_branch_info`、`classifyPrompt` / `classify_prompt`。 `children.links` 规则: - 分类和默认分支的拓扑只由 `children.links` 表达。 - 普通分类边使用 `kind: "case"` 和 `label: "branch_1"`、`branch_2` 等稳定标签;`desc` 与 `classes[i].name` 保持一致;`to` 指向该分类的入口 step。 - 缺省或 `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 - `label: "other"` 不表示默认分支,不要使用。 -- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data 和分支拓扑均已保存;更新时保留回读中的未修改字段,但不要向 `AIClassificationBranch.data` 写入 `mode`。 +- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data 和分支拓扑均已保存;更新时保留回读中的未修改字段。 ## System data 详细结构 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 00344729b0..c724dcf609 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -593,7 +593,7 @@ 4. 再次回读:确认 `AIClassificationBranch` 的 `classes`、`content`、`classification_rule`、`no_match_action` 和 `children.links` 均未丢失 关键点: -- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`;创建和更新都必须省略 `mode`,服务端按 AI 分类仅支持的 Exclusive 语义处理。 +- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`。 - `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类使用 `branch_1`、`branch_2` 等标签,默认分支使用 `label: "default"`。 - `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;缺省或 `no_match_action: "classifyToOther"` 时必须提供默认分支。 - AI 分类可能处理业务敏感信息;创建或更新后用 `+workflow-get` 确认最终保存结果,不要只依赖提交前 JSON。 From f3667d24d33d219ff3146de7bbdaa35aa4fd62ee Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Tue, 1 Sep 2026 14:56:51 +0800 Subject: [PATCH 14/33] =?UTF-8?q?fix:=20=E8=BF=98=E5=8E=9F=20step=20?= =?UTF-8?q?=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/base/workflow_execute_test.go | 31 ++++++++++++++++++---- shortcuts/base/workflow_json_validation.go | 11 -------- shortcuts/base/workflow_update.go | 8 +++--- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index 3dcead5ff6..8cc3cd703b 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -473,14 +473,14 @@ func TestBaseWorkflowExecuteUpdateValidateAIAnalysisData(t *testing.T) { }) } -func TestBaseWorkflowExecuteUpdateOmittedStepsClearsWorkflow(t *testing.T) { +func TestBaseWorkflowExecuteUpdatePreservesOmittedSteps(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) stub := &httpmock.Stub{ Method: "PUT", URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Only Title", "steps": []interface{}{}}, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Only Title"}, }, } reg.Register(stub) @@ -491,9 +491,30 @@ func TestBaseWorkflowExecuteUpdateOmittedStepsClearsWorkflow(t *testing.T) { if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { t.Fatalf("request body invalid JSON: %v", err) } - steps, ok := body["steps"].([]interface{}) - if !ok || len(steps) != 0 { - t.Fatalf("request steps=%#v, want []", body["steps"]) + if steps, ok := body["steps"]; ok { + t.Fatalf("request steps=%#v, want field omitted", steps) + } +} + +func TestBaseWorkflowDryRunUpdatePreservesOmittedSteps(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{ + "+workflow-update", + "--base-token", "app_x", + "--workflow-id", "wkf_1", + "--json", `{"title":"Only Title"}`, + "--dry-run", + "--format", "pretty", + } + if err := runShortcut(t, BaseWorkflowUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") || !strings.Contains(got, `"title":"Only Title"`) { + t.Fatalf("stdout=%s", got) + } + if strings.Contains(got, `"steps":`) { + t.Fatalf("dry-run injected omitted steps: %s", got) } } diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go index a4b3348a85..aaca2d49a1 100644 --- a/shortcuts/base/workflow_json_validation.go +++ b/shortcuts/base/workflow_json_validation.go @@ -28,17 +28,6 @@ func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface return body, nil } -func parseWorkflowUpdateBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { - body, err := parseWorkflowBodyJSON(runtime) - if err != nil { - return nil, err - } - if _, ok := body["steps"]; !ok { - body["steps"] = []interface{}{} - } - return body, nil -} - func validateWorkflowBodyForCLI(body map[string]interface{}) error { if err := validateWorkflowAIClassificationBranches(body); err != nil { return err diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index 78d4ecc3ae..eb2726aa5d 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -24,7 +24,7 @@ var BaseWorkflowUpdate = common.Shortcut{ }, Tips: []string{ "lark-cli base +workflow-update --base-token --workflow-id --json @workflow.json", - "PUT uses full replacement semantics; omitting steps clears the existing workflow steps.", + "PUT uses full replacement semantics.", "Use +workflow-get first, then edit the returned definition and keep title/status/steps fields you do not intend to change.", "workflow-id must start with wkf; do not pass a tbl table ID.", "Step ids must be unique, and every next/children link must reference an existing step id.", @@ -40,14 +40,14 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return baseFlagErrorf("--workflow-id must not be blank") } - if _, err := parseWorkflowUpdateBodyJSON(runtime); err != nil { + if _, err := parseWorkflowBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - body, _ = parseWorkflowUpdateBodyJSON(runtime) + body, _ = parseWorkflowBodyJSON(runtime) return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). @@ -55,7 +55,7 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := parseWorkflowUpdateBodyJSON(runtime) + body, err := parseWorkflowBodyJSON(runtime) if err != nil { return err } From 181f475eea79b8858a22201c60e78c56dc6bbca3 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Tue, 1 Sep 2026 15:14:14 +0800 Subject: [PATCH 15/33] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E9=80=BB=E8=BE=91=E7=BB=84=E7=BB=87=E5=BD=A2=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base/workflow_ai_analysis_validate.go | 45 +++++++++++++++ .../workflow_ai_classification_validate.go | 20 +------ shortcuts/base/workflow_json_validation.go | 55 ++++--------------- 3 files changed, 59 insertions(+), 61 deletions(-) create mode 100644 shortcuts/base/workflow_ai_analysis_validate.go diff --git a/shortcuts/base/workflow_ai_analysis_validate.go b/shortcuts/base/workflow_ai_analysis_validate.go new file mode 100644 index 0000000000..1dd79a6e5a --- /dev/null +++ b/shortcuts/base/workflow_ai_analysis_validate.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "fmt" + +const aiAnalysisActionType = "AIAnalysisAction" + +var aiAnalysisIdentityTypes = map[string]bool{ + "maker": true, + "triggerPersonal": true, +} + +func validateWorkflowAIAnalysisAction(stepIndex int, step map[string]interface{}) error { + data, ok := step["data"].(map[string]interface{}) + if !ok { + return nil + } + if value, exists := data["analysis_table_names"]; exists { + items, ok := value.([]interface{}) + if !ok { + return baseFlagErrorf("%s must be a string array", workflowJSONPath(stepIndex, "analysis_table_names")) + } + for itemIndex, item := range items { + if _, ok := item.(string); !ok { + return baseFlagErrorf("%s[%d] must be a string", workflowJSONPath(stepIndex, "analysis_table_names"), itemIndex) + } + } + } + if value, exists := data["identity_type"]; exists { + identityType, ok := value.(string) + if !ok { + return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) + } + if !aiAnalysisIdentityTypes[identityType] { + return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) + } + } + return nil +} + +func workflowJSONPath(stepIndex int, suffix string) string { + return fmt.Sprintf("--json.steps[%d].data.%s", stepIndex, suffix) +} diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go index ce920963f1..2737983614 100644 --- a/shortcuts/base/workflow_ai_classification_validate.go +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -13,12 +13,7 @@ const ( workflowAIClassificationDefaultNoMatchAction = "classifyToOther" ) -func validateWorkflowAIClassificationBranches(body map[string]interface{}) error { - steps, ok := body["steps"].([]interface{}) - if !ok { - return nil - } - +func indexWorkflowStepIDs(steps []interface{}) map[string]int { stepIDs := make(map[string]int, len(steps)) for i, raw := range steps { step, _ := raw.(map[string]interface{}) @@ -27,18 +22,7 @@ func validateWorkflowAIClassificationBranches(body map[string]interface{}) error stepIDs[id] = i } } - - for i, raw := range steps { - step, _ := raw.(map[string]interface{}) - stepType, _ := step["type"].(string) - if stepType != workflowAIClassificationStepType { - continue - } - if err := validateWorkflowAIClassificationStep(i, step, stepIDs); err != nil { - return err - } - } - return nil + return stepIDs } func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int) error { diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go index aaca2d49a1..90e77b7aea 100644 --- a/shortcuts/base/workflow_json_validation.go +++ b/shortcuts/base/workflow_json_validation.go @@ -3,18 +3,7 @@ package base -import ( - "fmt" - - "github.com/larksuite/cli/shortcuts/common" -) - -const aiAnalysisActionType = "AIAnalysisAction" - -var aiAnalysisIdentityTypes = map[string]bool{ - "maker": true, - "triggerPersonal": true, -} +import "github.com/larksuite/cli/shortcuts/common" func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { pc := newParseCtx(runtime) @@ -29,53 +18,33 @@ func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface } func validateWorkflowBodyForCLI(body map[string]interface{}) error { - if err := validateWorkflowAIClassificationBranches(body); err != nil { - return err - } - steps, ok := body["steps"].([]interface{}) if !ok { return nil } + var stepIDs map[string]int for stepIndex, rawStep := range steps { step, ok := rawStep.(map[string]interface{}) if !ok { continue } + stepType, _ := step["type"].(string) - if stepType != aiAnalysisActionType { - continue - } - data, ok := step["data"].(map[string]interface{}) - if !ok { - continue - } - if value, exists := data["analysis_table_names"]; exists { - items, ok := value.([]interface{}) - if !ok { - return baseFlagErrorf("%s must be a string array", workflowJSONPath(stepIndex, "analysis_table_names")) + switch stepType { + case aiAnalysisActionType: + if err := validateWorkflowAIAnalysisAction(stepIndex, step); err != nil { + return err } - for itemIndex, item := range items { - if _, ok := item.(string); !ok { - return baseFlagErrorf("%s[%d] must be a string", workflowJSONPath(stepIndex, "analysis_table_names"), itemIndex) - } + case workflowAIClassificationStepType: + if stepIDs == nil { + stepIDs = indexWorkflowStepIDs(steps) } - } - if value, exists := data["identity_type"]; exists { - identityType, ok := value.(string) - if !ok { - return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) - } - if !aiAnalysisIdentityTypes[identityType] { - return baseFlagErrorf("%s must be one of: maker, triggerPersonal", workflowJSONPath(stepIndex, "identity_type")) + if err := validateWorkflowAIClassificationStep(stepIndex, step, stepIDs); err != nil { + return err } } } return nil } - -func workflowJSONPath(stepIndex int, suffix string) string { - return fmt.Sprintf("--json.steps[%d].data.%s", stepIndex, suffix) -} From af948b0edaf5c8197b42ea9522cff3cb3bceb557 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Tue, 1 Sep 2026 15:38:33 +0800 Subject: [PATCH 16/33] fix: polish skill --- .../references/lark-base-workflow.md | 369 +++++++++--------- 1 file changed, 188 insertions(+), 181 deletions(-) diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 0556e78257..7b840caf5c 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -54,64 +54,19 @@ | 场景 | 步骤组合 | 示例 | |------|---------|------| -| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) | -| 定时触发+AI 分析 | TimerTrigger → AIAnalysisAction | [下方](#示例-ai-分析-定时分析-base-数据并回读核验) | +| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例-1-新增记录触发--发送消息) | +| 定时触发+AI 分析 | TimerTrigger → AIAnalysisAction | [下方](#示例-7-ai-分析定时分析-base-数据并回读核验) | | 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) | -| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) | -| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) | -| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支-switchbranch) | -| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例-ai-分类用户反馈自动分流) | -| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例5-组合场景-定时查找循环分支消息) | +| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例-2-定时触发--查找记录--循环遍历--发送消息) | +| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例-3-条件分支ifelsebranch) | +| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例-4-多路分支switchbranch) | +| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例-8-ai-分类用户反馈自动分流) | +| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例-5-组合场景定时查找循环分支消息) | --- ## 完整示例 -### 示例: AI 分析 - 定时分析 Base 数据并回读核验 - -**场景**: 每天早上 9 点分析「订单表」和「退款表」的昨日趋势。创建后先回读确认四项配置已落盘;修改时始终先 `+workflow-get` 再做全量更新。 - -```json -{ - "client_token": "1704067200-ai-analysis", - "title": "每日经营 AI 分析", - "steps": [ - { - "id": "step_timer", - "type": "TimerTrigger", - "title": "每天早上 9 点触发", - "next": "step_ai_analysis", - "data": { - "rule": "DAILY", - "start_time": "2025-01-01 09:00", - "is_never_end": true - } - }, - { - "id": "step_ai_analysis", - "type": "AIAnalysisAction", - "title": "分析昨日经营异常", - "next": null, - "data": { - "analysis_task": [ - { "value_type": "text", "value": "分析昨日订单趋势、退款异常和可能原因,并给出今日行动建议" } - ], - "analysis_table_names": ["订单表", "退款表"], - "identity_type": "maker", - "output_instruction": "先给结论,再列证据与行动建议" - } - } - ] -} -``` - -**推荐操作顺序**: -- 创建后立刻执行 `lark-cli base +workflow-get --base-token --workflow-id --as user`,确认 `steps[].type` 仍为 `AIAnalysisAction`,且四个字段语义与提交一致。 -- 更新已有流程时,先用 `+workflow-get` 读取完整 JSON,只改目标字段,再把完整 body 传给 `+workflow-update`;不要手写局部 patch。 -- `analysis_table_names: []` 表示当前 Base 的全部数据表;若要限制范围,请显式列出表名。 -- `identity_type: "maker"` 表示固定流程身份;`identity_type: "triggerPersonal"` 仅适用于能提供真实触发者身份的触发器。 -- AI 分析是异步执行链路:配置保存成功、工作流启用成功、单次节点执行成功是三件事,需分别观察。 - ### 示例 1: 新增记录触发 + 发送消息 **场景**: 当订单表新增记录时,发送飞书消息通知负责人。 @@ -517,135 +472,6 @@ --- -### 示例 AI 分类:用户反馈自动分流 - -**场景**: 当用户反馈表新增记录时,AI 根据反馈内容分类为 Bug、功能建议或体验问题;无法判断时进入其他分支并通知人工复核。 - -```json -{ - "client_token": "1704067206", - "title": "用户反馈自动分流", - "steps": [ - { - "id": "step_trigger", - "type": "AddRecordTrigger", - "title": "新增反馈时触发", - "next": "step_ai_classify", - "data": { - "table_name": "用户反馈表", - "watched_field_name": "反馈详情" - } - }, - { - "id": "step_ai_classify", - "type": "AIClassificationBranch", - "title": "AI 判断反馈类型", - "children": { - "links": [ - { "kind": "case", "to": "step_bug_action", "label": "branch_1", "desc": "Bug" }, - { "kind": "case", "to": "step_feature_action", "label": "branch_2", "desc": "功能建议" }, - { "kind": "case", "to": "step_experience_action", "label": "branch_3", "desc": "体验问题" }, - { "kind": "case", "to": "step_other_action", "label": "default", "desc": "默认分支" } - ] - }, - "next": null, - "data": { - "classes": [ - { - "name": "Bug", - "desc": "功能报错、异常、崩溃、无法使用或结果错误" - }, - { - "name": "功能建议", - "desc": "希望新增能力或改变产品行为" - }, - { - "name": "体验问题", - "desc": "流程繁琐、操作难懂、性能慢或界面体验不佳" - } - ], - "content": [ - { "value_type": "text", "value": "请根据反馈标题和反馈详情判断类型:" }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedbackTitle" }, - { "value_type": "text", "value": " " }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } - ], - "classification_rule": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入默认分支。" - } - }, - { - "id": "step_bug_action", - "type": "SetRecordAction", - "title": "标记为 Bug", - "next": null, - "data": { - "table_name": "用户反馈表", - "ref_info": { "step_id": "step_trigger" }, - "field_values": [ - { "field_name": "分类", "value": [{ "value_type": "text", "value": "Bug" }] } - ] - } - }, - { - "id": "step_feature_action", - "type": "SetRecordAction", - "title": "标记为功能建议", - "next": null, - "data": { - "table_name": "用户反馈表", - "ref_info": { "step_id": "step_trigger" }, - "field_values": [ - { "field_name": "分类", "value": [{ "value_type": "text", "value": "功能建议" }] } - ] - } - }, - { - "id": "step_experience_action", - "type": "SetRecordAction", - "title": "标记为体验问题", - "next": null, - "data": { - "table_name": "用户反馈表", - "ref_info": { "step_id": "step_trigger" }, - "field_values": [ - { "field_name": "分类", "value": [{ "value_type": "text", "value": "体验问题" }] } - ] - } - }, - { - "id": "step_other_action", - "type": "LarkMessageAction", - "title": "通知人工复核", - "next": null, - "data": { - "receiver": [{ "value_type": "user", "value": { "id": "ou_xxxx", "name": "负责人" } }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "反馈需要人工复核" }], - "content": [ - { "value_type": "text", "value": "AI 未能确定反馈分类,请人工确认:" }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } - ], - "btn_list": [] - } - } - ] -} -``` - -标准操作顺序: -1. 创建:`lark-cli base +workflow-create --base-token --json @workflow.json` -2. 回读:`lark-cli base +workflow-get --base-token --workflow-id ` -3. 更新:先保存回读结果,只修改目标字段,再执行 `+workflow-update --json @workflow.json` -4. 再次回读:确认 `AIClassificationBranch` 的 `classes`、`content`、`classification_rule`、`no_match_action` 和 `children.links` 均未丢失 - -关键点: -- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`。 -- `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类使用 `branch_1`、`branch_2` 等标签,默认分支使用 `label: "default"`。 -- `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;缺省或 `no_match_action: "classifyToOther"` 时必须提供默认分支。 -- AI 分类可能处理业务敏感信息;创建或更新后用 `+workflow-get` 确认最终保存结果,不要只依赖提交前 JSON。 - ---- - ### 示例 5: 组合场景(定时+查找+循环+分支+消息) **场景**: 每天早上 9 点,查找昨天的订单,按金额分级,给不同级别的销售发送不同的通知。 @@ -917,6 +743,187 @@ --- +### 示例 7: AI 分析(定时分析 Base 数据) + +**场景**: 每天早上 9 点分析「订单表」和「退款表」的昨日趋势。 + +```json +{ + "client_token": "1704067200-ai-analysis", + "title": "每日经营 AI 分析", + "steps": [ + { + "id": "step_timer", + "type": "TimerTrigger", + "title": "每天早上 9 点触发", + "next": "step_ai_analysis", + "data": { + "rule": "DAILY", + "start_time": "2025-01-01 09:00", + "is_never_end": true + } + }, + { + "id": "step_ai_analysis", + "type": "AIAnalysisAction", + "title": "分析昨日经营异常", + "next": null, + "data": { + "analysis_task": [ + { "value_type": "text", "value": "分析昨日订单趋势、退款异常和可能原因,并给出今日行动建议" } + ], + "analysis_table_names": ["订单表", "退款表"], + "identity_type": "maker", + "output_instruction": "先给结论,再列证据与行动建议" + } + } + ] +} +``` + +**关键点**: +- `analysis_table_names: []` 表示当前 Base 的全部数据表;若要限制范围,请显式列出表名。 +- `identity_type: "maker"` 表示固定流程身份;`identity_type: "triggerPersonal"` 仅适用于能提供真实触发者身份的触发器。 + +--- + +### 示例 8: AI 分类(用户反馈自动分流) + +**场景**: 当用户反馈表新增记录时,AI 根据反馈内容分类为 Bug、功能建议或体验问题;无法判断时进入其他分支并通知人工复核。 + +```json +{ + "client_token": "1704067206", + "title": "用户反馈自动分流", + "steps": [ + { + "id": "step_trigger", + "type": "AddRecordTrigger", + "title": "新增反馈时触发", + "next": "step_ai_classify", + "data": { + "table_name": "用户反馈表", + "watched_field_name": "反馈详情" + } + }, + { + "id": "step_ai_classify", + "type": "AIClassificationBranch", + "title": "AI 判断反馈类型", + "children": { + "links": [ + { "kind": "case", "to": "step_bug_action", "label": "branch_1", "desc": "Bug" }, + { "kind": "case", "to": "step_feature_action", "label": "branch_2", "desc": "功能建议" }, + { "kind": "case", "to": "step_experience_action", "label": "branch_3", "desc": "体验问题" }, + { "kind": "case", "to": "step_other_action", "label": "default", "desc": "默认分支" } + ] + }, + "next": null, + "data": { + "classes": [ + { + "name": "Bug", + "desc": "功能报错、异常、崩溃、无法使用或结果错误" + }, + { + "name": "功能建议", + "desc": "希望新增能力或改变产品行为" + }, + { + "name": "体验问题", + "desc": "流程繁琐、操作难懂、性能慢或界面体验不佳" + } + ], + "content": [ + { "value_type": "text", "value": "请根据反馈标题和反馈详情判断类型:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackTitle" }, + { "value_type": "text", "value": " " }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } + ], + "classification_rule": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入默认分支。" + } + }, + { + "id": "step_bug_action", + "type": "SetRecordAction", + "title": "标记为 Bug", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "Bug" }] } + ] + } + }, + { + "id": "step_feature_action", + "type": "SetRecordAction", + "title": "标记为功能建议", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "功能建议" }] } + ] + } + }, + { + "id": "step_experience_action", + "type": "SetRecordAction", + "title": "标记为体验问题", + "next": null, + "data": { + "table_name": "用户反馈表", + "ref_info": { "step_id": "step_trigger" }, + "field_values": [ + { "field_name": "分类", "value": [{ "value_type": "text", "value": "体验问题" }] } + ] + } + }, + { + "id": "step_other_action", + "type": "LarkMessageAction", + "title": "通知人工复核", + "next": null, + "data": { + "receiver": [{ "value_type": "user", "value": { "id": "ou_xxxx", "name": "负责人" } }], + "send_to_everyone": false, + "title": [{ "value_type": "text", "value": "反馈需要人工复核" }], + "content": [ + { "value_type": "text", "value": "AI 未能确定反馈分类,请人工确认:" }, + { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } + ], + "btn_list": [] + } + } + ] +} +``` + +关键点: +- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`。 +- `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类使用 `branch_1`、`branch_2` 等标签,默认分支使用 `label: "default"`。 +- `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;缺省或 `no_match_action: "classifyToOther"` 时必须提供默认分支。 + +**`no_match_action: "fail"` 差异**:当没有匹配分类时需要让当前节点失败,只修改以下部分;普通分类边和 `classes` / `content` 保持不变。 + +```diff + "data": { ++ "no_match_action": "fail" + } + "children": { + "links": [ +- { "kind": "case", "to": "step_other_action", "label": "default", "desc": "默认分支" } + ] + } +``` + +如果 `step_other_action` 仅由默认分支引用,应同时从 `steps` 中删除该节点。 + +--- + ## 构造技巧 ### Loop 构造要点 From 57992babad535050ffd028a702162c3630485387 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 12:06:07 +0800 Subject: [PATCH 17/33] fix: CR Comment --- shortcuts/base/workflow_create.go | 2 -- shortcuts/base/workflow_list.go | 2 +- shortcuts/base/workflow_update.go | 3 --- .../references/lark-base-workflow-schema.md | 16 ++++------------ 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index f9dbd343fa..cd9de3bcb8 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -28,8 +28,6 @@ var BaseWorkflowCreate = common.Shortcut{ "Before constructing steps, use +table-list and +field-list to confirm real table and field names.", "Step ids must be unique, and every next/children link must reference an existing step id.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "AIClassificationBranch data uses the public Agent Data shape: classes/content/classification_rule/no_match_action; omit mode because AI classification only supports service-side Exclusive semantics.", - "AIClassificationBranch cannot be saved as an empty shell; create downstream actions first and connect branch_1/branch_2/default through children.links.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/shortcuts/base/workflow_list.go b/shortcuts/base/workflow_list.go index a1149c8de3..d78661d9a8 100644 --- a/shortcuts/base/workflow_list.go +++ b/shortcuts/base/workflow_list.go @@ -46,7 +46,7 @@ var BaseWorkflowList = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - allItems := make([]interface{}, 0) + var allItems []interface{} pageToken := "" for { body := map[string]interface{}{ diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index eb2726aa5d..ec4494f487 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -24,14 +24,11 @@ var BaseWorkflowUpdate = common.Shortcut{ }, Tips: []string{ "lark-cli base +workflow-update --base-token --workflow-id --json @workflow.json", - "PUT uses full replacement semantics.", "Use +workflow-get first, then edit the returned definition and keep title/status/steps fields you do not intend to change.", "workflow-id must start with wkf; do not pass a tbl table ID.", "Step ids must be unique, and every next/children link must reference an existing step id.", "Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.", "Use lark-base-workflow.md as the module entry and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", - "AIClassificationBranch update accepts the public Agent Data shape classes/content/classification_rule/no_match_action; omit mode because AI classification only supports service-side Exclusive semantics.", - "AIClassificationBranch default branch must use children.links label default; label other is treated as invalid in CLI input.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 579e532aee..758c6ad7dc 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -495,10 +495,6 @@ | `identity_type` | 是 | 数据访问身份:`maker`(固定流程身份) / `triggerPersonal`(流程触发者) | | `output_instruction` | 否 | string 输出要求;仅支持纯文本,不支持引用、附件或云文档模板 | -> ⚠️ `AIAnalysisAction` 的公开 JSON 使用 snake_case;服务端内部会按既有规则映射到 `analysisTask` / `analysisTableNames` / `identityType` / `outputInstruction`。 -> -> ⚠️ `analysis_table_names` 中指定的表名必须能解析到当前 Base;无效表名、无权限或身份与触发器不兼容时,应在保存或启用前失败,不能静默降级。 - ## Branch data 详细结构 @@ -580,7 +576,7 @@ ### AIClassificationBranch -`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议,不提交内部 Draft Data 字段。 +`AIClassificationBranch` 用 AI 对 `content` 内容做分类,再通过 `children.links` 中的 `case` 边进入命中的后续步骤。`steps[].data` 使用公开 Agent Data 协议。 ```json { @@ -609,16 +605,12 @@ | `classes[].desc` | 是 | 分类描述,可为空字符串,但字段必须存在 | | `content` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` | | `classification_rule` | 否 | 全局分类规则纯文本 | -| `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。创建缺省时使用服务端/SDK 默认 `classifyToOther`,更新缺省时保留既有配置 | - -不要在公开 JSON 中提交内部 Draft Data 字段:`prompt`、`childBranchList` / `child_branch_list`、`defaultBranchInfo` / `default_branch_info`、`classifyPrompt` / `classify_prompt`。 +| `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。创建缺省时使用 `classifyToOther`,更新缺省时保留既有配置 | `children.links` 规则: -- 分类和默认分支的拓扑只由 `children.links` 表达。 +- 每个分类命中后要跳到哪个后续步骤,必须写在 children.links 中。 - 普通分类边使用 `kind: "case"` 和 `label: "branch_1"`、`branch_2` 等稳定标签;`desc` 与 `classes[i].name` 保持一致;`to` 指向该分类的入口 step。 -- 缺省或 `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 -- `label: "other"` 不表示默认分支,不要使用。 -- 创建或更新后,用 `+workflow-get` 回读确认公开 Agent Data 和分支拓扑均已保存;更新时保留回读中的未修改字段。 +- `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 ## System data 详细结构 From cef8f78dc64d4e0852cd9fe2fd7e67765f8e70e8 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 19 Aug 2026 11:03:50 +0800 Subject: [PATCH 18/33] feat: support development environment overrides --- .agents/skills/lark-approval | 1 + .agents/skills/lark-apps | 1 + .agents/skills/lark-attendance | 1 + .agents/skills/lark-base | 1 + .agents/skills/lark-calendar | 1 + .agents/skills/lark-contact | 1 + .agents/skills/lark-doc | 1 + .agents/skills/lark-drive | 1 + .agents/skills/lark-event | 1 + .agents/skills/lark-im | 1 + .agents/skills/lark-mail | 1 + .agents/skills/lark-markdown | 1 + .agents/skills/lark-minutes | 1 + .agents/skills/lark-note | 1 + .agents/skills/lark-okr | 1 + .agents/skills/lark-openapi-explorer | 1 + .agents/skills/lark-shared | 1 + .agents/skills/lark-sheets | 1 + .agents/skills/lark-skill-maker | 1 + .agents/skills/lark-slides | 1 + .agents/skills/lark-task | 1 + .agents/skills/lark-vc | 1 + .agents/skills/lark-vc-agent | 1 + .agents/skills/lark-whiteboard | 1 + .agents/skills/lark-wiki | 1 + .agents/skills/lark-workflow-meeting-summary | 1 + .agents/skills/lark-workflow-standup-report | 1 + .gitignore | 1 + env/claude-dev-lark.sh | 256 +++++++++++++ env/codex-dev-lark.sh | 362 +++++++++++++++++++ env/larkenv | 353 ++++++++++++++++++ internal/cmdutil/secheader.go | 5 + internal/cmdutil/secheader_test.go | 9 + internal/core/types.go | 23 +- internal/core/types_test.go | 29 ++ internal/envvars/envvars.go | 4 + internal/envvars/read.go | 35 ++ internal/envvars/read_test.go | 27 ++ 38 files changed, 1129 insertions(+), 2 deletions(-) create mode 120000 .agents/skills/lark-approval create mode 120000 .agents/skills/lark-apps create mode 120000 .agents/skills/lark-attendance create mode 120000 .agents/skills/lark-base create mode 120000 .agents/skills/lark-calendar create mode 120000 .agents/skills/lark-contact create mode 120000 .agents/skills/lark-doc create mode 120000 .agents/skills/lark-drive create mode 120000 .agents/skills/lark-event create mode 120000 .agents/skills/lark-im create mode 120000 .agents/skills/lark-mail create mode 120000 .agents/skills/lark-markdown create mode 120000 .agents/skills/lark-minutes create mode 120000 .agents/skills/lark-note create mode 120000 .agents/skills/lark-okr create mode 120000 .agents/skills/lark-openapi-explorer create mode 120000 .agents/skills/lark-shared create mode 120000 .agents/skills/lark-sheets create mode 120000 .agents/skills/lark-skill-maker create mode 120000 .agents/skills/lark-slides create mode 120000 .agents/skills/lark-task create mode 120000 .agents/skills/lark-vc create mode 120000 .agents/skills/lark-vc-agent create mode 120000 .agents/skills/lark-whiteboard create mode 120000 .agents/skills/lark-wiki create mode 120000 .agents/skills/lark-workflow-meeting-summary create mode 120000 .agents/skills/lark-workflow-standup-report create mode 100755 env/claude-dev-lark.sh create mode 100755 env/codex-dev-lark.sh create mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval new file mode 120000 index 0000000000..be32751967 --- /dev/null +++ b/.agents/skills/lark-approval @@ -0,0 +1 @@ +../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps new file mode 120000 index 0000000000..326837d904 --- /dev/null +++ b/.agents/skills/lark-apps @@ -0,0 +1 @@ +../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance new file mode 120000 index 0000000000..cea17c567b --- /dev/null +++ b/.agents/skills/lark-attendance @@ -0,0 +1 @@ +../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base new file mode 120000 index 0000000000..480c521328 --- /dev/null +++ b/.agents/skills/lark-base @@ -0,0 +1 @@ +../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar new file mode 120000 index 0000000000..0e067fb7c4 --- /dev/null +++ b/.agents/skills/lark-calendar @@ -0,0 +1 @@ +../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact new file mode 120000 index 0000000000..c4b523b6f7 --- /dev/null +++ b/.agents/skills/lark-contact @@ -0,0 +1 @@ +../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc new file mode 120000 index 0000000000..36be178ac6 --- /dev/null +++ b/.agents/skills/lark-doc @@ -0,0 +1 @@ +../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive new file mode 120000 index 0000000000..b90475836f --- /dev/null +++ b/.agents/skills/lark-drive @@ -0,0 +1 @@ +../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event new file mode 120000 index 0000000000..0edcd85ff3 --- /dev/null +++ b/.agents/skills/lark-event @@ -0,0 +1 @@ +../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im new file mode 120000 index 0000000000..0083317e7e --- /dev/null +++ b/.agents/skills/lark-im @@ -0,0 +1 @@ +../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail new file mode 120000 index 0000000000..1dc9d46966 --- /dev/null +++ b/.agents/skills/lark-mail @@ -0,0 +1 @@ +../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown new file mode 120000 index 0000000000..780a87258d --- /dev/null +++ b/.agents/skills/lark-markdown @@ -0,0 +1 @@ +../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes new file mode 120000 index 0000000000..f39545a341 --- /dev/null +++ b/.agents/skills/lark-minutes @@ -0,0 +1 @@ +../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note new file mode 120000 index 0000000000..64b27c3543 --- /dev/null +++ b/.agents/skills/lark-note @@ -0,0 +1 @@ +../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr new file mode 120000 index 0000000000..c0b5104fe3 --- /dev/null +++ b/.agents/skills/lark-okr @@ -0,0 +1 @@ +../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer new file mode 120000 index 0000000000..0357b2ae30 --- /dev/null +++ b/.agents/skills/lark-openapi-explorer @@ -0,0 +1 @@ +../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared new file mode 120000 index 0000000000..90db01f71f --- /dev/null +++ b/.agents/skills/lark-shared @@ -0,0 +1 @@ +../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets new file mode 120000 index 0000000000..7a4bb8f954 --- /dev/null +++ b/.agents/skills/lark-sheets @@ -0,0 +1 @@ +../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker new file mode 120000 index 0000000000..20a8485a86 --- /dev/null +++ b/.agents/skills/lark-skill-maker @@ -0,0 +1 @@ +../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides new file mode 120000 index 0000000000..a7b448b3a3 --- /dev/null +++ b/.agents/skills/lark-slides @@ -0,0 +1 @@ +../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task new file mode 120000 index 0000000000..40893742c9 --- /dev/null +++ b/.agents/skills/lark-task @@ -0,0 +1 @@ +../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc new file mode 120000 index 0000000000..6e20cbcd71 --- /dev/null +++ b/.agents/skills/lark-vc @@ -0,0 +1 @@ +../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent new file mode 120000 index 0000000000..550b733c72 --- /dev/null +++ b/.agents/skills/lark-vc-agent @@ -0,0 +1 @@ +../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard new file mode 120000 index 0000000000..a3e187627c --- /dev/null +++ b/.agents/skills/lark-whiteboard @@ -0,0 +1 @@ +../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki new file mode 120000 index 0000000000..aab2f46f80 --- /dev/null +++ b/.agents/skills/lark-wiki @@ -0,0 +1 @@ +../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary new file mode 120000 index 0000000000..58081f1f58 --- /dev/null +++ b/.agents/skills/lark-workflow-meeting-summary @@ -0,0 +1 @@ +../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report new file mode 120000 index 0000000000..b9e06f0d2c --- /dev/null +++ b/.agents/skills/lark-workflow-standup-report @@ -0,0 +1 @@ +../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index 645848906d..e0bbba695d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ app.log cover*.out lark-env.sh +.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh new file mode 100755 index 0000000000..dc6362de4f --- /dev/null +++ b/env/claude-dev-lark.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Claude Code in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.claude/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, or online. + Default: boe + --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/claude-dev-lark.sh + env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/claude-dev-lark.sh --skill lark-base + env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +claude_args=() +claude_arg_count=0 + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + claude_args=("$@") + claude_arg_count=$# + break + ;; + *) + claude_args+=("$1") + claude_arg_count=$((claude_arg_count + 1)) + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | online) ;; +*) die "--env must be one of: boe, pre, ppe, online" ;; +esac + +command -v claude >/dev/null 2>&1 || die "claude not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +# launch_claude starts Claude Code with the caller's environment inherited as +# is, including any proxy variables already exported by the shell. Network setup +# is left to the caller so this script stays portable. +# +# If your proxy is configured through a shell function (rather than exported +# variables), invoke this script through it, e.g. `my_proxy_wrapper +# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. +# +# lark-cli traffic is unaffected either way for boe: larkenv boe sets +# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe +# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn +# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". +launch_claude() { + exec claude --allow-dangerously-skip-permissions "$@" +} + +bin_dir="$repo_root/.claude-dev/bin" +skills_dir="$repo_root/.claude/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' + +# Only PPE needs the extra headers; every other target keeps the plain shim so +# the non-PPE path stays byte-identical to what it was before PPE support. +if [ "$use_ppe" -eq 1 ]; then + cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' + +for h in "x-use-ppe:1" "env:pre_release"; do + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $h;"*) ;; + *) + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$h" + fi + ;; + esac +done +PPE_SHIM +fi + +cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 + echo " identity: reuses the online app config and user login state" >&2 +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +export LARK_LANE="$lane" +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +# Claude has no "-C " flag like Codex; it uses the current working +# directory, which is $repo_root here (see the cd above). +if [ "$claude_arg_count" -eq 0 ]; then + launch_claude +fi + +launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh new file mode 100755 index 0000000000..dee647e9bd --- /dev/null +++ b/env/codex-dev-lark.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Codex in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.agents/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE; ignored by --use-pre. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, use-pre, or online. + Default: boe + --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. + --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; + auth/config remain on production. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --cx Launch Codex with cx-style permissions: + codex --dangerously-bypass-approvals-and-sandbox + Adds --profile proxy when CODEX_PROXY_API_KEY is exported. + --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/codex-dev-lark.sh + CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh + env/codex-dev-lark.sh --cx + env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/codex-dev-lark.sh --use-pre + env/codex-dev-lark.sh --skill lark-base + env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +use_pre=0 +use_cx="${CODEX_DEV_LARK_CX:-0}" +codex_args=() + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --use-pre) + use_pre=1 + shift + ;; + --cx) + use_cx=1 + shift + ;; + --no-cx) + use_cx=0 + shift + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + codex_args=("$@") + break + ;; + *) + codex_args+=("$1") + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then + die "--use-ppe and --use-pre are mutually exclusive" +fi + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi +if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="use-pre" +fi +if [ "$use_pre" -eq 1 ]; then + case "$target_env" in + use-pre) ;; + *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | use-pre | online) ;; +*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; +esac + +use_pre_effective=0 +if [ "$target_env" = "use-pre" ]; then + use_pre_effective=1 +fi + +case "$use_cx" in +0 | 1) ;; +*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; +esac + +codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + else + codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + fi +fi + +command -v codex >/dev/null 2>&1 || die "codex not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +bin_dir="$repo_root/.codex-dev/bin" +skills_dir="$repo_root/.agents/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +shim_path="$bin_dir/lark-cli" +shim_tmp="$bin_dir/.lark-cli.$$.tmp" +cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +if [ "$use_pre_effective" -eq 1 ]; then + echo " lark-cli -> larkenv $target_env" >&2 +else + echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +fi +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 +fi +if [ "$use_pre_effective" -eq 1 ]; then + echo " extra headers: env:pre_release" >&2 + echo " omitted: x-use-ppe; X-TT-ENV" >&2 +fi +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 + else + echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 + fi +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +if [ "$use_pre_effective" -eq 1 ]; then + unset LARK_LANE + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" +else + export LARK_LANE="$lane" +fi +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +elif [ "$use_pre_effective" -eq 1 ]; then + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv new file mode 100755 index 0000000000..5054dba2ac --- /dev/null +++ b/env/larkenv @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 +# +# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) +# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 +# larkenv init boe 同上,但走交互式选 app +# larkenv login boe 只补登录(app 已配好时用),自动出二维码 +# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 +# larkenv boe <任何 lark-cli 命令> +# larkenv pre <任何 lark-cli 命令> +# larkenv ppe <任何 lark-cli 命令> +# larkenv use-pre <任何 lark-cli 命令> +# larkenv online <任何 lark-cli 命令> +# +# 可选: +# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) +# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) +# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) +# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body +# +# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 +# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre +# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config +# 控制面仍使用正式 endpoint。 +set -euo pipefail + +BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" +CONFIG_ROOT="$HOME/.lark-cli-env" +BIN="$BIN_DIR/lark-cli-env" + +usage() { + # Print the header comment block (from the title line to the first + # non-comment line), stripped of its leading "# ". + awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" + exit "${1:-0}" +} + +# ---------- setup:编译 + 安装 + 配 PATH ---------- +do_setup() { + local repo_root + repo_root="$(cd "$(dirname "$0")/.." && pwd)" + + echo "==> 编译 lark-cli ..." + (cd "$repo_root" && ./build.sh) + + echo "==> 安装到 $BIN_DIR ..." + mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" + cp "$repo_root/lark-cli" "$BIN" + cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" + chmod +x "$BIN" "$BIN_DIR/larkenv" + + # 幂等地把 BIN_DIR 加进 shell rc + local rc marker='# added by lark-cli env/larkenv' + case "${SHELL##*/}" in + zsh) rc="$HOME/.zshrc" ;; + bash) rc="$HOME/.bashrc" ;; + *) rc="" ;; + esac + if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then + printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" + echo "==> 已把 $BIN_DIR 写入 $rc" + fi + + # NOTE: brace the expansions below — the surrounding full-width punctuation + # would otherwise be swallowed into the variable name by bash. + # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. + local reload="新开一个终端" + if [ -n "$rc" ]; then + reload="新开一个终端(或 source ${rc})" + fi + + cat < # 其他环境复用同一个 app,不用重配 + larkenv login boe # 登录态失效时,只补登录 + + larkenv boe base +table-list --base-token + larkenv pre wiki +node-get --token + larkenv ppe drive +search --query xxx + larkenv use-pre drive +search --query xxx + larkenv online drive +search --query xxx + +EOF +} + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header() { + local target="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + [ "$item" = "$target" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +# PPE reuses the production app config and user token, so authentication and +# configuration must stay on the production control plane. The launcher may +# already have injected PPE routing into the parent environment; remove only +# those routing values while preserving unrelated caller-supplied headers. +apply_ppe_control_env() { + unset LARKSUITE_CLI_CONFIG_DIR + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + remove_extra_header_name "x-use-ppe" + remove_extra_header "env:pre_release" + remove_extra_header_name "x-tt-env" + unset LARK_LANE +} + +# ---------- 环境变量注入 ---------- +apply_env() { + # Normalize values that may have been inherited from a previously selected + # environment before applying the requested target. + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 + # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 + unset LARKSUITE_CLI_CONFIG_DIR + else + export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" + remove_extra_header "x-use-ppe:1" + remove_extra_header "env:pre_release" + fi + case "$1" in + boe) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" + # boe 是内网域名,必须绕开公司外部 relay 代理 + export LARK_CLI_NO_PROXY=1 + ;; + pre) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + ;; + ppe) + # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers + # 选择 pre_release 环境和目标泳道。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" + ;; + use-pre) + # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 + # x-use-ppe 或泳道头。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" + unset LARK_LANE + append_extra_header "env:pre_release" + ;; + online) + # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 + ;; + esac + # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS + # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. + if [ -n "${LARK_LANE:-}" ]; then + append_extra_header "X-TT-ENV: $LARK_LANE" + fi + if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then + mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" + fi +} + +apply_control_env() { + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + apply_ppe_control_env + else + apply_env "$1" + fi +} + +# json_field extracts a top-level string field from a JSON object on stdin. +json_field() { + if command -v jq >/dev/null 2>&1; then + jq -r --arg k "$1" '.[$k] // empty' + else + python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" + fi +} + +# do_login runs the device flow but renders the verification URL as a terminal +# QR code before polling. Scanning with your own Feishu app avoids authorizing +# as whichever account the default browser happens to be signed in as — the +# usual cause of "用户登录态无效 (20033)" on a shared machine. +do_login() { + local json url code + json="$("$BIN" auth login --recommend --no-wait --json)" + url="$(printf '%s' "$json" | json_field verification_url)" + code="$(printf '%s' "$json" | json_field device_code)" + + if [ -z "$url" ] || [ -z "$code" ]; then + echo "无法解析登录信息,原始输出:" >&2 + printf '%s\n' "$json" >&2 + return 1 + fi + + echo "" >&2 + echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 + echo "" >&2 + "$BIN" auth qrcode --ascii "$url" + echo "" >&2 + echo "扫不了的话再用链接:$url" >&2 + echo "" >&2 + + "$BIN" auth login --device-code "$code" +} + +require_bin() { + [ -x "$BIN" ] || { + echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 + exit 1 + } +} + +# ---------- 入口 ---------- +[ $# -ge 1 ] || usage 1 + +case "$1" in +setup) + do_setup + ;; +qr) + # Renders the device-flow URL as a terminal QR code. The CLI never opens a + # browser itself, so scanning with your own Feishu app is the way to avoid + # authorizing as whichever account the default browser happens to hold. + [ $# -eq 2 ] || { + echo "用法: larkenv qr <登录URL>" >&2 + exit 1 + } + require_bin + exec "$BIN" auth qrcode --ascii "$2" + ;; +init) + # With an app id, config init runs fully non-interactively — no app-selection + # prompt at all. boe/pre can reuse the online app id, so there is usually no + # reason to register a separate app per environment. + case $# in + 2 | 3) ;; + *) + echo "用法: larkenv init [app-id]" >&2 + exit 1 + ;; + esac + require_bin + apply_control_env "$2" + + if [ $# -eq 3 ]; then + printf 'App Secret (输入不回显): ' >&2 + stty -echo 2>/dev/null || true + read -r secret + stty echo 2>/dev/null || true + printf '\n' >&2 + # --app-secret-stdin keeps the secret out of the process list. + printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin + unset secret + else + echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 + "$BIN" config init + fi + + # 固定成 user 身份,之后所有命令都不用再带 --as user。 + "$BIN" config default-as user + do_login + ;; +login) + # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 + [ $# -eq 2 ] || { + echo "用法: larkenv login " >&2 + exit 1 + } + require_bin + apply_control_env "$2" + do_login + ;; +boe | pre | ppe | use-pre | online) + require_bin + env_name="$1" + shift + if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then + apply_ppe_control_env + else + apply_env "$env_name" + fi + exec "$BIN" "$@" + ;; +-h | --help | help) + usage 0 + ;; +*) + echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 + exit 1 + ;; +esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 881aba3c80..96120ed6bd 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,6 +59,11 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } + for k, vs := range envvars.ExtraHeaders() { + for _, v := range vs { + h.Set(k, v) + } + } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index 13ad93b442..c6f55e1d6f 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,6 +262,15 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } +func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { + t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") + + h := BaseSecurityHeaders() + if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } +} + // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 4f8803cd4f..86abaa2847 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,6 +6,7 @@ package core import ( "context" "net/url" + "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -129,22 +130,40 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { + var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } + if domain := endpointDomainOverride(); domain != "" { + endpoints.Open = "https://open." + domain + endpoints.Accounts = "https://accounts." + domain + endpoints.MCP = "https://mcp." + domain + endpoints.AppLink = "https://applink." + domain + } + return endpoints +} + +func endpointDomainOverride() string { + value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) + if value == "" || + strings.Contains(value, "://") || + strings.ContainsAny(value, "/?#@") { + return "" + } + return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 23883493c7..259c21a57c 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,6 +8,8 @@ import ( "net/url" "reflect" "testing" + + "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -66,6 +68,33 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } +func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu-boe.cn" { + t.Errorf("Open = %q, want BOE open endpoint", ep.Open) + } + if ep.Accounts != "https://accounts.feishu-boe.cn" { + t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) + } + if ep.MCP != "https://mcp.feishu-boe.cn" { + t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) + } + if ep.AppLink != "https://applink.feishu-boe.cn" { + t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) + } +} + +func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) + } +} + func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 81b5d4eed6..32c9d5fb4f 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,4 +27,8 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" + + // Internal environment routing used by repo-local dev wrappers. + CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" + CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 34868386cd..90fb6adf63 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,6 +4,7 @@ package envvars import ( + "net/http" "os" "strings" "unicode" @@ -22,6 +23,26 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } +func ExtraHeaders() http.Header { + headers := make(http.Header) + for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { + name, value, ok := strings.Cut(item, ":") + if !ok { + continue + } + name = strings.TrimSpace(name) + value = sanitizeSingleLine(value, agentNameMaxLen) + if name == "" || value == "" || !validHeaderName(name) { + continue + } + headers.Set(name, value) + } + if len(headers) == 0 { + return nil + } + return headers +} + func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -34,3 +55,17 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } + +func validHeaderName(name string) bool { + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '-': + default: + return false + } + } + return true +} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index cf216e90a2..ef09531289 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,3 +131,30 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } + +func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") + + headers := ExtraHeaders() + if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } + if got := headers.Get("x-use-ppe"); got != "1" { + t.Fatalf("x-use-ppe = %q, want 1", got) + } +} + +func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") + + headers := ExtraHeaders() + if got := headers.Get("X-Good"); got != "ok" { + t.Fatalf("X-Good = %q, want ok", got) + } + if got := headers.Get("Bad Header"); got != "" { + t.Fatalf("Bad Header = %q, want rejected", got) + } + if got := headers.Get("X-Evil"); got != "" { + t.Fatalf("X-Evil = %q, want rejected", got) + } +} From 3ff4d5dd64ee0e222e79f022a609664eed66ee20 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 14:04:54 +0800 Subject: [PATCH 19/33] fix: CR Comment --- shortcuts/base/workflow_update.go | 1 + 1 file changed, 1 insertion(+) diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index ec4494f487..f367f0720f 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -24,6 +24,7 @@ var BaseWorkflowUpdate = common.Shortcut{ }, Tips: []string{ "lark-cli base +workflow-update --base-token --workflow-id --json @workflow.json", + "PUT uses full replacement semantics; omitting steps clears the existing workflow steps.", "Use +workflow-get first, then edit the returned definition and keep title/status/steps fields you do not intend to change.", "workflow-id must start with wkf; do not pass a tbl table ID.", "Step ids must be unique, and every next/children link must reference an existing step id.", From d57b55f756479ea10d9b21e5be488bb6ec93081c Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 14:05:00 +0800 Subject: [PATCH 20/33] Revert "feat: support development environment overrides" This reverts commit cef8f78dc64d4e0852cd9fe2fd7e67765f8e70e8. --- .agents/skills/lark-approval | 1 - .agents/skills/lark-apps | 1 - .agents/skills/lark-attendance | 1 - .agents/skills/lark-base | 1 - .agents/skills/lark-calendar | 1 - .agents/skills/lark-contact | 1 - .agents/skills/lark-doc | 1 - .agents/skills/lark-drive | 1 - .agents/skills/lark-event | 1 - .agents/skills/lark-im | 1 - .agents/skills/lark-mail | 1 - .agents/skills/lark-markdown | 1 - .agents/skills/lark-minutes | 1 - .agents/skills/lark-note | 1 - .agents/skills/lark-okr | 1 - .agents/skills/lark-openapi-explorer | 1 - .agents/skills/lark-shared | 1 - .agents/skills/lark-sheets | 1 - .agents/skills/lark-skill-maker | 1 - .agents/skills/lark-slides | 1 - .agents/skills/lark-task | 1 - .agents/skills/lark-vc | 1 - .agents/skills/lark-vc-agent | 1 - .agents/skills/lark-whiteboard | 1 - .agents/skills/lark-wiki | 1 - .agents/skills/lark-workflow-meeting-summary | 1 - .agents/skills/lark-workflow-standup-report | 1 - .gitignore | 1 - env/claude-dev-lark.sh | 256 ------------- env/codex-dev-lark.sh | 362 ------------------- env/larkenv | 353 ------------------ internal/cmdutil/secheader.go | 5 - internal/cmdutil/secheader_test.go | 9 - internal/core/types.go | 23 +- internal/core/types_test.go | 29 -- internal/envvars/envvars.go | 4 - internal/envvars/read.go | 35 -- internal/envvars/read_test.go | 27 -- 38 files changed, 2 insertions(+), 1129 deletions(-) delete mode 120000 .agents/skills/lark-approval delete mode 120000 .agents/skills/lark-apps delete mode 120000 .agents/skills/lark-attendance delete mode 120000 .agents/skills/lark-base delete mode 120000 .agents/skills/lark-calendar delete mode 120000 .agents/skills/lark-contact delete mode 120000 .agents/skills/lark-doc delete mode 120000 .agents/skills/lark-drive delete mode 120000 .agents/skills/lark-event delete mode 120000 .agents/skills/lark-im delete mode 120000 .agents/skills/lark-mail delete mode 120000 .agents/skills/lark-markdown delete mode 120000 .agents/skills/lark-minutes delete mode 120000 .agents/skills/lark-note delete mode 120000 .agents/skills/lark-okr delete mode 120000 .agents/skills/lark-openapi-explorer delete mode 120000 .agents/skills/lark-shared delete mode 120000 .agents/skills/lark-sheets delete mode 120000 .agents/skills/lark-skill-maker delete mode 120000 .agents/skills/lark-slides delete mode 120000 .agents/skills/lark-task delete mode 120000 .agents/skills/lark-vc delete mode 120000 .agents/skills/lark-vc-agent delete mode 120000 .agents/skills/lark-whiteboard delete mode 120000 .agents/skills/lark-wiki delete mode 120000 .agents/skills/lark-workflow-meeting-summary delete mode 120000 .agents/skills/lark-workflow-standup-report delete mode 100755 env/claude-dev-lark.sh delete mode 100755 env/codex-dev-lark.sh delete mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval deleted file mode 120000 index be32751967..0000000000 --- a/.agents/skills/lark-approval +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps deleted file mode 120000 index 326837d904..0000000000 --- a/.agents/skills/lark-apps +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance deleted file mode 120000 index cea17c567b..0000000000 --- a/.agents/skills/lark-attendance +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base deleted file mode 120000 index 480c521328..0000000000 --- a/.agents/skills/lark-base +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar deleted file mode 120000 index 0e067fb7c4..0000000000 --- a/.agents/skills/lark-calendar +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact deleted file mode 120000 index c4b523b6f7..0000000000 --- a/.agents/skills/lark-contact +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc deleted file mode 120000 index 36be178ac6..0000000000 --- a/.agents/skills/lark-doc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive deleted file mode 120000 index b90475836f..0000000000 --- a/.agents/skills/lark-drive +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event deleted file mode 120000 index 0edcd85ff3..0000000000 --- a/.agents/skills/lark-event +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im deleted file mode 120000 index 0083317e7e..0000000000 --- a/.agents/skills/lark-im +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail deleted file mode 120000 index 1dc9d46966..0000000000 --- a/.agents/skills/lark-mail +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown deleted file mode 120000 index 780a87258d..0000000000 --- a/.agents/skills/lark-markdown +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes deleted file mode 120000 index f39545a341..0000000000 --- a/.agents/skills/lark-minutes +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note deleted file mode 120000 index 64b27c3543..0000000000 --- a/.agents/skills/lark-note +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr deleted file mode 120000 index c0b5104fe3..0000000000 --- a/.agents/skills/lark-okr +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer deleted file mode 120000 index 0357b2ae30..0000000000 --- a/.agents/skills/lark-openapi-explorer +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared deleted file mode 120000 index 90db01f71f..0000000000 --- a/.agents/skills/lark-shared +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets deleted file mode 120000 index 7a4bb8f954..0000000000 --- a/.agents/skills/lark-sheets +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker deleted file mode 120000 index 20a8485a86..0000000000 --- a/.agents/skills/lark-skill-maker +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides deleted file mode 120000 index a7b448b3a3..0000000000 --- a/.agents/skills/lark-slides +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task deleted file mode 120000 index 40893742c9..0000000000 --- a/.agents/skills/lark-task +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc deleted file mode 120000 index 6e20cbcd71..0000000000 --- a/.agents/skills/lark-vc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent deleted file mode 120000 index 550b733c72..0000000000 --- a/.agents/skills/lark-vc-agent +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard deleted file mode 120000 index a3e187627c..0000000000 --- a/.agents/skills/lark-whiteboard +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki deleted file mode 120000 index aab2f46f80..0000000000 --- a/.agents/skills/lark-wiki +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary deleted file mode 120000 index 58081f1f58..0000000000 --- a/.agents/skills/lark-workflow-meeting-summary +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report deleted file mode 120000 index b9e06f0d2c..0000000000 --- a/.agents/skills/lark-workflow-standup-report +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index e0bbba695d..645848906d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,4 @@ app.log cover*.out lark-env.sh -.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh deleted file mode 100755 index dc6362de4f..0000000000 --- a/env/claude-dev-lark.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Claude Code in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.claude/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, or online. - Default: boe - --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/claude-dev-lark.sh - env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/claude-dev-lark.sh --skill lark-base - env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -claude_args=() -claude_arg_count=0 - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - claude_args=("$@") - claude_arg_count=$# - break - ;; - *) - claude_args+=("$1") - claude_arg_count=$((claude_arg_count + 1)) - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | online) ;; -*) die "--env must be one of: boe, pre, ppe, online" ;; -esac - -command -v claude >/dev/null 2>&1 || die "claude not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -# launch_claude starts Claude Code with the caller's environment inherited as -# is, including any proxy variables already exported by the shell. Network setup -# is left to the caller so this script stays portable. -# -# If your proxy is configured through a shell function (rather than exported -# variables), invoke this script through it, e.g. `my_proxy_wrapper -# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. -# -# lark-cli traffic is unaffected either way for boe: larkenv boe sets -# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe -# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn -# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". -launch_claude() { - exec claude --allow-dangerously-skip-permissions "$@" -} - -bin_dir="$repo_root/.claude-dev/bin" -skills_dir="$repo_root/.claude/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' - -# Only PPE needs the extra headers; every other target keeps the plain shim so -# the non-PPE path stays byte-identical to what it was before PPE support. -if [ "$use_ppe" -eq 1 ]; then - cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' - -for h in "x-use-ppe:1" "env:pre_release"; do - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $h;"*) ;; - *) - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$h" - fi - ;; - esac -done -PPE_SHIM -fi - -cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 - echo " identity: reuses the online app config and user login state" >&2 -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -export LARK_LANE="$lane" -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -# Claude has no "-C " flag like Codex; it uses the current working -# directory, which is $repo_root here (see the cd above). -if [ "$claude_arg_count" -eq 0 ]; then - launch_claude -fi - -launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh deleted file mode 100755 index dee647e9bd..0000000000 --- a/env/codex-dev-lark.sh +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Codex in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.agents/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE; ignored by --use-pre. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, use-pre, or online. - Default: boe - --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. - --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; - auth/config remain on production. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --cx Launch Codex with cx-style permissions: - codex --dangerously-bypass-approvals-and-sandbox - Adds --profile proxy when CODEX_PROXY_API_KEY is exported. - --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/codex-dev-lark.sh - CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh - env/codex-dev-lark.sh --cx - env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/codex-dev-lark.sh --use-pre - env/codex-dev-lark.sh --skill lark-base - env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -use_pre=0 -use_cx="${CODEX_DEV_LARK_CX:-0}" -codex_args=() - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --use-pre) - use_pre=1 - shift - ;; - --cx) - use_cx=1 - shift - ;; - --no-cx) - use_cx=0 - shift - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - codex_args=("$@") - break - ;; - *) - codex_args+=("$1") - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then - die "--use-ppe and --use-pre are mutually exclusive" -fi - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi -if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="use-pre" -fi -if [ "$use_pre" -eq 1 ]; then - case "$target_env" in - use-pre) ;; - *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | use-pre | online) ;; -*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; -esac - -use_pre_effective=0 -if [ "$target_env" = "use-pre" ]; then - use_pre_effective=1 -fi - -case "$use_cx" in -0 | 1) ;; -*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; -esac - -codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - else - codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - fi -fi - -command -v codex >/dev/null 2>&1 || die "codex not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -bin_dir="$repo_root/.codex-dev/bin" -skills_dir="$repo_root/.agents/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -shim_path="$bin_dir/lark-cli" -shim_tmp="$bin_dir/.lark-cli.$$.tmp" -cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -if [ "$use_pre_effective" -eq 1 ]; then - echo " lark-cli -> larkenv $target_env" >&2 -else - echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -fi -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 -fi -if [ "$use_pre_effective" -eq 1 ]; then - echo " extra headers: env:pre_release" >&2 - echo " omitted: x-use-ppe; X-TT-ENV" >&2 -fi -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 - else - echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 - fi -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -if [ "$use_pre_effective" -eq 1 ]; then - unset LARK_LANE - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" -else - export LARK_LANE="$lane" -fi -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -elif [ "$use_pre_effective" -eq 1 ]; then - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv deleted file mode 100755 index 5054dba2ac..0000000000 --- a/env/larkenv +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 -# -# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) -# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 -# larkenv init boe 同上,但走交互式选 app -# larkenv login boe 只补登录(app 已配好时用),自动出二维码 -# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 -# larkenv boe <任何 lark-cli 命令> -# larkenv pre <任何 lark-cli 命令> -# larkenv ppe <任何 lark-cli 命令> -# larkenv use-pre <任何 lark-cli 命令> -# larkenv online <任何 lark-cli 命令> -# -# 可选: -# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) -# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) -# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) -# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body -# -# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 -# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre -# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config -# 控制面仍使用正式 endpoint。 -set -euo pipefail - -BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" -CONFIG_ROOT="$HOME/.lark-cli-env" -BIN="$BIN_DIR/lark-cli-env" - -usage() { - # Print the header comment block (from the title line to the first - # non-comment line), stripped of its leading "# ". - awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" - exit "${1:-0}" -} - -# ---------- setup:编译 + 安装 + 配 PATH ---------- -do_setup() { - local repo_root - repo_root="$(cd "$(dirname "$0")/.." && pwd)" - - echo "==> 编译 lark-cli ..." - (cd "$repo_root" && ./build.sh) - - echo "==> 安装到 $BIN_DIR ..." - mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" - cp "$repo_root/lark-cli" "$BIN" - cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" - chmod +x "$BIN" "$BIN_DIR/larkenv" - - # 幂等地把 BIN_DIR 加进 shell rc - local rc marker='# added by lark-cli env/larkenv' - case "${SHELL##*/}" in - zsh) rc="$HOME/.zshrc" ;; - bash) rc="$HOME/.bashrc" ;; - *) rc="" ;; - esac - if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then - printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" - echo "==> 已把 $BIN_DIR 写入 $rc" - fi - - # NOTE: brace the expansions below — the surrounding full-width punctuation - # would otherwise be swallowed into the variable name by bash. - # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. - local reload="新开一个终端" - if [ -n "$rc" ]; then - reload="新开一个终端(或 source ${rc})" - fi - - cat < # 其他环境复用同一个 app,不用重配 - larkenv login boe # 登录态失效时,只补登录 - - larkenv boe base +table-list --base-token - larkenv pre wiki +node-get --token - larkenv ppe drive +search --query xxx - larkenv use-pre drive +search --query xxx - larkenv online drive +search --query xxx - -EOF -} - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header() { - local target="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - [ "$item" = "$target" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -# PPE reuses the production app config and user token, so authentication and -# configuration must stay on the production control plane. The launcher may -# already have injected PPE routing into the parent environment; remove only -# those routing values while preserving unrelated caller-supplied headers. -apply_ppe_control_env() { - unset LARKSUITE_CLI_CONFIG_DIR - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - remove_extra_header_name "x-use-ppe" - remove_extra_header "env:pre_release" - remove_extra_header_name "x-tt-env" - unset LARK_LANE -} - -# ---------- 环境变量注入 ---------- -apply_env() { - # Normalize values that may have been inherited from a previously selected - # environment before applying the requested target. - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 - # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 - unset LARKSUITE_CLI_CONFIG_DIR - else - export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" - remove_extra_header "x-use-ppe:1" - remove_extra_header "env:pre_release" - fi - case "$1" in - boe) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" - # boe 是内网域名,必须绕开公司外部 relay 代理 - export LARK_CLI_NO_PROXY=1 - ;; - pre) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - ;; - ppe) - # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers - # 选择 pre_release 环境和目标泳道。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" - ;; - use-pre) - # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 - # x-use-ppe 或泳道头。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" - unset LARK_LANE - append_extra_header "env:pre_release" - ;; - online) - # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 - ;; - esac - # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS - # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. - if [ -n "${LARK_LANE:-}" ]; then - append_extra_header "X-TT-ENV: $LARK_LANE" - fi - if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then - mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" - fi -} - -apply_control_env() { - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - apply_ppe_control_env - else - apply_env "$1" - fi -} - -# json_field extracts a top-level string field from a JSON object on stdin. -json_field() { - if command -v jq >/dev/null 2>&1; then - jq -r --arg k "$1" '.[$k] // empty' - else - python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" - fi -} - -# do_login runs the device flow but renders the verification URL as a terminal -# QR code before polling. Scanning with your own Feishu app avoids authorizing -# as whichever account the default browser happens to be signed in as — the -# usual cause of "用户登录态无效 (20033)" on a shared machine. -do_login() { - local json url code - json="$("$BIN" auth login --recommend --no-wait --json)" - url="$(printf '%s' "$json" | json_field verification_url)" - code="$(printf '%s' "$json" | json_field device_code)" - - if [ -z "$url" ] || [ -z "$code" ]; then - echo "无法解析登录信息,原始输出:" >&2 - printf '%s\n' "$json" >&2 - return 1 - fi - - echo "" >&2 - echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 - echo "" >&2 - "$BIN" auth qrcode --ascii "$url" - echo "" >&2 - echo "扫不了的话再用链接:$url" >&2 - echo "" >&2 - - "$BIN" auth login --device-code "$code" -} - -require_bin() { - [ -x "$BIN" ] || { - echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 - exit 1 - } -} - -# ---------- 入口 ---------- -[ $# -ge 1 ] || usage 1 - -case "$1" in -setup) - do_setup - ;; -qr) - # Renders the device-flow URL as a terminal QR code. The CLI never opens a - # browser itself, so scanning with your own Feishu app is the way to avoid - # authorizing as whichever account the default browser happens to hold. - [ $# -eq 2 ] || { - echo "用法: larkenv qr <登录URL>" >&2 - exit 1 - } - require_bin - exec "$BIN" auth qrcode --ascii "$2" - ;; -init) - # With an app id, config init runs fully non-interactively — no app-selection - # prompt at all. boe/pre can reuse the online app id, so there is usually no - # reason to register a separate app per environment. - case $# in - 2 | 3) ;; - *) - echo "用法: larkenv init [app-id]" >&2 - exit 1 - ;; - esac - require_bin - apply_control_env "$2" - - if [ $# -eq 3 ]; then - printf 'App Secret (输入不回显): ' >&2 - stty -echo 2>/dev/null || true - read -r secret - stty echo 2>/dev/null || true - printf '\n' >&2 - # --app-secret-stdin keeps the secret out of the process list. - printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin - unset secret - else - echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 - "$BIN" config init - fi - - # 固定成 user 身份,之后所有命令都不用再带 --as user。 - "$BIN" config default-as user - do_login - ;; -login) - # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 - [ $# -eq 2 ] || { - echo "用法: larkenv login " >&2 - exit 1 - } - require_bin - apply_control_env "$2" - do_login - ;; -boe | pre | ppe | use-pre | online) - require_bin - env_name="$1" - shift - if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then - apply_ppe_control_env - else - apply_env "$env_name" - fi - exec "$BIN" "$@" - ;; --h | --help | help) - usage 0 - ;; -*) - echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 - exit 1 - ;; -esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 96120ed6bd..881aba3c80 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,11 +59,6 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } - for k, vs := range envvars.ExtraHeaders() { - for _, v := range vs { - h.Set(k, v) - } - } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index c6f55e1d6f..13ad93b442 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,15 +262,6 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } -func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { - t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") - - h := BaseSecurityHeaders() - if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } -} - // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 86abaa2847..4f8803cd4f 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,7 +6,6 @@ package core import ( "context" "net/url" - "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -130,40 +129,22 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { - var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } - if domain := endpointDomainOverride(); domain != "" { - endpoints.Open = "https://open." + domain - endpoints.Accounts = "https://accounts." + domain - endpoints.MCP = "https://mcp." + domain - endpoints.AppLink = "https://applink." + domain - } - return endpoints -} - -func endpointDomainOverride() string { - value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) - if value == "" || - strings.Contains(value, "://") || - strings.ContainsAny(value, "/?#@") { - return "" - } - return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 259c21a57c..23883493c7 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,8 +8,6 @@ import ( "net/url" "reflect" "testing" - - "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -68,33 +66,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } -func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu-boe.cn" { - t.Errorf("Open = %q, want BOE open endpoint", ep.Open) - } - if ep.Accounts != "https://accounts.feishu-boe.cn" { - t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) - } - if ep.MCP != "https://mcp.feishu-boe.cn" { - t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) - } - if ep.AppLink != "https://applink.feishu-boe.cn" { - t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) - } -} - -func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu.cn" { - t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) - } -} - func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 32c9d5fb4f..81b5d4eed6 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,8 +27,4 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" - - // Internal environment routing used by repo-local dev wrappers. - CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" - CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 90fb6adf63..34868386cd 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,7 +4,6 @@ package envvars import ( - "net/http" "os" "strings" "unicode" @@ -23,26 +22,6 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } -func ExtraHeaders() http.Header { - headers := make(http.Header) - for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { - name, value, ok := strings.Cut(item, ":") - if !ok { - continue - } - name = strings.TrimSpace(name) - value = sanitizeSingleLine(value, agentNameMaxLen) - if name == "" || value == "" || !validHeaderName(name) { - continue - } - headers.Set(name, value) - } - if len(headers) == 0 { - return nil - } - return headers -} - func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -55,17 +34,3 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } - -func validHeaderName(name string) bool { - for _, r := range name { - switch { - case r >= 'a' && r <= 'z': - case r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9': - case r == '-': - default: - return false - } - } - return true -} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index ef09531289..cf216e90a2 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,30 +131,3 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } - -func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") - - headers := ExtraHeaders() - if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } - if got := headers.Get("x-use-ppe"); got != "1" { - t.Fatalf("x-use-ppe = %q, want 1", got) - } -} - -func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") - - headers := ExtraHeaders() - if got := headers.Get("X-Good"); got != "ok" { - t.Fatalf("X-Good = %q, want ok", got) - } - if got := headers.Get("Bad Header"); got != "" { - t.Fatalf("Bad Header = %q, want rejected", got) - } - if got := headers.Get("X-Evil"); got != "" { - t.Fatalf("X-Evil = %q, want rejected", got) - } -} From 087cf41ed5d4a9c3b5bda0ed391fe247d530617f Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 14:06:16 +0800 Subject: [PATCH 21/33] fix: polish skill --- .../references/lark-base-workflow.md | 49 +------------------ 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 7b840caf5c..8f27cce11e 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -55,13 +55,12 @@ | 场景 | 步骤组合 | 示例 | |------|---------|------| | 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例-1-新增记录触发--发送消息) | -| 定时触发+AI 分析 | TimerTrigger → AIAnalysisAction | [下方](#示例-7-ai-分析定时分析-base-数据并回读核验) | | 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) | | 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例-2-定时触发--查找记录--循环遍历--发送消息) | | 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例-3-条件分支ifelsebranch) | | 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例-4-多路分支switchbranch) | -| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例-8-ai-分类用户反馈自动分流) | | 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例-5-组合场景定时查找循环分支消息) | +| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例-7-ai-分类用户反馈自动分流) | --- @@ -743,51 +742,7 @@ --- -### 示例 7: AI 分析(定时分析 Base 数据) - -**场景**: 每天早上 9 点分析「订单表」和「退款表」的昨日趋势。 - -```json -{ - "client_token": "1704067200-ai-analysis", - "title": "每日经营 AI 分析", - "steps": [ - { - "id": "step_timer", - "type": "TimerTrigger", - "title": "每天早上 9 点触发", - "next": "step_ai_analysis", - "data": { - "rule": "DAILY", - "start_time": "2025-01-01 09:00", - "is_never_end": true - } - }, - { - "id": "step_ai_analysis", - "type": "AIAnalysisAction", - "title": "分析昨日经营异常", - "next": null, - "data": { - "analysis_task": [ - { "value_type": "text", "value": "分析昨日订单趋势、退款异常和可能原因,并给出今日行动建议" } - ], - "analysis_table_names": ["订单表", "退款表"], - "identity_type": "maker", - "output_instruction": "先给结论,再列证据与行动建议" - } - } - ] -} -``` - -**关键点**: -- `analysis_table_names: []` 表示当前 Base 的全部数据表;若要限制范围,请显式列出表名。 -- `identity_type: "maker"` 表示固定流程身份;`identity_type: "triggerPersonal"` 仅适用于能提供真实触发者身份的触发器。 - ---- - -### 示例 8: AI 分类(用户反馈自动分流) +### 示例 7: AI 分类(用户反馈自动分流) **场景**: 当用户反馈表新增记录时,AI 根据反馈内容分类为 Bug、功能建议或体验问题;无法判断时进入其他分支并通知人工复核。 From 403d8a92c34f5e661a6d3e326d55096b142a7750 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 14:27:18 +0800 Subject: [PATCH 22/33] fix: compress skill --- .../references/lark-base-workflow-schema.md | 1 + .../references/lark-base-workflow.md | 52 ++----------------- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index 758c6ad7dc..a4bb6a7cc6 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -611,6 +611,7 @@ - 每个分类命中后要跳到哪个后续步骤,必须写在 children.links 中。 - 普通分类边使用 `kind: "case"` 和 `label: "branch_1"`、`branch_2` 等稳定标签;`desc` 与 `classes[i].name` 保持一致;`to` 指向该分类的入口 step。 - `no_match_action: "classifyToOther"` 时必须额外提供一条默认分支边:`{ "kind": "case", "label": "default", "desc": "默认分支", "to": "step_other_action" }`。 +- `no_match_action: "fail"` 时不要提供默认分支边。 ## System data 详细结构 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 8f27cce11e..67085ba3a1 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -744,7 +744,7 @@ ### 示例 7: AI 分类(用户反馈自动分流) -**场景**: 当用户反馈表新增记录时,AI 根据反馈内容分类为 Bug、功能建议或体验问题;无法判断时进入其他分支并通知人工复核。 +**场景**: 当用户反馈表新增记录时,AI 根据反馈内容分类为 Bug 或功能建议;无法判断时标记为待人工复核。 ```json { @@ -769,7 +769,6 @@ "links": [ { "kind": "case", "to": "step_bug_action", "label": "branch_1", "desc": "Bug" }, { "kind": "case", "to": "step_feature_action", "label": "branch_2", "desc": "功能建议" }, - { "kind": "case", "to": "step_experience_action", "label": "branch_3", "desc": "体验问题" }, { "kind": "case", "to": "step_other_action", "label": "default", "desc": "默认分支" } ] }, @@ -783,16 +782,9 @@ { "name": "功能建议", "desc": "希望新增能力或改变产品行为" - }, - { - "name": "体验问题", - "desc": "流程繁琐、操作难懂、性能慢或界面体验不佳" } ], "content": [ - { "value_type": "text", "value": "请根据反馈标题和反馈详情判断类型:" }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedbackTitle" }, - { "value_type": "text", "value": " " }, { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } ], "classification_rule": "有明确故障现象时优先归入 Bug;同时包含多个诉求时,以最影响用户完成任务的问题为准;信息不足时进入默认分支。" @@ -825,57 +817,23 @@ } }, { - "id": "step_experience_action", + "id": "step_other_action", "type": "SetRecordAction", - "title": "标记为体验问题", + "title": "标记为待人工复核", "next": null, "data": { "table_name": "用户反馈表", "ref_info": { "step_id": "step_trigger" }, "field_values": [ - { "field_name": "分类", "value": [{ "value_type": "text", "value": "体验问题" }] } + { "field_name": "分类", "value": [{ "value_type": "text", "value": "待人工复核" }] } ] } - }, - { - "id": "step_other_action", - "type": "LarkMessageAction", - "title": "通知人工复核", - "next": null, - "data": { - "receiver": [{ "value_type": "user", "value": { "id": "ou_xxxx", "name": "负责人" } }], - "send_to_everyone": false, - "title": [{ "value_type": "text", "value": "反馈需要人工复核" }], - "content": [ - { "value_type": "text", "value": "AI 未能确定反馈分类,请人工确认:" }, - { "value_type": "ref", "value": "$.step_trigger.fldFeedbackDetail" } - ], - "btn_list": [] - } } ] } ``` -关键点: -- `AIClassificationBranch.data` 使用公开 Agent Data:`classes`、`content`、`classification_rule`、`no_match_action`。 -- `AIClassificationBranch.children.links` 使用 `kind: "case"`;普通分类使用 `branch_1`、`branch_2` 等标签,默认分支使用 `label: "default"`。 -- `classes[i].name` 与对应普通分支 `children.links[i].desc` 保持一致;缺省或 `no_match_action: "classifyToOther"` 时必须提供默认分支。 - -**`no_match_action: "fail"` 差异**:当没有匹配分类时需要让当前节点失败,只修改以下部分;普通分类边和 `classes` / `content` 保持不变。 - -```diff - "data": { -+ "no_match_action": "fail" - } - "children": { - "links": [ -- { "kind": "case", "to": "step_other_action", "label": "default", "desc": "默认分支" } - ] - } -``` - -如果 `step_other_action` 仅由默认分支引用,应同时从 `steps` 中删除该节点。 +关键点:`classes` 按顺序对应 `branch_1`、`branch_2`;`desc` 与分类名一致,`to` 指向已定义的下游 step;缺省无匹配策略使用 `default` 边。 --- From a7d7223db9738368f612bd97dd0d52979f886d80 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 19 Aug 2026 11:03:50 +0800 Subject: [PATCH 23/33] feat: support development environment overrides --- .agents/skills/lark-approval | 1 + .agents/skills/lark-apps | 1 + .agents/skills/lark-attendance | 1 + .agents/skills/lark-base | 1 + .agents/skills/lark-calendar | 1 + .agents/skills/lark-contact | 1 + .agents/skills/lark-doc | 1 + .agents/skills/lark-drive | 1 + .agents/skills/lark-event | 1 + .agents/skills/lark-im | 1 + .agents/skills/lark-mail | 1 + .agents/skills/lark-markdown | 1 + .agents/skills/lark-minutes | 1 + .agents/skills/lark-note | 1 + .agents/skills/lark-okr | 1 + .agents/skills/lark-openapi-explorer | 1 + .agents/skills/lark-shared | 1 + .agents/skills/lark-sheets | 1 + .agents/skills/lark-skill-maker | 1 + .agents/skills/lark-slides | 1 + .agents/skills/lark-task | 1 + .agents/skills/lark-vc | 1 + .agents/skills/lark-vc-agent | 1 + .agents/skills/lark-whiteboard | 1 + .agents/skills/lark-wiki | 1 + .agents/skills/lark-workflow-meeting-summary | 1 + .agents/skills/lark-workflow-standup-report | 1 + .gitignore | 1 + env/claude-dev-lark.sh | 256 +++++++++++++ env/codex-dev-lark.sh | 362 +++++++++++++++++++ env/larkenv | 353 ++++++++++++++++++ internal/cmdutil/secheader.go | 5 + internal/cmdutil/secheader_test.go | 9 + internal/core/types.go | 23 +- internal/core/types_test.go | 29 ++ internal/envvars/envvars.go | 4 + internal/envvars/read.go | 35 ++ internal/envvars/read_test.go | 27 ++ 38 files changed, 1129 insertions(+), 2 deletions(-) create mode 120000 .agents/skills/lark-approval create mode 120000 .agents/skills/lark-apps create mode 120000 .agents/skills/lark-attendance create mode 120000 .agents/skills/lark-base create mode 120000 .agents/skills/lark-calendar create mode 120000 .agents/skills/lark-contact create mode 120000 .agents/skills/lark-doc create mode 120000 .agents/skills/lark-drive create mode 120000 .agents/skills/lark-event create mode 120000 .agents/skills/lark-im create mode 120000 .agents/skills/lark-mail create mode 120000 .agents/skills/lark-markdown create mode 120000 .agents/skills/lark-minutes create mode 120000 .agents/skills/lark-note create mode 120000 .agents/skills/lark-okr create mode 120000 .agents/skills/lark-openapi-explorer create mode 120000 .agents/skills/lark-shared create mode 120000 .agents/skills/lark-sheets create mode 120000 .agents/skills/lark-skill-maker create mode 120000 .agents/skills/lark-slides create mode 120000 .agents/skills/lark-task create mode 120000 .agents/skills/lark-vc create mode 120000 .agents/skills/lark-vc-agent create mode 120000 .agents/skills/lark-whiteboard create mode 120000 .agents/skills/lark-wiki create mode 120000 .agents/skills/lark-workflow-meeting-summary create mode 120000 .agents/skills/lark-workflow-standup-report create mode 100755 env/claude-dev-lark.sh create mode 100755 env/codex-dev-lark.sh create mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval new file mode 120000 index 0000000000..be32751967 --- /dev/null +++ b/.agents/skills/lark-approval @@ -0,0 +1 @@ +../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps new file mode 120000 index 0000000000..326837d904 --- /dev/null +++ b/.agents/skills/lark-apps @@ -0,0 +1 @@ +../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance new file mode 120000 index 0000000000..cea17c567b --- /dev/null +++ b/.agents/skills/lark-attendance @@ -0,0 +1 @@ +../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base new file mode 120000 index 0000000000..480c521328 --- /dev/null +++ b/.agents/skills/lark-base @@ -0,0 +1 @@ +../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar new file mode 120000 index 0000000000..0e067fb7c4 --- /dev/null +++ b/.agents/skills/lark-calendar @@ -0,0 +1 @@ +../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact new file mode 120000 index 0000000000..c4b523b6f7 --- /dev/null +++ b/.agents/skills/lark-contact @@ -0,0 +1 @@ +../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc new file mode 120000 index 0000000000..36be178ac6 --- /dev/null +++ b/.agents/skills/lark-doc @@ -0,0 +1 @@ +../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive new file mode 120000 index 0000000000..b90475836f --- /dev/null +++ b/.agents/skills/lark-drive @@ -0,0 +1 @@ +../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event new file mode 120000 index 0000000000..0edcd85ff3 --- /dev/null +++ b/.agents/skills/lark-event @@ -0,0 +1 @@ +../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im new file mode 120000 index 0000000000..0083317e7e --- /dev/null +++ b/.agents/skills/lark-im @@ -0,0 +1 @@ +../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail new file mode 120000 index 0000000000..1dc9d46966 --- /dev/null +++ b/.agents/skills/lark-mail @@ -0,0 +1 @@ +../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown new file mode 120000 index 0000000000..780a87258d --- /dev/null +++ b/.agents/skills/lark-markdown @@ -0,0 +1 @@ +../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes new file mode 120000 index 0000000000..f39545a341 --- /dev/null +++ b/.agents/skills/lark-minutes @@ -0,0 +1 @@ +../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note new file mode 120000 index 0000000000..64b27c3543 --- /dev/null +++ b/.agents/skills/lark-note @@ -0,0 +1 @@ +../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr new file mode 120000 index 0000000000..c0b5104fe3 --- /dev/null +++ b/.agents/skills/lark-okr @@ -0,0 +1 @@ +../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer new file mode 120000 index 0000000000..0357b2ae30 --- /dev/null +++ b/.agents/skills/lark-openapi-explorer @@ -0,0 +1 @@ +../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared new file mode 120000 index 0000000000..90db01f71f --- /dev/null +++ b/.agents/skills/lark-shared @@ -0,0 +1 @@ +../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets new file mode 120000 index 0000000000..7a4bb8f954 --- /dev/null +++ b/.agents/skills/lark-sheets @@ -0,0 +1 @@ +../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker new file mode 120000 index 0000000000..20a8485a86 --- /dev/null +++ b/.agents/skills/lark-skill-maker @@ -0,0 +1 @@ +../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides new file mode 120000 index 0000000000..a7b448b3a3 --- /dev/null +++ b/.agents/skills/lark-slides @@ -0,0 +1 @@ +../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task new file mode 120000 index 0000000000..40893742c9 --- /dev/null +++ b/.agents/skills/lark-task @@ -0,0 +1 @@ +../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc new file mode 120000 index 0000000000..6e20cbcd71 --- /dev/null +++ b/.agents/skills/lark-vc @@ -0,0 +1 @@ +../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent new file mode 120000 index 0000000000..550b733c72 --- /dev/null +++ b/.agents/skills/lark-vc-agent @@ -0,0 +1 @@ +../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard new file mode 120000 index 0000000000..a3e187627c --- /dev/null +++ b/.agents/skills/lark-whiteboard @@ -0,0 +1 @@ +../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki new file mode 120000 index 0000000000..aab2f46f80 --- /dev/null +++ b/.agents/skills/lark-wiki @@ -0,0 +1 @@ +../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary new file mode 120000 index 0000000000..58081f1f58 --- /dev/null +++ b/.agents/skills/lark-workflow-meeting-summary @@ -0,0 +1 @@ +../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report new file mode 120000 index 0000000000..b9e06f0d2c --- /dev/null +++ b/.agents/skills/lark-workflow-standup-report @@ -0,0 +1 @@ +../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index 645848906d..e0bbba695d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ app.log cover*.out lark-env.sh +.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh new file mode 100755 index 0000000000..dc6362de4f --- /dev/null +++ b/env/claude-dev-lark.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Claude Code in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.claude/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, or online. + Default: boe + --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/claude-dev-lark.sh + env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/claude-dev-lark.sh --skill lark-base + env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +claude_args=() +claude_arg_count=0 + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + claude_args=("$@") + claude_arg_count=$# + break + ;; + *) + claude_args+=("$1") + claude_arg_count=$((claude_arg_count + 1)) + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | online) ;; +*) die "--env must be one of: boe, pre, ppe, online" ;; +esac + +command -v claude >/dev/null 2>&1 || die "claude not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +# launch_claude starts Claude Code with the caller's environment inherited as +# is, including any proxy variables already exported by the shell. Network setup +# is left to the caller so this script stays portable. +# +# If your proxy is configured through a shell function (rather than exported +# variables), invoke this script through it, e.g. `my_proxy_wrapper +# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. +# +# lark-cli traffic is unaffected either way for boe: larkenv boe sets +# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe +# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn +# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". +launch_claude() { + exec claude --allow-dangerously-skip-permissions "$@" +} + +bin_dir="$repo_root/.claude-dev/bin" +skills_dir="$repo_root/.claude/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' + +# Only PPE needs the extra headers; every other target keeps the plain shim so +# the non-PPE path stays byte-identical to what it was before PPE support. +if [ "$use_ppe" -eq 1 ]; then + cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' + +for h in "x-use-ppe:1" "env:pre_release"; do + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $h;"*) ;; + *) + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$h" + fi + ;; + esac +done +PPE_SHIM +fi + +cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 + echo " identity: reuses the online app config and user login state" >&2 +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +export LARK_LANE="$lane" +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +# Claude has no "-C " flag like Codex; it uses the current working +# directory, which is $repo_root here (see the cd above). +if [ "$claude_arg_count" -eq 0 ]; then + launch_claude +fi + +launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh new file mode 100755 index 0000000000..dee647e9bd --- /dev/null +++ b/env/codex-dev-lark.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Codex in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.agents/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE; ignored by --use-pre. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, use-pre, or online. + Default: boe + --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. + --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; + auth/config remain on production. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --cx Launch Codex with cx-style permissions: + codex --dangerously-bypass-approvals-and-sandbox + Adds --profile proxy when CODEX_PROXY_API_KEY is exported. + --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/codex-dev-lark.sh + CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh + env/codex-dev-lark.sh --cx + env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/codex-dev-lark.sh --use-pre + env/codex-dev-lark.sh --skill lark-base + env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +use_pre=0 +use_cx="${CODEX_DEV_LARK_CX:-0}" +codex_args=() + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --use-pre) + use_pre=1 + shift + ;; + --cx) + use_cx=1 + shift + ;; + --no-cx) + use_cx=0 + shift + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + codex_args=("$@") + break + ;; + *) + codex_args+=("$1") + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then + die "--use-ppe and --use-pre are mutually exclusive" +fi + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi +if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="use-pre" +fi +if [ "$use_pre" -eq 1 ]; then + case "$target_env" in + use-pre) ;; + *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | use-pre | online) ;; +*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; +esac + +use_pre_effective=0 +if [ "$target_env" = "use-pre" ]; then + use_pre_effective=1 +fi + +case "$use_cx" in +0 | 1) ;; +*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; +esac + +codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + else + codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + fi +fi + +command -v codex >/dev/null 2>&1 || die "codex not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +bin_dir="$repo_root/.codex-dev/bin" +skills_dir="$repo_root/.agents/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +shim_path="$bin_dir/lark-cli" +shim_tmp="$bin_dir/.lark-cli.$$.tmp" +cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +if [ "$use_pre_effective" -eq 1 ]; then + echo " lark-cli -> larkenv $target_env" >&2 +else + echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +fi +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 +fi +if [ "$use_pre_effective" -eq 1 ]; then + echo " extra headers: env:pre_release" >&2 + echo " omitted: x-use-ppe; X-TT-ENV" >&2 +fi +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 + else + echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 + fi +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +if [ "$use_pre_effective" -eq 1 ]; then + unset LARK_LANE + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" +else + export LARK_LANE="$lane" +fi +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +elif [ "$use_pre_effective" -eq 1 ]; then + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv new file mode 100755 index 0000000000..5054dba2ac --- /dev/null +++ b/env/larkenv @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 +# +# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) +# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 +# larkenv init boe 同上,但走交互式选 app +# larkenv login boe 只补登录(app 已配好时用),自动出二维码 +# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 +# larkenv boe <任何 lark-cli 命令> +# larkenv pre <任何 lark-cli 命令> +# larkenv ppe <任何 lark-cli 命令> +# larkenv use-pre <任何 lark-cli 命令> +# larkenv online <任何 lark-cli 命令> +# +# 可选: +# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) +# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) +# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) +# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body +# +# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 +# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre +# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config +# 控制面仍使用正式 endpoint。 +set -euo pipefail + +BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" +CONFIG_ROOT="$HOME/.lark-cli-env" +BIN="$BIN_DIR/lark-cli-env" + +usage() { + # Print the header comment block (from the title line to the first + # non-comment line), stripped of its leading "# ". + awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" + exit "${1:-0}" +} + +# ---------- setup:编译 + 安装 + 配 PATH ---------- +do_setup() { + local repo_root + repo_root="$(cd "$(dirname "$0")/.." && pwd)" + + echo "==> 编译 lark-cli ..." + (cd "$repo_root" && ./build.sh) + + echo "==> 安装到 $BIN_DIR ..." + mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" + cp "$repo_root/lark-cli" "$BIN" + cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" + chmod +x "$BIN" "$BIN_DIR/larkenv" + + # 幂等地把 BIN_DIR 加进 shell rc + local rc marker='# added by lark-cli env/larkenv' + case "${SHELL##*/}" in + zsh) rc="$HOME/.zshrc" ;; + bash) rc="$HOME/.bashrc" ;; + *) rc="" ;; + esac + if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then + printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" + echo "==> 已把 $BIN_DIR 写入 $rc" + fi + + # NOTE: brace the expansions below — the surrounding full-width punctuation + # would otherwise be swallowed into the variable name by bash. + # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. + local reload="新开一个终端" + if [ -n "$rc" ]; then + reload="新开一个终端(或 source ${rc})" + fi + + cat < # 其他环境复用同一个 app,不用重配 + larkenv login boe # 登录态失效时,只补登录 + + larkenv boe base +table-list --base-token + larkenv pre wiki +node-get --token + larkenv ppe drive +search --query xxx + larkenv use-pre drive +search --query xxx + larkenv online drive +search --query xxx + +EOF +} + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header() { + local target="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + [ "$item" = "$target" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +# PPE reuses the production app config and user token, so authentication and +# configuration must stay on the production control plane. The launcher may +# already have injected PPE routing into the parent environment; remove only +# those routing values while preserving unrelated caller-supplied headers. +apply_ppe_control_env() { + unset LARKSUITE_CLI_CONFIG_DIR + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + remove_extra_header_name "x-use-ppe" + remove_extra_header "env:pre_release" + remove_extra_header_name "x-tt-env" + unset LARK_LANE +} + +# ---------- 环境变量注入 ---------- +apply_env() { + # Normalize values that may have been inherited from a previously selected + # environment before applying the requested target. + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 + # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 + unset LARKSUITE_CLI_CONFIG_DIR + else + export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" + remove_extra_header "x-use-ppe:1" + remove_extra_header "env:pre_release" + fi + case "$1" in + boe) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" + # boe 是内网域名,必须绕开公司外部 relay 代理 + export LARK_CLI_NO_PROXY=1 + ;; + pre) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + ;; + ppe) + # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers + # 选择 pre_release 环境和目标泳道。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" + ;; + use-pre) + # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 + # x-use-ppe 或泳道头。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" + unset LARK_LANE + append_extra_header "env:pre_release" + ;; + online) + # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 + ;; + esac + # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS + # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. + if [ -n "${LARK_LANE:-}" ]; then + append_extra_header "X-TT-ENV: $LARK_LANE" + fi + if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then + mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" + fi +} + +apply_control_env() { + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + apply_ppe_control_env + else + apply_env "$1" + fi +} + +# json_field extracts a top-level string field from a JSON object on stdin. +json_field() { + if command -v jq >/dev/null 2>&1; then + jq -r --arg k "$1" '.[$k] // empty' + else + python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" + fi +} + +# do_login runs the device flow but renders the verification URL as a terminal +# QR code before polling. Scanning with your own Feishu app avoids authorizing +# as whichever account the default browser happens to be signed in as — the +# usual cause of "用户登录态无效 (20033)" on a shared machine. +do_login() { + local json url code + json="$("$BIN" auth login --recommend --no-wait --json)" + url="$(printf '%s' "$json" | json_field verification_url)" + code="$(printf '%s' "$json" | json_field device_code)" + + if [ -z "$url" ] || [ -z "$code" ]; then + echo "无法解析登录信息,原始输出:" >&2 + printf '%s\n' "$json" >&2 + return 1 + fi + + echo "" >&2 + echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 + echo "" >&2 + "$BIN" auth qrcode --ascii "$url" + echo "" >&2 + echo "扫不了的话再用链接:$url" >&2 + echo "" >&2 + + "$BIN" auth login --device-code "$code" +} + +require_bin() { + [ -x "$BIN" ] || { + echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 + exit 1 + } +} + +# ---------- 入口 ---------- +[ $# -ge 1 ] || usage 1 + +case "$1" in +setup) + do_setup + ;; +qr) + # Renders the device-flow URL as a terminal QR code. The CLI never opens a + # browser itself, so scanning with your own Feishu app is the way to avoid + # authorizing as whichever account the default browser happens to hold. + [ $# -eq 2 ] || { + echo "用法: larkenv qr <登录URL>" >&2 + exit 1 + } + require_bin + exec "$BIN" auth qrcode --ascii "$2" + ;; +init) + # With an app id, config init runs fully non-interactively — no app-selection + # prompt at all. boe/pre can reuse the online app id, so there is usually no + # reason to register a separate app per environment. + case $# in + 2 | 3) ;; + *) + echo "用法: larkenv init [app-id]" >&2 + exit 1 + ;; + esac + require_bin + apply_control_env "$2" + + if [ $# -eq 3 ]; then + printf 'App Secret (输入不回显): ' >&2 + stty -echo 2>/dev/null || true + read -r secret + stty echo 2>/dev/null || true + printf '\n' >&2 + # --app-secret-stdin keeps the secret out of the process list. + printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin + unset secret + else + echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 + "$BIN" config init + fi + + # 固定成 user 身份,之后所有命令都不用再带 --as user。 + "$BIN" config default-as user + do_login + ;; +login) + # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 + [ $# -eq 2 ] || { + echo "用法: larkenv login " >&2 + exit 1 + } + require_bin + apply_control_env "$2" + do_login + ;; +boe | pre | ppe | use-pre | online) + require_bin + env_name="$1" + shift + if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then + apply_ppe_control_env + else + apply_env "$env_name" + fi + exec "$BIN" "$@" + ;; +-h | --help | help) + usage 0 + ;; +*) + echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 + exit 1 + ;; +esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 881aba3c80..96120ed6bd 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,6 +59,11 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } + for k, vs := range envvars.ExtraHeaders() { + for _, v := range vs { + h.Set(k, v) + } + } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index 13ad93b442..c6f55e1d6f 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,6 +262,15 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } +func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { + t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") + + h := BaseSecurityHeaders() + if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } +} + // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 4f8803cd4f..86abaa2847 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,6 +6,7 @@ package core import ( "context" "net/url" + "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -129,22 +130,40 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { + var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } + if domain := endpointDomainOverride(); domain != "" { + endpoints.Open = "https://open." + domain + endpoints.Accounts = "https://accounts." + domain + endpoints.MCP = "https://mcp." + domain + endpoints.AppLink = "https://applink." + domain + } + return endpoints +} + +func endpointDomainOverride() string { + value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) + if value == "" || + strings.Contains(value, "://") || + strings.ContainsAny(value, "/?#@") { + return "" + } + return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 23883493c7..259c21a57c 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,6 +8,8 @@ import ( "net/url" "reflect" "testing" + + "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -66,6 +68,33 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } +func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu-boe.cn" { + t.Errorf("Open = %q, want BOE open endpoint", ep.Open) + } + if ep.Accounts != "https://accounts.feishu-boe.cn" { + t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) + } + if ep.MCP != "https://mcp.feishu-boe.cn" { + t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) + } + if ep.AppLink != "https://applink.feishu-boe.cn" { + t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) + } +} + +func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) + } +} + func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 81b5d4eed6..32c9d5fb4f 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,4 +27,8 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" + + // Internal environment routing used by repo-local dev wrappers. + CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" + CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 34868386cd..90fb6adf63 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,6 +4,7 @@ package envvars import ( + "net/http" "os" "strings" "unicode" @@ -22,6 +23,26 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } +func ExtraHeaders() http.Header { + headers := make(http.Header) + for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { + name, value, ok := strings.Cut(item, ":") + if !ok { + continue + } + name = strings.TrimSpace(name) + value = sanitizeSingleLine(value, agentNameMaxLen) + if name == "" || value == "" || !validHeaderName(name) { + continue + } + headers.Set(name, value) + } + if len(headers) == 0 { + return nil + } + return headers +} + func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -34,3 +55,17 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } + +func validHeaderName(name string) bool { + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '-': + default: + return false + } + } + return true +} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index cf216e90a2..ef09531289 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,3 +131,30 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } + +func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") + + headers := ExtraHeaders() + if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } + if got := headers.Get("x-use-ppe"); got != "1" { + t.Fatalf("x-use-ppe = %q, want 1", got) + } +} + +func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") + + headers := ExtraHeaders() + if got := headers.Get("X-Good"); got != "ok" { + t.Fatalf("X-Good = %q, want ok", got) + } + if got := headers.Get("Bad Header"); got != "" { + t.Fatalf("Bad Header = %q, want rejected", got) + } + if got := headers.Get("X-Evil"); got != "" { + t.Fatalf("X-Evil = %q, want rejected", got) + } +} From 7babc387846147cd3c9ba9c98aaae5180e86448c Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 14:36:08 +0800 Subject: [PATCH 24/33] Revert "feat: support development environment overrides" This reverts commit cef8f78dc64d4e0852cd9fe2fd7e67765f8e70e8. --- .agents/skills/lark-approval | 1 - .agents/skills/lark-apps | 1 - .agents/skills/lark-attendance | 1 - .agents/skills/lark-base | 1 - .agents/skills/lark-calendar | 1 - .agents/skills/lark-contact | 1 - .agents/skills/lark-doc | 1 - .agents/skills/lark-drive | 1 - .agents/skills/lark-event | 1 - .agents/skills/lark-im | 1 - .agents/skills/lark-mail | 1 - .agents/skills/lark-markdown | 1 - .agents/skills/lark-minutes | 1 - .agents/skills/lark-note | 1 - .agents/skills/lark-okr | 1 - .agents/skills/lark-openapi-explorer | 1 - .agents/skills/lark-shared | 1 - .agents/skills/lark-sheets | 1 - .agents/skills/lark-skill-maker | 1 - .agents/skills/lark-slides | 1 - .agents/skills/lark-task | 1 - .agents/skills/lark-vc | 1 - .agents/skills/lark-vc-agent | 1 - .agents/skills/lark-whiteboard | 1 - .agents/skills/lark-wiki | 1 - .agents/skills/lark-workflow-meeting-summary | 1 - .agents/skills/lark-workflow-standup-report | 1 - .gitignore | 1 - env/claude-dev-lark.sh | 256 ------------- env/codex-dev-lark.sh | 362 ------------------- env/larkenv | 353 ------------------ internal/cmdutil/secheader.go | 5 - internal/cmdutil/secheader_test.go | 9 - internal/core/types.go | 23 +- internal/core/types_test.go | 29 -- internal/envvars/envvars.go | 4 - internal/envvars/read.go | 35 -- internal/envvars/read_test.go | 27 -- 38 files changed, 2 insertions(+), 1129 deletions(-) delete mode 120000 .agents/skills/lark-approval delete mode 120000 .agents/skills/lark-apps delete mode 120000 .agents/skills/lark-attendance delete mode 120000 .agents/skills/lark-base delete mode 120000 .agents/skills/lark-calendar delete mode 120000 .agents/skills/lark-contact delete mode 120000 .agents/skills/lark-doc delete mode 120000 .agents/skills/lark-drive delete mode 120000 .agents/skills/lark-event delete mode 120000 .agents/skills/lark-im delete mode 120000 .agents/skills/lark-mail delete mode 120000 .agents/skills/lark-markdown delete mode 120000 .agents/skills/lark-minutes delete mode 120000 .agents/skills/lark-note delete mode 120000 .agents/skills/lark-okr delete mode 120000 .agents/skills/lark-openapi-explorer delete mode 120000 .agents/skills/lark-shared delete mode 120000 .agents/skills/lark-sheets delete mode 120000 .agents/skills/lark-skill-maker delete mode 120000 .agents/skills/lark-slides delete mode 120000 .agents/skills/lark-task delete mode 120000 .agents/skills/lark-vc delete mode 120000 .agents/skills/lark-vc-agent delete mode 120000 .agents/skills/lark-whiteboard delete mode 120000 .agents/skills/lark-wiki delete mode 120000 .agents/skills/lark-workflow-meeting-summary delete mode 120000 .agents/skills/lark-workflow-standup-report delete mode 100755 env/claude-dev-lark.sh delete mode 100755 env/codex-dev-lark.sh delete mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval deleted file mode 120000 index be32751967..0000000000 --- a/.agents/skills/lark-approval +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps deleted file mode 120000 index 326837d904..0000000000 --- a/.agents/skills/lark-apps +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance deleted file mode 120000 index cea17c567b..0000000000 --- a/.agents/skills/lark-attendance +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base deleted file mode 120000 index 480c521328..0000000000 --- a/.agents/skills/lark-base +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar deleted file mode 120000 index 0e067fb7c4..0000000000 --- a/.agents/skills/lark-calendar +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact deleted file mode 120000 index c4b523b6f7..0000000000 --- a/.agents/skills/lark-contact +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc deleted file mode 120000 index 36be178ac6..0000000000 --- a/.agents/skills/lark-doc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive deleted file mode 120000 index b90475836f..0000000000 --- a/.agents/skills/lark-drive +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event deleted file mode 120000 index 0edcd85ff3..0000000000 --- a/.agents/skills/lark-event +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im deleted file mode 120000 index 0083317e7e..0000000000 --- a/.agents/skills/lark-im +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail deleted file mode 120000 index 1dc9d46966..0000000000 --- a/.agents/skills/lark-mail +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown deleted file mode 120000 index 780a87258d..0000000000 --- a/.agents/skills/lark-markdown +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes deleted file mode 120000 index f39545a341..0000000000 --- a/.agents/skills/lark-minutes +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note deleted file mode 120000 index 64b27c3543..0000000000 --- a/.agents/skills/lark-note +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr deleted file mode 120000 index c0b5104fe3..0000000000 --- a/.agents/skills/lark-okr +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer deleted file mode 120000 index 0357b2ae30..0000000000 --- a/.agents/skills/lark-openapi-explorer +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared deleted file mode 120000 index 90db01f71f..0000000000 --- a/.agents/skills/lark-shared +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets deleted file mode 120000 index 7a4bb8f954..0000000000 --- a/.agents/skills/lark-sheets +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker deleted file mode 120000 index 20a8485a86..0000000000 --- a/.agents/skills/lark-skill-maker +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides deleted file mode 120000 index a7b448b3a3..0000000000 --- a/.agents/skills/lark-slides +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task deleted file mode 120000 index 40893742c9..0000000000 --- a/.agents/skills/lark-task +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc deleted file mode 120000 index 6e20cbcd71..0000000000 --- a/.agents/skills/lark-vc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent deleted file mode 120000 index 550b733c72..0000000000 --- a/.agents/skills/lark-vc-agent +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard deleted file mode 120000 index a3e187627c..0000000000 --- a/.agents/skills/lark-whiteboard +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki deleted file mode 120000 index aab2f46f80..0000000000 --- a/.agents/skills/lark-wiki +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary deleted file mode 120000 index 58081f1f58..0000000000 --- a/.agents/skills/lark-workflow-meeting-summary +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report deleted file mode 120000 index b9e06f0d2c..0000000000 --- a/.agents/skills/lark-workflow-standup-report +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index e0bbba695d..645848906d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,4 @@ app.log cover*.out lark-env.sh -.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh deleted file mode 100755 index dc6362de4f..0000000000 --- a/env/claude-dev-lark.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Claude Code in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.claude/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, or online. - Default: boe - --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/claude-dev-lark.sh - env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/claude-dev-lark.sh --skill lark-base - env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -claude_args=() -claude_arg_count=0 - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - claude_args=("$@") - claude_arg_count=$# - break - ;; - *) - claude_args+=("$1") - claude_arg_count=$((claude_arg_count + 1)) - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | online) ;; -*) die "--env must be one of: boe, pre, ppe, online" ;; -esac - -command -v claude >/dev/null 2>&1 || die "claude not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -# launch_claude starts Claude Code with the caller's environment inherited as -# is, including any proxy variables already exported by the shell. Network setup -# is left to the caller so this script stays portable. -# -# If your proxy is configured through a shell function (rather than exported -# variables), invoke this script through it, e.g. `my_proxy_wrapper -# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. -# -# lark-cli traffic is unaffected either way for boe: larkenv boe sets -# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe -# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn -# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". -launch_claude() { - exec claude --allow-dangerously-skip-permissions "$@" -} - -bin_dir="$repo_root/.claude-dev/bin" -skills_dir="$repo_root/.claude/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' - -# Only PPE needs the extra headers; every other target keeps the plain shim so -# the non-PPE path stays byte-identical to what it was before PPE support. -if [ "$use_ppe" -eq 1 ]; then - cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' - -for h in "x-use-ppe:1" "env:pre_release"; do - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $h;"*) ;; - *) - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$h" - fi - ;; - esac -done -PPE_SHIM -fi - -cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 - echo " identity: reuses the online app config and user login state" >&2 -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -export LARK_LANE="$lane" -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -# Claude has no "-C " flag like Codex; it uses the current working -# directory, which is $repo_root here (see the cd above). -if [ "$claude_arg_count" -eq 0 ]; then - launch_claude -fi - -launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh deleted file mode 100755 index dee647e9bd..0000000000 --- a/env/codex-dev-lark.sh +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Codex in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.agents/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE; ignored by --use-pre. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, use-pre, or online. - Default: boe - --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. - --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; - auth/config remain on production. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --cx Launch Codex with cx-style permissions: - codex --dangerously-bypass-approvals-and-sandbox - Adds --profile proxy when CODEX_PROXY_API_KEY is exported. - --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/codex-dev-lark.sh - CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh - env/codex-dev-lark.sh --cx - env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/codex-dev-lark.sh --use-pre - env/codex-dev-lark.sh --skill lark-base - env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -use_pre=0 -use_cx="${CODEX_DEV_LARK_CX:-0}" -codex_args=() - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --use-pre) - use_pre=1 - shift - ;; - --cx) - use_cx=1 - shift - ;; - --no-cx) - use_cx=0 - shift - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - codex_args=("$@") - break - ;; - *) - codex_args+=("$1") - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then - die "--use-ppe and --use-pre are mutually exclusive" -fi - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi -if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="use-pre" -fi -if [ "$use_pre" -eq 1 ]; then - case "$target_env" in - use-pre) ;; - *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | use-pre | online) ;; -*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; -esac - -use_pre_effective=0 -if [ "$target_env" = "use-pre" ]; then - use_pre_effective=1 -fi - -case "$use_cx" in -0 | 1) ;; -*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; -esac - -codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - else - codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - fi -fi - -command -v codex >/dev/null 2>&1 || die "codex not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -bin_dir="$repo_root/.codex-dev/bin" -skills_dir="$repo_root/.agents/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -shim_path="$bin_dir/lark-cli" -shim_tmp="$bin_dir/.lark-cli.$$.tmp" -cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -if [ "$use_pre_effective" -eq 1 ]; then - echo " lark-cli -> larkenv $target_env" >&2 -else - echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -fi -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 -fi -if [ "$use_pre_effective" -eq 1 ]; then - echo " extra headers: env:pre_release" >&2 - echo " omitted: x-use-ppe; X-TT-ENV" >&2 -fi -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 - else - echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 - fi -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -if [ "$use_pre_effective" -eq 1 ]; then - unset LARK_LANE - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" -else - export LARK_LANE="$lane" -fi -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -elif [ "$use_pre_effective" -eq 1 ]; then - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv deleted file mode 100755 index 5054dba2ac..0000000000 --- a/env/larkenv +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 -# -# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) -# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 -# larkenv init boe 同上,但走交互式选 app -# larkenv login boe 只补登录(app 已配好时用),自动出二维码 -# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 -# larkenv boe <任何 lark-cli 命令> -# larkenv pre <任何 lark-cli 命令> -# larkenv ppe <任何 lark-cli 命令> -# larkenv use-pre <任何 lark-cli 命令> -# larkenv online <任何 lark-cli 命令> -# -# 可选: -# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) -# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) -# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) -# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body -# -# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 -# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre -# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config -# 控制面仍使用正式 endpoint。 -set -euo pipefail - -BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" -CONFIG_ROOT="$HOME/.lark-cli-env" -BIN="$BIN_DIR/lark-cli-env" - -usage() { - # Print the header comment block (from the title line to the first - # non-comment line), stripped of its leading "# ". - awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" - exit "${1:-0}" -} - -# ---------- setup:编译 + 安装 + 配 PATH ---------- -do_setup() { - local repo_root - repo_root="$(cd "$(dirname "$0")/.." && pwd)" - - echo "==> 编译 lark-cli ..." - (cd "$repo_root" && ./build.sh) - - echo "==> 安装到 $BIN_DIR ..." - mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" - cp "$repo_root/lark-cli" "$BIN" - cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" - chmod +x "$BIN" "$BIN_DIR/larkenv" - - # 幂等地把 BIN_DIR 加进 shell rc - local rc marker='# added by lark-cli env/larkenv' - case "${SHELL##*/}" in - zsh) rc="$HOME/.zshrc" ;; - bash) rc="$HOME/.bashrc" ;; - *) rc="" ;; - esac - if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then - printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" - echo "==> 已把 $BIN_DIR 写入 $rc" - fi - - # NOTE: brace the expansions below — the surrounding full-width punctuation - # would otherwise be swallowed into the variable name by bash. - # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. - local reload="新开一个终端" - if [ -n "$rc" ]; then - reload="新开一个终端(或 source ${rc})" - fi - - cat < # 其他环境复用同一个 app,不用重配 - larkenv login boe # 登录态失效时,只补登录 - - larkenv boe base +table-list --base-token - larkenv pre wiki +node-get --token - larkenv ppe drive +search --query xxx - larkenv use-pre drive +search --query xxx - larkenv online drive +search --query xxx - -EOF -} - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header() { - local target="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - [ "$item" = "$target" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -# PPE reuses the production app config and user token, so authentication and -# configuration must stay on the production control plane. The launcher may -# already have injected PPE routing into the parent environment; remove only -# those routing values while preserving unrelated caller-supplied headers. -apply_ppe_control_env() { - unset LARKSUITE_CLI_CONFIG_DIR - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - remove_extra_header_name "x-use-ppe" - remove_extra_header "env:pre_release" - remove_extra_header_name "x-tt-env" - unset LARK_LANE -} - -# ---------- 环境变量注入 ---------- -apply_env() { - # Normalize values that may have been inherited from a previously selected - # environment before applying the requested target. - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 - # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 - unset LARKSUITE_CLI_CONFIG_DIR - else - export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" - remove_extra_header "x-use-ppe:1" - remove_extra_header "env:pre_release" - fi - case "$1" in - boe) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" - # boe 是内网域名,必须绕开公司外部 relay 代理 - export LARK_CLI_NO_PROXY=1 - ;; - pre) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - ;; - ppe) - # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers - # 选择 pre_release 环境和目标泳道。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" - ;; - use-pre) - # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 - # x-use-ppe 或泳道头。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" - unset LARK_LANE - append_extra_header "env:pre_release" - ;; - online) - # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 - ;; - esac - # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS - # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. - if [ -n "${LARK_LANE:-}" ]; then - append_extra_header "X-TT-ENV: $LARK_LANE" - fi - if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then - mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" - fi -} - -apply_control_env() { - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - apply_ppe_control_env - else - apply_env "$1" - fi -} - -# json_field extracts a top-level string field from a JSON object on stdin. -json_field() { - if command -v jq >/dev/null 2>&1; then - jq -r --arg k "$1" '.[$k] // empty' - else - python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" - fi -} - -# do_login runs the device flow but renders the verification URL as a terminal -# QR code before polling. Scanning with your own Feishu app avoids authorizing -# as whichever account the default browser happens to be signed in as — the -# usual cause of "用户登录态无效 (20033)" on a shared machine. -do_login() { - local json url code - json="$("$BIN" auth login --recommend --no-wait --json)" - url="$(printf '%s' "$json" | json_field verification_url)" - code="$(printf '%s' "$json" | json_field device_code)" - - if [ -z "$url" ] || [ -z "$code" ]; then - echo "无法解析登录信息,原始输出:" >&2 - printf '%s\n' "$json" >&2 - return 1 - fi - - echo "" >&2 - echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 - echo "" >&2 - "$BIN" auth qrcode --ascii "$url" - echo "" >&2 - echo "扫不了的话再用链接:$url" >&2 - echo "" >&2 - - "$BIN" auth login --device-code "$code" -} - -require_bin() { - [ -x "$BIN" ] || { - echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 - exit 1 - } -} - -# ---------- 入口 ---------- -[ $# -ge 1 ] || usage 1 - -case "$1" in -setup) - do_setup - ;; -qr) - # Renders the device-flow URL as a terminal QR code. The CLI never opens a - # browser itself, so scanning with your own Feishu app is the way to avoid - # authorizing as whichever account the default browser happens to hold. - [ $# -eq 2 ] || { - echo "用法: larkenv qr <登录URL>" >&2 - exit 1 - } - require_bin - exec "$BIN" auth qrcode --ascii "$2" - ;; -init) - # With an app id, config init runs fully non-interactively — no app-selection - # prompt at all. boe/pre can reuse the online app id, so there is usually no - # reason to register a separate app per environment. - case $# in - 2 | 3) ;; - *) - echo "用法: larkenv init [app-id]" >&2 - exit 1 - ;; - esac - require_bin - apply_control_env "$2" - - if [ $# -eq 3 ]; then - printf 'App Secret (输入不回显): ' >&2 - stty -echo 2>/dev/null || true - read -r secret - stty echo 2>/dev/null || true - printf '\n' >&2 - # --app-secret-stdin keeps the secret out of the process list. - printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin - unset secret - else - echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 - "$BIN" config init - fi - - # 固定成 user 身份,之后所有命令都不用再带 --as user。 - "$BIN" config default-as user - do_login - ;; -login) - # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 - [ $# -eq 2 ] || { - echo "用法: larkenv login " >&2 - exit 1 - } - require_bin - apply_control_env "$2" - do_login - ;; -boe | pre | ppe | use-pre | online) - require_bin - env_name="$1" - shift - if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then - apply_ppe_control_env - else - apply_env "$env_name" - fi - exec "$BIN" "$@" - ;; --h | --help | help) - usage 0 - ;; -*) - echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 - exit 1 - ;; -esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 96120ed6bd..881aba3c80 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,11 +59,6 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } - for k, vs := range envvars.ExtraHeaders() { - for _, v := range vs { - h.Set(k, v) - } - } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index c6f55e1d6f..13ad93b442 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,15 +262,6 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } -func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { - t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") - - h := BaseSecurityHeaders() - if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } -} - // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 86abaa2847..4f8803cd4f 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,7 +6,6 @@ package core import ( "context" "net/url" - "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -130,40 +129,22 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { - var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } - if domain := endpointDomainOverride(); domain != "" { - endpoints.Open = "https://open." + domain - endpoints.Accounts = "https://accounts." + domain - endpoints.MCP = "https://mcp." + domain - endpoints.AppLink = "https://applink." + domain - } - return endpoints -} - -func endpointDomainOverride() string { - value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) - if value == "" || - strings.Contains(value, "://") || - strings.ContainsAny(value, "/?#@") { - return "" - } - return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 259c21a57c..23883493c7 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,8 +8,6 @@ import ( "net/url" "reflect" "testing" - - "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -68,33 +66,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } -func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu-boe.cn" { - t.Errorf("Open = %q, want BOE open endpoint", ep.Open) - } - if ep.Accounts != "https://accounts.feishu-boe.cn" { - t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) - } - if ep.MCP != "https://mcp.feishu-boe.cn" { - t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) - } - if ep.AppLink != "https://applink.feishu-boe.cn" { - t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) - } -} - -func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu.cn" { - t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) - } -} - func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 32c9d5fb4f..81b5d4eed6 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,8 +27,4 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" - - // Internal environment routing used by repo-local dev wrappers. - CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" - CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 90fb6adf63..34868386cd 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,7 +4,6 @@ package envvars import ( - "net/http" "os" "strings" "unicode" @@ -23,26 +22,6 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } -func ExtraHeaders() http.Header { - headers := make(http.Header) - for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { - name, value, ok := strings.Cut(item, ":") - if !ok { - continue - } - name = strings.TrimSpace(name) - value = sanitizeSingleLine(value, agentNameMaxLen) - if name == "" || value == "" || !validHeaderName(name) { - continue - } - headers.Set(name, value) - } - if len(headers) == 0 { - return nil - } - return headers -} - func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -55,17 +34,3 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } - -func validHeaderName(name string) bool { - for _, r := range name { - switch { - case r >= 'a' && r <= 'z': - case r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9': - case r == '-': - default: - return false - } - } - return true -} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index ef09531289..cf216e90a2 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,30 +131,3 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } - -func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") - - headers := ExtraHeaders() - if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } - if got := headers.Get("x-use-ppe"); got != "1" { - t.Fatalf("x-use-ppe = %q, want 1", got) - } -} - -func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") - - headers := ExtraHeaders() - if got := headers.Get("X-Good"); got != "ok" { - t.Fatalf("X-Good = %q, want ok", got) - } - if got := headers.Get("Bad Header"); got != "" { - t.Fatalf("Bad Header = %q, want rejected", got) - } - if got := headers.Get("X-Evil"); got != "" { - t.Fatalf("X-Evil = %q, want rejected", got) - } -} From f785a78aa9d89af36b66c1b96ccfc9deafa703be Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 14:44:47 +0800 Subject: [PATCH 25/33] fix: polish skill --- .../lark-base/references/lark-base-workflow.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index 67085ba3a1..e2d6704e75 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -54,13 +54,13 @@ | 场景 | 步骤组合 | 示例 | |------|---------|------| -| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例-1-新增记录触发--发送消息) | +| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) | | 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) | -| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例-2-定时触发--查找记录--循环遍历--发送消息) | -| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例-3-条件分支ifelsebranch) | -| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例-4-多路分支switchbranch) | -| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例-5-组合场景定时查找循环分支消息) | -| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例-7-ai-分类用户反馈自动分流) | +| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) | +| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支ifelsebranch) | +| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支switchbranch) | +| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例5-组合场景定时查找循环分支消息) | +| AI 分类 | ... → AIClassificationBranch → 分类后处理 | [下方](#示例7-ai-分类用户反馈自动分流) | --- @@ -832,8 +832,9 @@ ] } ``` - -关键点:`classes` 按顺序对应 `branch_1`、`branch_2`;`desc` 与分类名一致,`to` 指向已定义的下游 step;缺省无匹配策略使用 `default` 边。 +**关键点**: +- `classes` 按顺序对应 `branch_1`、`branch_2`;`desc` 与分类名一致,`to` 指向已定义的下游 step; +- 创建时省略 no_match_action 等同于 classifyToOther,必须提供 default 边;更新时省略会保留既有策略。 --- From 0f9044d303e4d986926c25c0905fed3d7d0068f7 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 19 Aug 2026 11:03:50 +0800 Subject: [PATCH 26/33] feat: support development environment overrides --- .agents/skills/lark-approval | 1 + .agents/skills/lark-apps | 1 + .agents/skills/lark-attendance | 1 + .agents/skills/lark-base | 1 + .agents/skills/lark-calendar | 1 + .agents/skills/lark-contact | 1 + .agents/skills/lark-doc | 1 + .agents/skills/lark-drive | 1 + .agents/skills/lark-event | 1 + .agents/skills/lark-im | 1 + .agents/skills/lark-mail | 1 + .agents/skills/lark-markdown | 1 + .agents/skills/lark-minutes | 1 + .agents/skills/lark-note | 1 + .agents/skills/lark-okr | 1 + .agents/skills/lark-openapi-explorer | 1 + .agents/skills/lark-shared | 1 + .agents/skills/lark-sheets | 1 + .agents/skills/lark-skill-maker | 1 + .agents/skills/lark-slides | 1 + .agents/skills/lark-task | 1 + .agents/skills/lark-vc | 1 + .agents/skills/lark-vc-agent | 1 + .agents/skills/lark-whiteboard | 1 + .agents/skills/lark-wiki | 1 + .agents/skills/lark-workflow-meeting-summary | 1 + .agents/skills/lark-workflow-standup-report | 1 + .gitignore | 1 + env/claude-dev-lark.sh | 256 +++++++++++++ env/codex-dev-lark.sh | 362 +++++++++++++++++++ env/larkenv | 353 ++++++++++++++++++ internal/cmdutil/secheader.go | 5 + internal/cmdutil/secheader_test.go | 9 + internal/core/types.go | 23 +- internal/core/types_test.go | 29 ++ internal/envvars/envvars.go | 4 + internal/envvars/read.go | 35 ++ internal/envvars/read_test.go | 27 ++ 38 files changed, 1129 insertions(+), 2 deletions(-) create mode 120000 .agents/skills/lark-approval create mode 120000 .agents/skills/lark-apps create mode 120000 .agents/skills/lark-attendance create mode 120000 .agents/skills/lark-base create mode 120000 .agents/skills/lark-calendar create mode 120000 .agents/skills/lark-contact create mode 120000 .agents/skills/lark-doc create mode 120000 .agents/skills/lark-drive create mode 120000 .agents/skills/lark-event create mode 120000 .agents/skills/lark-im create mode 120000 .agents/skills/lark-mail create mode 120000 .agents/skills/lark-markdown create mode 120000 .agents/skills/lark-minutes create mode 120000 .agents/skills/lark-note create mode 120000 .agents/skills/lark-okr create mode 120000 .agents/skills/lark-openapi-explorer create mode 120000 .agents/skills/lark-shared create mode 120000 .agents/skills/lark-sheets create mode 120000 .agents/skills/lark-skill-maker create mode 120000 .agents/skills/lark-slides create mode 120000 .agents/skills/lark-task create mode 120000 .agents/skills/lark-vc create mode 120000 .agents/skills/lark-vc-agent create mode 120000 .agents/skills/lark-whiteboard create mode 120000 .agents/skills/lark-wiki create mode 120000 .agents/skills/lark-workflow-meeting-summary create mode 120000 .agents/skills/lark-workflow-standup-report create mode 100755 env/claude-dev-lark.sh create mode 100755 env/codex-dev-lark.sh create mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval new file mode 120000 index 0000000000..be32751967 --- /dev/null +++ b/.agents/skills/lark-approval @@ -0,0 +1 @@ +../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps new file mode 120000 index 0000000000..326837d904 --- /dev/null +++ b/.agents/skills/lark-apps @@ -0,0 +1 @@ +../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance new file mode 120000 index 0000000000..cea17c567b --- /dev/null +++ b/.agents/skills/lark-attendance @@ -0,0 +1 @@ +../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base new file mode 120000 index 0000000000..480c521328 --- /dev/null +++ b/.agents/skills/lark-base @@ -0,0 +1 @@ +../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar new file mode 120000 index 0000000000..0e067fb7c4 --- /dev/null +++ b/.agents/skills/lark-calendar @@ -0,0 +1 @@ +../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact new file mode 120000 index 0000000000..c4b523b6f7 --- /dev/null +++ b/.agents/skills/lark-contact @@ -0,0 +1 @@ +../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc new file mode 120000 index 0000000000..36be178ac6 --- /dev/null +++ b/.agents/skills/lark-doc @@ -0,0 +1 @@ +../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive new file mode 120000 index 0000000000..b90475836f --- /dev/null +++ b/.agents/skills/lark-drive @@ -0,0 +1 @@ +../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event new file mode 120000 index 0000000000..0edcd85ff3 --- /dev/null +++ b/.agents/skills/lark-event @@ -0,0 +1 @@ +../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im new file mode 120000 index 0000000000..0083317e7e --- /dev/null +++ b/.agents/skills/lark-im @@ -0,0 +1 @@ +../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail new file mode 120000 index 0000000000..1dc9d46966 --- /dev/null +++ b/.agents/skills/lark-mail @@ -0,0 +1 @@ +../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown new file mode 120000 index 0000000000..780a87258d --- /dev/null +++ b/.agents/skills/lark-markdown @@ -0,0 +1 @@ +../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes new file mode 120000 index 0000000000..f39545a341 --- /dev/null +++ b/.agents/skills/lark-minutes @@ -0,0 +1 @@ +../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note new file mode 120000 index 0000000000..64b27c3543 --- /dev/null +++ b/.agents/skills/lark-note @@ -0,0 +1 @@ +../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr new file mode 120000 index 0000000000..c0b5104fe3 --- /dev/null +++ b/.agents/skills/lark-okr @@ -0,0 +1 @@ +../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer new file mode 120000 index 0000000000..0357b2ae30 --- /dev/null +++ b/.agents/skills/lark-openapi-explorer @@ -0,0 +1 @@ +../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared new file mode 120000 index 0000000000..90db01f71f --- /dev/null +++ b/.agents/skills/lark-shared @@ -0,0 +1 @@ +../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets new file mode 120000 index 0000000000..7a4bb8f954 --- /dev/null +++ b/.agents/skills/lark-sheets @@ -0,0 +1 @@ +../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker new file mode 120000 index 0000000000..20a8485a86 --- /dev/null +++ b/.agents/skills/lark-skill-maker @@ -0,0 +1 @@ +../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides new file mode 120000 index 0000000000..a7b448b3a3 --- /dev/null +++ b/.agents/skills/lark-slides @@ -0,0 +1 @@ +../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task new file mode 120000 index 0000000000..40893742c9 --- /dev/null +++ b/.agents/skills/lark-task @@ -0,0 +1 @@ +../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc new file mode 120000 index 0000000000..6e20cbcd71 --- /dev/null +++ b/.agents/skills/lark-vc @@ -0,0 +1 @@ +../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent new file mode 120000 index 0000000000..550b733c72 --- /dev/null +++ b/.agents/skills/lark-vc-agent @@ -0,0 +1 @@ +../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard new file mode 120000 index 0000000000..a3e187627c --- /dev/null +++ b/.agents/skills/lark-whiteboard @@ -0,0 +1 @@ +../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki new file mode 120000 index 0000000000..aab2f46f80 --- /dev/null +++ b/.agents/skills/lark-wiki @@ -0,0 +1 @@ +../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary new file mode 120000 index 0000000000..58081f1f58 --- /dev/null +++ b/.agents/skills/lark-workflow-meeting-summary @@ -0,0 +1 @@ +../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report new file mode 120000 index 0000000000..b9e06f0d2c --- /dev/null +++ b/.agents/skills/lark-workflow-standup-report @@ -0,0 +1 @@ +../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index 645848906d..e0bbba695d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ app.log cover*.out lark-env.sh +.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh new file mode 100755 index 0000000000..dc6362de4f --- /dev/null +++ b/env/claude-dev-lark.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Claude Code in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.claude/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, or online. + Default: boe + --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/claude-dev-lark.sh + env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/claude-dev-lark.sh --skill lark-base + env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +claude_args=() +claude_arg_count=0 + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + claude_args=("$@") + claude_arg_count=$# + break + ;; + *) + claude_args+=("$1") + claude_arg_count=$((claude_arg_count + 1)) + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | online) ;; +*) die "--env must be one of: boe, pre, ppe, online" ;; +esac + +command -v claude >/dev/null 2>&1 || die "claude not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +# launch_claude starts Claude Code with the caller's environment inherited as +# is, including any proxy variables already exported by the shell. Network setup +# is left to the caller so this script stays portable. +# +# If your proxy is configured through a shell function (rather than exported +# variables), invoke this script through it, e.g. `my_proxy_wrapper +# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. +# +# lark-cli traffic is unaffected either way for boe: larkenv boe sets +# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe +# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn +# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". +launch_claude() { + exec claude --allow-dangerously-skip-permissions "$@" +} + +bin_dir="$repo_root/.claude-dev/bin" +skills_dir="$repo_root/.claude/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' + +# Only PPE needs the extra headers; every other target keeps the plain shim so +# the non-PPE path stays byte-identical to what it was before PPE support. +if [ "$use_ppe" -eq 1 ]; then + cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' + +for h in "x-use-ppe:1" "env:pre_release"; do + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $h;"*) ;; + *) + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$h" + fi + ;; + esac +done +PPE_SHIM +fi + +cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 + echo " identity: reuses the online app config and user login state" >&2 +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +export LARK_LANE="$lane" +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +# Claude has no "-C " flag like Codex; it uses the current working +# directory, which is $repo_root here (see the cd above). +if [ "$claude_arg_count" -eq 0 ]; then + launch_claude +fi + +launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh new file mode 100755 index 0000000000..dee647e9bd --- /dev/null +++ b/env/codex-dev-lark.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Codex in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.agents/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE; ignored by --use-pre. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, use-pre, or online. + Default: boe + --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. + --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; + auth/config remain on production. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --cx Launch Codex with cx-style permissions: + codex --dangerously-bypass-approvals-and-sandbox + Adds --profile proxy when CODEX_PROXY_API_KEY is exported. + --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/codex-dev-lark.sh + CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh + env/codex-dev-lark.sh --cx + env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/codex-dev-lark.sh --use-pre + env/codex-dev-lark.sh --skill lark-base + env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +use_pre=0 +use_cx="${CODEX_DEV_LARK_CX:-0}" +codex_args=() + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --use-pre) + use_pre=1 + shift + ;; + --cx) + use_cx=1 + shift + ;; + --no-cx) + use_cx=0 + shift + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + codex_args=("$@") + break + ;; + *) + codex_args+=("$1") + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then + die "--use-ppe and --use-pre are mutually exclusive" +fi + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi +if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="use-pre" +fi +if [ "$use_pre" -eq 1 ]; then + case "$target_env" in + use-pre) ;; + *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | use-pre | online) ;; +*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; +esac + +use_pre_effective=0 +if [ "$target_env" = "use-pre" ]; then + use_pre_effective=1 +fi + +case "$use_cx" in +0 | 1) ;; +*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; +esac + +codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + else + codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + fi +fi + +command -v codex >/dev/null 2>&1 || die "codex not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +bin_dir="$repo_root/.codex-dev/bin" +skills_dir="$repo_root/.agents/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +shim_path="$bin_dir/lark-cli" +shim_tmp="$bin_dir/.lark-cli.$$.tmp" +cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +if [ "$use_pre_effective" -eq 1 ]; then + echo " lark-cli -> larkenv $target_env" >&2 +else + echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +fi +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 +fi +if [ "$use_pre_effective" -eq 1 ]; then + echo " extra headers: env:pre_release" >&2 + echo " omitted: x-use-ppe; X-TT-ENV" >&2 +fi +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 + else + echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 + fi +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +if [ "$use_pre_effective" -eq 1 ]; then + unset LARK_LANE + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" +else + export LARK_LANE="$lane" +fi +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +elif [ "$use_pre_effective" -eq 1 ]; then + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv new file mode 100755 index 0000000000..5054dba2ac --- /dev/null +++ b/env/larkenv @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 +# +# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) +# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 +# larkenv init boe 同上,但走交互式选 app +# larkenv login boe 只补登录(app 已配好时用),自动出二维码 +# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 +# larkenv boe <任何 lark-cli 命令> +# larkenv pre <任何 lark-cli 命令> +# larkenv ppe <任何 lark-cli 命令> +# larkenv use-pre <任何 lark-cli 命令> +# larkenv online <任何 lark-cli 命令> +# +# 可选: +# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) +# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) +# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) +# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body +# +# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 +# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre +# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config +# 控制面仍使用正式 endpoint。 +set -euo pipefail + +BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" +CONFIG_ROOT="$HOME/.lark-cli-env" +BIN="$BIN_DIR/lark-cli-env" + +usage() { + # Print the header comment block (from the title line to the first + # non-comment line), stripped of its leading "# ". + awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" + exit "${1:-0}" +} + +# ---------- setup:编译 + 安装 + 配 PATH ---------- +do_setup() { + local repo_root + repo_root="$(cd "$(dirname "$0")/.." && pwd)" + + echo "==> 编译 lark-cli ..." + (cd "$repo_root" && ./build.sh) + + echo "==> 安装到 $BIN_DIR ..." + mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" + cp "$repo_root/lark-cli" "$BIN" + cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" + chmod +x "$BIN" "$BIN_DIR/larkenv" + + # 幂等地把 BIN_DIR 加进 shell rc + local rc marker='# added by lark-cli env/larkenv' + case "${SHELL##*/}" in + zsh) rc="$HOME/.zshrc" ;; + bash) rc="$HOME/.bashrc" ;; + *) rc="" ;; + esac + if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then + printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" + echo "==> 已把 $BIN_DIR 写入 $rc" + fi + + # NOTE: brace the expansions below — the surrounding full-width punctuation + # would otherwise be swallowed into the variable name by bash. + # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. + local reload="新开一个终端" + if [ -n "$rc" ]; then + reload="新开一个终端(或 source ${rc})" + fi + + cat < # 其他环境复用同一个 app,不用重配 + larkenv login boe # 登录态失效时,只补登录 + + larkenv boe base +table-list --base-token + larkenv pre wiki +node-get --token + larkenv ppe drive +search --query xxx + larkenv use-pre drive +search --query xxx + larkenv online drive +search --query xxx + +EOF +} + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header() { + local target="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + [ "$item" = "$target" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +# PPE reuses the production app config and user token, so authentication and +# configuration must stay on the production control plane. The launcher may +# already have injected PPE routing into the parent environment; remove only +# those routing values while preserving unrelated caller-supplied headers. +apply_ppe_control_env() { + unset LARKSUITE_CLI_CONFIG_DIR + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + remove_extra_header_name "x-use-ppe" + remove_extra_header "env:pre_release" + remove_extra_header_name "x-tt-env" + unset LARK_LANE +} + +# ---------- 环境变量注入 ---------- +apply_env() { + # Normalize values that may have been inherited from a previously selected + # environment before applying the requested target. + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 + # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 + unset LARKSUITE_CLI_CONFIG_DIR + else + export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" + remove_extra_header "x-use-ppe:1" + remove_extra_header "env:pre_release" + fi + case "$1" in + boe) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" + # boe 是内网域名,必须绕开公司外部 relay 代理 + export LARK_CLI_NO_PROXY=1 + ;; + pre) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + ;; + ppe) + # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers + # 选择 pre_release 环境和目标泳道。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" + ;; + use-pre) + # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 + # x-use-ppe 或泳道头。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" + unset LARK_LANE + append_extra_header "env:pre_release" + ;; + online) + # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 + ;; + esac + # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS + # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. + if [ -n "${LARK_LANE:-}" ]; then + append_extra_header "X-TT-ENV: $LARK_LANE" + fi + if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then + mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" + fi +} + +apply_control_env() { + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + apply_ppe_control_env + else + apply_env "$1" + fi +} + +# json_field extracts a top-level string field from a JSON object on stdin. +json_field() { + if command -v jq >/dev/null 2>&1; then + jq -r --arg k "$1" '.[$k] // empty' + else + python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" + fi +} + +# do_login runs the device flow but renders the verification URL as a terminal +# QR code before polling. Scanning with your own Feishu app avoids authorizing +# as whichever account the default browser happens to be signed in as — the +# usual cause of "用户登录态无效 (20033)" on a shared machine. +do_login() { + local json url code + json="$("$BIN" auth login --recommend --no-wait --json)" + url="$(printf '%s' "$json" | json_field verification_url)" + code="$(printf '%s' "$json" | json_field device_code)" + + if [ -z "$url" ] || [ -z "$code" ]; then + echo "无法解析登录信息,原始输出:" >&2 + printf '%s\n' "$json" >&2 + return 1 + fi + + echo "" >&2 + echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 + echo "" >&2 + "$BIN" auth qrcode --ascii "$url" + echo "" >&2 + echo "扫不了的话再用链接:$url" >&2 + echo "" >&2 + + "$BIN" auth login --device-code "$code" +} + +require_bin() { + [ -x "$BIN" ] || { + echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 + exit 1 + } +} + +# ---------- 入口 ---------- +[ $# -ge 1 ] || usage 1 + +case "$1" in +setup) + do_setup + ;; +qr) + # Renders the device-flow URL as a terminal QR code. The CLI never opens a + # browser itself, so scanning with your own Feishu app is the way to avoid + # authorizing as whichever account the default browser happens to hold. + [ $# -eq 2 ] || { + echo "用法: larkenv qr <登录URL>" >&2 + exit 1 + } + require_bin + exec "$BIN" auth qrcode --ascii "$2" + ;; +init) + # With an app id, config init runs fully non-interactively — no app-selection + # prompt at all. boe/pre can reuse the online app id, so there is usually no + # reason to register a separate app per environment. + case $# in + 2 | 3) ;; + *) + echo "用法: larkenv init [app-id]" >&2 + exit 1 + ;; + esac + require_bin + apply_control_env "$2" + + if [ $# -eq 3 ]; then + printf 'App Secret (输入不回显): ' >&2 + stty -echo 2>/dev/null || true + read -r secret + stty echo 2>/dev/null || true + printf '\n' >&2 + # --app-secret-stdin keeps the secret out of the process list. + printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin + unset secret + else + echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 + "$BIN" config init + fi + + # 固定成 user 身份,之后所有命令都不用再带 --as user。 + "$BIN" config default-as user + do_login + ;; +login) + # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 + [ $# -eq 2 ] || { + echo "用法: larkenv login " >&2 + exit 1 + } + require_bin + apply_control_env "$2" + do_login + ;; +boe | pre | ppe | use-pre | online) + require_bin + env_name="$1" + shift + if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then + apply_ppe_control_env + else + apply_env "$env_name" + fi + exec "$BIN" "$@" + ;; +-h | --help | help) + usage 0 + ;; +*) + echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 + exit 1 + ;; +esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 881aba3c80..96120ed6bd 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,6 +59,11 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } + for k, vs := range envvars.ExtraHeaders() { + for _, v := range vs { + h.Set(k, v) + } + } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index 13ad93b442..c6f55e1d6f 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,6 +262,15 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } +func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { + t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") + + h := BaseSecurityHeaders() + if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } +} + // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 4f8803cd4f..86abaa2847 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,6 +6,7 @@ package core import ( "context" "net/url" + "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -129,22 +130,40 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { + var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } + if domain := endpointDomainOverride(); domain != "" { + endpoints.Open = "https://open." + domain + endpoints.Accounts = "https://accounts." + domain + endpoints.MCP = "https://mcp." + domain + endpoints.AppLink = "https://applink." + domain + } + return endpoints +} + +func endpointDomainOverride() string { + value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) + if value == "" || + strings.Contains(value, "://") || + strings.ContainsAny(value, "/?#@") { + return "" + } + return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 23883493c7..259c21a57c 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,6 +8,8 @@ import ( "net/url" "reflect" "testing" + + "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -66,6 +68,33 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } +func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu-boe.cn" { + t.Errorf("Open = %q, want BOE open endpoint", ep.Open) + } + if ep.Accounts != "https://accounts.feishu-boe.cn" { + t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) + } + if ep.MCP != "https://mcp.feishu-boe.cn" { + t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) + } + if ep.AppLink != "https://applink.feishu-boe.cn" { + t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) + } +} + +func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) + } +} + func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 81b5d4eed6..32c9d5fb4f 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,4 +27,8 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" + + // Internal environment routing used by repo-local dev wrappers. + CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" + CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 34868386cd..90fb6adf63 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,6 +4,7 @@ package envvars import ( + "net/http" "os" "strings" "unicode" @@ -22,6 +23,26 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } +func ExtraHeaders() http.Header { + headers := make(http.Header) + for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { + name, value, ok := strings.Cut(item, ":") + if !ok { + continue + } + name = strings.TrimSpace(name) + value = sanitizeSingleLine(value, agentNameMaxLen) + if name == "" || value == "" || !validHeaderName(name) { + continue + } + headers.Set(name, value) + } + if len(headers) == 0 { + return nil + } + return headers +} + func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -34,3 +55,17 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } + +func validHeaderName(name string) bool { + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '-': + default: + return false + } + } + return true +} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index cf216e90a2..ef09531289 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,3 +131,30 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } + +func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") + + headers := ExtraHeaders() + if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } + if got := headers.Get("x-use-ppe"); got != "1" { + t.Fatalf("x-use-ppe = %q, want 1", got) + } +} + +func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") + + headers := ExtraHeaders() + if got := headers.Get("X-Good"); got != "ok" { + t.Fatalf("X-Good = %q, want ok", got) + } + if got := headers.Get("Bad Header"); got != "" { + t.Fatalf("Bad Header = %q, want rejected", got) + } + if got := headers.Get("X-Evil"); got != "" { + t.Fatalf("X-Evil = %q, want rejected", got) + } +} From a2ddae9bc016f089f22f95a8e39c9b052474891a Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 15:07:21 +0800 Subject: [PATCH 27/33] fix(base): preserve omitted AI classification strategy --- .../workflow_ai_classification_validate.go | 13 ++-- shortcuts/base/workflow_create.go | 6 +- shortcuts/base/workflow_execute_test.go | 65 +++++++++++++++++++ shortcuts/base/workflow_json_validation.go | 15 +++-- shortcuts/base/workflow_update.go | 6 +- 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go index 2737983614..f23f05a2d0 100644 --- a/shortcuts/base/workflow_ai_classification_validate.go +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -25,7 +25,7 @@ func indexWorkflowStepIDs(steps []interface{}) map[string]int { return stepIDs } -func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int) error { +func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int, operation workflowWriteOperation) error { path := fmt.Sprintf("--json steps[%d]", index) data, ok := step["data"].(map[string]interface{}) if !ok || data == nil { @@ -35,7 +35,7 @@ func validateWorkflowAIClassificationStep(index int, step map[string]interface{} if err != nil { return err } - return validateAIClassificationLinks(path, step, stepIDs, classes, aiClassificationNoMatchAction(data)) + return validateAIClassificationLinks(path, step, stepIDs, classes, aiClassificationNoMatchAction(data, operation)) } func validateAIClassificationAgentData(path string, data map[string]interface{}, stepIndex int, stepIDs map[string]int) ([]string, error) { @@ -192,6 +192,9 @@ func validateAIClassificationLinks(path string, step map[string]interface{}, ste if ordinaryLinks != len(classes) { return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path) } + if noMatchAction == "" && defaultLinks > 1 { + return baseValidationErrorf("%s.children.links must contain at most one default link when no_match_action is omitted during update", path) + } if noMatchAction == "classifyToOther" && defaultLinks != 1 { return baseValidationErrorf("%s.children.links must contain exactly one default link when no_match_action is classifyToOther", path) } @@ -201,12 +204,14 @@ func validateAIClassificationLinks(path string, step map[string]interface{}, ste return nil } -func aiClassificationNoMatchAction(data map[string]interface{}) string { +func aiClassificationNoMatchAction(data map[string]interface{}, operation workflowWriteOperation) string { action, _ := data["no_match_action"].(string) action = strings.TrimSpace(action) - if action == "" { + if action == "" && operation == workflowWriteCreate { return workflowAIClassificationDefaultNoMatchAction } + // Update omission preserves the stored setting, which cannot be inferred + // from the replacement body alone. Leave it unset for link validation. return action } diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index cd9de3bcb8..d0a1835e10 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -33,14 +33,14 @@ var BaseWorkflowCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("base-token")) == "" { return baseFlagErrorf("--base-token must not be blank") } - if _, err := parseWorkflowBodyJSON(runtime); err != nil { + if _, err := parseWorkflowBodyJSON(runtime, workflowWriteCreate); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - if parsed, err := parseWorkflowBodyJSON(runtime); err == nil { + if parsed, err := parseWorkflowBodyJSON(runtime, workflowWriteCreate); err == nil { body = parsed } return common.NewDryRunAPI(). @@ -49,7 +49,7 @@ var BaseWorkflowCreate = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := parseWorkflowBodyJSON(runtime) + body, err := parseWorkflowBodyJSON(runtime, workflowWriteCreate) if err != nil { return err } diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index 8cc3cd703b..e2d26a98d4 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -406,6 +406,71 @@ func TestBaseWorkflowExecuteUpdatePreservesAIClassificationWithoutMode(t *testin } } +func TestBaseWorkflowExecuteUpdatePreservesOmittedAIClassificationFailWithoutDefault(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Language classify"}, + }, + } + reg.Register(stub) + + body := `{ + "title": "Language classify", + "status": "enabled", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":[ + {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, + {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"} + ]}, + "data": { + "classes": [ + {"name": "English", "desc": "English text"}, + {"name": "Chinese", "desc": "Chinese text"} + ], + "content": [{"value_type": "text", "value": "Classify"}] + } + }, + {"id": "step_english", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_chinese", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` + if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := string(stub.CapturedBody) + if strings.Contains(got, `"no_match_action"`) || strings.Contains(got, `"label":"default"`) { + t.Fatalf("update should preserve omitted fail semantics without injecting a default branch: %s", got) + } + + dryRunFactory, dryRunStdout, _ := newExecuteFactory(t) + dryRunArgs := []string{ + "+workflow-update", + "--base-token", "app_x", + "--workflow-id", "wkf_1", + "--json", body, + "--dry-run", + "--format", "pretty", + } + if err := runShortcut(t, BaseWorkflowUpdate, dryRunArgs, dryRunFactory, dryRunStdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + dryRun := dryRunStdout.String() + if !strings.Contains(dryRun, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") { + t.Fatalf("dry-run missing update request: %s", dryRun) + } + if strings.Contains(dryRun, `"no_match_action"`) || strings.Contains(dryRun, `"label": "default"`) { + t.Fatalf("dry-run should preserve omitted fail semantics without injecting a default branch: %s", dryRun) + } +} + func TestBaseWorkflowExecuteCreateValidateAIAnalysisData(t *testing.T) { t.Run("rejects table names string", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go index 90e77b7aea..d560f3c949 100644 --- a/shortcuts/base/workflow_json_validation.go +++ b/shortcuts/base/workflow_json_validation.go @@ -5,19 +5,26 @@ package base import "github.com/larksuite/cli/shortcuts/common" -func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { +type workflowWriteOperation uint8 + +const ( + workflowWriteCreate workflowWriteOperation = iota + workflowWriteUpdate +) + +func parseWorkflowBodyJSON(runtime *common.RuntimeContext, operation workflowWriteOperation) (map[string]interface{}, error) { pc := newParseCtx(runtime) body, err := parseJSONObject(pc, runtime.Str("json"), "json") if err != nil { return nil, err } - if err := validateWorkflowBodyForCLI(body); err != nil { + if err := validateWorkflowBodyForCLI(body, operation); err != nil { return nil, err } return body, nil } -func validateWorkflowBodyForCLI(body map[string]interface{}) error { +func validateWorkflowBodyForCLI(body map[string]interface{}, operation workflowWriteOperation) error { steps, ok := body["steps"].([]interface{}) if !ok { return nil @@ -40,7 +47,7 @@ func validateWorkflowBodyForCLI(body map[string]interface{}) error { if stepIDs == nil { stepIDs = indexWorkflowStepIDs(steps) } - if err := validateWorkflowAIClassificationStep(stepIndex, step, stepIDs); err != nil { + if err := validateWorkflowAIClassificationStep(stepIndex, step, stepIDs, operation); err != nil { return err } } diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index f367f0720f..38cbe54233 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -38,14 +38,14 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return baseFlagErrorf("--workflow-id must not be blank") } - if _, err := parseWorkflowBodyJSON(runtime); err != nil { + if _, err := parseWorkflowBodyJSON(runtime, workflowWriteUpdate); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - body, _ = parseWorkflowBodyJSON(runtime) + body, _ = parseWorkflowBodyJSON(runtime, workflowWriteUpdate) return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). @@ -53,7 +53,7 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := parseWorkflowBodyJSON(runtime) + body, err := parseWorkflowBodyJSON(runtime, workflowWriteUpdate) if err != nil { return err } From 69215cf123a22d03a8d3a79fe50865e6940d7f2a Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 15:07:41 +0800 Subject: [PATCH 28/33] Revert "feat: support development environment overrides" This reverts commit cef8f78dc64d4e0852cd9fe2fd7e67765f8e70e8. --- .agents/skills/lark-approval | 1 - .agents/skills/lark-apps | 1 - .agents/skills/lark-attendance | 1 - .agents/skills/lark-base | 1 - .agents/skills/lark-calendar | 1 - .agents/skills/lark-contact | 1 - .agents/skills/lark-doc | 1 - .agents/skills/lark-drive | 1 - .agents/skills/lark-event | 1 - .agents/skills/lark-im | 1 - .agents/skills/lark-mail | 1 - .agents/skills/lark-markdown | 1 - .agents/skills/lark-minutes | 1 - .agents/skills/lark-note | 1 - .agents/skills/lark-okr | 1 - .agents/skills/lark-openapi-explorer | 1 - .agents/skills/lark-shared | 1 - .agents/skills/lark-sheets | 1 - .agents/skills/lark-skill-maker | 1 - .agents/skills/lark-slides | 1 - .agents/skills/lark-task | 1 - .agents/skills/lark-vc | 1 - .agents/skills/lark-vc-agent | 1 - .agents/skills/lark-whiteboard | 1 - .agents/skills/lark-wiki | 1 - .agents/skills/lark-workflow-meeting-summary | 1 - .agents/skills/lark-workflow-standup-report | 1 - .gitignore | 1 - env/claude-dev-lark.sh | 256 ------------- env/codex-dev-lark.sh | 362 ------------------- env/larkenv | 353 ------------------ internal/cmdutil/secheader.go | 5 - internal/cmdutil/secheader_test.go | 9 - internal/core/types.go | 23 +- internal/core/types_test.go | 29 -- internal/envvars/envvars.go | 4 - internal/envvars/read.go | 35 -- internal/envvars/read_test.go | 27 -- 38 files changed, 2 insertions(+), 1129 deletions(-) delete mode 120000 .agents/skills/lark-approval delete mode 120000 .agents/skills/lark-apps delete mode 120000 .agents/skills/lark-attendance delete mode 120000 .agents/skills/lark-base delete mode 120000 .agents/skills/lark-calendar delete mode 120000 .agents/skills/lark-contact delete mode 120000 .agents/skills/lark-doc delete mode 120000 .agents/skills/lark-drive delete mode 120000 .agents/skills/lark-event delete mode 120000 .agents/skills/lark-im delete mode 120000 .agents/skills/lark-mail delete mode 120000 .agents/skills/lark-markdown delete mode 120000 .agents/skills/lark-minutes delete mode 120000 .agents/skills/lark-note delete mode 120000 .agents/skills/lark-okr delete mode 120000 .agents/skills/lark-openapi-explorer delete mode 120000 .agents/skills/lark-shared delete mode 120000 .agents/skills/lark-sheets delete mode 120000 .agents/skills/lark-skill-maker delete mode 120000 .agents/skills/lark-slides delete mode 120000 .agents/skills/lark-task delete mode 120000 .agents/skills/lark-vc delete mode 120000 .agents/skills/lark-vc-agent delete mode 120000 .agents/skills/lark-whiteboard delete mode 120000 .agents/skills/lark-wiki delete mode 120000 .agents/skills/lark-workflow-meeting-summary delete mode 120000 .agents/skills/lark-workflow-standup-report delete mode 100755 env/claude-dev-lark.sh delete mode 100755 env/codex-dev-lark.sh delete mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval deleted file mode 120000 index be32751967..0000000000 --- a/.agents/skills/lark-approval +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps deleted file mode 120000 index 326837d904..0000000000 --- a/.agents/skills/lark-apps +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance deleted file mode 120000 index cea17c567b..0000000000 --- a/.agents/skills/lark-attendance +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base deleted file mode 120000 index 480c521328..0000000000 --- a/.agents/skills/lark-base +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar deleted file mode 120000 index 0e067fb7c4..0000000000 --- a/.agents/skills/lark-calendar +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact deleted file mode 120000 index c4b523b6f7..0000000000 --- a/.agents/skills/lark-contact +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc deleted file mode 120000 index 36be178ac6..0000000000 --- a/.agents/skills/lark-doc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive deleted file mode 120000 index b90475836f..0000000000 --- a/.agents/skills/lark-drive +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event deleted file mode 120000 index 0edcd85ff3..0000000000 --- a/.agents/skills/lark-event +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im deleted file mode 120000 index 0083317e7e..0000000000 --- a/.agents/skills/lark-im +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail deleted file mode 120000 index 1dc9d46966..0000000000 --- a/.agents/skills/lark-mail +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown deleted file mode 120000 index 780a87258d..0000000000 --- a/.agents/skills/lark-markdown +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes deleted file mode 120000 index f39545a341..0000000000 --- a/.agents/skills/lark-minutes +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note deleted file mode 120000 index 64b27c3543..0000000000 --- a/.agents/skills/lark-note +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr deleted file mode 120000 index c0b5104fe3..0000000000 --- a/.agents/skills/lark-okr +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer deleted file mode 120000 index 0357b2ae30..0000000000 --- a/.agents/skills/lark-openapi-explorer +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared deleted file mode 120000 index 90db01f71f..0000000000 --- a/.agents/skills/lark-shared +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets deleted file mode 120000 index 7a4bb8f954..0000000000 --- a/.agents/skills/lark-sheets +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker deleted file mode 120000 index 20a8485a86..0000000000 --- a/.agents/skills/lark-skill-maker +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides deleted file mode 120000 index a7b448b3a3..0000000000 --- a/.agents/skills/lark-slides +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task deleted file mode 120000 index 40893742c9..0000000000 --- a/.agents/skills/lark-task +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc deleted file mode 120000 index 6e20cbcd71..0000000000 --- a/.agents/skills/lark-vc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent deleted file mode 120000 index 550b733c72..0000000000 --- a/.agents/skills/lark-vc-agent +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard deleted file mode 120000 index a3e187627c..0000000000 --- a/.agents/skills/lark-whiteboard +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki deleted file mode 120000 index aab2f46f80..0000000000 --- a/.agents/skills/lark-wiki +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary deleted file mode 120000 index 58081f1f58..0000000000 --- a/.agents/skills/lark-workflow-meeting-summary +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report deleted file mode 120000 index b9e06f0d2c..0000000000 --- a/.agents/skills/lark-workflow-standup-report +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index e0bbba695d..645848906d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,4 @@ app.log cover*.out lark-env.sh -.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh deleted file mode 100755 index dc6362de4f..0000000000 --- a/env/claude-dev-lark.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Claude Code in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.claude/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, or online. - Default: boe - --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/claude-dev-lark.sh - env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/claude-dev-lark.sh --skill lark-base - env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -claude_args=() -claude_arg_count=0 - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - claude_args=("$@") - claude_arg_count=$# - break - ;; - *) - claude_args+=("$1") - claude_arg_count=$((claude_arg_count + 1)) - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | online) ;; -*) die "--env must be one of: boe, pre, ppe, online" ;; -esac - -command -v claude >/dev/null 2>&1 || die "claude not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -# launch_claude starts Claude Code with the caller's environment inherited as -# is, including any proxy variables already exported by the shell. Network setup -# is left to the caller so this script stays portable. -# -# If your proxy is configured through a shell function (rather than exported -# variables), invoke this script through it, e.g. `my_proxy_wrapper -# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. -# -# lark-cli traffic is unaffected either way for boe: larkenv boe sets -# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe -# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn -# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". -launch_claude() { - exec claude --allow-dangerously-skip-permissions "$@" -} - -bin_dir="$repo_root/.claude-dev/bin" -skills_dir="$repo_root/.claude/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' - -# Only PPE needs the extra headers; every other target keeps the plain shim so -# the non-PPE path stays byte-identical to what it was before PPE support. -if [ "$use_ppe" -eq 1 ]; then - cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' - -for h in "x-use-ppe:1" "env:pre_release"; do - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $h;"*) ;; - *) - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$h" - fi - ;; - esac -done -PPE_SHIM -fi - -cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 - echo " identity: reuses the online app config and user login state" >&2 -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -export LARK_LANE="$lane" -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -# Claude has no "-C " flag like Codex; it uses the current working -# directory, which is $repo_root here (see the cd above). -if [ "$claude_arg_count" -eq 0 ]; then - launch_claude -fi - -launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh deleted file mode 100755 index dee647e9bd..0000000000 --- a/env/codex-dev-lark.sh +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Codex in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.agents/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE; ignored by --use-pre. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, use-pre, or online. - Default: boe - --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. - --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; - auth/config remain on production. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --cx Launch Codex with cx-style permissions: - codex --dangerously-bypass-approvals-and-sandbox - Adds --profile proxy when CODEX_PROXY_API_KEY is exported. - --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/codex-dev-lark.sh - CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh - env/codex-dev-lark.sh --cx - env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/codex-dev-lark.sh --use-pre - env/codex-dev-lark.sh --skill lark-base - env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -use_pre=0 -use_cx="${CODEX_DEV_LARK_CX:-0}" -codex_args=() - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --use-pre) - use_pre=1 - shift - ;; - --cx) - use_cx=1 - shift - ;; - --no-cx) - use_cx=0 - shift - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - codex_args=("$@") - break - ;; - *) - codex_args+=("$1") - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then - die "--use-ppe and --use-pre are mutually exclusive" -fi - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi -if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="use-pre" -fi -if [ "$use_pre" -eq 1 ]; then - case "$target_env" in - use-pre) ;; - *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | use-pre | online) ;; -*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; -esac - -use_pre_effective=0 -if [ "$target_env" = "use-pre" ]; then - use_pre_effective=1 -fi - -case "$use_cx" in -0 | 1) ;; -*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; -esac - -codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - else - codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - fi -fi - -command -v codex >/dev/null 2>&1 || die "codex not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -bin_dir="$repo_root/.codex-dev/bin" -skills_dir="$repo_root/.agents/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -shim_path="$bin_dir/lark-cli" -shim_tmp="$bin_dir/.lark-cli.$$.tmp" -cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -if [ "$use_pre_effective" -eq 1 ]; then - echo " lark-cli -> larkenv $target_env" >&2 -else - echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -fi -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 -fi -if [ "$use_pre_effective" -eq 1 ]; then - echo " extra headers: env:pre_release" >&2 - echo " omitted: x-use-ppe; X-TT-ENV" >&2 -fi -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 - else - echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 - fi -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -if [ "$use_pre_effective" -eq 1 ]; then - unset LARK_LANE - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" -else - export LARK_LANE="$lane" -fi -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -elif [ "$use_pre_effective" -eq 1 ]; then - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv deleted file mode 100755 index 5054dba2ac..0000000000 --- a/env/larkenv +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 -# -# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) -# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 -# larkenv init boe 同上,但走交互式选 app -# larkenv login boe 只补登录(app 已配好时用),自动出二维码 -# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 -# larkenv boe <任何 lark-cli 命令> -# larkenv pre <任何 lark-cli 命令> -# larkenv ppe <任何 lark-cli 命令> -# larkenv use-pre <任何 lark-cli 命令> -# larkenv online <任何 lark-cli 命令> -# -# 可选: -# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) -# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) -# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) -# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body -# -# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 -# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre -# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config -# 控制面仍使用正式 endpoint。 -set -euo pipefail - -BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" -CONFIG_ROOT="$HOME/.lark-cli-env" -BIN="$BIN_DIR/lark-cli-env" - -usage() { - # Print the header comment block (from the title line to the first - # non-comment line), stripped of its leading "# ". - awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" - exit "${1:-0}" -} - -# ---------- setup:编译 + 安装 + 配 PATH ---------- -do_setup() { - local repo_root - repo_root="$(cd "$(dirname "$0")/.." && pwd)" - - echo "==> 编译 lark-cli ..." - (cd "$repo_root" && ./build.sh) - - echo "==> 安装到 $BIN_DIR ..." - mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" - cp "$repo_root/lark-cli" "$BIN" - cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" - chmod +x "$BIN" "$BIN_DIR/larkenv" - - # 幂等地把 BIN_DIR 加进 shell rc - local rc marker='# added by lark-cli env/larkenv' - case "${SHELL##*/}" in - zsh) rc="$HOME/.zshrc" ;; - bash) rc="$HOME/.bashrc" ;; - *) rc="" ;; - esac - if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then - printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" - echo "==> 已把 $BIN_DIR 写入 $rc" - fi - - # NOTE: brace the expansions below — the surrounding full-width punctuation - # would otherwise be swallowed into the variable name by bash. - # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. - local reload="新开一个终端" - if [ -n "$rc" ]; then - reload="新开一个终端(或 source ${rc})" - fi - - cat < # 其他环境复用同一个 app,不用重配 - larkenv login boe # 登录态失效时,只补登录 - - larkenv boe base +table-list --base-token - larkenv pre wiki +node-get --token - larkenv ppe drive +search --query xxx - larkenv use-pre drive +search --query xxx - larkenv online drive +search --query xxx - -EOF -} - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header() { - local target="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - [ "$item" = "$target" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -# PPE reuses the production app config and user token, so authentication and -# configuration must stay on the production control plane. The launcher may -# already have injected PPE routing into the parent environment; remove only -# those routing values while preserving unrelated caller-supplied headers. -apply_ppe_control_env() { - unset LARKSUITE_CLI_CONFIG_DIR - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - remove_extra_header_name "x-use-ppe" - remove_extra_header "env:pre_release" - remove_extra_header_name "x-tt-env" - unset LARK_LANE -} - -# ---------- 环境变量注入 ---------- -apply_env() { - # Normalize values that may have been inherited from a previously selected - # environment before applying the requested target. - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 - # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 - unset LARKSUITE_CLI_CONFIG_DIR - else - export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" - remove_extra_header "x-use-ppe:1" - remove_extra_header "env:pre_release" - fi - case "$1" in - boe) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" - # boe 是内网域名,必须绕开公司外部 relay 代理 - export LARK_CLI_NO_PROXY=1 - ;; - pre) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - ;; - ppe) - # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers - # 选择 pre_release 环境和目标泳道。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" - ;; - use-pre) - # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 - # x-use-ppe 或泳道头。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" - unset LARK_LANE - append_extra_header "env:pre_release" - ;; - online) - # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 - ;; - esac - # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS - # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. - if [ -n "${LARK_LANE:-}" ]; then - append_extra_header "X-TT-ENV: $LARK_LANE" - fi - if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then - mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" - fi -} - -apply_control_env() { - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - apply_ppe_control_env - else - apply_env "$1" - fi -} - -# json_field extracts a top-level string field from a JSON object on stdin. -json_field() { - if command -v jq >/dev/null 2>&1; then - jq -r --arg k "$1" '.[$k] // empty' - else - python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" - fi -} - -# do_login runs the device flow but renders the verification URL as a terminal -# QR code before polling. Scanning with your own Feishu app avoids authorizing -# as whichever account the default browser happens to be signed in as — the -# usual cause of "用户登录态无效 (20033)" on a shared machine. -do_login() { - local json url code - json="$("$BIN" auth login --recommend --no-wait --json)" - url="$(printf '%s' "$json" | json_field verification_url)" - code="$(printf '%s' "$json" | json_field device_code)" - - if [ -z "$url" ] || [ -z "$code" ]; then - echo "无法解析登录信息,原始输出:" >&2 - printf '%s\n' "$json" >&2 - return 1 - fi - - echo "" >&2 - echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 - echo "" >&2 - "$BIN" auth qrcode --ascii "$url" - echo "" >&2 - echo "扫不了的话再用链接:$url" >&2 - echo "" >&2 - - "$BIN" auth login --device-code "$code" -} - -require_bin() { - [ -x "$BIN" ] || { - echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 - exit 1 - } -} - -# ---------- 入口 ---------- -[ $# -ge 1 ] || usage 1 - -case "$1" in -setup) - do_setup - ;; -qr) - # Renders the device-flow URL as a terminal QR code. The CLI never opens a - # browser itself, so scanning with your own Feishu app is the way to avoid - # authorizing as whichever account the default browser happens to hold. - [ $# -eq 2 ] || { - echo "用法: larkenv qr <登录URL>" >&2 - exit 1 - } - require_bin - exec "$BIN" auth qrcode --ascii "$2" - ;; -init) - # With an app id, config init runs fully non-interactively — no app-selection - # prompt at all. boe/pre can reuse the online app id, so there is usually no - # reason to register a separate app per environment. - case $# in - 2 | 3) ;; - *) - echo "用法: larkenv init [app-id]" >&2 - exit 1 - ;; - esac - require_bin - apply_control_env "$2" - - if [ $# -eq 3 ]; then - printf 'App Secret (输入不回显): ' >&2 - stty -echo 2>/dev/null || true - read -r secret - stty echo 2>/dev/null || true - printf '\n' >&2 - # --app-secret-stdin keeps the secret out of the process list. - printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin - unset secret - else - echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 - "$BIN" config init - fi - - # 固定成 user 身份,之后所有命令都不用再带 --as user。 - "$BIN" config default-as user - do_login - ;; -login) - # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 - [ $# -eq 2 ] || { - echo "用法: larkenv login " >&2 - exit 1 - } - require_bin - apply_control_env "$2" - do_login - ;; -boe | pre | ppe | use-pre | online) - require_bin - env_name="$1" - shift - if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then - apply_ppe_control_env - else - apply_env "$env_name" - fi - exec "$BIN" "$@" - ;; --h | --help | help) - usage 0 - ;; -*) - echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 - exit 1 - ;; -esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 96120ed6bd..881aba3c80 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,11 +59,6 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } - for k, vs := range envvars.ExtraHeaders() { - for _, v := range vs { - h.Set(k, v) - } - } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index c6f55e1d6f..13ad93b442 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,15 +262,6 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } -func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { - t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") - - h := BaseSecurityHeaders() - if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } -} - // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 86abaa2847..4f8803cd4f 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,7 +6,6 @@ package core import ( "context" "net/url" - "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -130,40 +129,22 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { - var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } - if domain := endpointDomainOverride(); domain != "" { - endpoints.Open = "https://open." + domain - endpoints.Accounts = "https://accounts." + domain - endpoints.MCP = "https://mcp." + domain - endpoints.AppLink = "https://applink." + domain - } - return endpoints -} - -func endpointDomainOverride() string { - value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) - if value == "" || - strings.Contains(value, "://") || - strings.ContainsAny(value, "/?#@") { - return "" - } - return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 259c21a57c..23883493c7 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,8 +8,6 @@ import ( "net/url" "reflect" "testing" - - "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -68,33 +66,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } -func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu-boe.cn" { - t.Errorf("Open = %q, want BOE open endpoint", ep.Open) - } - if ep.Accounts != "https://accounts.feishu-boe.cn" { - t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) - } - if ep.MCP != "https://mcp.feishu-boe.cn" { - t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) - } - if ep.AppLink != "https://applink.feishu-boe.cn" { - t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) - } -} - -func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu.cn" { - t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) - } -} - func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 32c9d5fb4f..81b5d4eed6 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,8 +27,4 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" - - // Internal environment routing used by repo-local dev wrappers. - CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" - CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 90fb6adf63..34868386cd 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,7 +4,6 @@ package envvars import ( - "net/http" "os" "strings" "unicode" @@ -23,26 +22,6 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } -func ExtraHeaders() http.Header { - headers := make(http.Header) - for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { - name, value, ok := strings.Cut(item, ":") - if !ok { - continue - } - name = strings.TrimSpace(name) - value = sanitizeSingleLine(value, agentNameMaxLen) - if name == "" || value == "" || !validHeaderName(name) { - continue - } - headers.Set(name, value) - } - if len(headers) == 0 { - return nil - } - return headers -} - func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -55,17 +34,3 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } - -func validHeaderName(name string) bool { - for _, r := range name { - switch { - case r >= 'a' && r <= 'z': - case r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9': - case r == '-': - default: - return false - } - } - return true -} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index ef09531289..cf216e90a2 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,30 +131,3 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } - -func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") - - headers := ExtraHeaders() - if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } - if got := headers.Get("x-use-ppe"); got != "1" { - t.Fatalf("x-use-ppe = %q, want 1", got) - } -} - -func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") - - headers := ExtraHeaders() - if got := headers.Get("X-Good"); got != "ok" { - t.Fatalf("X-Good = %q, want ok", got) - } - if got := headers.Get("Bad Header"); got != "" { - t.Fatalf("Bad Header = %q, want rejected", got) - } - if got := headers.Get("X-Evil"); got != "" { - t.Fatalf("X-Evil = %q, want rejected", got) - } -} From b822ce82ad455084b1661a9f5ebc77715cb16f1c Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 15:12:28 +0800 Subject: [PATCH 29/33] fix: polish skill --- skills/lark-base/references/lark-base-workflow-schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index a4bb6a7cc6..abdeca1ef9 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -493,7 +493,7 @@ | `analysis_task` | 是 | TextRefItem[] 分析任务,支持 `text` / `ref` 混排;至少包含一项有效内容 | | `analysis_table_names` | 否 | string[] 分析数据范围;为空数组 `[]` 或省略时表示当前 Base 的全部数据表 | | `identity_type` | 是 | 数据访问身份:`maker`(固定流程身份) / `triggerPersonal`(流程触发者) | -| `output_instruction` | 否 | string 输出要求;仅支持纯文本,不支持引用、附件或云文档模板 | +| `output_instruction` | 否 | 仅支持纯文本 | ## Branch data 详细结构 From ad9181bb4106342212027cfee8490b5b3f579eb1 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 15:12:42 +0800 Subject: [PATCH 30/33] fix: ut --- shortcuts/base/base_shortcuts_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index c6c6f76075..5ff21275bd 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -879,10 +879,6 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "lark-base-workflow.md as the module entry", "lark-base-workflow-schema.md as the steps JSON SSOT", "do not invent steps[].type/data/next/children from natural language", - "classes/content/classification_rule/no_match_action", - "omit mode", - "only supports service-side Exclusive semantics", - "cannot be saved as an empty shell", }, }, { @@ -896,10 +892,6 @@ func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { "workflow-id must start with wkf", "Updating does not enable or disable", "do not invent steps[].type/data/next/children from natural language", - "classes/content/classification_rule/no_match_action", - "omit mode", - "only supports service-side Exclusive semantics", - "label default", }, }, { From f805081e8989979e2311bc38277215e2cc8c65db Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 19 Aug 2026 11:03:50 +0800 Subject: [PATCH 31/33] feat: support development environment overrides --- .agents/skills/lark-approval | 1 + .agents/skills/lark-apps | 1 + .agents/skills/lark-attendance | 1 + .agents/skills/lark-base | 1 + .agents/skills/lark-calendar | 1 + .agents/skills/lark-contact | 1 + .agents/skills/lark-doc | 1 + .agents/skills/lark-drive | 1 + .agents/skills/lark-event | 1 + .agents/skills/lark-im | 1 + .agents/skills/lark-mail | 1 + .agents/skills/lark-markdown | 1 + .agents/skills/lark-minutes | 1 + .agents/skills/lark-note | 1 + .agents/skills/lark-okr | 1 + .agents/skills/lark-openapi-explorer | 1 + .agents/skills/lark-shared | 1 + .agents/skills/lark-sheets | 1 + .agents/skills/lark-skill-maker | 1 + .agents/skills/lark-slides | 1 + .agents/skills/lark-task | 1 + .agents/skills/lark-vc | 1 + .agents/skills/lark-vc-agent | 1 + .agents/skills/lark-whiteboard | 1 + .agents/skills/lark-wiki | 1 + .agents/skills/lark-workflow-meeting-summary | 1 + .agents/skills/lark-workflow-standup-report | 1 + .gitignore | 1 + env/claude-dev-lark.sh | 256 +++++++++++++ env/codex-dev-lark.sh | 362 +++++++++++++++++++ env/larkenv | 353 ++++++++++++++++++ internal/cmdutil/secheader.go | 5 + internal/cmdutil/secheader_test.go | 9 + internal/core/types.go | 23 +- internal/core/types_test.go | 29 ++ internal/envvars/envvars.go | 4 + internal/envvars/read.go | 35 ++ internal/envvars/read_test.go | 27 ++ 38 files changed, 1129 insertions(+), 2 deletions(-) create mode 120000 .agents/skills/lark-approval create mode 120000 .agents/skills/lark-apps create mode 120000 .agents/skills/lark-attendance create mode 120000 .agents/skills/lark-base create mode 120000 .agents/skills/lark-calendar create mode 120000 .agents/skills/lark-contact create mode 120000 .agents/skills/lark-doc create mode 120000 .agents/skills/lark-drive create mode 120000 .agents/skills/lark-event create mode 120000 .agents/skills/lark-im create mode 120000 .agents/skills/lark-mail create mode 120000 .agents/skills/lark-markdown create mode 120000 .agents/skills/lark-minutes create mode 120000 .agents/skills/lark-note create mode 120000 .agents/skills/lark-okr create mode 120000 .agents/skills/lark-openapi-explorer create mode 120000 .agents/skills/lark-shared create mode 120000 .agents/skills/lark-sheets create mode 120000 .agents/skills/lark-skill-maker create mode 120000 .agents/skills/lark-slides create mode 120000 .agents/skills/lark-task create mode 120000 .agents/skills/lark-vc create mode 120000 .agents/skills/lark-vc-agent create mode 120000 .agents/skills/lark-whiteboard create mode 120000 .agents/skills/lark-wiki create mode 120000 .agents/skills/lark-workflow-meeting-summary create mode 120000 .agents/skills/lark-workflow-standup-report create mode 100755 env/claude-dev-lark.sh create mode 100755 env/codex-dev-lark.sh create mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval new file mode 120000 index 0000000000..be32751967 --- /dev/null +++ b/.agents/skills/lark-approval @@ -0,0 +1 @@ +../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps new file mode 120000 index 0000000000..326837d904 --- /dev/null +++ b/.agents/skills/lark-apps @@ -0,0 +1 @@ +../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance new file mode 120000 index 0000000000..cea17c567b --- /dev/null +++ b/.agents/skills/lark-attendance @@ -0,0 +1 @@ +../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base new file mode 120000 index 0000000000..480c521328 --- /dev/null +++ b/.agents/skills/lark-base @@ -0,0 +1 @@ +../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar new file mode 120000 index 0000000000..0e067fb7c4 --- /dev/null +++ b/.agents/skills/lark-calendar @@ -0,0 +1 @@ +../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact new file mode 120000 index 0000000000..c4b523b6f7 --- /dev/null +++ b/.agents/skills/lark-contact @@ -0,0 +1 @@ +../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc new file mode 120000 index 0000000000..36be178ac6 --- /dev/null +++ b/.agents/skills/lark-doc @@ -0,0 +1 @@ +../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive new file mode 120000 index 0000000000..b90475836f --- /dev/null +++ b/.agents/skills/lark-drive @@ -0,0 +1 @@ +../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event new file mode 120000 index 0000000000..0edcd85ff3 --- /dev/null +++ b/.agents/skills/lark-event @@ -0,0 +1 @@ +../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im new file mode 120000 index 0000000000..0083317e7e --- /dev/null +++ b/.agents/skills/lark-im @@ -0,0 +1 @@ +../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail new file mode 120000 index 0000000000..1dc9d46966 --- /dev/null +++ b/.agents/skills/lark-mail @@ -0,0 +1 @@ +../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown new file mode 120000 index 0000000000..780a87258d --- /dev/null +++ b/.agents/skills/lark-markdown @@ -0,0 +1 @@ +../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes new file mode 120000 index 0000000000..f39545a341 --- /dev/null +++ b/.agents/skills/lark-minutes @@ -0,0 +1 @@ +../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note new file mode 120000 index 0000000000..64b27c3543 --- /dev/null +++ b/.agents/skills/lark-note @@ -0,0 +1 @@ +../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr new file mode 120000 index 0000000000..c0b5104fe3 --- /dev/null +++ b/.agents/skills/lark-okr @@ -0,0 +1 @@ +../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer new file mode 120000 index 0000000000..0357b2ae30 --- /dev/null +++ b/.agents/skills/lark-openapi-explorer @@ -0,0 +1 @@ +../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared new file mode 120000 index 0000000000..90db01f71f --- /dev/null +++ b/.agents/skills/lark-shared @@ -0,0 +1 @@ +../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets new file mode 120000 index 0000000000..7a4bb8f954 --- /dev/null +++ b/.agents/skills/lark-sheets @@ -0,0 +1 @@ +../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker new file mode 120000 index 0000000000..20a8485a86 --- /dev/null +++ b/.agents/skills/lark-skill-maker @@ -0,0 +1 @@ +../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides new file mode 120000 index 0000000000..a7b448b3a3 --- /dev/null +++ b/.agents/skills/lark-slides @@ -0,0 +1 @@ +../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task new file mode 120000 index 0000000000..40893742c9 --- /dev/null +++ b/.agents/skills/lark-task @@ -0,0 +1 @@ +../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc new file mode 120000 index 0000000000..6e20cbcd71 --- /dev/null +++ b/.agents/skills/lark-vc @@ -0,0 +1 @@ +../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent new file mode 120000 index 0000000000..550b733c72 --- /dev/null +++ b/.agents/skills/lark-vc-agent @@ -0,0 +1 @@ +../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard new file mode 120000 index 0000000000..a3e187627c --- /dev/null +++ b/.agents/skills/lark-whiteboard @@ -0,0 +1 @@ +../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki new file mode 120000 index 0000000000..aab2f46f80 --- /dev/null +++ b/.agents/skills/lark-wiki @@ -0,0 +1 @@ +../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary new file mode 120000 index 0000000000..58081f1f58 --- /dev/null +++ b/.agents/skills/lark-workflow-meeting-summary @@ -0,0 +1 @@ +../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report new file mode 120000 index 0000000000..b9e06f0d2c --- /dev/null +++ b/.agents/skills/lark-workflow-standup-report @@ -0,0 +1 @@ +../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index 645848906d..e0bbba695d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ app.log cover*.out lark-env.sh +.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh new file mode 100755 index 0000000000..dc6362de4f --- /dev/null +++ b/env/claude-dev-lark.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Claude Code in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.claude/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, or online. + Default: boe + --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/claude-dev-lark.sh + env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/claude-dev-lark.sh --skill lark-base + env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +claude_args=() +claude_arg_count=0 + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + claude_args=("$@") + claude_arg_count=$# + break + ;; + *) + claude_args+=("$1") + claude_arg_count=$((claude_arg_count + 1)) + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | online) ;; +*) die "--env must be one of: boe, pre, ppe, online" ;; +esac + +command -v claude >/dev/null 2>&1 || die "claude not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +# launch_claude starts Claude Code with the caller's environment inherited as +# is, including any proxy variables already exported by the shell. Network setup +# is left to the caller so this script stays portable. +# +# If your proxy is configured through a shell function (rather than exported +# variables), invoke this script through it, e.g. `my_proxy_wrapper +# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. +# +# lark-cli traffic is unaffected either way for boe: larkenv boe sets +# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe +# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn +# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". +launch_claude() { + exec claude --allow-dangerously-skip-permissions "$@" +} + +bin_dir="$repo_root/.claude-dev/bin" +skills_dir="$repo_root/.claude/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' + +# Only PPE needs the extra headers; every other target keeps the plain shim so +# the non-PPE path stays byte-identical to what it was before PPE support. +if [ "$use_ppe" -eq 1 ]; then + cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' + +for h in "x-use-ppe:1" "env:pre_release"; do + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $h;"*) ;; + *) + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$h" + fi + ;; + esac +done +PPE_SHIM +fi + +cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 + echo " identity: reuses the online app config and user login state" >&2 +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +export LARK_LANE="$lane" +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +# Claude has no "-C " flag like Codex; it uses the current working +# directory, which is $repo_root here (see the cd above). +if [ "$claude_arg_count" -eq 0 ]; then + launch_claude +fi + +launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh new file mode 100755 index 0000000000..dee647e9bd --- /dev/null +++ b/env/codex-dev-lark.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# Launch Codex in this checkout with project-local Lark skills and a dev +# lark-cli shim. The global ~/.agents/skills install is left untouched. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] + +Options: + --lane Lane injected through LARK_LANE; ignored by --use-pre. + Default: boe_bitable_bk11 + --env larkenv target: boe, pre, ppe, use-pre, or online. + Default: boe + --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. + --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; + auth/config remain on production. + --skill Link only one local skill, e.g. lark-base. + Default: all lark-* skills under ./skills + --cx Launch Codex with cx-style permissions: + codex --dangerously-bypass-approvals-and-sandbox + Adds --profile proxy when CODEX_PROXY_API_KEY is exported. + --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. + --no-build Reuse the current ./lark-cli binary instead of rebuilding. + -h, --help Show this help. + +Examples: + env/codex-dev-lark.sh + CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh + env/codex-dev-lark.sh --cx + env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe + env/codex-dev-lark.sh --use-pre + env/codex-dev-lark.sh --skill lark-base + env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" +USAGE +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +lane="${LARK_LANE:-boe_bitable_bk11}" +target_env="${LARKENV_TARGET:-boe}" +target_env_explicit=0 +skill_filter="all" +do_build=1 +use_ppe=0 +use_pre=0 +use_cx="${CODEX_DEV_LARK_CX:-0}" +codex_args=() + +while [ $# -gt 0 ]; do + case "$1" in + --lane) + [ $# -ge 2 ] || die "--lane requires a value" + lane="$2" + shift 2 + ;; + --env) + [ $# -ge 2 ] || die "--env requires a value" + target_env="$2" + target_env_explicit=1 + shift 2 + ;; + --skill) + [ $# -ge 2 ] || die "--skill requires a value" + skill_filter="$2" + shift 2 + ;; + --ppe | --use-ppe) + use_ppe=1 + shift + ;; + --use-pre) + use_pre=1 + shift + ;; + --cx) + use_cx=1 + shift + ;; + --no-cx) + use_cx=0 + shift + ;; + --no-build) + do_build=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + codex_args=("$@") + break + ;; + *) + codex_args+=("$1") + shift + ;; + esac +done + +if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then + die "--use-ppe and --use-pre are mutually exclusive" +fi + +if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="ppe" +fi +if [ "$use_ppe" -eq 1 ]; then + case "$target_env" in + ppe) ;; + *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; + esac +fi +if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then + target_env="use-pre" +fi +if [ "$use_pre" -eq 1 ]; then + case "$target_env" in + use-pre) ;; + *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; + esac +fi + +case "$target_env" in +boe | pre | ppe | use-pre | online) ;; +*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; +esac + +use_pre_effective=0 +if [ "$target_env" = "use-pre" ]; then + use_pre_effective=1 +fi + +case "$use_cx" in +0 | 1) ;; +*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; +esac + +codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + else + codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") + fi +fi + +command -v codex >/dev/null 2>&1 || die "codex not found in PATH" +[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" + +bin_dir="$repo_root/.codex-dev/bin" +skills_dir="$repo_root/.agents/skills" +mkdir -p "$bin_dir" "$skills_dir" + +cd "$repo_root" + +if [ "$do_build" -eq 1 ]; then + echo "==> Building dev lark-cli from $repo_root" >&2 + ./build.sh +else + echo "==> Reusing existing ./lark-cli" >&2 +fi + +[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" + +cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" +cp "$repo_root/env/larkenv" "$bin_dir/larkenv" +chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" + +link_skill() { + local name="$1" + local src="$repo_root/skills/$name" + local dst="$skills_dir/$name" + local rel="../../skills/$name" + local current backup + + [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" + + current="$(readlink "$dst" 2>/dev/null || true)" + if [ "$current" = "$rel" ]; then + return + fi + + if [ -L "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill symlink: $backup" >&2 + elif [ -e "$dst" ]; then + backup="$dst.bak.$(date +%Y%m%d%H%M%S)" + mv "$dst" "$backup" + echo "==> Backed up existing local skill directory: $backup" >&2 + fi + + ln -s "$rel" "$dst" +} + +if [ "$skill_filter" = "all" ]; then + found=0 + for skill_path in "$repo_root"/skills/lark-*; do + [ -d "$skill_path" ] || continue + link_skill "${skill_path##*/}" + found=1 + done + [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" +else + link_skill "$skill_filter" +fi + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +shim_path="$bin_dir/lark-cli" +shim_tmp="$bin_dir/.lark-cli.$$.tmp" +cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 +echo " PATH prefix: $bin_dir" >&2 +echo " Skill root: $skills_dir" >&2 +if [ "$use_pre_effective" -eq 1 ]; then + echo " lark-cli -> larkenv $target_env" >&2 +else + echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 +fi +if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then + echo " extra headers: x-use-ppe:1; env:pre_release" >&2 +fi +if [ "$use_pre_effective" -eq 1 ]; then + echo " extra headers: env:pre_release" >&2 + echo " omitted: x-use-ppe; X-TT-ENV" >&2 +fi +if [ "$use_cx" -eq 1 ]; then + if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then + echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 + else + echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 + fi +fi + +export PATH="$bin_dir:$PATH" +export LARK_CLI_ENV_BIN="$bin_dir" +if [ "$use_pre_effective" -eq 1 ]; then + unset LARK_LANE + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" +else + export LARK_LANE="$lane" +fi +if [ "$use_ppe" -eq 1 ]; then + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" +elif [ "$use_pre_effective" -eq 1 ]; then + append_extra_header "env:pre_release" +fi +export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" +export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" + +exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv new file mode 100755 index 0000000000..5054dba2ac --- /dev/null +++ b/env/larkenv @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 +# +# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) +# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 +# larkenv init boe 同上,但走交互式选 app +# larkenv login boe 只补登录(app 已配好时用),自动出二维码 +# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 +# larkenv boe <任何 lark-cli 命令> +# larkenv pre <任何 lark-cli 命令> +# larkenv ppe <任何 lark-cli 命令> +# larkenv use-pre <任何 lark-cli 命令> +# larkenv online <任何 lark-cli 命令> +# +# 可选: +# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) +# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) +# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) +# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body +# +# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 +# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre +# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config +# 控制面仍使用正式 endpoint。 +set -euo pipefail + +BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" +CONFIG_ROOT="$HOME/.lark-cli-env" +BIN="$BIN_DIR/lark-cli-env" + +usage() { + # Print the header comment block (from the title line to the first + # non-comment line), stripped of its leading "# ". + awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" + exit "${1:-0}" +} + +# ---------- setup:编译 + 安装 + 配 PATH ---------- +do_setup() { + local repo_root + repo_root="$(cd "$(dirname "$0")/.." && pwd)" + + echo "==> 编译 lark-cli ..." + (cd "$repo_root" && ./build.sh) + + echo "==> 安装到 $BIN_DIR ..." + mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" + cp "$repo_root/lark-cli" "$BIN" + cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" + chmod +x "$BIN" "$BIN_DIR/larkenv" + + # 幂等地把 BIN_DIR 加进 shell rc + local rc marker='# added by lark-cli env/larkenv' + case "${SHELL##*/}" in + zsh) rc="$HOME/.zshrc" ;; + bash) rc="$HOME/.bashrc" ;; + *) rc="" ;; + esac + if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then + printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" + echo "==> 已把 $BIN_DIR 写入 $rc" + fi + + # NOTE: brace the expansions below — the surrounding full-width punctuation + # would otherwise be swallowed into the variable name by bash. + # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. + local reload="新开一个终端" + if [ -n "$rc" ]; then + reload="新开一个终端(或 source ${rc})" + fi + + cat < # 其他环境复用同一个 app,不用重配 + larkenv login boe # 登录态失效时,只补登录 + + larkenv boe base +table-list --base-token + larkenv pre wiki +node-get --token + larkenv ppe drive +search --query xxx + larkenv use-pre drive +search --query xxx + larkenv online drive +search --query xxx + +EOF +} + +append_extra_header() { + local header="$1" + case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in + *"; $header;"*) return ;; + esac + if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" + else + export LARKSUITE_CLI_EXTRA_HEADERS="$header" + fi +} + +remove_extra_header() { + local target="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + [ "$item" = "$target" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +remove_extra_header_name() { + local target_name="$1" + local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" + local item result="" name + local IFS=';' + + for item in $raw; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [ -n "$item" ] || continue + name="${item%%:*}" + name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [ "$name" = "$target_name" ] && continue + if [ -n "$result" ]; then + result="$result; $item" + else + result="$item" + fi + done + + if [ -n "$result" ]; then + export LARKSUITE_CLI_EXTRA_HEADERS="$result" + else + unset LARKSUITE_CLI_EXTRA_HEADERS + fi +} + +# PPE reuses the production app config and user token, so authentication and +# configuration must stay on the production control plane. The launcher may +# already have injected PPE routing into the parent environment; remove only +# those routing values while preserving unrelated caller-supplied headers. +apply_ppe_control_env() { + unset LARKSUITE_CLI_CONFIG_DIR + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + remove_extra_header_name "x-use-ppe" + remove_extra_header "env:pre_release" + remove_extra_header_name "x-tt-env" + unset LARK_LANE +} + +# ---------- 环境变量注入 ---------- +apply_env() { + # Normalize values that may have been inherited from a previously selected + # environment before applying the requested target. + unset LARKSUITE_CLI_ENDPOINT_DOMAIN + unset LARK_CLI_NO_PROXY + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 + # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 + unset LARKSUITE_CLI_CONFIG_DIR + else + export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" + remove_extra_header "x-use-ppe:1" + remove_extra_header "env:pre_release" + fi + case "$1" in + boe) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" + # boe 是内网域名,必须绕开公司外部 relay 代理 + export LARK_CLI_NO_PROXY=1 + ;; + pre) + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + ;; + ppe) + # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers + # 选择 pre_release 环境和目标泳道。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + append_extra_header "x-use-ppe:1" + append_extra_header "env:pre_release" + ;; + use-pre) + # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 + # x-use-ppe 或泳道头。 + export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" + remove_extra_header_name "x-use-ppe" + remove_extra_header_name "x-tt-env" + unset LARK_LANE + append_extra_header "env:pre_release" + ;; + online) + # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 + ;; + esac + # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS + # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. + if [ -n "${LARK_LANE:-}" ]; then + append_extra_header "X-TT-ENV: $LARK_LANE" + fi + if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then + mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" + fi +} + +apply_control_env() { + if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then + apply_ppe_control_env + else + apply_env "$1" + fi +} + +# json_field extracts a top-level string field from a JSON object on stdin. +json_field() { + if command -v jq >/dev/null 2>&1; then + jq -r --arg k "$1" '.[$k] // empty' + else + python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" + fi +} + +# do_login runs the device flow but renders the verification URL as a terminal +# QR code before polling. Scanning with your own Feishu app avoids authorizing +# as whichever account the default browser happens to be signed in as — the +# usual cause of "用户登录态无效 (20033)" on a shared machine. +do_login() { + local json url code + json="$("$BIN" auth login --recommend --no-wait --json)" + url="$(printf '%s' "$json" | json_field verification_url)" + code="$(printf '%s' "$json" | json_field device_code)" + + if [ -z "$url" ] || [ -z "$code" ]; then + echo "无法解析登录信息,原始输出:" >&2 + printf '%s\n' "$json" >&2 + return 1 + fi + + echo "" >&2 + echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 + echo "" >&2 + "$BIN" auth qrcode --ascii "$url" + echo "" >&2 + echo "扫不了的话再用链接:$url" >&2 + echo "" >&2 + + "$BIN" auth login --device-code "$code" +} + +require_bin() { + [ -x "$BIN" ] || { + echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 + exit 1 + } +} + +# ---------- 入口 ---------- +[ $# -ge 1 ] || usage 1 + +case "$1" in +setup) + do_setup + ;; +qr) + # Renders the device-flow URL as a terminal QR code. The CLI never opens a + # browser itself, so scanning with your own Feishu app is the way to avoid + # authorizing as whichever account the default browser happens to hold. + [ $# -eq 2 ] || { + echo "用法: larkenv qr <登录URL>" >&2 + exit 1 + } + require_bin + exec "$BIN" auth qrcode --ascii "$2" + ;; +init) + # With an app id, config init runs fully non-interactively — no app-selection + # prompt at all. boe/pre can reuse the online app id, so there is usually no + # reason to register a separate app per environment. + case $# in + 2 | 3) ;; + *) + echo "用法: larkenv init [app-id]" >&2 + exit 1 + ;; + esac + require_bin + apply_control_env "$2" + + if [ $# -eq 3 ]; then + printf 'App Secret (输入不回显): ' >&2 + stty -echo 2>/dev/null || true + read -r secret + stty echo 2>/dev/null || true + printf '\n' >&2 + # --app-secret-stdin keeps the secret out of the process list. + printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin + unset secret + else + echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 + "$BIN" config init + fi + + # 固定成 user 身份,之后所有命令都不用再带 --as user。 + "$BIN" config default-as user + do_login + ;; +login) + # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 + [ $# -eq 2 ] || { + echo "用法: larkenv login " >&2 + exit 1 + } + require_bin + apply_control_env "$2" + do_login + ;; +boe | pre | ppe | use-pre | online) + require_bin + env_name="$1" + shift + if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then + apply_ppe_control_env + else + apply_env "$env_name" + fi + exec "$BIN" "$@" + ;; +-h | --help | help) + usage 0 + ;; +*) + echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 + exit 1 + ;; +esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 881aba3c80..96120ed6bd 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,6 +59,11 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } + for k, vs := range envvars.ExtraHeaders() { + for _, v := range vs { + h.Set(k, v) + } + } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index 13ad93b442..c6f55e1d6f 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,6 +262,15 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } +func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { + t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") + + h := BaseSecurityHeaders() + if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } +} + // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 4f8803cd4f..86abaa2847 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,6 +6,7 @@ package core import ( "context" "net/url" + "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -129,22 +130,40 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { + var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - return Endpoints{ + endpoints = Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } + if domain := endpointDomainOverride(); domain != "" { + endpoints.Open = "https://open." + domain + endpoints.Accounts = "https://accounts." + domain + endpoints.MCP = "https://mcp." + domain + endpoints.AppLink = "https://applink." + domain + } + return endpoints +} + +func endpointDomainOverride() string { + value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) + if value == "" || + strings.Contains(value, "://") || + strings.ContainsAny(value, "/?#@") { + return "" + } + return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 23883493c7..259c21a57c 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,6 +8,8 @@ import ( "net/url" "reflect" "testing" + + "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -66,6 +68,33 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } +func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu-boe.cn" { + t.Errorf("Open = %q, want BOE open endpoint", ep.Open) + } + if ep.Accounts != "https://accounts.feishu-boe.cn" { + t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) + } + if ep.MCP != "https://mcp.feishu-boe.cn" { + t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) + } + if ep.AppLink != "https://applink.feishu-boe.cn" { + t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) + } +} + +func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { + t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") + + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) + } +} + func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 81b5d4eed6..32c9d5fb4f 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,4 +27,8 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" + + // Internal environment routing used by repo-local dev wrappers. + CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" + CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 34868386cd..90fb6adf63 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,6 +4,7 @@ package envvars import ( + "net/http" "os" "strings" "unicode" @@ -22,6 +23,26 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } +func ExtraHeaders() http.Header { + headers := make(http.Header) + for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { + name, value, ok := strings.Cut(item, ":") + if !ok { + continue + } + name = strings.TrimSpace(name) + value = sanitizeSingleLine(value, agentNameMaxLen) + if name == "" || value == "" || !validHeaderName(name) { + continue + } + headers.Set(name, value) + } + if len(headers) == 0 { + return nil + } + return headers +} + func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -34,3 +55,17 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } + +func validHeaderName(name string) bool { + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '-': + default: + return false + } + } + return true +} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index cf216e90a2..ef09531289 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,3 +131,30 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } + +func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") + + headers := ExtraHeaders() + if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { + t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) + } + if got := headers.Get("x-use-ppe"); got != "1" { + t.Fatalf("x-use-ppe = %q, want 1", got) + } +} + +func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { + t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") + + headers := ExtraHeaders() + if got := headers.Get("X-Good"); got != "ok" { + t.Fatalf("X-Good = %q, want ok", got) + } + if got := headers.Get("Bad Header"); got != "" { + t.Fatalf("Bad Header = %q, want rejected", got) + } + if got := headers.Get("X-Evil"); got != "" { + t.Fatalf("X-Evil = %q, want rejected", got) + } +} From d48d5de2b92f26e471a5ef595ea51eab4d8b7fb1 Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 16:23:14 +0800 Subject: [PATCH 32/33] =?UTF-8?q?fix:=20=E6=B2=BF=E7=94=A8=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow_ai_classification_validate.go | 13 +- shortcuts/base/workflow_create.go | 6 +- shortcuts/base/workflow_execute_test.go | 175 ++++++++++++------ shortcuts/base/workflow_json_validation.go | 15 +- shortcuts/base/workflow_update.go | 6 +- .../references/lark-base-workflow-schema.md | 16 +- .../references/lark-base-workflow.md | 1 - 7 files changed, 139 insertions(+), 93 deletions(-) diff --git a/shortcuts/base/workflow_ai_classification_validate.go b/shortcuts/base/workflow_ai_classification_validate.go index f23f05a2d0..2737983614 100644 --- a/shortcuts/base/workflow_ai_classification_validate.go +++ b/shortcuts/base/workflow_ai_classification_validate.go @@ -25,7 +25,7 @@ func indexWorkflowStepIDs(steps []interface{}) map[string]int { return stepIDs } -func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int, operation workflowWriteOperation) error { +func validateWorkflowAIClassificationStep(index int, step map[string]interface{}, stepIDs map[string]int) error { path := fmt.Sprintf("--json steps[%d]", index) data, ok := step["data"].(map[string]interface{}) if !ok || data == nil { @@ -35,7 +35,7 @@ func validateWorkflowAIClassificationStep(index int, step map[string]interface{} if err != nil { return err } - return validateAIClassificationLinks(path, step, stepIDs, classes, aiClassificationNoMatchAction(data, operation)) + return validateAIClassificationLinks(path, step, stepIDs, classes, aiClassificationNoMatchAction(data)) } func validateAIClassificationAgentData(path string, data map[string]interface{}, stepIndex int, stepIDs map[string]int) ([]string, error) { @@ -192,9 +192,6 @@ func validateAIClassificationLinks(path string, step map[string]interface{}, ste if ordinaryLinks != len(classes) { return baseValidationErrorf("%s.children.links must contain one non-empty case link for each class", path) } - if noMatchAction == "" && defaultLinks > 1 { - return baseValidationErrorf("%s.children.links must contain at most one default link when no_match_action is omitted during update", path) - } if noMatchAction == "classifyToOther" && defaultLinks != 1 { return baseValidationErrorf("%s.children.links must contain exactly one default link when no_match_action is classifyToOther", path) } @@ -204,14 +201,12 @@ func validateAIClassificationLinks(path string, step map[string]interface{}, ste return nil } -func aiClassificationNoMatchAction(data map[string]interface{}, operation workflowWriteOperation) string { +func aiClassificationNoMatchAction(data map[string]interface{}) string { action, _ := data["no_match_action"].(string) action = strings.TrimSpace(action) - if action == "" && operation == workflowWriteCreate { + if action == "" { return workflowAIClassificationDefaultNoMatchAction } - // Update omission preserves the stored setting, which cannot be inferred - // from the replacement body alone. Leave it unset for link validation. return action } diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index d0a1835e10..cd9de3bcb8 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -33,14 +33,14 @@ var BaseWorkflowCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("base-token")) == "" { return baseFlagErrorf("--base-token must not be blank") } - if _, err := parseWorkflowBodyJSON(runtime, workflowWriteCreate); err != nil { + if _, err := parseWorkflowBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - if parsed, err := parseWorkflowBodyJSON(runtime, workflowWriteCreate); err == nil { + if parsed, err := parseWorkflowBodyJSON(runtime); err == nil { body = parsed } return common.NewDryRunAPI(). @@ -49,7 +49,7 @@ var BaseWorkflowCreate = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := parseWorkflowBodyJSON(runtime, workflowWriteCreate) + body, err := parseWorkflowBodyJSON(runtime) if err != nil { return err } diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go index e2d26a98d4..48fd28f856 100644 --- a/shortcuts/base/workflow_execute_test.go +++ b/shortcuts/base/workflow_execute_test.go @@ -406,68 +406,127 @@ func TestBaseWorkflowExecuteUpdatePreservesAIClassificationWithoutMode(t *testin } } -func TestBaseWorkflowExecuteUpdatePreservesOmittedAIClassificationFailWithoutDefault(t *testing.T) { - factory, stdout, reg := newExecuteFactory(t) - stub := &httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "Language classify"}, - }, +func TestBaseWorkflowExecuteUpdateValidatesAIClassificationNoMatchActionTopology(t *testing.T) { + body := func(noMatchAction string, links string) string { + return `{ + "title": "Language classify", + "steps": [ + {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, + { + "id": "step_classify", + "type": "AIClassificationBranch", + "children": {"links":` + links + `}, + "data": { + "classes": [ + {"name": "English", "desc": "English text"}, + {"name": "Chinese", "desc": "Chinese text"} + ], + "content": [{"value_type": "text", "value": "Classify"}]` + noMatchAction + ` + } + }, + {"id": "step_english", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_chinese", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other", "type": "SetRecordAction", "next": null, "data": {}}, + {"id": "step_other_2", "type": "SetRecordAction", "next": null, "data": {}} + ] + }` } - reg.Register(stub) + caseLinks := `[ + {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, + {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"} + ]` + defaultLinks := `[ + {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, + {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"}, + {"kind":"case","label":"default","desc":"Other","to":"step_other"} + ]` + multipleDefaultLinks := `[ + {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, + {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"}, + {"kind":"case","label":"default","desc":"Other","to":"step_other"}, + {"kind":"case","label":"default","desc":"Other 2","to":"step_other_2"} + ]` - body := `{ - "title": "Language classify", - "status": "enabled", - "steps": [ - {"id": "step_trigger", "type": "AddRecordTrigger", "next": "step_classify", "data": {}}, - { - "id": "step_classify", - "type": "AIClassificationBranch", - "children": {"links":[ - {"kind":"case","label":"branch_1","desc":"English","to":"step_english"}, - {"kind":"case","label":"branch_2","desc":"Chinese","to":"step_chinese"} - ]}, - "data": { - "classes": [ - {"name": "English", "desc": "English text"}, - {"name": "Chinese", "desc": "Chinese text"} - ], - "content": [{"value_type": "text", "value": "Classify"}] - } - }, - {"id": "step_english", "type": "SetRecordAction", "next": null, "data": {}}, - {"id": "step_chinese", "type": "SetRecordAction", "next": null, "data": {}} - ] - }` - if err := runShortcut(t, BaseWorkflowUpdate, []string{"+workflow-update", "--base-token", "app_x", "--workflow-id", "wkf_1", "--json", body}, factory, stdout); err != nil { - t.Fatalf("err=%v", err) - } - got := string(stub.CapturedBody) - if strings.Contains(got, `"no_match_action"`) || strings.Contains(got, `"label":"default"`) { - t.Fatalf("update should preserve omitted fail semantics without injecting a default branch: %s", got) + tests := []struct { + name string + noMatchAction string + links string + want string + wantForwarded string + }{ + { + name: "rejects omitted action without default link", + links: caseLinks, + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, + { + name: "accepts omitted action with one default link without injection", + links: defaultLinks, + wantForwarded: `"label":"default"`, + }, + { + name: "accepts explicit fail without default link", + noMatchAction: `,"no_match_action":"fail"`, + links: caseLinks, + wantForwarded: `"no_match_action":"fail"`, + }, + { + name: "rejects explicit fail with default link", + noMatchAction: `,"no_match_action":"fail"`, + links: defaultLinks, + want: "children.links must not contain a default link when no_match_action is fail", + }, + { + name: "accepts explicit classifyToOther with one default link", + noMatchAction: `,"no_match_action":"classifyToOther"`, + links: defaultLinks, + wantForwarded: `"no_match_action":"classifyToOther"`, + }, + { + name: "rejects explicit classifyToOther without default link", + noMatchAction: `,"no_match_action":"classifyToOther"`, + links: caseLinks, + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, + { + name: "rejects omitted action with multiple default links", + links: multipleDefaultLinks, + want: "children.links must contain exactly one default link when no_match_action is classifyToOther", + }, } - dryRunFactory, dryRunStdout, _ := newExecuteFactory(t) - dryRunArgs := []string{ - "+workflow-update", - "--base-token", "app_x", - "--workflow-id", "wkf_1", - "--json", body, - "--dry-run", - "--format", "pretty", - } - if err := runShortcut(t, BaseWorkflowUpdate, dryRunArgs, dryRunFactory, dryRunStdout); err != nil { - t.Fatalf("dry-run err=%v", err) - } - dryRun := dryRunStdout.String() - if !strings.Contains(dryRun, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") { - t.Fatalf("dry-run missing update request: %s", dryRun) - } - if strings.Contains(dryRun, `"no_match_action"`) || strings.Contains(dryRun, `"label": "default"`) { - t.Fatalf("dry-run should preserve omitted fail semantics without injecting a default branch: %s", dryRun) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowUpdate, []string{ + "+workflow-update", + "--base-token", "app_x", + "--workflow-id", "wkf_1", + "--json", body(tt.noMatchAction, tt.links), + "--dry-run", + "--format", "pretty", + }, factory, stdout) + if tt.want != "" { + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want substring %q", err, tt.want) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("err type=%T want *errs.ValidationError", err) + } + return + } + if err != nil { + t.Fatalf("dry-run err=%v", err) + } + dryRun := stdout.String() + if !strings.Contains(dryRun, "PUT /open-apis/base/v3/bases/app_x/workflows/wkf_1") || !strings.Contains(dryRun, tt.wantForwarded) { + t.Fatalf("dry-run did not preserve the validated request: %s", dryRun) + } + if tt.noMatchAction == "" && strings.Contains(dryRun, `"no_match_action"`) { + t.Fatalf("dry-run must not inject omitted no_match_action: %s", dryRun) + } + }) } } diff --git a/shortcuts/base/workflow_json_validation.go b/shortcuts/base/workflow_json_validation.go index d560f3c949..90e77b7aea 100644 --- a/shortcuts/base/workflow_json_validation.go +++ b/shortcuts/base/workflow_json_validation.go @@ -5,26 +5,19 @@ package base import "github.com/larksuite/cli/shortcuts/common" -type workflowWriteOperation uint8 - -const ( - workflowWriteCreate workflowWriteOperation = iota - workflowWriteUpdate -) - -func parseWorkflowBodyJSON(runtime *common.RuntimeContext, operation workflowWriteOperation) (map[string]interface{}, error) { +func parseWorkflowBodyJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { pc := newParseCtx(runtime) body, err := parseJSONObject(pc, runtime.Str("json"), "json") if err != nil { return nil, err } - if err := validateWorkflowBodyForCLI(body, operation); err != nil { + if err := validateWorkflowBodyForCLI(body); err != nil { return nil, err } return body, nil } -func validateWorkflowBodyForCLI(body map[string]interface{}, operation workflowWriteOperation) error { +func validateWorkflowBodyForCLI(body map[string]interface{}) error { steps, ok := body["steps"].([]interface{}) if !ok { return nil @@ -47,7 +40,7 @@ func validateWorkflowBodyForCLI(body map[string]interface{}, operation workflowW if stepIDs == nil { stepIDs = indexWorkflowStepIDs(steps) } - if err := validateWorkflowAIClassificationStep(stepIndex, step, stepIDs, operation); err != nil { + if err := validateWorkflowAIClassificationStep(stepIndex, step, stepIDs); err != nil { return err } } diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index 38cbe54233..f367f0720f 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -38,14 +38,14 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return baseFlagErrorf("--workflow-id must not be blank") } - if _, err := parseWorkflowBodyJSON(runtime, workflowWriteUpdate); err != nil { + if _, err := parseWorkflowBodyJSON(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - body, _ = parseWorkflowBodyJSON(runtime, workflowWriteUpdate) + body, _ = parseWorkflowBodyJSON(runtime) return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). Body(body). @@ -53,7 +53,7 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := parseWorkflowBodyJSON(runtime, workflowWriteUpdate) + body, err := parseWorkflowBodyJSON(runtime) if err != nil { return err } diff --git a/skills/lark-base/references/lark-base-workflow-schema.md b/skills/lark-base/references/lark-base-workflow-schema.md index abdeca1ef9..9769f66deb 100644 --- a/skills/lark-base/references/lark-base-workflow-schema.md +++ b/skills/lark-base/references/lark-base-workflow-schema.md @@ -598,14 +598,14 @@ } ``` -| 字段 | 必填 | 说明 | -|------|------|------| -| `classes` | 是 | 分类列表,至少 2 项。每项包含 `name` 和 `desc` | -| `classes[].name` | 是 | 分类名称,需与对应普通 `children.links[].desc` 保持一致 | -| `classes[].desc` | 是 | 分类描述,可为空字符串,但字段必须存在 | -| `content` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` | -| `classification_rule` | 否 | 全局分类规则纯文本 | -| `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。创建缺省时使用 `classifyToOther`,更新缺省时保留既有配置 | +| 字段 | 必填 | 说明 | +|------|------|----------------------------------------------------------------------| +| `classes` | 是 | 分类列表,至少 2 项。每项包含 `name` 和 `desc` | +| `classes[].name` | 是 | 分类名称,需与对应普通 `children.links[].desc` 保持一致 | +| `classes[].desc` | 是 | 分类描述,可为空字符串,但字段必须存在 | +| `content` | 是 | TextRefItem[],用于分类的内容,支持 `text` / `ref` | +| `classification_rule` | 否 | 全局分类规则纯文本 | +| `no_match_action` | 否 | 无匹配策略。`classifyToOther`:进入默认分支;`fail`:当前节点失败。省略时使用 `classifyToOther` | `children.links` 规则: - 每个分类命中后要跳到哪个后续步骤,必须写在 children.links 中。 diff --git a/skills/lark-base/references/lark-base-workflow.md b/skills/lark-base/references/lark-base-workflow.md index e2d6704e75..dfb7edb0a6 100644 --- a/skills/lark-base/references/lark-base-workflow.md +++ b/skills/lark-base/references/lark-base-workflow.md @@ -834,7 +834,6 @@ ``` **关键点**: - `classes` 按顺序对应 `branch_1`、`branch_2`;`desc` 与分类名一致,`to` 指向已定义的下游 step; -- 创建时省略 no_match_action 等同于 classifyToOther,必须提供 default 边;更新时省略会保留既有策略。 --- From 482fa9b1ad1ee22bc2980281e6cd07ccce233bbe Mon Sep 17 00:00:00 2001 From: "zhangbinkai.zbk" Date: Wed, 2 Sep 2026 17:11:10 +0800 Subject: [PATCH 33/33] Revert "feat: support development environment overrides" This reverts commit f805081e8989979e2311bc38277215e2cc8c65db. --- .agents/skills/lark-approval | 1 - .agents/skills/lark-apps | 1 - .agents/skills/lark-attendance | 1 - .agents/skills/lark-base | 1 - .agents/skills/lark-calendar | 1 - .agents/skills/lark-contact | 1 - .agents/skills/lark-doc | 1 - .agents/skills/lark-drive | 1 - .agents/skills/lark-event | 1 - .agents/skills/lark-im | 1 - .agents/skills/lark-mail | 1 - .agents/skills/lark-markdown | 1 - .agents/skills/lark-minutes | 1 - .agents/skills/lark-note | 1 - .agents/skills/lark-okr | 1 - .agents/skills/lark-openapi-explorer | 1 - .agents/skills/lark-shared | 1 - .agents/skills/lark-sheets | 1 - .agents/skills/lark-skill-maker | 1 - .agents/skills/lark-slides | 1 - .agents/skills/lark-task | 1 - .agents/skills/lark-vc | 1 - .agents/skills/lark-vc-agent | 1 - .agents/skills/lark-whiteboard | 1 - .agents/skills/lark-wiki | 1 - .agents/skills/lark-workflow-meeting-summary | 1 - .agents/skills/lark-workflow-standup-report | 1 - .gitignore | 1 - env/claude-dev-lark.sh | 256 ------------- env/codex-dev-lark.sh | 362 ------------------- env/larkenv | 353 ------------------ internal/cmdutil/secheader.go | 5 - internal/cmdutil/secheader_test.go | 9 - internal/core/types.go | 23 +- internal/core/types_test.go | 29 -- internal/envvars/envvars.go | 4 - internal/envvars/read.go | 35 -- internal/envvars/read_test.go | 27 -- 38 files changed, 2 insertions(+), 1129 deletions(-) delete mode 120000 .agents/skills/lark-approval delete mode 120000 .agents/skills/lark-apps delete mode 120000 .agents/skills/lark-attendance delete mode 120000 .agents/skills/lark-base delete mode 120000 .agents/skills/lark-calendar delete mode 120000 .agents/skills/lark-contact delete mode 120000 .agents/skills/lark-doc delete mode 120000 .agents/skills/lark-drive delete mode 120000 .agents/skills/lark-event delete mode 120000 .agents/skills/lark-im delete mode 120000 .agents/skills/lark-mail delete mode 120000 .agents/skills/lark-markdown delete mode 120000 .agents/skills/lark-minutes delete mode 120000 .agents/skills/lark-note delete mode 120000 .agents/skills/lark-okr delete mode 120000 .agents/skills/lark-openapi-explorer delete mode 120000 .agents/skills/lark-shared delete mode 120000 .agents/skills/lark-sheets delete mode 120000 .agents/skills/lark-skill-maker delete mode 120000 .agents/skills/lark-slides delete mode 120000 .agents/skills/lark-task delete mode 120000 .agents/skills/lark-vc delete mode 120000 .agents/skills/lark-vc-agent delete mode 120000 .agents/skills/lark-whiteboard delete mode 120000 .agents/skills/lark-wiki delete mode 120000 .agents/skills/lark-workflow-meeting-summary delete mode 120000 .agents/skills/lark-workflow-standup-report delete mode 100755 env/claude-dev-lark.sh delete mode 100755 env/codex-dev-lark.sh delete mode 100755 env/larkenv diff --git a/.agents/skills/lark-approval b/.agents/skills/lark-approval deleted file mode 120000 index be32751967..0000000000 --- a/.agents/skills/lark-approval +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-approval \ No newline at end of file diff --git a/.agents/skills/lark-apps b/.agents/skills/lark-apps deleted file mode 120000 index 326837d904..0000000000 --- a/.agents/skills/lark-apps +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-apps \ No newline at end of file diff --git a/.agents/skills/lark-attendance b/.agents/skills/lark-attendance deleted file mode 120000 index cea17c567b..0000000000 --- a/.agents/skills/lark-attendance +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-attendance \ No newline at end of file diff --git a/.agents/skills/lark-base b/.agents/skills/lark-base deleted file mode 120000 index 480c521328..0000000000 --- a/.agents/skills/lark-base +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-base \ No newline at end of file diff --git a/.agents/skills/lark-calendar b/.agents/skills/lark-calendar deleted file mode 120000 index 0e067fb7c4..0000000000 --- a/.agents/skills/lark-calendar +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-calendar \ No newline at end of file diff --git a/.agents/skills/lark-contact b/.agents/skills/lark-contact deleted file mode 120000 index c4b523b6f7..0000000000 --- a/.agents/skills/lark-contact +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-contact \ No newline at end of file diff --git a/.agents/skills/lark-doc b/.agents/skills/lark-doc deleted file mode 120000 index 36be178ac6..0000000000 --- a/.agents/skills/lark-doc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-doc \ No newline at end of file diff --git a/.agents/skills/lark-drive b/.agents/skills/lark-drive deleted file mode 120000 index b90475836f..0000000000 --- a/.agents/skills/lark-drive +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-drive \ No newline at end of file diff --git a/.agents/skills/lark-event b/.agents/skills/lark-event deleted file mode 120000 index 0edcd85ff3..0000000000 --- a/.agents/skills/lark-event +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-event \ No newline at end of file diff --git a/.agents/skills/lark-im b/.agents/skills/lark-im deleted file mode 120000 index 0083317e7e..0000000000 --- a/.agents/skills/lark-im +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-im \ No newline at end of file diff --git a/.agents/skills/lark-mail b/.agents/skills/lark-mail deleted file mode 120000 index 1dc9d46966..0000000000 --- a/.agents/skills/lark-mail +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-mail \ No newline at end of file diff --git a/.agents/skills/lark-markdown b/.agents/skills/lark-markdown deleted file mode 120000 index 780a87258d..0000000000 --- a/.agents/skills/lark-markdown +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-markdown \ No newline at end of file diff --git a/.agents/skills/lark-minutes b/.agents/skills/lark-minutes deleted file mode 120000 index f39545a341..0000000000 --- a/.agents/skills/lark-minutes +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-minutes \ No newline at end of file diff --git a/.agents/skills/lark-note b/.agents/skills/lark-note deleted file mode 120000 index 64b27c3543..0000000000 --- a/.agents/skills/lark-note +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-note \ No newline at end of file diff --git a/.agents/skills/lark-okr b/.agents/skills/lark-okr deleted file mode 120000 index c0b5104fe3..0000000000 --- a/.agents/skills/lark-okr +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-okr \ No newline at end of file diff --git a/.agents/skills/lark-openapi-explorer b/.agents/skills/lark-openapi-explorer deleted file mode 120000 index 0357b2ae30..0000000000 --- a/.agents/skills/lark-openapi-explorer +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-openapi-explorer \ No newline at end of file diff --git a/.agents/skills/lark-shared b/.agents/skills/lark-shared deleted file mode 120000 index 90db01f71f..0000000000 --- a/.agents/skills/lark-shared +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-shared \ No newline at end of file diff --git a/.agents/skills/lark-sheets b/.agents/skills/lark-sheets deleted file mode 120000 index 7a4bb8f954..0000000000 --- a/.agents/skills/lark-sheets +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-sheets \ No newline at end of file diff --git a/.agents/skills/lark-skill-maker b/.agents/skills/lark-skill-maker deleted file mode 120000 index 20a8485a86..0000000000 --- a/.agents/skills/lark-skill-maker +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-skill-maker \ No newline at end of file diff --git a/.agents/skills/lark-slides b/.agents/skills/lark-slides deleted file mode 120000 index a7b448b3a3..0000000000 --- a/.agents/skills/lark-slides +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-slides \ No newline at end of file diff --git a/.agents/skills/lark-task b/.agents/skills/lark-task deleted file mode 120000 index 40893742c9..0000000000 --- a/.agents/skills/lark-task +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-task \ No newline at end of file diff --git a/.agents/skills/lark-vc b/.agents/skills/lark-vc deleted file mode 120000 index 6e20cbcd71..0000000000 --- a/.agents/skills/lark-vc +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc \ No newline at end of file diff --git a/.agents/skills/lark-vc-agent b/.agents/skills/lark-vc-agent deleted file mode 120000 index 550b733c72..0000000000 --- a/.agents/skills/lark-vc-agent +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-vc-agent \ No newline at end of file diff --git a/.agents/skills/lark-whiteboard b/.agents/skills/lark-whiteboard deleted file mode 120000 index a3e187627c..0000000000 --- a/.agents/skills/lark-whiteboard +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-whiteboard \ No newline at end of file diff --git a/.agents/skills/lark-wiki b/.agents/skills/lark-wiki deleted file mode 120000 index aab2f46f80..0000000000 --- a/.agents/skills/lark-wiki +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-wiki \ No newline at end of file diff --git a/.agents/skills/lark-workflow-meeting-summary b/.agents/skills/lark-workflow-meeting-summary deleted file mode 120000 index 58081f1f58..0000000000 --- a/.agents/skills/lark-workflow-meeting-summary +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-meeting-summary \ No newline at end of file diff --git a/.agents/skills/lark-workflow-standup-report b/.agents/skills/lark-workflow-standup-report deleted file mode 120000 index b9e06f0d2c..0000000000 --- a/.agents/skills/lark-workflow-standup-report +++ /dev/null @@ -1 +0,0 @@ -../../skills/lark-workflow-standup-report \ No newline at end of file diff --git a/.gitignore b/.gitignore index e0bbba695d..645848906d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,4 @@ app.log cover*.out lark-env.sh -.agents/ /automations/ diff --git a/env/claude-dev-lark.sh b/env/claude-dev-lark.sh deleted file mode 100755 index dc6362de4f..0000000000 --- a/env/claude-dev-lark.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Claude Code in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.claude/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/claude-dev-lark.sh [options] [--] [claude args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, or online. - Default: boe - --ppe, --use-ppe Use PPE: pre endpoint plus x-use-ppe:1 and env:pre_release headers. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/claude-dev-lark.sh - env/claude-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/claude-dev-lark.sh --skill lark-base - env/claude-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -claude_args=() -claude_arg_count=0 - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - claude_args=("$@") - claude_arg_count=$# - break - ;; - *) - claude_args+=("$1") - claude_arg_count=$((claude_arg_count + 1)) - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | online) ;; -*) die "--env must be one of: boe, pre, ppe, online" ;; -esac - -command -v claude >/dev/null 2>&1 || die "claude not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -# launch_claude starts Claude Code with the caller's environment inherited as -# is, including any proxy variables already exported by the shell. Network setup -# is left to the caller so this script stays portable. -# -# If your proxy is configured through a shell function (rather than exported -# variables), invoke this script through it, e.g. `my_proxy_wrapper -# env/claude-dev-lark.sh`. A bash script cannot exec a shell function. -# -# lark-cli traffic is unaffected either way for boe: larkenv boe sets -# LARK_CLI_NO_PROXY=1 and dials the internal endpoint directly. For pre/ppe -# larkenv does not set it, so if you run behind a proxy make sure feishu-pre.cn -# is in your own no_proxy — ".feishu.cn" does not cover "feishu-pre.cn". -launch_claude() { - exec claude --allow-dangerously-skip-permissions "$@" -} - -bin_dir="$repo_root/.claude-dev/bin" -skills_dir="$repo_root/.claude/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -> "$bin_dir/lark-cli" echo '#!/usr/bin/env bash' - -# Only PPE needs the extra headers; every other target keeps the plain shim so -# the non-PPE path stays byte-identical to what it was before PPE support. -if [ "$use_ppe" -eq 1 ]; then - cat >>"$bin_dir/lark-cli" <<'PPE_SHIM' - -for h in "x-use-ppe:1" "env:pre_release"; do - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $h;"*) ;; - *) - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $h" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$h" - fi - ;; - esac -done -PPE_SHIM -fi - -cat >>"$bin_dir/lark-cli" < Starting Claude Code with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 - echo " identity: reuses the online app config and user login state" >&2 -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -export LARK_LANE="$lane" -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -# Claude has no "-C " flag like Codex; it uses the current working -# directory, which is $repo_root here (see the cd above). -if [ "$claude_arg_count" -eq 0 ]; then - launch_claude -fi - -launch_claude "${claude_args[@]}" diff --git a/env/codex-dev-lark.sh b/env/codex-dev-lark.sh deleted file mode 100755 index dee647e9bd..0000000000 --- a/env/codex-dev-lark.sh +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# Launch Codex in this checkout with project-local Lark skills and a dev -# lark-cli shim. The global ~/.agents/skills install is left untouched. - -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: - env/codex-dev-lark.sh [options] [--] [codex args or initial prompt] - -Options: - --lane Lane injected through LARK_LANE; ignored by --use-pre. - Default: boe_bitable_bk11 - --env larkenv target: boe, pre, ppe, use-pre, or online. - Default: boe - --ppe, --use-ppe Use PPE for business APIs; auth/config remain on production. - --use-pre Use pre for business APIs without x-use-ppe or X-TT-ENV; - auth/config remain on production. - --skill Link only one local skill, e.g. lark-base. - Default: all lark-* skills under ./skills - --cx Launch Codex with cx-style permissions: - codex --dangerously-bypass-approvals-and-sandbox - Adds --profile proxy when CODEX_PROXY_API_KEY is exported. - --no-cx Disable --cx when CODEX_DEV_LARK_CX=1 is set. - --no-build Reuse the current ./lark-cli binary instead of rebuilding. - -h, --help Show this help. - -Examples: - env/codex-dev-lark.sh - CODEX_DEV_LARK_CX=1 env/codex-dev-lark.sh - env/codex-dev-lark.sh --cx - env/codex-dev-lark.sh --lane boe_larkcli_baseapp --use-ppe - env/codex-dev-lark.sh --use-pre - env/codex-dev-lark.sh --skill lark-base - env/codex-dev-lark.sh -- "用 Base Skill 查一下这个 workspace" -USAGE -} - -die() { - echo "error: $*" >&2 - exit 1 -} - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -lane="${LARK_LANE:-boe_bitable_bk11}" -target_env="${LARKENV_TARGET:-boe}" -target_env_explicit=0 -skill_filter="all" -do_build=1 -use_ppe=0 -use_pre=0 -use_cx="${CODEX_DEV_LARK_CX:-0}" -codex_args=() - -while [ $# -gt 0 ]; do - case "$1" in - --lane) - [ $# -ge 2 ] || die "--lane requires a value" - lane="$2" - shift 2 - ;; - --env) - [ $# -ge 2 ] || die "--env requires a value" - target_env="$2" - target_env_explicit=1 - shift 2 - ;; - --skill) - [ $# -ge 2 ] || die "--skill requires a value" - skill_filter="$2" - shift 2 - ;; - --ppe | --use-ppe) - use_ppe=1 - shift - ;; - --use-pre) - use_pre=1 - shift - ;; - --cx) - use_cx=1 - shift - ;; - --no-cx) - use_cx=0 - shift - ;; - --no-build) - do_build=0 - shift - ;; - -h | --help) - usage - exit 0 - ;; - --) - shift - codex_args=("$@") - break - ;; - *) - codex_args+=("$1") - shift - ;; - esac -done - -if [ "$use_ppe" -eq 1 ] && [ "$use_pre" -eq 1 ]; then - die "--use-ppe and --use-pre are mutually exclusive" -fi - -if [ "$use_ppe" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="ppe" -fi -if [ "$use_ppe" -eq 1 ]; then - case "$target_env" in - ppe) ;; - *) die "--use-ppe uses the pre endpoint; remove --env $target_env or pass --env ppe" ;; - esac -fi -if [ "$use_pre" -eq 1 ] && [ "$target_env_explicit" -eq 0 ]; then - target_env="use-pre" -fi -if [ "$use_pre" -eq 1 ]; then - case "$target_env" in - use-pre) ;; - *) die "--use-pre uses the pre endpoint without PPE/lane headers; remove --env $target_env or pass --env use-pre" ;; - esac -fi - -case "$target_env" in -boe | pre | ppe | use-pre | online) ;; -*) die "--env must be one of: boe, pre, ppe, use-pre, online" ;; -esac - -use_pre_effective=0 -if [ "$target_env" = "use-pre" ]; then - use_pre_effective=1 -fi - -case "$use_cx" in -0 | 1) ;; -*) die "CODEX_DEV_LARK_CX must be 0 or 1" ;; -esac - -codex_launch_args=(-C "$repo_root" -c shell_environment_policy.inherit=all) -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - codex_launch_args=(--profile proxy --dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - else - codex_launch_args=(--dangerously-bypass-approvals-and-sandbox "${codex_launch_args[@]}") - fi -fi - -command -v codex >/dev/null 2>&1 || die "codex not found in PATH" -[ -x "$repo_root/env/larkenv" ] || die "missing executable env/larkenv" - -bin_dir="$repo_root/.codex-dev/bin" -skills_dir="$repo_root/.agents/skills" -mkdir -p "$bin_dir" "$skills_dir" - -cd "$repo_root" - -if [ "$do_build" -eq 1 ]; then - echo "==> Building dev lark-cli from $repo_root" >&2 - ./build.sh -else - echo "==> Reusing existing ./lark-cli" >&2 -fi - -[ -x "$repo_root/lark-cli" ] || die "missing ./lark-cli; run without --no-build first" - -cp "$repo_root/lark-cli" "$bin_dir/lark-cli-env" -cp "$repo_root/env/larkenv" "$bin_dir/larkenv" -chmod +x "$bin_dir/lark-cli-env" "$bin_dir/larkenv" - -link_skill() { - local name="$1" - local src="$repo_root/skills/$name" - local dst="$skills_dir/$name" - local rel="../../skills/$name" - local current backup - - [ -f "$src/SKILL.md" ] || die "missing skill: skills/$name/SKILL.md" - - current="$(readlink "$dst" 2>/dev/null || true)" - if [ "$current" = "$rel" ]; then - return - fi - - if [ -L "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill symlink: $backup" >&2 - elif [ -e "$dst" ]; then - backup="$dst.bak.$(date +%Y%m%d%H%M%S)" - mv "$dst" "$backup" - echo "==> Backed up existing local skill directory: $backup" >&2 - fi - - ln -s "$rel" "$dst" -} - -if [ "$skill_filter" = "all" ]; then - found=0 - for skill_path in "$repo_root"/skills/lark-*; do - [ -d "$skill_path" ] || continue - link_skill "${skill_path##*/}" - found=1 - done - [ "$found" -eq 1 ] || die "no local lark-* skills found under ./skills" -else - link_skill "$skill_filter" -fi - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -shim_path="$bin_dir/lark-cli" -shim_tmp="$bin_dir/.lark-cli.$$.tmp" -cat >"$shim_tmp" < Starting Codex with dev lark-cli and project-local skills" >&2 -echo " PATH prefix: $bin_dir" >&2 -echo " Skill root: $skills_dir" >&2 -if [ "$use_pre_effective" -eq 1 ]; then - echo " lark-cli -> larkenv $target_env" >&2 -else - echo " lark-cli -> LARK_LANE=$lane larkenv $target_env" >&2 -fi -if [ "$use_ppe" -eq 1 ] || [ "$target_env" = "ppe" ]; then - echo " extra headers: x-use-ppe:1; env:pre_release" >&2 -fi -if [ "$use_pre_effective" -eq 1 ]; then - echo " extra headers: env:pre_release" >&2 - echo " omitted: x-use-ppe; X-TT-ENV" >&2 -fi -if [ "$use_cx" -eq 1 ]; then - if [ -n "${CODEX_PROXY_API_KEY:-}" ]; then - echo " codex mode: cx (--profile proxy --dangerously-bypass-approvals-and-sandbox)" >&2 - else - echo " codex mode: cx (--dangerously-bypass-approvals-and-sandbox; proxy profile skipped)" >&2 - fi -fi - -export PATH="$bin_dir:$PATH" -export LARK_CLI_ENV_BIN="$bin_dir" -if [ "$use_pre_effective" -eq 1 ]; then - unset LARK_LANE - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" -else - export LARK_LANE="$lane" -fi -if [ "$use_ppe" -eq 1 ]; then - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" -elif [ "$use_pre_effective" -eq 1 ]; then - append_extra_header "env:pre_release" -fi -export LARKSUITE_CLI_NO_UPDATE_NOTIFIER="${LARKSUITE_CLI_NO_UPDATE_NOTIFIER:-1}" -export LARKSUITE_CLI_NO_SKILLS_NOTIFIER="${LARKSUITE_CLI_NO_SKILLS_NOTIFIER:-1}" - -exec codex "${codex_launch_args[@]}" "${codex_args[@]}" diff --git a/env/larkenv b/env/larkenv deleted file mode 100755 index 5054dba2ac..0000000000 --- a/env/larkenv +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -# -# larkenv —— 一个脚本搞定 lark-cli 的 boe / pre / ppe / use-pre / online 环境切换。 -# -# ./env/larkenv setup 编译 + 安装 + 自动配 PATH(只需跑这一次) -# larkenv init boe 一条龙:配 app(不弹选择)+ 固定 user 身份 + 登录 -# larkenv init boe 同上,但走交互式选 app -# larkenv login boe 只补登录(app 已配好时用),自动出二维码 -# larkenv qr <登录URL> 手动把某个 URL 转成终端二维码 -# larkenv boe <任何 lark-cli 命令> -# larkenv pre <任何 lark-cli 命令> -# larkenv ppe <任何 lark-cli 命令> -# larkenv use-pre <任何 lark-cli 命令> -# larkenv online <任何 lark-cli 命令> -# -# 可选: -# LARK_LANE=<泳道> larkenv ... 指定泳道(use-pre 除外,注入 X-TT-ENV 头) -# LARK_CLI_SHOW_LOGID=1 larkenv ... 打印所有请求的 x-tt-logid(默认只在出错时打) -# LARK_CLI_SHOW_REQ_DETAIL=1 larkenv ... 打印每次请求/响应的完整 header 和 body(凭证已脱敏) -# LARK_CLI_DEBUG_HEADERS=1 larkenv ... 只打印出站 header,不打 body -# -# boe / pre / online 的配置和登录态隔离在 ~/.lark-cli-env/config/{boe,pre,online}。 -# ppe / use-pre 复用正式 CLI 的默认配置和用户登录态;业务 API 使用 pre -# endpoint 和 pre_release header,ppe 额外注入 PPE/lane headers,auth/config -# 控制面仍使用正式 endpoint。 -set -euo pipefail - -BIN_DIR="${LARK_CLI_ENV_BIN:-$HOME/.local/bin}" -CONFIG_ROOT="$HOME/.lark-cli-env" -BIN="$BIN_DIR/lark-cli-env" - -usage() { - # Print the header comment block (from the title line to the first - # non-comment line), stripped of its leading "# ". - awk 'NR<5{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" - exit "${1:-0}" -} - -# ---------- setup:编译 + 安装 + 配 PATH ---------- -do_setup() { - local repo_root - repo_root="$(cd "$(dirname "$0")/.." && pwd)" - - echo "==> 编译 lark-cli ..." - (cd "$repo_root" && ./build.sh) - - echo "==> 安装到 $BIN_DIR ..." - mkdir -p "$BIN_DIR" "$CONFIG_ROOT/config" - cp "$repo_root/lark-cli" "$BIN" - cp "$repo_root/env/larkenv" "$BIN_DIR/larkenv" - chmod +x "$BIN" "$BIN_DIR/larkenv" - - # 幂等地把 BIN_DIR 加进 shell rc - local rc marker='# added by lark-cli env/larkenv' - case "${SHELL##*/}" in - zsh) rc="$HOME/.zshrc" ;; - bash) rc="$HOME/.bashrc" ;; - *) rc="" ;; - esac - if [ -n "$rc" ] && ! grep -qF "$marker" "$rc" 2>/dev/null; then - printf '\n%s\nexport PATH="%s:$PATH"\n' "$marker" "$BIN_DIR" >>"$rc" - echo "==> 已把 $BIN_DIR 写入 $rc" - fi - - # NOTE: brace the expansions below — the surrounding full-width punctuation - # would otherwise be swallowed into the variable name by bash. - # Plain `[ … ] && x=y` would return non-zero on the else path and trip set -e. - local reload="新开一个终端" - if [ -n "$rc" ]; then - reload="新开一个终端(或 source ${rc})" - fi - - cat < # 其他环境复用同一个 app,不用重配 - larkenv login boe # 登录态失效时,只补登录 - - larkenv boe base +table-list --base-token - larkenv pre wiki +node-get --token - larkenv ppe drive +search --query xxx - larkenv use-pre drive +search --query xxx - larkenv online drive +search --query xxx - -EOF -} - -append_extra_header() { - local header="$1" - case "; ${LARKSUITE_CLI_EXTRA_HEADERS:-};" in - *"; $header;"*) return ;; - esac - if [ -n "${LARKSUITE_CLI_EXTRA_HEADERS:-}" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="${LARKSUITE_CLI_EXTRA_HEADERS}; $header" - else - export LARKSUITE_CLI_EXTRA_HEADERS="$header" - fi -} - -remove_extra_header() { - local target="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - [ "$item" = "$target" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -remove_extra_header_name() { - local target_name="$1" - local raw="${LARKSUITE_CLI_EXTRA_HEADERS:-}" - local item result="" name - local IFS=';' - - for item in $raw; do - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - [ -n "$item" ] || continue - name="${item%%:*}" - name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - [ "$name" = "$target_name" ] && continue - if [ -n "$result" ]; then - result="$result; $item" - else - result="$item" - fi - done - - if [ -n "$result" ]; then - export LARKSUITE_CLI_EXTRA_HEADERS="$result" - else - unset LARKSUITE_CLI_EXTRA_HEADERS - fi -} - -# PPE reuses the production app config and user token, so authentication and -# configuration must stay on the production control plane. The launcher may -# already have injected PPE routing into the parent environment; remove only -# those routing values while preserving unrelated caller-supplied headers. -apply_ppe_control_env() { - unset LARKSUITE_CLI_CONFIG_DIR - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - remove_extra_header_name "x-use-ppe" - remove_extra_header "env:pre_release" - remove_extra_header_name "x-tt-env" - unset LARK_LANE -} - -# ---------- 环境变量注入 ---------- -apply_env() { - # Normalize values that may have been inherited from a previously selected - # environment before applying the requested target. - unset LARKSUITE_CLI_ENDPOINT_DOMAIN - unset LARK_CLI_NO_PROXY - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - # PPE / use-pre 与正式环境使用同一套应用配置和用户登录态。 - # 显式 unset,避免调用方继承的配置目录把它们再次隔离。 - unset LARKSUITE_CLI_CONFIG_DIR - else - export LARKSUITE_CLI_CONFIG_DIR="$CONFIG_ROOT/config/$1" - remove_extra_header "x-use-ppe:1" - remove_extra_header "env:pre_release" - fi - case "$1" in - boe) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-boe.cn" - # boe 是内网域名,必须绕开公司外部 relay 代理 - export LARK_CLI_NO_PROXY=1 - ;; - pre) - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - ;; - ppe) - # PPE 复用正式登录态,但请求明确发往 pre 网关,并通过 headers - # 选择 pre_release 环境和目标泳道。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - append_extra_header "x-use-ppe:1" - append_extra_header "env:pre_release" - ;; - use-pre) - # use-pre 与 PPE 一样复用正式登录态并访问 pre 网关,但不能带 - # x-use-ppe 或泳道头。 - export LARKSUITE_CLI_ENDPOINT_DOMAIN="feishu-pre.cn" - remove_extra_header_name "x-use-ppe" - remove_extra_header_name "x-tt-env" - unset LARK_LANE - append_extra_header "env:pre_release" - ;; - online) - # endpoint 已清除,走开源默认 feishu.cn / larksuite.com;仅隔离配置目录 - ;; - esac - # Append rather than overwrite, so a caller-supplied LARKSUITE_CLI_EXTRA_HEADERS - # (e.g. "x-use-boe: 1") survives alongside LARK_LANE. - if [ -n "${LARK_LANE:-}" ]; then - append_extra_header "X-TT-ENV: $LARK_LANE" - fi - if [ -n "${LARKSUITE_CLI_CONFIG_DIR:-}" ]; then - mkdir -p "$LARKSUITE_CLI_CONFIG_DIR" - fi -} - -apply_control_env() { - if [ "$1" = "ppe" ] || [ "$1" = "use-pre" ]; then - apply_ppe_control_env - else - apply_env "$1" - fi -} - -# json_field extracts a top-level string field from a JSON object on stdin. -json_field() { - if command -v jq >/dev/null 2>&1; then - jq -r --arg k "$1" '.[$k] // empty' - else - python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1],""))' "$1" - fi -} - -# do_login runs the device flow but renders the verification URL as a terminal -# QR code before polling. Scanning with your own Feishu app avoids authorizing -# as whichever account the default browser happens to be signed in as — the -# usual cause of "用户登录态无效 (20033)" on a shared machine. -do_login() { - local json url code - json="$("$BIN" auth login --recommend --no-wait --json)" - url="$(printf '%s' "$json" | json_field verification_url)" - code="$(printf '%s' "$json" | json_field device_code)" - - if [ -z "$url" ] || [ -z "$code" ]; then - echo "无法解析登录信息,原始输出:" >&2 - printf '%s\n' "$json" >&2 - return 1 - fi - - echo "" >&2 - echo "用你自己的手机飞书扫下面的码(别用浏览器,容易串成别人的账号):" >&2 - echo "" >&2 - "$BIN" auth qrcode --ascii "$url" - echo "" >&2 - echo "扫不了的话再用链接:$url" >&2 - echo "" >&2 - - "$BIN" auth login --device-code "$code" -} - -require_bin() { - [ -x "$BIN" ] || { - echo "还没安装,先在仓库根目录跑一次:./env/larkenv setup" >&2 - exit 1 - } -} - -# ---------- 入口 ---------- -[ $# -ge 1 ] || usage 1 - -case "$1" in -setup) - do_setup - ;; -qr) - # Renders the device-flow URL as a terminal QR code. The CLI never opens a - # browser itself, so scanning with your own Feishu app is the way to avoid - # authorizing as whichever account the default browser happens to hold. - [ $# -eq 2 ] || { - echo "用法: larkenv qr <登录URL>" >&2 - exit 1 - } - require_bin - exec "$BIN" auth qrcode --ascii "$2" - ;; -init) - # With an app id, config init runs fully non-interactively — no app-selection - # prompt at all. boe/pre can reuse the online app id, so there is usually no - # reason to register a separate app per environment. - case $# in - 2 | 3) ;; - *) - echo "用法: larkenv init [app-id]" >&2 - exit 1 - ;; - esac - require_bin - apply_control_env "$2" - - if [ $# -eq 3 ]; then - printf 'App Secret (输入不回显): ' >&2 - stty -echo 2>/dev/null || true - read -r secret - stty echo 2>/dev/null || true - printf '\n' >&2 - # --app-secret-stdin keeps the secret out of the process list. - printf '%s' "$secret" | "$BIN" config init --app-id "$3" --app-secret-stdin - unset secret - else - echo "提示:已有 app 时可直接 'larkenv init $2 ' 跳过选择流程。" >&2 - "$BIN" config init - fi - - # 固定成 user 身份,之后所有命令都不用再带 --as user。 - "$BIN" config default-as user - do_login - ;; -login) - # 单独补登录:app 配好了、只是授权失败或过期时用这个,不用重跑 init。 - [ $# -eq 2 ] || { - echo "用法: larkenv login " >&2 - exit 1 - } - require_bin - apply_control_env "$2" - do_login - ;; -boe | pre | ppe | use-pre | online) - require_bin - env_name="$1" - shift - if { [ "$env_name" = "ppe" ] || [ "$env_name" = "use-pre" ]; } && { [ "${1:-}" = "auth" ] || [ "${1:-}" = "config" ]; }; then - apply_ppe_control_env - else - apply_env "$env_name" - fi - exec "$BIN" "$@" - ;; --h | --help | help) - usage 0 - ;; -*) - echo "未知命令 '$1'(可用: boe / pre / ppe / use-pre / online / setup / init / login / qr)" >&2 - exit 1 - ;; -esac diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 96120ed6bd..881aba3c80 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -59,11 +59,6 @@ func BaseSecurityHeaders() http.Header { if v := envvars.AgentName(); v != "" { h.Set(HeaderAgentName, v) } - for k, vs := range envvars.ExtraHeaders() { - for _, v := range vs { - h.Set(k, v) - } - } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index c6f55e1d6f..13ad93b442 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -262,15 +262,6 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } -func TestBaseSecurityHeaders_IncludesExtraHeaders(t *testing.T) { - t.Setenv(envvars.CliExtraHeaders, "X-TT-ENV: boe_bitable_bk") - - h := BaseSecurityHeaders() - if got := h.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } -} - // --------------------------------------------------------------------------- // Agent headers injected via BaseSecurityHeaders // --------------------------------------------------------------------------- diff --git a/internal/core/types.go b/internal/core/types.go index 86abaa2847..4f8803cd4f 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -6,7 +6,6 @@ package core import ( "context" "net/url" - "os" "strings" "github.com/larksuite/cli/internal/envvars" @@ -130,40 +129,22 @@ type Endpoints struct { // ResolveEndpoints resolves endpoint URLs for the brand, normalizing its // input so stored values with unusual casing still resolve correctly. func ResolveEndpoints(brand LarkBrand) Endpoints { - var endpoints Endpoints switch ParseBrand(string(brand)) { case BrandLark: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", AppLink: "https://applink.larksuite.com", } default: - endpoints = Endpoints{ + return Endpoints{ Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } } - if domain := endpointDomainOverride(); domain != "" { - endpoints.Open = "https://open." + domain - endpoints.Accounts = "https://accounts." + domain - endpoints.MCP = "https://mcp." + domain - endpoints.AppLink = "https://applink." + domain - } - return endpoints -} - -func endpointDomainOverride() string { - value := strings.ToLower(strings.TrimSpace(os.Getenv(envvars.CliEndpointDomain))) - if value == "" || - strings.Contains(value, "://") || - strings.ContainsAny(value, "/?#@") { - return "" - } - return strings.TrimRight(value, ".") } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 259c21a57c..23883493c7 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -8,8 +8,6 @@ import ( "net/url" "reflect" "testing" - - "github.com/larksuite/cli/internal/envvars" ) func TestCredentialSourceContext(t *testing.T) { @@ -68,33 +66,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { } } -func TestResolveEndpoints_EndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu-boe.cn" { - t.Errorf("Open = %q, want BOE open endpoint", ep.Open) - } - if ep.Accounts != "https://accounts.feishu-boe.cn" { - t.Errorf("Accounts = %q, want BOE accounts endpoint", ep.Accounts) - } - if ep.MCP != "https://mcp.feishu-boe.cn" { - t.Errorf("MCP = %q, want BOE mcp endpoint", ep.MCP) - } - if ep.AppLink != "https://applink.feishu-boe.cn" { - t.Errorf("AppLink = %q, want BOE applink endpoint", ep.AppLink) - } -} - -func TestResolveEndpoints_RejectsURLAsEndpointDomainOverride(t *testing.T) { - t.Setenv(envvars.CliEndpointDomain, "https://open.feishu-boe.cn") - - ep := ResolveEndpoints(BrandFeishu) - if ep.Open != "https://open.feishu.cn" { - t.Errorf("Open = %q, want default endpoint for invalid override", ep.Open) - } -} - func TestResolveOpenBaseURL(t *testing.T) { if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 32c9d5fb4f..81b5d4eed6 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -27,8 +27,4 @@ const ( CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" - - // Internal environment routing used by repo-local dev wrappers. - CliEndpointDomain = "LARKSUITE_CLI_ENDPOINT_DOMAIN" - CliExtraHeaders = "LARKSUITE_CLI_EXTRA_HEADERS" ) diff --git a/internal/envvars/read.go b/internal/envvars/read.go index 90fb6adf63..34868386cd 100644 --- a/internal/envvars/read.go +++ b/internal/envvars/read.go @@ -4,7 +4,6 @@ package envvars import ( - "net/http" "os" "strings" "unicode" @@ -23,26 +22,6 @@ func AgentTrace() string { return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen) } -func ExtraHeaders() http.Header { - headers := make(http.Header) - for _, item := range strings.Split(os.Getenv(CliExtraHeaders), ";") { - name, value, ok := strings.Cut(item, ":") - if !ok { - continue - } - name = strings.TrimSpace(name) - value = sanitizeSingleLine(value, agentNameMaxLen) - if name == "" || value == "" || !validHeaderName(name) { - continue - } - headers.Set(name, value) - } - if len(headers) == 0 { - return nil - } - return headers -} - func sanitizeSingleLine(raw string, maxLen int) string { v := strings.TrimSpace(raw) if v == "" || len(v) > maxLen { @@ -55,17 +34,3 @@ func sanitizeSingleLine(raw string, maxLen int) string { } return v } - -func validHeaderName(name string) bool { - for _, r := range name { - switch { - case r >= 'a' && r <= 'z': - case r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9': - case r == '-': - default: - return false - } - } - return true -} diff --git a/internal/envvars/read_test.go b/internal/envvars/read_test.go index ef09531289..cf216e90a2 100644 --- a/internal/envvars/read_test.go +++ b/internal/envvars/read_test.go @@ -131,30 +131,3 @@ func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) { t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen) } } - -func TestExtraHeaders_ParsesSemicolonSeparatedHeaders(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-TT-ENV: boe_bitable_bk; x-use-ppe:1") - - headers := ExtraHeaders() - if got := headers.Get("X-TT-ENV"); got != "boe_bitable_bk" { - t.Fatalf("X-TT-ENV = %q, want boe_bitable_bk", got) - } - if got := headers.Get("x-use-ppe"); got != "1" { - t.Fatalf("x-use-ppe = %q, want 1", got) - } -} - -func TestExtraHeaders_RejectsHeaderInjection(t *testing.T) { - t.Setenv(CliExtraHeaders, "X-Good: ok; Bad Header: nope; X-Evil: one\nTwo") - - headers := ExtraHeaders() - if got := headers.Get("X-Good"); got != "ok" { - t.Fatalf("X-Good = %q, want ok", got) - } - if got := headers.Get("Bad Header"); got != "" { - t.Fatalf("Bad Header = %q, want rejected", got) - } - if got := headers.Get("X-Evil"); got != "" { - t.Fatalf("X-Evil = %q, want rejected", got) - } -}