Compare commits

...

4 Commits

Author SHA1 Message Date
zhangheng.023
e01132227d fix: retry TempDir cleanup for test-created git repos
Local git tooling that hooks trace2 (e.g. a global trace2.eventtarget
socket listener) can asynchronously write into a repo's .git/ shortly
after a git command runs. This races with t.TempDir's automatic
RemoveAll cleanup and intermittently fails tests with "directory not
empty", unrelated to the code under test.
2026-07-07 12:28:54 +08:00
zhangheng.023
aca356ece6 fix: write skills-state.json updated_at in local timezone 2026-07-07 11:39:40 +08:00
Zee Zheng
869a259d4e docs: clarify success envelope contract — judge success by ok, not code (#1730)
The stdout success envelope is {ok, identity, data, meta?} and carries no
top-level code/msg field, but wrappers following the raw OpenAPI convention
test code == 0 and misclassify every successful call as a failure. Around
write commands this defeats caller-side idempotency and causes duplicate
creates (see #1689).

Document the contract in ERROR_CONTRACT.md (success-envelope counterpart to
the error wire format), both READMEs (JSON Output Contract section), the
lark-shared skill (shared prerequisite for all skills), and add a success
response example to lark-task-create.md.

Closes #1689
2026-07-06 20:03:05 +08:00
fangshuyu-768
ee46e22abd docs: tighten doc creation validation workflow (#1759) 2026-07-06 17:07:10 +08:00
11 changed files with 234 additions and 22 deletions

View File

@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values
```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination
```bash

View File

@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值
```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页
```bash

View File

@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories
| Category | When | Exit | Typed struct |

View File

@@ -10,6 +10,7 @@ import (
"path/filepath"
"reflect"
"testing"
"time"
)
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
@@ -51,7 +52,7 @@ func TestFileAtRevisionMissingClassifier(t *testing.T) {
}
func TestChangedFilesIncludingWorktree(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -83,7 +84,7 @@ func TestChangedFilesIncludingWorktree(t *testing.T) {
}
func TestChangedFilesHandlesWhitespacePaths(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -139,3 +140,22 @@ func gitOutput(t *testing.T, repo string, args ...string) string {
}
return string(out[:len(out)-1])
}
// newGitTestRepo returns a temp dir for a test-created git repo. Git tooling
// on this machine (trace2 hooks, etc.) can asynchronously write into a
// repo's .git/ shortly after a git command runs, racing with t.TempDir's
// automatic RemoveAll cleanup. Removing the tree ourselves first (retrying
// past that transient window) makes the later t.TempDir cleanup a no-op.
func newGitTestRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
t.Cleanup(func() {
for i := 0; i < 10; i++ {
if err := os.RemoveAll(repo); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
})
return repo
}

View File

@@ -10,10 +10,11 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -60,7 +61,7 @@ api_`+`key = "example-public-key"
}
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -92,7 +93,7 @@ func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
}
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -608,7 +609,7 @@ func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
}
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -699,7 +700,7 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
}
func TestCollectScansBranchNameAsWarning(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
got, err := Collect(context.Background(), Options{
@@ -799,7 +800,7 @@ func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
func newGitRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -840,7 +841,7 @@ func requireFinding(t *testing.T, got []Finding, file, rule string) {
}
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":`)
@@ -874,6 +875,25 @@ func runGitOutput(t *testing.T, repo string, args ...string) []byte {
return out
}
// newGitTestRepo returns a temp dir for a test-created git repo. Git tooling
// on this machine (trace2 hooks, etc.) can asynchronously write into a
// repo's .git/ shortly after a git command runs, racing with t.TempDir's
// automatic RemoveAll cleanup. Removing the tree ourselves first (retrying
// past that transient window) makes the later t.TempDir cleanup a no-op.
func newGitTestRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
t.Cleanup(func() {
for i := 0; i < 10; i++ {
if err := os.RemoveAll(repo); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
})
return repo
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

View File

@@ -11,6 +11,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
@@ -69,7 +70,7 @@ func TestReferenceCommandSurfaceNormalizesShortcutDomain(t *testing.T) {
}
func TestRunRequiresCommandIndexToCoverManifest(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
manifestPath := filepath.Join(repo, "command-manifest.json")
indexPath := filepath.Join(repo, "command-index.json")
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
@@ -104,7 +105,7 @@ func TestRunRequiresCommandIndexToCoverManifest(t *testing.T) {
}
func TestRunReadsManifestFilesAndAcceptsServiceReferences(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -190,7 +191,7 @@ description: Manage Drive comments with service command references.
}
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -283,7 +284,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
}
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -323,7 +324,7 @@ func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
}
func TestLoadBaseReferenceManifestReadsCommandIndexGolden(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -364,7 +365,7 @@ func TestLoadBaseReferenceManifestReadsCommandIndexGolden(t *testing.T) {
}
func TestLoadBaseReferenceManifestRejectsEmptyGolden(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -384,7 +385,7 @@ func TestLoadBaseReferenceManifestRejectsEmptyGolden(t *testing.T) {
}
func TestLoadBaseReferenceManifestRejectsInvalidGoldenKind(t *testing.T) {
repo := t.TempDir()
repo := newGitTestRepo(t)
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -606,3 +607,22 @@ func runGit(t *testing.T, repo string, args ...string) {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
// newGitTestRepo returns a temp dir for a test-created git repo. Git tooling
// on this machine (trace2 hooks, etc.) can asynchronously write into a
// repo's .git/ shortly after a git command runs, racing with t.TempDir's
// automatic RemoveAll cleanup. Removing the tree ourselves first (retrying
// past that transient window) makes the later t.TempDir cleanup a no-op.
func newGitTestRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
t.Cleanup(func() {
for i := 0; i < 10; i++ {
if err := os.RemoveAll(repo); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
})
return repo
}

View File

@@ -335,7 +335,7 @@ func SyncSkills(opts SyncOptions) *SyncResult {
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
UpdatedAt: opts.Now().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
@@ -428,7 +428,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
UpdatedSkills: official,
AddedOfficialSkills: official,
SkippedDeletedSkills: []string{},
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
UpdatedAt: opts.Now().Format(time.RFC3339),
}
if writeErr := WriteState(state); writeErr != nil {
return &SyncResult{

View File

@@ -853,3 +853,65 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll)
}
}
func TestSyncSkills_WritesLocalTimezoneUpdatedAt(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
globalOut: globalSkillsOutput("lark-mail"),
}
localZone := time.FixedZone("UTC+8", 8*60*60)
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, localZone) },
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
want := "2026-05-18T12:00:00+08:00"
if state.UpdatedAt != want {
t.Fatalf("state.UpdatedAt = %q, want %q (local timezone offset, not UTC Z-suffix)", state.UpdatedAt, want)
}
}
func TestSyncSkills_FallbackWritesLocalTimezoneUpdatedAt(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
localZone := time.FixedZone("UTC+8", 8*60*60)
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, localZone) },
})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
want := "2026-05-18T12:00:00+08:00"
if state.UpdatedAt != want {
t.Fatalf("state.UpdatedAt = %q, want %q (local timezone offset, not UTC Z-suffix)", state.UpdatedAt, want)
}
}

View File

@@ -23,7 +23,7 @@
1. 分析用户需求:受众、目的、范围
2. 设计大纲根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式不要默认套固定章节、固定开头或固定富 block 配比
3. `docs +create` 创建并撰写:
- **短文档**:一次写入完整内容
- **短文档**:一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
- **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**用 `block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
- ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
@@ -41,6 +41,7 @@
7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
8. 由**主 Agent 自行润色**(不另起内容子 Agent正文始终一人维护文字密集且不易读时优先拆段、加小标题或调整顺序——叙述内容保持成段**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
### 步骤四:专项校验(按需执行)
### 步骤四:专项校验
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现结果
9. **字数门禁**如果用户给出任何明确字数要求如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现目标区间、`word_count` 和达标结论
10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现

View File

@@ -146,6 +146,24 @@ lark-cli update
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
## JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同:
成功信封写入 **stdout**(退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**(退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
**判断成功必须用 `ok == true`(或进程退出码 0不要用 `code == 0`**:成功信封没有顶层 `code` / `msg` 字段,`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。按 OpenAPI 老格式 `{"code": 0, "msg": "ok"}` 判断会把所有成功调用误判为失败;封装写入类命令(如 `task +create`)时尤其危险,误判会绕过幂等逻辑导致重复创建。
## 安全规则
- **禁止输出密钥**appSecret、accessToken到终端明文。

View File

@@ -46,7 +46,20 @@ lark-cli task +create --summary "Test Task" --dry-run
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
2. Execute `lark-cli task +create --summary "..." ...`
3. Report the result: task ID and summary.
3. Judge success by `ok == true` in the stdout JSON (the success envelope has no `code` field — do not test `code == 0`), then report the result: task ID (`data.guid`) and summary.
Example success response:
```json
{
"ok": true,
"identity": "user",
"data": {
"guid": "e297d3d0-4b60-4a5f-a4d4-xxxxxxxxxxxx",
"url": "https://applink.larkoffice.com/client/todo/detail?guid=e297d3d0-4b60-4a5f-a4d4-xxxxxxxxxxxx"
}
}
```
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.