Skip to content

Commit d43f41a

Browse files
committed
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/<domain>.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.
1 parent 9413e7c commit d43f41a

12 files changed

Lines changed: 439 additions & 60 deletions

File tree

affordance/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
1010
A small, fixed markdown subset; each file describes one domain:
1111

1212
# <domain> optional `> skill: <name>` applies to every command below
13-
## <command> the command as typed, minus `lark-cli <domain>`
13+
## <command> the command as typed, minus `lark-cli <domain>`; a
14+
+-prefixed heading (## +create) targets that shortcut
1415
<lead paragraph> when to use this command
1516
### Avoid when when not to use it / which command to use instead
1617
### Prerequisites what you must have first (e.g. an id, and where it comes from)
1718
### Tips gotchas and constraints
1819
### Examples **description** lines, each followed by a fenced command
20+
### Skills bullet skill names, or name/relpath references
21+
(lark-contact/references/x.md), to read for usage;
22+
merged with the domain `> skill:` default (deduped,
23+
domain first)
1924
### <other heading> a custom section; flows through verbatim
2025

2126
Reference another command with `[[command]]` — it renders as `command` in help.
2227
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
2328
("… from [[command]]") it means "get the input there first".
2429

30+
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
31+
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
32+
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
33+
(validated against the path); help drops any that don't resolve, so a typo shows
34+
nothing. Point a command at its own reference (e.g. `+search-user`
35+
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
36+
domain skill, which the `> skill:` default already covers. When a shortcut also
37+
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
38+
replace the Go tips (not merged), so keep tips in one place.
39+
2540
## Example
2641

2742
## messages get
@@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites`
4762
anything the schema and flags already show; the agent infers the rest.
4863
- Command-form headings resolve to method ids via the registry, so plural resource
4964
names (`messages`) map to the singular method id (`message`) automatically.
65+
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
66+
so the heading must equal the shortcut command exactly (`## +history-revert`).

affordance/contact.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
11
# contact
22
> skill: lark-contact
33
4+
## +search-user
5+
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.
6+
7+
### Skills
8+
- lark-contact/references/lark-contact-search-user.md
9+
10+
### Avoid when
11+
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
12+
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
13+
14+
### Examples
15+
16+
**Find a user by name**
17+
```bash
18+
lark-cli contact +search-user --query "alice" --as user
19+
```
20+
21+
**Fetch known users by open_id (me = yourself)**
22+
```bash
23+
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
24+
```
25+
26+
## +get-user
27+
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
28+
29+
### Skills
30+
- lark-contact/references/lark-contact-get-user.md
31+
32+
### Avoid when
33+
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
34+
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
35+
36+
### Tips
37+
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
38+
- --user-id-type must match the id you pass (default open_id)
39+
440
## user_profiles batch_query
541
Bulk-fetch personal status and signature for user ids you already have.
642

cmd/root.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,11 @@ func installTipsHelpFunc(root *cobra.Command) {
679679
defaultHelp(cmd, args)
680680
return
681681
}
682-
if service.PrepareMethodHelp(cmd) {
682+
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
683+
defaultHelp(cmd, args)
684+
return
685+
}
686+
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
683687
defaultHelp(cmd, args)
684688
return
685689
}

cmd/service/affordance.go

Lines changed: 119 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
7171
}
7272

7373
// domainHelpBase returns the description to seed domain help with — the
74-
// hand-authored Long when present, else the Short — captured once into an
75-
// annotation so re-rendering reuses the pristine text instead of the
76-
// already-augmented Long.
74+
// hand-authored Long when present, else the Short.
7775
func domainHelpBase(cmd *cobra.Command) string {
78-
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
76+
return captureHelpBase(cmd, domainBaseAnnotation)
77+
}
78+
79+
// captureHelpBase records a command's pristine lead text once — its
80+
// hand-authored Long, or Short when Long is empty — into the given annotation,
81+
// so lazy re-renders compose onto the original text instead of onto an
82+
// already-augmented Long. This is what lets a shortcut's PostMount-authored
83+
// Long survive: it becomes the base the affordance block is appended below.
84+
func captureHelpBase(cmd *cobra.Command, key string) string {
85+
if base, ok := cmd.Annotations[key]; ok {
7986
return base
8087
}
8188
base := cmd.Long
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
8592
if cmd.Annotations == nil {
8693
cmd.Annotations = map[string]string{}
8794
}
88-
cmd.Annotations[domainBaseAnnotation] = base
95+
cmd.Annotations[key] = base
8996
return base
9097
}
9198

@@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
101108
}
102109

103110
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
111+
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
104112
const (
105-
affordanceServiceAnnotation = "affordance-service"
106-
affordanceMethodAnnotation = "affordance-method"
107-
schemaPathAnnotation = "method-schema-path"
108-
paramsOnlyAnnotation = "method-params-only"
109-
domainBaseAnnotation = "affordance-domain-base"
113+
schemaPathAnnotation = "method-schema-path"
114+
paramsOnlyAnnotation = "method-params-only"
115+
domainBaseAnnotation = "affordance-domain-base"
116+
shortcutBaseAnnotation = "affordance-shortcut-base"
110117
)
111118

112119
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
@@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
115122
if cmd.Annotations == nil {
116123
cmd.Annotations = map[string]string{}
117124
}
118-
if service != "" && methodID != "" {
119-
cmd.Annotations[affordanceServiceAnnotation] = service
120-
cmd.Annotations[affordanceMethodAnnotation] = methodID
121-
}
125+
cmdmeta.SetAffordanceRef(cmd, service, methodID)
122126
cmd.Annotations[schemaPathAnnotation] = schemaPath
123127
if paramsOnly != "" {
124128
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
@@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
128132
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
129133
// guidance at the TOP (Risk, then the affordance block, then the schema
130134
// pointer), returning false for non-method commands. The overlay is parsed
131-
// here — only when help is rendered.
132-
func PrepareMethodHelp(cmd *cobra.Command) bool {
135+
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
136+
// pointers: each is emitted only when it resolves in the skill tree (see
137+
// affordance.SkillStatPath), so a typo or a build without embedded skills never
138+
// prints a `skills read` that cannot be opened.
139+
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
133140
ann := cmd.Annotations
134141
if ann == nil {
135142
return false
@@ -141,40 +148,111 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
141148

142149
var b strings.Builder
143150
b.WriteString(cmd.Short)
144-
if level, ok := cmdutil.GetRisk(cmd); ok {
145-
// --yes asserts the USER confirmed; the agent must not self-approve.
146-
if level == cmdutil.RiskHighRiskWrite {
147-
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)
148-
} else {
149-
fmt.Fprintf(&b, "\n\nRisk: %s", level)
150-
}
151-
}
151+
writeRisk(&b, cmd)
152152

153153
var skills []string
154154
if raw, ok := affordanceRaw(cmd); ok {
155-
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
156-
b.WriteString("\n\n")
157-
b.WriteString(block)
158-
}
159155
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
156+
if block := renderAffordanceValue(a); block != "" {
157+
b.WriteString("\n\n")
158+
b.WriteString(block)
159+
}
160160
skills = a.Skills
161161
}
162162
}
163163

164164
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
165165
b.WriteString(ann[paramsOnlyAnnotation])
166166

167-
if len(skills) > 0 {
168-
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
169-
for _, s := range skills {
170-
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
171-
}
167+
writeRelatedSkills(&b, skills, skillFS)
168+
169+
cmd.Long = b.String()
170+
return true
171+
}
172+
173+
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
174+
// overlay — the same top layout as method help (description, Risk, guidance
175+
// block, related skills) minus the schema pointer, which shortcuts have none
176+
// of. Returns false when the command is not a shortcut or carries no overlay
177+
// entry, so shortcuts without guidance keep the default help plus the bottom
178+
// risk/tips append.
179+
//
180+
// The lead is the command's pristine base (captureHelpBase): a shortcut that
181+
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
182+
// read the skill" directive) keeps it — the affordance block is appended below,
183+
// never clobbering it.
184+
//
185+
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
186+
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
187+
// the overlay declares none; when the overlay has tips, the Go tips are dropped
188+
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
189+
// therefore silently retires that shortcut's Go Tips — consolidate into one.
190+
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
191+
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
192+
return false
172193
}
194+
raw, ok := affordanceRaw(cmd)
195+
if !ok {
196+
return false
197+
}
198+
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
199+
if !ok {
200+
return false
201+
}
202+
if len(a.Tips) == 0 {
203+
a.Tips = cmdutil.GetTips(cmd)
204+
}
205+
206+
var b strings.Builder
207+
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
208+
writeRisk(&b, cmd)
209+
if block := renderAffordanceValue(a); block != "" {
210+
b.WriteString("\n\n")
211+
b.WriteString(block)
212+
}
213+
writeRelatedSkills(&b, a.Skills, skillFS)
173214

174215
cmd.Long = b.String()
175216
return true
176217
}
177218

219+
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
220+
// high-risk-write commands. A no-op when the command has no risk annotation.
221+
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
222+
level, ok := cmdutil.GetRisk(cmd)
223+
if !ok {
224+
return
225+
}
226+
// --yes asserts the USER confirmed; the agent must not self-approve.
227+
if level == cmdutil.RiskHighRiskWrite {
228+
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)
229+
} else {
230+
fmt.Fprintf(b, "\n\nRisk: %s", level)
231+
}
232+
}
233+
234+
// writeRelatedSkills appends the "Related skills" block for the entries that
235+
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
236+
// so help never prints a `skills read` pointer that cannot be opened.
237+
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
238+
if skillFS == nil || len(skills) == 0 {
239+
return
240+
}
241+
var avail []string
242+
for _, s := range skills {
243+
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
244+
avail = append(avail, s)
245+
}
246+
}
247+
if len(avail) == 0 {
248+
return
249+
}
250+
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
251+
for _, s := range avail {
252+
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
253+
}
254+
}
255+
178256
// affordanceLookup is the overlay source; a package var so tests can inject.
179257
var affordanceLookup = affordance.For
180258

@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
189267
}
190268

191269
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
192-
if cmd.Annotations == nil {
193-
return nil, false
194-
}
195-
service := cmd.Annotations[affordanceServiceAnnotation]
196-
methodID := cmd.Annotations[affordanceMethodAnnotation]
197-
if service == "" || methodID == "" {
270+
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
271+
if !ok {
198272
return nil, false
199273
}
200274
return affordanceLookup(service, methodID)
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
207281
if !ok {
208282
return ""
209283
}
284+
return renderAffordanceValue(a)
285+
}
210286

287+
// renderAffordanceValue renders an already-parsed affordance. Split from
288+
// renderAffordance so callers can render a value they have adjusted first (e.g.
289+
// a shortcut folding its declarative tips into an overlay that has none).
290+
func renderAffordanceValue(a meta.Affordance) string {
211291
var sections []string
212292
bullets := func(title string, items []string) {
213293
var nonEmpty []string

0 commit comments

Comments
 (0)