lshed 0.4.0 → 0.6.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.
- package/CHANGELOG.md +14 -0
- package/README.md +225 -74
- package/dist/cli.js +328 -125
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — 2026-09-03
|
|
4
|
+
|
|
5
|
+
- `lshed sync [-m <msg>] [--no-push] [--dry-run]`: commits everything in the shed, `git pull --rebase`, `git push` (setting the upstream the first time). Without `origin` it only commits. When commits come in it says to run `lshed restore`. On a conflict it aborts the rebase, leaves your commit in place and hands you the git command. It warns first if `diff` shows edits you have not saved, and never runs `save` for you.
|
|
6
|
+
- README rewritten as a usage guide: day one, daily loop, new machine, profiles, adding things, updating packages, housekeeping, output marks, every command with its flags.
|
|
7
|
+
|
|
8
|
+
## 0.5.0 — 2026-09-03
|
|
9
|
+
|
|
10
|
+
`init` was a one-shot. Anything you made afterwards had to be copied into the shed and typed into `lshed.yaml` by hand.
|
|
11
|
+
|
|
12
|
+
- `lshed add [keys...] [--all]` scans the agent root the way `init` does and lists what the shed lacks: authored skills, agents, commands, MCP servers, and git clones or plugins that should be packages. Without keys it only lists. Chosen items are copied (or recorded with a lock entry for packages), appended to `lshed.yaml` with your comments intact, added to the current profile, and added to the managed set.
|
|
13
|
+
- `status` reports things outside the shed as `창고 밖 N개 → lshed add`.
|
|
14
|
+
- `init --exclude` is now remembered as `exclude:` in the manifest, so `add` and `status` do not keep proposing the aliases you left out.
|
|
15
|
+
- `init` and `add` share one classification path (`discover`) and one ingest path, so they cannot drift apart. `init` now edits a YAML document instead of serialising an object; output is unchanged.
|
|
16
|
+
|
|
3
17
|
## 0.4.0 — 2026-09-03
|
|
4
18
|
|
|
5
19
|
Hand-configured MCP servers travel with the profile. Secret values do not.
|
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# lshed
|
|
2
2
|
|
|
3
|
-
Keep your coding-agent harness — skills, subagents, commands, instructions — in a **shed**, and restore it on any machine with one command.
|
|
3
|
+
Keep your coding-agent harness — skills, subagents, commands, instructions, MCP servers — in a **shed**, and restore it on any machine with one command.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
lshed init --shed ~/lshed # scan ~/.claude into a shed + write lshed.yaml
|
|
7
7
|
lshed restore research # apply a profile anywhere
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
The shed is a plain directory. Put it in a git repo, Dropbox, whatever. lshed
|
|
10
|
+
The shed is a plain directory. Put it in a git repo, Dropbox, whatever. `lshed sync` wraps the git part if you want it to.
|
|
11
11
|
|
|
12
12
|
## Why
|
|
13
13
|
|
|
@@ -17,7 +17,7 @@ lshed adds three first-class ideas on top of "a directory in git":
|
|
|
17
17
|
|
|
18
18
|
| Idea | What it gives you |
|
|
19
19
|
|---|---|
|
|
20
|
-
| **Components** | every skill / agent / command / instruction fragment is one named part in the shed |
|
|
20
|
+
| **Components** | every skill / agent / command / instruction fragment / MCP server is one named part in the shed |
|
|
21
21
|
| **Profiles** | named recipes — `research`, `work`, `minimal` — that pick a subset of parts |
|
|
22
22
|
| **Managed set** | lshed remembers what it placed, so switching profiles removes only its own files and never touches yours |
|
|
23
23
|
|
|
@@ -29,29 +29,190 @@ Currently supports **Claude Code** (`~/.claude`). Other agents plug in through a
|
|
|
29
29
|
npm install -g lshed
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Node 20 or newer.
|
|
32
|
+
Node 20 or newer. `git` on the PATH for packages and `sync`. `claude` on the PATH if your shed lists plugins.
|
|
33
33
|
|
|
34
34
|
## Quick start
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
# 1. On the machine that already has your setup
|
|
38
38
|
lshed init --shed ~/lshed
|
|
39
|
-
cd ~/lshed && git init && git
|
|
39
|
+
cd ~/lshed && git init && git remote add origin <your private repo>
|
|
40
|
+
lshed sync # commit + push
|
|
40
41
|
|
|
41
42
|
# 2. Edit ~/lshed/lshed.yaml — add profiles, drop parts you don't need everywhere
|
|
42
43
|
|
|
43
44
|
# 3. On any other machine
|
|
44
45
|
git clone <your private repo> ~/lshed
|
|
45
|
-
lshed restore research --shed ~/lshed
|
|
46
|
+
lshed restore research --shed ~/lshed # --shed only needed the first time
|
|
46
47
|
```
|
|
47
48
|
|
|
49
|
+
## How to use it
|
|
50
|
+
|
|
51
|
+
### Day one: put what you have into a shed
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
$ lshed init --shed ~/lshed --exclude _gstack-command connect-chrome
|
|
55
|
+
스캔: /home/me/.claude → 창고: /home/me/lshed
|
|
56
|
+
≡ package gstack github:garrytan/gstack@main @253d1df (참조만 기록)
|
|
57
|
+
≡ package claude-plugins-official claude-marketplace:anthropics/claude-plugins-official (참조만 기록)
|
|
58
|
+
≡ package exa claude-plugin:exa@claude-plugins-official @3.4.1 (참조만 기록)
|
|
59
|
+
· skills/browse (gstack 가 생성한 것 → 건너뜀)
|
|
60
|
+
· skills/review (gstack 가 생성한 것 → 건너뜀)
|
|
61
|
+
- skills/_gstack-command (--exclude)
|
|
62
|
+
+ skills/add-drivers
|
|
63
|
+
+ skills/domain-modeling
|
|
64
|
+
+ mcp/notion (시크릿 → ${NOTION_AUTHORIZATION})
|
|
65
|
+
+ instructions/main (CLAUDE.md)
|
|
66
|
+
|
|
67
|
+
lshed.yaml 생성: /home/me/lshed/lshed.yaml (부품 4개, 패키지 3개, 생성물 53개 건너뜀, 제외 2개, 프로필 "default")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`init` reads your agent root and writes only to the shed and `~/.claude/lshed/`. It sorts everything into [three kinds](#three-kinds-of-things): authored parts are copied (`+`), things you installed become packages recorded by source and version (`≡`), and files an installer generated are skipped (`·`). Aliases an installer created without symlinks look authored; leave them out with `--exclude`, and lshed remembers that under `exclude:` in the manifest.
|
|
71
|
+
|
|
72
|
+
Then open `lshed.yaml`. It has one profile, `default`, listing everything. Fill in `install:` for git packages that need a post-clone step, and make it a git repo:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
cd ~/lshed && git init && git remote add origin git@github.com:me/harness.git
|
|
76
|
+
lshed sync
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Every day: edit, save, sync
|
|
80
|
+
|
|
81
|
+
You edit skills where the agent reads them, in `~/.claude`. The shed does not change by itself.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
lshed status # what profile is applied, what drifted, what is new
|
|
85
|
+
lshed diff # file-level differences between ~/.claude and the shed
|
|
86
|
+
lshed save # copy local edits into the shed (or: lshed save skills/add-drivers)
|
|
87
|
+
lshed sync # commit the shed, pull, push
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`save` is the only path from `~/.claude` to the shed, and it only works for parts the shed owns (`file:` sources). `sync` warns if you have unsaved edits so you do not push a shed that is behind your machine.
|
|
91
|
+
|
|
92
|
+
### A new machine
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
git clone git@github.com:me/harness.git ~/lshed
|
|
96
|
+
lshed restore default --shed ~/lshed
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
+ package gstack (clone https://github.com/garrytan/gstack.git @main → 253d1df)
|
|
101
|
+
+ package claude-plugins-official (claude plugin marketplace add anthropics/claude-plugins-official)
|
|
102
|
+
+ package exa (claude plugin install exa@claude-plugins-official (전에 3.4.1; 고정은 안 됨))
|
|
103
|
+
+ skills/add-drivers
|
|
104
|
+
+ skills/domain-modeling
|
|
105
|
+
+ mcp:notion (${NOTION_AUTHORIZATION})
|
|
106
|
+
+ lshed/instructions/main.md
|
|
107
|
+
+ CLAUDE.md
|
|
108
|
+
|
|
109
|
+
프로필 "default" 적용: 배치 5, 제거 0, 패키지 설치 3
|
|
110
|
+
|
|
111
|
+
설치 명령 1개를 실행하지 않았습니다. 확인 후 '--yes' 로 다시 실행하거나 직접 돌리세요:
|
|
112
|
+
cd /home/me/.claude/skills/gstack && ./setup
|
|
113
|
+
|
|
114
|
+
환경변수가 없는 항목이 있습니다. 시크릿 값은 창고에 담지 않으므로 이 기기의 셸 환경에 넣으세요 (예: ~/.zshrc 의 export):
|
|
115
|
+
mcp:notion: NOTION_AUTHORIZATION
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Two things need you afterwards. Package `install:` commands are shell commands from a repository you cloned, so `restore` shows them and stops; run them yourself or rerun with `--yes`. MCP servers reference secrets as `${VAR}`; export the variables in your shell and Claude Code fills them in. From then on `lshed restore` with no arguments reapplies the last profile, and the shed location is remembered.
|
|
119
|
+
|
|
120
|
+
### Profiles
|
|
121
|
+
|
|
122
|
+
A profile is a list of ids per category. Add as many as you like to `lshed.yaml`:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
profiles:
|
|
126
|
+
default:
|
|
127
|
+
packages: [gstack, claude-plugins-official, exa]
|
|
128
|
+
skills: [add-drivers, domain-modeling, grilling]
|
|
129
|
+
instructions: [main]
|
|
130
|
+
mcp: [notion]
|
|
131
|
+
server: # headless box: no browser toolkit, no MCP
|
|
132
|
+
skills: [add-drivers]
|
|
133
|
+
instructions: [main, server-rules]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
lshed restore server
|
|
138
|
+
- skills/domain-modeling
|
|
139
|
+
- skills/grilling
|
|
140
|
+
- mcp:notion
|
|
141
|
+
= skills/add-drivers
|
|
142
|
+
~ CLAUDE.md
|
|
143
|
+
+ lshed/instructions/server-rules.md
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Switching removes only what the previous profile placed (`-`), keeps what both use (`=`), and rewrites what changed (`~`). Everything removed or overwritten goes to `~/.claude/lshed/backups/<timestamp>/` first. Packages are additive: a profile that does not list `gstack` leaves the clone alone. `--dry-run` prints this plan without touching anything.
|
|
147
|
+
|
|
148
|
+
Instructions fragments are ordered. `restore` writes a `CLAUDE.md` that `@`-imports each fragment, so editing a fragment in the shed shows up on the next `restore` and there is nothing to merge.
|
|
149
|
+
|
|
150
|
+
### Adding things later
|
|
151
|
+
|
|
152
|
+
Write a new skill, add an MCP server with `claude mcp add`, clone a toolkit into `~/.claude/skills/`. Then:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
$ lshed add
|
|
156
|
+
창고에 없는 항목 3개 (넣으려면 lshed add <key...> 또는 --all):
|
|
157
|
+
skills/paper-review
|
|
158
|
+
mcp/linear
|
|
159
|
+
≡ packages/superpowers github:obra/superpowers@main
|
|
160
|
+
· 패키지 gstack 가 생성한 것 53개는 담지 않습니다
|
|
161
|
+
|
|
162
|
+
$ lshed add paper-review mcp/linear
|
|
163
|
+
+ skills/paper-review
|
|
164
|
+
+ mcp/linear (시크릿 → ${LINEAR_API_KEY})
|
|
165
|
+
|
|
166
|
+
2개를 창고에 넣고 프로필 "default" 에 추가했습니다. 창고를 커밋하세요: /home/me/lshed
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`add` classifies exactly like `init`, appends to `lshed.yaml` without disturbing your comments, adds the parts to the current profile and to the managed set. Without keys it only lists. `status` shows the count as `창고 밖`. To put a part that is already in the shed into another profile, edit `profiles:` by hand; `add` tells you when that is the case.
|
|
170
|
+
|
|
171
|
+
### Keeping packages current
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
lshed status # shows "253d1df ≠ lock 0d1bd56 → lshed update" when a clone moved
|
|
175
|
+
lshed update # fast-forward every package in the profile, refresh lshed.lock
|
|
176
|
+
lshed update gstack --yes # one package, and run its install: afterwards
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Git packages are pinned by commit in `lshed.lock`; a new machine gets exactly that commit. Plugins cannot be pinned, so the lock records what got installed and `status` says when it differs from the machine you came from.
|
|
180
|
+
|
|
181
|
+
### Housekeeping
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
lshed list # everything in the shed and which profiles use it
|
|
185
|
+
lshed list --unused # parts no profile lists
|
|
186
|
+
lshed remove skills/old # delete from the shed (refused while a profile uses it)
|
|
187
|
+
lshed prune --yes # delete everything unused
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`remove` and `prune` delete from the shed without a backup; the shed lives in git, so commit before you prune.
|
|
191
|
+
|
|
192
|
+
### Reading the output
|
|
193
|
+
|
|
194
|
+
| Mark | Meaning |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `+` | placed / added |
|
|
197
|
+
| `=` | already identical, nothing done |
|
|
198
|
+
| `~` | existed with different content, replaced (backed up) |
|
|
199
|
+
| `-` | removed (backed up) or excluded |
|
|
200
|
+
| `≡` | package: recorded by source, not copied |
|
|
201
|
+
| `·` | generated by an installer, skipped |
|
|
202
|
+
| `!` | needs your attention |
|
|
203
|
+
| `↑` `↓` | pushed / pulled (sync), updated (update) |
|
|
204
|
+
|
|
205
|
+
Errors go to stderr with exit code 1. Everything else is on stdout.
|
|
206
|
+
|
|
48
207
|
## The manifest
|
|
49
208
|
|
|
50
|
-
`lshed.yaml` lives at the root of the shed. `init` generates it; edit it by hand from then on.
|
|
209
|
+
`lshed.yaml` lives at the root of the shed. `init` generates it; edit it by hand from then on. `add` and `remove` edit it for you and keep your comments.
|
|
51
210
|
|
|
52
211
|
```yaml
|
|
53
212
|
version: 1
|
|
54
213
|
agent: claude-code
|
|
214
|
+
exclude: [skills/_gstack-command] # things init/add must not pick up
|
|
215
|
+
ignore: [dist] # extra names never copied (adds to the built-in list)
|
|
55
216
|
|
|
56
217
|
components:
|
|
57
218
|
skills:
|
|
@@ -67,8 +228,15 @@ components:
|
|
|
67
228
|
mcp:
|
|
68
229
|
- id: exa # file:./mcp/exa.json — secrets replaced by ${VAR}
|
|
69
230
|
|
|
231
|
+
packages:
|
|
232
|
+
- id: gstack
|
|
233
|
+
source: github:garrytan/gstack@main
|
|
234
|
+
into: skills/gstack
|
|
235
|
+
install: ./setup
|
|
236
|
+
|
|
70
237
|
profiles:
|
|
71
238
|
research:
|
|
239
|
+
packages: [gstack]
|
|
72
240
|
skills: [paper-review]
|
|
73
241
|
agents: [reviewer]
|
|
74
242
|
instructions: [base, research-style] # order matters
|
|
@@ -79,25 +247,10 @@ profiles:
|
|
|
79
247
|
instructions: [base]
|
|
80
248
|
```
|
|
81
249
|
|
|
82
|
-
- Component `source` accepts `file:<path relative to the shed>`. Package `source` accepts `github:owner/repo@ref
|
|
250
|
+
- Component `source` accepts `file:<path relative to the shed>`. Package `source` accepts `github:owner/repo@ref`, `git:<url>#ref`, `claude-marketplace:<owner/repo>`, `claude-plugin:<name>@<marketplace>`.
|
|
83
251
|
- Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`, `mcp`.
|
|
84
|
-
- `ignore:`
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
## MCP servers and secrets
|
|
88
|
-
|
|
89
|
-
User-scope MCP servers live in `~/.claude.json`, next to machine IDs and session state. lshed treats each server as a component of category `mcp`: the shed holds `mcp/<name>.json`, and `restore` edits only the `mcpServers.<name>` key of `~/.claude.json`, leaving everything else in that file alone.
|
|
90
|
-
|
|
91
|
-
**No secret value enters the shed.** `init` replaces values under `env` and `headers` whose key looks like a secret (`key`, `token`, `secret`, `pass`, `auth`, `credential`, `cookie`, `session`) with a `${VAR}` placeholder:
|
|
92
|
-
|
|
93
|
-
```json
|
|
94
|
-
{ "type": "stdio", "command": "npx", "args": ["-y", "exa-mcp-server"],
|
|
95
|
-
"env": { "EXA_API_KEY": "${EXA_API_KEY}" } }
|
|
96
|
-
{ "type": "http", "url": "https://mcp.notion.com/mcp",
|
|
97
|
-
"headers": { "Authorization": "Bearer ${NOTION_AUTHORIZATION}" } }
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
`restore` writes the placeholder as is. Claude Code expands `${VAR}` from the environment when it starts the server, so the value only ever lives in your shell (`export EXA_API_KEY=...` in `~/.zshrc`, or however you manage secrets). `restore` and `status` list the variables the profile needs that are not set. The heuristic is a suggestion: edit the JSON in the shed to add or remove placeholders, and `init` warns when something in `args` or `url` looks like a token. `save` keeps existing placeholders and masks new secret-looking keys, so a rotated key never leaks into the shed by accident.
|
|
252
|
+
- `ignore:` adds to the built-in list of things never copied: `node_modules`, `.git`, `__pycache__`, `.venv`, cache directories, `*.log`. Build output like `dist/` is not ignored by default, since some skills ship it.
|
|
253
|
+
- `exclude:` lists parts that exist locally but must not enter the shed. `init --exclude` writes it.
|
|
101
254
|
|
|
102
255
|
## Three kinds of things
|
|
103
256
|
|
|
@@ -105,77 +258,71 @@ A real `~/.claude` mixes three kinds of content, and they need different handlin
|
|
|
105
258
|
|
|
106
259
|
| Kind | Example | What lshed does |
|
|
107
260
|
|---|---|---|
|
|
108
|
-
| **Authored** | a skill you wrote, your `CLAUDE.md
|
|
109
|
-
| **Installed** | a toolkit you `git clone`d, a plugin | records source + commit; `restore` clones it back |
|
|
261
|
+
| **Authored** | a skill you wrote, your `CLAUDE.md`, an MCP server you added | copies it into the shed |
|
|
262
|
+
| **Installed** | a toolkit you `git clone`d, a plugin | records source + commit; `restore` clones or installs it back |
|
|
110
263
|
| **Generated** | stub skills an installer wrote for you | skips them; they return when the installer runs |
|
|
111
264
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
```yaml
|
|
115
|
-
packages:
|
|
116
|
-
- id: gstack
|
|
117
|
-
source: github:garrytan/gstack@main # git:<url>#ref for other hosts
|
|
118
|
-
into: skills/gstack # where it lives under ~/.claude
|
|
119
|
-
install: ./setup # optional; run after clone, only with --yes
|
|
120
|
-
|
|
121
|
-
profiles:
|
|
122
|
-
default:
|
|
123
|
-
packages: [gstack]
|
|
124
|
-
skills: [add-drivers, domain-modeling]
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
`lshed.lock` pins each package to a commit, so a fresh machine gets the same version you had. `lshed update` moves it forward.
|
|
265
|
+
A directory with a `.git` and a remote becomes a **package**. A skill whose symlink points inside a package is treated as generated and skipped. Everything else is authored and copied. The rules that keep this safe:
|
|
128
266
|
|
|
129
|
-
|
|
267
|
+
- A package that is already present is never touched by `restore`. Your local checkout is yours.
|
|
268
|
+
- `install:` is a shell command. `restore` and `update` **print it and stop** unless you pass `--yes`. Plugin installs go through Claude Code's own package manager and run without it; `--yes` is forwarded as `-y` for plugins that declare an install command.
|
|
269
|
+
- Packages are not part of the managed set. Switching profiles never deletes a clone.
|
|
130
270
|
|
|
131
|
-
|
|
132
|
-
packages:
|
|
133
|
-
- id: claude-plugins-official
|
|
134
|
-
source: claude-marketplace:anthropics/claude-plugins-official
|
|
135
|
-
- id: exa
|
|
136
|
-
source: claude-plugin:exa@claude-plugins-official
|
|
137
|
-
```
|
|
271
|
+
Claude Code plugins are packages with their own scheme. `init` finds user-scope ones in `~/.claude/plugins`; `restore` adds the marketplace first, then runs `claude plugin install`. Project-scope plugins belong to their project and are not recorded.
|
|
138
272
|
|
|
139
|
-
|
|
273
|
+
## MCP servers and secrets
|
|
140
274
|
|
|
141
|
-
|
|
275
|
+
User-scope MCP servers live in `~/.claude.json`, next to machine IDs and session state. lshed treats each server as a component of category `mcp`: the shed holds `mcp/<name>.json`, and `restore` edits only the `mcpServers.<name>` key of `~/.claude.json`, leaving everything else in that file alone.
|
|
142
276
|
|
|
143
|
-
|
|
144
|
-
- `install:` is a shell command. `restore` and `update` **print it and stop** unless you pass `--yes`. Plugin installs go through Claude Code's own package manager and run without it; `--yes` is forwarded as `-y` for plugins that declare an install command.
|
|
145
|
-
- Packages are not part of the managed set. Switching profiles never deletes a clone.
|
|
146
|
-
- Installers sometimes create aliases without symlinks, which `init` cannot tell from authored skills. Leave those out with `--exclude`:
|
|
277
|
+
**No secret value enters the shed.** `init` and `add` replace values under `env` and `headers` whose key looks like a secret (`key`, `token`, `secret`, `pass`, `auth`, `credential`, `cookie`, `session`) with a `${VAR}` placeholder:
|
|
147
278
|
|
|
148
|
-
```
|
|
149
|
-
|
|
279
|
+
```json
|
|
280
|
+
{ "type": "stdio", "command": "npx", "args": ["-y", "exa-mcp-server"],
|
|
281
|
+
"env": { "EXA_API_KEY": "${EXA_API_KEY}" } }
|
|
282
|
+
{ "type": "http", "url": "https://mcp.notion.com/mcp",
|
|
283
|
+
"headers": { "Authorization": "Bearer ${NOTION_AUTHORIZATION}" } }
|
|
150
284
|
```
|
|
151
285
|
|
|
286
|
+
`restore` writes the placeholder as is. Claude Code expands `${VAR}` from the environment when it starts the server, so the value only ever lives in your shell (`export EXA_API_KEY=...` in `~/.zshrc`, or however you manage secrets). `restore` and `status` list the variables the profile needs that are not set. The heuristic is a suggestion: edit the JSON in the shed to add or remove placeholders, and `init` warns when something in `args` or `url` looks like a token. `save` keeps existing placeholders and masks new secret-looking keys, so a rotated key never leaks into the shed by accident. `diff` compares with placeholders as wildcards, so a machine holding real values is not drift.
|
|
287
|
+
|
|
152
288
|
## Commands
|
|
153
289
|
|
|
154
290
|
```
|
|
155
291
|
lshed init [--shed <dir>] [--profile <name>] [--exclude <id...>]
|
|
292
|
+
lshed add [keys...] [--all] put things that appeared since init into the shed
|
|
156
293
|
lshed restore [profile] [--dry-run] [--no-backup] [--yes]
|
|
157
|
-
lshed
|
|
158
|
-
lshed
|
|
159
|
-
lshed diff files that differ between local and shed
|
|
294
|
+
lshed status applied profile, drift, packages, missing env, new things
|
|
295
|
+
lshed diff files (or JSON keys) that differ between local and shed
|
|
160
296
|
lshed save [ids...] copy local edits back into the shed
|
|
297
|
+
lshed sync [-m <msg>] [--no-push] [--dry-run] commit the shed, pull --rebase, push
|
|
298
|
+
lshed update [ids...] [--dry-run] [--yes] pull packages forward, refresh lshed.lock
|
|
161
299
|
lshed list [--unused] what is in the shed, and which profiles use it
|
|
162
300
|
lshed remove <key> drop a component or package from the shed
|
|
163
301
|
lshed prune [--yes] drop everything no profile uses
|
|
164
302
|
```
|
|
165
303
|
|
|
166
|
-
|
|
304
|
+
Keys are `category/id`, or just `id` when unambiguous: `skills/paper-review`, `mcp/exa`, `packages/gstack`.
|
|
167
305
|
|
|
168
|
-
Global options: `--shed <dir>` (or `LSHED_HOME`; after the first restore lshed remembers it), `--root <dir>` (agent config root, default `~/.claude`).
|
|
306
|
+
Global options: `--shed <dir>` (or `LSHED_HOME`; after the first restore lshed remembers it), `--root <dir>` (agent config root, default `~/.claude` or `CLAUDE_CONFIG_DIR`).
|
|
169
307
|
|
|
170
308
|
### What `restore` does
|
|
171
309
|
|
|
172
|
-
0.
|
|
310
|
+
0. Installs any package in the profile that is missing, at the version in `lshed.lock`.
|
|
173
311
|
1. Removes paths that the **previous** profile placed and the new one doesn't need.
|
|
174
|
-
2. Copies every part of the new profile into place.
|
|
312
|
+
2. Copies every part of the new profile into place; writes every MCP entry into `~/.claude.json`.
|
|
175
313
|
3. Regenerates the instructions file.
|
|
176
314
|
|
|
177
315
|
Anything it overwrites or removes is backed up first under `~/.claude/lshed/backups/<timestamp>/`, unless you pass `--no-backup`. Files lshed never placed are left alone. `--dry-run` prints the plan and writes nothing.
|
|
178
316
|
|
|
317
|
+
### What `sync` does
|
|
318
|
+
|
|
319
|
+
1. Warns if `diff` shows local edits you have not saved.
|
|
320
|
+
2. Commits everything in the shed (message names the changed parts, or `-m`).
|
|
321
|
+
3. If `origin` exists: `git pull --rebase`, then `git push` (sets the upstream the first time).
|
|
322
|
+
4. If commits came in, tells you to run `lshed restore`.
|
|
323
|
+
|
|
324
|
+
On a conflict it aborts the rebase, leaves the shed clean with your commit intact, and tells you to resolve with git. Without a remote it only commits. It never runs `save` for you.
|
|
325
|
+
|
|
179
326
|
### Ownership
|
|
180
327
|
|
|
181
328
|
The shed is the source of truth for authored parts: `save` copies local edits back for `file:` components. Packages are owned by their upstream: `update` pulls them, `save` ignores them.
|
|
@@ -184,16 +331,17 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
|
|
|
184
331
|
|
|
185
332
|
```
|
|
186
333
|
<shed>/
|
|
187
|
-
lshed.yaml
|
|
188
|
-
|
|
189
|
-
|
|
334
|
+
lshed.yaml manifest
|
|
335
|
+
lshed.lock package versions (generated)
|
|
336
|
+
skills/<id>/ agents/<id>.md commands/<id>.md instructions/<id>.md
|
|
337
|
+
mcp/<id>.json secrets as ${VAR}
|
|
190
338
|
|
|
191
339
|
~/.claude/
|
|
192
340
|
skills/ agents/ commands/ CLAUDE.md ← placed by restore
|
|
193
|
-
~/.claude.json mcpServers.<id> ← one key per mcp component; the rest of the file is untouched
|
|
194
341
|
lshed/state.json ← which profile, which paths are managed
|
|
195
342
|
lshed/instructions/<id>.md ← fragments imported by CLAUDE.md
|
|
196
343
|
lshed/backups/<timestamp>/ ← whatever restore replaced
|
|
344
|
+
~/.claude.json mcpServers.<id> ← one key per mcp component; the rest of the file is untouched
|
|
197
345
|
```
|
|
198
346
|
|
|
199
347
|
`state.json` is per machine and is not part of the shed. If `CLAUDE_CONFIG_DIR` is set, lshed uses it as the root and expects `.claude.json` inside it, as Claude Code does.
|
|
@@ -201,16 +349,19 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
|
|
|
201
349
|
## Not in scope (yet)
|
|
202
350
|
|
|
203
351
|
- Secrets beyond "name the variable". Encrypted values, `op://` references and OS keychains are possible later; today lshed is deliberately no better than dotfiles here.
|
|
204
|
-
- Project-scope MCP servers (`.mcp.json`, `~/.claude.json` `projects.*`). They belong to the project.
|
|
352
|
+
- Project-scope MCP servers (`.mcp.json`, `~/.claude.json` `projects.*`) and project-scope plugins. They belong to the project.
|
|
205
353
|
- `settings.json` merging (hooks, permissions).
|
|
206
|
-
-
|
|
207
|
-
- Windows and macOS have not been tested. The code avoids platform-specific paths, but treat 0.1 as Linux/WSL.
|
|
354
|
+
- Windows and macOS have not been tested. The code avoids platform-specific paths, but treat this as Linux/WSL for now.
|
|
208
355
|
|
|
209
356
|
## Troubleshooting
|
|
210
357
|
|
|
211
358
|
- **"창고 위치를 모릅니다"** — pass `--shed <dir>` or set `LSHED_HOME`. After one successful `restore`, lshed remembers it.
|
|
212
359
|
- **restore replaced my `CLAUDE.md`** — it is in `~/.claude/lshed/backups/<timestamp>/CLAUDE.md`. Move its content into a fragment in the shed and add that fragment to your profile.
|
|
213
|
-
- **I edited a skill locally and want to keep it** — `lshed diff` to see, `lshed save <id>` to push it into the shed, then
|
|
360
|
+
- **I edited a skill locally and want to keep it** — `lshed diff` to see, `lshed save <id>` to push it into the shed, then `lshed sync`.
|
|
361
|
+
- **`status` says a package differs from the lock** — something updated the clone or plugin behind lshed's back (Claude Code auto-updates plugins). `lshed update` records the new version.
|
|
362
|
+
- **`status` keeps listing the same new things** — they are installer aliases or scratch. Add them to `exclude:` in `lshed.yaml`.
|
|
363
|
+
- **restore says an MCP variable is missing** — export it in your shell profile and restart Claude Code. The placeholder in `~/.claude.json` is correct; Claude Code fills it at startup.
|
|
364
|
+
- **sync stopped on a conflict** — `cd <shed> && git pull --rebase`, resolve, `git rebase --continue`, then `lshed sync` again.
|
|
214
365
|
|
|
215
366
|
## License
|
|
216
367
|
|
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { createRequire } from "module";
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import os3 from "os";
|
|
7
|
+
import path19 from "path";
|
|
8
8
|
|
|
9
9
|
// src/adapters/claude-code.ts
|
|
10
10
|
import { promises as fs4 } from "fs";
|
|
@@ -493,6 +493,8 @@ var ManifestSchema = z.object({
|
|
|
493
493
|
agent: z.string().default("claude-code"),
|
|
494
494
|
/** 창고에 담지 않을 이름들. 기본값(DEFAULT_IGNORE)에 더해진다. */
|
|
495
495
|
ignore: z.array(z.string()).optional(),
|
|
496
|
+
/** 로컬에 있어도 창고에 넣지 않을 부품 ("id" 또는 "category/id"). init --exclude 가 적고 add/status 가 따른다. */
|
|
497
|
+
exclude: z.array(z.string()).optional(),
|
|
496
498
|
components: ComponentsSchema.default({}),
|
|
497
499
|
packages: z.array(PackageSchema).default([]),
|
|
498
500
|
profiles: z.record(z.string(), ProfileSchema).default({})
|
|
@@ -558,12 +560,6 @@ function effectiveSource(category, c, kind = "dir") {
|
|
|
558
560
|
const ext = kind === "file" ? ".md" : kind === "entry" ? ".json" : "";
|
|
559
561
|
return c.source ?? `file:./${category}/${c.id}${ext}`;
|
|
560
562
|
}
|
|
561
|
-
function stringifyManifest(m) {
|
|
562
|
-
const out = { ...m };
|
|
563
|
-
if (!m.packages.length) delete out.packages;
|
|
564
|
-
if (!m.ignore?.length) delete out.ignore;
|
|
565
|
-
return YAML.stringify(out, { lineWidth: 0 });
|
|
566
|
-
}
|
|
567
563
|
function packagesOf(m, profile) {
|
|
568
564
|
const ids = m.profiles[profile]?.[PACKAGES] ?? [];
|
|
569
565
|
return ids.map((id) => m.packages.find((p) => p.id === id));
|
|
@@ -701,7 +697,8 @@ function abs(ctx, rel) {
|
|
|
701
697
|
|
|
702
698
|
// src/core/init.ts
|
|
703
699
|
import { promises as fs11 } from "fs";
|
|
704
|
-
import
|
|
700
|
+
import path16 from "path";
|
|
701
|
+
import YAML4 from "yaml";
|
|
705
702
|
|
|
706
703
|
// src/core/instructions.ts
|
|
707
704
|
import path10 from "path";
|
|
@@ -725,10 +722,6 @@ ${f.content.trimEnd()}
|
|
|
725
722
|
`).join("\n");
|
|
726
723
|
}
|
|
727
724
|
|
|
728
|
-
// src/core/packages.ts
|
|
729
|
-
import { promises as fs9 } from "fs";
|
|
730
|
-
import path12 from "path";
|
|
731
|
-
|
|
732
725
|
// src/lock.ts
|
|
733
726
|
import { promises as fs8 } from "fs";
|
|
734
727
|
import path11 from "path";
|
|
@@ -753,7 +746,12 @@ async function writeLock(shed, lock) {
|
|
|
753
746
|
await fs8.writeFile(path11.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
|
|
754
747
|
}
|
|
755
748
|
|
|
749
|
+
// src/core/discover.ts
|
|
750
|
+
import path13 from "path";
|
|
751
|
+
|
|
756
752
|
// src/core/packages.ts
|
|
753
|
+
import { promises as fs9 } from "fs";
|
|
754
|
+
import path12 from "path";
|
|
757
755
|
async function detectPackages(ctx, found) {
|
|
758
756
|
const out = [];
|
|
759
757
|
for (const inst of installersFor(ctx)) out.push(...await inst.detect(ctx, found));
|
|
@@ -868,9 +866,69 @@ async function updatePackages(ctx, pkgs, opts = {}) {
|
|
|
868
866
|
return res;
|
|
869
867
|
}
|
|
870
868
|
|
|
869
|
+
// src/core/discover.ts
|
|
870
|
+
var keyOf = (f) => `${f.category}/${f.id}`;
|
|
871
|
+
async function discover(ctx, exclude = []) {
|
|
872
|
+
const isExcluded = (cat, id) => exclude.some((e) => e === id || e === `${cat}/${id}`);
|
|
873
|
+
const excluded = [];
|
|
874
|
+
const items = [];
|
|
875
|
+
const all = await ctx.adapter.scan();
|
|
876
|
+
const pkgs = await detectPackages(ctx, all);
|
|
877
|
+
const kept = pkgs.filter((p) => {
|
|
878
|
+
if (isExcluded("packages", p.id)) {
|
|
879
|
+
excluded.push(`packages/${p.id}`);
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
return true;
|
|
883
|
+
});
|
|
884
|
+
const generated = await detectGenerated(all, kept);
|
|
885
|
+
for (const p of kept) items.push({ kind: "package", category: "packages", id: p.id, pkg: p });
|
|
886
|
+
for (const cat of ctx.adapter.categories()) {
|
|
887
|
+
for (const f of all.filter((f2) => f2.category === cat.name)) {
|
|
888
|
+
if (pkgs.some((p) => p.path === f.path) || generated.has(keyOf(f))) continue;
|
|
889
|
+
if (isExcluded(f.category, f.id)) {
|
|
890
|
+
excluded.push(keyOf(f));
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
items.push({ kind: "component", category: cat.name, id: f.id, path: f.path, cat });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
for (const cat of ctx.adapter.entries()) {
|
|
897
|
+
const all2 = await cat.read();
|
|
898
|
+
for (const id of Object.keys(all2).sort()) {
|
|
899
|
+
if (isExcluded(cat.name, id)) {
|
|
900
|
+
excluded.push(`${cat.name}/${id}`);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (!/^[\w.-]+$/.test(id)) {
|
|
904
|
+
ctx.log(` ! ${cat.name}/${id}: \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB294 \uBB38\uC790\uAC00 \uC788\uC5B4 \uAC74\uB108\uB700`);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
items.push({ kind: "entry", category: cat.name, id, value: all2[id], cat });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return { items, generated, excluded };
|
|
911
|
+
}
|
|
912
|
+
function notInManifest(m, d) {
|
|
913
|
+
return d.items.filter(
|
|
914
|
+
(f) => f.kind === "package" ? !m.packages.some((p) => p.id === f.id) : !(m.components[f.category] ?? []).some((c) => c.id === f.id)
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
function inManifestNotInProfile(m, profile, d) {
|
|
918
|
+
const p = m.profiles[profile] ?? {};
|
|
919
|
+
return d.items.filter((f) => !notInManifest(m, d).includes(f)).filter((f) => !(p[f.category] ?? []).includes(f.id)).map(keyOf);
|
|
920
|
+
}
|
|
921
|
+
function shortRev(rev) {
|
|
922
|
+
return /^[0-9a-f]{40}$/.test(rev) ? rev.slice(0, 7) : rev;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// src/core/ingest.ts
|
|
926
|
+
import path15 from "path";
|
|
927
|
+
import { isSeq, isMap } from "yaml";
|
|
928
|
+
|
|
871
929
|
// src/core/entries.ts
|
|
872
930
|
import { promises as fs10 } from "fs";
|
|
873
|
-
import
|
|
931
|
+
import path14 from "path";
|
|
874
932
|
var SECRET_KEY_RE = /key|token|secret|pass|auth|credential|cookie|session/i;
|
|
875
933
|
var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
876
934
|
function placeholdersIn(v) {
|
|
@@ -990,124 +1048,114 @@ async function readEntryFile(p) {
|
|
|
990
1048
|
}
|
|
991
1049
|
}
|
|
992
1050
|
async function writeEntryFile(p, v) {
|
|
993
|
-
await fs10.mkdir(
|
|
1051
|
+
await fs10.mkdir(path14.dirname(p), { recursive: true });
|
|
994
1052
|
await fs10.writeFile(p, JSON.stringify(v, null, 2) + "\n");
|
|
995
1053
|
}
|
|
996
1054
|
|
|
997
|
-
// src/core/
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
for (const f of found.filter((f2) => f2.category === cat.name && isExcluded(f2.category, f2.id))) {
|
|
1024
|
-
skipped.push(`${f.category}/${f.id}`);
|
|
1025
|
-
ctx.log(` - ${f.category}/${f.id} (--exclude)`);
|
|
1055
|
+
// src/core/ingest.ts
|
|
1056
|
+
function seqAt(doc, p) {
|
|
1057
|
+
let node = doc.getIn(p);
|
|
1058
|
+
if (!isSeq(node)) {
|
|
1059
|
+
node = doc.createNode([]);
|
|
1060
|
+
doc.setIn(p, node);
|
|
1061
|
+
}
|
|
1062
|
+
return node;
|
|
1063
|
+
}
|
|
1064
|
+
function pushUnique(seq, value) {
|
|
1065
|
+
if (!seq.items.some((it) => (isMap(it) ? it.get("id") : String(it)) === value)) seq.add(value);
|
|
1066
|
+
}
|
|
1067
|
+
async function ingest(ctx, doc, profile, items, lock) {
|
|
1068
|
+
const out = { managed: [], copied: 0, packages: [] };
|
|
1069
|
+
for (const f of items) {
|
|
1070
|
+
if (f.kind === "package") {
|
|
1071
|
+
const p = f.pkg;
|
|
1072
|
+
const node = doc.createNode(p.into ? { id: p.id, source: p.source, into: p.into } : { id: p.id, source: p.source });
|
|
1073
|
+
if (p.into) node.comment = " install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)";
|
|
1074
|
+
const seq = seqAt(doc, [PACKAGES]);
|
|
1075
|
+
if (!seq.items.some((it) => isMap(it) && it.get("id") === p.id)) seq.add(node);
|
|
1076
|
+
pushUnique(seqAt(doc, ["profiles", profile, PACKAGES]), p.id);
|
|
1077
|
+
lock.packages[p.id] = { source: p.source, rev: p.rev };
|
|
1078
|
+
out.packages.push(p.id);
|
|
1079
|
+
ctx.log(` \u2261 package ${p.id} ${p.source} @${shortRev(p.rev)} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
|
|
1080
|
+
continue;
|
|
1026
1081
|
}
|
|
1027
|
-
if (
|
|
1028
|
-
|
|
1029
|
-
m.profiles[profileName][cat.name] = [];
|
|
1030
|
-
for (const f of mine) {
|
|
1031
|
-
const dst = path14.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
|
|
1082
|
+
if (f.kind === "component") {
|
|
1083
|
+
const dst = path15.join(ctx.shed, f.cat.root, f.cat.kind === "dir" ? f.id : `${f.id}.md`);
|
|
1032
1084
|
await copyTree(f.path, dst, ignoreOf(ctx));
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
ctx.log(` + ${cat.name}/${f.id}`);
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
for (const cat of ctx.adapter.entries()) {
|
|
1041
|
-
const all2 = await cat.read();
|
|
1042
|
-
const ids = Object.keys(all2).sort();
|
|
1043
|
-
for (const id of ids.filter((id2) => isExcluded(cat.name, id2))) {
|
|
1044
|
-
skipped.push(`${cat.name}/${id}`);
|
|
1045
|
-
ctx.log(` - ${cat.name}/${id} (--exclude)`);
|
|
1046
|
-
}
|
|
1047
|
-
const mine = ids.filter((id) => !isExcluded(cat.name, id));
|
|
1048
|
-
if (!mine.length) continue;
|
|
1049
|
-
m.components[cat.name] = [];
|
|
1050
|
-
m.profiles[profileName][cat.name] = [];
|
|
1051
|
-
for (const id of mine) {
|
|
1052
|
-
if (!/^[\w.-]+$/.test(id)) {
|
|
1053
|
-
ctx.log(` ! ${cat.name}/${id}: \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB294 \uBB38\uC790\uAC00 \uC788\uC5B4 \uAC74\uB108\uB700`);
|
|
1054
|
-
continue;
|
|
1055
|
-
}
|
|
1056
|
-
const masked = mask(id, all2[id], cat);
|
|
1057
|
-
await writeEntryFile(path14.join(ctx.shed, cat.name, `${id}.json`), masked);
|
|
1058
|
-
m.components[cat.name].push({ id });
|
|
1059
|
-
m.profiles[profileName][cat.name].push(id);
|
|
1060
|
-
managed.push(targetRel(cat, id));
|
|
1061
|
-
copied++;
|
|
1085
|
+
ctx.log(` + ${f.category}/${f.id}`);
|
|
1086
|
+
} else {
|
|
1087
|
+
const masked = mask(f.id, f.value, f.cat);
|
|
1088
|
+
await writeEntryFile(path15.join(ctx.shed, f.category, `${f.id}.json`), masked);
|
|
1062
1089
|
const vars = placeholdersIn(masked);
|
|
1063
|
-
ctx.log(` + ${
|
|
1064
|
-
for (const where of suspiciousStrings(masked)) ctx.log(` ! ${where} \uAC00 \uC2DC\uD06C\uB9BF\uCC98\uB7FC \uBCF4\uC785\uB2C8\uB2E4. \uCC3D\uACE0\uC758 ${
|
|
1090
|
+
ctx.log(` + ${f.category}/${f.id}${vars.length ? ` (\uC2DC\uD06C\uB9BF \u2192 ${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
|
|
1091
|
+
for (const where of suspiciousStrings(masked)) ctx.log(` ! ${where} \uAC00 \uC2DC\uD06C\uB9BF\uCC98\uB7FC \uBCF4\uC785\uB2C8\uB2E4. \uCC3D\uACE0\uC758 ${f.category}/${f.id}.json \uC5D0\uC11C \${VAR} \uB85C \uBC14\uAFB8\uC138\uC694`);
|
|
1065
1092
|
}
|
|
1093
|
+
const comps = seqAt(doc, ["components", f.category]);
|
|
1094
|
+
if (!comps.items.some((it) => isMap(it) && it.get("id") === f.id)) comps.add(doc.createNode({ id: f.id }));
|
|
1095
|
+
pushUnique(seqAt(doc, ["profiles", profile, f.category]), f.id);
|
|
1096
|
+
out.managed.push(targetRel(f.cat, f.id));
|
|
1097
|
+
out.copied++;
|
|
1066
1098
|
}
|
|
1099
|
+
return out;
|
|
1100
|
+
}
|
|
1101
|
+
function tidy(doc) {
|
|
1102
|
+
const pk = doc.get(PACKAGES);
|
|
1103
|
+
if (isSeq(pk) && !pk.items.length) doc.delete(PACKAGES);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/core/init.ts
|
|
1107
|
+
var MANIFEST_HEADER = "# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed\n";
|
|
1108
|
+
async function init(ctx, opts = {}) {
|
|
1109
|
+
const profileName = opts.profile ?? "default";
|
|
1110
|
+
if (await exists(manifestPath(ctx))) {
|
|
1111
|
+
throw new Error(`\uC774\uBBF8 \uCD08\uAE30\uD654\uB41C \uCC3D\uACE0\uC785\uB2C8\uB2E4: ${manifestPath(ctx)}
|
|
1112
|
+
\uC774 \uD658\uACBD\uC5D0 \uC0C8\uB85C \uC0DD\uAE34 \uAC83\uC744 \uAE30\uC874 \uCC3D\uACE0\uC5D0 \uB123\uC73C\uB824\uBA74 'lshed add' \uB97C, \uB2E4\uB978 \uD658\uACBD\uC758 \uC124\uC815\uC744 \uAC00\uC838\uC624\uB824\uBA74 'lshed restore' \uD6C4 'lshed save' \uB97C \uC4F0\uC138\uC694.`);
|
|
1113
|
+
}
|
|
1114
|
+
const d = await discover(ctx, opts.exclude);
|
|
1115
|
+
for (const [key, by] of d.generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
|
|
1116
|
+
for (const key of d.excluded) ctx.log(` - ${key} (--exclude)`);
|
|
1117
|
+
const exclude = opts.exclude?.length ? { exclude: [...opts.exclude] } : {};
|
|
1118
|
+
const doc = new YAML4.Document({ version: 1, agent: ctx.adapter.name, ...exclude, components: {}, packages: [], profiles: { [profileName]: {} } });
|
|
1119
|
+
const lock = { version: 1, packages: {} };
|
|
1120
|
+
await fs11.mkdir(ctx.shed, { recursive: true });
|
|
1121
|
+
const res = await ingest(ctx, doc, profileName, d.items, lock);
|
|
1122
|
+
const managed = [...res.managed];
|
|
1123
|
+
let copied = res.copied;
|
|
1067
1124
|
const instr = instructionsFile(ctx);
|
|
1068
1125
|
if (await exists(instr)) {
|
|
1069
1126
|
const text = await fs11.readFile(instr, "utf8");
|
|
1070
1127
|
if (!isGenerated(text)) {
|
|
1071
|
-
const dst =
|
|
1072
|
-
await fs11.mkdir(
|
|
1128
|
+
const dst = path16.join(ctx.shed, INSTRUCTIONS, "main.md");
|
|
1129
|
+
await fs11.mkdir(path16.dirname(dst), { recursive: true });
|
|
1073
1130
|
await fs11.writeFile(dst, text);
|
|
1074
1131
|
const fragRel = targetRel(INSTRUCTIONS, "main");
|
|
1075
1132
|
await copyTree(dst, abs(ctx, fragRel), ignoreOf(ctx));
|
|
1076
1133
|
managed.push(fragRel);
|
|
1077
|
-
|
|
1078
|
-
|
|
1134
|
+
doc.setIn(["components", INSTRUCTIONS], [{ id: "main" }]);
|
|
1135
|
+
doc.setIn(["profiles", profileName, INSTRUCTIONS], ["main"]);
|
|
1079
1136
|
copied++;
|
|
1080
|
-
ctx.log(` + ${INSTRUCTIONS}/main (${
|
|
1137
|
+
ctx.log(` + ${INSTRUCTIONS}/main (${path16.basename(instr)})`);
|
|
1081
1138
|
}
|
|
1082
1139
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
yamlText = yamlText.replace(` into: ${p.into}
|
|
1088
|
-
`, ` into: ${p.into}
|
|
1089
|
-
# install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)
|
|
1090
|
-
`);
|
|
1091
|
-
}
|
|
1092
|
-
await fs11.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
|
|
1093
|
-
` + yamlText);
|
|
1094
|
-
if (pkgs.length) {
|
|
1095
|
-
await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, rev: p.rev }])) });
|
|
1096
|
-
}
|
|
1140
|
+
tidy(doc);
|
|
1141
|
+
const yamlText = MANIFEST_HEADER + doc.toString({ lineWidth: 0 });
|
|
1142
|
+
await fs11.writeFile(manifestPath(ctx), yamlText);
|
|
1143
|
+
if (res.packages.length) await writeLock(ctx.shed, lock);
|
|
1097
1144
|
await writeState(ctx.adapter, { profile: profileName, shed: ctx.shed, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1098
1145
|
const parts = [`\uBD80\uD488 ${copied}\uAC1C`];
|
|
1099
|
-
if (
|
|
1100
|
-
if (generated.size) parts.push(`\uC0DD\uC131\uBB3C ${generated.size}\uAC1C \uAC74\uB108\uB700`);
|
|
1101
|
-
if (
|
|
1146
|
+
if (res.packages.length) parts.push(`\uD328\uD0A4\uC9C0 ${res.packages.length}\uAC1C`);
|
|
1147
|
+
if (d.generated.size) parts.push(`\uC0DD\uC131\uBB3C ${d.generated.size}\uAC1C \uAC74\uB108\uB700`);
|
|
1148
|
+
if (d.excluded.length) parts.push(`\uC81C\uC678 ${d.excluded.length}\uAC1C`);
|
|
1102
1149
|
ctx.log(`
|
|
1103
1150
|
${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (${parts.join(", ")}, \uD504\uB85C\uD544 "${profileName}")`);
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1151
|
+
if (d.items.some((f) => f.kind === "package" && f.pkg.into)) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
|
|
1152
|
+
const manifest = parseManifest(yamlText);
|
|
1153
|
+
return { manifest, copied, skipped: d.excluded, packages: res.packages, generated: [...d.generated.keys()] };
|
|
1106
1154
|
}
|
|
1107
1155
|
|
|
1108
1156
|
// src/core/restore.ts
|
|
1109
1157
|
import { promises as fs12 } from "fs";
|
|
1110
|
-
import
|
|
1158
|
+
import path17 from "path";
|
|
1111
1159
|
async function restore(ctx, profileArg, opts = {}) {
|
|
1112
1160
|
const backup = opts.backup ?? true;
|
|
1113
1161
|
const state = await readState(ctx.adapter);
|
|
@@ -1126,7 +1174,7 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
1126
1174
|
const oldManaged = new Set(state?.managed ?? []);
|
|
1127
1175
|
const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
|
|
1128
1176
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1129
|
-
const backupDir =
|
|
1177
|
+
const backupDir = path17.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
|
|
1130
1178
|
const backedUp = [];
|
|
1131
1179
|
const placed = [];
|
|
1132
1180
|
const missingEnv = [];
|
|
@@ -1142,14 +1190,14 @@ async function restore(ctx, profileArg, opts = {}) {
|
|
|
1142
1190
|
if (cur === void 0) return;
|
|
1143
1191
|
backedUp.push(rel);
|
|
1144
1192
|
if (opts.dryRun || !backup) return;
|
|
1145
|
-
await writeEntryFile(
|
|
1193
|
+
await writeEntryFile(path17.join(backupDir, en.cat.name, `${en.id}.json`), cur);
|
|
1146
1194
|
return;
|
|
1147
1195
|
}
|
|
1148
1196
|
const from = abs(ctx, rel);
|
|
1149
1197
|
if (!await exists(from)) return;
|
|
1150
1198
|
backedUp.push(rel);
|
|
1151
1199
|
if (opts.dryRun || !backup) return;
|
|
1152
|
-
await copyTree(from,
|
|
1200
|
+
await copyTree(from, path17.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
|
|
1153
1201
|
}
|
|
1154
1202
|
for (const rel of toRemove) {
|
|
1155
1203
|
ctx.log(` - ${rel}`);
|
|
@@ -1260,10 +1308,71 @@ function formatDiff(diffs) {
|
|
|
1260
1308
|
return lines.join("\n");
|
|
1261
1309
|
}
|
|
1262
1310
|
|
|
1311
|
+
// src/core/add.ts
|
|
1312
|
+
import { promises as fs13 } from "fs";
|
|
1313
|
+
import YAML5 from "yaml";
|
|
1314
|
+
async function candidates(ctx, m, profile, d) {
|
|
1315
|
+
d ??= await discover(ctx, m.exclude);
|
|
1316
|
+
return { fresh: notInManifest(m, d), notInProfile: inManifestNotInProfile(m, profile, d), generated: d.generated };
|
|
1317
|
+
}
|
|
1318
|
+
async function add(ctx, keys = [], opts = {}) {
|
|
1319
|
+
const state = await readState(ctx.adapter);
|
|
1320
|
+
if (!state) throw new Error("\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 'lshed init' \uB610\uB294 'lshed restore <profile>' \uC744 \uC2E4\uD589\uD558\uC138\uC694.");
|
|
1321
|
+
const m = await loadManifest(ctx);
|
|
1322
|
+
const c = await candidates(ctx, m, state.profile);
|
|
1323
|
+
if (!keys.length && !opts.all) {
|
|
1324
|
+
if (!c.fresh.length) ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
1325
|
+
else {
|
|
1326
|
+
ctx.log(`\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uD56D\uBAA9 ${c.fresh.length}\uAC1C (\uB123\uC73C\uB824\uBA74 lshed add <key...> \uB610\uB294 --all):`);
|
|
1327
|
+
for (const f of c.fresh) ctx.log(` ${f.kind === "package" ? "\u2261" : " "} ${keyOf(f)}${f.kind === "package" ? ` ${f.pkg.source}` : ""}`);
|
|
1328
|
+
}
|
|
1329
|
+
hint(ctx, c, state.profile);
|
|
1330
|
+
return [];
|
|
1331
|
+
}
|
|
1332
|
+
let chosen = c.fresh;
|
|
1333
|
+
if (keys.length) {
|
|
1334
|
+
chosen = keys.map((raw) => {
|
|
1335
|
+
const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
|
|
1336
|
+
const hits = c.fresh.filter((f) => f.id === b && (a === void 0 || f.category === a));
|
|
1337
|
+
if (!hits.length) {
|
|
1338
|
+
const known = (m.components[a ?? ""] ?? []).some((x2) => x2.id === b) || Object.values(m.components).some((cs) => cs.some((x2) => x2.id === b)) || m.packages.some((p) => p.id === b);
|
|
1339
|
+
throw new Error(known ? `"${raw}" \uB294 \uC774\uBBF8 \uCC3D\uACE0\uC5D0 \uC788\uC2B5\uB2C8\uB2E4. \uD504\uB85C\uD544\uC5D0 \uB123\uC73C\uB824\uBA74 lshed.yaml \uC758 profiles \uB97C \uACE0\uCE58\uC138\uC694.` : `"${raw}" \uB294 \uB85C\uCEEC\uC5D0\uC11C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. 'lshed add' \uB85C \uD6C4\uBCF4\uB97C \uBCF4\uC138\uC694.`);
|
|
1340
|
+
}
|
|
1341
|
+
if (hits.length > 1) throw new Error(`"${raw}" \uAC00 \uBAA8\uD638\uD569\uB2C8\uB2E4: ${hits.map(keyOf).join(", ")}`);
|
|
1342
|
+
return hits[0];
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
if (!chosen.length) {
|
|
1346
|
+
ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
1347
|
+
hint(ctx, c, state.profile);
|
|
1348
|
+
return [];
|
|
1349
|
+
}
|
|
1350
|
+
const doc = YAML5.parseDocument(await fs13.readFile(manifestPath(ctx), "utf8"));
|
|
1351
|
+
const lock = await readLock(ctx.shed);
|
|
1352
|
+
const res = await ingest(ctx, doc, state.profile, chosen, lock);
|
|
1353
|
+
tidy(doc);
|
|
1354
|
+
await fs13.writeFile(manifestPath(ctx), doc.toString({ lineWidth: 0 }));
|
|
1355
|
+
if (res.packages.length) await writeLock(ctx.shed, lock);
|
|
1356
|
+
const managed = [.../* @__PURE__ */ new Set([...state.managed, ...res.managed])].sort();
|
|
1357
|
+
await writeState(ctx.adapter, { ...state, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1358
|
+
const added = chosen.map(keyOf);
|
|
1359
|
+
ctx.log(`
|
|
1360
|
+
${added.length}\uAC1C\uB97C \uCC3D\uACE0\uC5D0 \uB123\uACE0 \uD504\uB85C\uD544 "${state.profile}" \uC5D0 \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC138\uC694: ${ctx.shed}`);
|
|
1361
|
+
if (res.packages.some((id) => chosen.find((f) => f.id === id && f.kind === "package" && f.pkg.into))) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
|
|
1362
|
+
hint(ctx, { ...c, fresh: c.fresh.filter((f) => !chosen.includes(f)) }, state.profile);
|
|
1363
|
+
return added;
|
|
1364
|
+
}
|
|
1365
|
+
function hint(ctx, c, profile) {
|
|
1366
|
+
const byPkg = /* @__PURE__ */ new Map();
|
|
1367
|
+
for (const by of c.generated.values()) byPkg.set(by, (byPkg.get(by) ?? 0) + 1);
|
|
1368
|
+
for (const [by, n] of byPkg) ctx.log(` \xB7 \uD328\uD0A4\uC9C0 ${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 ${n}\uAC1C\uB294 \uB2F4\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
|
|
1369
|
+
if (c.notInProfile.length) ctx.log(`\uCC3D\uACE0\uC5D0\uB294 \uC788\uC9C0\uB9CC \uD504\uB85C\uD544 "${profile}" \uC774 \uC548 \uC4F0\uB294 \uAC83 ${c.notInProfile.length}\uAC1C: ${c.notInProfile.join(", ")} \u2192 lshed.yaml \uC758 profiles \uC5D0 \uCD94\uAC00`);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1263
1372
|
// src/core/status.ts
|
|
1264
1373
|
async function status(ctx) {
|
|
1265
1374
|
const state = await readState(ctx.adapter);
|
|
1266
|
-
if (!state) return { state: null, drifted: [], packages: [], missingEnv: [] };
|
|
1375
|
+
if (!state) return { state: null, drifted: [], packages: [], missingEnv: [], fresh: [] };
|
|
1267
1376
|
const d = await diff(ctx);
|
|
1268
1377
|
const m = await loadManifest(ctx);
|
|
1269
1378
|
const lock = await readLock(ctx.shed);
|
|
@@ -1274,7 +1383,8 @@ async function status(ctx) {
|
|
|
1274
1383
|
const missing = shed === null ? [] : expand(shed).missing;
|
|
1275
1384
|
if (missing.length) missingEnv.push({ rel: it.rel, vars: missing });
|
|
1276
1385
|
}
|
|
1277
|
-
|
|
1386
|
+
const fresh = (await candidates(ctx, m, state.profile)).fresh.map(keyOf);
|
|
1387
|
+
return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages, missingEnv, fresh };
|
|
1278
1388
|
}
|
|
1279
1389
|
function formatStatus(s, adapterRoot) {
|
|
1280
1390
|
if (!s.state) return `\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (${adapterRoot}).
|
|
@@ -1292,6 +1402,7 @@ function formatStatus(s, adapterRoot) {
|
|
|
1292
1402
|
lines.push(`\uD328\uD0A4\uC9C0 ${p.pkg.id} ${where}`);
|
|
1293
1403
|
}
|
|
1294
1404
|
for (const m of s.missingEnv) lines.push(`\uD658\uACBD\uBCC0\uC218 ${m.rel}: ${m.vars.join(", ")} \uC5C6\uC74C \u2192 \uC178\uC5D0\uC11C export \uD558\uC138\uC694`);
|
|
1405
|
+
if (s.fresh.length) lines.push(`\uCC3D\uACE0 \uBC16 ${s.fresh.length}\uAC1C: ${s.fresh.join(", ")} \u2192 lshed add`);
|
|
1295
1406
|
return lines.join("\n");
|
|
1296
1407
|
}
|
|
1297
1408
|
|
|
@@ -1371,9 +1482,9 @@ function formatRows(rows, m) {
|
|
|
1371
1482
|
}
|
|
1372
1483
|
|
|
1373
1484
|
// src/core/remove.ts
|
|
1374
|
-
import { promises as
|
|
1375
|
-
import
|
|
1376
|
-
import
|
|
1485
|
+
import { promises as fs14 } from "fs";
|
|
1486
|
+
import path18 from "path";
|
|
1487
|
+
import YAML6, { isSeq as isSeq2, isMap as isMap2 } from "yaml";
|
|
1377
1488
|
function resolveKey(m, raw) {
|
|
1378
1489
|
const rows = listRows(m);
|
|
1379
1490
|
const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
|
|
@@ -1387,13 +1498,13 @@ async function remove(ctx, raw) {
|
|
|
1387
1498
|
const { category, id } = resolveKey(m, raw);
|
|
1388
1499
|
const users = listRows(m).find((r) => r.category === category && r.id === id).usedBy;
|
|
1389
1500
|
if (users.length) throw new Error(`${category}/${id} \uB294 \uD504\uB85C\uD544 ${users.join(", ")} \uC774 \uC4F0\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 \uD504\uB85C\uD544\uC5D0\uC11C \uBE7C\uC138\uC694.`);
|
|
1390
|
-
const text = await
|
|
1391
|
-
const doc =
|
|
1501
|
+
const text = await fs14.readFile(manifestPath(ctx), "utf8");
|
|
1502
|
+
const doc = YAML6.parseDocument(text);
|
|
1392
1503
|
let deleted;
|
|
1393
1504
|
if (category === PACKAGES) {
|
|
1394
1505
|
const seq = doc.get(PACKAGES);
|
|
1395
|
-
if (!
|
|
1396
|
-
const idx = seq.items.findIndex((it) =>
|
|
1506
|
+
if (!isSeq2(seq)) throw new Error("packages \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4");
|
|
1507
|
+
const idx = seq.items.findIndex((it) => isMap2(it) && it.get("id") === id);
|
|
1397
1508
|
seq.delete(idx);
|
|
1398
1509
|
if (!seq.items.length) doc.delete(PACKAGES);
|
|
1399
1510
|
const lock = await readLock(ctx.shed);
|
|
@@ -1404,19 +1515,19 @@ async function remove(ctx, raw) {
|
|
|
1404
1515
|
ctx.log(` - package ${id} (\uB9E4\uB2C8\uD398\uC2A4\uD2B8\xB7\uB77D\uC5D0\uC11C \uC81C\uAC70. \uB85C\uCEEC clone \uC740 \uADF8\uB300\uB85C)`);
|
|
1405
1516
|
} else {
|
|
1406
1517
|
const seq = doc.getIn(["components", category]);
|
|
1407
|
-
if (!
|
|
1408
|
-
const idx = seq.items.findIndex((it) =>
|
|
1518
|
+
if (!isSeq2(seq)) throw new Error(`components.${category} \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
|
|
1519
|
+
const idx = seq.items.findIndex((it) => isMap2(it) && it.get("id") === id);
|
|
1409
1520
|
seq.delete(idx);
|
|
1410
1521
|
if (!seq.items.length) doc.deleteIn(["components", category]);
|
|
1411
1522
|
const src = sourcePath(ctx, category, findComponent(m, category, id));
|
|
1412
|
-
const inside = !
|
|
1523
|
+
const inside = !path18.relative(ctx.shed, src).startsWith("..");
|
|
1413
1524
|
if (inside && await exists(src)) {
|
|
1414
1525
|
await removeTree(src);
|
|
1415
1526
|
deleted = src;
|
|
1416
1527
|
}
|
|
1417
1528
|
ctx.log(` - ${category}/${id}${deleted ? "" : " (\uCC3D\uACE0 \uBC16 \uACBD\uB85C\uB77C \uD30C\uC77C\uC740 \uB450\uC5C8\uC74C)"}`);
|
|
1418
1529
|
}
|
|
1419
|
-
await
|
|
1530
|
+
await fs14.writeFile(manifestPath(ctx), doc.toString());
|
|
1420
1531
|
return { category, id, deleted };
|
|
1421
1532
|
}
|
|
1422
1533
|
async function prune(ctx, opts = {}) {
|
|
@@ -1441,21 +1552,105 @@ ${removed.length}\uAC1C \uC81C\uAC70. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC13
|
|
|
1441
1552
|
return removed;
|
|
1442
1553
|
}
|
|
1443
1554
|
|
|
1555
|
+
// src/core/sync.ts
|
|
1556
|
+
import os2 from "os";
|
|
1557
|
+
async function sync(ctx, opts = {}) {
|
|
1558
|
+
const shed = ctx.shed;
|
|
1559
|
+
const push = opts.push ?? true;
|
|
1560
|
+
if (!await isRepo(shed)) {
|
|
1561
|
+
throw new Error(`\uCC3D\uACE0\uAC00 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${shed}
|
|
1562
|
+
cd ${shed} && git init && git add -A && git commit -m "my harness"
|
|
1563
|
+
\uC6D0\uACA9\uC5D0 \uB450\uB824\uBA74: git remote add origin <url> && git push -u origin HEAD`);
|
|
1564
|
+
}
|
|
1565
|
+
const res = { committed: [], pulled: 0, pushed: false, unsaved: [] };
|
|
1566
|
+
if (await readState(ctx.adapter)) {
|
|
1567
|
+
try {
|
|
1568
|
+
res.unsaved = (await diff(ctx)).map((d) => `${d.item.category}/${d.item.id}`);
|
|
1569
|
+
} catch {
|
|
1570
|
+
}
|
|
1571
|
+
if (res.unsaved.length) ctx.log(` ! \uB85C\uCEEC \uD3B8\uC9D1 ${res.unsaved.length}\uAC1C\uAC00 \uCC3D\uACE0\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4: ${res.unsaved.join(", ")} \u2192 lshed save \uD6C4 \uB2E4\uC2DC sync`);
|
|
1572
|
+
}
|
|
1573
|
+
const dirty = (await git(["status", "--porcelain", "--untracked-files=all"], shed)).split("\n").filter(Boolean).map((l) => l.slice(3).trim());
|
|
1574
|
+
if (dirty.length) {
|
|
1575
|
+
const msg = opts.message ?? defaultMessage(dirty, (await readState(ctx.adapter))?.profile);
|
|
1576
|
+
ctx.log(` ${opts.dryRun ? "(dry-run) " : ""}commit ${dirty.length}\uAC1C: ${dirty.slice(0, 5).join(", ")}${dirty.length > 5 ? ` \uC678 ${dirty.length - 5}` : ""}`);
|
|
1577
|
+
if (!opts.dryRun) {
|
|
1578
|
+
await git(["add", "-A"], shed);
|
|
1579
|
+
await git(["commit", "--quiet", "-m", msg], shed);
|
|
1580
|
+
}
|
|
1581
|
+
res.committed = dirty;
|
|
1582
|
+
} else {
|
|
1583
|
+
ctx.log(" = \uCC3D\uACE0\uC5D0 \uCEE4\uBC0B\uD560 \uBCC0\uACBD \uC5C6\uC74C");
|
|
1584
|
+
}
|
|
1585
|
+
const remote = await git(["remote", "get-url", "origin"], shed).catch(() => null);
|
|
1586
|
+
if (!remote) {
|
|
1587
|
+
ctx.log(" \xB7 origin \uC774 \uC5C6\uC5B4 pull/push \uB294 \uAC74\uB108\uB700 (git remote add origin <url>)");
|
|
1588
|
+
return res;
|
|
1589
|
+
}
|
|
1590
|
+
if (opts.dryRun) {
|
|
1591
|
+
ctx.log(` (dry-run) pull --rebase, push \u2192 ${remote}`);
|
|
1592
|
+
return res;
|
|
1593
|
+
}
|
|
1594
|
+
const before = await git(["rev-parse", "HEAD"], shed);
|
|
1595
|
+
const branch2 = await git(["rev-parse", "--abbrev-ref", "HEAD"], shed);
|
|
1596
|
+
const hasUpstream = await git(["rev-parse", "--abbrev-ref", "@{upstream}"], shed).then(() => true, () => false);
|
|
1597
|
+
if (hasUpstream) {
|
|
1598
|
+
try {
|
|
1599
|
+
await git(["pull", "--rebase", "--quiet"], shed);
|
|
1600
|
+
} catch (e) {
|
|
1601
|
+
await git(["rebase", "--abort"], shed).catch(() => {
|
|
1602
|
+
});
|
|
1603
|
+
throw new Error(`pull \uC911 \uCDA9\uB3CC\uC774 \uB098\uC11C \uB418\uB3CC\uB838\uC2B5\uB2C8\uB2E4. \uCC3D\uACE0\uC5D0\uC11C \uC9C1\uC811 \uD574\uACB0\uD558\uC138\uC694:
|
|
1604
|
+
cd ${shed} && git pull --rebase
|
|
1605
|
+
(${firstLine(e.message)})`);
|
|
1606
|
+
}
|
|
1607
|
+
const after = await git(["rev-parse", "HEAD"], shed);
|
|
1608
|
+
if (after !== before) {
|
|
1609
|
+
const n = Number(await git(["rev-list", "--count", `${before}..${after}`], shed).catch(() => "0"));
|
|
1610
|
+
res.pulled = Math.max(0, n - (res.committed.length ? 1 : 0));
|
|
1611
|
+
if (res.pulled) ctx.log(` \u2193 \uC6D0\uACA9 \uCEE4\uBC0B ${res.pulled}\uAC1C \uBC1B\uC74C`);
|
|
1612
|
+
}
|
|
1613
|
+
} else {
|
|
1614
|
+
ctx.log(` \xB7 \uBE0C\uB79C\uCE58 ${branch2} \uC5D0 upstream \uC774 \uC5C6\uC5B4 pull \uC740 \uAC74\uB108\uB700`);
|
|
1615
|
+
}
|
|
1616
|
+
if (push) {
|
|
1617
|
+
const ahead = hasUpstream ? Number(await git(["rev-list", "--count", "@{upstream}..HEAD"], shed)) : 1;
|
|
1618
|
+
if (ahead > 0) {
|
|
1619
|
+
await git(hasUpstream ? ["push", "--quiet"] : ["push", "--quiet", "-u", "origin", branch2], shed);
|
|
1620
|
+
res.pushed = true;
|
|
1621
|
+
ctx.log(` \u2191 push ${hasUpstream ? `${ahead}\uAC1C \uCEE4\uBC0B` : `(upstream \uC124\uC815: origin/${branch2})`}`);
|
|
1622
|
+
} else {
|
|
1623
|
+
ctx.log(" = \uC6D0\uACA9\uACFC \uAC19\uC74C");
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (res.pulled) ctx.log(`
|
|
1627
|
+
\uCC3D\uACE0\uAC00 \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4. \uC774 \uAE30\uAE30\uC5D0 \uC801\uC6A9\uD558\uB824\uBA74: lshed restore`);
|
|
1628
|
+
return res;
|
|
1629
|
+
}
|
|
1630
|
+
function defaultMessage(paths, profile) {
|
|
1631
|
+
const parts = [...new Set(paths.map((p) => p.split("/").slice(0, 2).join("/")))];
|
|
1632
|
+
const head2 = parts.slice(0, 3).join(", ") + (parts.length > 3 ? ` +${parts.length - 3}` : "");
|
|
1633
|
+
return `lshed sync: ${head2}
|
|
1634
|
+
|
|
1635
|
+
${os2.hostname()}${profile ? ` \xB7 profile ${profile}` : ""}`;
|
|
1636
|
+
}
|
|
1637
|
+
var firstLine = (s) => s.split("\n").find((l) => l.trim() && !l.startsWith("Command failed")) ?? s;
|
|
1638
|
+
|
|
1444
1639
|
// src/cli.ts
|
|
1445
1640
|
var { version } = createRequire(import.meta.url)("../package.json");
|
|
1446
1641
|
var program = new Command().name("lshed").description("Keep your coding-agent harness (skills, agents, commands, instructions) in a shed and restore it anywhere by profile.").version(version).option("--shed <dir>", "shed directory (default: $LSHED_HOME, then the shed recorded by the last restore)").option("--root <dir>", "agent config root (default: ~/.claude)");
|
|
1447
1642
|
function adapterFromOpts() {
|
|
1448
1643
|
const { root } = program.opts();
|
|
1449
|
-
return new ClaudeCodeAdapter(root ?
|
|
1644
|
+
return new ClaudeCodeAdapter(root ? path19.resolve(root) : void 0);
|
|
1450
1645
|
}
|
|
1451
1646
|
async function ctxFor(cmd) {
|
|
1452
1647
|
const adapter = adapterFromOpts();
|
|
1453
1648
|
const { shed: flag } = program.opts();
|
|
1454
1649
|
let shed = flag ?? process.env.LSHED_HOME;
|
|
1455
1650
|
if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
|
|
1456
|
-
if (!shed && cmd === "init") shed =
|
|
1651
|
+
if (!shed && cmd === "init") shed = path19.join(os3.homedir(), "lshed");
|
|
1457
1652
|
if (!shed) throw new Error("\uCC3D\uACE0 \uC704\uCE58\uB97C \uBAA8\uB985\uB2C8\uB2E4. --shed <dir> \uB610\uB294 LSHED_HOME \uC744 \uC9C0\uC815\uD558\uC138\uC694.");
|
|
1458
|
-
return { adapter, shed:
|
|
1653
|
+
return { adapter, shed: path19.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
|
|
1459
1654
|
}
|
|
1460
1655
|
async function run(fn) {
|
|
1461
1656
|
try {
|
|
@@ -1500,7 +1695,7 @@ program.command("status").description("show the applied profile, managed paths a
|
|
|
1500
1695
|
const adapter = adapterFromOpts();
|
|
1501
1696
|
const state = await readState(adapter);
|
|
1502
1697
|
if (!state) {
|
|
1503
|
-
console.log(formatStatus({ state: null, drifted: [], packages: [], missingEnv: [] }, adapter.root));
|
|
1698
|
+
console.log(formatStatus({ state: null, drifted: [], packages: [], missingEnv: [], fresh: [] }, adapter.root));
|
|
1504
1699
|
return;
|
|
1505
1700
|
}
|
|
1506
1701
|
const ctx = await ctxFor("other");
|
|
@@ -1514,6 +1709,14 @@ program.command("save [ids...]").description("copy local edits back into the she
|
|
|
1514
1709
|
const ctx = await ctxFor("other");
|
|
1515
1710
|
await save(ctx, ids);
|
|
1516
1711
|
}));
|
|
1712
|
+
program.command("add [keys...]").description("put things that appeared locally since init into the shed and the current profile (lists candidates without keys)").option("--all", "add every candidate").action((keys, o) => run(async () => {
|
|
1713
|
+
const ctx = await ctxFor("other");
|
|
1714
|
+
await add(ctx, keys, { all: o.all });
|
|
1715
|
+
}));
|
|
1716
|
+
program.command("sync").description("commit the shed, pull --rebase and push (the shed must be a git repo with origin)").option("-m, --message <msg>", "commit message (default: names the changed parts)").option("--no-push", "commit and pull only").option("--dry-run", "show what would be committed and pushed").action((o) => run(async () => {
|
|
1717
|
+
const ctx = await ctxFor("other");
|
|
1718
|
+
await sync(ctx, { message: o.message, push: o.push, dryRun: o.dryRun });
|
|
1719
|
+
}));
|
|
1517
1720
|
program.command("list").description("everything in the shed and which profiles use it").option("--unused", "only things no profile uses").action((o) => run(async () => {
|
|
1518
1721
|
const ctx = await ctxFor("other");
|
|
1519
1722
|
const m = await loadManifest(ctx);
|