env-agent 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f061d96c573ec1c540732024985a8fa07db34750841268950285b2690c68c02
4
+ data.tar.gz: 8e0deaee7c6f4bbefbffa1302f721e992d3c0a7028cd1536f838deb33fac85de
5
+ SHA512:
6
+ metadata.gz: b3273b86ebfae5e5d26057f858db35fae16ea875d9c712823edb2262fd058c9dc12527882f6e4f4b73577e1c918ea4446a53954a46cbd49bc94b78e740363c14
7
+ data.tar.gz: c6cd1d208d7a19f25f1b21281b577a05bd2ea042406ae6f981f4bf1df1d2226083ac0fb32e566e10ed536d1302faf9b08ea30aa4d9f26f3143eae70f5c35a3eb
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Panos Dalitsouris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # env-agent
2
+
3
+ Your project's docs, skills and agent instructions sometimes need files from
4
+ **another repo** — a design-system demo, a vendored theme, a sibling service.
5
+ That repo is cloned somewhere on each developer's machine, but everyone puts
6
+ it somewhere different, and every git worktree is in yet another place.
7
+
8
+ env-agent gives that other repo **one fixed path inside your project**, the
9
+ same on every machine and in every worktree:
10
+
11
+ ```
12
+ .env-agent/design-system/docs/index.html
13
+ ```
14
+
15
+ `.env-agent/design-system` is a link to wherever *your* clone happens to be. Docs
16
+ and AI agents use the link, so nobody hardcodes `/Users/someone/Code/...`, and
17
+ nobody's home directory ends up in a transcript, a log or a commit.
18
+
19
+ It comes in two parts:
20
+
21
+ - **The `env-agent` command** finds each clone and creates the links. It never
22
+ asks questions, never clones anything, and never prints a path.
23
+ - **The `env-agent` agent** (for Claude Code) does the parts that need a human:
24
+ it asks where your clone is, or whether to clone it, and then runs the command.
25
+
26
+ Works on macOS and Linux, in any git repository, with any Ruby 3.3 or newer.
27
+ No other dependencies. Windows is not supported.
28
+
29
+ ## Install
30
+
31
+ env-agent is not published to RubyGems yet. Install it from a clone:
32
+
33
+ ```sh
34
+ git clone <this repo> env-agent
35
+ cd env-agent
36
+ gem build env-agent.gemspec
37
+ gem install ./env-agent-0.1.0.gem
38
+ ```
39
+
40
+ Then, once per machine, install the agent for Claude Code:
41
+
42
+ ```sh
43
+ env-agent install-agent # writes ~/.claude/agents/env-agent.md
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ### Setting up a clone
49
+
50
+ From anywhere in the project (main checkout or any worktree), tell env-agent
51
+ where your clone is. It asks for the directory without showing what you type:
52
+
53
+ ```sh
54
+ env-agent set design-system
55
+ ```
56
+
57
+ Or give the directory directly: `env-agent set design-system ~/Code/design-system`.
58
+
59
+ The name (`design-system`) is yours to choose; it becomes the link
60
+ `.env-agent/design-system`. It may contain letters, digits, `_`, `.` and `-`.
61
+
62
+ You only do this once per machine. Every worktree of the project picks up the
63
+ same setting; a new worktree just needs `env-agent link`.
64
+
65
+ In Claude Code you can also just ask: *"use the env-agent agent to set up the
66
+ external repos"*.
67
+
68
+ ### Adding the agent to a project
69
+
70
+ ```sh
71
+ env-agent init
72
+ ```
73
+
74
+ This creates:
75
+
76
+ | File | What it is | Commit it? |
77
+ |---|---|---|
78
+ | `.AGENT/agents/env-agent.md` | The project's copy of the agent | Yes |
79
+ | `.claude/agents/env-agent.md` | A link to the copy above, where Claude Code looks for it | Yes |
80
+
81
+ Then point your docs at the link, e.g. *"copy markup from
82
+ `.env-agent/design-system/docs/`; if the link is missing, run `env-agent link`"*.
83
+
84
+ There is no per-project config file. A project only commits the agent and
85
+ mentions the link names in its docs; the paths live on each machine.
86
+
87
+ ## Commands
88
+
89
+ | Command | What it does |
90
+ |---|---|
91
+ | `env-agent set NAME [DIR]` | Saves where your clone is, then links it. Without DIR it asks, hiding your input |
92
+ | `env-agent link [NAME]` | Creates or refreshes `.env-agent/NAME` (every saved name if NAME is omitted) |
93
+ | `env-agent check [NAME]` | Shows where each name was found, or why not. Changes nothing |
94
+ | `env-agent unset NAME` | Forgets the saved location and removes the link |
95
+ | `env-agent init` | Adds the agent to the current project (see above) |
96
+ | `env-agent install-agent` | Installs the agent for your user in `~/.claude/agents/` |
97
+ | `env-agent help`, `version` | Usage and version |
98
+
99
+ Exit status: `0` everything resolved, `1` something did not, `2` a wrong
100
+ command, argument or name, or not inside a git checkout.
101
+
102
+ Example `check` output:
103
+
104
+ ```
105
+ design-system: ok (main checkout .env.agents)
106
+ main checkout .env.agents: ok
107
+ link .env-agent/design-system: ok
108
+ ```
109
+
110
+ In a linked worktree, `check` also lists `worktree .env.agents` first.
111
+
112
+ ## How a directory is found
113
+
114
+ For each name, env-agent tries these places in order and uses the first one
115
+ whose directory exists:
116
+
117
+ 1. **This worktree's `.env.agents`**: only when you are in a linked worktree.
118
+ Lets one worktree use a different clone.
119
+ 2. **The main checkout's `.env.agents`**: where `env-agent set` saves things.
120
+ Found from any worktree, which is why one setting covers all of them.
121
+
122
+ If neither has a directory that exists, the name is reported as not found and
123
+ its link is removed. A saved location that no longer exists is skipped, not
124
+ trusted.
125
+
126
+ `.env.agents` is a plain `NAME=value` file:
127
+
128
+ ```sh
129
+ design-system=/Users/you/Code/design-system
130
+ ```
131
+
132
+ Use `env-agent set` / `unset` rather than editing it by hand; `set` checks the
133
+ directory before saving it. Values with spaces or quotes are quoted for you.
134
+
135
+ Environment variables are deliberately **not** read. A `design-system`
136
+ variable exported in your shell never changes where a link points; only
137
+ `.env.agents` does.
138
+
139
+ ## Keeping paths private
140
+
141
+ env-agent never prints a resolved path: not in `link`, `check`, `set`, or any
142
+ error. It names the *source* instead ("main checkout .env.agents") and the
143
+ outcome ("ok", "not set", "directory missing").
144
+
145
+ That keeps your home directory and folder layout out of AI agent transcripts,
146
+ CI logs, screen shares and commits. It is not a secret store: the path is in
147
+ `.env.agents` on your disk, and `ls -l .env-agent/` shows where a link points.
148
+
149
+ To keep the path out of an agent conversation entirely, run `set` yourself
150
+ without a directory. In Claude Code, type `! env-agent set design-system`; the
151
+ prompt hides your input.
152
+
153
+ ## Nothing to add to `.gitignore`
154
+
155
+ The first time `set`, `link` or `init` runs in a project, env-agent adds
156
+ `/.env.agents` and `/.env-agent/` to the repository's `.git/info/exclude`. That
157
+ file is local to your machine and shared by all worktrees, so the project's
158
+ `.gitignore` never needs to change.
159
+
160
+ ## The agent
161
+
162
+ The agent is a short Claude Code instruction file. It:
163
+
164
+ 1. runs `env-agent link`;
165
+ 2. for each name that is missing, asks where that directory is on your
166
+ machine, and offers the private `! env-agent set` route;
167
+ 3. otherwise runs `env-agent set NAME DIR` with the directory you give;
168
+ 4. runs `env-agent check` and reports it.
169
+
170
+ It only runs commands; it never reads or edits `.env.agents`, never guesses a
171
+ directory and never repeats a path back to you.
172
+
173
+ There can be two copies, and both come from the same template inside the gem:
174
+
175
+ - **Project copy** (`env-agent init`): committed, so everyone who clones the
176
+ project has it. Claude Code prefers it when present.
177
+ - **User copy** (`env-agent install-agent`): used in projects that have no
178
+ copy of their own.
179
+
180
+ The template has a version number. When a project's copy is older than your
181
+ installed env-agent, `env-agent check` prints a warning; run `env-agent init`
182
+ to update it.
183
+
184
+ ## Notes
185
+
186
+ - **Docker:** run env-agent on your machine, not inside a container. The
187
+ external clones live on the host.
188
+ - **Git:** needs git 2.31 or newer (for `rev-parse --path-format`).
189
+
190
+ ## For other tools
191
+
192
+ The lookup order and the `.env.agents` format above are the whole contract.
193
+ Any tool, in any language, can implement them and produce the same result;
194
+ this gem is one implementation.
195
+
196
+ ## Development
197
+
198
+ ```sh
199
+ bundle install
200
+ bundle exec rake # RuboCop + specs
201
+ ```
202
+
203
+ The specs run the CLI against throwaway repositories with real git worktrees,
204
+ and check that no output ever contains a path.
205
+
206
+ [AGENTS.md](AGENTS.md) has the code map, the rules every change must keep, and
207
+ how to add a command. It is written for AI agents and people alike.
data/exe/env-agent ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../lib/env_agent'
3
+
4
+ exit EnvAgent::CLI.start(ARGV)
@@ -0,0 +1,93 @@
1
+ require 'fileutils'
2
+
3
+ # The env-agent Claude Code agent: one bundled template, written into a repo and into the user's config.
4
+ class EnvAgent::Agent
5
+ TEMPLATE = File.expand_path('../../templates/agent.md', __dir__)
6
+ REPO_COPY = '.AGENT/agents/env-agent.md'.freeze
7
+ CLAUDE_COPY = '.claude/agents/env-agent.md'.freeze
8
+ CLAUDE_LINK_TARGET = '../../.AGENT/agents/env-agent.md'.freeze
9
+
10
+ # @return [String]
11
+ def self.template
12
+ File.read(TEMPLATE)
13
+ end
14
+
15
+ # @param [String] text agent markdown
16
+ #
17
+ # @return [Integer, nil] the frontmatter version
18
+ def self.version_of(text)
19
+ text[/^version:\s*(\d+)\s*$/, 1]&.to_i
20
+ end
21
+
22
+ # @param [EnvAgent::Checkout] checkout
23
+ #
24
+ # @raise [EnvAgent::Error] when a link is on the way to either copy
25
+ #
26
+ # @return [Array<String>] what changed, in repo-relative terms
27
+ def write_repo!(checkout)
28
+ repo_copy = checkout.own_path!(REPO_COPY)
29
+ claude_copy = File.join(checkout.own_path!(File.dirname(CLAUDE_COPY)), File.basename(CLAUDE_COPY))
30
+ actions = []
31
+ actions << "wrote #{REPO_COPY}" if write_if_changed(repo_copy)
32
+ actions << "linked #{CLAUDE_COPY} -> #{REPO_COPY}" if link_claude_copy(claude_copy)
33
+ actions
34
+ end
35
+
36
+ # @param [String] home the user's home directory
37
+ #
38
+ # @return [TrueClass, FalseClass] whether the copy changed
39
+ def write_user!(home)
40
+ write_if_changed(File.join(home, CLAUDE_COPY))
41
+ end
42
+
43
+ # @param [String] root checkout root
44
+ #
45
+ # @return [Integer, nil] version of the repo's committed copy, nil when it has none
46
+ def repo_version(root)
47
+ copy = [REPO_COPY, CLAUDE_COPY].map { |relative| File.join(root, relative) }.find { |path| File.file?(path) }
48
+ copy && self.class.version_of(File.read(copy))
49
+ end
50
+
51
+ private
52
+
53
+ # @param [String] path
54
+ #
55
+ # @return [TrueClass, FalseClass]
56
+ def write_if_changed(path)
57
+ return false if File.file?(path) && File.read(path) == self.class.template
58
+
59
+ FileUtils.mkdir_p(File.dirname(path))
60
+ File.write(path, self.class.template)
61
+ true
62
+ end
63
+
64
+ # @param [String] link
65
+ #
66
+ # @return [TrueClass, FalseClass] whether the link changed
67
+ def link_claude_copy(link)
68
+ return false if File.symlink?(link) && File.readlink(link) == CLAUDE_LINK_TARGET
69
+
70
+ clear(link)
71
+ FileUtils.mkdir_p(File.dirname(link))
72
+ File.symlink(CLAUDE_LINK_TARGET, link)
73
+ true
74
+ end
75
+
76
+ # @param [String] link
77
+ #
78
+ # @raise [EnvAgent::Error] when an unrelated file is in the way
79
+ def clear(link)
80
+ return File.unlink(link) if File.symlink?(link)
81
+ return unless File.exist?(link)
82
+ raise EnvAgent::Error, "#{CLAUDE_COPY} exists and is not the env-agent agent; leaving it" unless agent_file?(link)
83
+
84
+ File.unlink(link)
85
+ end
86
+
87
+ # @param [String] path
88
+ #
89
+ # @return [TrueClass, FalseClass]
90
+ def agent_file?(path)
91
+ File.read(path).match?(/^name:\s*env-agent\s*$/)
92
+ end
93
+ end
@@ -0,0 +1,104 @@
1
+ require 'open3'
2
+ require 'pathname'
3
+
4
+ # The git checkout env-agent runs in: the current worktree and the main checkout behind it.
5
+ class EnvAgent::Checkout
6
+ # @param [String] dir any directory inside the checkout
7
+ def initialize(dir = Dir.pwd)
8
+ @dir = dir
9
+ end
10
+
11
+ # @return [String] root of the current worktree, or of the main checkout when run there
12
+ def root
13
+ @root ||= File.realpath(git('rev-parse', '--show-toplevel'))
14
+ end
15
+
16
+ # @return [String] the git directory shared by the main checkout and every worktree
17
+ def common_dir
18
+ @common_dir ||= File.realpath(git('rev-parse', '--path-format=absolute', '--git-common-dir'))
19
+ end
20
+
21
+ # @return [String] root of the main checkout
22
+ def main_root
23
+ @main_root ||= File.dirname(common_dir)
24
+ end
25
+
26
+ # @return [TrueClass, FalseClass] whether the current checkout is the main one
27
+ def main?
28
+ root == main_root
29
+ end
30
+
31
+ # @raise [EnvAgent::Error] when git tracks the file
32
+ #
33
+ # @return [EnvAgent::Dotenv] the main checkout's .env.agents
34
+ def main_dotenv
35
+ @main_dotenv ||= untracked_dotenv(main_root, 'the main checkout')
36
+ end
37
+
38
+ # @raise [EnvAgent::Error] when git tracks the file
39
+ #
40
+ # @return [EnvAgent::Dotenv] this worktree's .env.agents
41
+ def worktree_dotenv
42
+ @worktree_dotenv ||= untracked_dotenv(root, 'this worktree')
43
+ end
44
+
45
+ # @param [String] relative path inside the current worktree that env-agent writes to
46
+ #
47
+ # @raise [EnvAgent::Error] when the path, or a directory on the way to it, is a link
48
+ #
49
+ # @return [String] the absolute path
50
+ def own_path!(relative)
51
+ Pathname(relative).descend do |part|
52
+ raise EnvAgent::Error, "#{part} is a link; move it away first" if File.symlink?(File.join(root, part))
53
+ end
54
+ File.join(root, relative)
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :dir
60
+
61
+ # @param [String] checkout_root
62
+ # @param [String] where
63
+ #
64
+ # @raise [EnvAgent::Error] when git tracks the file
65
+ #
66
+ # @return [EnvAgent::Dotenv]
67
+ def untracked_dotenv(checkout_root, where)
68
+ file = EnvAgent::Dotenv::FILE
69
+
70
+ if tracked?(checkout_root, file)
71
+ raise EnvAgent::Error, "#{file} is committed in #{where}, so env-agent will not use it. " \
72
+ "Keep it on this machine only: git rm --cached #{file}"
73
+ end
74
+
75
+ EnvAgent::Dotenv.new(checkout_root)
76
+ end
77
+
78
+ # @param [String] checkout_root
79
+ # @param [String] relative
80
+ #
81
+ # @return [TrueClass, FalseClass] false also when checkout_root is not a checkout
82
+ def tracked?(checkout_root, relative)
83
+ out, status = capture(checkout_root, 'ls-files', '-z', '--', relative)
84
+ status.success? && !out.empty?
85
+ end
86
+
87
+ # @param [Array<String>] args
88
+ #
89
+ # @return [String]
90
+ def git(*args)
91
+ out, status = capture(dir, *args)
92
+ raise EnvAgent::UsageError, 'not inside a git checkout' unless status.success?
93
+
94
+ out.strip
95
+ end
96
+
97
+ # @param [String] chdir
98
+ # @param [Array<String>] args
99
+ #
100
+ # @return [Array(String, Process::Status)]
101
+ def capture(chdir, *args)
102
+ Open3.capture2('git', *args, chdir: chdir, err: File::NULL)
103
+ end
104
+ end
@@ -0,0 +1,296 @@
1
+ require 'io/console'
2
+
3
+ # Command-line entry point: runs one command and maps failures to exit statuses. Never prints a saved path.
4
+ class EnvAgent::CLI
5
+ USAGE = <<~TEXT.freeze
6
+ Usage: env-agent <command> [arguments]
7
+
8
+ set NAME [DIR] Save DIR as NAME and link it at .env-agent/NAME; asks for DIR, hidden, when omitted
9
+ link [NAME] Recreate .env-agent/NAME (every saved name when NAME is omitted)
10
+ check [NAME] Show where each name resolves from, changing nothing
11
+ unset NAME Forget NAME and remove its link
12
+ init Add the env-agent agent to this repo
13
+ install-agent Install the env-agent agent for your user (~/.claude/agents)
14
+ help Show this message
15
+ version Show the version
16
+
17
+ env-agent never prints a saved path. See the README for details.
18
+ TEXT
19
+
20
+ LINK_STATES = {
21
+ ok: 'ok',
22
+ missing: 'missing (run `env-agent link`)',
23
+ stale: 'stale (run `env-agent link`)'
24
+ }.freeze
25
+
26
+ # @param [Array<String>] argv
27
+ # @param [IO] stdin
28
+ # @param [IO] stdout
29
+ # @param [IO] stderr
30
+ #
31
+ # @return [Integer] exit status
32
+ def self.start(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
33
+ new(stdin: stdin, stdout: stdout, stderr: stderr).run(argv)
34
+ end
35
+
36
+ # @param [IO] stdin
37
+ # @param [IO] stdout
38
+ # @param [IO] stderr
39
+ def initialize(stdin:, stdout:, stderr:)
40
+ @stdin = stdin
41
+ @stdout = stdout
42
+ @stderr = stderr
43
+ end
44
+
45
+ # @param [Array<String>] argv
46
+ #
47
+ # @return [Integer] exit status
48
+ def run(argv)
49
+ command, *args = argv
50
+ dispatch(command, args)
51
+ rescue EnvAgent::UsageError => e
52
+ stderr.puts("env-agent: #{e.message}")
53
+ stderr.puts('Run `env-agent help` for usage.')
54
+ 2
55
+ rescue EnvAgent::Error => e
56
+ stderr.puts("env-agent: #{e.message}")
57
+ 1
58
+ rescue SystemCallError => e
59
+ stderr.puts("env-agent: #{SystemCallError.new(e.errno).message}")
60
+ 1
61
+ rescue StandardError => e
62
+ stderr.puts("env-agent: unexpected #{e.class}; details withheld so no path is printed")
63
+ 1
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :stdin, :stdout, :stderr
69
+
70
+ # @param [String, nil] command
71
+ # @param [Array<String>] args
72
+ #
73
+ # @return [Integer]
74
+ def dispatch(command, args)
75
+ case command
76
+ when 'set' then set(*arity!(args, 1, 2))
77
+ when 'link' then link(arity!(args, 0, 1).first)
78
+ when 'check' then check(arity!(args, 0, 1).first)
79
+ when 'unset' then unset(arity!(args, 1, 1).first)
80
+ when 'init' then arity!(args, 0, 0) && init
81
+ when 'install-agent' then arity!(args, 0, 0) && install_agent
82
+ when nil, 'help', '-h', '--help' then say(USAGE)
83
+ when 'version', '-v', '--version' then say(EnvAgent::VERSION)
84
+ else raise EnvAgent::UsageError, "unknown command '#{command}'"
85
+ end
86
+ end
87
+
88
+ # @param [String] name
89
+ # @param [String, nil] dir
90
+ #
91
+ # @return [Integer]
92
+ def set(name, dir = nil)
93
+ validate_name!(name)
94
+ path = EnvAgent::Dotenv.absolute(dir || ask_for_dir(name))
95
+ raise EnvAgent::Error, "#{name}: that directory does not exist. Nothing saved." unless File.directory?(path)
96
+
97
+ excluder.apply!
98
+ checkout.main_dotenv.set(name, path)
99
+ winner = resolver(name).resolve
100
+ linker(name).link!(winner.path)
101
+ stdout.puts("#{name}: set, linked (#{winner.label})")
102
+ stdout.puts(" note: this worktree's #{EnvAgent::Dotenv::FILE} overrides it here") if winner.source == :worktree
103
+ 0
104
+ end
105
+
106
+ # @param [String, nil] name
107
+ #
108
+ # @return [Integer]
109
+ def link(name)
110
+ excluder.apply!
111
+ linked = names(name).map { |each_name| link_name(each_name) }
112
+ linked.all? ? 0 : 1
113
+ end
114
+
115
+ # @param [String, nil] name
116
+ #
117
+ # @return [Integer]
118
+ def check(name)
119
+ resolved = names(name).map { |each_name| check_name(each_name) }
120
+ warn_outdated_agent
121
+ resolved.all? ? 0 : 1
122
+ end
123
+
124
+ # @param [String] name
125
+ #
126
+ # @return [Integer]
127
+ def unset(name)
128
+ validate_name!(name)
129
+ removed = checkout.main_dotenv.delete(name)
130
+ linker(name).remove!
131
+ say("#{name}: #{removed ? 'unset' : 'was not set'}, link removed")
132
+ end
133
+
134
+ # @return [Integer]
135
+ def init
136
+ excluder.apply!
137
+ actions = agent.write_repo!(checkout)
138
+ say(actions.empty? ? 'already up to date' : actions)
139
+ end
140
+
141
+ # @return [Integer]
142
+ def install_agent
143
+ changed = agent.write_user!(Dir.home)
144
+ say(changed ? "installed ~/#{EnvAgent::Agent::CLAUDE_COPY}" : 'already up to date')
145
+ end
146
+
147
+ # @param [String] name
148
+ #
149
+ # @return [TrueClass, FalseClass] whether it resolved
150
+ def link_name(name)
151
+ winner = resolver(name).resolve
152
+ return not_found(name) unless winner
153
+
154
+ linker(name).link!(winner.path)
155
+ stdout.puts("#{name}: linked (#{winner.label})")
156
+ true
157
+ end
158
+
159
+ # @param [String] name
160
+ #
161
+ # @return [FalseClass]
162
+ def not_found(name)
163
+ linker(name).remove!
164
+ stderr.puts("#{name}: not linked (#{summary(name)}).")
165
+ stderr.puts(not_found_hint(name))
166
+ false
167
+ end
168
+
169
+ # @param [String] name
170
+ #
171
+ # @return [TrueClass, FalseClass] whether it resolved
172
+ def check_name(name)
173
+ winner = resolver(name).resolve
174
+ stdout.puts("#{name}: #{winner ? "ok (#{winner.label})" : 'not found'}")
175
+ resolver(name).candidates.each { |candidate| stdout.puts(" #{candidate}") }
176
+ stdout.puts(" link #{linker(name).relative_path}: #{LINK_STATES.fetch(linker(name).state(winner&.path))}")
177
+ stdout.puts(not_found_hint(name)) unless winner
178
+ !winner.nil?
179
+ end
180
+
181
+ # @param [String] name
182
+ #
183
+ # @return [String]
184
+ def summary(name)
185
+ resolver(name).candidates.join(', ')
186
+ end
187
+
188
+ # @param [String] name
189
+ #
190
+ # @return [String]
191
+ def not_found_hint(name)
192
+ " Run `env-agent set #{name}` (it asks for the directory), or ask the env-agent agent to set it up."
193
+ end
194
+
195
+ # @param [String] name
196
+ #
197
+ # @raise [EnvAgent::UsageError] when nothing was typed
198
+ #
199
+ # @return [String]
200
+ def ask_for_dir(name)
201
+ stderr.print("Directory for #{name} (input hidden): ")
202
+ line = stdin.tty? ? stdin.noecho(&:gets) : stdin.gets
203
+ stderr.puts
204
+ dir = line.to_s.strip
205
+ raise EnvAgent::UsageError, 'no directory given' if dir.empty?
206
+
207
+ dir
208
+ end
209
+
210
+ def warn_outdated_agent
211
+ version = agent.repo_version(checkout.root)
212
+ latest = EnvAgent::Agent.version_of(EnvAgent::Agent.template)
213
+ return unless version && version < latest
214
+
215
+ stderr.puts("warning: this repo's env-agent agent is version #{version}; this CLI ships version #{latest}. " \
216
+ 'Run `env-agent init` to refresh it.')
217
+ end
218
+
219
+ # @param [String, nil] name
220
+ #
221
+ # @return [Array<String>] the given name, or every name saved in either .env.agents
222
+ def names(name)
223
+ return [validate_name!(name)] if name
224
+
225
+ list = (worktree_dotenv_names + checkout.main_dotenv.names).uniq.sort
226
+ stdout.puts("nothing set in #{EnvAgent::Dotenv::FILE} (use `env-agent set NAME DIR`)") if list.empty?
227
+ list
228
+ end
229
+
230
+ # @return [Array<String>]
231
+ def worktree_dotenv_names
232
+ checkout.main? ? [] : checkout.worktree_dotenv.names
233
+ end
234
+
235
+ # @param [String] name
236
+ #
237
+ # @raise [EnvAgent::UsageError] when the name cannot be stored or linked
238
+ #
239
+ # @return [String]
240
+ def validate_name!(name)
241
+ return name if EnvAgent::Dotenv.valid_name?(name)
242
+
243
+ raise EnvAgent::UsageError, "'#{name}' is not a valid name (letters, digits, '_', '.' and '-')"
244
+ end
245
+
246
+ # @param [Array<String>] args
247
+ # @param [Integer] min
248
+ # @param [Integer] max
249
+ #
250
+ # @raise [EnvAgent::UsageError] when the count is outside min..max
251
+ #
252
+ # @return [Array<String>]
253
+ def arity!(args, min, max)
254
+ raise EnvAgent::UsageError, 'wrong number of arguments' unless (min..max).cover?(args.size)
255
+
256
+ args
257
+ end
258
+
259
+ # @param [String, Array<String>] text
260
+ #
261
+ # @return [Integer]
262
+ def say(text)
263
+ stdout.puts(text)
264
+ 0
265
+ end
266
+
267
+ # @return [EnvAgent::Checkout]
268
+ def checkout
269
+ @checkout ||= EnvAgent::Checkout.new
270
+ end
271
+
272
+ # @return [EnvAgent::Excluder]
273
+ def excluder
274
+ @excluder ||= EnvAgent::Excluder.new(checkout.common_dir)
275
+ end
276
+
277
+ # @return [EnvAgent::Agent]
278
+ def agent
279
+ @agent ||= EnvAgent::Agent.new
280
+ end
281
+
282
+ # @param [String] name
283
+ #
284
+ # @return [EnvAgent::Resolver]
285
+ def resolver(name)
286
+ @resolvers ||= {}
287
+ @resolvers[name] ||= EnvAgent::Resolver.new(name, checkout)
288
+ end
289
+
290
+ # @param [String] name
291
+ #
292
+ # @return [EnvAgent::Linker]
293
+ def linker(name)
294
+ EnvAgent::Linker.new(checkout, name)
295
+ end
296
+ end
@@ -0,0 +1,124 @@
1
+ require 'fileutils'
2
+
3
+ # A .env.agents file: NAME=directory lines, optional quotes and # comments, no interpolation.
4
+ class EnvAgent::Dotenv
5
+ FILE = '.env.agents'.freeze
6
+ NAME = /[A-Za-z0-9_][A-Za-z0-9_.-]*/
7
+ LINE = /\A\s*(?:export\s+)?(#{NAME})\s*=\s*(.*)\z/
8
+
9
+ # @param [String] name
10
+ #
11
+ # @return [TrueClass, FalseClass] whether the name can be stored and used as a link name
12
+ def self.valid_name?(name)
13
+ name.match?(/\A#{NAME}\z/)
14
+ end
15
+
16
+ # @param [String] value a directory as typed or saved
17
+ # @param [String] base directory a relative value is resolved against
18
+ #
19
+ # @return [String] the absolute path; a ~name that names no account stays literal, as in a shell
20
+ def self.absolute(value, base = Dir.pwd)
21
+ File.expand_path(value, base)
22
+ rescue ArgumentError
23
+ File.expand_path("./#{value}", base)
24
+ end
25
+
26
+ # @param [String] dir directory holding the .env.agents file
27
+ def initialize(dir)
28
+ @path = File.join(dir, FILE)
29
+ end
30
+
31
+ # @return [Array<String>] every name with a value
32
+ def names
33
+ values.reject { |_name, value| value.empty? }.keys
34
+ end
35
+
36
+ # @param [String] key
37
+ #
38
+ # @return [String, nil] the value as an absolute path, relative values resolved against the file's directory
39
+ def [](key)
40
+ value = values[key]
41
+ return if value.nil? || value.empty?
42
+
43
+ self.class.absolute(value, File.dirname(path))
44
+ end
45
+
46
+ # @param [String] key
47
+ # @param [String] value
48
+ def set(key, value)
49
+ replaced = false
50
+ kept = lines.each_with_object([]) do |line, out|
51
+ next out << line unless key_of(line) == key
52
+ next if replaced
53
+
54
+ out << "#{key}=#{quote(value)}"
55
+ replaced = true
56
+ end
57
+ kept << "#{key}=#{quote(value)}" unless replaced
58
+ write(kept)
59
+ end
60
+
61
+ # @param [String] key
62
+ #
63
+ # @return [TrueClass, FalseClass] whether the key was present
64
+ def delete(key)
65
+ kept = lines.reject { |line| key_of(line) == key }
66
+ return false if kept.size == lines.size
67
+
68
+ write(kept)
69
+ true
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :path
75
+
76
+ # @return [Array<String>]
77
+ def lines
78
+ File.exist?(path) ? File.read(path).split(/\r?\n/) : []
79
+ end
80
+
81
+ # @return [Hash{String => String}]
82
+ def values
83
+ lines.each_with_object({}) do |line, out|
84
+ match = LINE.match(line)
85
+ out[match[1]] = unquote(match[2]) if match
86
+ end
87
+ end
88
+
89
+ # @param [String] line
90
+ #
91
+ # @return [String, nil]
92
+ def key_of(line)
93
+ LINE.match(line)&.[](1)
94
+ end
95
+
96
+ # @param [String] raw
97
+ #
98
+ # @return [String]
99
+ def unquote(raw)
100
+ raw[/\A"([^"]*)"\s*(?:#.*)?\z/, 1] || raw[/\A'([^']*)'\s*(?:#.*)?\z/, 1] || raw.sub(/\s+#.*\z/, '').strip
101
+ end
102
+
103
+ # @param [String] value
104
+ #
105
+ # @raise [EnvAgent::Error] when the value cannot be stored safely
106
+ #
107
+ # @return [String]
108
+ def quote(value)
109
+ raise EnvAgent::Error, 'a directory name with a line break cannot be stored' if value.match?(/[\r\n]/)
110
+ return value unless value.match?(/[\s#'"]/)
111
+ return "\"#{value}\"" unless value.include?('"')
112
+ return "'#{value}'" unless value.include?("'")
113
+
114
+ raise EnvAgent::Error, 'a directory name with both kinds of quotes cannot be stored'
115
+ end
116
+
117
+ # @param [Array<String>] new_lines
118
+ def write(new_lines)
119
+ FileUtils.mkdir_p(File.dirname(path))
120
+ tmp = "#{path}.tmp-#{Process.pid}"
121
+ File.write(tmp, new_lines.empty? ? '' : "#{new_lines.join("\n")}\n")
122
+ File.rename(tmp, path)
123
+ end
124
+ end
@@ -0,0 +1,25 @@
1
+ require 'fileutils'
2
+
3
+ # Keeps env-agent's own files untracked through the shared info/exclude, so no repo edits .gitignore.
4
+ class EnvAgent::Excluder
5
+ PATTERNS = ["/#{EnvAgent::Dotenv::FILE}", "/#{EnvAgent::Linker::DIR}/"].freeze
6
+
7
+ # @param [String] common_dir the git directory shared by every worktree
8
+ def initialize(common_dir)
9
+ @path = File.join(common_dir, 'info', 'exclude')
10
+ end
11
+
12
+ def apply!
13
+ content = File.exist?(path) ? File.read(path) : ''
14
+ missing = PATTERNS - content.split(/\r?\n/)
15
+ return if missing.empty?
16
+
17
+ content += "\n" unless content.empty? || content.end_with?("\n")
18
+ FileUtils.mkdir_p(File.dirname(path))
19
+ File.write(path, "#{content}#{missing.join("\n")}\n")
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :path
25
+ end
@@ -0,0 +1,63 @@
1
+ require 'fileutils'
2
+
3
+ # The .env-agent/NAME symlink inside a checkout.
4
+ class EnvAgent::Linker
5
+ DIR = '.env-agent'.freeze
6
+
7
+ # @param [EnvAgent::Checkout] checkout the checkout the link lives in
8
+ # @param [String] name registry name
9
+ def initialize(checkout, name)
10
+ @checkout = checkout
11
+ @name = name
12
+ end
13
+
14
+ # @return [String] the link as consumers reference it
15
+ def relative_path
16
+ "#{DIR}/#{name}"
17
+ end
18
+
19
+ # @param [String] target directory the link should point at
20
+ #
21
+ # @raise [EnvAgent::Error] when something other than a link is in the way, or .env-agent is a link
22
+ def link!(target)
23
+ remove!
24
+ raise EnvAgent::Error, "#{relative_path} exists and is not a link; move it away first" if File.exist?(path)
25
+
26
+ FileUtils.mkdir_p(own_dir!)
27
+ File.symlink(target, path)
28
+ end
29
+
30
+ # @raise [EnvAgent::Error] when .env-agent is a link
31
+ def remove!
32
+ own_dir!
33
+ File.unlink(path) if File.symlink?(path)
34
+ end
35
+
36
+ # @param [String, nil] target where the link should point, nil when nothing resolved
37
+ #
38
+ # @return [Symbol] :ok, :missing or :stale
39
+ def state(target)
40
+ return :missing unless File.symlink?(path) || File.exist?(path)
41
+ return :stale if target.nil?
42
+
43
+ File.realpath(path) == File.realpath(target) ? :ok : :stale
44
+ rescue Errno::ENOENT
45
+ :stale
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :checkout, :name
51
+
52
+ # @return [String]
53
+ def path
54
+ File.join(checkout.root, DIR, name)
55
+ end
56
+
57
+ # @raise [EnvAgent::Error] when .env-agent is a link
58
+ #
59
+ # @return [String]
60
+ def own_dir!
61
+ checkout.own_path!(DIR)
62
+ end
63
+ end
@@ -0,0 +1,67 @@
1
+ # Finds the directory saved for a name, trying this worktree's .env.agents, then the main checkout's.
2
+ class EnvAgent::Resolver
3
+ LABELS = {
4
+ worktree: 'worktree .env.agents',
5
+ main: 'main checkout .env.agents'
6
+ }.freeze
7
+
8
+ OUTCOMES = {
9
+ ok: 'ok',
10
+ unset: 'not set',
11
+ missing: 'directory missing'
12
+ }.freeze
13
+
14
+ Candidate = Struct.new(:source, :path, :outcome) do
15
+ # @return [String]
16
+ def label
17
+ LABELS.fetch(source)
18
+ end
19
+
20
+ # @return [String]
21
+ def outcome_text
22
+ OUTCOMES.fetch(outcome)
23
+ end
24
+
25
+ # @return [String]
26
+ def to_s
27
+ "#{label}: #{outcome_text}"
28
+ end
29
+ end
30
+
31
+ # @param [String] name
32
+ # @param [EnvAgent::Checkout] checkout
33
+ def initialize(name, checkout)
34
+ @name = name
35
+ @checkout = checkout
36
+ end
37
+
38
+ # @return [Array<EnvAgent::Resolver::Candidate>] every source, in lookup order
39
+ def candidates
40
+ @candidates ||= sources.map { |source, path| Candidate.new(source, path, outcome_of(path)) }
41
+ end
42
+
43
+ # @return [EnvAgent::Resolver::Candidate, nil] the first candidate whose directory exists
44
+ def resolve
45
+ candidates.find { |candidate| candidate.outcome == :ok }
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :name, :checkout
51
+
52
+ # @return [Array<Array(Symbol, String)>]
53
+ def sources
54
+ list = []
55
+ list << [:worktree, checkout.worktree_dotenv[name]] unless checkout.main?
56
+ list << [:main, checkout.main_dotenv[name]]
57
+ end
58
+
59
+ # @param [String, nil] path
60
+ #
61
+ # @return [Symbol]
62
+ def outcome_of(path)
63
+ return :unset if path.nil?
64
+
65
+ File.directory?(path) ? :ok : :missing
66
+ end
67
+ end
@@ -0,0 +1,3 @@
1
+ module EnvAgent
2
+ VERSION = '0.1.1'.freeze
3
+ end
data/lib/env_agent.rb ADDED
@@ -0,0 +1,15 @@
1
+ require_relative 'env_agent/version'
2
+
3
+ # A failure the user can fix; exits with status 1.
4
+ class EnvAgent::Error < StandardError; end
5
+
6
+ # A wrong command, argument or registry; exits with status 2.
7
+ class EnvAgent::UsageError < EnvAgent::Error; end
8
+
9
+ require_relative 'env_agent/checkout'
10
+ require_relative 'env_agent/dotenv'
11
+ require_relative 'env_agent/resolver'
12
+ require_relative 'env_agent/linker'
13
+ require_relative 'env_agent/excluder'
14
+ require_relative 'env_agent/agent'
15
+ require_relative 'env_agent/cli'
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: env-agent
3
+ version: 1
4
+ description: Set up the local directories this repo's skills and docs reach through .env-agent/<name> links. Use when such a link is missing or stale, or when `env-agent check` or `env-agent link` fails.
5
+ tools: Bash
6
+ ---
7
+
8
+ Some directories this repository's docs and skills need live outside it, in a
9
+ different place on every machine. Each one is reached through a link at
10
+ `.env-agent/<name>`, and the `env-agent` command manages those links.
11
+
12
+ Run every command on the host, from inside this repository. Never inside Docker.
13
+
14
+ ## Rules
15
+
16
+ - Never print, echo or repeat an absolute path. Not from `.env.agents`, not
17
+ from `readlink`, not one the user typed. Refer to a directory by its name
18
+ ("your design-system directory").
19
+ - Never read or edit `.env.agents` yourself. Only `env-agent set` and
20
+ `env-agent unset` change it.
21
+ - Never guess or choose a directory. The user decides.
22
+
23
+ ## Steps
24
+
25
+ 1. Run `env-agent link`. If every name links, report its output and stop.
26
+ 2. For each name that is missing, ask the user where that directory is on
27
+ their machine. The docs or skill that sent you here may say what it should
28
+ contain or where to get it; pass that on.
29
+ 3. Offer the private option first: the user can run `! env-agent set <name>`
30
+ themselves. It asks for the directory without showing it, so the path never
31
+ enters this conversation. If they do, skip to step 5.
32
+ 4. Run `env-agent set <name> <directory>`. If it says the directory does not
33
+ exist, tell the user and ask again; do not retry with a guess.
34
+ 5. Run `env-agent check` and report its output.
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: env-agent
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Panos Dalitsouris
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: env-agent resolves the external repos a project depends on from per-machine
13
+ .env.agents files and links them into every worktree, plus a Claude Code agent that
14
+ sets them up.
15
+ executables:
16
+ - env-agent
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - exe/env-agent
23
+ - lib/env_agent.rb
24
+ - lib/env_agent/agent.rb
25
+ - lib/env_agent/checkout.rb
26
+ - lib/env_agent/cli.rb
27
+ - lib/env_agent/dotenv.rb
28
+ - lib/env_agent/excluder.rb
29
+ - lib/env_agent/linker.rb
30
+ - lib/env_agent/resolver.rb
31
+ - lib/env_agent/version.rb
32
+ - templates/agent.md
33
+ homepage: https://github.com/PanosCodes/env-agent
34
+ licenses:
35
+ - MIT
36
+ metadata:
37
+ source_code_uri: https://github.com/PanosCodes/env-agent
38
+ rubygems_mfa_required: 'true'
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.3'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.6.9
54
+ specification_version: 4
55
+ summary: Link repos cloned outside a git checkout into it, from any worktree, without
56
+ printing paths.
57
+ test_files: []