@markjaquith/agency 2.25.0 → 2.26.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/README.md CHANGED
@@ -379,14 +379,24 @@ conditions remain visible in `warnings` or `unresolved` with a suggested action.
379
379
  agency init [path] [--json]
380
380
  agency workbase add <path> [--name <name>] [--json]
381
381
  agency workbase list [--json]
382
+ agency workbase show <id|name|path> [--json]
383
+ agency workbase name <id|name|path> <name> [--json]
384
+ agency workbase name <id|name|path> --clear [--json]
382
385
  agency workbase remove <id|name|path> [--json]
383
386
  agency workbase prune [--json]
384
- agency workbase default [<id|name> | --clear] [--json]
387
+ agency workbase default [<id|name|path> | --clear] [--json]
385
388
  agency integration status [--json]
386
389
  agency integration sync [--json]
387
390
  agency repo add <alias> <remote> [--json]
388
391
  agency repo link <alias> <path> [--json]
389
392
  agency repo list [--json]
393
+ agency repo show <alias> [--json]
394
+ agency repo fetch <alias> [--json]
395
+ agency repo remove <alias> [--json]
396
+ agency repo unlink <alias> [--json]
397
+ agency repo rename <alias> <new-alias> [--json]
398
+ agency repo remote <alias> [remote] [--json]
399
+ agency repo verify <alias> [--json]
390
400
  ```
391
401
 
392
402
  Registered workbases are stored in
@@ -395,7 +405,9 @@ Each registration has a stable ID and may have a unique name. A default workbase
395
405
  is used when the current directory is outside every workbase. `prune` removes
396
406
  registrations whose workbase configuration no longer exists.
397
407
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
398
- repository. Alias names are then used by all documents and commands.
408
+ repository. Alias names are then used by all documents and commands. Remove,
409
+ unlink, and rename refuse aliases referenced by active work or backed by linked
410
+ worktrees, and report each blocker.
399
411
 
400
412
  Commands that print Agency-owned results accept `--json`, including initialization,
401
413
  integration inspection/sync, repository mutations, entity creation/list/show,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -65,7 +65,9 @@ Register and name known workbases so commands can select one from anywhere:
65
65
  ```bash
66
66
  agency workbase add <path> [--name <name>]
67
67
  agency workbase list
68
- agency workbase default [<id|name> | --clear]
68
+ agency workbase show <id|name|path>
69
+ agency workbase name <id|name|path> <name> | --clear
70
+ agency workbase default [<id|name|path> | --clear]
69
71
  agency workbase remove <id|name|path>
70
72
  agency workbase prune
71
73
  ```
@@ -85,6 +87,13 @@ Link an existing local Git repository:
85
87
 
86
88
  ```bash
87
89
  agency repo link <alias> <path>
90
+ agency repo show <alias>
91
+ agency repo fetch <alias>
92
+ agency repo remove <alias>
93
+ agency repo unlink <alias>
94
+ agency repo rename <alias> <new-alias>
95
+ agency repo remote <alias> [remote]
96
+ agency repo verify <alias>
88
97
  ```
89
98
 
90
99
  Use aliases, never absolute paths or Git URLs, in epic/task/phase frontmatter.
@@ -121,6 +121,23 @@ describe("strict CLI parsing", () => {
121
121
  expect(parseCli(["status", "--no-pr"]).values["no-pr"]).toBe(true)
122
122
  })
123
123
 
124
+ test("parses addressable resource maintenance commands", () => {
125
+ for (const args of [
126
+ ["repo", "show", "agency", "--json"],
127
+ ["repo", "fetch", "agency"],
128
+ ["repo", "remove", "agency"],
129
+ ["repo", "unlink", "agency"],
130
+ ["repo", "rename", "agency", "renamed"],
131
+ ["repo", "remote", "agency", "https://example.com/repo.git"],
132
+ ["repo", "verify", "agency"],
133
+ ["workbase", "show", "primary", "--json"],
134
+ ["workbase", "name", "primary", "renamed"],
135
+ ["workbase", "name", "primary", "--clear"],
136
+ ]) {
137
+ expect(() => parseCli(args)).not.toThrow()
138
+ }
139
+ })
140
+
124
141
  test("validates view filter values and conflicts", () => {
125
142
  expect(() => parseCli(["epic", "list", "--status", "invalid"])).toThrow(
126
143
  "Invalid '--status' value",
package/src/cli-parser.ts CHANGED
@@ -127,7 +127,7 @@ const commands = {
127
127
  },
128
128
  },
129
129
  workbase: {
130
- usage: "agency workbase <add|list|remove|prune|default>",
130
+ usage: "agency workbase <add|list|show|name|remove|prune|default>",
131
131
  options: {
132
132
  ...outputOptions,
133
133
  name: { type: "string" },
@@ -146,6 +146,18 @@ const commands = {
146
146
  maxArgs: 0,
147
147
  options: ["json"],
148
148
  },
149
+ show: {
150
+ usage: "agency workbase show <selector> [--json]",
151
+ minArgs: 1,
152
+ maxArgs: 1,
153
+ options: ["json"],
154
+ },
155
+ name: {
156
+ usage: "agency workbase name <selector> <name> | --clear [--json]",
157
+ minArgs: 1,
158
+ maxArgs: 2,
159
+ options: ["json", "clear"],
160
+ },
149
161
  remove: {
150
162
  usage: "agency workbase remove <selector> [--json]",
151
163
  minArgs: 1,
@@ -186,7 +198,8 @@ const commands = {
186
198
  },
187
199
  },
188
200
  repo: {
189
- usage: "agency repo <add|link|list>",
201
+ usage:
202
+ "agency repo <add|link|list|show|fetch|remove|unlink|rename|remote|verify>",
190
203
  options: outputOptions,
191
204
  subcommands: {
192
205
  add: {
@@ -207,6 +220,48 @@ const commands = {
207
220
  maxArgs: 0,
208
221
  options: ["json"],
209
222
  },
223
+ show: {
224
+ usage: "agency repo show <alias> [--json]",
225
+ minArgs: 1,
226
+ maxArgs: 1,
227
+ options: ["json"],
228
+ },
229
+ fetch: {
230
+ usage: "agency repo fetch <alias> [--json]",
231
+ minArgs: 1,
232
+ maxArgs: 1,
233
+ options: ["json"],
234
+ },
235
+ remove: {
236
+ usage: "agency repo remove <alias> [--json]",
237
+ minArgs: 1,
238
+ maxArgs: 1,
239
+ options: ["json"],
240
+ },
241
+ unlink: {
242
+ usage: "agency repo unlink <alias> [--json]",
243
+ minArgs: 1,
244
+ maxArgs: 1,
245
+ options: ["json"],
246
+ },
247
+ rename: {
248
+ usage: "agency repo rename <alias> <new-alias> [--json]",
249
+ minArgs: 2,
250
+ maxArgs: 2,
251
+ options: ["json"],
252
+ },
253
+ remote: {
254
+ usage: "agency repo remote <alias> [remote] [--json]",
255
+ minArgs: 1,
256
+ maxArgs: 2,
257
+ options: ["json"],
258
+ },
259
+ verify: {
260
+ usage: "agency repo verify <alias> [--json]",
261
+ minArgs: 1,
262
+ maxArgs: 1,
263
+ options: ["json"],
264
+ },
210
265
  },
211
266
  },
212
267
  epic: {
@@ -52,6 +52,21 @@ describe("repo command", () => {
52
52
  ])
53
53
  })
54
54
 
55
+ test("shows a repository by alias as JSON", async () => {
56
+ const logs = await captureLogs(() =>
57
+ runTestEffect(
58
+ repo({
59
+ subcommand: "show",
60
+ args: ["agency"],
61
+ cwd: root,
62
+ json: true,
63
+ }),
64
+ ),
65
+ )
66
+
67
+ expect(JSON.parse(logs[0]!).alias).toBe("agency")
68
+ })
69
+
55
70
  test("outputs a linked repository as JSON", async () => {
56
71
  const target = join(root, "source")
57
72
  await mkdir(target)
@@ -77,4 +92,33 @@ describe("repo command", () => {
77
92
  path: join(root, "repos/linked"),
78
93
  })
79
94
  })
95
+
96
+ test("unlinks a linked repository by alias", async () => {
97
+ const target = join(root, "unlink-source")
98
+ await mkdir(target)
99
+ const git = Bun.spawn(["git", "init", target], {
100
+ stdout: "ignore",
101
+ stderr: "ignore",
102
+ })
103
+ expect(await git.exited).toBe(0)
104
+ await runTestEffect(
105
+ repo({
106
+ subcommand: "link",
107
+ args: ["linked", target],
108
+ cwd: root,
109
+ silent: true,
110
+ }),
111
+ )
112
+
113
+ await runTestEffect(
114
+ repo({
115
+ subcommand: "unlink",
116
+ args: ["linked"],
117
+ cwd: root,
118
+ silent: true,
119
+ }),
120
+ )
121
+
122
+ expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
123
+ })
80
124
  })
@@ -9,6 +9,9 @@ interface RepoOptions extends BaseCommandOptions {
9
9
  readonly json?: boolean
10
10
  }
11
11
 
12
+ const requireArg = (args: readonly string[], index: number, usage: string) =>
13
+ args[index] ? Effect.succeed(args[index]) : Effect.fail(new Error(usage))
14
+
12
15
  export const repo = (options: RepoOptions) =>
13
16
  Effect.gen(function* () {
14
17
  const repositories = yield* RepositoryService
@@ -61,10 +64,106 @@ export const repo = (options: RepoOptions) =>
61
64
  return
62
65
  }
63
66
 
67
+ case "show": {
68
+ const alias = yield* requireArg(
69
+ options.args,
70
+ 0,
71
+ "Usage: agency repo show <alias>",
72
+ )
73
+ const item = yield* repositories.show(alias, cwd)
74
+ log(
75
+ options.json
76
+ ? JSON.stringify(item, null, 2)
77
+ : `${item.alias}\t${item.kind}\t${item.target ?? item.remote ?? item.path}`,
78
+ )
79
+ return
80
+ }
81
+
82
+ case "fetch": {
83
+ const alias = yield* requireArg(
84
+ options.args,
85
+ 0,
86
+ "Usage: agency repo fetch <alias>",
87
+ )
88
+ const item = yield* repositories.fetch(alias, cwd)
89
+ log(options.json ? JSON.stringify(item, null, 2) : `Fetched '${alias}'`)
90
+ return
91
+ }
92
+
93
+ case "remove":
94
+ case "unlink": {
95
+ const alias = yield* requireArg(
96
+ options.args,
97
+ 0,
98
+ `Usage: agency repo ${options.subcommand} <alias>`,
99
+ )
100
+ const item = yield* repositories[options.subcommand](alias, cwd)
101
+ log(
102
+ options.json
103
+ ? JSON.stringify(item, null, 2)
104
+ : `${options.subcommand === "unlink" ? "Unlinked" : "Removed"} '${alias}'`,
105
+ )
106
+ return
107
+ }
108
+
109
+ case "rename": {
110
+ const alias = yield* requireArg(
111
+ options.args,
112
+ 0,
113
+ "Usage: agency repo rename <alias> <new-alias>",
114
+ )
115
+ const newAlias = yield* requireArg(
116
+ options.args,
117
+ 1,
118
+ "Usage: agency repo rename <alias> <new-alias>",
119
+ )
120
+ const item = yield* repositories.rename(alias, newAlias, cwd)
121
+ log(
122
+ options.json
123
+ ? JSON.stringify(item, null, 2)
124
+ : `Renamed '${alias}' to '${newAlias}'`,
125
+ )
126
+ return
127
+ }
128
+
129
+ case "remote": {
130
+ const alias = yield* requireArg(
131
+ options.args,
132
+ 0,
133
+ "Usage: agency repo remote <alias> [remote]",
134
+ )
135
+ const item = yield* repositories.remote(alias, options.args[1], cwd)
136
+ log(
137
+ options.json
138
+ ? JSON.stringify(item, null, 2)
139
+ : (item.remote ?? "No origin remote configured"),
140
+ )
141
+ return
142
+ }
143
+
144
+ case "verify": {
145
+ const alias = yield* requireArg(
146
+ options.args,
147
+ 0,
148
+ "Usage: agency repo verify <alias>",
149
+ )
150
+ const report = yield* repositories.verify(alias, cwd)
151
+ if (options.json) log(JSON.stringify(report, null, 2))
152
+ if (!report.valid) {
153
+ return yield* Effect.fail(
154
+ new Error(
155
+ `Repository '${alias}' verification failed:\n${report.issues.map((issue) => `- ${issue}`).join("\n")}`,
156
+ ),
157
+ )
158
+ }
159
+ if (!options.json) log(`Verified repository '${alias}'`)
160
+ return
161
+ }
162
+
64
163
  default:
65
164
  return yield* Effect.fail(
66
165
  new Error(
67
- "Subcommand is required. Available subcommands: add, link, list",
166
+ "Subcommand is required. Available subcommands: add, link, list, show, fetch, remove, unlink, rename, remote, verify",
68
167
  ),
69
168
  )
70
169
  }
@@ -77,6 +176,13 @@ Subcommands:
77
176
  add <alias> <remote> Create a bare clone
78
177
  link <alias> <path> Link an existing Git repository
79
178
  list List repository aliases
179
+ show <alias> Show a repository alias
180
+ fetch <alias> Fetch and prune a repository
181
+ remove <alias> Remove an unused repository alias
182
+ unlink <alias> Remove an unused linked alias
183
+ rename <old> <new> Rename an unused repository alias
184
+ remote <alias> [url] Show or update the origin remote
185
+ verify <alias> Verify repository operation
80
186
 
81
187
  Options:
82
188
  --json Output repository aliases as JSON
@@ -108,6 +108,53 @@ describe("workbase command", () => {
108
108
  ).toEqual({ version: 2, workbases: [] })
109
109
  })
110
110
 
111
+ test("names, shows, and clears a workbase name", async () => {
112
+ const added = await captureLogs(() =>
113
+ runTestEffect(
114
+ workbase({
115
+ subcommand: "add",
116
+ args: [root],
117
+ configDirectory,
118
+ json: true,
119
+ }),
120
+ ),
121
+ )
122
+ const registration = JSON.parse(added[0]!)
123
+ await runTestEffect(
124
+ workbase({
125
+ subcommand: "name",
126
+ args: [registration.id, "primary"],
127
+ configDirectory,
128
+ silent: true,
129
+ }),
130
+ )
131
+
132
+ const shown = await captureLogs(() =>
133
+ runTestEffect(
134
+ workbase({
135
+ subcommand: "show",
136
+ args: ["primary"],
137
+ configDirectory,
138
+ json: true,
139
+ }),
140
+ ),
141
+ )
142
+ expect(JSON.parse(shown[0]!).name).toBe("primary")
143
+
144
+ await runTestEffect(
145
+ workbase({
146
+ subcommand: "name",
147
+ args: [registration.id],
148
+ clear: true,
149
+ configDirectory,
150
+ silent: true,
151
+ }),
152
+ )
153
+ expect(
154
+ await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
155
+ ).toEqual({ version: 2, workbases: [registration] })
156
+ })
157
+
111
158
  test("requires an add path", async () => {
112
159
  await expect(
113
160
  runTestEffect(
@@ -51,6 +51,54 @@ export const workbase = (options: WorkbaseOptions) =>
51
51
  }
52
52
  return
53
53
  }
54
+ case "show": {
55
+ const selector = options.args[0]
56
+ if (!selector) {
57
+ return yield* Effect.fail(
58
+ new Error("Usage: agency workbase show <selector>"),
59
+ )
60
+ }
61
+ const entry = yield* service.showRegistered(
62
+ selector,
63
+ options.configDirectory,
64
+ options.cwd,
65
+ )
66
+ log(
67
+ options.json
68
+ ? JSON.stringify(entry, null, 2)
69
+ : `${entry.name ?? entry.id}\t${entry.path}`,
70
+ )
71
+ return
72
+ }
73
+ case "name": {
74
+ const selector = options.args[0]
75
+ const name = options.clear ? null : options.args[1]
76
+ if (
77
+ !selector ||
78
+ name === undefined ||
79
+ (options.clear && options.args[1] !== undefined)
80
+ ) {
81
+ return yield* Effect.fail(
82
+ new Error(
83
+ "Usage: agency workbase name <selector> <name> | --clear",
84
+ ),
85
+ )
86
+ }
87
+ const entry = yield* service.nameRegistered(
88
+ selector,
89
+ name,
90
+ options.configDirectory,
91
+ options.cwd,
92
+ )
93
+ log(
94
+ options.json
95
+ ? JSON.stringify(entry, null, 2)
96
+ : name === null
97
+ ? `Cleared name for ${entry.id}`
98
+ : `Named workbase ${name}`,
99
+ )
100
+ return
101
+ }
54
102
  case "remove": {
55
103
  const entry = yield* service.removeRegistered(
56
104
  options.args[0]!,
@@ -89,6 +137,7 @@ export const workbase = (options: WorkbaseOptions) =>
89
137
  const entry = yield* service.setDefault(
90
138
  selector,
91
139
  options.configDirectory,
140
+ options.cwd,
92
141
  )
93
142
  log(
94
143
  options.json
@@ -102,7 +151,7 @@ export const workbase = (options: WorkbaseOptions) =>
102
151
  default:
103
152
  return yield* Effect.fail(
104
153
  new Error(
105
- "Subcommand is required. Available: add, list, remove, prune, default",
154
+ "Subcommand is required. Available: add, list, show, name, remove, prune, default",
106
155
  ),
107
156
  )
108
157
  }
@@ -114,6 +163,8 @@ Usage: agency workbase <subcommand>
114
163
  Subcommands:
115
164
  add <path> Register an Agency workbase
116
165
  list List registered workbases
166
+ show <selector> Show a registered workbase
167
+ name <selector> Set or clear a registered workbase name
117
168
  remove <selector> Remove a registered workbase
118
169
  prune Remove registrations whose paths no longer exist
119
170
  default [selector] Show or set the default workbase
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { mkdir } from "node:fs/promises"
4
- import { join } from "node:path"
4
+ import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
7
7
 
@@ -16,6 +16,12 @@ const runGit = async (args: string[]) => {
16
16
  }
17
17
  }
18
18
 
19
+ const write = async (root: string, path: string, content: string) => {
20
+ const fullPath = join(root, path)
21
+ await mkdir(dirname(fullPath), { recursive: true })
22
+ await Bun.write(fullPath, content)
23
+ }
24
+
19
25
  describe("RepositoryService", () => {
20
26
  let root: string
21
27
 
@@ -103,4 +109,126 @@ describe("RepositoryService", () => {
103
109
  ),
104
110
  ).rejects.toThrow("already exists")
105
111
  })
112
+
113
+ test("shows, fetches, updates, and verifies a repository", async () => {
114
+ const source = join(root, "source")
115
+ const replacement = join(root, "replacement.git")
116
+ await runGit(["init", "--initial-branch=main", source])
117
+ await Bun.write(join(source, "README.md"), "# Source\n")
118
+ await runGit(["-C", source, "add", "README.md"])
119
+ await runGit([
120
+ "-C",
121
+ source,
122
+ "-c",
123
+ "user.name=Agency Tests",
124
+ "-c",
125
+ "user.email=agency@example.com",
126
+ "commit",
127
+ "-m",
128
+ "Initial commit",
129
+ ])
130
+ await runGit(["init", "--bare", "--initial-branch=main", replacement])
131
+ await runTestEffect(
132
+ RepositoryService.pipe(
133
+ Effect.flatMap((service) => service.add("agency", source, root)),
134
+ ),
135
+ )
136
+
137
+ const result = await runTestEffect(
138
+ RepositoryService.pipe(
139
+ Effect.flatMap((service) =>
140
+ Effect.gen(function* () {
141
+ const shown = yield* service.show("agency", root)
142
+ yield* service.fetch("agency", root)
143
+ const updated = yield* service.remote("agency", replacement, root)
144
+ const verified = yield* service.verify("agency", root)
145
+ return { shown, updated, verified }
146
+ }),
147
+ ),
148
+ ),
149
+ )
150
+
151
+ expect(result.shown.remote).toBe(source)
152
+ expect(result.updated.remote).toBe(replacement)
153
+ expect(result.verified.valid).toBe(true)
154
+ expect(result.verified.issues).toEqual([])
155
+ })
156
+
157
+ test("renames and removes an unused repository", async () => {
158
+ const source = join(root, "source.git")
159
+ await runGit(["init", "--bare", "--initial-branch=main", source])
160
+ await runTestEffect(
161
+ RepositoryService.pipe(
162
+ Effect.flatMap((service) => service.add("old", source, root)),
163
+ ),
164
+ )
165
+
166
+ const removed = await runTestEffect(
167
+ RepositoryService.pipe(
168
+ Effect.flatMap((service) =>
169
+ Effect.gen(function* () {
170
+ const renamed = yield* service.rename("old", "new", root)
171
+ expect(renamed.alias).toBe("new")
172
+ return yield* service.remove("new", root)
173
+ }),
174
+ ),
175
+ ),
176
+ )
177
+
178
+ expect(removed.alias).toBe("new")
179
+ expect(await Bun.file(join(root, "repos/new/HEAD")).exists()).toBe(false)
180
+ })
181
+
182
+ test("unlinks a symlink without deleting its target", async () => {
183
+ const target = join(root, "linked-repository")
184
+ await mkdir(target, { recursive: true })
185
+ await runGit(["init", "--initial-branch=main", target])
186
+ await runTestEffect(
187
+ RepositoryService.pipe(
188
+ Effect.flatMap((service) => service.link("linked", target, root)),
189
+ ),
190
+ )
191
+
192
+ await runTestEffect(
193
+ RepositoryService.pipe(
194
+ Effect.flatMap((service) => service.unlink("linked", root)),
195
+ ),
196
+ )
197
+
198
+ expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
199
+ expect(await Bun.file(join(root, "repos/linked/.git/HEAD")).exists()).toBe(
200
+ false,
201
+ )
202
+ })
203
+
204
+ test("reports active references and refuses unsafe removal", async () => {
205
+ const source = join(root, "source.git")
206
+ await runGit(["init", "--bare", "--initial-branch=main", source])
207
+ await runTestEffect(
208
+ RepositoryService.pipe(
209
+ Effect.flatMap((service) => service.add("agency", source, root)),
210
+ ),
211
+ )
212
+ await write(
213
+ root,
214
+ "tasks/active/TASK.md",
215
+ `---
216
+ ticketUrl: null
217
+ repo: agency
218
+ branch: task/active
219
+ base: main
220
+ pr: null
221
+ ---
222
+ `,
223
+ )
224
+
225
+ await expect(
226
+ runTestEffect(
227
+ RepositoryService.pipe(
228
+ Effect.flatMap((service) => service.remove("agency", root)),
229
+ ),
230
+ ),
231
+ ).rejects.toThrow("active reference execution-unit:task/active")
232
+ expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(true)
233
+ })
106
234
  })
@@ -2,6 +2,7 @@ import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
3
  import { join, resolve } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
+ import { GraphService } from "./GraphService"
5
6
  import { WorkbaseService } from "./WorkbaseService"
6
7
  import { RepositoryAlias } from "../workbase/schemas"
7
8
 
@@ -18,6 +19,11 @@ interface RepositoryInfo {
18
19
  readonly target: string | null
19
20
  }
20
21
 
22
+ interface RepositoryVerification extends RepositoryInfo {
23
+ readonly valid: boolean
24
+ readonly issues: readonly string[]
25
+ }
26
+
21
27
  const validateAlias = (alias: string) => {
22
28
  const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
23
29
  return Either.isLeft(result)
@@ -29,6 +35,65 @@ const validateAlias = (alias: string) => {
29
35
  : Effect.succeed(result.right)
30
36
  }
31
37
 
38
+ const find = (alias: string, startPath: string) =>
39
+ Effect.gen(function* () {
40
+ const service = yield* RepositoryService
41
+ const validAlias = yield* validateAlias(alias)
42
+ const repositories = yield* service.list(startPath)
43
+ const repository = repositories.find((item) => item.alias === validAlias)
44
+ if (!repository) {
45
+ return yield* new RepositoryError({
46
+ message: `Unknown repository alias '${validAlias}'`,
47
+ })
48
+ }
49
+ return repository
50
+ })
51
+
52
+ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
53
+ Effect.gen(function* () {
54
+ const fs = yield* FileSystemService
55
+ const graph = yield* GraphService
56
+ const report = yield* graph.get({ cwd: startPath })
57
+ const repositoryId = `repository:${repository.alias}`
58
+ const references = report.edges
59
+ .filter(
60
+ (edge) =>
61
+ edge.to === repositoryId &&
62
+ (edge.kind === "writes" || edge.kind === "references"),
63
+ )
64
+ .map((edge) => edge.from)
65
+ .sort()
66
+ const worktrees: string[] = []
67
+ if (repository.kind !== "symlink") {
68
+ const result = yield* fs.runCommand(
69
+ ["git", "-C", repository.path, "worktree", "list", "--porcelain"],
70
+ { captureOutput: true },
71
+ )
72
+ if (result.exitCode === 0) {
73
+ for (const block of result.stdout.trim().split(/\n\n+/)) {
74
+ if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
75
+ const path = block.match(/^worktree (.+)$/m)?.[1]
76
+ if (path) worktrees.push(path)
77
+ }
78
+ }
79
+ }
80
+ return { references, worktrees }
81
+ })
82
+
83
+ const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
84
+ Effect.gen(function* () {
85
+ const blockers = yield* removalBlockers(repository, startPath)
86
+ const details = [
87
+ ...blockers.references.map((item) => `active reference ${item}`),
88
+ ...blockers.worktrees.map((item) => `linked worktree ${item}`),
89
+ ]
90
+ if (details.length > 0) {
91
+ return yield* new RepositoryError({
92
+ message: `Repository alias '${repository.alias}' is in use and cannot be removed or renamed:\n${details.map((item) => `- ${item}`).join("\n")}`,
93
+ })
94
+ }
95
+ })
96
+
32
97
  export class RepositoryService extends Effect.Service<RepositoryService>()(
33
98
  "RepositoryService",
34
99
  {
@@ -154,6 +219,122 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
154
219
 
155
220
  return repositories
156
221
  }),
222
+
223
+ show: (alias: string, startPath: string = process.cwd()) =>
224
+ find(alias, startPath),
225
+
226
+ fetch: (alias: string, startPath: string = process.cwd()) =>
227
+ Effect.gen(function* () {
228
+ const fs = yield* FileSystemService
229
+ const repository = yield* find(alias, startPath)
230
+ const result = yield* fs.runCommand(
231
+ ["git", "-C", repository.path, "fetch", "--prune"],
232
+ { captureOutput: true },
233
+ )
234
+ if (result.exitCode !== 0) {
235
+ return yield* new RepositoryError({
236
+ message: `Failed to fetch repository '${alias}': ${result.stderr.trim()}`,
237
+ })
238
+ }
239
+ return repository
240
+ }),
241
+
242
+ remove: (alias: string, startPath: string = process.cwd()) =>
243
+ Effect.gen(function* () {
244
+ const fs = yield* FileSystemService
245
+ const repository = yield* find(alias, startPath)
246
+ yield* assertRemovable(repository, startPath)
247
+ yield* fs.deleteDirectory(repository.path)
248
+ return repository
249
+ }),
250
+
251
+ unlink: (alias: string, startPath: string = process.cwd()) =>
252
+ Effect.gen(function* () {
253
+ const service = yield* RepositoryService
254
+ const repository = yield* find(alias, startPath)
255
+ if (repository.kind !== "symlink") {
256
+ return yield* new RepositoryError({
257
+ message: `Repository alias '${alias}' is not a link; use 'agency repo remove ${alias}'`,
258
+ })
259
+ }
260
+ return yield* service.remove(alias, startPath)
261
+ }),
262
+
263
+ rename: (
264
+ alias: string,
265
+ newAlias: string,
266
+ startPath: string = process.cwd(),
267
+ ) =>
268
+ Effect.gen(function* () {
269
+ const fs = yield* FileSystemService
270
+ const workbase = yield* WorkbaseService
271
+ const repository = yield* find(alias, startPath)
272
+ const validNewAlias = yield* validateAlias(newAlias)
273
+ const root = yield* workbase.discover(startPath)
274
+ const destination = join(root, "repos", validNewAlias)
275
+ if (yield* fs.exists(destination)) {
276
+ return yield* new RepositoryError({
277
+ message: `Repository alias '${validNewAlias}' already exists`,
278
+ })
279
+ }
280
+ yield* assertRemovable(repository, startPath)
281
+ yield* fs.moveDirectory(repository.path, destination)
282
+ return yield* find(validNewAlias, startPath)
283
+ }),
284
+
285
+ remote: (
286
+ alias: string,
287
+ remote: string | undefined,
288
+ startPath: string = process.cwd(),
289
+ ) =>
290
+ Effect.gen(function* () {
291
+ const fs = yield* FileSystemService
292
+ const repository = yield* find(alias, startPath)
293
+ if (remote === undefined) return repository
294
+ if (!remote.trim()) {
295
+ return yield* new RepositoryError({
296
+ message: "Repository remote is required",
297
+ })
298
+ }
299
+ const hasOrigin = repository.remote !== null
300
+ const result = yield* fs.runCommand(
301
+ [
302
+ "git",
303
+ "-C",
304
+ repository.path,
305
+ "remote",
306
+ hasOrigin ? "set-url" : "add",
307
+ "origin",
308
+ remote,
309
+ ],
310
+ { captureOutput: true },
311
+ )
312
+ if (result.exitCode !== 0) {
313
+ return yield* new RepositoryError({
314
+ message: `Failed to update remote for repository '${alias}': ${result.stderr.trim()}`,
315
+ })
316
+ }
317
+ return yield* find(alias, startPath)
318
+ }),
319
+
320
+ verify: (alias: string, startPath: string = process.cwd()) =>
321
+ Effect.gen(function* () {
322
+ const fs = yield* FileSystemService
323
+ const repository = yield* find(alias, startPath)
324
+ const issues: string[] = []
325
+ const git = yield* fs.runCommand(
326
+ ["git", "-C", repository.path, "rev-parse", "--git-dir"],
327
+ { captureOutput: true },
328
+ )
329
+ if (git.exitCode !== 0) issues.push("Path is not a Git repository")
330
+ if (repository.remote === null)
331
+ issues.push("Origin remote is not configured")
332
+ return {
333
+ ...repository,
334
+ valid: issues.length === 0,
335
+ issues,
336
+ } satisfies RepositoryVerification
337
+ }),
157
338
  }),
158
339
  },
159
340
  ) {}
@@ -111,6 +111,52 @@ describe("WorkbaseService", () => {
111
111
  expect(result.defaultWorkbase).toEqual(registered)
112
112
  })
113
113
 
114
+ test("shows, names, clears names, and defaults by path", async () => {
115
+ const workbaseRoot = join(root, "workbase")
116
+ const configDirectory = join(root, "config")
117
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
118
+ const registered = await runTestEffect(
119
+ WorkbaseService.pipe(
120
+ Effect.flatMap((service) =>
121
+ service.register(workbaseRoot, configDirectory),
122
+ ),
123
+ ),
124
+ )
125
+
126
+ const result = await runTestEffect(
127
+ WorkbaseService.pipe(
128
+ Effect.flatMap((service) =>
129
+ Effect.gen(function* () {
130
+ const named = yield* service.nameRegistered(
131
+ workbaseRoot,
132
+ "primary",
133
+ configDirectory,
134
+ )
135
+ const shown = yield* service.showRegistered(
136
+ "primary",
137
+ configDirectory,
138
+ )
139
+ const defaultWorkbase = yield* service.setDefault(
140
+ workbaseRoot,
141
+ configDirectory,
142
+ )
143
+ const unnamed = yield* service.nameRegistered(
144
+ registered.id,
145
+ null,
146
+ configDirectory,
147
+ )
148
+ return { named, shown, defaultWorkbase, unnamed }
149
+ }),
150
+ ),
151
+ ),
152
+ )
153
+
154
+ expect(result.named.name).toBe("primary")
155
+ expect(result.shown).toEqual(result.named)
156
+ expect(result.defaultWorkbase?.id).toBe(registered.id)
157
+ expect(result.unnamed).toEqual({ id: registered.id, path: registered.path })
158
+ })
159
+
114
160
  test("rejects names that collide with stable IDs", async () => {
115
161
  const firstRoot = join(root, "first")
116
162
  const secondRoot = join(root, "second")
@@ -146,6 +146,31 @@ const writeRegistry = (
146
146
  yield* fs.writeJSON(path, registry)
147
147
  })
148
148
 
149
+ const findRegistration = (
150
+ selector: string,
151
+ configDirectory?: string,
152
+ basePath: string = process.cwd(),
153
+ ) =>
154
+ Effect.gen(function* () {
155
+ const fs = yield* FileSystemService
156
+ const { path, registry } = yield* readRegistry(configDirectory)
157
+ const candidatePath = resolve(basePath, selector)
158
+ const canonicalCandidate = (yield* fs.exists(candidatePath))
159
+ ? yield* fs.realPath(candidatePath)
160
+ : candidatePath
161
+ const entry =
162
+ registry.workbases.find((item) => item.id === selector) ??
163
+ registry.workbases.find((item) => item.name === selector) ??
164
+ registry.workbases.find((item) => item.path === canonicalCandidate)
165
+ if (!entry) {
166
+ return yield* new WorkbaseRegistryError({
167
+ path,
168
+ message: `Unknown workbase selector '${selector}'`,
169
+ })
170
+ }
171
+ return { path, registry, entry }
172
+ })
173
+
149
174
  export class WorkbaseService extends Effect.Service<WorkbaseService>()(
150
175
  "WorkbaseService",
151
176
  {
@@ -401,22 +426,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
401
426
  basePath: string = process.cwd(),
402
427
  ) =>
403
428
  Effect.gen(function* () {
404
- const fs = yield* FileSystemService
405
- const { path, registry } = yield* readRegistry(configDirectory)
406
- const candidatePath = resolve(basePath, selector)
407
- const canonicalCandidate = (yield* fs.exists(candidatePath))
408
- ? yield* fs.realPath(candidatePath)
409
- : candidatePath
410
- const entry =
411
- registry.workbases.find((item) => item.id === selector) ??
412
- registry.workbases.find((item) => item.name === selector) ??
413
- registry.workbases.find((item) => item.path === canonicalCandidate)
414
- if (!entry) {
415
- return yield* new WorkbaseRegistryError({
416
- path,
417
- message: `Unknown workbase selector '${selector}'`,
418
- })
419
- }
429
+ const { path, registry, entry } = yield* findRegistration(
430
+ selector,
431
+ configDirectory,
432
+ basePath,
433
+ )
420
434
  const workbases = registry.workbases.filter(
421
435
  (item) => item.id !== entry.id,
422
436
  )
@@ -431,6 +445,61 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
431
445
  return entry
432
446
  }),
433
447
 
448
+ showRegistered: (
449
+ selector: string,
450
+ configDirectory?: string,
451
+ basePath: string = process.cwd(),
452
+ ) =>
453
+ findRegistration(selector, configDirectory, basePath).pipe(
454
+ Effect.map(({ entry }) => entry),
455
+ ),
456
+
457
+ nameRegistered: (
458
+ selector: string,
459
+ name: string | null,
460
+ configDirectory?: string,
461
+ basePath: string = process.cwd(),
462
+ ) =>
463
+ Effect.gen(function* () {
464
+ const { path, registry, entry } = yield* findRegistration(
465
+ selector,
466
+ configDirectory,
467
+ basePath,
468
+ )
469
+ if (name !== null) {
470
+ const decodedName = decode(EntityId, name)
471
+ if (!decodedName.success) {
472
+ return yield* new WorkbaseRegistryError({
473
+ path,
474
+ message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`,
475
+ })
476
+ }
477
+ const collision = registry.workbases.find(
478
+ (item) =>
479
+ item.id !== entry.id &&
480
+ (item.id === name || item.name === name),
481
+ )
482
+ if (collision) {
483
+ return yield* new WorkbaseRegistryError({
484
+ path,
485
+ message: `Workbase name '${name}' is already registered for ${collision.path}`,
486
+ })
487
+ }
488
+ }
489
+ const updated = {
490
+ id: entry.id,
491
+ ...(name === null ? {} : { name }),
492
+ path: entry.path,
493
+ }
494
+ yield* writeRegistry(path, {
495
+ ...registry,
496
+ workbases: registry.workbases.map((item) =>
497
+ item.id === entry.id ? updated : item,
498
+ ),
499
+ })
500
+ return updated
501
+ }),
502
+
434
503
  pruneRegistered: (configDirectory?: string) =>
435
504
  Effect.gen(function* () {
436
505
  const fs = yield* FileSystemService
@@ -455,7 +524,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
455
524
  return removed
456
525
  }),
457
526
 
458
- setDefault: (selector: string | null, configDirectory?: string) =>
527
+ setDefault: (
528
+ selector: string | null,
529
+ configDirectory?: string,
530
+ basePath: string = process.cwd(),
531
+ ) =>
459
532
  Effect.gen(function* () {
460
533
  const { path, registry } = yield* readRegistry(configDirectory)
461
534
  if (selector === null) {
@@ -465,15 +538,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
465
538
  })
466
539
  return null
467
540
  }
468
- const entry = registry.workbases.find(
469
- (item) => item.id === selector || item.name === selector,
541
+ const { entry } = yield* findRegistration(
542
+ selector,
543
+ configDirectory,
544
+ basePath,
470
545
  )
471
- if (!entry) {
472
- return yield* new WorkbaseRegistryError({
473
- path,
474
- message: `Unknown registered workbase selector '${selector}'`,
475
- })
476
- }
477
546
  yield* writeRegistry(path, {
478
547
  version: 2,
479
548
  workbases: registry.workbases,