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.12
github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d
github.com/mattn/go-runewidth v0.0.30
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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.12 h1:+x+wSCcVKpxlBfGBMU5vftwk6ducoumYKmlmyDhEO8c=
github.com/flashcatcloud/go-flashduty v0.15.12/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d h1:LU8KL6c3fSBVtevbAyLkzNcEltvtWH3wuqeQcNvKMjI=
github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d/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
31 changes: 23 additions & 8 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
timezone string
)

cmd := &cobra.Command{
Expand All @@ -61,10 +62,11 @@ 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.

Schedule helpers build a 5-field cron expression evaluated in the rule's
timezone, which defaults to the caller's member timezone, then the account
timezone. Pass the user's local wall-clock time to --at or --cron-expr;
do not convert it to UTC first. This command has no --timezone flag; use
safari automation-rule-create --timezone to pin a different one.
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.

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 Down Expand Up @@ -102,6 +104,9 @@ the rule under that team. The scope is immutable after creation.
EnvironmentKind: strings.TrimSpace(environmentKind),
EnvironmentID: strings.TrimSpace(environmentID),
}
if cmd.Flags().Changed("timezone") {
req.Timezone = strings.TrimSpace(timezone)
}
out, _, err := ctx.Client.Automations.RuleWriteCreate(cmdContext(ctx.Cmd), req)
if err != nil {
return err
Expand All @@ -117,6 +122,7 @@ the rule under that team. The scope is immutable after creation.
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(&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")
Expand Down Expand Up @@ -213,6 +219,7 @@ func newAutomationUpdateCmd() *cobra.Command {
enableHTTPPostTrigger bool
disableHTTPPostTrigger bool
rotateHTTPPostToken bool
timezone string
)

cmd := &cobra.Command{
Expand All @@ -224,10 +231,13 @@ func newAutomationUpdateCmd() *cobra.Command {
after creation; create a new Automation if the target person/team scope needs to change.

Schedule helpers build a 5-field cron expression evaluated in the rule's
timezone, set at creation from the caller's member timezone, then the account
timezone. Pass the user's local wall-clock time to --at or --cron-expr;
do not convert it to UTC first. This command has no --timezone flag; the
rule's timezone cannot be changed after creation.`, "Automations", "RuleWriteUpdate"),
timezone. Pass --timezone when the user names one. When --timezone is
omitted, the stored timezone stays, including a legacy empty string. Pass
the user's local wall-clock time to --at or --cron-expr; do not convert it
to UTC first. An explicit empty --timezone "" is sent and stored as UTC,
not the account default. Update recalculates the next fire immediately;
read schedule_next_fire_at_ms from the response and do not assume the
previously scheduled occurrence will still run.`, "Automations", "RuleWriteUpdate"),
Example: ` flashduty automation update auto_123 --name "Daily brief v2" --cron-expr "15 1 * * *"
flashduty automation update auto_123 --disable
flashduty automation update auto_123 --enable-http-post-trigger --rotate-http-post-token`,
Expand Down Expand Up @@ -277,6 +287,10 @@ func newAutomationUpdateCmd() *cobra.Command {
req.CronExpr = flashduty.String(cron)
changed = true
}
if cmd.Flags().Changed("timezone") {
req.Timezone = flashduty.String(strings.TrimSpace(timezone))
changed = true
}
if enableSchedule {
req.ScheduleTriggerEnabled = flashduty.Bool(true)
changed = true
Expand Down Expand Up @@ -323,6 +337,7 @@ func newAutomationUpdateCmd() *cobra.Command {
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(&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")
cmd.Flags().BoolVar(&enableSchedule, "enable-schedule", false, "Enable the schedule trigger")
Expand Down
159 changes: 159 additions & 0 deletions internal/cli/automation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,172 @@ func TestAutomationScheduleHelpDocumentsTimezone(t *testing.T) {
}
for _, want := range []string{
"do not convert it to UTC",
"--timezone",
automationTimezoneNote,
} {
if !strings.Contains(out, want) {
t.Fatalf("%v help missing %q\n%s", args, want, out)
}
}
if strings.Contains(out, "has no --timezone flag") {
t.Fatalf("%v help still says the command has no --timezone flag\n%s", args, out)
}
}
}

func TestAutomationCreateTimezone(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "create",
"--name", "Daily SRE brief",
"--schedule", "daily",
"--at", "09:00",
"--prompt", "Summarize yesterday's incidents",
"--timezone", "Asia/Shanghai",
"--json",
)
if err != nil {
t.Fatalf("[automation-create-timezone] unexpected error: %v", err)
}
assertBody(t, stub.lastBody, "timezone", "Asia/Shanghai")
}

func TestAutomationCreateExplicitEmptyTimezoneIsOmitted(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "create",
"--name", "Daily SRE brief",
"--prompt", "Summarize yesterday's incidents",
"--timezone", "",
"--json",
)
if err != nil {
t.Fatalf("[automation-create-empty-timezone] unexpected error: %v", err)
}
// CreateRequest.Timezone is a string with omitempty, so an explicit ""
// is dropped. Update's pointer is what sends an empty string.
if _, ok := stub.lastBody["timezone"]; ok {
t.Fatalf("[automation-create-empty-timezone] empty string must not be on the wire, body=%#v", stub.lastBody)
}
}

func TestAutomationCreateOmitsTimezone(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-omit-timezone] unexpected error: %v", err)
}
if _, ok := stub.lastBody["timezone"]; ok {
t.Fatalf("[automation-create-omit-timezone] timezone must be omitted, body=%#v", stub.lastBody)
}
}

func TestAutomationUpdateTimezone(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "update", "auto_123",
"--timezone", "Asia/Tokyo",
"--json",
)
if err != nil {
t.Fatalf("[automation-update-timezone] unexpected error: %v", err)
}
assertBody(t, stub.lastBody, "rule_id", "auto_123")
assertBody(t, stub.lastBody, "timezone", "Asia/Tokyo")
if _, ok := stub.lastBody["cron_expr"]; ok {
t.Fatalf("[automation-update-timezone] cron_expr must stay omitted, body=%#v", stub.lastBody)
}
}

func TestAutomationUpdateEmptyTimezoneIsSent(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "update", "auto_123",
"--timezone", "",
"--json",
)
if err != nil {
t.Fatalf("[automation-update-empty-timezone] unexpected error: %v", err)
}
assertBody(t, stub.lastBody, "timezone", "")
}

func TestAutomationUpdateOmitsTimezone(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)

_, err := execCommand(
"automation", "update", "auto_123",
"--name", "Daily brief v2",
"--json",
)
if err != nil {
t.Fatalf("[automation-update-omit-timezone] unexpected error: %v", err)
}
assertBody(t, stub.lastBody, "name", "Daily brief v2")
if _, ok := stub.lastBody["timezone"]; ok {
t.Fatalf("[automation-update-omit-timezone] timezone must stay nil, body=%#v", stub.lastBody)
}
}

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

help, err := execCommand("safari", "automation-rule-update", "--help")
if err != nil {
t.Fatalf("[safari-update-timezone] help: %v", err)
}
if !strings.Contains(help, "--timezone") {
t.Fatalf("[safari-update-timezone] help missing --timezone\n%s", help)
}

stub := newGFStub(t)
_, err = execCommand(
"safari", "automation-rule-update", "arule_1",
"--name", "kept",
"--json",
)
if err != nil {
t.Fatalf("[safari-update-timezone] omit: %v", err)
}
if _, ok := stub.lastBody["timezone"]; ok {
t.Fatalf("[safari-update-timezone] omitted flag must not send timezone, body=%#v", stub.lastBody)
}

_, err = execCommand(
"safari", "automation-rule-update", "arule_1",
"--timezone", "",
"--json",
)
if err != nil {
t.Fatalf("[safari-update-timezone] empty: %v", err)
}
assertBody(t, stub.lastBody, "timezone", "")

_, err = execCommand(
"safari", "automation-rule-update", "arule_1",
"--timezone", "Europe/London",
"--json",
)
if err != nil {
t.Fatalf("[safari-update-timezone] named: %v", err)
}
assertBody(t, stub.lastBody, "timezone", "Europe/London")
}

func TestAutomationCreateHTTPPostOnly(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/zz_generated_automations.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 10 additions & 4 deletions skills/flashduty/reference/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@ Prereq: `SKILL.md` read. Automations create AI SRE sessions on a schedule or thr
## Scheduling

- Default create behavior: enabled immediately. Use `--disabled` only if the user asks for a disabled Automation.
- `create`/`update` expose no `--timezone` flag. The cron expression runs in the rule's timezone, which the server resolves from the caller's member timezone, then the account timezone (a server-side default applies when neither is set).
- Pass the user's local wall-clock time directly to `--at` or `--cron-expr` — do not convert it to UTC first. The rule already runs in the caller's own resolved timezone, so a manual UTC conversion shifts the schedule by the account's UTC offset.
- Helper schedules (times are in the rule's resolved timezone, not UTC):
- If the user names a timezone, pass `--timezone` with that IANA name on `create` or `update`. The cron expression is that timezone's wall clock. Do not convert it to UTC before calling.
- `create` without `--timezone` still uses the caller's member timezone, then the account timezone, then Asia/Shanghai. Do not invent a timezone in the CLI.
- `update` without `--timezone` keeps the stored value, including a legacy empty string.
- `update` recalculates the next fire immediately. Read `schedule_next_fire_at_ms` from the response and tell the user. Do not assume the previously scheduled occurrence will still run.
- An explicit empty string (`--timezone ""`) is UTC, not the account default. On `update` that empty string is sent and stored. On `create`, omit the flag for the default chain above; do not pass an empty string to mean UTC.
- Pass the user's local wall-clock time directly to `--at` or `--cron-expr`.
- Helper schedules (times are in the rule's timezone, not UTC):
- `--schedule hourly --at 00:15` -> minute 15 of every hour.
- `--schedule daily --at 01:30` -> every day at 01:30.
- `--schedule weekly --weekday mon --at 02:00` -> every Monday at 02:00.
- For exact minute-level control, use `--cron-expr '<minute> <hour> <day> <month> <weekday>'` in that same local time.
- To pin a rule to a specific timezone (e.g. UTC) regardless of the caller's default, use `safari automation-rule-create --timezone <IANA tz>` instead — the curated `create`/`update` commands cannot set it, and `update` cannot change it after creation.
- HTTP POST-only rule: pass `--http-post-trigger` without schedule flags. The CLI sends a placeholder cron and disables the schedule trigger.

## Hot flow - create from chat
Expand Down Expand Up @@ -119,6 +122,7 @@ Create an Automation
- `--schedule` string
- `--schedule-enabled` bool
- `--team-id` int64
- `--timezone` string
- `--weekday` string
- response: single object (`data` unwrapped to the top level) — fields: account_id (integer); can_edit (boolean); created_at (string); cron_expr (string); enabled (boolean); environment_id (string); environment_kind (string); http_post_token (string); http_post_trigger_enabled (boolean); http_post_trigger_id (string); http_post_trigger_url (string); name (string); oncall_incident_channel_ids (array<integer>); oncall_incident_severities (array<string>); oncall_incident_trigger_enabled (boolean); oncall_incident_trigger_id (string); owner_id (integer); prompt (string); rule_id (string); run_scope (string); schedule_next_fire_at_ms (string); schedule_trigger_enabled (boolean); schedule_trigger_id (string); team_id (integer); timezone (string); updated_at (string)

Expand Down Expand Up @@ -177,6 +181,7 @@ Update an Automation
- `--prompt-file` string
- `--rotate-http-post-token` bool
- `--schedule` string
- `--timezone` string
- `--weekday` string
- response: same shape as `create` above

Expand All @@ -185,6 +190,7 @@ Update an Automation
## Gotchas

- **Do not ask form-like follow-up questions** when the request is clear enough. Choose practical defaults: personal scope when no team is named, enabled on create, daily 09:00 for a vague daily schedule, Monday 09:00 for a vague weekly schedule.
- **An explicit empty `--timezone ""` is UTC, not the account default.** On `update` that value is sent and stored. Omitting the flag on `update` keeps the stored timezone, including a legacy empty string. Omitting it on `create` uses member, then account, then Asia/Shanghai. After `update`, read `schedule_next_fire_at_ms` and tell the user; the previous occurrence is not guaranteed to run.
- **Ask only when required data is missing**: task prompt, trigger token for `fire`, or an ambiguous target rule for update/delete.
- **`update` cannot move personal/team scope.** If the user asks to move scope, create a replacement Automation in the new scope and then delete or disable the old one after confirmation.
- **Use `--prompt-file` for long prompts.** Shell quoting is the most common failure when the prompt contains quotes, markdown, or JSON.
Expand Down
1 change: 1 addition & 0 deletions skills/flashduty/reference/safari.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Update Automation rule
- `<rule-id>` (positional, required) string — Target rule ID, from the list returned by 'POST /safari/automation/rule/list'.
- `--schedule-trigger-enabled` bool — Whether the schedule trigger is enabled.
- `--team-id` int64 — Reassign the rule's scope: 0 converts to a personal rule (only the rule owner may convert a team rule); >0 moves it into a team the caller belongs to. Omit to leave unchanged. (min 0)
- `--timezone` string — New IANA timezone for evaluating 'cron_expr'. Omit or send null to leave the current timezone unchanged.
- response: same shape as `automation-rule-create` above

### automation-run-list <rule-id>
Expand Down
Loading