Skip to content

Commit aed8700

Browse files
author
Sisyphus Agent
committed
feat: 添加 oho add 命令 - 一键创建会话并发送消息
新增根级别命令 oho add,整合 session create 和 message add 功能。 主要特性: - 自动在当前目录创建会话 (支持 --directory 覆盖) - 自动生成会话标题 (支持 --title 自定义) - 支持消息发送的所有标志 (--agent, --model, --no-reply, --system, --tools, --file) - 支持 JSON 格式输出 (--json) - 智能错误处理 (会话创建失败不发送消息,消息发送失败仍显示会话 ID) 使用示例: oho add "帮我分析这个项目" # 最简用法 oho add "修复 bug" --title "Bug 修复" # 自定义标题 oho add "测试" --no-reply --json # JSON 输出 oho add "分析" --file log.txt # 带附件 技术实现: - 新增 cmd/add/add.go 实现命令逻辑 - 使用 PostWithQuery 发送 directory query 参数 - 复用 message.go 的 detectMimeType 函数处理文件附件 - 遵循现有命令的命名和注册模式
1 parent e2d612c commit aed8700

2 files changed

Lines changed: 288 additions & 0 deletions

File tree

oho/cmd/add/add.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package add
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"strings"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/anomalyco/oho/internal/client"
15+
"github.com/anomalyco/oho/internal/types"
16+
)
17+
18+
// Flag variables for add command
19+
var (
20+
addTitle string
21+
addParent string
22+
addAgent string
23+
addModel string
24+
addNoReply bool
25+
addSystem string
26+
addTools []string
27+
addFiles []string
28+
addDirectory string
29+
addJSONOutput bool
30+
)
31+
32+
// Cmd add 命令 - 创建会话并发送消息
33+
var Cmd = &cobra.Command{
34+
Use: "add [message]",
35+
Short: "Create a new session and send a message in one command",
36+
Long: `Create a new session in the current directory and send a message to it in one step.
37+
38+
This command combines session creation and message sending into a single operation.
39+
By default, it uses the current working directory as the session directory and
40+
generates an automatic title.
41+
42+
Examples:
43+
oho add "帮我分析这个项目"
44+
oho add "修复登录 bug" --title "Bug 修复"
45+
oho add "测试功能" --no-reply --agent default
46+
oho add "分析日志" --file /var/log/app.log`,
47+
Args: cobra.MinimumNArgs(1),
48+
RunE: runAdd,
49+
}
50+
51+
func init() {
52+
// Session-related flags
53+
Cmd.Flags().StringVar(&addTitle, "title", "", "Session title (auto-generated if not provided)")
54+
Cmd.Flags().StringVar(&addParent, "parent", "", "Parent session ID (for creating sub-session)")
55+
Cmd.Flags().StringVar(&addDirectory, "directory", "", "Working directory for the session (default: current directory)")
56+
57+
// Message-related flags
58+
Cmd.Flags().StringVar(&addAgent, "agent", "", "Agent ID for message")
59+
Cmd.Flags().StringVar(&addModel, "model", "", "Model ID for message")
60+
Cmd.Flags().BoolVar(&addNoReply, "no-reply", false, "Don't wait for AI response")
61+
Cmd.Flags().StringVar(&addSystem, "system", "", "System prompt")
62+
Cmd.Flags().StringSliceVar(&addTools, "tools", nil, "Tools list (can be specified multiple times)")
63+
Cmd.Flags().StringSliceVar(&addFiles, "file", nil, "File attachments (can be specified multiple times)")
64+
65+
// Output format
66+
Cmd.Flags().BoolVarP(&addJSONOutput, "json", "j", false, "Output in JSON format")
67+
}
68+
69+
func runAdd(cmd *cobra.Command, args []string) error {
70+
c := client.NewClient()
71+
ctx := context.Background()
72+
73+
// Step 1: Get current working directory
74+
sessionDir := addDirectory
75+
if sessionDir == "" {
76+
var err error
77+
sessionDir, err = os.Getwd()
78+
if err != nil {
79+
return fmt.Errorf("failed to get current directory: %w", err)
80+
}
81+
}
82+
83+
// Step 2: Generate title if not provided
84+
sessionTitle := addTitle
85+
if sessionTitle == "" {
86+
sessionTitle = fmt.Sprintf("New session - %s", time.Now().Format("2006-01-02T15:04:05"))
87+
}
88+
89+
// Step 3: Create session
90+
sessionID, err := createSession(c, ctx, sessionTitle, addParent, sessionDir)
91+
if err != nil {
92+
return fmt.Errorf("failed to create session: %w", err)
93+
}
94+
95+
// Step 4: Send message
96+
message := args[0]
97+
messageID, err := sendMessage(c, ctx, sessionID, message, addAgent, addModel, addNoReply, addSystem, addTools, addFiles)
98+
if err != nil {
99+
// Message send failed, but session was created
100+
if addJSONOutput {
101+
output := map[string]interface{}{
102+
"sessionId": sessionID,
103+
"status": "partial",
104+
"error": fmt.Sprintf("failed to send message: %v", err),
105+
}
106+
data, _ := json.MarshalIndent(output, "", " ")
107+
fmt.Println(string(data))
108+
} else {
109+
fmt.Printf("Session created: %s\n", sessionID)
110+
fmt.Printf("Warning: Message send failed: %v\n", err)
111+
}
112+
return nil
113+
}
114+
115+
// Step 5: Output result
116+
if addJSONOutput {
117+
output := map[string]interface{}{
118+
"sessionId": sessionID,
119+
"messageId": messageID,
120+
"directory": sessionDir,
121+
"title": sessionTitle,
122+
"status": "success",
123+
}
124+
data, _ := json.MarshalIndent(output, "", " ")
125+
fmt.Println(string(data))
126+
} else {
127+
fmt.Printf("Session created: %s\n", sessionID)
128+
fmt.Printf("Message sent: %s\n", messageID)
129+
}
130+
131+
return nil
132+
}
133+
134+
// createSession creates a new session and returns the session ID
135+
func createSession(c *client.Client, ctx context.Context, title, parentID, directory string) (string, error) {
136+
req := map[string]interface{}{}
137+
if title != "" {
138+
req["title"] = title
139+
}
140+
if parentID != "" {
141+
req["parentID"] = parentID
142+
}
143+
144+
// Use directory as query parameter (per OpenCode SDK spec)
145+
queryParams := map[string]string{"directory": directory}
146+
resp, err := c.PostWithQuery(ctx, "/session", queryParams, req)
147+
if err != nil {
148+
return "", fmt.Errorf("API request failed: %w", err)
149+
}
150+
151+
var session types.Session
152+
if err := json.Unmarshal(resp, &session); err != nil {
153+
return "", fmt.Errorf("failed to parse response: %w", err)
154+
}
155+
156+
return session.ID, nil
157+
}
158+
159+
// sendMessage sends a message to the session and returns the message ID
160+
func sendMessage(c *client.Client, ctx context.Context, sessionID, message, agent, model string, noReply bool, system string, tools, files []string) (string, error) {
161+
// Build message parts
162+
var parts []types.Part
163+
164+
// Add text part
165+
text := message
166+
parts = append(parts, types.Part{
167+
Type: "text",
168+
Text: &text,
169+
})
170+
171+
// Add file parts
172+
for _, filePath := range files {
173+
// Check if file exists
174+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
175+
return "", fmt.Errorf("file not found: %s", filePath)
176+
}
177+
178+
// Read file content
179+
fileData, err := os.ReadFile(filePath)
180+
if err != nil {
181+
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
182+
}
183+
184+
// Detect MIME type
185+
mimeType := detectMimeType(filePath)
186+
187+
// Encode to base64 data URL
188+
base64Data := base64.StdEncoding.EncodeToString(fileData)
189+
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
190+
191+
parts = append(parts, types.Part{
192+
Type: "file",
193+
URL: dataURL,
194+
Mime: mimeType,
195+
})
196+
}
197+
198+
// Build message request
199+
msgReq := types.MessageRequest{
200+
Model: model,
201+
Agent: agent,
202+
NoReply: noReply,
203+
System: system,
204+
Tools: tools,
205+
Parts: parts,
206+
}
207+
208+
resp, err := c.Post(ctx, fmt.Sprintf("/session/%s/message", sessionID), msgReq)
209+
if err != nil {
210+
return "", fmt.Errorf("API request failed: %w", err)
211+
}
212+
213+
// Handle empty response (no-reply mode)
214+
if len(resp) == 0 {
215+
return "pending", nil
216+
}
217+
218+
var result types.MessageWithParts
219+
if err := json.Unmarshal(resp, &result); err != nil {
220+
return "", fmt.Errorf("failed to parse response: %w", err)
221+
}
222+
223+
return result.Info.ID, nil
224+
}
225+
226+
// detectMimeType detects MIME type based on file extension
227+
func detectMimeType(filePath string) string {
228+
ext := strings.ToLower(filePath[strings.LastIndex(filePath, "."):])
229+
230+
mimeTypes := map[string]string{
231+
// Images
232+
".jpg": "image/jpeg",
233+
".jpeg": "image/jpeg",
234+
".png": "image/png",
235+
".gif": "image/gif",
236+
".webp": "image/webp",
237+
".bmp": "image/bmp",
238+
".svg": "image/svg+xml",
239+
240+
// Documents
241+
".pdf": "application/pdf",
242+
".doc": "application/msword",
243+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
244+
".xls": "application/vnd.ms-excel",
245+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
246+
".ppt": "application/vnd.ms-powerpoint",
247+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
248+
249+
// Text
250+
".txt": "text/plain",
251+
".md": "text/markdown",
252+
".html": "text/html",
253+
".css": "text/css",
254+
".js": "application/javascript",
255+
".json": "application/json",
256+
".xml": "application/xml",
257+
".yaml": "application/x-yaml",
258+
".yml": "application/x-yaml",
259+
260+
// Code
261+
".py": "text/x-python",
262+
".go": "text/x-go",
263+
".java": "text/x-java",
264+
".c": "text/x-c",
265+
".cpp": "text/x-c++",
266+
".h": "text/x-c",
267+
".rs": "text/x-rust",
268+
".ts": "text/x-typescript",
269+
".tsx": "text/x-typescript",
270+
271+
// Other
272+
".zip": "application/zip",
273+
".tar": "application/x-tar",
274+
".gz": "application/gzip",
275+
".mp3": "audio/mpeg",
276+
".mp4": "video/mp4",
277+
".wav": "audio/wav",
278+
}
279+
280+
if mimeType, ok := mimeTypes[ext]; ok {
281+
return mimeType
282+
}
283+
284+
// Default to octet-stream
285+
return "application/octet-stream"
286+
}

oho/cmd/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/spf13/cobra"
88

9+
"github.com/anomalyco/oho/cmd/add"
910
"github.com/anomalyco/oho/cmd/agent"
1011
"github.com/anomalyco/oho/cmd/auth"
1112
"github.com/anomalyco/oho/cmd/command"
@@ -89,6 +90,7 @@ func main() {
8990

9091
// 添加子命令
9192
rootCmd.AddCommand(
93+
add.Cmd,
9294
global.Cmd,
9395
project.Cmd,
9496
session.Cmd,

0 commit comments

Comments
 (0)