fix(cron): gate --workdir flag on hasCronWorkdir capability

`HermesCapabilities.hasCronWorkdir` was added but never consumed: the
editor sheet always rendered the Workdir TextField and the view model
unconditionally appended `--workdir <path>` whenever the field was
non-empty. On a pre-v0.12 host argparse rejects the unknown flag and
the entire `cron create`/`cron edit` call fails.

Two-layer gate:
- CronJobEditor takes a `supportsWorkdir` flag and hides the field on
  pre-v0.12 hosts.
- CronView reads `\.hermesCapabilities` and forces the workdir argument
  to "" / nil when the capability is absent, so an editing-an-existing-
  job path that hydrates `form.workdir` from a pre-existing value can't
  smuggle the flag through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 13:21:35 +02:00
parent 11bb2bd0c3
commit 4a2ef74b74
+15 -5
View File
@@ -12,11 +12,16 @@ import ScarfDesign
struct CronView: View { struct CronView: View {
@State private var viewModel: CronViewModel @State private var viewModel: CronViewModel
@State private var pendingDelete: HermesCronJob? @State private var pendingDelete: HermesCronJob?
@Environment(\.hermesCapabilities) private var capabilitiesStore
init(context: ServerContext) { init(context: ServerContext) {
_viewModel = State(initialValue: CronViewModel(context: context)) _viewModel = State(initialValue: CronViewModel(context: context))
} }
private var hasCronWorkdir: Bool {
capabilitiesStore?.capabilities.hasCronWorkdir ?? false
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
pageHeader pageHeader
@@ -32,7 +37,7 @@ struct CronView: View {
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty) .loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
.sheet(isPresented: $viewModel.showCreateSheet) { .sheet(isPresented: $viewModel.showCreateSheet) {
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
viewModel.createJob( viewModel.createJob(
schedule: form.schedule, schedule: form.schedule,
prompt: form.prompt, prompt: form.prompt,
@@ -41,7 +46,7 @@ struct CronView: View {
skills: form.skills, skills: form.skills,
script: form.script, script: form.script,
repeatCount: form.repeatCount, repeatCount: form.repeatCount,
workdir: form.workdir workdir: hasCronWorkdir ? form.workdir : ""
) )
viewModel.showCreateSheet = false viewModel.showCreateSheet = false
} onCancel: { } onCancel: {
@@ -49,7 +54,7 @@ struct CronView: View {
} }
} }
.sheet(item: $viewModel.editingJob) { job in .sheet(item: $viewModel.editingJob) { job in
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
viewModel.updateJob( viewModel.updateJob(
id: job.id, id: job.id,
schedule: form.schedule, schedule: form.schedule,
@@ -60,7 +65,7 @@ struct CronView: View {
newSkills: form.skills, newSkills: form.skills,
clearSkills: form.clearSkills, clearSkills: form.clearSkills,
script: form.script, script: form.script,
workdir: form.workdir workdir: hasCronWorkdir ? form.workdir : nil
) )
viewModel.editingJob = nil viewModel.editingJob = nil
} onCancel: { } onCancel: {
@@ -477,6 +482,9 @@ struct CronJobEditor: View {
let mode: Mode let mode: Mode
let availableSkills: [String] let availableSkills: [String]
/// Pass `false` on pre-v0.12 hosts; the `--workdir` field is hidden and
/// the form's value is dropped when the parent calls `createJob`/`updateJob`.
let supportsWorkdir: Bool
let onSave: (FormState) -> Void let onSave: (FormState) -> Void
let onCancel: () -> Void let onCancel: () -> Void
@@ -511,7 +519,9 @@ struct CronJobEditor: View {
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true) formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count") formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true) formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context (v0.12+)", mono: true) if supportsWorkdir {
formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context", mono: true)
}
if !availableSkills.isEmpty { if !availableSkills.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Skills") Text("Skills")