Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/errclass/codemeta_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ package errclass
import "github.com/larksuite/cli/errs"

var baseCodeMeta = map[int]CodeMeta{
// Base write-path errors.
1254291: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},

// Copy Table domain errors (technical design chapter 18.2).
800020304: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied},
800010102: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition},
Expand Down
351 changes: 350 additions & 1 deletion shortcuts/base/base_execute_test.go

Large diffs are not rendered by default.

53 changes: 52 additions & 1 deletion shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,61 @@ func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
}
}

func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
func TestBaseFieldCreateTipsGuideTypeSelectionByStoredValue(t *testing.T) {
tips := strings.Join(BaseFieldCreate.Tips, "\n")
for _, want := range []string{
"+field-create defines storage schema only",
"a documented field type",
"explicit stored-value requirements and the user's semantics",
"field name or business purpose only as a clue to confirm",
"do not use it to invent derived behavior",
"Use style only to format the chosen type",
"explicitly requested derived, automatic, synchronized, or backfilled behavior",
"use documented formula, lookup, link, workflow, or automation only",
"formula, lookup, link, workflow, or automation",
"If unsupported, do not probe code/web/OpenAPI, create a storage placeholder, or claim completion",
"report the boundary and alternatives",
"arrays remain sequential per-field requests",
"split only for timeout bounds, not a fixed chunk size",
"prefer --json @file or an argv-safe subprocess call",
"do not double-escape JSON inside shell command substitution",
"For large arrays, bound successful stdout with --jq",
"if .ok then (.data | {created,total,field_get_recommended,next_step,verification_hint}) else . end",
"preserves the full partial-failure envelope",
"next_step:done means stop",
"filter +field-list with --jq",
} {
if !strings.Contains(tips, want) {
t.Fatalf("field-create tips should contain %q, got:\n%s", want, tips)
}
}
lowerTips := strings.ToLower(tips)
for _, caseArtifact := range []string{
"base_table_",
"larkoffice.com/base/",
"grading_pass_rate",
"benchmark",
} {
if strings.Contains(lowerTips, caseArtifact) {
t.Fatalf("field-create tips should remain generic, found %q:\n%s", caseArtifact, tips)
}
}
}

func TestBaseFieldCreateHelpDocumentsBatchAndHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
if !strings.Contains(cmd.Short, "one or more fields") {
t.Fatalf("help should describe creating one or more fields, got %q", cmd.Short)
}
jsonFlag := cmd.Flags().Lookup("json")
if jsonFlag == nil {
t.Fatal("flag json must exist")
}
if !strings.Contains(jsonFlag.Usage, "JSON object or non-empty array") || !strings.Contains(jsonFlag.Usage, "supports @file") {
t.Fatalf("json flag help should document object and array input, got %q", jsonFlag.Usage)
}
if cmd.Flags().Lookup("i-have-read-guide") == nil {
t.Fatalf("flag i-have-read-guide must exist for runtime validation")
}
Expand Down
109 changes: 109 additions & 0 deletions shortcuts/base/base_skill_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"strings"
"testing"

"github.com/larksuite/cli/internal/vfs"
)

func TestBaseSkillKeepsCreateSemanticsLiteralAndGeneric(t *testing.T) {
const skillPath = "../../skills/lark-base/SKILL.md"
content, err := vfs.ReadFile(skillPath)
if err != nil {
t.Fatalf("read lark-base skill: %v", err)
}

skill := string(content)
normalizedSkill := strings.Join(strings.Fields(skill), " ")
for _, want := range []string{
"用户要求“新增/创建”时",
"本轮 create 返回的对象、ID 或数量",
"不能把已有资源算作本轮新增",
"按具体命令或 guide 的同名契约处理",
"不得自行改写用户语义",
"对每类资源只做一次必要盘点",
"只有命令明确返回逐项结果时才优先使用批量创建",
"继续配置本轮返回的 ID",
} {
if !strings.Contains(normalizedSkill, want) {
t.Fatalf("lark-base skill missing %q", want)
}
}

for _, forbidden := range []string{
"base_table_",
"larkoffice.com/base/",
"grading_pass_rate",
} {
if strings.Contains(skill, forbidden) {
t.Fatalf("lark-base skill must remain generic, found %q", forbidden)
}
}
}

func TestFieldCreateBatchContractStaysConsistentAcrossSkillAndReferences(t *testing.T) {
readNormalized := func(path string) string {
t.Helper()
content, err := vfs.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return strings.Join(strings.Fields(string(content)), " ")
}

skill := readNormalized("../../skills/lark-base/SKILL.md")
fieldJSON := readNormalized("../../skills/lark-base/references/lark-base-field-json.md")
fieldCreate := readNormalized("../../skills/lark-base/references/lark-base-field-create.md")

for _, want := range []string{
"同一表创建多个字段时,默认一次向 `+field-create --json` 传字段对象数组",
"预计串行运行时间超过 caller/tool timeout 时按时间预算拆分",
"不按固定条数切块",
"仅创建一个或多个只含 `name` + `type:text` 的简单字段时按 `+field-create --help` 即可",
"其他类型或属性必读",
"除上述简单 text fast path 外,写字段前先读",
"只有命令明确返回逐项结果时才优先使用批量创建",
"`+field-create` 数组是顺序单项请求,按 caller timeout 而非固定条数拆分",
} {
if !strings.Contains(skill, want) {
t.Fatalf("lark-base skill missing %q", want)
}
}
for _, want := range []string{
"单个字段定义始终是 JSON 对象",
"`+field-create --json` 接受一个字段对象或非空字段对象数组",
"`+field-update --json` 只接受一个字段对象",
} {
if !strings.Contains(fieldJSON, want) {
t.Fatalf("field JSON SSOT missing %q", want)
}
}
if strings.Contains(fieldJSON, "`--json` 必须是 JSON 对象") {
t.Fatal("field JSON SSOT must not apply the update-only top-level object rule to field-create")
}
for _, want := range []string{
"预计串行运行时间超过 caller/tool timeout 时按时间预算拆分,不按固定条数切块",
"遇到首个失败即停止且不自动回滚已创建字段",
"部分失败返回 `ok:false`",
"`summary`",
"`items`",
"`next_step:\"inspect_items\"`",
"`missing_scopes`、`identity`、`console_url`",
"`challenge_url`",
"`retryable:true` 只表示该 `failed` 项可原样自动重试",
"否则先按该项 `hint` 完成授权或修正输入,再重新提交该项",
"`not_attempted` 项应单独继续",
"调用方超时且未收到命令终态输出时,不要重投整个数组",
"先按本次提交的字段名定向读回,再只提交缺失项",
"没有写前快照时,读回命中的同名项只能标记为 `ambiguous`",
"不得计作本轮 `created`",
} {
if !strings.Contains(fieldCreate, want) {
t.Fatalf("field-create reference missing %q", want)
}
}
}
53 changes: 53 additions & 0 deletions shortcuts/base/data_query_guide_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"strings"
"testing"

"github.com/larksuite/cli/internal/vfs"
)

func TestDataQueryQuickGuideCoversConditionValueShapesWithoutCaseArtifacts(t *testing.T) {
const guidePath = "../../skills/lark-base/references/lark-base-data-query-guide.md"
content, err := vfs.ReadFile(guidePath)
if err != nil {
t.Fatalf("read data-query quick guide: %v", err)
}
guide := string(content)
normalizedGuide := strings.Join(strings.Fields(guide), " ")

for _, want := range []string{
"Common `Condition.value` shapes",
"`is` / `isNot`",
"exactly one option name",
"`isGreater`",
"`isLess`",
"`isEmpty`",
"`isNotEmpty`",
"uses `[]`",
`["Today"]`,
`["ExactDate","<epoch_ms>"]`,
"Use relative date keywords only for relative requests",
"[lark-base-data-query.md](lark-base-data-query.md)",
} {
if !strings.Contains(normalizedGuide, want) {
t.Fatalf("quick guide missing %q", want)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if len(content) > 6*1024 {
t.Fatalf("quick guide grew to %d bytes; keep full DSL details in lark-base-data-query.md", len(content))
}

for _, forbidden := range []string{
"base_table_",
"bytedance.larkoffice.com/base/",
} {
if strings.Contains(normalizedGuide, forbidden) {
t.Fatalf("quick guide must remain generic, found %q", forbidden)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
11 changes: 8 additions & 3 deletions shortcuts/base/field_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,25 @@ import (
var BaseFieldCreate = common.Shortcut{
Service: "base",
Command: "+field-create",
Description: "Create a field",
Description: "Create one or more fields",
Risk: "write",
Scopes: []string{"base:field:create"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "json", Desc: "field property JSON object or non-empty array of field objects; supports @file", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
},
Tips: []string{
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"text"}'`,
`Example select: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
`+field-create defines storage schema only: choose a documented field type from explicit stored-value requirements and the user's semantics. Treat the field name or business purpose only as a clue to confirm; do not use it to invent derived behavior. Use style only to format the chosen type.`,
`For explicitly requested derived, automatic, synchronized, or backfilled behavior, use documented formula, lookup, link, workflow, or automation only. If unsupported, do not probe code/web/OpenAPI, create a storage placeholder, or claim completion; report the boundary and alternatives.`,
"Agent hint: arrays remain sequential per-field requests; use one array per table when its estimated runtime fits the caller timeout, and split only for timeout bounds, not a fixed chunk size.",
"For generated arrays, prefer --json @file or an argv-safe subprocess call; do not double-escape JSON inside shell command substitution.",
`For large arrays, bound successful stdout with --jq 'if .ok then (.data | {created,total,field_get_recommended,next_step,verification_hint}) else . end'; this preserves the full partial-failure envelope. Omit the projection when individual field IDs are needed.`,
"On successful simple fields, next_step:done means stop: do not list/get fields unless readback is explicitly requested; if needed, filter +field-list with --jq instead of printing every field.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFieldCreate(runtime)
Expand Down
Loading
Loading