all_in_ruby 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e016ce2d0604883066cae2f675273dc7a239291bcd591324e15eafcc3a2ced8
4
- data.tar.gz: ed253dfd0c43ee7ee7e2c40546639500b50b2aa3bf97c548d5788386adddcc0a
3
+ metadata.gz: 6930c270ee263f4f0479be9854cf03b7af4db01e88e6ffa3ee585a2dad0f152b
4
+ data.tar.gz: f0491de4a159d7e327d6e6f270146ec5546213b82d6ed68dc5cbd1e3f1cd610a
5
5
  SHA512:
6
- metadata.gz: 46e170a80461bbcdde8c26c73e81e479954199fe2332937f1a5aff96a76de3f33e9db9104440576ec23945a3cef658b018ee5b642a9bb55cd478fad7d3056144
7
- data.tar.gz: 73e95dfb8c32a8ac29e5fa839d0deac2728d57f4d04ed15087f27df3878a371561cd9f55368a37c99f24dd6371a7e38950c7c9b0bed4cda9ebedafe3bbffa9b3
6
+ metadata.gz: d771a3ce9f1669f73d1bb441dfaaae80b180570ddcce09d6ea38f1c709fee3d9a8df778b6a8e72aa7b213591777066e51f06f5f016c892a1f3a4ab8b46993311
7
+ data.tar.gz: 66e7bffbd34e3f45f1bd8366e8cbd12f068f93de6f89e3ea6cf5424772e8cdd0224b360174d8b617cd4e09a48334fe1b123b6f84f051432b32d5a1179463de73
data/AGENTS.md ADDED
@@ -0,0 +1,52 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `all_in_ruby` is a zero-dependency CLI gem that adds (or removes) a single "write throwaway scripts in Ruby" instruction block to AI-agent instruction files — `CLAUDE.md` for Claude Code, `AGENTS.md` for Codex/OpenCode/Cursor — either in the current repo (`local`) or in home-directory config (`global`). The core contract is: **additive and idempotent**. Existing file content is preserved verbatim; the block lives inside marker comments so it can be found and removed cleanly.
8
+
9
+ ## Commands
10
+
11
+ The project uses `.ruby-version` (3.4.7) with **chruby**. Prefix Ruby commands with the activation, e.g.:
12
+ `source /opt/homebrew/share/chruby/chruby.sh && chruby ruby-3.4.7 && <cmd>`
13
+
14
+ - Install deps: `bundle install`
15
+ - Run all tests: `bundle exec rake test` (Minitest; `rake` default task is `test`)
16
+ - Run one test file: `bundle exec rake test TEST=test/installer_test.rb`
17
+ - Run one test by name: `bundle exec ruby -Itest test/installer_test.rb -n test_install_is_idempotent`
18
+ - Run the CLI from source (bypasses installed gem): `bundle exec exe/all-in-ruby install --dry-run`
19
+ - Build gem: `bundle exec rake build` — Release: `bundle exec rake release` (both from `bundler/gem_tasks`)
20
+
21
+ There is no linter configured. `--dry-run` (`-n`) is the safest way to exercise real behavior without touching disk.
22
+
23
+ ## Architecture
24
+
25
+ Four collaborating layers under `lib/all_in_ruby/`, loaded by `lib/all_in_ruby.rb`:
26
+
27
+ - **`CLI`** (`cli.rb`) — parses argv with `OptionParser`, resolves defaults, calls the installer, formats output. It owns two label maps, `STATUS_LABELS` and `DRY_RUN_LABELS`, that translate the installer's status *symbols* into user-facing strings. The CLI never touches the filesystem itself.
28
+ - **`Installer`** (`installer.rb`) — the orchestrator. `path_for(agent, scope)` is the single source of truth mapping an `(agent, scope)` pair to a file path (or `nil`). `install`/`uninstall`/`status` each iterate targets via `each_target` and return an array of `Result` structs `(agent, path, status, diff, backup)`.
29
+ - **`Instruction`** (`instruction.rb`) — owns the block text and the marker comments (`<!-- BEGIN all_in_ruby -->` / `<!-- END all_in_ruby -->`). `installed?` detects the block; `remove` strips it with a regex. Change the wording here in exactly one place.
30
+ - **`Diff`** (`diff.rb`) — pure-function unified-diff generator for dry-run previews. It assumes a single contiguous edit per file (that assumption is what makes the common-prefix/suffix approach valid), and handles the "no newline at end of file" case.
31
+
32
+ Data flows one direction: `CLI → Installer → (Instruction, Diff)`. `Result` is the boundary object between the installer and the CLI.
33
+
34
+ ### Non-obvious behaviors to preserve
35
+
36
+ These are the invariants that most of the code exists to protect — read `installer.rb` and the shared-file tests before changing any of them:
37
+
38
+ - **Multiple agents share one file.** Locally, `codex`, `opencode`, and `cursor` all map to `AGENTS.md`. So installing several agents in one run produces e.g. `[:created, :already_installed, :already_installed]` — not three creates. Tests assert this exact sequence.
39
+ - **Dry run simulates writes in memory.** The `planned` hash (`installer.rb:82-97`) holds what each file *would* contain, so later same-file agents in a dry run report the same statuses (and the diff is emitted only once) a real run would. Don't "simplify" this away — it's load-bearing.
40
+ - **`Cursor` + `global` is `:manual`** — `path_for` returns `nil` because Cursor has no global rules file. The installer yields `:manual` and writes nothing; the CLI prints the instruction body to paste into Cursor Settings > Rules. This only happens for `install`, not `status`.
41
+ - **Backups before overwriting.** `install`/`uninstall` copy an existing file to a timestamped `<path>.YYYYMMDD-HHMMSS.bak` before writing — but never on `:created`, since a brand-new file has nothing to preserve. The clock is injected (`Installer.new(clock:)`) so tests can assert the exact name. `apply` returns `[status, diff, backup]` and the CLI lists the backups at the end of the run. `--no-backup` skips it; a dry run names the backup it *would* make without creating it.
42
+ - **`CODEX_HOME` overrides `~/.codex`** for the codex global path (empty string is ignored), matching Codex's own resolution.
43
+ - **Scope defaults differ by command** (`cli.rb:scopes_for`): `status` with no scope flag reports *both* local and global (it's read-only); `install`/`uninstall` stay `local` unless `--global` is given. Scope and agent flags both *combine* rather than last-wins.
44
+ - **Separator logic** (`separator_for`): when appending to an existing file, the installer inserts the right number of newlines so the block is separated by exactly one blank line, whether or not the file ended with a trailing newline.
45
+
46
+ ## Testing conventions
47
+
48
+ Everything is dependency-injected for isolation — no mocking, no global state:
49
+ - `Installer.new(root:, home:, env:, clock:)` runs against `Dir.mktmpdir` temp roots; tests assert on real files. Inject `clock:` (a `-> { Time }` lambda) to make timestamped backup names deterministic.
50
+ - `CLI.new(stdout:, stderr:, installer:)` captures output in `StringIO` and receives an installer wired to temp dirs.
51
+
52
+ When adding a feature, cover both the installer level (file effects, status symbols) and the CLI level (exit code, printed labels, diffs). The shared-`AGENTS.md` and dry-run-simulation cases are the ones most likely to break — mirror the existing tests for them.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-08-03)
4
+
5
+ - `install` and `uninstall` now back up any existing file before modifying it, writing a timestamped copy (`CLAUDE.md.20260802-153000.bak`) next to the original and listing the backups created at the end of the run. New files are not backed up. Pass `--no-backup` to skip, or `--dry-run` to preview which backups would be made
6
+ - Add `--dry-run` (`-n`) to `install` and `uninstall`: prints a unified diff per file showing the exact lines that would be added or removed, without writing anything. Dry runs simulate writes in memory, so agents sharing `AGENTS.md` report the same statuses a real run would
7
+ - `status` with no scope flag now reports both local and global scopes (previously it silently checked only local)
8
+ - Add `--local` flag; scope flags combine, so `install --local --global` targets both scopes in one run
9
+
10
+ ## 0.1.1 (2026-07-22)
11
+
12
+ - Support OpenCode: local `AGENTS.md` (shared with Codex) and global `~/.config/opencode/AGENTS.md`
13
+ - Support Cursor: local `AGENTS.md` (shared with Codex); `--global` prints the instruction to paste into Cursor Settings > Rules, since Cursor has no global rules file
14
+ - Agent flags now combine (`--claude --codex` targets both) instead of last-flag-wins
15
+
3
16
  ## 0.1.0 (2026-07-20)
4
17
 
5
18
  - First release
data/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ AGENTS.md
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Tell your AI coding agents to write scripts in Ruby.
4
4
 
5
- Adds a "write throwaway and utility scripts in Ruby" instruction to agent instruction files - `CLAUDE.md` for Claude Code, `AGENTS.md` for Codex. Additive and idempotent: your existing instructions are never modified.
5
+ Adds a "write throwaway and utility scripts in Ruby" instruction to agent instruction files - `CLAUDE.md` for Claude Code, `AGENTS.md` for Codex, OpenCode, and Cursor. Additive and idempotent: your existing instructions are never modified.
6
6
 
7
7
  ## Installation
8
8
 
@@ -24,29 +24,119 @@ Or add it globally, for every project:
24
24
  all-in-ruby install --global
25
25
  ```
26
26
 
27
- This writes to `~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md`. If `CODEX_HOME` is set, it is used instead of `~/.codex`, matching Codex's own resolution.
27
+ This writes to `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, and `~/.config/opencode/AGENTS.md`. If `CODEX_HOME` is set, it is used instead of `~/.codex`, matching Codex's own resolution.
28
+
29
+ Cursor has no global rules file — its global "User Rules" live only in the app settings. For `--global`, the instruction text is printed so you can paste it into Cursor Settings > Rules.
28
30
 
29
31
  ## Usage
30
32
 
31
- Target a single agent:
33
+ Target one or more agents:
32
34
 
33
35
  ```sh
34
36
  all-in-ruby install --claude
35
- all-in-ruby install --codex
37
+ all-in-ruby install --codex --opencode
38
+ all-in-ruby install --cursor
36
39
  ```
37
40
 
38
- Check where the instruction is installed:
41
+ Codex, OpenCode, and Cursor all read `AGENTS.md` from the project root, so locally they share one file: installing for any of them covers all three, and uninstalling removes the block for all three.
42
+
43
+ Check where the instruction is installed. With no scope flag, `status` reports both the current repo and the global files:
39
44
 
40
45
  ```sh
41
46
  all-in-ruby status
42
47
  ```
43
48
 
49
+ ```text
50
+ local:
51
+ claude /path/to/repo/CLAUDE.md: installed
52
+ codex /path/to/repo/AGENTS.md: not installed
53
+ ...
54
+ global:
55
+ claude ~/.claude/CLAUDE.md: not installed
56
+ codex ~/.codex/AGENTS.md: installed
57
+ ...
58
+ ```
59
+
60
+ Pass `--local` or `--global` to check a single scope.
61
+
44
62
  Remove it:
45
63
 
46
64
  ```sh
47
65
  all-in-ruby uninstall
48
66
  ```
49
67
 
68
+ ### Backups
69
+
70
+ Before `install` or `uninstall` modifies a file that already exists, it writes a timestamped copy next to the original and lists the backups at the end of the run:
71
+
72
+ ```text
73
+ claude /path/to/repo/CLAUDE.md: instructions added
74
+
75
+ Backups created:
76
+ /path/to/repo/CLAUDE.md.20260802-153000.bak
77
+ ```
78
+
79
+ Only existing files are backed up — creating a brand-new `CLAUDE.md` or `AGENTS.md` makes no backup, since there is nothing to preserve. Each run uses a fresh timestamp, so an earlier backup is never overwritten. Pass `--no-backup` to skip them, or `--dry-run` to see which backups would be made without writing anything.
80
+
81
+ ### Dry run
82
+
83
+ Preview what `install` or `uninstall` would change without writing anything:
84
+
85
+ ```sh
86
+ all-in-ruby install --dry-run
87
+ all-in-ruby uninstall -n
88
+ ```
89
+
90
+ For each file, dry run prints the status a real run would produce, followed by a unified diff showing the exact location of the change:
91
+
92
+ ```text
93
+ claude /path/to/repo/CLAUDE.md: instructions would be added
94
+ codex /path/to/repo/AGENTS.md: would be created
95
+ opencode /path/to/repo/AGENTS.md: would already be covered, no change
96
+ cursor /path/to/repo/AGENTS.md: would already be covered, no change
97
+
98
+ --- /path/to/repo/CLAUDE.md
99
+ +++ /path/to/repo/CLAUDE.md
100
+ @@ -1,3 +1,19 @@
101
+ # My project
102
+
103
+ Always use tabs.
104
+ +
105
+ +<!-- BEGIN all_in_ruby -->
106
+ +## Scripts
107
+ +
108
+ +Write throwaway and utility scripts (data munging, one-off migrations,
109
+ ...
110
+ +<!-- END all_in_ruby -->
111
+
112
+ --- /dev/null
113
+ +++ /path/to/repo/AGENTS.md
114
+ @@ -0,0 +1,15 @@
115
+ +<!-- BEGIN all_in_ruby -->
116
+ +## Scripts
117
+ ...
118
+ ```
119
+
120
+ A dry run simulates its own writes, so the statuses match what a real run would report - above, creating `AGENTS.md` for Codex also covers OpenCode and Cursor, so only one diff is shown for it. New files appear as `--- /dev/null`.
121
+
122
+ The diffs are standard unified format: pipe the output to `delta` or `bat -l diff` for coloring, or apply it with `patch -p0`.
123
+
124
+ ### Options
125
+
126
+ ```text
127
+ --local Target the current repo (default for install/uninstall)
128
+ --global Target global files instead of the current repo
129
+ --claude Claude Code (CLAUDE.md)
130
+ --codex Codex (AGENTS.md)
131
+ --opencode OpenCode (AGENTS.md)
132
+ --cursor Cursor (AGENTS.md; no global file)
133
+ -n, --dry-run Preview changes as diffs without writing any file
134
+ --no-backup Skip backing up existing files before modifying them
135
+ -v, --version Print version
136
+ ```
137
+
138
+ With no agent flags, all agents are targeted. Scope flags combine too: `install --local --global` installs in both places. Without a scope flag, `install` and `uninstall` stay local, while `status` reports both scopes.
139
+
50
140
  ## How It Works
51
141
 
52
142
  The instruction is appended inside marker comments:
data/exe/all-in-ruby CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "all_in_ruby"
4
5
 
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "optparse"
2
4
 
3
5
  module AllInRuby
4
6
  class CLI
5
- COMMANDS = ["install", "uninstall", "status"].freeze
7
+ COMMANDS = %w[install uninstall status].freeze
6
8
 
7
9
  STATUS_LABELS = {
8
10
  created: "created",
@@ -10,9 +12,18 @@ module AllInRuby
10
12
  already_installed: "already installed, left untouched",
11
13
  removed: "instructions removed",
12
14
  not_installed: "not installed",
13
- installed: "installed"
15
+ installed: "installed",
16
+ manual: "no global rules file; use Cursor Settings > Rules"
14
17
  }.freeze
15
18
 
19
+ DRY_RUN_LABELS = STATUS_LABELS.merge(
20
+ created: "would be created",
21
+ updated: "instructions would be added",
22
+ already_installed: "would already be covered, no change",
23
+ removed: "instructions would be removed",
24
+ not_installed: "not installed, nothing to remove"
25
+ ).freeze
26
+
16
27
  def initialize(stdout: $stdout, stderr: $stderr, installer: Installer.new)
17
28
  @stdout = stdout
18
29
  @stderr = stderr
@@ -20,7 +31,7 @@ module AllInRuby
20
31
  end
21
32
 
22
33
  def run(argv)
23
- options = {agents: Installer::AGENTS.dup, scope: "local"}
34
+ options = { agents: [], scopes: [], dry_run: false, backup: true }
24
35
  parser = build_parser(options)
25
36
  parser.parse!(argv)
26
37
 
@@ -30,9 +41,38 @@ module AllInRuby
30
41
  return 1
31
42
  end
32
43
 
33
- results = @installer.public_send(command, agents: options[:agents], scope: options[:scope])
44
+ agents = options[:agents].empty? ? Installer::AGENTS.dup : options[:agents]
45
+ scopes = scopes_for(command, options[:scopes])
46
+ dry_run = options[:dry_run] && command != "status"
47
+
48
+ scope_results = scopes.map do |scope|
49
+ results =
50
+ if command == "status"
51
+ @installer.status(agents: agents, scope: scope)
52
+ else
53
+ @installer.public_send(command, agents: agents, scope: scope, dry_run: dry_run, backup: options[:backup])
54
+ end
55
+ [scope, results]
56
+ end
57
+
58
+ print_statuses(scope_results, dry_run ? DRY_RUN_LABELS : STATUS_LABELS)
59
+
60
+ results = scope_results.flat_map { |_scope, scope_result| scope_result }
34
61
  results.each do |result|
35
- @stdout.puts "#{result.agent.ljust(6)} #{result.path}: #{STATUS_LABELS.fetch(result.status)}"
62
+ next unless result.diff
63
+
64
+ @stdout.puts
65
+ @stdout.puts result.diff
66
+ end
67
+
68
+ print_backups(results, dry_run)
69
+
70
+ if command == "install" && results.any? { |result| result.status == :manual }
71
+ @stdout.puts
72
+ @stdout.puts "Cursor has no global rules file. To apply the instruction globally,"
73
+ @stdout.puts "paste this into Cursor Settings > Rules:"
74
+ @stdout.puts
75
+ @stdout.puts Instruction.body
36
76
  end
37
77
  0
38
78
  rescue OptionParser::ParseError => e
@@ -42,31 +82,76 @@ module AllInRuby
42
82
 
43
83
  private
44
84
 
85
+ # status is read-only, so with no scope flag it reports both scopes;
86
+ # install/uninstall write files, so they stay local unless asked.
87
+ def scopes_for(command, requested)
88
+ return requested.uniq unless requested.empty?
89
+
90
+ command == "status" ? Installer::SCOPES.dup : ["local"]
91
+ end
92
+
93
+ def print_backups(results, dry_run)
94
+ backups = results.filter_map(&:backup)
95
+ return if backups.empty?
96
+
97
+ @stdout.puts
98
+ @stdout.puts(dry_run ? "Backups that would be created:" : "Backups created:")
99
+ backups.each { |backup| @stdout.puts " #{backup}" }
100
+ end
101
+
102
+ def print_statuses(scope_results, labels)
103
+ show_headers = scope_results.length > 1
104
+ indent = show_headers ? " " : ""
105
+ scope_results.each do |scope, results|
106
+ @stdout.puts "#{scope}:" if show_headers
107
+ results.each do |result|
108
+ location = result.path || "(none)"
109
+ @stdout.puts "#{indent}#{result.agent.ljust(8)} #{location}: #{labels.fetch(result.status)}"
110
+ end
111
+ end
112
+ end
113
+
45
114
  def build_parser(options)
46
115
  OptionParser.new do |opts|
47
116
  opts.banner = <<~BANNER
48
117
  Usage: all-in-ruby COMMAND [options]
49
118
 
50
119
  Adds a "write scripts in Ruby" instruction to AI agent instruction
51
- files (CLAUDE.md for Claude Code, AGENTS.md for Codex). Additive and
52
- idempotent: existing content is never modified.
120
+ files: CLAUDE.md for Claude Code, AGENTS.md for Codex, OpenCode, and
121
+ Cursor. Additive and idempotent: existing content is never modified.
53
122
 
54
123
  Commands:
55
124
  install add the instruction block
56
125
  uninstall remove the instruction block
57
- status show where the block is installed
126
+ status show where the block is installed (both scopes unless one is given)
58
127
 
59
128
  Options:
60
129
  BANNER
61
130
 
62
- opts.on("--global", "Target global files (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md)") do
63
- options[:scope] = "global"
131
+ opts.on("--local", "Target the current repo (default for install/uninstall)") do
132
+ options[:scopes] << "local"
133
+ end
134
+ opts.on("--global",
135
+ "Target global files (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.config/opencode/AGENTS.md)") do
136
+ options[:scopes] << "global"
137
+ end
138
+ opts.on("--claude", "Claude Code (CLAUDE.md)") do
139
+ options[:agents] << "claude"
140
+ end
141
+ opts.on("--codex", "Codex (AGENTS.md)") do
142
+ options[:agents] << "codex"
143
+ end
144
+ opts.on("--opencode", "OpenCode (AGENTS.md)") do
145
+ options[:agents] << "opencode"
146
+ end
147
+ opts.on("--cursor", "Cursor (AGENTS.md; no global file)") do
148
+ options[:agents] << "cursor"
64
149
  end
65
- opts.on("--claude", "Only Claude Code (CLAUDE.md)") do
66
- options[:agents] = ["claude"]
150
+ opts.on("-n", "--dry-run", "Preview changes as diffs without writing any file") do
151
+ options[:dry_run] = true
67
152
  end
68
- opts.on("--codex", "Only Codex (AGENTS.md)") do
69
- options[:agents] = ["codex"]
153
+ opts.on("--no-backup", "Skip backing up existing files before modifying them") do
154
+ options[:backup] = false
70
155
  end
71
156
  opts.on("-v", "--version", "Print version") do
72
157
  @stdout.puts VERSION
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AllInRuby
4
+ # Builds unified diffs for dry-run previews. The installer only ever makes
5
+ # one contiguous edit per file (appending or removing a single block), so a
6
+ # common prefix/suffix comparison finds the exact changed region.
7
+ module Diff
8
+ CONTEXT_LINES = 3
9
+ NO_NEWLINE_MARKER = "\"
10
+
11
+ def self.unified(old_content, new_content, path)
12
+ old_lines = (old_content || "").lines
13
+ new_lines = new_content.lines
14
+
15
+ prefix = common_prefix_length(old_lines, new_lines)
16
+ suffix = common_suffix_length(old_lines, new_lines, prefix)
17
+
18
+ removed = old_lines[prefix...(old_lines.length - suffix)]
19
+ added = new_lines[prefix...(new_lines.length - suffix)]
20
+ lead = [prefix, CONTEXT_LINES].min
21
+ trail = [suffix, CONTEXT_LINES].min
22
+
23
+ output = []
24
+ output << "--- #{old_content ? path : File::NULL}"
25
+ output << "+++ #{path}"
26
+ output << hunk_header(prefix, lead, trail, removed.length, added.length)
27
+ old_lines[prefix - lead, lead].each { |line| append(output, " ", line) }
28
+ removed.each { |line| append(output, "-", line) }
29
+ added.each { |line| append(output, "+", line) }
30
+ old_lines[old_lines.length - suffix, trail].each { |line| append(output, " ", line) }
31
+ "#{output.join("\n")}\n"
32
+ end
33
+
34
+ def self.common_prefix_length(old_lines, new_lines)
35
+ limit = [old_lines.length, new_lines.length].min
36
+ count = 0
37
+ count += 1 while count < limit && old_lines[count] == new_lines[count]
38
+ count
39
+ end
40
+
41
+ def self.common_suffix_length(old_lines, new_lines, prefix)
42
+ limit = [old_lines.length, new_lines.length].min - prefix
43
+ count = 0
44
+ count += 1 while count < limit && old_lines[-1 - count] == new_lines[-1 - count]
45
+ count
46
+ end
47
+
48
+ def self.hunk_header(prefix, lead, trail, removed_count, added_count)
49
+ old_range = range(prefix - lead, lead + removed_count + trail)
50
+ new_range = range(prefix - lead, lead + added_count + trail)
51
+ "@@ -#{old_range} +#{new_range} @@"
52
+ end
53
+
54
+ def self.range(start_index, count)
55
+ count.zero? ? "#{start_index},0" : "#{start_index + 1},#{count}"
56
+ end
57
+
58
+ def self.append(output, sign, line)
59
+ output << "#{sign}#{line.chomp}"
60
+ output << NO_NEWLINE_MARKER unless line.end_with?("\n")
61
+ end
62
+
63
+ private_class_method :common_prefix_length, :common_suffix_length,
64
+ :hunk_header, :range, :append
65
+ end
66
+ end
@@ -1,54 +1,60 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "fileutils"
2
4
 
3
5
  module AllInRuby
4
6
  class Installer
5
- AGENTS = ["claude", "codex"].freeze
6
- SCOPES = ["local", "global"].freeze
7
+ AGENTS = %w[claude codex opencode cursor].freeze
8
+ SCOPES = %w[local global].freeze
7
9
 
8
- Result = Struct.new(:agent, :path, :status)
10
+ Result = Struct.new(:agent, :path, :status, :diff, :backup)
9
11
 
10
- def initialize(root: Dir.pwd, home: Dir.home, env: ENV)
12
+ def initialize(root: Dir.pwd, home: Dir.home, env: ENV, clock: -> { Time.now })
11
13
  @root = root
12
14
  @home = home
13
15
  @env = env
16
+ @clock = clock
14
17
  end
15
18
 
16
19
  def path_for(agent, scope)
17
20
  case [agent, scope]
18
- when ["claude", "local"] then File.join(@root, "CLAUDE.md")
19
- when ["claude", "global"] then File.join(@home, ".claude", "CLAUDE.md")
20
- when ["codex", "local"] then File.join(@root, "AGENTS.md")
21
- when ["codex", "global"] then File.join(codex_home, "AGENTS.md")
21
+ when %w[claude local] then File.join(@root, "CLAUDE.md")
22
+ when %w[claude global] then File.join(@home, ".claude", "CLAUDE.md")
23
+ when %w[codex local] then File.join(@root, "AGENTS.md")
24
+ when %w[codex global] then File.join(codex_home, "AGENTS.md")
25
+ when %w[opencode local] then File.join(@root, "AGENTS.md")
26
+ when %w[opencode global] then File.join(@home, ".config", "opencode", "AGENTS.md")
27
+ when %w[cursor local] then File.join(@root, "AGENTS.md")
28
+ # Cursor has no documented global rules file; User Rules live in the app settings
29
+ when %w[cursor global] then nil
22
30
  else
23
31
  raise ArgumentError, "unknown agent or scope: #{agent}, #{scope}"
24
32
  end
25
33
  end
26
34
 
27
- def install(agents:, scope:)
35
+ def install(agents:, scope:, dry_run: false, backup: true)
36
+ planned = {}
28
37
  each_target(agents, scope) do |path|
29
- content = File.exist?(path) ? File.read(path) : nil
38
+ content = current_content(path, planned)
30
39
 
31
40
  if content && Instruction.installed?(content)
32
41
  :already_installed
33
42
  elsif content
34
- separator = content.empty? || content.end_with?("\n\n") ? "" : (content.end_with?("\n") ? "\n" : "\n\n")
35
- File.write(path, content + separator + Instruction.block)
36
- :updated
43
+ new_content = content + separator_for(content) + Instruction.block
44
+ apply(path, content, new_content, :updated, dry_run, backup, planned)
37
45
  else
38
- FileUtils.mkdir_p(File.dirname(path))
39
- File.write(path, Instruction.block)
40
- :created
46
+ apply(path, nil, Instruction.block, :created, dry_run, backup, planned)
41
47
  end
42
48
  end
43
49
  end
44
50
 
45
- def uninstall(agents:, scope:)
51
+ def uninstall(agents:, scope:, dry_run: false, backup: true)
52
+ planned = {}
46
53
  each_target(agents, scope) do |path|
47
- content = File.exist?(path) ? File.read(path) : nil
54
+ content = current_content(path, planned)
48
55
 
49
56
  if content && Instruction.installed?(content)
50
- File.write(path, Instruction.remove(content))
51
- :removed
57
+ apply(path, content, Instruction.remove(content), :removed, dry_run, backup, planned)
52
58
  else
53
59
  :not_installed
54
60
  end
@@ -70,10 +76,44 @@ module AllInRuby
70
76
  codex_home && !codex_home.empty? ? codex_home : File.join(@home, ".codex")
71
77
  end
72
78
 
79
+ def separator_for(content)
80
+ return "" if content.empty? || content.end_with?("\n\n")
81
+
82
+ content.end_with?("\n") ? "\n" : "\n\n"
83
+ end
84
+
85
+ # Planned holds the content a dry run would have written, so later agents
86
+ # sharing the same file (codex/opencode/cursor all use AGENTS.md locally)
87
+ # report the same statuses a real run would produce.
88
+ def current_content(path, planned)
89
+ planned.fetch(path) { File.exist?(path) ? File.read(path) : nil }
90
+ end
91
+
92
+ # Returns [status, diff, backup]. A backup is made only when an existing
93
+ # file is modified (never on create); a dry run computes the name it would
94
+ # use so the preview can report it, but writes nothing.
95
+ def apply(path, old_content, new_content, status, dry_run, backup, planned)
96
+ backup_path = backup && old_content ? backup_path_for(path) : nil
97
+ if dry_run
98
+ planned[path] = new_content
99
+ [status, Diff.unified(old_content, new_content, path), backup_path]
100
+ else
101
+ FileUtils.cp(path, backup_path) if backup_path
102
+ FileUtils.mkdir_p(File.dirname(path)) unless old_content
103
+ File.write(path, new_content)
104
+ [status, nil, backup_path]
105
+ end
106
+ end
107
+
108
+ def backup_path_for(path)
109
+ "#{path}.#{@clock.call.strftime("%Y%m%d-%H%M%S")}.bak"
110
+ end
111
+
73
112
  def each_target(agents, scope)
74
113
  agents.map do |agent|
75
114
  path = path_for(agent, scope)
76
- Result.new(agent, path, yield(path))
115
+ status, diff, backup = path ? yield(path) : :manual
116
+ Result.new(agent, path, status, diff, backup)
77
117
  end
78
118
  end
79
119
  end
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AllInRuby
2
4
  module Instruction
3
- BEGIN_MARKER = "<!-- BEGIN all_in_ruby -->".freeze
4
- END_MARKER = "<!-- END all_in_ruby -->".freeze
5
+ BEGIN_MARKER = "<!-- BEGIN all_in_ruby -->"
6
+ END_MARKER = "<!-- END all_in_ruby -->"
5
7
 
6
- BODY = <<~MARKDOWN.freeze
8
+ BODY = <<~MARKDOWN
7
9
  ## Scripts
8
10
 
9
11
  Write throwaway and utility scripts (data munging, one-off migrations,
@@ -25,6 +27,10 @@ module AllInRuby
25
27
  BLOCK
26
28
  end
27
29
 
30
+ def self.body
31
+ BODY
32
+ end
33
+
28
34
  def self.installed?(content)
29
35
  content.include?(BEGIN_MARKER)
30
36
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AllInRuby
2
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
3
5
  end
data/lib/all_in_ruby.rb CHANGED
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "all_in_ruby/version"
2
4
  require_relative "all_in_ruby/instruction"
5
+ require_relative "all_in_ruby/diff"
3
6
  require_relative "all_in_ruby/installer"
4
7
  require_relative "all_in_ruby/cli"
5
8
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: all_in_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucian Ghinda
@@ -18,12 +18,15 @@ executables:
18
18
  extensions: []
19
19
  extra_rdoc_files: []
20
20
  files:
21
+ - AGENTS.md
21
22
  - CHANGELOG.md
23
+ - CLAUDE.md
22
24
  - LICENSE.txt
23
25
  - README.md
24
26
  - exe/all-in-ruby
25
27
  - lib/all_in_ruby.rb
26
28
  - lib/all_in_ruby/cli.rb
29
+ - lib/all_in_ruby/diff.rb
27
30
  - lib/all_in_ruby/installer.rb
28
31
  - lib/all_in_ruby/instruction.rb
29
32
  - lib/all_in_ruby/version.rb