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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +140 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +249 -0
  9. data/Rakefile +27 -0
  10. data/aia_schedule_idea.md +256 -0
  11. data/docs/assets/images/logo.jpg +0 -0
  12. data/docs/cli/add.md +101 -0
  13. data/docs/cli/check.md +70 -0
  14. data/docs/cli/clear.md +45 -0
  15. data/docs/cli/dry-run.md +57 -0
  16. data/docs/cli/index.md +51 -0
  17. data/docs/cli/install.md +198 -0
  18. data/docs/cli/last.md +49 -0
  19. data/docs/cli/list.md +40 -0
  20. data/docs/cli/next.md +49 -0
  21. data/docs/cli/remove.md +87 -0
  22. data/docs/cli/show.md +54 -0
  23. data/docs/cli/uninstall.md +29 -0
  24. data/docs/cli/update.md +75 -0
  25. data/docs/getting-started/installation.md +69 -0
  26. data/docs/getting-started/quick-start.md +105 -0
  27. data/docs/guides/configuration-layering.md +168 -0
  28. data/docs/guides/cron-environment.md +112 -0
  29. data/docs/guides/scheduling-prompts.md +319 -0
  30. data/docs/guides/understanding-cron.md +134 -0
  31. data/docs/guides/validation.md +114 -0
  32. data/docs/index.md +100 -0
  33. data/docs/reference/api.md +409 -0
  34. data/docs/reference/architecture.md +122 -0
  35. data/docs/reference/environment.md +67 -0
  36. data/docs/reference/logging.md +73 -0
  37. data/example_prompts/code_health_check.md +51 -0
  38. data/example_prompts/daily_digest.md +19 -0
  39. data/example_prompts/morning_standup.md +19 -0
  40. data/example_prompts/reports/monthly_review.md +44 -0
  41. data/example_prompts/reports/weekly_summary.md +22 -0
  42. data/example_prompts/you_are_good.md +22 -0
  43. data/exe/aias +5 -0
  44. data/lib/aias/block_parser.rb +42 -0
  45. data/lib/aias/cli/add.rb +30 -0
  46. data/lib/aias/cli/check.rb +50 -0
  47. data/lib/aias/cli/clear.rb +11 -0
  48. data/lib/aias/cli/dry_run.rb +24 -0
  49. data/lib/aias/cli/install.rb +57 -0
  50. data/lib/aias/cli/last.rb +26 -0
  51. data/lib/aias/cli/list.rb +20 -0
  52. data/lib/aias/cli/next.rb +28 -0
  53. data/lib/aias/cli/remove.rb +14 -0
  54. data/lib/aias/cli/show.rb +18 -0
  55. data/lib/aias/cli/uninstall.rb +15 -0
  56. data/lib/aias/cli/update.rb +30 -0
  57. data/lib/aias/cli/version.rb +10 -0
  58. data/lib/aias/cli.rb +91 -0
  59. data/lib/aias/cron_describer.rb +142 -0
  60. data/lib/aias/crontab_manager.rb +140 -0
  61. data/lib/aias/env_file.rb +75 -0
  62. data/lib/aias/job_builder.rb +56 -0
  63. data/lib/aias/paths.rb +14 -0
  64. data/lib/aias/prompt_scanner.rb +111 -0
  65. data/lib/aias/schedule_config.rb +31 -0
  66. data/lib/aias/validator.rb +101 -0
  67. data/lib/aias/version.rb +5 -0
  68. data/lib/aias.rb +17 -0
  69. data/mkdocs.yml +137 -0
  70. data/sig/aias.rbs +4 -0
  71. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aias
4
+ VERSION = "0.1.0"
5
+ end