Skip to content

Commit 0be15a9

Browse files
committed
feat: dynamic plugin system — lifecycle management, daemon mode, tool registry integration
- DynamicPluginManager with activate/deactivate/reload lifecycle - Daemon mode plugins (long-lived JSON-RPC over stdin/stdout) - PluginToolAdapter bridges plugin tools into main tool.Registry - ManifestV2 format with hooks, dependencies, config, entrypoint - CLI: hawk plugin activate/deactivate/reload/status/create/logs - Plugin scaffolding via hawk plugin create <name> - GitHub installation via hawk plugin install <repo>
1 parent 339276d commit 0be15a9

4 files changed

Lines changed: 1397 additions & 0 deletions

File tree

cmd/plugin_dynamic.go

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"text/tabwriter"
9+
"time"
10+
11+
"github.com/GrayCodeAI/hawk/plugin"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var dynamicManager *plugin.DynamicPluginManager
16+
17+
func getDynamicManager() *plugin.DynamicPluginManager {
18+
if dynamicManager == nil {
19+
dynamicManager = plugin.NewDynamicPluginManager(nil, nil, nil)
20+
_ = dynamicManager.DiscoverAll()
21+
}
22+
return dynamicManager
23+
}
24+
25+
var pluginActivateCmd = &cobra.Command{
26+
Use: "activate <name>",
27+
Short: "Activate a discovered plugin",
28+
Args: cobra.ExactArgs(1),
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
dm := getDynamicManager()
31+
name := args[0]
32+
if err := dm.Activate(name); err != nil {
33+
return fmt.Errorf("activate plugin %q: %w", name, err)
34+
}
35+
cmd.Printf("Plugin %q activated.\n", name)
36+
return nil
37+
},
38+
}
39+
40+
var pluginDeactivateCmd = &cobra.Command{
41+
Use: "deactivate <name>",
42+
Short: "Deactivate an active plugin",
43+
Args: cobra.ExactArgs(1),
44+
RunE: func(cmd *cobra.Command, args []string) error {
45+
dm := getDynamicManager()
46+
name := args[0]
47+
if err := dm.Deactivate(name); err != nil {
48+
return fmt.Errorf("deactivate plugin %q: %w", name, err)
49+
}
50+
cmd.Printf("Plugin %q deactivated.\n", name)
51+
return nil
52+
},
53+
}
54+
55+
var pluginReloadCmd = &cobra.Command{
56+
Use: "reload <name>",
57+
Short: "Reload a plugin (deactivate then activate)",
58+
Args: cobra.ExactArgs(1),
59+
RunE: func(cmd *cobra.Command, args []string) error {
60+
dm := getDynamicManager()
61+
name := args[0]
62+
if err := dm.Reload(name); err != nil {
63+
return fmt.Errorf("reload plugin %q: %w", name, err)
64+
}
65+
cmd.Printf("Plugin %q reloaded.\n", name)
66+
return nil
67+
},
68+
}
69+
70+
var pluginStatusCmd = &cobra.Command{
71+
Use: "status",
72+
Short: "Show all plugins with their state",
73+
RunE: func(cmd *cobra.Command, args []string) error {
74+
dm := getDynamicManager()
75+
statuses := dm.Status()
76+
77+
if len(statuses) == 0 {
78+
cmd.Println("No plugins discovered. Run 'hawk plugin install' to add plugins.")
79+
return nil
80+
}
81+
82+
jsonOut, _ := cmd.Flags().GetBool("json")
83+
if jsonOut {
84+
data, _ := json.MarshalIndent(statuses, "", " ")
85+
cmd.Println(string(data))
86+
return nil
87+
}
88+
89+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
90+
fmt.Fprintf(w, "NAME\tVERSION\tSTATE\tTOOLS\tHOOKS\n")
91+
for _, s := range statuses {
92+
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n",
93+
s.Name, s.Version, s.State, s.ToolCount, s.HookCount)
94+
}
95+
w.Flush()
96+
97+
return nil
98+
},
99+
}
100+
101+
var pluginInstallDynamicCmd = &cobra.Command{
102+
Use: "install <repo-or-dir>",
103+
Short: "Install a plugin from GitHub or local directory",
104+
Args: cobra.ExactArgs(1),
105+
RunE: func(cmd *cobra.Command, args []string) error {
106+
source := args[0]
107+
108+
// Check if it is a local directory
109+
if info, err := os.Stat(source); err == nil && info.IsDir() {
110+
if err := plugin.Install(source); err != nil {
111+
return err
112+
}
113+
cmd.Printf("Installed plugin from %s.\n", source)
114+
return nil
115+
}
116+
117+
// Otherwise treat as GitHub repo
118+
dm := getDynamicManager()
119+
if err := dm.InstallFromGitHub(source); err != nil {
120+
return err
121+
}
122+
cmd.Printf("Installed plugin from %s.\n", source)
123+
124+
// Re-discover
125+
_ = dm.DiscoverAll()
126+
return nil
127+
},
128+
}
129+
130+
var pluginUninstallCmd = &cobra.Command{
131+
Use: "uninstall <name>",
132+
Short: "Uninstall a plugin (deactivate and remove from disk)",
133+
Args: cobra.ExactArgs(1),
134+
RunE: func(cmd *cobra.Command, args []string) error {
135+
dm := getDynamicManager()
136+
name := args[0]
137+
if err := dm.Uninstall(name); err != nil {
138+
return err
139+
}
140+
cmd.Printf("Plugin %q uninstalled.\n", name)
141+
return nil
142+
},
143+
}
144+
145+
var pluginCreateCmd = &cobra.Command{
146+
Use: "create <name>",
147+
Short: "Scaffold a new plugin in the current directory",
148+
Args: cobra.ExactArgs(1),
149+
RunE: func(cmd *cobra.Command, args []string) error {
150+
name := args[0]
151+
dir := filepath.Join(".", name)
152+
153+
if _, err := os.Stat(dir); err == nil {
154+
return fmt.Errorf("directory %q already exists", dir)
155+
}
156+
157+
if err := os.MkdirAll(dir, 0o755); err != nil {
158+
return fmt.Errorf("create directory: %w", err)
159+
}
160+
161+
// Write plugin.json manifest
162+
manifest := &plugin.ManifestV2{
163+
Name: name,
164+
Version: "0.1.0",
165+
Description: fmt.Sprintf("A hawk plugin: %s", name),
166+
Author: "",
167+
Mode: "subprocess",
168+
Tools: []plugin.ManifestTool{
169+
{
170+
Name: "hello",
171+
Description: fmt.Sprintf("Example tool from %s plugin", name),
172+
Command: "go run .",
173+
InputSchema: map[string]interface{}{
174+
"type": "object",
175+
"properties": map[string]interface{}{
176+
"message": map[string]interface{}{
177+
"type": "string",
178+
"description": "Input message",
179+
},
180+
},
181+
},
182+
},
183+
},
184+
Permissions: []string{},
185+
License: "MIT",
186+
}
187+
188+
if err := plugin.WriteManifestV2(dir, manifest); err != nil {
189+
return fmt.Errorf("write manifest: %w", err)
190+
}
191+
192+
// Write main.go
193+
mainGo := fmt.Sprintf(`package main
194+
195+
import (
196+
"encoding/json"
197+
"fmt"
198+
"os"
199+
)
200+
201+
// Input represents the tool input passed via stdin.
202+
type Input struct {
203+
Message string `+"`"+`json:"message"`+"`"+`
204+
}
205+
206+
// Output represents the tool response written to stdout.
207+
type Output struct {
208+
Result string `+"`"+`json:"result"`+"`"+`
209+
}
210+
211+
func main() {
212+
var input Input
213+
if err := json.NewDecoder(os.Stdin).Decode(&input); err != nil {
214+
fmt.Fprintf(os.Stderr, "error reading input: %%v\n", err)
215+
os.Exit(1)
216+
}
217+
218+
output := Output{
219+
Result: fmt.Sprintf("Hello from %s! You said: %%s", input.Message),
220+
}
221+
222+
if err := json.NewEncoder(os.Stdout).Encode(output); err != nil {
223+
fmt.Fprintf(os.Stderr, "error writing output: %%v\n", err)
224+
os.Exit(1)
225+
}
226+
}
227+
`, name)
228+
229+
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0o644); err != nil {
230+
return fmt.Errorf("write main.go: %w", err)
231+
}
232+
233+
// Write README.md
234+
readme := fmt.Sprintf(`# %s
235+
236+
A hawk plugin.
237+
238+
## Installation
239+
240+
`+"```bash"+`
241+
hawk plugin install ./%s
242+
`+"```"+`
243+
244+
## Usage
245+
246+
Once installed and activated, the plugin provides the following tools:
247+
248+
- **hello** - Example tool that echoes input
249+
250+
## Development
251+
252+
Run the plugin locally:
253+
254+
`+"```bash"+`
255+
echo '{"message": "world"}' | go run .
256+
`+"```"+`
257+
258+
## Plugin Manifest
259+
260+
See `+"`plugin.json`"+` for the full manifest configuration.
261+
`, name, name)
262+
263+
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(readme), 0o644); err != nil {
264+
return fmt.Errorf("write README.md: %w", err)
265+
}
266+
267+
cmd.Printf("Created plugin scaffold at ./%s/\n", name)
268+
cmd.Printf(" %s/plugin.json - Plugin manifest\n", name)
269+
cmd.Printf(" %s/main.go - Plugin entrypoint\n", name)
270+
cmd.Printf(" %s/README.md - Documentation\n", name)
271+
cmd.Println()
272+
cmd.Printf("Next steps:\n")
273+
cmd.Printf(" cd %s && go mod init %s\n", name, name)
274+
cmd.Printf(" hawk plugin install ./%s\n", name)
275+
cmd.Printf(" hawk plugin activate %s\n", name)
276+
return nil
277+
},
278+
}
279+
280+
var pluginLogsCmd = &cobra.Command{
281+
Use: "logs [name]",
282+
Short: "Show recent plugin lifecycle events",
283+
RunE: func(cmd *cobra.Command, args []string) error {
284+
dm := getDynamicManager()
285+
events := dm.Events()
286+
287+
// Collect recent events (non-blocking drain)
288+
var collected []plugin.PluginEvent
289+
for {
290+
select {
291+
case ev := <-events:
292+
if len(args) == 0 || ev.PluginName == args[0] {
293+
collected = append(collected, ev)
294+
}
295+
default:
296+
goto done
297+
}
298+
}
299+
done:
300+
301+
if len(collected) == 0 {
302+
// Show current status as fallback
303+
statuses := dm.Status()
304+
if len(args) > 0 {
305+
name := args[0]
306+
for _, s := range statuses {
307+
if s.Name == name {
308+
cmd.Printf("Plugin: %s\n", s.Name)
309+
cmd.Printf("State: %s\n", s.State)
310+
if s.Error != "" {
311+
cmd.Printf("Error: %s\n", s.Error)
312+
}
313+
if !s.ActivatedAt.IsZero() {
314+
cmd.Printf("Activated: %s\n", s.ActivatedAt.Format(time.RFC3339))
315+
}
316+
return nil
317+
}
318+
}
319+
return fmt.Errorf("plugin %q not found", name)
320+
}
321+
cmd.Println("No recent plugin events.")
322+
return nil
323+
}
324+
325+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
326+
fmt.Fprintf(w, "TIME\tPLUGIN\tEVENT\tERROR\n")
327+
for _, ev := range collected {
328+
errStr := ""
329+
if ev.Error != "" {
330+
errStr = truncatePluginStr(ev.Error, 50)
331+
}
332+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
333+
ev.Timestamp.Format("15:04:05"),
334+
ev.PluginName,
335+
ev.Type,
336+
errStr,
337+
)
338+
}
339+
w.Flush()
340+
return nil
341+
},
342+
}
343+
344+
func truncatePluginStr(s string, maxLen int) string {
345+
if len(s) <= maxLen {
346+
return s
347+
}
348+
return s[:maxLen-3] + "..."
349+
}
350+
351+
func init() {
352+
pluginStatusCmd.Flags().Bool("json", false, "output as JSON")
353+
354+
pluginCmd.AddCommand(pluginActivateCmd)
355+
pluginCmd.AddCommand(pluginDeactivateCmd)
356+
pluginCmd.AddCommand(pluginReloadCmd)
357+
pluginCmd.AddCommand(pluginStatusCmd)
358+
pluginCmd.AddCommand(pluginCreateCmd)
359+
pluginCmd.AddCommand(pluginInstallDynamicCmd)
360+
pluginCmd.AddCommand(pluginUninstallCmd)
361+
pluginCmd.AddCommand(pluginLogsCmd)
362+
}
363+
364+
// pluginInstallDynamicCmd overrides the default "install" subcommand behavior.
365+
// The original pluginCmd handles "install" as args[0], but now it's also
366+
// a proper subcommand. Cobra handles this gracefully since subcommands take
367+
// priority over args-based dispatching.

0 commit comments

Comments
 (0)