From d43f41a8a0714e7044b79f9b0defdf352d9f1452 Mon Sep 17 00:00:00 2001 From: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:39:30 +0800 Subject: [PATCH] feat(affordance): usage guidance for shortcuts and per-command skills Extend the affordance overlay (per-command --help guidance) beyond generated service-API methods to +-prefixed shortcuts, and let a command point at specific related skills. - shortcuts render the same guidance block as methods, from `## +cmd` entries in affordance/.md; the lookup coordinate is unified in cmdmeta (SetAffordanceRef/AffordanceRef) so both share one path - PrepareShortcutHelp composes onto the command's captured base, so a shortcut's PostMount-authored Long (e.g. the docs "read the skill" directive) is preserved, not clobbered - a per-command `### Skills` section merges with the domain `> skill:` default (deduped, domain first); an entry may be a bare skill name or a name/relpath reference (lark-contact/references/x.md), validated against the embedded skill tree and dropped when unresolved - +-prefixed headings resolve verbatim (no space->dot folding) - guide contact's +search-user / +get-user shortcuts, dropping the +search-user Go tips now covered by the overlay's examples and lead Tips precedence: a shortcut's overlay `### Tips` replace its Go Tips field. --- affordance/README.md | 19 ++- affordance/contact.md | 36 ++++++ cmd/root.go | 6 +- cmd/service/affordance.go | 158 +++++++++++++++++------ cmd/service/affordance_test.go | 131 ++++++++++++++++++- internal/affordance/affordance.go | 5 +- internal/affordance/affordance_test.go | 37 ++++++ internal/affordance/mdparse.go | 52 +++++++- internal/cmdmeta/meta.go | 43 +++++- internal/meta/affordance.go | 7 +- shortcuts/common/runner.go | 1 + shortcuts/contact/contact_search_user.go | 4 +- 12 files changed, 439 insertions(+), 60 deletions(-) diff --git a/affordance/README.md b/affordance/README.md index ba760874c0..7a0c2a54a9 100644 --- a/affordance/README.md +++ b/affordance/README.md @@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`. A small, fixed markdown subset; each file describes one domain: # optional `> skill: ` applies to every command below - ## the command as typed, minus `lark-cli ` + ## the command as typed, minus `lark-cli `; a + +-prefixed heading (## +create) targets that shortcut when to use this command ### Avoid when when not to use it / which command to use instead ### Prerequisites what you must have first (e.g. an id, and where it comes from) ### Tips gotchas and constraints ### Examples **description** lines, each followed by a fenced command + ### Skills bullet skill names, or name/relpath references + (lark-contact/references/x.md), to read for usage; + merged with the domain `> skill:` default (deduped, + domain first) ### a custom section; flows through verbatim Reference another command with `[[command]]` — it renders as `command` in help. Under `Avoid when` it means "use that one instead"; under `Prerequisites` ("… from [[command]]") it means "get the input there first". +Both service-API commands (`## messages get`) and `+`-prefixed shortcuts +(`## +create`) take entries. A `### Skills` entry is a skill name (validated +against `/SKILL.md`) or a `name/relpath` reference into that skill +(validated against the path); help drops any that don't resolve, so a typo shows +nothing. Point a command at its own reference (e.g. `+search-user` → +`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the +domain skill, which the `> skill:` default already covers. When a shortcut also +sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they +replace the Go tips (not merged), so keep tips in one place. + ## Example ## messages get @@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites` anything the schema and flags already show; the agent infers the rest. - Command-form headings resolve to method ids via the registry, so plural resource names (`messages`) map to the singular method id (`message`) automatically. + `+`-prefixed shortcut headings are matched verbatim (no plural/space folding), + so the heading must equal the shortcut command exactly (`## +history-revert`). diff --git a/affordance/contact.md b/affordance/contact.md index e0a71e70d8..13c1196837 100644 --- a/affordance/contact.md +++ b/affordance/contact.md @@ -1,6 +1,42 @@ # contact > skill: lark-contact +## +search-user +The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups. + +### Skills +- lark-contact/references/lark-contact-search-user.md + +### Avoid when +- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity) +- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]] + +### Examples + +**Find a user by name** +```bash +lark-cli contact +search-user --query "alice" --as user +``` + +**Fetch known users by open_id (me = yourself)** +```bash +lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user +``` + +## +get-user +Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only. + +### Skills +- lark-contact/references/lark-contact-get-user.md + +### Avoid when +- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]] +- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool + +### Tips +- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id +- --user-id-type must match the id you pass (default open_id) + ## user_profiles batch_query Bulk-fetch personal status and signature for user ids you already have. diff --git a/cmd/root.go b/cmd/root.go index 8e4e52cd41..c1715ab4f1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -679,7 +679,11 @@ func installTipsHelpFunc(root *cobra.Command) { defaultHelp(cmd, args) return } - if service.PrepareMethodHelp(cmd) { + if service.PrepareMethodHelp(cmd, embeddedSkillContent) { + defaultHelp(cmd, args) + return + } + if service.PrepareShortcutHelp(cmd, embeddedSkillContent) { defaultHelp(cmd, args) return } diff --git a/cmd/service/affordance.go b/cmd/service/affordance.go index 6a9aa804ca..36f41113b2 100644 --- a/cmd/service/affordance.go +++ b/cmd/service/affordance.go @@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool { } // domainHelpBase returns the description to seed domain help with — the -// hand-authored Long when present, else the Short — captured once into an -// annotation so re-rendering reuses the pristine text instead of the -// already-augmented Long. +// hand-authored Long when present, else the Short. func domainHelpBase(cmd *cobra.Command) string { - if base, ok := cmd.Annotations[domainBaseAnnotation]; ok { + return captureHelpBase(cmd, domainBaseAnnotation) +} + +// captureHelpBase records a command's pristine lead text once — its +// hand-authored Long, or Short when Long is empty — into the given annotation, +// so lazy re-renders compose onto the original text instead of onto an +// already-augmented Long. This is what lets a shortcut's PostMount-authored +// Long survive: it becomes the base the affordance block is appended below. +func captureHelpBase(cmd *cobra.Command, key string) string { + if base, ok := cmd.Annotations[key]; ok { return base } base := cmd.Long @@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} } - cmd.Annotations[domainBaseAnnotation] = base + cmd.Annotations[key] = base return base } @@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string { } // Annotation keys PrepareMethodHelp reads to rebuild a method command's Long. +// The affordance overlay coordinates live in cmdmeta (shared with shortcuts). const ( - affordanceServiceAnnotation = "affordance-service" - affordanceMethodAnnotation = "affordance-method" - schemaPathAnnotation = "method-schema-path" - paramsOnlyAnnotation = "method-params-only" - domainBaseAnnotation = "affordance-domain-base" + schemaPathAnnotation = "method-schema-path" + paramsOnlyAnnotation = "method-params-only" + domainBaseAnnotation = "affordance-domain-base" + shortcutBaseAnnotation = "affordance-shortcut-base" ) // setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a @@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params if cmd.Annotations == nil { cmd.Annotations = map[string]string{} } - if service != "" && methodID != "" { - cmd.Annotations[affordanceServiceAnnotation] = service - cmd.Annotations[affordanceMethodAnnotation] = methodID - } + cmdmeta.SetAffordanceRef(cmd, service, methodID) cmd.Annotations[schemaPathAnnotation] = schemaPath if paramsOnly != "" { cmd.Annotations[paramsOnlyAnnotation] = paramsOnly @@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params // PrepareMethodHelp rebuilds a generated method command's Long with the agent // guidance at the TOP (Risk, then the affordance block, then the schema // pointer), returning false for non-method commands. The overlay is parsed -// here — only when help is rendered. -func PrepareMethodHelp(cmd *cobra.Command) bool { +// here — only when help is rendered. skillFS (nil-safe) gates the related-skill +// pointers: each is emitted only when it resolves in the skill tree (see +// affordance.SkillStatPath), so a typo or a build without embedded skills never +// prints a `skills read` that cannot be opened. +func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool { ann := cmd.Annotations if ann == nil { return false @@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool { var b strings.Builder b.WriteString(cmd.Short) - if level, ok := cmdutil.GetRisk(cmd); ok { - // --yes asserts the USER confirmed; the agent must not self-approve. - if level == cmdutil.RiskHighRiskWrite { - fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level) - } else { - fmt.Fprintf(&b, "\n\nRisk: %s", level) - } - } + writeRisk(&b, cmd) var skills []string if raw, ok := affordanceRaw(cmd); ok { - if block := renderAffordance(meta.Method{Affordance: raw}); block != "" { - b.WriteString("\n\n") - b.WriteString(block) - } if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok { + if block := renderAffordanceValue(a); block != "" { + b.WriteString("\n\n") + b.WriteString(block) + } skills = a.Skills } } @@ -164,17 +164,95 @@ func PrepareMethodHelp(cmd *cobra.Command) bool { fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath) b.WriteString(ann[paramsOnlyAnnotation]) - if len(skills) > 0 { - b.WriteString("\n\nWorkflow skill (end-to-end usage):") - for _, s := range skills { - fmt.Fprintf(&b, "\n lark-cli skills read %s", s) - } + writeRelatedSkills(&b, skills, skillFS) + + cmd.Long = b.String() + return true +} + +// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance +// overlay — the same top layout as method help (description, Risk, guidance +// block, related skills) minus the schema pointer, which shortcuts have none +// of. Returns false when the command is not a shortcut or carries no overlay +// entry, so shortcuts without guidance keep the default help plus the bottom +// risk/tips append. +// +// The lead is the command's pristine base (captureHelpBase): a shortcut that +// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST +// read the skill" directive) keeps it — the affordance block is appended below, +// never clobbering it. +// +// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The +// shortcut's declarative Tips (the Go Tips field) are only a fallback used when +// the overlay declares none; when the overlay has tips, the Go tips are dropped +// (replaced, not merged) so tips never render twice. Authoring a ### Tips block +// therefore silently retires that shortcut's Go Tips — consolidate into one. +func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool { + if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut { + return false } + raw, ok := affordanceRaw(cmd) + if !ok { + return false + } + a, ok := (meta.Method{Affordance: raw}).ParsedAffordance() + if !ok { + return false + } + if len(a.Tips) == 0 { + a.Tips = cmdutil.GetTips(cmd) + } + + var b strings.Builder + b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation)) + writeRisk(&b, cmd) + if block := renderAffordanceValue(a); block != "" { + b.WriteString("\n\n") + b.WriteString(block) + } + writeRelatedSkills(&b, a.Skills, skillFS) cmd.Long = b.String() return true } +// writeRisk appends the "Risk: " line, warning agents not to self-approve +// high-risk-write commands. A no-op when the command has no risk annotation. +func writeRisk(b *strings.Builder, cmd *cobra.Command) { + level, ok := cmdutil.GetRisk(cmd) + if !ok { + return + } + // --yes asserts the USER confirmed; the agent must not self-approve. + if level == cmdutil.RiskHighRiskWrite { + fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level) + } else { + fmt.Fprintf(b, "\n\nRisk: %s", level) + } +} + +// writeRelatedSkills appends the "Related skills" block for the entries that +// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves, +// so help never prints a `skills read` pointer that cannot be opened. +func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) { + if skillFS == nil || len(skills) == 0 { + return + } + var avail []string + for _, s := range skills { + if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil { + avail = append(avail, s) + } + } + if len(avail) == 0 { + return + } + b.WriteString("\n\nRelated skills (read for end-to-end usage):") + for _, s := range avail { + fmt.Fprintf(b, "\n lark-cli skills read %s", s) + } +} + // affordanceLookup is the overlay source; a package var so tests can inject. var affordanceLookup = affordance.For @@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string { } func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) { - if cmd.Annotations == nil { - return nil, false - } - service := cmd.Annotations[affordanceServiceAnnotation] - methodID := cmd.Annotations[affordanceMethodAnnotation] - if service == "" || methodID == "" { + service, methodID, ok := cmdmeta.AffordanceRef(cmd) + if !ok { return nil, false } return affordanceLookup(service, methodID) @@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string { if !ok { return "" } + return renderAffordanceValue(a) +} +// renderAffordanceValue renders an already-parsed affordance. Split from +// renderAffordance so callers can render a value they have adjusted first (e.g. +// a shortcut folding its declarative tips into an overlay that has none). +func renderAffordanceValue(a meta.Affordance) string { var sections []string bullets := func(title string, items []string) { var nonEmpty []string diff --git a/cmd/service/affordance_test.go b/cmd/service/affordance_test.go index e5a8a550db..3202ee4320 100644 --- a/cmd/service/affordance_test.go +++ b/cmd/service/affordance_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "strings" "testing" + "testing/fstest" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" @@ -70,8 +71,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) { t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long) } // The lookup ref is recorded so the help path can resolve it later. - if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" { - t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations) + if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" { + t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok) } } @@ -119,7 +120,7 @@ func TestPrepareMethodHelp(t *testing.T) { m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"} cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil) - if !PrepareMethodHelp(cmd) { + if !PrepareMethodHelp(cmd, nil) { t.Fatal("PrepareMethodHelp returned false for a service-method command") } long := cmd.Long @@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) { } // A non-service command (no schema-path annotation) is left untouched. - if PrepareMethodHelp(&cobra.Command{Use: "plain"}) { + if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) { t.Error("PrepareMethodHelp should return false for a non-service command") } } +// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same +// top layout as method help (no schema pointer), folding declarative tips when +// the overlay declares none, and leaves shortcuts without an overlay entry (and +// non-shortcut commands) for the default help path. +func TestPrepareShortcutHelp(t *testing.T) { + orig := affordanceLookup + t.Cleanup(func() { affordanceLookup = orig }) + affordanceLookup = func(service, methodID string) (json.RawMessage, bool) { + if service == "calendar" && methodID == "+create" { + return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true + } + return nil, false + } + + sc := &cobra.Command{Use: "+create", Short: "Create an event"} + cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false) + cmdmeta.SetAffordanceRef(sc, "calendar", "+create") + cmdutil.SetRisk(sc, "write") + cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"}) + + if !PrepareShortcutHelp(sc, nil) { + t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay") + } + for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} { + if !strings.Contains(sc.Long, want) { + t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long) + } + } + if strings.Contains(sc.Long, "Full parameter schema:") { + t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long) + } + + // No overlay entry -> leave it for the default help path. + bare := &cobra.Command{Use: "+bare", Short: "x"} + cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false) + cmdmeta.SetAffordanceRef(bare, "calendar", "+bare") + if PrepareShortcutHelp(bare, nil) { + t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay") + } + + // Non-shortcut source is ignored even with a ref. + notSc := &cobra.Command{Use: "create", Short: "x"} + cmdmeta.SetAffordanceRef(notSc, "calendar", "+create") + if PrepareShortcutHelp(notSc, nil) { + t.Error("PrepareShortcutHelp should return false for a non-shortcut command") + } +} + +// Related-skill pointers are gated on existence: a skill that resolves in the +// skill FS renders, a typo is dropped (never print an unopenable `skills read`), +// and a nil skill FS suppresses the whole block. +func TestRelatedSkillsStatGating(t *testing.T) { + orig := affordanceLookup + t.Cleanup(func() { affordanceLookup = orig }) + affordanceLookup = func(_, _ string) (json.RawMessage, bool) { + return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true + } + skillFS := fstest.MapFS{ + "lark-real/SKILL.md": {Data: []byte("# real")}, + "lark-real/references/deep.md": {Data: []byte("# deep")}, + } + + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"} + + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil) + if !PrepareMethodHelp(cmd, skillFS) { + t.Fatal("PrepareMethodHelp returned false") + } + if !strings.Contains(cmd.Long, "skills read lark-real\n") { + t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long) + } + if strings.Contains(cmd.Long, "lark-typo") { + t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long) + } + // A name/relpath reference to an existing file renders; a missing one drops. + if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") { + t.Errorf("existing reference entry should render; got:\n%s", cmd.Long) + } + if strings.Contains(cmd.Long, "references/missing.md") { + t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long) + } + + // nil skill FS: the whole Related-skills block is suppressed. + bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil) + PrepareMethodHelp(bare, nil) + if strings.Contains(bare.Long, "Related skills") { + t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long) + } +} + +// A shortcut that set a hand-authored Long (as the docs shortcuts do in +// PostMount) keeps it as the lead: the affordance block is appended below, not +// clobbered, and re-rendering does not double-append. +func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) { + orig := affordanceLookup + t.Cleanup(func() { affordanceLookup = orig }) + affordanceLookup = func(_, _ string) (json.RawMessage, bool) { + return json.RawMessage(`{"use_when":["高层创建日程"]}`), true + } + + const authored = "Custom docs help. AI agents MUST read the skill first." + sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored} + cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false) + cmdmeta.SetAffordanceRef(sc, "calendar", "+create") + + if !PrepareShortcutHelp(sc, nil) { + t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay") + } + if !strings.HasPrefix(sc.Long, authored) { + t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long) + } + if !strings.Contains(sc.Long, "When to use:") { + t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long) + } + // Re-render must reuse the captured base, not append the block twice. + PrepareShortcutHelp(sc, nil) + if n := strings.Count(sc.Long, "When to use:"); n != 1 { + t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long) + } +} + // domainCmd wires a domain-tagged command with a subcommand under a root, the // shape PrepareDomainHelp expects. func domainCmd(short, long string) *cobra.Command { diff --git a/internal/affordance/affordance.go b/internal/affordance/affordance.go index 6ad46cd598..488c4021de 100644 --- a/internal/affordance/affordance.go +++ b/internal/affordance/affordance.go @@ -83,10 +83,9 @@ func commandFormResolver(service string) func(string) string { } } return func(h string) string { - h = strings.TrimSpace(h) - if id, ok := byForm[h]; ok { + if id, ok := byForm[strings.TrimSpace(h)]; ok { return id } - return strings.ReplaceAll(h, " ", ".") + return headingToKey(h) // one home for the shortcut/method key convention } } diff --git a/internal/affordance/affordance_test.go b/internal/affordance/affordance_test.go index a72899a84e..6446eea146 100644 --- a/internal/affordance/affordance_test.go +++ b/internal/affordance/affordance_test.go @@ -7,6 +7,8 @@ import ( "encoding/json" "testing" "testing/fstest" + + "github.com/larksuite/cli/internal/meta" ) // fixtureMD is a minimal affordance source: two methods, each with a lead @@ -84,3 +86,38 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) { t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions) } } + +// The ### Skills section merges with the domain `> skill:` default: domain +// first, then per-command entries, de-duplicated. A command with no ### Skills +// still inherits the domain default. +func TestParseDomainMD_SkillsMerge(t *testing.T) { + md := "# d\n> skill: lark-d\n\n" + + "## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default + "## bar\ndoes bar.\n" + got := parseDomainMD([]byte(md), nil) + + if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" { + t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills) + } + if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" { + t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills) + } +} + +// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it +// matches the shortcut command as mounted. +func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) { + md := "# d\n\n## +create\ncreate via shortcut.\n" + got := parseDomainMD([]byte(md), nil) + if _, ok := got["+create"]; !ok { + t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got)) + } +} + +func keysOf(m map[string]meta.Affordance) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/affordance/mdparse.go b/internal/affordance/mdparse.go index 8ef7d2513f..9d1d4bd6ff 100644 --- a/internal/affordance/mdparse.go +++ b/internal/affordance/mdparse.go @@ -19,6 +19,7 @@ import ( // ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge) // ### Tips -> tips // ### Examples -> examples: **description** + a ```fenced``` command +// ### Skills -> skills: bullet skill names, added to the domain default // ### -> extensions[] (custom section, flows through verbatim) // [[cmd]] -> a command reference, rendered as `cmd` // @@ -34,16 +35,56 @@ var standardSection = map[string]string{ "Prerequisites": "prerequisites", "Tips": "tips", "Examples": "examples", + "Skills": "skills", +} + +// mergeSkills returns the domain-default skill followed by a command's own skill +// entries, de-duplicated in author order and empties dropped. Backticks (left by +// the shared bullet parse) are stripped so each entry is a bare skill name. +func mergeSkills(domain string, extra []string) []string { + var out []string + seen := map[string]bool{} + add := func(s string) { + s = strings.Trim(strings.TrimSpace(s), "`") + if s == "" || seen[s] { + return + } + seen[s] = true + out = append(out, s) + } + add(domain) + for _, s := range extra { + add(s) + } + return out } func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") } +// SkillStatPath maps a `### Skills` entry to the path (relative to the skill +// tree) whose existence gates it: a bare skill name resolves to its SKILL.md, +// while an entry containing a slash is a name/relative-path reference (e.g. +// "lark-contact/references/lark-contact-search-user.md") and resolves to that +// path directly. Both render as `lark-cli skills read ` — the slash form +// skills read already accepts — so a per-command entry can point at that +// command's own reference file, not just re-point the domain skill. +func SkillStatPath(entry string) string { + if strings.Contains(entry, "/") { + return entry + } + return entry + "/SKILL.md" +} + // headingToKey maps a command heading ("instances get") to its affordance key // ("instances.get"). The space→dot rule holds where the command form matches // the method id; domains whose resource names differ (e.g. plural "messages" // vs id segment "message") need the registry's authoritative resource↔id table. func headingToKey(h string) string { - return strings.ReplaceAll(strings.TrimSpace(h), " ", ".") + h = strings.TrimSpace(h) + if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim + return h + } + return strings.ReplaceAll(h, " ", ".") } type mdSection struct { @@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo if len(useWhen) > 0 { a.UseWhen = useWhen } + var perCmdSkills []string for _, s := range secs { switch standardSection[s.label] { case "avoid_when": @@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo a.Tips = s.items case "examples": a.Examples = s.cases + case "skills": + perCmdSkills = s.items default: a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items}) } } - if skill != "" { - a.Skills = []string{skill} + if s := mergeSkills(skill, perCmdSkills); len(s) > 0 { + a.Skills = s } out[curKey] = a } @@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo inFence, fence = true, nil } else { inFence = false - sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")}) + sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")}) pending = "" } continue diff --git a/internal/cmdmeta/meta.go b/internal/cmdmeta/meta.go index ff4ab40e39..81d7624cd3 100644 --- a/internal/cmdmeta/meta.go +++ b/internal/cmdmeta/meta.go @@ -2,9 +2,11 @@ // SPDX-License-Identifier: MIT // Package cmdmeta is the single source of truth for command metadata that the -// policy engine and the hook selector both consume. It wraps the existing -// cmdutil annotations (risk_level, supportedIdentities) and adds the -// "domain" axis that the hook selector and Rule path globs need. +// policy engine, the hook selector, and help rendering consume. It wraps the +// existing cmdutil annotations (risk_level, supportedIdentities) and adds the +// "domain" axis that the hook selector and Rule path globs need, plus the +// affordance ref (service, method id) that lets service-method and shortcut +// help share one usage-guidance lookup path. // // Three axes: // @@ -51,6 +53,12 @@ const ( sourceAnnotationKey = "cmdmeta.source" generatedAnnotationKey = "cmdmeta.generated" + + // affordance{Service,Method}Key locate the command's usage-guidance overlay + // entry (see internal/affordance). Both service-method commands and + // +-prefixed shortcuts set these so help rendering shares one lookup path. + affordanceServiceKey = "cmdmeta.affordance.service" + affordanceMethodKey = "cmdmeta.affordance.method" ) // Meta groups the three command-level metadata axes consumed by the policy @@ -125,6 +133,35 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) { } } +// SetAffordanceRef records which affordance overlay entry (service, method id) +// a command maps to, so help rendering can look up its usage guidance. Stored +// on the command itself (no inheritance): each method / shortcut owns its ref. +// A no-op if either coordinate is empty. +func SetAffordanceRef(cmd *cobra.Command, service, method string) { + if service == "" || method == "" { + return + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[affordanceServiceKey] = service + cmd.Annotations[affordanceMethodKey] = method +} + +// AffordanceRef returns the command's own affordance overlay coordinates. +// ok is false when the command carries no ref. +func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) { + if cmd.Annotations == nil { + return "", "", false + } + service = cmd.Annotations[affordanceServiceKey] + method = cmd.Annotations[affordanceMethodKey] + if service == "" || method == "" { + return "", "", false + } + return service, method, true +} + // Domain returns the nearest-ancestor domain for the command. Empty string // when no ancestor has the annotation -- this is the "unknown" state the // policy engine must treat as ALLOW. diff --git a/internal/meta/affordance.go b/internal/meta/affordance.go index 77af8647a3..32dee0c3f4 100644 --- a/internal/meta/affordance.go +++ b/internal/meta/affordance.go @@ -8,8 +8,11 @@ import "encoding/json" // Affordance is the typed usage guidance overlaid on a method. It is the single // model the envelope renderer and the command help both parse, so the // vocabulary is defined once; the JSON tags double as the envelope wire shape. -// Skills entries are skill names (or name/path) rendered as runnable -// `lark-cli skills read ` pointers. +// Skills entries are either a bare skill name (e.g. "lark-doc") or a +// name/relative-path reference (e.g. "lark-contact/references/x.md"); both +// render as runnable `lark-cli skills read ` pointers. Help validates +// each against the embedded skill tree (a name → its SKILL.md, a reference → +// that path) and drops any that do not resolve. type Affordance struct { UseWhen []string `json:"use_when,omitempty"` AvoidWhen []string `json:"avoid_when,omitempty"` diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 468148c2c6..0dc1078e40 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -889,6 +889,7 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f } } cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false) + cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command) cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes) registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut) cmdutil.SetTips(cmd, shortcut.Tips) diff --git a/shortcuts/contact/contact_search_user.go b/shortcuts/contact/contact_search_user.go index 9159745b48..b1a43b841a 100644 --- a/shortcuts/contact/contact_search_user.go +++ b/shortcuts/contact/contact_search_user.go @@ -150,12 +150,10 @@ var ContactSearchUser = common.Shortcut{ {Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"}, }, Tips: []string{ - "Keyword search: lark-cli contact +search-user --query 'alice'", - "Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'", "Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted", "Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users", "Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'", - "open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.", + "on has_more=true add filters or tighten --query — there is no auto-pagination.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateSearchUser(runtime)