aias 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +140 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +249 -0
- data/Rakefile +27 -0
- data/aia_schedule_idea.md +256 -0
- data/docs/assets/images/logo.jpg +0 -0
- data/docs/cli/add.md +101 -0
- data/docs/cli/check.md +70 -0
- data/docs/cli/clear.md +45 -0
- data/docs/cli/dry-run.md +57 -0
- data/docs/cli/index.md +51 -0
- data/docs/cli/install.md +198 -0
- data/docs/cli/last.md +49 -0
- data/docs/cli/list.md +40 -0
- data/docs/cli/next.md +49 -0
- data/docs/cli/remove.md +87 -0
- data/docs/cli/show.md +54 -0
- data/docs/cli/uninstall.md +29 -0
- data/docs/cli/update.md +75 -0
- data/docs/getting-started/installation.md +69 -0
- data/docs/getting-started/quick-start.md +105 -0
- data/docs/guides/configuration-layering.md +168 -0
- data/docs/guides/cron-environment.md +112 -0
- data/docs/guides/scheduling-prompts.md +319 -0
- data/docs/guides/understanding-cron.md +134 -0
- data/docs/guides/validation.md +114 -0
- data/docs/index.md +100 -0
- data/docs/reference/api.md +409 -0
- data/docs/reference/architecture.md +122 -0
- data/docs/reference/environment.md +67 -0
- data/docs/reference/logging.md +73 -0
- data/example_prompts/code_health_check.md +51 -0
- data/example_prompts/daily_digest.md +19 -0
- data/example_prompts/morning_standup.md +19 -0
- data/example_prompts/reports/monthly_review.md +44 -0
- data/example_prompts/reports/weekly_summary.md +22 -0
- data/example_prompts/you_are_good.md +22 -0
- data/exe/aias +5 -0
- data/lib/aias/block_parser.rb +42 -0
- data/lib/aias/cli/add.rb +30 -0
- data/lib/aias/cli/check.rb +50 -0
- data/lib/aias/cli/clear.rb +11 -0
- data/lib/aias/cli/dry_run.rb +24 -0
- data/lib/aias/cli/install.rb +57 -0
- data/lib/aias/cli/last.rb +26 -0
- data/lib/aias/cli/list.rb +20 -0
- data/lib/aias/cli/next.rb +28 -0
- data/lib/aias/cli/remove.rb +14 -0
- data/lib/aias/cli/show.rb +18 -0
- data/lib/aias/cli/uninstall.rb +15 -0
- data/lib/aias/cli/update.rb +30 -0
- data/lib/aias/cli/version.rb +10 -0
- data/lib/aias/cli.rb +91 -0
- data/lib/aias/cron_describer.rb +142 -0
- data/lib/aias/crontab_manager.rb +140 -0
- data/lib/aias/env_file.rb +75 -0
- data/lib/aias/job_builder.rb +56 -0
- data/lib/aias/paths.rb +14 -0
- data/lib/aias/prompt_scanner.rb +111 -0
- data/lib/aias/schedule_config.rb +31 -0
- data/lib/aias/validator.rb +101 -0
- data/lib/aias/version.rb +5 -0
- data/lib/aias.rb +17 -0
- data/mkdocs.yml +137 -0
- data/sig/aias.rbs +4 -0
- metadata +191 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aias
|
|
4
|
+
# Converts a cron expression or @ keyword into a plain-English description.
|
|
5
|
+
# Returns the original expression unchanged for anything it cannot parse,
|
|
6
|
+
# so it is always safe to call.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# CronDescriber.describe("0 8 * * *") # => "every day at 8am"
|
|
10
|
+
# CronDescriber.describe("0 8 * * 1-5") # => "every weekday at 8am"
|
|
11
|
+
# CronDescriber.display("0 8 * * *") # => "every day at 8am (0 8 * * *)"
|
|
12
|
+
class CronDescriber
|
|
13
|
+
DAYS = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
14
|
+
|
|
15
|
+
MONTHS = [
|
|
16
|
+
nil, "January", "February", "March", "April", "May", "June",
|
|
17
|
+
"July", "August", "September", "October", "November", "December"
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
KEYWORDS = {
|
|
21
|
+
"@yearly" => "every year",
|
|
22
|
+
"@annually" => "every year",
|
|
23
|
+
"@monthly" => "every month",
|
|
24
|
+
"@weekly" => "every Sunday at midnight",
|
|
25
|
+
"@daily" => "every day at midnight",
|
|
26
|
+
"@midnight" => "every day at midnight",
|
|
27
|
+
"@hourly" => "every hour"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Returns "plain english (expr)".
|
|
31
|
+
def self.display(expr)
|
|
32
|
+
desc = describe(expr)
|
|
33
|
+
desc == expr ? expr : "#{desc} (#{expr})"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns only the plain-English description (or the original expr on failure).
|
|
37
|
+
def self.describe(expr)
|
|
38
|
+
new(expr.to_s.strip).describe
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(expr)
|
|
42
|
+
@expr = expr
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def describe
|
|
46
|
+
return KEYWORDS[@expr] if KEYWORDS.key?(@expr)
|
|
47
|
+
|
|
48
|
+
parts = @expr.split
|
|
49
|
+
return @expr unless parts.size == 5
|
|
50
|
+
|
|
51
|
+
min, hour, dom, mon, dow = parts
|
|
52
|
+
|
|
53
|
+
# Fall back for step expressions (*/n) — we don't describe these.
|
|
54
|
+
return @expr if [min, hour, dom, mon, dow].any? { |f| f.include?("/") }
|
|
55
|
+
|
|
56
|
+
"#{describe_date(dom, mon, dow)}#{describe_time(min, hour)}"
|
|
57
|
+
rescue StandardError
|
|
58
|
+
@expr
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# -------------------------------------------------------------------------
|
|
64
|
+
# Time
|
|
65
|
+
# -------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def describe_time(min, hour)
|
|
68
|
+
return "" if hour == "*" && min == "*"
|
|
69
|
+
return " at every minute" if hour != "*" && min == "*"
|
|
70
|
+
return "" if hour == "*"
|
|
71
|
+
|
|
72
|
+
format_clock(hour.to_i, min.to_i)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def format_clock(hour, min)
|
|
76
|
+
ampm = hour < 12 ? "am" : "pm"
|
|
77
|
+
h = hour % 12
|
|
78
|
+
h = 12 if h == 0
|
|
79
|
+
m = min.zero? ? "" : ":#{min.to_s.rjust(2, '0')}"
|
|
80
|
+
" at #{h}#{m}#{ampm}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -------------------------------------------------------------------------
|
|
84
|
+
# Date
|
|
85
|
+
# -------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def describe_date(dom, mon, dow)
|
|
88
|
+
if dom == "*" && dow == "*"
|
|
89
|
+
mon == "*" ? "every day" : "every #{month_name(mon)}"
|
|
90
|
+
elsif dom != "*" && dow == "*"
|
|
91
|
+
suffix = mon == "*" ? "monthly" : "every #{month_name(mon)}"
|
|
92
|
+
"#{suffix} on the #{ordinal(dom.to_i)}"
|
|
93
|
+
else
|
|
94
|
+
# dow is specified (dom may also be set — cron ORs them, but dow wins here)
|
|
95
|
+
"every #{describe_dow(dow)}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def describe_dow(dow)
|
|
100
|
+
case dow
|
|
101
|
+
when /^\d+$/
|
|
102
|
+
DAYS[dow.to_i % 7]
|
|
103
|
+
when /^(\d+)-(\d+)$/
|
|
104
|
+
a = Regexp.last_match(1).to_i % 7
|
|
105
|
+
b = Regexp.last_match(2).to_i % 7
|
|
106
|
+
return "weekday" if a == 1 && b == 5
|
|
107
|
+
return "day" if (a == 0 && b == 6) || (a == 0 && b == 7 % 7)
|
|
108
|
+
|
|
109
|
+
"#{DAYS[a]}\u2013#{DAYS[b]}"
|
|
110
|
+
when /^\d+(,\d+)+$/
|
|
111
|
+
days = dow.split(",").map { |d| d.to_i % 7 }
|
|
112
|
+
# Fugit normalises "1-5" (weekday range) to "1,2,3,4,5" — detect that.
|
|
113
|
+
return "weekday" if days == [1, 2, 3, 4, 5]
|
|
114
|
+
|
|
115
|
+
days.map { |d| DAYS[d] }.join(", ")
|
|
116
|
+
else
|
|
117
|
+
dow
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def month_name(mon)
|
|
122
|
+
return mon unless mon.match?(/^\d+$/)
|
|
123
|
+
|
|
124
|
+
MONTHS[mon.to_i] || mon
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ordinal(n)
|
|
128
|
+
suffix =
|
|
129
|
+
case n % 100
|
|
130
|
+
when 11, 12, 13 then "th"
|
|
131
|
+
else
|
|
132
|
+
case n % 10
|
|
133
|
+
when 1 then "st"
|
|
134
|
+
when 2 then "nd"
|
|
135
|
+
when 3 then "rd"
|
|
136
|
+
else "th"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
"#{n}#{suffix}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Aias
|
|
7
|
+
class CrontabManager
|
|
8
|
+
include BlockParser
|
|
9
|
+
IDENTIFIER = "aias"
|
|
10
|
+
|
|
11
|
+
BLOCK_OPEN = "# BEGIN aias"
|
|
12
|
+
BLOCK_CLOSE = "# END aias"
|
|
13
|
+
|
|
14
|
+
# Parses cron lines generated by JobBuilder.
|
|
15
|
+
# Current format (flags before prompt_id, single redirect):
|
|
16
|
+
# <cron> <shell> -c 'source <env.sh> && <aia> [--prompts-dir <dir>] [--config <cfg>] <prompt_id> > <log> 2>&1'
|
|
17
|
+
# Legacy formats (with -l, inline env vars, flags after prompt_id, >>) are also
|
|
18
|
+
# matched for backward compatibility when reading older installed entries.
|
|
19
|
+
ENTRY_RE = /^(?<cron>[^\/\n]+)\s+\/\S+\s+(?:-l\s+)?-c\s+['"](?:source\s+\S+\s+&&\s+)?(?:\w+=\S+\s+)*(?:[^\s'"]*\/)?aia\s+(?:--prompts-dir\s+\S+\s+)?(?:--config(?:-file)?\s+\S+\s+)?(?<prompt_id>(?!--)\S+)(?:\s+--prompts-dir\s+\S+)?(?:\s+--config(?:-file)?\s+\S+)?\s+>>?\s+(?<log>\S+)\s+2>&1/
|
|
20
|
+
|
|
21
|
+
def initialize(crontab_command: "crontab", log_base: Paths::SCHEDULE_LOG)
|
|
22
|
+
@crontab_command = crontab_command
|
|
23
|
+
@log_base = log_base
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Installs the given cron lines into the user's crontab, replacing any
|
|
27
|
+
# previously installed aias block. Creates the log base directory first.
|
|
28
|
+
# Raises Aias::Error if the crontab write fails.
|
|
29
|
+
def install(cron_lines)
|
|
30
|
+
FileUtils.mkdir_p(@log_base, mode: 0o700)
|
|
31
|
+
updated = replace_block(read_crontab, Array(cron_lines))
|
|
32
|
+
write_crontab(updated)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Removes the aias-managed block from the crontab.
|
|
36
|
+
# Non-aias entries are not touched.
|
|
37
|
+
def clear
|
|
38
|
+
write_crontab(remove_block(read_crontab))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the cron lines as they would be written, without touching the
|
|
42
|
+
# crontab. Useful for `aias dry-run`.
|
|
43
|
+
def dry_run(cron_lines)
|
|
44
|
+
Array(cron_lines).join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the raw text of the aias-managed block currently in the crontab
|
|
48
|
+
# (markers excluded). Returns an empty string when no block exists.
|
|
49
|
+
def current_block
|
|
50
|
+
extract_block(read_crontab, BLOCK_OPEN, BLOCK_CLOSE)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns an Array of Hashes describing each installed aias cron job.
|
|
54
|
+
# Each hash has :prompt_id, :cron_expr, and :log_path keys.
|
|
55
|
+
def installed_jobs
|
|
56
|
+
current_block.each_line.filter_map do |line|
|
|
57
|
+
line = line.strip
|
|
58
|
+
next if line.empty? || line.start_with?("#")
|
|
59
|
+
|
|
60
|
+
match = ENTRY_RE.match(line)
|
|
61
|
+
next unless match
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
prompt_id: match[:prompt_id],
|
|
65
|
+
cron_expr: match[:cron].strip,
|
|
66
|
+
log_path: match[:log]
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Inserts or replaces a single cron job in the aias block (upsert).
|
|
72
|
+
# Any existing entry whose prompt_id matches is removed first, then the
|
|
73
|
+
# new line is appended. All other managed entries are left untouched.
|
|
74
|
+
# Raises Aias::Error if the crontab write fails.
|
|
75
|
+
def add_job(cron_line, prompt_id)
|
|
76
|
+
FileUtils.mkdir_p(@log_base, mode: 0o700)
|
|
77
|
+
current = read_crontab
|
|
78
|
+
existing = extract_block(current, BLOCK_OPEN, BLOCK_CLOSE).each_line.map(&:chomp).reject(&:empty?)
|
|
79
|
+
updated = existing.reject { |l| ENTRY_RE.match(l)&.[](:prompt_id) == prompt_id }
|
|
80
|
+
updated << cron_line
|
|
81
|
+
write_crontab(replace_block(current, updated))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Removes a single cron job from the aias block by prompt_id.
|
|
85
|
+
# All other managed entries are left untouched.
|
|
86
|
+
# Raises Aias::Error if the prompt_id is not currently installed or
|
|
87
|
+
# if the crontab write fails.
|
|
88
|
+
def remove_job(prompt_id)
|
|
89
|
+
current = read_crontab
|
|
90
|
+
existing = extract_block(current, BLOCK_OPEN, BLOCK_CLOSE).each_line.map(&:chomp).reject(&:empty?)
|
|
91
|
+
updated = existing.reject { |l| ENTRY_RE.match(l)&.[](:prompt_id) == prompt_id }
|
|
92
|
+
raise Aias::Error, "'#{prompt_id}' is not currently installed" if updated.size == existing.size
|
|
93
|
+
|
|
94
|
+
write_crontab(replace_block(current, updated))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Creates subdirectory structure under @log_base for each prompt_id.
|
|
98
|
+
# Called by CLI before install to ensure log dirs exist.
|
|
99
|
+
def ensure_log_directories(prompt_ids)
|
|
100
|
+
prompt_ids.each do |id|
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(File.join(@log_base, "#{id}.log")), mode: 0o700)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Reads the current crontab. Returns '' when no crontab exists.
|
|
108
|
+
# Raises Aias::Error for any other non-zero exit to prevent silently
|
|
109
|
+
# overwriting a crontab that could not be read.
|
|
110
|
+
def read_crontab
|
|
111
|
+
out, err, status = Open3.capture3(@crontab_command, "-l")
|
|
112
|
+
return out if status.success?
|
|
113
|
+
return "" if err.include?("no crontab for")
|
|
114
|
+
raise Aias::Error, "crontab -l failed: #{err.strip}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Writes content to the crontab via stdin.
|
|
118
|
+
# Raises Aias::Error if the crontab command exits non-zero.
|
|
119
|
+
def write_crontab(content)
|
|
120
|
+
Open3.popen2(@crontab_command, "-") do |stdin, _stdout, thr|
|
|
121
|
+
stdin.write(content)
|
|
122
|
+
stdin.close
|
|
123
|
+
raise Aias::Error, "failed to write crontab" unless thr.value.success?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Removes the aias jobs block from content, leaving other lines intact.
|
|
128
|
+
def remove_block(content)
|
|
129
|
+
strip_block(content, BLOCK_OPEN, BLOCK_CLOSE)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Removes any existing aias block then appends a fresh one.
|
|
133
|
+
def replace_block(content, cron_lines)
|
|
134
|
+
cleaned = remove_block(content)
|
|
135
|
+
new_block = ([BLOCK_OPEN] + cron_lines + [BLOCK_CLOSE]).join("\n") + "\n"
|
|
136
|
+
cleaned.empty? ? new_block : cleaned.rstrip + "\n\n" + new_block
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Aias
|
|
6
|
+
# Manages a shell env file (~/.config/aia/schedule/env.sh) using a
|
|
7
|
+
# BEGIN/END block so user content above or below the block is preserved.
|
|
8
|
+
#
|
|
9
|
+
# The file is sourced by every cron entry before the aia command, giving
|
|
10
|
+
# scheduled jobs a controlled PATH and all necessary env vars (API keys,
|
|
11
|
+
# AIA_PROMPTS__DIR, etc.) without relying on crontab env vars or a login
|
|
12
|
+
# shell that would reset PATH via path_helper.
|
|
13
|
+
class EnvFile
|
|
14
|
+
include BlockParser
|
|
15
|
+
BLOCK_OPEN = "# BEGIN aias-env"
|
|
16
|
+
BLOCK_CLOSE = "# END aias-env"
|
|
17
|
+
|
|
18
|
+
def initialize(path: Paths::SCHEDULE_ENV)
|
|
19
|
+
@path = path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Writes env_vars into the managed block (merge — new values win on conflict).
|
|
23
|
+
# env_vars is a Hash of { "KEY" => "value" }.
|
|
24
|
+
# Chmod 0600 is applied by write so API keys are never world-readable.
|
|
25
|
+
def install(env_vars)
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
27
|
+
merged = parse_block(current_block).merge(env_vars)
|
|
28
|
+
lines = merged.map { |k, v| "export #{k}=\"#{v}\"" }
|
|
29
|
+
write(replace_block(read, lines))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Removes the managed block from the file.
|
|
33
|
+
# Deletes the file entirely when no other content remains.
|
|
34
|
+
def uninstall
|
|
35
|
+
content = strip_block(read, BLOCK_OPEN, BLOCK_CLOSE)
|
|
36
|
+
if content.strip.empty?
|
|
37
|
+
File.delete(@path) if File.exist?(@path)
|
|
38
|
+
else
|
|
39
|
+
write(content)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns the raw content of the managed block (markers excluded).
|
|
44
|
+
# Returns an empty string when no block exists.
|
|
45
|
+
def current_block
|
|
46
|
+
extract_block(read, BLOCK_OPEN, BLOCK_CLOSE)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def read
|
|
52
|
+
File.exist?(@path) ? File.read(@path) : ""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def write(content)
|
|
56
|
+
File.write(@path, content)
|
|
57
|
+
FileUtils.chmod(0o600, @path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def replace_block(content, export_lines)
|
|
61
|
+
cleaned = strip_block(content, BLOCK_OPEN, BLOCK_CLOSE)
|
|
62
|
+
new_block = ([BLOCK_OPEN] + export_lines + [BLOCK_CLOSE]).join("\n") + "\n"
|
|
63
|
+
cleaned.empty? ? new_block : new_block + "\n" + cleaned.lstrip
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Parses `export KEY="value"` lines into { "KEY" => "value" }.
|
|
67
|
+
def parse_block(block_content)
|
|
68
|
+
block_content.each_line.each_with_object({}) do |line, h|
|
|
69
|
+
if (m = line.chomp.match(/\Aexport\s+(\w+)="(.*)"\z/))
|
|
70
|
+
h[m[1]] = m[2]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aias
|
|
4
|
+
class JobBuilder
|
|
5
|
+
def initialize(shell: ENV.fetch("SHELL", "/bin/bash"), aia_path: nil, env_file: nil, config_file: nil)
|
|
6
|
+
@shell = shell
|
|
7
|
+
@aia_path = aia_path || 'aia'
|
|
8
|
+
@env_file = env_file || Paths::SCHEDULE_ENV
|
|
9
|
+
@config_file = config_file
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns a single cron line string for the given scanner result, e.g.:
|
|
13
|
+
# 0 8 * * * /bin/bash -c 'source ~/.config/aia/schedule/env.sh && /path/to/aia --prompts-dir /path --config ~/.config/aia/schedule/aia.yml daily_digest > ~/.config/aia/schedule/logs/daily_digest.log 2>&1'
|
|
14
|
+
#
|
|
15
|
+
# env.sh is sourced first to set PATH, API keys, etc. All flags come before
|
|
16
|
+
# the prompt ID. config_file selects the schedule-specific AIA config.
|
|
17
|
+
# prompts_dir sets the directory AIA searches for the prompt file.
|
|
18
|
+
def build(scanner_result, prompts_dir: nil)
|
|
19
|
+
cron_expr = resolved_cron(scanner_result.schedule)
|
|
20
|
+
prompt_id = scanner_result.prompt_id
|
|
21
|
+
log = log_path_for(prompt_id)
|
|
22
|
+
prompts_flag = prompts_dir ? %( --prompts-dir "#{File.expand_path(prompts_dir)}") : ""
|
|
23
|
+
config_flag = @config_file ? %( --config "#{@config_file}") : ""
|
|
24
|
+
cmd = %(source "#{@env_file}" && #{@aia_path}#{prompts_flag}#{config_flag} #{prompt_id} > "#{log}" 2>&1)
|
|
25
|
+
"#{cron_expr} #{shell_binary} -c '#{cmd}'"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the log file path for a given prompt_id.
|
|
29
|
+
# Mirrors the subdirectory structure of the prompt_id.
|
|
30
|
+
# e.g. "reports/weekly" → "~/.config/aia/schedule/logs/reports/weekly.log"
|
|
31
|
+
def log_path_for(prompt_id)
|
|
32
|
+
File.join(Paths::SCHEDULE_LOG, "#{prompt_id}.log")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Resolves the schedule string to a canonical 5-field cron expression
|
|
38
|
+
# via fugit. Accepts both raw cron expressions and natural language.
|
|
39
|
+
# Raises Aias::Error if the schedule cannot be resolved — this should
|
|
40
|
+
# not happen in practice since Validator rejects invalid schedules first.
|
|
41
|
+
def resolved_cron(schedule)
|
|
42
|
+
cron = Fugit.parse_cronish(schedule)
|
|
43
|
+
raise Aias::Error, "Cannot resolve schedule '#{schedule}' to a cron expression" unless cron
|
|
44
|
+
|
|
45
|
+
cron.to_cron_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the shell binary path. Falls back to /bin/bash if SHELL is unset.
|
|
49
|
+
def shell_binary
|
|
50
|
+
return "/bin/bash" if @shell.nil? || @shell.strip.empty?
|
|
51
|
+
|
|
52
|
+
@shell.strip
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/aias/paths.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aias
|
|
4
|
+
# Single source of truth for all filesystem paths used by aias.
|
|
5
|
+
# Every class that needs a path should reference Aias::Paths rather than
|
|
6
|
+
# defining its own constant.
|
|
7
|
+
module Paths
|
|
8
|
+
AIA_CONFIG = File.expand_path("~/.config/aia/aia.yml")
|
|
9
|
+
SCHEDULE_DIR = File.expand_path("~/.config/aia/schedule")
|
|
10
|
+
SCHEDULE_CFG = File.expand_path("~/.config/aia/schedule/aia.yml")
|
|
11
|
+
SCHEDULE_LOG = File.expand_path("~/.config/aia/schedule/logs")
|
|
12
|
+
SCHEDULE_ENV = File.expand_path("~/.config/aia/schedule/env.sh")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Aias
|
|
6
|
+
class PromptScanner
|
|
7
|
+
# Immutable value object representing one discovered scheduled prompt.
|
|
8
|
+
Result = Data.define(:prompt_id, :schedule, :metadata, :file_path)
|
|
9
|
+
|
|
10
|
+
PROMPTS_DIR_ENVVAR_NEW = "AIA_PROMPTS__DIR"
|
|
11
|
+
PROMPTS_DIR_ENVVAR_OLD = "AIA_PROMPTS_DIR"
|
|
12
|
+
|
|
13
|
+
def initialize(prompts_dir: nil)
|
|
14
|
+
@prompts_dir = (prompts_dir || ENV[PROMPTS_DIR_ENVVAR_NEW] || ENV[PROMPTS_DIR_ENVVAR_OLD]).to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns Array<Result> of all prompts with a non-empty schedule: key.
|
|
18
|
+
# Raises Aias::Error if the prompts directory is missing or unreadable.
|
|
19
|
+
def scan
|
|
20
|
+
validate_prompts_dir!
|
|
21
|
+
candidate_files.filter_map { |path| build_result(path) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Parses a single prompt file by path (relative or absolute).
|
|
25
|
+
# Derives the prompt_id using the configured prompts_dir.
|
|
26
|
+
# Raises Aias::Error when the file is missing/unreadable, lies outside
|
|
27
|
+
# the prompts directory, or carries no schedule: in its frontmatter.
|
|
28
|
+
def scan_one(path)
|
|
29
|
+
absolute = File.expand_path(path)
|
|
30
|
+
|
|
31
|
+
raise Aias::Error, "Prompt file not found: #{absolute}" unless File.exist?(absolute)
|
|
32
|
+
raise Aias::Error, "Prompt file not readable: #{absolute}" unless File.readable?(absolute)
|
|
33
|
+
|
|
34
|
+
validate_prompts_dir!
|
|
35
|
+
|
|
36
|
+
base = @prompts_dir.chomp("/")
|
|
37
|
+
unless absolute.start_with?("#{base}/")
|
|
38
|
+
raise Aias::Error, "'#{absolute}' is not inside the prompts directory '#{@prompts_dir}'"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
parsed, schedule = begin
|
|
42
|
+
p = PM.parse(absolute)
|
|
43
|
+
[p, p.metadata&.schedule]
|
|
44
|
+
rescue => e
|
|
45
|
+
raise Aias::Error, "Failed to parse '#{prompt_id_for(absolute)}': #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if schedule.nil? || schedule.to_s.strip.empty?
|
|
49
|
+
raise Aias::Error, "'#{prompt_id_for(absolute)}' has no schedule: in its frontmatter"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Result.new(
|
|
53
|
+
prompt_id: prompt_id_for(absolute),
|
|
54
|
+
schedule: schedule.to_s.strip,
|
|
55
|
+
metadata: parsed.metadata,
|
|
56
|
+
file_path: absolute
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def validate_prompts_dir!
|
|
63
|
+
if @prompts_dir.empty?
|
|
64
|
+
raise Aias::Error, "#{PROMPTS_DIR_ENVVAR_NEW} (or #{PROMPTS_DIR_ENVVAR_OLD}) is not set"
|
|
65
|
+
end
|
|
66
|
+
unless File.directory?(@prompts_dir)
|
|
67
|
+
raise Aias::Error, "AIA_PROMPTS_DIR '#{@prompts_dir}' does not exist"
|
|
68
|
+
end
|
|
69
|
+
unless File.readable?(@prompts_dir)
|
|
70
|
+
raise Aias::Error, "AIA_PROMPTS_DIR '#{@prompts_dir}' is not readable"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Runs grep via Open3 to avoid shell injection.
|
|
75
|
+
# --include=*.md limits matches to prompt files; -m 1 stops after the first
|
|
76
|
+
# match per file (presence is all we need). Returns [] when nothing matches.
|
|
77
|
+
def candidate_files
|
|
78
|
+
out, _err, _status = Open3.capture3(
|
|
79
|
+
"grep", "-rl", "--include=*.md", "-m", "1", "schedule:", @prompts_dir
|
|
80
|
+
)
|
|
81
|
+
out.lines.map(&:chomp).reject(&:empty?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Strips the prompts_dir prefix and .md suffix to produce a prompt ID.
|
|
85
|
+
# e.g. "/home/user/.prompts/reports/weekly.md" → "reports/weekly"
|
|
86
|
+
def prompt_id_for(absolute_path)
|
|
87
|
+
base = @prompts_dir.chomp("/")
|
|
88
|
+
relative = absolute_path.delete_prefix("#{base}/")
|
|
89
|
+
relative.delete_suffix(".md")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Parses the file via PM and returns a Result if schedule: is present.
|
|
93
|
+
# Returns nil (filter_map drops it) if schedule is absent or empty.
|
|
94
|
+
# Warns to stderr and returns nil if PM.parse raises.
|
|
95
|
+
def build_result(absolute_path)
|
|
96
|
+
parsed = PM.parse(absolute_path)
|
|
97
|
+
schedule = parsed.metadata.schedule
|
|
98
|
+
return nil if schedule.nil? || schedule.to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
Result.new(
|
|
101
|
+
prompt_id: prompt_id_for(absolute_path),
|
|
102
|
+
schedule: schedule.to_s.strip,
|
|
103
|
+
metadata: parsed.metadata,
|
|
104
|
+
file_path: absolute_path
|
|
105
|
+
)
|
|
106
|
+
rescue => e
|
|
107
|
+
warn "aias: skipping #{absolute_path}: #{e.message}"
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Aias
|
|
6
|
+
# Manages ~/.config/aia/schedule/aia.yml — the AIA config file used by all
|
|
7
|
+
# scheduled cron jobs. Provides targeted updates without clobbering user
|
|
8
|
+
# settings already in the file.
|
|
9
|
+
class ScheduleConfig
|
|
10
|
+
def initialize(path: Paths::SCHEDULE_CFG)
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Sets prompts.dir in the config file to +dir+ if it is not already that value.
|
|
15
|
+
# Returns true when the file was updated, false when already correct or file absent.
|
|
16
|
+
# Raises Aias::Error on YAML parse failure or write error.
|
|
17
|
+
def set_prompts_dir(dir)
|
|
18
|
+
return false unless File.exist?(@path)
|
|
19
|
+
|
|
20
|
+
config = YAML.safe_load_file(@path) || {}
|
|
21
|
+
return false if config.dig("prompts", "dir") == dir
|
|
22
|
+
|
|
23
|
+
config["prompts"] ||= {}
|
|
24
|
+
config["prompts"]["dir"] = dir
|
|
25
|
+
File.write(@path, config.to_yaml)
|
|
26
|
+
true
|
|
27
|
+
rescue Psych::Exception => e
|
|
28
|
+
raise Aias::Error, "could not update prompts.dir in #{@path}: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Aias
|
|
6
|
+
class Validator
|
|
7
|
+
# Immutable value object returned by #validate.
|
|
8
|
+
ValidationResult = Data.define(:valid?, :errors)
|
|
9
|
+
|
|
10
|
+
# Common locations where version managers install binary shims.
|
|
11
|
+
# Checked as a fallback when the login shell cannot find the binary.
|
|
12
|
+
BINARY_FALLBACK_DIRS = [
|
|
13
|
+
File.expand_path("~/.rbenv/shims"),
|
|
14
|
+
File.expand_path("~/.rbenv/bin"),
|
|
15
|
+
File.expand_path("~/.rvm/bin"),
|
|
16
|
+
File.expand_path("~/.asdf/shims"),
|
|
17
|
+
"/usr/local/bin",
|
|
18
|
+
"/usr/bin",
|
|
19
|
+
"/opt/homebrew/bin"
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
shell: ENV.fetch("SHELL", "/bin/bash"),
|
|
24
|
+
binary_to_check: "aia",
|
|
25
|
+
fallback_dirs: BINARY_FALLBACK_DIRS
|
|
26
|
+
)
|
|
27
|
+
@shell = shell
|
|
28
|
+
@binary_to_check = binary_to_check
|
|
29
|
+
@fallback_dirs = fallback_dirs
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns a ValidationResult for the given PromptScanner::Result.
|
|
33
|
+
def validate(scanner_result)
|
|
34
|
+
errors = []
|
|
35
|
+
errors.concat(check_schedule_syntax(scanner_result.schedule))
|
|
36
|
+
errors.concat(check_parameters(scanner_result.metadata))
|
|
37
|
+
errors.concat(aia_binary_errors)
|
|
38
|
+
ValidationResult.new(valid?: errors.empty?, errors: errors)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Validates the schedule string using fugit, which accepts both raw cron
|
|
44
|
+
# expressions and natural language ("every weekday at 8am").
|
|
45
|
+
def check_schedule_syntax(schedule)
|
|
46
|
+
Fugit.parse_cronish(schedule) ? [] : ["Schedule '#{schedule}': not a valid cron expression or natural language schedule"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Validates that all parameter keys have non-nil, non-empty default values.
|
|
50
|
+
# Scheduled prompts run unattended — interactive input is impossible.
|
|
51
|
+
def check_parameters(metadata)
|
|
52
|
+
params = metadata.parameters
|
|
53
|
+
return [] if params.nil?
|
|
54
|
+
|
|
55
|
+
errors = []
|
|
56
|
+
params.each do |key, value|
|
|
57
|
+
if value.nil? || value.to_s.strip.empty?
|
|
58
|
+
errors << "Parameter '#{key}' has no default value (required for unattended cron execution)"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
errors
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Checks that `aia` is locatable in the login-shell PATH or in a known
|
|
65
|
+
# version-manager shim directory. Results are cached per Validator instance
|
|
66
|
+
# (shell spawn is expensive).
|
|
67
|
+
#
|
|
68
|
+
# Two-tier check:
|
|
69
|
+
# 1. Spawn a login shell and run `which <binary>` — covers the normal case
|
|
70
|
+
# where the shell profile properly initialises the version manager.
|
|
71
|
+
# 2. If that fails, scan fallback_dirs for an executable file with the
|
|
72
|
+
# binary name — covers setups where rbenv/rvm/asdf shims are installed
|
|
73
|
+
# but the login shell profile does not initialise the version manager.
|
|
74
|
+
def aia_binary_errors
|
|
75
|
+
return @aia_binary_errors if defined?(@aia_binary_errors)
|
|
76
|
+
|
|
77
|
+
shell_found = begin
|
|
78
|
+
_out, _err, status = Open3.capture3(@shell, "-l", "-c", "which #{@binary_to_check}")
|
|
79
|
+
status.success?
|
|
80
|
+
rescue Errno::ENOENT
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@aia_binary_errors =
|
|
85
|
+
if shell_found || binary_in_fallback_location?
|
|
86
|
+
[]
|
|
87
|
+
else
|
|
88
|
+
["#{@binary_to_check} binary not found in #{@shell} login shell PATH or known version manager directories"]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns true when an executable named @binary_to_check exists in any of
|
|
93
|
+
# the @fallback_dirs entries.
|
|
94
|
+
def binary_in_fallback_location?
|
|
95
|
+
@fallback_dirs.any? do |dir|
|
|
96
|
+
path = File.join(dir, @binary_to_check)
|
|
97
|
+
File.executable?(path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|