Skip to content

Commit a659c49

Browse files
Merge pull request #61 from langgenius/fix/skill-archive-roots
fix(skill): support CLI skill package uploads
2 parents cabac7e + 840bb3e commit a659c49

5 files changed

Lines changed: 559 additions & 2 deletions

File tree

.goreleaser.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ project_name: mosoo
55
before:
66
hooks:
77
- rm -rf .cache/goreleaser-skill
8-
- mkdir -p .cache/goreleaser-skill
9-
- cp -R publish/skills/mosoo .cache/goreleaser-skill/mosoo
8+
- mkdir -p .cache/goreleaser-skill/mosoo
9+
- cp publish/skills/mosoo/SKILL.md .cache/goreleaser-skill/mosoo/SKILL.md
10+
- cp -R publish/skills/mosoo/references .cache/goreleaser-skill/mosoo/references
1011

1112
builds:
1213
- id: mosoo

cmd/mosoo/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/langgenius/mosoo-connector/internal/doctor"
1818
"github.com/langgenius/mosoo-connector/internal/generated"
1919
"github.com/langgenius/mosoo-connector/internal/publicthreads"
20+
"github.com/langgenius/mosoo-connector/internal/skillcommands"
2021
"github.com/langgenius/mosoo-connector/internal/target"
2122
publishskills "github.com/langgenius/mosoo-connector/publish/skills"
2223
)
@@ -47,6 +48,9 @@ func main() {
4748
if err := consolecommands.Install(root); err != nil {
4849
os.Exit(runtime.FormatError(err, "table", os.Stderr))
4950
}
51+
if err := skillcommands.Install(root); err != nil {
52+
os.Exit(runtime.FormatError(err, "table", os.Stderr))
53+
}
5054
if err := publicthreads.Install(root); err != nil {
5155
os.Exit(runtime.FormatError(err, "table", os.Stderr))
5256
}

internal/skillcommands/client.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package skillcommands
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"mime/multipart"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
14+
latheruntime "github.com/lathe-cli/lathe/pkg/runtime"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
// Client is a minimal Console REST skill package client. It owns multipart
19+
// construction because the generated Lathe runtime cannot build file parts.
20+
type Client struct {
21+
hostname string
22+
opts latheruntime.ClientOptions
23+
}
24+
25+
func NewClient(cmd *cobra.Command) (*Client, error) {
26+
hostname, opts, err := latheruntime.LoadHostOptions(cmd)
27+
if err != nil {
28+
return nil, err
29+
}
30+
opts.UserAgent = cmd.Root().Use
31+
opts.Accept = "application/json"
32+
if debug, derr := cmd.Root().PersistentFlags().GetBool("debug"); derr == nil && debug {
33+
opts.Debug = true
34+
}
35+
return &Client{hostname: hostname, opts: opts}, nil
36+
}
37+
38+
func (c *Client) Inspect(ctx context.Context, opts uploadOptions) ([]byte, error) {
39+
return c.postMultipart(ctx, "/skill/inspect", opts.formFields(false), opts.file)
40+
}
41+
42+
func (c *Client) Package(ctx context.Context, opts uploadOptions) ([]byte, error) {
43+
return c.postMultipart(ctx, "/skill/package", opts.formFields(true), opts.file)
44+
}
45+
46+
func (o uploadOptions) formFields(includePackageFields bool) map[string]string {
47+
fields := map[string]string{}
48+
if strings.TrimSpace(o.githubURL) != "" {
49+
fields["githubUrl"] = strings.TrimSpace(o.githubURL)
50+
}
51+
if includePackageFields {
52+
fields["appId"] = strings.TrimSpace(o.appID)
53+
if strings.TrimSpace(o.skillID) != "" {
54+
fields["skillId"] = strings.TrimSpace(o.skillID)
55+
}
56+
}
57+
return fields
58+
}
59+
60+
func (c *Client) postMultipart(ctx context.Context, path string, fields map[string]string, filePath string) ([]byte, error) {
61+
if ctx == nil {
62+
ctx = context.Background()
63+
}
64+
var body bytes.Buffer
65+
writer := multipart.NewWriter(&body)
66+
for name, value := range fields {
67+
if err := writer.WriteField(name, value); err != nil {
68+
return nil, fmt.Errorf("write multipart field %s: %w", name, err)
69+
}
70+
}
71+
if strings.TrimSpace(filePath) != "" {
72+
if err := addFilePart(writer, strings.TrimSpace(filePath)); err != nil {
73+
return nil, err
74+
}
75+
}
76+
if err := writer.Close(); err != nil {
77+
return nil, fmt.Errorf("close multipart body: %w", err)
78+
}
79+
80+
opts := c.opts
81+
opts.Headers = mergeHeaders(opts.Headers, map[string]string{
82+
"Content-Type": writer.FormDataContentType(),
83+
})
84+
result, err := latheruntime.DoRawFull(ctx, c.hostname, http.MethodPost, path, body.Bytes(), opts)
85+
if err != nil {
86+
return nil, err
87+
}
88+
return result.Body, nil
89+
}
90+
91+
func addFilePart(writer *multipart.Writer, filePath string) error {
92+
file, err := os.Open(filePath)
93+
if err != nil {
94+
return fmt.Errorf("open --file %s: %w", filePath, err)
95+
}
96+
defer file.Close()
97+
98+
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
99+
if err != nil {
100+
return fmt.Errorf("create multipart file part: %w", err)
101+
}
102+
if _, err := io.Copy(part, file); err != nil {
103+
return fmt.Errorf("read --file %s: %w", filePath, err)
104+
}
105+
return nil
106+
}
107+
108+
func mergeHeaders(base map[string]string, extra map[string]string) map[string]string {
109+
merged := make(map[string]string, len(base)+len(extra))
110+
for key, value := range base {
111+
merged[key] = value
112+
}
113+
for key, value := range extra {
114+
merged[key] = value
115+
}
116+
return merged
117+
}

internal/skillcommands/command.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package skillcommands
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strings"
7+
8+
consolerest "github.com/langgenius/mosoo-connector/internal/generated/consolerest"
9+
latheruntime "github.com/lathe-cli/lathe/pkg/runtime"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
const (
14+
inspectOperationID = "Skill_Inspect"
15+
packageOperationID = "Skill_Package"
16+
)
17+
18+
// Install mounts hand-maintained replacements for skill package upload commands
19+
// that require multipart/form-data file parts. Lathe currently exposes the
20+
// multipart request body but cannot build the file part from generated specs.
21+
func Install(root *cobra.Command) error {
22+
surface := findChild(root, "console-rest")
23+
if surface == nil {
24+
return fmt.Errorf("console-rest command tree is not mounted")
25+
}
26+
skills := findChild(surface, "skills")
27+
if skills == nil {
28+
return fmt.Errorf("console-rest skills command tree is not mounted")
29+
}
30+
31+
replaceCommand(skills, "inspect", newInspectCommand())
32+
replaceCommand(skills, "package", newPackageCommand())
33+
return nil
34+
}
35+
36+
type uploadOptions struct {
37+
appID string
38+
file string
39+
githubURL string
40+
skillID string
41+
}
42+
43+
func newInspectCommand() *cobra.Command {
44+
var opts uploadOptions
45+
cmd := &cobra.Command{
46+
Use: "inspect",
47+
Short: "Inspect a skill package from upload or GitHub URL",
48+
Long: "Inspect a skill package by uploading a local package file or by passing a GitHub URL.",
49+
Example: "mosoo console-rest skills inspect --file ./mosoo-skill.zip -o json",
50+
RunE: func(cmd *cobra.Command, _ []string) error {
51+
if err := opts.validate(false); err != nil {
52+
return err
53+
}
54+
client, err := NewClient(cmd)
55+
if err != nil {
56+
return err
57+
}
58+
data, err := client.Inspect(cmd.Context(), opts)
59+
if err != nil {
60+
return err
61+
}
62+
format, _ := cmd.Root().PersistentFlags().GetString("output")
63+
return latheruntime.FormatOutput(data, format, cmd.OutOrStdout(), inspectCatalogSpec(cmd).Output)
64+
},
65+
}
66+
addSourceFlags(cmd, &opts)
67+
latheruntime.AttachCatalogCommand(cmd, "console-rest", inspectCatalogSpec(cmd))
68+
return cmd
69+
}
70+
71+
func newPackageCommand() *cobra.Command {
72+
var opts uploadOptions
73+
cmd := &cobra.Command{
74+
Use: "package",
75+
Short: "Create or update a skill package upload",
76+
Long: "Create or update a skill package by uploading a local package file or by passing a GitHub URL.",
77+
Example: "mosoo console-rest skills package --app-id <app-id> --file ./mosoo-skill.zip -o json",
78+
RunE: func(cmd *cobra.Command, _ []string) error {
79+
if err := opts.validate(true); err != nil {
80+
return err
81+
}
82+
client, err := NewClient(cmd)
83+
if err != nil {
84+
return err
85+
}
86+
data, err := client.Package(cmd.Context(), opts)
87+
if err != nil {
88+
return err
89+
}
90+
format, _ := cmd.Root().PersistentFlags().GetString("output")
91+
return latheruntime.FormatOutput(data, format, cmd.OutOrStdout(), packageCatalogSpec(cmd).Output)
92+
},
93+
}
94+
flags := cmd.Flags()
95+
flags.StringVar(&opts.appID, "app-id", "", "App ID that owns the skill. (form, required, ulid)")
96+
flags.StringVar(&opts.skillID, "skill-id", "", "Existing skill ID to update. (form, ulid)")
97+
addSourceFlags(cmd, &opts)
98+
_ = cmd.MarkFlagRequired("app-id")
99+
latheruntime.AttachCatalogCommand(cmd, "console-rest", packageCatalogSpec(cmd))
100+
return cmd
101+
}
102+
103+
func addSourceFlags(cmd *cobra.Command, opts *uploadOptions) {
104+
flags := cmd.Flags()
105+
flags.StringVarP(&opts.file, "file", "f", "", "Skill package file path (.zip, .skill, or SKILL.md)")
106+
flags.StringVar(&opts.githubURL, "github-url", "", "GitHub URL for the skill package source")
107+
}
108+
109+
func (o uploadOptions) validate(requireApp bool) error {
110+
if requireApp && strings.TrimSpace(o.appID) == "" {
111+
return fmt.Errorf("--app-id is required")
112+
}
113+
hasFile := strings.TrimSpace(o.file) != ""
114+
hasURL := strings.TrimSpace(o.githubURL) != ""
115+
switch {
116+
case hasFile && hasURL:
117+
return fmt.Errorf("only one of --file or --github-url can be set")
118+
case !hasFile && !hasURL:
119+
return fmt.Errorf("one of --file or --github-url is required")
120+
default:
121+
return nil
122+
}
123+
}
124+
125+
func inspectCatalogSpec(cmd *cobra.Command) latheruntime.CommandSpec {
126+
spec := generatedSpec(inspectOperationID)
127+
spec.Long = cmd.Long
128+
spec.Example = cmd.Example
129+
spec.Params = []latheruntime.ParamSpec{
130+
{Name: "file", Flag: "file", In: latheruntime.InFormData, GoType: "string", Help: "Skill package file path (.zip, .skill, or SKILL.md)"},
131+
{Name: "githubUrl", Flag: "github-url", In: latheruntime.InFormData, GoType: "string", Help: "GitHub URL for the skill package source"},
132+
}
133+
spec.KnownErrors = skillKnownErrors()
134+
return spec
135+
}
136+
137+
func packageCatalogSpec(cmd *cobra.Command) latheruntime.CommandSpec {
138+
spec := generatedSpec(packageOperationID)
139+
spec.Long = cmd.Long
140+
spec.Example = cmd.Example
141+
spec.Params = []latheruntime.ParamSpec{
142+
{Name: "appId", Flag: "app-id", In: latheruntime.InFormData, GoType: "string", Help: "App ID that owns the skill. (form, required, ulid)", Required: true, Format: "ulid"},
143+
{Name: "skillId", Flag: "skill-id", In: latheruntime.InFormData, GoType: "string", Help: "Existing skill ID to update. (form, ulid)", Format: "ulid"},
144+
{Name: "file", Flag: "file", In: latheruntime.InFormData, GoType: "string", Help: "Skill package file path (.zip, .skill, or SKILL.md)"},
145+
{Name: "githubUrl", Flag: "github-url", In: latheruntime.InFormData, GoType: "string", Help: "GitHub URL for the skill package source"},
146+
}
147+
spec.KnownErrors = skillKnownErrors()
148+
return spec
149+
}
150+
151+
func skillKnownErrors() []latheruntime.KnownError {
152+
return []latheruntime.KnownError{
153+
{Status: http.StatusBadRequest, Cause: "Invalid package source, malformed multipart form, or unsupported skill package layout."},
154+
{Status: http.StatusUnauthorized, Cause: "Missing, invalid, or revoked personal access token."},
155+
}
156+
}
157+
158+
func generatedSpec(operationID string) latheruntime.CommandSpec {
159+
for _, spec := range consolerest.Specs {
160+
if spec.OperationID == operationID {
161+
return spec
162+
}
163+
}
164+
panic(fmt.Sprintf("missing generated console-rest spec %q", operationID))
165+
}
166+
167+
func replaceCommand(parent *cobra.Command, name string, replacement *cobra.Command) {
168+
if existing := findChild(parent, name); existing != nil {
169+
parent.RemoveCommand(existing)
170+
}
171+
parent.AddCommand(replacement)
172+
}
173+
174+
func findChild(parent *cobra.Command, name string) *cobra.Command {
175+
for _, child := range parent.Commands() {
176+
if child.Name() == name {
177+
return child
178+
}
179+
}
180+
return nil
181+
}

0 commit comments

Comments
 (0)