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 +7 -0
- data/LICENSE +21 -0
- data/README.md +207 -0
- data/exe/env-agent +4 -0
- data/lib/env_agent/agent.rb +93 -0
- data/lib/env_agent/checkout.rb +104 -0
- data/lib/env_agent/cli.rb +296 -0
- data/lib/env_agent/dotenv.rb +124 -0
- data/lib/env_agent/excluder.rb +25 -0
- data/lib/env_agent/linker.rb +63 -0
- data/lib/env_agent/resolver.rb +67 -0
- data/lib/env_agent/version.rb +3 -0
- data/lib/env_agent.rb +15 -0
- data/templates/agent.md +34 -0
- metadata +57 -0
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,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
|
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'
|
data/templates/agent.md
ADDED
|
@@ -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: []
|