forked from chroe/gitlink-cli
395 lines
11 KiB
Go
395 lines
11 KiB
Go
package workflow
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
// --- Registry ---
|
|
|
|
var registry = map[string]*WorkflowDef{}
|
|
|
|
func register(wf *WorkflowDef) {
|
|
registry[wf.Name] = wf
|
|
}
|
|
|
|
// All returns all registered workflows sorted by name.
|
|
func All() []*WorkflowDef {
|
|
names := make([]string, 0, len(registry))
|
|
for n := range registry {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
result := make([]*WorkflowDef, len(names))
|
|
for i, n := range names {
|
|
result[i] = registry[n]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Get returns a workflow by name, or nil.
|
|
func Get(name string) *WorkflowDef {
|
|
return registry[name]
|
|
}
|
|
|
|
// --- CLI Commands ---
|
|
|
|
// Shortcuts returns all workflow CLI commands.
|
|
func Shortcuts() []*common.Shortcut {
|
|
return []*common.Shortcut{
|
|
{
|
|
Name: "list",
|
|
Description: "List available workflows",
|
|
Flags: []common.Flag{
|
|
{Name: "category", Short: "c", Usage: "Filter by category", Default: ""},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
cat := ctx.Arg("category")
|
|
workflows := All()
|
|
filtered := make([]*WorkflowDef, 0)
|
|
for _, w := range workflows {
|
|
if cat == "" || strings.EqualFold(w.Category, cat) {
|
|
filtered = append(filtered, w)
|
|
}
|
|
}
|
|
type listItem struct {
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
StepCount int `json:"step_count"`
|
|
}
|
|
items := make([]listItem, len(filtered))
|
|
for i, w := range filtered {
|
|
items[i] = listItem{
|
|
Name: w.Name,
|
|
Category: w.Category,
|
|
Description: w.Description,
|
|
StepCount: len(w.Steps),
|
|
}
|
|
}
|
|
return ctx.OutputData(items)
|
|
},
|
|
},
|
|
{
|
|
Name: "info",
|
|
Description: "Show workflow detail (steps and trigger)",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wf := Get(name)
|
|
if wf == nil {
|
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
|
}
|
|
return ctx.OutputData(wf)
|
|
},
|
|
},
|
|
{
|
|
Name: "run",
|
|
Description: "Execute a workflow manually",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
|
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
|
|
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wf := Get(name)
|
|
if wf == nil {
|
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
|
}
|
|
|
|
aiMode, err := resolveAIModeFromArgs(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Daemon loop mode (internal — forked by +start)
|
|
if ctx.Arg("daemon-loop") == "true" {
|
|
intervalStr := ctx.Arg("interval")
|
|
interval, err := time.ParseDuration(intervalStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
|
}
|
|
return DaemonLoop(ctx, wf, interval)
|
|
}
|
|
|
|
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
|
},
|
|
},
|
|
{
|
|
Name: "init",
|
|
Description: "Run project one-click initialization workflow",
|
|
Flags: []common.Flag{
|
|
{Name: "dry-run", Usage: "Preview initialization without AI calls", Bool: true},
|
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
wf := Get("project-init")
|
|
if wf == nil {
|
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", "project-init")
|
|
}
|
|
|
|
aiMode, err := resolveAIModeFromArgs(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
|
|
},
|
|
},
|
|
{
|
|
Name: "watch",
|
|
Description: "Poll for changes and trigger workflow on delta",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 30s, 5m, 1h)", Default: "5m"},
|
|
{Name: "step", Short: "s", Usage: "Step name to watch for changes (default: first step)", Default: ""},
|
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wf := Get(name)
|
|
if wf == nil {
|
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
|
}
|
|
intervalStr := ctx.Arg("interval")
|
|
interval, err := time.ParseDuration(intervalStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
|
}
|
|
|
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
|
if modeErr != nil {
|
|
return modeErr
|
|
}
|
|
ctx.AIMode = aiMode
|
|
|
|
return Watch(ctx, wf, interval, ctx.Arg("step"))
|
|
},
|
|
},
|
|
{
|
|
Name: "schedule",
|
|
Description: "Run a workflow on a repeating schedule",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
{Name: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
|
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wf := Get(name)
|
|
if wf == nil {
|
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
|
}
|
|
intervalStr := ctx.Arg("interval")
|
|
interval, err := time.ParseDuration(intervalStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
|
}
|
|
|
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
|
if modeErr != nil {
|
|
return modeErr
|
|
}
|
|
ctx.AIMode = aiMode
|
|
|
|
return Schedule(ctx, wf, interval)
|
|
},
|
|
},
|
|
{
|
|
Name: "start",
|
|
Description: "Start workflow as background daemon",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
|
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wf := Get(name)
|
|
if wf == nil {
|
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
|
}
|
|
intervalStr := ctx.Arg("interval")
|
|
interval, err := time.ParseDuration(intervalStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
|
}
|
|
|
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
|
if modeErr != nil {
|
|
return modeErr
|
|
}
|
|
|
|
return StartDaemon(ctx, wf, interval, aiMode)
|
|
},
|
|
},
|
|
{
|
|
Name: "stop",
|
|
Description: "Stop workflow daemon",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return StopDaemon(name)
|
|
},
|
|
},
|
|
{
|
|
Name: "status",
|
|
Description: "Show daemon status",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return StatusDaemon(name)
|
|
},
|
|
},
|
|
{
|
|
Name: "logs",
|
|
Description: "View daemon log output",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
{Name: "follow", Short: "f", Usage: "Follow log output (like tail -f)", Bool: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tailDaemonLog(name, ctx.Arg("follow") == "true")
|
|
},
|
|
},
|
|
{
|
|
Name: "install-systemd",
|
|
Description: "Generate systemd service unit for a workflow daemon",
|
|
Flags: []common.Flag{
|
|
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
|
{Name: "interval", Short: "i", Usage: "Poll interval", Default: "5m"},
|
|
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
|
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
|
},
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
name, err := ctx.RequireArg("name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wf := Get(name)
|
|
if wf == nil {
|
|
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
|
}
|
|
|
|
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
|
if modeErr != nil {
|
|
return modeErr
|
|
}
|
|
|
|
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
|
|
ai := ctx.Arg("ai") == "true"
|
|
noAI := ctx.Arg("no-ai") == "true"
|
|
if ai && noAI {
|
|
return "", fmt.Errorf("--ai 和 --no-ai 互斥,只能指定其中一个")
|
|
}
|
|
if ai {
|
|
return "ai", nil
|
|
}
|
|
if noAI {
|
|
return "no-ai", nil
|
|
}
|
|
return "auto", nil
|
|
}
|
|
|
|
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
|
|
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
needsAI := 0
|
|
ruleEngine := 0
|
|
for _, sr := range result.Steps {
|
|
if m, ok := sr.Data.(map[string]interface{}); ok {
|
|
if v, _ := m["_needs_ai"]; v == true {
|
|
needsAI++
|
|
}
|
|
if v, _ := m["_ai_used"]; v == true {
|
|
ruleEngine++ // AI was used
|
|
}
|
|
}
|
|
}
|
|
|
|
if needsAI > 0 {
|
|
fmt.Fprintf(os.Stderr, "\n⚠ %d 个 skill 步骤需要 AI 处理:\n", needsAI)
|
|
for _, sr := range result.Steps {
|
|
if m, ok := sr.Data.(map[string]interface{}); ok {
|
|
if v, _ := m["_needs_ai"]; v == true {
|
|
fmt.Fprintf(os.Stderr, " - %s (%s)\n", sr.Step, m["_skill"])
|
|
}
|
|
}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\n你可以:\n")
|
|
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set anthropic_api_key <key>\n")
|
|
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
|
|
} else if ruleEngine > 0 {
|
|
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
|
} else {
|
|
noAI := 0
|
|
for _, sr := range result.Steps {
|
|
if m, ok := sr.Data.(map[string]interface{}); ok {
|
|
if v, _ := m["_ai_used"]; v == false {
|
|
if _, hasSkill := m["_skill"]; hasSkill {
|
|
noAI++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if noAI > 0 {
|
|
fmt.Fprintf(os.Stderr, "⚙️ 规则引擎已处理 %d 个 skill 步骤 (未使用 AI)\n", noAI)
|
|
}
|
|
}
|
|
|
|
return ctx.Output(output.SuccessEnvelope(result, nil))
|
|
}
|