Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
go 1.26.0

require (
github.com/flashcatcloud/go-flashduty v0.15.18-0.20261015103612-f84d9ddaca1a
github.com/flashcatcloud/go-flashduty v0.15.18-0.20261016025836-93c9482173b5
github.com/mattn/go-runewidth v0.0.30
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/flashcatcloud/go-flashduty v0.15.18-0.20261015103612-f84d9ddaca1a h1:+oqnFVWaZlC7yGMu+wl0QpBwEOGdOG+a6hFagXSHYuM=
github.com/flashcatcloud/go-flashduty v0.15.18-0.20261015103612-f84d9ddaca1a/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.17-0.20261014102327-2f2d5497df53 h1:v6tVZmttBD67/gcdQym1SmY1TavYXd95WU3AQAog9VQ=
github.com/flashcatcloud/go-flashduty v0.15.17-0.20261014102327-2f2d5497df53/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.18-0.20261016025836-93c9482173b5 h1:e5ApVt+44vsgUvakvkDn51tjIBC53hRqHiw863O9iTw=
github.com/flashcatcloud/go-flashduty v0.15.18-0.20261016025836-93c9482173b5/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
Expand Down
44 changes: 37 additions & 7 deletions internal/cli/automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func newAutomationCreateCmd() *cobra.Command {
promptFile string
environmentKind string
environmentID string
runIn string
timezone string
)

Expand All @@ -58,15 +59,31 @@ func newAutomationCreateCmd() *cobra.Command {
Long: curatedLong(`Create an AI SRE Automation.

By default the rule is enabled. Use --disabled only when the user explicitly
asks to create it disabled. team_id=0 means personal scope; --team-id >0 creates
the rule under that team. The scope is immutable after creation.
asks to create it disabled. Omit --team-id to let the server inherit the live
IM conversation scope when available; --team-id 0 explicitly selects personal
scope and --team-id >0 selects that team. The scope is immutable after creation.

When this command is invoked from a live IM conversation, omitting --run-in
lets the server bind the Automation to that current chat. Use --run-in web to
run it in the web console instead. Do not supply chat, platform, integration,
or session identifiers: the server resolves and authorizes the current chat.
Each ordinary current-chat run starts a fresh execution session and, where the
IM integration creates threads, a fresh IM thread. It remains bound to the
original chat and its immutable scope, rather than an old session or thread.
Write the task prompt with the needed chat context; do not refer to an earlier
session or say "above". A retry of the same run reuses that run's execution
identity. If the bound chat's scope no longer matches the rule, the run is
skipped. War-room Automations keep their existing behavior.

Schedule helpers build a 5-field cron expression evaluated in the rule's
timezone. Pass --timezone when the user names one. When --timezone is
omitted, leave it unset so the server uses the caller's member timezone,
then the account timezone, then Asia/Shanghai. Do not send a timezone
default from the CLI. Pass the user's local wall-clock time to --at or
--cron-expr; do not convert it to UTC first.
Custom cron uses five fields, but its minute must be one fixed integer from
0 through 59. The fastest supported schedule is once per hour; minute
wildcards, ranges, lists, and steps are unsupported.

For HTTP POST-only rules, pass --http-post-trigger without a schedule; the CLI
sends a valid placeholder cron and disables the schedule trigger.`, "Automations", "RuleWriteCreate"),
Expand All @@ -86,6 +103,12 @@ the rule under that team. The scope is immutable after creation.
if strings.TrimSpace(taskPrompt) == "" {
return fmt.Errorf("one of --prompt or --prompt-file is required")
}
if cmd.Flags().Changed("run-in") {
runIn = strings.TrimSpace(runIn)
if runIn != "current-chat" && runIn != "web" {
return fmt.Errorf("--run-in must be current-chat or web")
}
}

effectiveScheduleEnabled := scheduleEnabled
cron, err := resolveAutomationCreateCron(cmd, schedule, at, weekday, cronExpr, httpPostTrigger, &effectiveScheduleEnabled)
Expand All @@ -95,7 +118,6 @@ the rule under that team. The scope is immutable after creation.

req := &flashduty.AutomationRuleCreateRequest{
Name: name,
TeamID: teamID,
CronExpr: cron,
Enabled: !disabled,
ScheduleTriggerEnabled: flashduty.Bool(effectiveScheduleEnabled),
Expand All @@ -104,6 +126,12 @@ the rule under that team. The scope is immutable after creation.
EnvironmentKind: strings.TrimSpace(environmentKind),
EnvironmentID: strings.TrimSpace(environmentID),
}
if cmd.Flags().Changed("team-id") {
req.TeamID = flashduty.Int64(teamID)
}
if cmd.Flags().Changed("run-in") {
req.RunIn = flashduty.String(runIn)
}
if cmd.Flags().Changed("timezone") {
req.Timezone = strings.TrimSpace(timezone)
}
Expand All @@ -117,21 +145,23 @@ the rule under that team. The scope is immutable after creation.
}

cmd.Flags().StringVar(&name, "name", "", "Automation name")
cmd.Flags().Int64Var(&teamID, "team-id", 0, "Scope team ID; 0 means personal scope")
cmd.Flags().Int64Var(&teamID, "team-id", 0, "Scope team ID; omit to inherit IM scope when available, or pass 0 for personal scope")
cmd.Flags().StringVar(&runIn, "run-in", "", "Execution destination: current-chat or web; omit for the server default")
cmd.Flags().StringVar(&schedule, "schedule", "", "Schedule helper: hourly, daily, weekly, or cron")
cmd.Flags().StringVar(&at, "at", "", "Local time in HH:MM (rule's timezone); for hourly schedules, only the minute is used. "+automationTimezoneNote)
cmd.Flags().StringVar(&weekday, "weekday", "", "Weekday for weekly schedules: sun, mon, tue, wed, thu, fri, sat, or 0-7")
cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Exact 5-field cron expression in the rule's timezone; overrides --schedule helpers. "+automationTimezoneNote)
cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Supported 5-field cron expression in the rule's timezone. The minute must be a fixed 0-59 integer, so the fastest schedule is hourly; overrides --schedule helpers. "+automationTimezoneNote)
cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name cron_expr is evaluated in, for example Asia/Shanghai. The server must be able to load it; an invalid value is rejected. Omit to use the caller's member timezone, then the account timezone, then Asia/Shanghai.")
cmd.Flags().BoolVar(&disabled, "disabled", false, "Create the Automation disabled")
cmd.Flags().BoolVar(&scheduleEnabled, "schedule-enabled", true, "Whether the schedule trigger is enabled")
cmd.Flags().BoolVar(&httpPostTrigger, "http-post-trigger", false, "Create and enable an HTTP POST trigger")
cmd.Flags().StringVar(&prompt, "prompt", "", "Task prompt sent to the AI SRE agent")
cmd.Flags().StringVar(&prompt, "prompt", "", "Self-contained task prompt for the fresh AI SRE execution; include needed chat context and do not refer to earlier messages or 'above'")
cmd.Flags().StringVar(&promptFile, "prompt-file", "", "Read task prompt from a file, or - for stdin")
cmd.Flags().StringVar(&environmentKind, "environment-kind", "", "Runtime environment kind: cloud or byoc; empty means automatic")
cmd.Flags().StringVar(&environmentID, "environment-id", "", "BYOC Runner ID when --environment-kind=byoc")
registerEnumFlag(cmd, "schedule", "hourly", "daily", "weekly", "cron")
registerEnumFlag(cmd, "environment-kind", "cloud", "byoc")
registerEnumFlag(cmd, "run-in", "current-chat", "web")
return cmd
}

Expand Down Expand Up @@ -336,7 +366,7 @@ func newAutomationUpdateCmd() *cobra.Command {
cmd.Flags().StringVar(&schedule, "schedule", "", "Schedule helper: hourly, daily, weekly, or cron")
cmd.Flags().StringVar(&at, "at", "", "Local time in HH:MM (rule's timezone); for hourly schedules, only the minute is used. "+automationTimezoneNote)
cmd.Flags().StringVar(&weekday, "weekday", "", "Weekday for weekly schedules: sun, mon, tue, wed, thu, fri, sat, or 0-7")
cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Exact 5-field cron expression in the rule's timezone; overrides --schedule helpers. "+automationTimezoneNote)
cmd.Flags().StringVar(&cronExpr, "cron-expr", "", "Supported 5-field cron expression in the rule's timezone. The minute must be a fixed 0-59 integer, so the fastest schedule is hourly; overrides --schedule helpers. "+automationTimezoneNote)
cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name cron_expr is evaluated in. The server must be able to load it; an invalid value is rejected. Omit to leave the stored timezone unchanged, including a legacy empty string. An explicit empty string is stored as UTC, not the account default.")
cmd.Flags().BoolVar(&enableRule, "enable", false, "Enable the Automation")
cmd.Flags().BoolVar(&disableRule, "disable", false, "Disable the Automation")
Expand Down
95 changes: 95 additions & 0 deletions internal/cli/automation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,65 @@ func TestAutomationCreateDailyDefaultsEnabled(t *testing.T) {
assertBody(t, stub.lastBody, "prompt", "Summarize yesterday's incidents")
}

func TestAutomationCreateExecutionDestinationWireContract(t *testing.T) {
t.Run("omits server selected destination", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "create",
"--name", "Daily SRE brief",
"--prompt", "Summarize yesterday's incidents",
"--json",
)
if err != nil {
t.Fatalf("[automation-create-omitted-destination] unexpected error: %v", err)
}
for _, field := range []string{"run_in", "team_id"} {
if _, ok := stub.lastBody[field]; ok {
t.Fatalf("[automation-create-omitted-destination] %s must be omitted, body=%#v", field, stub.lastBody)
}
}
})

t.Run("sends explicit chat and personal scope", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "create",
"--name", "Daily SRE brief",
"--prompt", "Summarize yesterday's incidents",
"--run-in", "current-chat",
"--team-id", "0",
"--json",
)
if err != nil {
t.Fatalf("[automation-create-current-chat] unexpected error: %v", err)
}
assertBody(t, stub.lastBody, "run_in", "current-chat")
assertBody(t, stub.lastBody, "team_id", float64(0))
})

t.Run("rejects invalid destination before request", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "create",
"--name", "Daily SRE brief",
"--prompt", "Summarize yesterday's incidents",
"--run-in", "other-chat",
)
if err == nil || !strings.Contains(err.Error(), "--run-in must be current-chat or web") {
t.Fatalf("[automation-create-invalid-destination] err = %v", err)
}
if stub.requests != 0 {
t.Fatalf("[automation-create-invalid-destination] request must not reach API, requests=%d", stub.requests)
}
})
}

func TestAutomationScheduleHelpDocumentsTimezone(t *testing.T) {
saveAndResetGlobals(t)

Expand All @@ -60,6 +119,30 @@ func TestAutomationScheduleHelpDocumentsTimezone(t *testing.T) {
}
}

func TestAutomationCreateHelpDocumentsFreshIMExecutionAndScheduleLimit(t *testing.T) {
saveAndResetGlobals(t)

out, err := execCommand("automation", "create", "--help")
if err != nil {
t.Fatalf("[automation-create-help] unexpected error: %v", err)
}
for _, want := range []string{
"fresh execution session",
"fresh IM thread",
"Write the task prompt with the needed chat context",
"say \"above\"",
"scope no longer matches the rule",
"skipped. War-room Automations keep their existing behavior",
"The fastest supported schedule is once per hour",
"wildcards, ranges, lists, and steps are unsupported",
"Self-contained task prompt for the fresh AI SRE execution",
} {
if !strings.Contains(out, want) {
t.Fatalf("[automation-create-help] missing %q\n%s", want, out)
}
}
}

func TestAutomationCreateTimezone(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
Expand Down Expand Up @@ -279,6 +362,18 @@ func TestAutomationUpdateDoesNotExposeScopeFlag(t *testing.T) {
}
}

func TestSafariAutomationRuleUpdateDoesNotExposeExecutionTargetFlags(t *testing.T) {
saveAndResetGlobals(t)
newGFStub(t)

for _, flag := range []string{"--team-id", "--run-in"} {
_, err := execCommand("safari", "automation-rule-update", "auto_123", flag, "0")
if err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("[safari-automation-update-target] %s err = %v", flag, err)
}
}
}

func TestAutomationFireSendsBearerToken(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
Expand Down
Loading
Loading